@terrafirmagreg-team/recipe 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (231) hide show
  1. package/package.json +62 -3
  2. package/src/components/button-copy.tsx +42 -0
  3. package/src/components/cards/card/index.tsx +212 -0
  4. package/src/components/cards/card/model.ts +64 -0
  5. package/src/components/cards/card/scene.tsx +105 -0
  6. package/src/components/cards/card/strip.tsx +196 -0
  7. package/src/components/cards/card/text.tsx +45 -0
  8. package/src/components/cards/card/tips.tsx +62 -0
  9. package/src/components/cards/default-item.tsx +43 -0
  10. package/src/components/cards/fit-card.tsx +26 -0
  11. package/src/components/cards/glare-filter.tsx +28 -0
  12. package/src/components/cards/paged-card.tsx +221 -0
  13. package/src/components/cards/slot-hitbox.tsx +211 -0
  14. package/src/components/cards/strip-card/buttons.tsx +101 -0
  15. package/src/components/cards/strip-card/index.tsx +212 -0
  16. package/src/components/cards/strip-card/member-cell.tsx +165 -0
  17. package/src/components/cards/strip-card/sprite.tsx +35 -0
  18. package/src/components/mdx/recipe.tsx +111 -0
  19. package/src/components/tool-list.tsx +48 -0
  20. package/src/components/views/alloy/index.tsx +105 -0
  21. package/src/components/views/alloy/list.tsx +84 -0
  22. package/src/components/views/alloy/mix/index.tsx +34 -0
  23. package/src/components/views/alloy/mix/ratio.tsx +93 -0
  24. package/src/components/views/alloy/mix/rows.tsx +146 -0
  25. package/src/components/views/alloy/mix/status.tsx +121 -0
  26. package/src/components/views/alloy/mix/total.tsx +131 -0
  27. package/src/components/views/alloy/range-bar.tsx +64 -0
  28. package/src/components/views/alloy/stack-bar.tsx +86 -0
  29. package/src/components/views/browser/axis-switcher.tsx +53 -0
  30. package/src/components/views/browser/card-grid.tsx +274 -0
  31. package/src/components/views/browser/favorite-button.tsx +45 -0
  32. package/src/components/views/browser/frame.tsx +111 -0
  33. package/src/components/views/browser/index.tsx +96 -0
  34. package/src/components/views/browser/item-panel/index.tsx +227 -0
  35. package/src/components/views/browser/item-panel/skeleton.tsx +25 -0
  36. package/src/components/views/browser/item-panel-grid/cell.tsx +176 -0
  37. package/src/components/views/browser/item-panel-grid/favorite-cell.tsx +127 -0
  38. package/src/components/views/browser/item-panel-grid/index.tsx +103 -0
  39. package/src/components/views/browser/item-view/index.tsx +191 -0
  40. package/src/components/views/browser/item-view/tags.tsx +83 -0
  41. package/src/components/views/browser/item-view-header.tsx +130 -0
  42. package/src/components/views/browser/pane-tabs.tsx +60 -0
  43. package/src/components/views/browser/place-header.tsx +71 -0
  44. package/src/components/views/browser/query-controls.tsx +103 -0
  45. package/src/components/views/browser/recipe-item-header.tsx +59 -0
  46. package/src/components/views/browser/settings.tsx +51 -0
  47. package/src/components/views/browser/toolbar.tsx +53 -0
  48. package/src/components/views/browser/type-tabs.tsx +69 -0
  49. package/src/components/views/browser/type-view.tsx +189 -0
  50. package/src/components/views/browser/workstation-rail.tsx +42 -0
  51. package/src/components/views/calc/calculator/actions.tsx +57 -0
  52. package/src/components/views/calc/calculator/help.tsx +148 -0
  53. package/src/components/views/calc/calculator/index.tsx +220 -0
  54. package/src/components/views/calc/dot-overlay.tsx +79 -0
  55. package/src/components/views/calc/edge.tsx +50 -0
  56. package/src/components/views/calc/flow/index.tsx +267 -0
  57. package/src/components/views/calc/flow/picker-dialogs.tsx +68 -0
  58. package/src/components/views/calc/flow/toolbar.tsx +72 -0
  59. package/src/components/views/calc/flow/view-menu.tsx +221 -0
  60. package/src/components/views/calc/member-picker.tsx +118 -0
  61. package/src/components/views/calc/node-card/actions.tsx +168 -0
  62. package/src/components/views/calc/node-card/badges.tsx +77 -0
  63. package/src/components/views/calc/node-card/chips.tsx +219 -0
  64. package/src/components/views/calc/node-card/far.tsx +175 -0
  65. package/src/components/views/calc/node-card/full.tsx +282 -0
  66. package/src/components/views/calc/node-card/index.tsx +26 -0
  67. package/src/components/views/calc/node-card/menu.tsx +211 -0
  68. package/src/components/views/calc/recipe-picker/body.tsx +204 -0
  69. package/src/components/views/calc/recipe-picker/footer.tsx +129 -0
  70. package/src/components/views/calc/recipe-picker/index.tsx +47 -0
  71. package/src/components/views/calc/sidebar.tsx +162 -0
  72. package/src/components/views/calc/target.tsx +271 -0
  73. package/src/components/views/calc/trees/icon-picker.tsx +96 -0
  74. package/src/components/views/calc/trees/index.tsx +133 -0
  75. package/src/components/views/calc/trees/label.tsx +65 -0
  76. package/src/components/views/calc/trees/rows.tsx +139 -0
  77. package/src/components/views/forge/gui.tsx +130 -0
  78. package/src/components/views/forge/index.tsx +105 -0
  79. package/src/components/views/forge/list.tsx +112 -0
  80. package/src/components/views/forge/workbench/controls.tsx +96 -0
  81. package/src/components/views/forge/workbench/index.tsx +84 -0
  82. package/src/components/views/forge/workbench/panel.tsx +227 -0
  83. package/src/components/views/forge/workbench/ruler.tsx +132 -0
  84. package/src/components/views/forge/workbench/status.tsx +54 -0
  85. package/src/components/views/forge/workbench/tape.tsx +69 -0
  86. package/src/hooks/alloy/use-fluid-colors.ts +122 -0
  87. package/src/hooks/alloy/use-mix-state.ts +239 -0
  88. package/src/hooks/calc/flow/engage.ts +89 -0
  89. package/src/hooks/calc/flow/handlers.ts +115 -0
  90. package/src/hooks/calc/flow/layout.ts +149 -0
  91. package/src/hooks/calc/flow/mount.ts +96 -0
  92. package/src/hooks/calc/flow/nodes.ts +251 -0
  93. package/src/hooks/calc/flow/pickers.ts +50 -0
  94. package/src/hooks/calc/flow/props.ts +40 -0
  95. package/src/hooks/calc/flow/viewport.ts +154 -0
  96. package/src/hooks/calc/flow/window.ts +86 -0
  97. package/src/hooks/calc/use-actions.ts +376 -0
  98. package/src/hooks/calc/use-amounts.ts +101 -0
  99. package/src/hooks/calc/use-calc-fill.ts +91 -0
  100. package/src/hooks/calc/use-data.ts +289 -0
  101. package/src/hooks/calc/use-defaults.ts +38 -0
  102. package/src/hooks/calc/use-density.ts +28 -0
  103. package/src/hooks/calc/use-derived.ts +258 -0
  104. package/src/hooks/calc/use-direction.ts +23 -0
  105. package/src/hooks/calc/use-edge-style.ts +40 -0
  106. package/src/hooks/calc/use-far-mode.ts +36 -0
  107. package/src/hooks/calc/use-fill.ts +213 -0
  108. package/src/hooks/calc/use-focus.ts +30 -0
  109. package/src/hooks/calc/use-maps.ts +75 -0
  110. package/src/hooks/calc/use-reset.ts +14 -0
  111. package/src/hooks/calc/use-resolver.ts +234 -0
  112. package/src/hooks/calc/use-session.ts +12 -0
  113. package/src/hooks/calc/use-share.ts +61 -0
  114. package/src/hooks/calc/use-storage.ts +125 -0
  115. package/src/hooks/calc/use-tier.ts +12 -0
  116. package/src/hooks/calc/use-view-mode.ts +23 -0
  117. package/src/hooks/calc/use-worker.ts +269 -0
  118. package/src/hooks/forge/use-anvil-gui.ts +14 -0
  119. package/src/hooks/forge/use-forge-state.ts +134 -0
  120. package/src/hooks/forge/use-ruler.ts +99 -0
  121. package/src/hooks/use-add-to-calc.ts +47 -0
  122. package/src/hooks/use-favorite-hotkey.ts +38 -0
  123. package/src/hooks/use-favorites.ts +134 -0
  124. package/src/hooks/use-group-outlines.ts +64 -0
  125. package/src/hooks/use-hide-unused.ts +11 -0
  126. package/src/hooks/use-index.ts +68 -0
  127. package/src/hooks/use-item-feeds.ts +44 -0
  128. package/src/hooks/use-item-pane.ts +104 -0
  129. package/src/hooks/use-item-press.ts +85 -0
  130. package/src/hooks/use-item-tag-groups.ts +52 -0
  131. package/src/hooks/use-item-tooltips.ts +16 -0
  132. package/src/hooks/use-left-sidebar-tree.ts +26 -0
  133. package/src/hooks/use-mod-names.ts +15 -0
  134. package/src/hooks/use-mount-limit.ts +21 -0
  135. package/src/hooks/use-panel-cells.ts +115 -0
  136. package/src/hooks/use-panel-page.ts +51 -0
  137. package/src/hooks/use-panel-tabs.ts +42 -0
  138. package/src/hooks/use-query.ts +184 -0
  139. package/src/hooks/use-session.ts +84 -0
  140. package/src/hooks/use-sidebar-group.ts +20 -0
  141. package/src/hooks/use-tag-members.ts +14 -0
  142. package/src/hooks/use-tool-recipes.ts +25 -0
  143. package/src/hooks/use-type-filter.ts +72 -0
  144. package/src/lib/alloy/alloy.test.ts +186 -0
  145. package/src/lib/alloy/mix.ts +209 -0
  146. package/src/lib/alloy/parse.ts +36 -0
  147. package/src/lib/calc/defaults.ts +45 -0
  148. package/src/lib/calc/flow.ts +279 -0
  149. package/src/lib/calc/focus.ts +14 -0
  150. package/src/lib/calc/meta.ts +15 -0
  151. package/src/lib/calc/pack.ts +184 -0
  152. package/src/lib/calc/resolve.ts +230 -0
  153. package/src/lib/calc/route/buckets.ts +48 -0
  154. package/src/lib/calc/route/geometry.ts +62 -0
  155. package/src/lib/calc/route/index.ts +2 -0
  156. package/src/lib/calc/route/polyline.ts +103 -0
  157. package/src/lib/calc/route/router.ts +270 -0
  158. package/src/lib/calc/storage/entry.ts +89 -0
  159. package/src/lib/calc/storage/index.ts +18 -0
  160. package/src/lib/calc/storage/keys.ts +42 -0
  161. package/src/lib/calc/storage/store.ts +223 -0
  162. package/src/lib/calc/tree/build.ts +196 -0
  163. package/src/lib/calc/tree/context.ts +42 -0
  164. package/src/lib/calc/tree/goal.ts +64 -0
  165. package/src/lib/calc/tree/ideal.ts +36 -0
  166. package/src/lib/calc/tree/index.ts +18 -0
  167. package/src/lib/calc/tree/materials.ts +28 -0
  168. package/src/lib/calc/tree/pick.ts +68 -0
  169. package/src/lib/calc/tree/recipe.ts +103 -0
  170. package/src/lib/calc/tree/share.ts +198 -0
  171. package/src/lib/calc/tree/tree.test.ts +169 -0
  172. package/src/lib/calc/worker/api.ts +33 -0
  173. package/src/lib/calc/worker/fetch.ts +65 -0
  174. package/src/lib/calc/worker/fill.ts +123 -0
  175. package/src/lib/calc/worker/flush.ts +52 -0
  176. package/src/lib/calc/worker/index.ts +8 -0
  177. package/src/lib/calc/worker/protocol.ts +32 -0
  178. package/src/lib/calc/worker/resolve.ts +101 -0
  179. package/src/lib/calc/worker/state.ts +49 -0
  180. package/src/lib/client/recipe-categories.ts +96 -0
  181. package/src/lib/client/recipe.ts +83 -0
  182. package/src/lib/client-index/cache.ts +13 -0
  183. package/src/lib/client-index/catalog.ts +85 -0
  184. package/src/lib/client-index/chrome.ts +180 -0
  185. package/src/lib/client-index/index.ts +19 -0
  186. package/src/lib/client-index/registry.ts +110 -0
  187. package/src/lib/client-index/rows.ts +254 -0
  188. package/src/lib/client-index/tooltips.ts +80 -0
  189. package/src/lib/config.ts +17 -0
  190. package/src/lib/data/category/dto.ts +24 -0
  191. package/src/lib/data/category/load.ts +46 -0
  192. package/src/lib/data/category/parse.ts +70 -0
  193. package/src/lib/data/dto.ts +151 -0
  194. package/src/lib/data/grid.ts +39 -0
  195. package/src/lib/data/parse/entry.ts +84 -0
  196. package/src/lib/data/parse/index.ts +2 -0
  197. package/src/lib/data/parse/layout.ts +135 -0
  198. package/src/lib/data/parse/slots.ts +84 -0
  199. package/src/lib/data/parse/widgets.ts +80 -0
  200. package/src/lib/data/scene.ts +103 -0
  201. package/src/lib/data/slot-io.ts +43 -0
  202. package/src/lib/dump.ts +5 -0
  203. package/src/lib/favorite-hover.ts +12 -0
  204. package/src/lib/fluid-outputs.ts +72 -0
  205. package/src/lib/forge/actions.ts +58 -0
  206. package/src/lib/forge/data.ts +50 -0
  207. package/src/lib/forge/forge.test.ts +123 -0
  208. package/src/lib/forge/solve.ts +79 -0
  209. package/src/lib/group.ts +22 -0
  210. package/src/lib/index-store.ts +64 -0
  211. package/src/lib/links.ts +125 -0
  212. package/src/lib/lookup.ts +117 -0
  213. package/src/lib/panel-outline.ts +85 -0
  214. package/src/lib/panel.ts +104 -0
  215. package/src/lib/preview.ts +28 -0
  216. package/src/lib/recipe-slots.ts +7 -0
  217. package/src/lib/search.ts +138 -0
  218. package/src/lib/sidebar.ts +46 -0
  219. package/src/lib/tree.ts +90 -0
  220. package/src/lib/used-items.ts +24 -0
  221. package/src/locales/en_us.json +228 -0
  222. package/src/locales/ru_ru.json +228 -0
  223. package/src/locales.ts +5 -0
  224. package/src/types/alloy.ts +19 -0
  225. package/src/types/calc.ts +95 -0
  226. package/src/types/forge.ts +20 -0
  227. package/src/types/index.ts +134 -0
  228. package/styles/calc.css +47 -0
  229. package/styles/strip.css +7 -0
  230. package/styles.css +2 -0
  231. package/README.md +0 -3
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import { McText } from '@terrafirmagreg-team/game/components/entry/mc-text';
4
+ import { dumpTextString } from '@terrafirmagreg-team/game/lib/dump/assets/lang/names';
5
+ import type { RecipeTextWidget } from '@terrafirmagreg-team/recipe/lib/data/dto';
6
+
7
+ export function CardText({
8
+ widget,
9
+ offX = 0,
10
+ offY = 0,
11
+ scale,
12
+ langMap,
13
+ }: {
14
+ widget: RecipeTextWidget;
15
+ offX?: number;
16
+ offY?: number;
17
+ scale: number;
18
+ langMap?: Record<string, string>;
19
+ }) {
20
+ const content = widget.component
21
+ ? dumpTextString(langMap ?? {}, widget.component) || widget.text
22
+ : widget.text;
23
+ if (!content) return null;
24
+ const rgb = widget.color === undefined ? 0xffffff : widget.color & 0xffffff;
25
+ const shadowRgb = ((rgb & 0xfcfcfc) >> 2) & 0xffffff;
26
+ const shadowed = widget.shadow === true;
27
+ return (
28
+ <span
29
+ className="font-mc pointer-events-none absolute leading-none whitespace-pre"
30
+ style={{
31
+ left: (widget.x + offX) * scale,
32
+ top: (widget.y + offY) * scale,
33
+ fontSize: Math.max(6, (widget.h - 1 || 8) * scale),
34
+ color: `#${rgb.toString(16).padStart(6, '0')}`,
35
+ ...(shadowed
36
+ ? {
37
+ textShadow: `${scale}px ${scale}px 0 #${shadowRgb.toString(16).padStart(6, '0')}`,
38
+ }
39
+ : {}),
40
+ }}
41
+ >
42
+ <McText text={content} />
43
+ </span>
44
+ );
45
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { McText } from '@terrafirmagreg-team/game/components/entry/mc-text';
4
+ import { sceneEntityHit } from '@terrafirmagreg-team/recipe/components/cards/card/scene';
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
10
+ import type {
11
+ RecipeDecorWidget,
12
+ RecipeWidget,
13
+ } from '@terrafirmagreg-team/recipe/lib/data/dto';
14
+ import type { RecipeSceneEntity } from '@terrafirmagreg-team/recipe/lib/data/scene';
15
+
16
+ export function CardWidgetTips({
17
+ widgets,
18
+ entities,
19
+ margin,
20
+ scale,
21
+ }: {
22
+ widgets: RecipeWidget[];
23
+ entities: RecipeSceneEntity[];
24
+ margin: number;
25
+ scale: number;
26
+ }) {
27
+ return widgets
28
+ .filter(
29
+ (widget): widget is RecipeDecorWidget =>
30
+ widget.type === 'widget' && (widget.tooltip?.length ?? 0) > 0,
31
+ )
32
+ .map((widget, index) => {
33
+ const panel = sceneEntityHit(entities, widget);
34
+ const hit = panel
35
+ ? {
36
+ left: (widget.x + widget.w - 10 + margin) * scale,
37
+ top: (widget.y + 1 + margin) * scale,
38
+ width: 9 * scale,
39
+ height: 9 * scale,
40
+ }
41
+ : {
42
+ left: (widget.x + margin) * scale,
43
+ top: (widget.y + margin) * scale,
44
+ width: widget.w * scale,
45
+ height: widget.h * scale,
46
+ };
47
+ return (
48
+ <Tooltip key={`tip:${index}`}>
49
+ <TooltipTrigger
50
+ render={<span className="absolute block" style={hit} />}
51
+ />
52
+ <TooltipContent className="flex-col items-start gap-0.5 p-3 text-sm whitespace-pre-wrap">
53
+ {(widget.tooltip ?? []).map((line, lineIndex) => (
54
+ <p key={lineIndex}>
55
+ <McText text={line} />
56
+ </p>
57
+ ))}
58
+ </TooltipContent>
59
+ </Tooltip>
60
+ );
61
+ });
62
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { Pin, PinOff } from 'lucide-react';
4
+ import { useEffect, useState } from 'react';
5
+ import { ContextMenuItem } from '@terrafirmagreg-team/ui/components/ui/context-menu';
6
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
7
+ import {
8
+ readRecipeDefaults,
9
+ subscribeRecipeDefaults,
10
+ writeRecipeDefault,
11
+ } from '@terrafirmagreg-team/recipe/lib/calc/defaults';
12
+ export function RecipeDefaultMenuItem({
13
+ dataset,
14
+ materialId,
15
+ recipeId,
16
+ }: {
17
+ dataset: string;
18
+ materialId?: string;
19
+ recipeId: string;
20
+ }) {
21
+ const t = useTranslations();
22
+ const [pinned, setPinned] = useState(
23
+ () => materialId != null && readRecipeDefaults(dataset)[materialId] === recipeId,
24
+ );
25
+ useEffect(
26
+ () =>
27
+ subscribeRecipeDefaults(() => {
28
+ if (materialId != null) {
29
+ setPinned(readRecipeDefaults(dataset)[materialId] === recipeId);
30
+ }
31
+ }),
32
+ [dataset, materialId, recipeId],
33
+ );
34
+ if (!materialId) return null;
35
+ return (
36
+ <ContextMenuItem
37
+ onClick={() => writeRecipeDefault(dataset, materialId, pinned ? null : recipeId)}
38
+ >
39
+ {pinned ? <PinOff /> : <Pin />}
40
+ {t(pinned ? 'recipe.calc.clear.default' : 'recipe.calc.set_default')}
41
+ </ContextMenuItem>
42
+ );
43
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { useRef, type ReactNode } from 'react';
4
+ import { useElementSize } from '@terrafirmagreg-team/ui/hooks/use-element-size';
5
+
6
+ export function FitCard({
7
+ w,
8
+ children,
9
+ }: {
10
+ w: number;
11
+ children: ReactNode;
12
+ }) {
13
+ const ref = useRef<HTMLDivElement>(null);
14
+ const { w: avail } = useElementSize(ref);
15
+ const fit = w > 0 && avail > 0 ? Math.min(1, avail / w) : 1;
16
+ return (
17
+ <div ref={ref} className="w-full">
18
+ <div
19
+ className="mx-auto w-fit"
20
+ style={fit < 1 ? { zoom: fit } : undefined}
21
+ >
22
+ {children}
23
+ </div>
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,28 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ export const RECIPE_GLARE_FILTER_ID = 'recipe-glare-cut';
4
+
5
+ export const recipeGlareFilter: CSSProperties = {
6
+ filter: `url(#${RECIPE_GLARE_FILTER_ID})`,
7
+ };
8
+
9
+ export function RecipeGlareFilter() {
10
+ return (
11
+ <svg aria-hidden className="absolute size-0 overflow-hidden">
12
+ <defs>
13
+ <filter
14
+ id={RECIPE_GLARE_FILTER_ID}
15
+ colorInterpolationFilters="sRGB"
16
+ >
17
+ <feColorMatrix
18
+ type="matrix"
19
+ values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 -2.946 -9.912 -1.001 13.44 0"
20
+ />
21
+ <feComponentTransfer>
22
+ <feFuncA type="table" tableValues="0.03 1" />
23
+ </feComponentTransfer>
24
+ </filter>
25
+ </defs>
26
+ </svg>
27
+ );
28
+ }
@@ -0,0 +1,221 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useRef, useState, type ComponentProps } from 'react';
4
+ import { RecipeCard } from './card';
5
+ import { SlotHitbox } from './slot-hitbox';
6
+ import { StripCard } from './strip-card';
7
+ import { recipeGlareFilter } from './glare-filter';
8
+ import { recipeGrid } from '@terrafirmagreg-team/recipe/lib/data/grid';
9
+ import { recipeSlots } from '@terrafirmagreg-team/recipe/lib/recipe-slots';
10
+ import { PagerArrow } from '@terrafirmagreg-team/ui/components/blocks/page/pager';
11
+ import { useInView } from '@terrafirmagreg-team/ui/hooks/use-in-view';
12
+ import { useSwipe } from '@terrafirmagreg-team/ui/hooks/use-swipe';
13
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
14
+
15
+ const FALLBACK_PITCH = 18;
16
+
17
+ type RecipeCardProps = ComponentProps<typeof RecipeCard>;
18
+
19
+ export function PagedCard({
20
+ maxH,
21
+ ...props
22
+ }: RecipeCardProps & { maxH?: number }) {
23
+ const { recipe, naturalH, naturalW, src, title, onFail } = props;
24
+ const scale = recipe.scale ?? 2;
25
+ const margin = recipe.margin ?? 0;
26
+ const natural = naturalH ?? 0;
27
+ const rootRef = useRef<HTMLDivElement>(null);
28
+ const observedInView = useInView(rootRef);
29
+ const inView = props.eager || observedInView;
30
+ const [page, setPage] = useState(0);
31
+
32
+ const [imgReady, setImgReady] = useState(false);
33
+ const [prevSrc, setPrevSrc] = useState(src);
34
+ if (prevSrc !== src) {
35
+ setPrevSrc(src);
36
+ setImgReady(false);
37
+ }
38
+
39
+ const slots = useMemo(() => recipeSlots(recipe), [recipe]);
40
+ const visibleH = maxH ? Math.floor(maxH) : 0;
41
+ const overflow = visibleH > 0 && natural > visibleH;
42
+
43
+ const pinTop = useMemo(() => {
44
+ if (!overflow) return 0;
45
+ const tops = slots
46
+ .filter((slot) => slot.role === 'output')
47
+ .map((slot) => ((slot.y ?? 0) + margin) * scale);
48
+ const min = tops.length ? Math.min(...tops) : 0;
49
+ return min > 0 && min < visibleH ? min : 0;
50
+ }, [slots, scale, margin, overflow, visibleH]);
51
+
52
+ const rows = useMemo(
53
+ () =>
54
+ [
55
+ ...new Set(
56
+ slots
57
+ .map((slot) => ((slot.y ?? 0) + margin) * scale)
58
+ .filter((y) => y >= pinTop),
59
+ ),
60
+ ].sort((a, b) => a - b),
61
+ [slots, scale, margin, pinTop],
62
+ );
63
+
64
+ const pitchPx = useMemo(() => {
65
+ let min = 0;
66
+ for (let i = 1; i < rows.length; i += 1) {
67
+ const delta = rows[i] - rows[i - 1];
68
+ if (delta > 0 && (min === 0 || delta < min)) min = delta;
69
+ }
70
+ return min || FALLBACK_PITCH * scale;
71
+ }, [rows, scale]);
72
+
73
+ const capH = Math.max(margin, 1) * scale;
74
+ const memberH = overflow
75
+ ? Math.max(
76
+ pitchPx,
77
+ Math.floor((visibleH - pinTop - capH) / pitchPx) * pitchPx,
78
+ )
79
+ : 0;
80
+
81
+ const starts = useMemo(() => {
82
+ if (!overflow) return [0];
83
+ const maxStart = Math.max(pinTop, natural - memberH - capH);
84
+ const stride = Math.max(pitchPx, Math.floor(memberH / pitchPx) * pitchPx);
85
+ const out = [pinTop];
86
+ let start = pinTop;
87
+ while (start < maxStart) {
88
+ const row = rows.find((y) => y > start && y + pitchPx > start + memberH);
89
+ const next = Math.min(row ?? start + stride, maxStart);
90
+ if (next <= start) break;
91
+ out.push(next);
92
+ start = next;
93
+ }
94
+ return out;
95
+ }, [overflow, natural, memberH, capH, pinTop, rows, pitchPx]);
96
+
97
+ const pages = starts.length;
98
+ const clamped = Math.min(page, pages - 1);
99
+ const swipe = useSwipe((dir) =>
100
+ setPage((p) => Math.max(0, Math.min(pages - 1, p + dir))),
101
+ );
102
+
103
+ if (recipeGrid(recipe.properties)) {
104
+ return <StripCard {...props} />;
105
+ }
106
+ if (!overflow) return <RecipeCard {...props} />;
107
+
108
+ return (
109
+ <div
110
+ ref={rootRef}
111
+ className={cn(
112
+ 'relative w-fit overflow-hidden rounded-sm',
113
+ imgReady && 'bg-mc-panel',
114
+ )}
115
+ style={{ height: pinTop + memberH + capH }}
116
+ {...swipe}
117
+ onWheel={(event) => {
118
+ if (event.deltaY === 0) return;
119
+ const next = clamped + (event.deltaY > 0 ? 1 : -1);
120
+ if (next < 0 || next >= pages) return;
121
+ event.stopPropagation();
122
+ setPage(next);
123
+ }}
124
+ >
125
+ {pinTop > 0 && (
126
+ <div
127
+ className="relative w-fit overflow-hidden"
128
+ style={{ height: pinTop }}
129
+ >
130
+ {/* eslint-disable-next-line @next/next/no-img-element -- dump asset */}
131
+ <img
132
+ src={src}
133
+ alt={title}
134
+ className="block"
135
+ width={naturalW}
136
+ height={natural}
137
+ loading="lazy"
138
+ decoding="async"
139
+ onError={onFail}
140
+ style={recipeGlareFilter}
141
+ />
142
+ {inView && !props.frameOnly
143
+ ? slots
144
+ .filter(
145
+ (widget) => ((widget.y ?? 0) + margin) * scale < pinTop,
146
+ )
147
+ .map((widget, index) => (
148
+ <SlotHitbox
149
+ key={`${widget.id}:${index}`}
150
+ widget={widget}
151
+ dataset={props.dataset}
152
+ lang={props.lang}
153
+ langMap={props.langMap}
154
+ nav={props.nav}
155
+ scale={scale}
156
+ margin={margin}
157
+ interactive={props.interactive}
158
+ tooltipLevel={props.slotTooltip}
159
+ />
160
+ ))
161
+ : null}
162
+ </div>
163
+ )}
164
+ <div className="overflow-hidden" style={{ height: memberH }}>
165
+ <div style={{ transform: `translateY(${-starts[clamped]}px)` }}>
166
+ <RecipeCard {...props} />
167
+ </div>
168
+ </div>
169
+ <div className="overflow-hidden" style={{ height: capH }}>
170
+ {/* eslint-disable-next-line @next/next/no-img-element -- dump asset */}
171
+ <img
172
+ src={src}
173
+ alt=""
174
+ aria-hidden
175
+ className="block"
176
+ width={naturalW}
177
+ height={natural}
178
+ style={{
179
+ transform: `translateY(${-(natural - capH)}px)`,
180
+ ...recipeGlareFilter,
181
+ }}
182
+ loading="lazy"
183
+ decoding="async"
184
+ onLoad={() => setImgReady(true)}
185
+ />
186
+ </div>
187
+ {pages > 1 && (
188
+ <div
189
+ className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-lg bg-background/80 px-0.5 py-0.5"
190
+ onClick={(event) => event.stopPropagation()}
191
+ >
192
+ <PagerArrow
193
+ dir={-1}
194
+ variant="ghost"
195
+ size="icon-xs"
196
+ disabled={clamped <= 0}
197
+ onClick={() =>
198
+ setPage((p) => Math.max(0, Math.min(p, pages - 1) - 1))
199
+ }
200
+ />
201
+ <span
202
+ className={cn(
203
+ 'px-0.5 text-2xs tabular-nums text-muted-foreground',
204
+ )}
205
+ >
206
+ {clamped + 1}/{pages}
207
+ </span>
208
+ <PagerArrow
209
+ dir={1}
210
+ variant="ghost"
211
+ size="icon-xs"
212
+ disabled={clamped + 1 >= pages}
213
+ onClick={() =>
214
+ setPage((p) => Math.min(pages - 1, Math.min(p, pages - 1) + 1))
215
+ }
216
+ />
217
+ </div>
218
+ )}
219
+ </div>
220
+ );
221
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { ENTRY_CYCLE_MS, EntryIcon } from '@terrafirmagreg-team/game/components/entry/icon';
5
+ import {
6
+ EntryTooltip,
7
+ type EntryTooltipLevel,
8
+ } from '@terrafirmagreg-team/game/components/entry/tooltip';
9
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
10
+ import { stackDisplayName } from '@terrafirmagreg-team/game/lib/entry/display';
11
+ import { entryHref } from '@terrafirmagreg-team/game/lib/entry/href';
12
+ import { loadStackDisplay } from '@terrafirmagreg-team/game/lib/entry/tooltip';
13
+ import { favoriteHover } from '@terrafirmagreg-team/recipe/lib/favorite-hover';
14
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
15
+ import type { ReferenceKind } from '@terrafirmagreg-team/game/lib/dump/contract/ref';
16
+ import type { RecipeSlotWidget } from '@terrafirmagreg-team/recipe/lib/data/dto';
17
+ import type { RecipeHrefOpts } from '@terrafirmagreg-team/recipe/types';
18
+
19
+ const SCALE = 2;
20
+
21
+ const TAG_REF_KIND: Record<string, ReferenceKind> = {
22
+ block: 'blockTag',
23
+ fluid: 'fluidTag',
24
+ entity: 'entityTag',
25
+ };
26
+
27
+ function widgetRefKind(widget: RecipeSlotWidget): ReferenceKind {
28
+ if (widget.kind === 'tag') {
29
+ return TAG_REF_KIND[widget.tagKind ?? 'item'] ?? 'itemTag';
30
+ }
31
+ return widget.kind === 'fluid' ? 'fluid' : 'item';
32
+ }
33
+
34
+ function stackFavoriteProps(widget: RecipeSlotWidget) {
35
+ const refKind = widgetRefKind(widget);
36
+ const stackable =
37
+ (refKind === 'item' || refKind === 'fluid') &&
38
+ !widget.id.startsWith('#');
39
+ return {
40
+ onMouseEnter: () => {
41
+ favoriteHover.slot = stackable
42
+ ? {
43
+ kind: 'stack',
44
+ id: widget.id,
45
+ ...(refKind === 'fluid' ? { ref: 'fluid' as const } : {}),
46
+ }
47
+ : undefined;
48
+ },
49
+ onMouseLeave: () => {
50
+ favoriteHover.slot = undefined;
51
+ },
52
+ };
53
+ }
54
+
55
+ export function SlotHitbox({
56
+ widget,
57
+ dataset,
58
+ lang,
59
+ langMap,
60
+ nav,
61
+ scale = SCALE,
62
+ margin = 0,
63
+ interactive = true,
64
+ tooltipLevel,
65
+ }: {
66
+ widget: RecipeSlotWidget;
67
+ dataset: string;
68
+ lang: string;
69
+ langMap?: Record<string, string>;
70
+ nav?: Pick<RecipeHrefOpts, 'tab' | 'search'>;
71
+ scale?: number;
72
+ margin?: number;
73
+ interactive?: boolean;
74
+
75
+ tooltipLevel?: EntryTooltipLevel;
76
+ }) {
77
+ const name = stackDisplayName(langMap, widget.id);
78
+ const kind =
79
+ widget.kind === 'fluid' || widget.kind === 'tag' ? widget.kind : 'item';
80
+ const checkKind = kind === 'tag' || !interactive ? undefined : kind;
81
+ const stackPeek =
82
+ dataset && checkKind && widget.id
83
+ ? loadStackDisplay.peek(dataset, widget.id, checkKind)
84
+ : undefined;
85
+ const stackRes = useAsyncResult(
86
+ dataset && checkKind && widget.id && stackPeek === undefined
87
+ ? async () => ({
88
+ key: widget.id,
89
+ display: await loadStackDisplay(dataset, widget.id, checkKind),
90
+ })
91
+ : false,
92
+ [dataset, widget.id, checkKind, stackPeek],
93
+ );
94
+ const missingItem =
95
+ checkKind !== undefined &&
96
+ stackPeek === undefined &&
97
+ !stackRes.pending &&
98
+ stackRes.value?.key === widget.id &&
99
+ stackRes.value.display === undefined;
100
+ const iconSize = Math.max(8, Math.min(widget.w - 2, 16) * scale);
101
+ const hover = stackFavoriteProps(widget);
102
+ const alternatives = widget.alternatives;
103
+ const [altIndex] = useCycleIndex(alternatives?.length ?? 0, {
104
+ enabled: interactive !== false && (alternatives?.length ?? 0) > 1,
105
+ intervalMs: ENTRY_CYCLE_MS,
106
+ });
107
+ const iconId =
108
+ alternatives && alternatives.length > 0
109
+ ? (alternatives[altIndex] ?? alternatives[0]!)
110
+ : widget.id;
111
+ const iconKind = alternatives?.length
112
+ ? widget.tagKind === 'fluid' || widget.tagKind === 'block'
113
+ ? widget.tagKind
114
+ : undefined
115
+ : widgetRefKind(widget);
116
+ const icon = (
117
+ <span className="pointer-events-none flex size-full items-center justify-center">
118
+ {alternatives && alternatives.length > 1 ? (
119
+ <span
120
+ aria-hidden
121
+ className="absolute bg-mc-inset"
122
+ style={{ inset: scale }}
123
+ />
124
+ ) : null}
125
+ <EntryIcon
126
+ id={iconId}
127
+ kind={iconKind}
128
+ dataset={dataset}
129
+ size={iconSize}
130
+ missing="placeholder"
131
+ />
132
+ </span>
133
+ );
134
+ const pos = {
135
+ left: (widget.x + margin) * scale,
136
+ top: (widget.y + margin) * scale,
137
+ width: widget.w * scale,
138
+ height: widget.h * scale,
139
+ };
140
+ if (!interactive) {
141
+
142
+ if (tooltipLevel === 'none') {
143
+ return (
144
+ <span
145
+ aria-label={name}
146
+ className="absolute block"
147
+ style={pos}
148
+ >
149
+ {icon}
150
+ </span>
151
+ );
152
+ }
153
+ return (
154
+ <EntryTooltip
155
+ dataset={dataset}
156
+ id={widget.id}
157
+ name={name}
158
+ kind={kind === 'tag' ? 'item' : kind}
159
+ lang={lang}
160
+ langMap={langMap}
161
+ level={tooltipLevel}
162
+ >
163
+ <span
164
+ {...hover}
165
+ aria-label={name}
166
+ className="absolute block"
167
+ style={pos}
168
+ >
169
+ {icon}
170
+ </span>
171
+ </EntryTooltip>
172
+ );
173
+ }
174
+ return (
175
+ <EntryTooltip
176
+ dataset={dataset}
177
+ id={widget.id}
178
+ name={name}
179
+ kind={kind === 'tag' ? 'item' : kind}
180
+ lang={lang}
181
+ langMap={langMap}
182
+ level={tooltipLevel}
183
+ >
184
+ <span
185
+ {...hover}
186
+ className="absolute block"
187
+ style={pos}
188
+ aria-label={name}
189
+ >
190
+ {!missingItem ? (
191
+ <Link
192
+ href={entryHref(
193
+ dataset,
194
+ lang,
195
+ {
196
+ kind: widgetRefKind(widget),
197
+ id: widget.id.startsWith('#') ? widget.id.slice(1) : widget.id,
198
+ },
199
+ nav,
200
+ )}
201
+ className="absolute inset-0 block"
202
+ >
203
+ {icon}
204
+ </Link>
205
+ ) : (
206
+ <span className="absolute inset-0 block">{icon}</span>
207
+ )}
208
+ </span>
209
+ </EntryTooltip>
210
+ );
211
+ }