@terrafirmagreg-team/recipe 0.0.0-stage → 1.2.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 (221) hide show
  1. package/package.json +62 -3
  2. package/src/components/button-copy.tsx +43 -0
  3. package/src/components/cards/card/index.tsx +214 -0
  4. package/src/components/cards/card/scene.tsx +107 -0
  5. package/src/components/cards/card/strip.tsx +198 -0
  6. package/src/components/cards/card/text.tsx +46 -0
  7. package/src/components/cards/card/tips.tsx +63 -0
  8. package/src/components/cards/default-item.tsx +47 -0
  9. package/src/components/cards/glare-filter.tsx +29 -0
  10. package/src/components/cards/paged-card.tsx +222 -0
  11. package/src/components/cards/slot-hitbox.tsx +213 -0
  12. package/src/components/cards/strip-card/buttons.tsx +106 -0
  13. package/src/components/cards/strip-card/index.tsx +214 -0
  14. package/src/components/cards/strip-card/member-cell.tsx +167 -0
  15. package/src/components/cards/strip-card/sprite.tsx +36 -0
  16. package/src/components/embed-card.tsx +44 -0
  17. package/src/components/mdx/recipe.tsx +129 -0
  18. package/src/components/tool-list.tsx +49 -0
  19. package/src/components/tree.tsx +98 -0
  20. package/src/components/views/alloy/index.tsx +112 -0
  21. package/src/components/views/alloy/list.tsx +85 -0
  22. package/src/components/views/alloy/mix/index.tsx +35 -0
  23. package/src/components/views/alloy/mix/ratio.tsx +94 -0
  24. package/src/components/views/alloy/mix/rows.tsx +145 -0
  25. package/src/components/views/alloy/mix/status.tsx +122 -0
  26. package/src/components/views/alloy/mix/total.tsx +132 -0
  27. package/src/components/views/alloy/range-bar.tsx +65 -0
  28. package/src/components/views/alloy/stack-bar.tsx +88 -0
  29. package/src/components/views/browser/axis-switcher.tsx +54 -0
  30. package/src/components/views/browser/card-grid.tsx +275 -0
  31. package/src/components/views/browser/favorite-button.tsx +46 -0
  32. package/src/components/views/browser/frame.tsx +112 -0
  33. package/src/components/views/browser/index.tsx +114 -0
  34. package/src/components/views/browser/item-panel/index.tsx +229 -0
  35. package/src/components/views/browser/item-panel/skeleton.tsx +26 -0
  36. package/src/components/views/browser/item-panel-grid/cell.tsx +178 -0
  37. package/src/components/views/browser/item-panel-grid/favorite-cell.tsx +129 -0
  38. package/src/components/views/browser/item-panel-grid/index.tsx +105 -0
  39. package/src/components/views/browser/item-view/index.tsx +186 -0
  40. package/src/components/views/browser/item-view/tags.tsx +85 -0
  41. package/src/components/views/browser/item-view-header.tsx +131 -0
  42. package/src/components/views/browser/pane-tabs.tsx +61 -0
  43. package/src/components/views/browser/place-header.tsx +72 -0
  44. package/src/components/views/browser/query-controls.tsx +105 -0
  45. package/src/components/views/browser/recipe-item-header.tsx +60 -0
  46. package/src/components/views/browser/settings.tsx +52 -0
  47. package/src/components/views/browser/toolbar.tsx +54 -0
  48. package/src/components/views/browser/type-tabs.tsx +71 -0
  49. package/src/components/views/browser/type-view.tsx +184 -0
  50. package/src/components/views/browser/workstation-rail.tsx +43 -0
  51. package/src/components/views/calc/calculator/actions.tsx +47 -0
  52. package/src/components/views/calc/calculator/help.tsx +189 -0
  53. package/src/components/views/calc/calculator/index.tsx +172 -0
  54. package/src/components/views/calc/dot-overlay.tsx +80 -0
  55. package/src/components/views/calc/edge.tsx +152 -0
  56. package/src/components/views/calc/flow/index.tsx +305 -0
  57. package/src/components/views/calc/flow/picker-dialogs.tsx +146 -0
  58. package/src/components/views/calc/flow/toolbar.tsx +60 -0
  59. package/src/components/views/calc/flow/view-menu.tsx +117 -0
  60. package/src/components/views/calc/member-picker.tsx +120 -0
  61. package/src/components/views/calc/node-card/actions.tsx +90 -0
  62. package/src/components/views/calc/node-card/badges.tsx +59 -0
  63. package/src/components/views/calc/node-card/chips.tsx +187 -0
  64. package/src/components/views/calc/node-card/far.tsx +123 -0
  65. package/src/components/views/calc/node-card/full.tsx +332 -0
  66. package/src/components/views/calc/node-card/index.tsx +27 -0
  67. package/src/components/views/calc/node-card/menu.tsx +282 -0
  68. package/src/components/views/calc/node-card/port-cells.tsx +186 -0
  69. package/src/components/views/calc/node-card/port-rails.tsx +251 -0
  70. package/src/components/views/calc/node-card/ports.tsx +86 -0
  71. package/src/components/views/calc/recipe-picker/body.tsx +207 -0
  72. package/src/components/views/calc/recipe-picker/footer.tsx +131 -0
  73. package/src/components/views/calc/recipe-picker/index.tsx +52 -0
  74. package/src/components/views/calc/sidebar.tsx +171 -0
  75. package/src/components/views/calc/trees/icon-picker.tsx +97 -0
  76. package/src/components/views/calc/trees/index.tsx +134 -0
  77. package/src/components/views/calc/trees/label.tsx +67 -0
  78. package/src/components/views/calc/trees/rows.tsx +140 -0
  79. package/src/components/views/forge/gui.tsx +131 -0
  80. package/src/components/views/forge/index.tsx +112 -0
  81. package/src/components/views/forge/list.tsx +113 -0
  82. package/src/components/views/forge/workbench/controls.tsx +103 -0
  83. package/src/components/views/forge/workbench/index.tsx +85 -0
  84. package/src/components/views/forge/workbench/panel.tsx +228 -0
  85. package/src/components/views/forge/workbench/ruler.tsx +133 -0
  86. package/src/components/views/forge/workbench/status.tsx +55 -0
  87. package/src/components/views/forge/workbench/tape.tsx +70 -0
  88. package/src/hooks/alloy/use-fluid-colors.ts +125 -0
  89. package/src/hooks/alloy/use-mix-state.ts +240 -0
  90. package/src/hooks/calc/flow/use-engage.ts +129 -0
  91. package/src/hooks/calc/flow/use-handlers.ts +192 -0
  92. package/src/hooks/calc/flow/use-layout.ts +105 -0
  93. package/src/hooks/calc/flow/use-mount.ts +97 -0
  94. package/src/hooks/calc/flow/use-nodes.ts +260 -0
  95. package/src/hooks/calc/flow/use-pickers.ts +57 -0
  96. package/src/hooks/calc/flow/use-viewport.ts +141 -0
  97. package/src/hooks/calc/flow/use-window.ts +87 -0
  98. package/src/hooks/calc/use-actions.ts +355 -0
  99. package/src/hooks/calc/use-amounts.ts +64 -0
  100. package/src/hooks/calc/use-data.ts +203 -0
  101. package/src/hooks/calc/use-defaults.ts +40 -0
  102. package/src/hooks/calc/use-far-mode.ts +37 -0
  103. package/src/hooks/calc/use-focus.ts +31 -0
  104. package/src/hooks/calc/use-maps.ts +76 -0
  105. package/src/hooks/calc/use-reset.ts +15 -0
  106. package/src/hooks/calc/use-resolver.ts +70 -0
  107. package/src/hooks/calc/use-session.ts +13 -0
  108. package/src/hooks/calc/use-share.ts +64 -0
  109. package/src/hooks/calc/use-storage.ts +126 -0
  110. package/src/hooks/calc/use-tier.ts +13 -0
  111. package/src/hooks/calc/use-view-mode.ts +24 -0
  112. package/src/hooks/calc/use-worker.ts +204 -0
  113. package/src/hooks/forge/use-anvil-gui.ts +16 -0
  114. package/src/hooks/forge/use-forge-state.ts +135 -0
  115. package/src/hooks/forge/use-ruler.ts +100 -0
  116. package/src/hooks/use-add-to-calc.ts +51 -0
  117. package/src/hooks/use-categories-by-id.ts +18 -0
  118. package/src/hooks/use-favorite-hotkey.ts +39 -0
  119. package/src/hooks/use-favorites.ts +135 -0
  120. package/src/hooks/use-group-outlines.ts +65 -0
  121. package/src/hooks/use-hide-unused.ts +12 -0
  122. package/src/hooks/use-index.ts +72 -0
  123. package/src/hooks/use-item-feeds.ts +46 -0
  124. package/src/hooks/use-item-pane.ts +106 -0
  125. package/src/hooks/use-item-press.ts +89 -0
  126. package/src/hooks/use-item-tag-groups.ts +54 -0
  127. package/src/hooks/use-item-tooltips.ts +18 -0
  128. package/src/hooks/use-left-sidebar-tree.ts +34 -0
  129. package/src/hooks/use-mod-names.ts +17 -0
  130. package/src/hooks/use-mount-limit.ts +22 -0
  131. package/src/hooks/use-panel-cells.ts +117 -0
  132. package/src/hooks/use-panel-page.ts +54 -0
  133. package/src/hooks/use-panel-tabs.ts +43 -0
  134. package/src/hooks/use-query.ts +186 -0
  135. package/src/hooks/use-session.ts +88 -0
  136. package/src/hooks/use-sidebar-group.ts +16 -0
  137. package/src/hooks/use-tag-members.ts +16 -0
  138. package/src/hooks/use-tool-recipes.ts +26 -0
  139. package/src/hooks/use-type-filter.ts +73 -0
  140. package/src/lib/alloy/mix.ts +210 -0
  141. package/src/lib/alloy/parse.ts +37 -0
  142. package/src/lib/calc/defaults.ts +47 -0
  143. package/src/lib/calc/flow.ts +315 -0
  144. package/src/lib/calc/focus.ts +15 -0
  145. package/src/lib/calc/meta.ts +17 -0
  146. package/src/lib/calc/pack.ts +77 -0
  147. package/src/lib/calc/ports.ts +43 -0
  148. package/src/lib/calc/resolve.ts +198 -0
  149. package/src/lib/calc/route/buckets.ts +49 -0
  150. package/src/lib/calc/route/geometry.ts +51 -0
  151. package/src/lib/calc/route/index.ts +3 -0
  152. package/src/lib/calc/route/polyline.ts +103 -0
  153. package/src/lib/calc/route/router.ts +394 -0
  154. package/src/lib/calc/scheme.ts +226 -0
  155. package/src/lib/calc/storage/entry.ts +97 -0
  156. package/src/lib/calc/storage/index.ts +20 -0
  157. package/src/lib/calc/storage/keys.ts +38 -0
  158. package/src/lib/calc/storage/store.ts +198 -0
  159. package/src/lib/calc/tree/index.ts +4 -0
  160. package/src/lib/calc/tree/materials.ts +29 -0
  161. package/src/lib/calc/tree/recipe.ts +104 -0
  162. package/src/lib/calc/tree/share.ts +135 -0
  163. package/src/lib/calc/worker/api.ts +25 -0
  164. package/src/lib/calc/worker/fetch.ts +66 -0
  165. package/src/lib/calc/worker/flush.ts +39 -0
  166. package/src/lib/calc/worker/index.ts +8 -0
  167. package/src/lib/calc/worker/protocol.ts +35 -0
  168. package/src/lib/calc/worker/resolve.ts +49 -0
  169. package/src/lib/calc/worker/state.ts +48 -0
  170. package/src/lib/card-model.ts +65 -0
  171. package/src/lib/client/recipe-categories.ts +14 -0
  172. package/src/lib/client/recipe.ts +85 -0
  173. package/src/lib/client-index/catalog.ts +91 -0
  174. package/src/lib/client-index/chrome.ts +180 -0
  175. package/src/lib/client-index/index.ts +20 -0
  176. package/src/lib/client-index/registry.ts +121 -0
  177. package/src/lib/client-index/rows.ts +260 -0
  178. package/src/lib/client-index/tooltips.ts +74 -0
  179. package/src/lib/config.ts +18 -0
  180. package/src/lib/data/category/dto.ts +25 -0
  181. package/src/lib/data/category/load.ts +47 -0
  182. package/src/lib/data/category/parse.ts +71 -0
  183. package/src/lib/data/dto.ts +152 -0
  184. package/src/lib/data/grid.ts +40 -0
  185. package/src/lib/data/parse/entry.ts +85 -0
  186. package/src/lib/data/parse/index.ts +3 -0
  187. package/src/lib/data/parse/layout.ts +138 -0
  188. package/src/lib/data/parse/slots.ts +85 -0
  189. package/src/lib/data/parse/widgets.ts +81 -0
  190. package/src/lib/data/scene.ts +103 -0
  191. package/src/lib/data/slot-io.ts +44 -0
  192. package/src/lib/dump.ts +6 -0
  193. package/src/lib/embed.ts +22 -0
  194. package/src/lib/favorite-hover.ts +13 -0
  195. package/src/lib/fluid-outputs.ts +70 -0
  196. package/src/lib/forge/actions.ts +59 -0
  197. package/src/lib/forge/data.ts +51 -0
  198. package/src/lib/forge/solve.ts +80 -0
  199. package/src/lib/group.ts +23 -0
  200. package/src/lib/index-store.ts +70 -0
  201. package/src/lib/links.ts +131 -0
  202. package/src/lib/lookup.ts +119 -0
  203. package/src/lib/panel-outline.ts +86 -0
  204. package/src/lib/panel.ts +105 -0
  205. package/src/lib/preview.ts +32 -0
  206. package/src/lib/recipe-slots.ts +8 -0
  207. package/src/lib/search.ts +139 -0
  208. package/src/lib/sidebar.ts +47 -0
  209. package/src/lib/used-items.ts +25 -0
  210. package/src/locales/en_us.json +172 -0
  211. package/src/locales/ru_ru.json +172 -0
  212. package/src/locales.ts +6 -0
  213. package/src/types/alloy.ts +20 -0
  214. package/src/types/calc-flow-props.ts +44 -0
  215. package/src/types/calc.ts +92 -0
  216. package/src/types/forge.ts +21 -0
  217. package/src/types/index.ts +136 -0
  218. package/styles/calc.css +308 -0
  219. package/styles/strip.css +7 -0
  220. package/styles.css +2 -0
  221. package/README.md +0 -3
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import { useEffect, useMemo, useReducer } from 'react';
5
+ import { loadClientModAtlases } from '@terrafirmagreg-team/dump/lib/dump/client/atlases';
6
+ import { lookupSpriteInBundle } from '@terrafirmagreg-team/dump/lib/dump/assets/atlas/lookup';
7
+ import { atlasPagePublicUrl } from '@terrafirmagreg-team/dump/lib/dump/assets/urls';
8
+ import { splitDumpId } from '@terrafirmagreg-team/dump/lib/dump/paths';
9
+ import { createAsyncCache } from '@terrafirmagreg-team/data/lib/async-cache';
10
+ import { createDatasetCache } from '@terrafirmagreg-team/dump/lib/dump/reader';
11
+ import type { RecipeSlotFill } from '@terrafirmagreg-team/recipe/lib/data/dto';
12
+
13
+ const loadAtlasPage = createAsyncCache(
14
+ async (url: string): Promise<HTMLImageElement> => {
15
+ const image = new Image();
16
+ image.crossOrigin = 'anonymous';
17
+ const done = new Promise<HTMLImageElement>((resolve, reject) => {
18
+ image.onload = () => resolve(image);
19
+ image.onerror = () =>
20
+ reject(new Error(`atlas page failed to load: ${url}`));
21
+ });
22
+ image.src = url;
23
+ return done;
24
+ },
25
+ );
26
+
27
+ function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
28
+ const max = Math.max(r, g, b);
29
+ const min = Math.min(r, g, b);
30
+ const l = (max + min) / 510;
31
+ if (max === min) return [0, 0, l];
32
+ const d = (max - min) / 255;
33
+ const s = l > 0.5 ? d / (2 - (max + min) / 255) : d / ((max + min) / 255);
34
+ let h = 0;
35
+ if (max === r) h = ((g - b) / 255 / d + (g < b ? 6 : 0)) / 6;
36
+ else if (max === g) h = ((b - r) / 255 / d + 2) / 6;
37
+ else h = ((r - g) / 255 / d + 4) / 6;
38
+ return [h, s, l];
39
+ }
40
+
41
+ function displayColor(r: number, g: number, b: number): string {
42
+ const [h, s, l] = rgbToHsl(r, g, b);
43
+ const sat = Math.min(0.85, s);
44
+ const light = Math.min(0.78, Math.max(0.25, l));
45
+ return `hsl(${Math.round(h * 360)} ${Math.round(sat * 100)}% ${Math.round(light * 100)}%)`;
46
+ }
47
+
48
+ const loadFluidColor = createDatasetCache(
49
+ async (dataset: DatasetRef, id: string): Promise<string | null> => {
50
+ const parts = splitDumpId(id);
51
+ if (!parts) return null;
52
+ const bundle = await loadClientModAtlases(dataset, parts.ns);
53
+ const hit = lookupSpriteInBundle(bundle, { id, kind: 'fluid' });
54
+ if (!hit) return null;
55
+ const url = atlasPagePublicUrl(dataset, hit.atlas, hit.page);
56
+ if (!url) return null;
57
+ const { sprite } = hit;
58
+ const image = await loadAtlasPage(url);
59
+ const canvas = document.createElement('canvas');
60
+ canvas.width = sprite.w;
61
+ canvas.height = sprite.h;
62
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
63
+ if (!ctx) return null;
64
+ ctx.drawImage(
65
+ image,
66
+ sprite.x,
67
+ sprite.y,
68
+ sprite.w,
69
+ sprite.h,
70
+ 0,
71
+ 0,
72
+ sprite.w,
73
+ sprite.h,
74
+ );
75
+ const { data } = ctx.getImageData(0, 0, sprite.w, sprite.h);
76
+ let r = 0;
77
+ let g = 0;
78
+ let b = 0;
79
+ let n = 0;
80
+ for (let i = 0; i < data.length; i += 4) {
81
+ if (data[i + 3] < 128) continue;
82
+ r += data[i];
83
+ g += data[i + 1];
84
+ b += data[i + 2];
85
+ n += 1;
86
+ }
87
+ return n ? displayColor(r / n, g / n, b / n) : null;
88
+ },
89
+ (dataset, id) => `${dataset.id}/${dataset.version ?? ''}|${id}`,
90
+ );
91
+
92
+ export function useFluidColors(
93
+ dataset: DatasetRef,
94
+ slots: readonly (RecipeSlotFill | { id: string; kind?: string })[],
95
+ ): (string | undefined)[] {
96
+ const ids = useMemo(
97
+ () =>
98
+ slots.map((slot) =>
99
+ slot && (slot.kind === 'fluid' || !slot.kind) ? slot.id : null,
100
+ ),
101
+ [slots],
102
+ );
103
+ const [bump, force] = useReducer((v: number) => v + 1, 0);
104
+ useEffect(() => {
105
+ let live = true;
106
+ const pending = ids.filter(
107
+ (id): id is string => !!id && loadFluidColor.peek(dataset, id) === undefined,
108
+ );
109
+ if (pending.length) {
110
+ Promise.allSettled(
111
+ pending.map((id) => loadFluidColor(dataset, id)),
112
+ ).then(() => {
113
+ if (live) force();
114
+ });
115
+ }
116
+ return () => {
117
+ live = false;
118
+ };
119
+ }, [dataset, ids]);
120
+ void bump;
121
+ return ids.map((id) =>
122
+ id ? (loadFluidColor.peek(dataset, id) ?? undefined) : undefined,
123
+ );
124
+ }
125
+
@@ -0,0 +1,240 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
5
+ import { replaceUrlState } from '@terrafirmagreg-team/ui/lib/utils/history';
6
+ import { recipeItemName } from '@terrafirmagreg-team/recipe/lib/client-index';
7
+ import { useFluidColors } from '@terrafirmagreg-team/recipe/hooks/alloy/use-fluid-colors';
8
+ import { recipeHref } from '@terrafirmagreg-team/recipe/lib/links';
9
+ import {
10
+ INGOT_MB,
11
+ checkMix,
12
+ ingotRatioMix,
13
+ minimalAdditions,
14
+ scaleAmounts,
15
+ snapToIngots,
16
+ suggestAmounts,
17
+ } from '@terrafirmagreg-team/recipe/lib/alloy/mix';
18
+ import type { AlloyComponent, AlloyRecipe } from '@terrafirmagreg-team/recipe/types/alloy';
19
+ import type { RecipeSession } from '@terrafirmagreg-team/recipe/types';
20
+
21
+ function seedMix(
22
+ components: readonly AlloyComponent[],
23
+ unit: 'mb' | 'ingot',
24
+ mixParam: string | undefined,
25
+ ): { inputs: string[]; total: string } {
26
+ const parts = mixParam?.split(';').map((value) => value.trim());
27
+ if (
28
+ parts &&
29
+ parts.length === components.length &&
30
+ parts.every((value) => value !== '' && Number.isFinite(Number(value)))
31
+ ) {
32
+ return {
33
+ inputs: parts,
34
+ total: String(
35
+ Number(parts.reduce((sum, value) => sum + Number(value), 0).toFixed(3)),
36
+ ),
37
+ };
38
+ }
39
+ const ratio = ingotRatioMix(components);
40
+ if (unit === 'ingot' && ratio) {
41
+ return { inputs: ratio.counts.map(String), total: String(ratio.total) };
42
+ }
43
+ return {
44
+ inputs: (suggestAmounts(components, 144) ?? []).map(String),
45
+ total: '144',
46
+ };
47
+ }
48
+
49
+ export function useMixState(recipe: AlloyRecipe, session: RecipeSession) {
50
+ const t = useTranslations();
51
+ const name = (id: string) =>
52
+ recipeItemName(session.names, session.lang, id);
53
+ const slots = useMemo(
54
+ () => recipe.components.map(({ slot }) => slot),
55
+ [recipe.components],
56
+ );
57
+ const colors = useFluidColors(session.dataset, slots);
58
+ const [unit, setUnit] = useState<'mb' | 'ingot'>(() =>
59
+ session.alloyUnit === 'ingot' ? 'ingot' : 'mb',
60
+ );
61
+ const factor = unit === 'ingot' ? INGOT_MB : 1;
62
+
63
+ const [seed] = useState(() =>
64
+ seedMix(recipe.components, unit, session.alloyMix),
65
+ );
66
+ const [total, setTotal] = useState(seed.total);
67
+ const [inputs, setInputs] = useState(seed.inputs);
68
+ const [seen, setSeen] = useState({
69
+ unit: session.alloyUnit,
70
+ mix: session.alloyMix,
71
+ });
72
+
73
+ const num = (value: string) => Number(value.replace(',', '.'));
74
+ const trim = (value: number) => String(Number(value.toFixed(3)));
75
+ const totalNumber = num(total);
76
+ const amounts = useMemo(
77
+ () => inputs.map((value) => num(value) || 0),
78
+ [inputs],
79
+ );
80
+
81
+ if (session.alloyUnit !== seen.unit || session.alloyMix !== seen.mix) {
82
+ setSeen({ unit: session.alloyUnit, mix: session.alloyMix });
83
+ if (
84
+ session.alloyUnit !== (unit === 'ingot' ? 'ingot' : undefined) ||
85
+ session.alloyMix !== amounts.map((value) => trim(value)).join(';')
86
+ ) {
87
+ const nextUnit = session.alloyUnit === 'ingot' ? 'ingot' : 'mb';
88
+ const next = seedMix(recipe.components, nextUnit, session.alloyMix);
89
+ setUnit(nextUnit);
90
+ setInputs(next.inputs);
91
+ setTotal(next.total);
92
+ }
93
+ }
94
+
95
+ useEffect(() => {
96
+ const mix = amounts.map((value) => trim(value)).join(';');
97
+ replaceUrlState(
98
+ recipeHref(session.dataset, session.lang, {
99
+ tool: 'alloy',
100
+ id: recipe.recipe.id,
101
+ tab: session.tab,
102
+ unit: unit === 'ingot' ? 'ingot' : undefined,
103
+ mix,
104
+ }),
105
+ );
106
+ }, [
107
+ unit,
108
+ amounts,
109
+ recipe.recipe.id,
110
+ session.dataset,
111
+ session.lang,
112
+ session.tab,
113
+ ]);
114
+ const mix = useMemo(
115
+ () => checkMix(recipe.components, amounts),
116
+ [recipe.components, amounts],
117
+ );
118
+ const fix = useMemo(
119
+ () => minimalAdditions(recipe.components, amounts),
120
+ [recipe.components, amounts],
121
+ );
122
+ const ratioMix = useMemo(
123
+ () => ingotRatioMix(recipe.components),
124
+ [recipe.components],
125
+ );
126
+ const step = unit === 'ingot' ? 1 : INGOT_MB;
127
+ const presets =
128
+ unit === 'ingot' ? [1, 4, 10, 32, 64] : [144, 576, 1440, 4608, 9216];
129
+ const econTotal =
130
+ Number.isFinite(totalNumber) && totalNumber > 0 ? totalNumber : mix.total;
131
+ const needsSnap = amounts.some(
132
+ (value) => Math.abs((value * factor) % INGOT_MB) > 1e-6,
133
+ );
134
+ const snapped = useMemo(
135
+ () =>
136
+ needsSnap
137
+ ? snapToIngots(
138
+ recipe.components,
139
+ amounts.map((value) => value * factor),
140
+ )
141
+ : null,
142
+ [needsSnap, recipe.components, amounts, factor],
143
+ );
144
+ const output = recipe.recipe.outputs[0];
145
+ const unitLabel =
146
+ unit === 'ingot' ? t('ing.', { note: 'recipe.alloy.unit.ingot' }) : t('mB', { note: 'recipe.alloy.unit.mb' });
147
+ const format = (value: number) =>
148
+ value.toLocaleString(undefined, { maximumFractionDigits: 2 });
149
+ const formatPercent = (value: number) =>
150
+ value.toLocaleString(undefined, { maximumFractionDigits: 1 });
151
+ const totalInvalid =
152
+ total.trim() !== '' && (!Number.isFinite(totalNumber) || totalNumber <= 0);
153
+ const mixLabel = recipe.components
154
+ .map(
155
+ ({ slot }, i) =>
156
+ `${formatPercent(mix.rows[i]?.percent ?? 0)}% ${name(slot.id)}`,
157
+ )
158
+ .join(', ');
159
+ const copyText = `${recipe.components
160
+ .map(({ slot }, i) => `${name(slot.id)} ${format(amounts[i] || 0)}`)
161
+ .join(' + ')} → ${output ? name(output.id) : t('Alloy', { note: 'recipe.alloy.result' })} ${format(
162
+ mix.output,
163
+ )} ${unitLabel}`;
164
+ const fill = (target: number) => {
165
+ const next = suggestAmounts(recipe.components, target);
166
+ if (next) {
167
+ setInputs(next.map((value) => trim(value)));
168
+ }
169
+ };
170
+
171
+ const applyTotal = (raw: string) => {
172
+ setTotal(raw);
173
+ const value = num(raw);
174
+ if (!Number.isFinite(value) || value <= 0) return;
175
+ const next =
176
+ scaleAmounts(amounts, value) ?? suggestAmounts(recipe.components, value);
177
+ if (next) {
178
+ setInputs(next.map((v) => trim(v)));
179
+ }
180
+ };
181
+ const stepInput = (index: number, dir: 1 | -1) => {
182
+ setInputs((prev) =>
183
+ prev.map((value, j) =>
184
+ j === index ? trim(Math.max(0, (num(value) || 0) + dir * step)) : value,
185
+ ),
186
+ );
187
+ };
188
+ const switchUnit = (next: 'mb' | 'ingot') => {
189
+ if (next === unit) return;
190
+ const k = next === 'ingot' ? 1 / INGOT_MB : INGOT_MB;
191
+ const convert = (value: string) =>
192
+ value.trim() !== '' && Number.isFinite(num(value))
193
+ ? trim(num(value) * k)
194
+ : value;
195
+ setInputs((prev) => prev.map(convert));
196
+ setTotal(convert);
197
+ setUnit(next);
198
+ };
199
+ const applyRatioMix = (mult = 1) => {
200
+ if (!ratioMix) return;
201
+
202
+ const perIngot = INGOT_MB / factor;
203
+ setInputs(ratioMix.counts.map((count) => trim(count * mult * perIngot)));
204
+ setTotal(trim(ratioMix.total * mult * perIngot));
205
+ };
206
+
207
+ return {
208
+ unit,
209
+ total,
210
+ inputs,
211
+ totalNumber,
212
+ factor,
213
+ presets,
214
+ mix,
215
+ fix,
216
+ ratioMix,
217
+ econTotal,
218
+ needsSnap,
219
+ snapped,
220
+ output,
221
+ colors,
222
+ name,
223
+ format,
224
+ formatPercent,
225
+ unitLabel,
226
+ totalInvalid,
227
+ mixLabel,
228
+ copyText,
229
+ trim,
230
+ setInputs,
231
+ fill,
232
+ applyTotal,
233
+ stepInput,
234
+ switchUnit,
235
+ applyRatioMix,
236
+ };
237
+ }
238
+
239
+ export type MixState = ReturnType<typeof useMixState>;
240
+
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ type Dispatch,
9
+ type RefObject,
10
+ type SetStateAction,
11
+ } from 'react';
12
+ import type { Edge } from '@terrafirmagreg-team/ui/lib/canvas/xyflow';
13
+ import type {
14
+ AnyCalcFlowNode,
15
+ cardsToFlow,
16
+ } from '@terrafirmagreg-team/recipe/lib/calc/flow';
17
+ import type { ChevronEdgeHighlight } from '@terrafirmagreg-team/ui/types/canvas';
18
+ import type { MountedNodes } from './use-mount';
19
+
20
+ const CHAIN_NODE_MARK = 'calc-node--chain';
21
+ const DIM_NODE_MARK = 'calc-node--dim';
22
+
23
+ function stripMark(cls: string | undefined) {
24
+ return cls
25
+ ?.split(' ')
26
+ .filter((c) => c !== CHAIN_NODE_MARK && c !== DIM_NODE_MARK)
27
+ .join(' ');
28
+ }
29
+
30
+ export function useEdgeEngage({
31
+ computed,
32
+ setState,
33
+ rawFlow,
34
+ focusIdRef,
35
+ }: {
36
+ computed: { nodes: AnyCalcFlowNode[]; edges: Edge[] };
37
+ setState: Dispatch<SetStateAction<MountedNodes>>;
38
+ rawFlow: ReturnType<typeof cardsToFlow>;
39
+ focusIdRef: RefObject<string | null>;
40
+ }) {
41
+ const engagedRef = useRef<string | null>(null);
42
+ const adjacency = useMemo(() => {
43
+ const downstream = new Map<string, Edge[]>();
44
+ const upstream = new Map<string, Edge[]>();
45
+ for (const edge of computed.edges) {
46
+ downstream.set(edge.source, [
47
+ ...(downstream.get(edge.source) ?? []),
48
+ edge,
49
+ ]);
50
+ upstream.set(edge.target, [...(upstream.get(edge.target) ?? []), edge]);
51
+ }
52
+ return { downstream, upstream };
53
+ }, [computed]);
54
+
55
+ const applyEngaged = useCallback(
56
+ (id: string | null) => {
57
+ const prev = engagedRef.current;
58
+ if (prev === id) return;
59
+ engagedRef.current = id;
60
+
61
+ const chainEdges = new Map<string, ChevronEdgeHighlight>();
62
+ const chainNodes = new Set<string>();
63
+ if (id) {
64
+ chainNodes.add(id);
65
+ const walk = (
66
+ start: string,
67
+ edgesOf: (n: string) => Edge[] | undefined,
68
+ nextOf: (e: Edge) => string,
69
+ mark: ChevronEdgeHighlight,
70
+ ) => {
71
+ const seen = new Set<string>([start]);
72
+ const queue = [start];
73
+ while (queue.length) {
74
+ const nodeId = queue.shift()!;
75
+ for (const edge of edgesOf(nodeId) ?? []) {
76
+ chainEdges.set(edge.id, mark);
77
+ const next = nextOf(edge);
78
+ if (!seen.has(next)) {
79
+ seen.add(next);
80
+ chainNodes.add(next);
81
+ queue.push(next);
82
+ }
83
+ }
84
+ }
85
+ };
86
+ walk(id, (n) => adjacency.upstream.get(n), (e) => e.source, 'incoming');
87
+ walk(id, (n) => adjacency.downstream.get(n), (e) => e.target, 'outgoing');
88
+ }
89
+ const active = chainEdges.size > 0;
90
+
91
+ setState((current) => {
92
+ let edgesChanged = false;
93
+ const edges = current.edges.map((edge) => {
94
+ const mark = active
95
+ ? (chainEdges.get(edge.id) ?? 'dim')
96
+ : undefined;
97
+ if ((edge.data?.highlight ?? undefined) === mark) return edge;
98
+ edgesChanged = true;
99
+ return { ...edge, data: { ...edge.data, highlight: mark } };
100
+ });
101
+ let nodesChanged = false;
102
+ const nodes = current.nodes.map((node) => {
103
+ const base = stripMark(node.className);
104
+ const cls = active
105
+ ? chainNodes.has(node.id)
106
+ ? `${base ?? ''} ${CHAIN_NODE_MARK}`.trim()
107
+ : `${base ?? ''} ${DIM_NODE_MARK}`.trim()
108
+ : base;
109
+ if ((node.className ?? '') === (cls ?? '')) return node;
110
+ nodesChanged = true;
111
+ return { ...node, className: cls || undefined };
112
+ });
113
+ if (!edgesChanged && !nodesChanged) return current;
114
+ return {
115
+ ...current,
116
+ edges: edgesChanged ? edges : current.edges,
117
+ nodes: nodesChanged ? nodes : current.nodes,
118
+ };
119
+ });
120
+ },
121
+ [adjacency, setState],
122
+ );
123
+ useEffect(() => {
124
+ engagedRef.current = null;
125
+ focusIdRef.current = null;
126
+ }, [rawFlow, focusIdRef]);
127
+ return applyEngaged;
128
+ }
129
+
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ type DragEvent,
6
+ type MouseEvent,
7
+ } from 'react';
8
+ import {
9
+ useReactFlow,
10
+ type Connection,
11
+ type Edge,
12
+ } from '@terrafirmagreg-team/ui/lib/canvas/xyflow';
13
+ import type {
14
+ AnyCalcFlowNode,
15
+ CalcFlowData,
16
+ } from '@terrafirmagreg-team/recipe/lib/calc/flow';
17
+ import type {
18
+ CalcKind,
19
+ CalcNode,
20
+ } from '@terrafirmagreg-team/recipe/types/calc';
21
+
22
+ export function useFlowHandlers({
23
+ data,
24
+ applyFocus,
25
+ applyEngaged,
26
+ centerOnNode,
27
+ setPickerNode,
28
+ }: {
29
+ data: Omit<
30
+ CalcFlowData,
31
+ 'node' | 'mode' | 'onRequestPicker' | 'onRequestMemberPicker'
32
+ >;
33
+ applyFocus: (id: string | null) => void;
34
+ applyEngaged: (id: string | null) => void;
35
+ centerOnNode: (node: AnyCalcFlowNode) => void;
36
+ setPickerNode: (node: CalcNode | null) => void;
37
+ }) {
38
+ const { screenToFlowPosition, getNode } = useReactFlow();
39
+
40
+ const handleNodeEnter = useCallback(
41
+ (_event: MouseEvent, node: AnyCalcFlowNode) => applyEngaged(node.id),
42
+ [applyEngaged],
43
+ );
44
+ const handleNodeLeave = useCallback(
45
+ () => applyEngaged(null),
46
+ [applyEngaged],
47
+ );
48
+ const handleNodeClick = useCallback(
49
+ (_event: MouseEvent, node: AnyCalcFlowNode) => {
50
+ applyFocus(node.id);
51
+ centerOnNode(node);
52
+ },
53
+ [applyFocus, centerOnNode],
54
+ );
55
+
56
+ const handleNodeDoubleClick = useCallback(
57
+ (event: MouseEvent, node: AnyCalcFlowNode) => {
58
+ const el = event.target as HTMLElement | null;
59
+ if (
60
+ el?.closest(
61
+ 'button, a, input, [role="button"], .nodrag, .calc-port',
62
+ )
63
+ ) {
64
+ return;
65
+ }
66
+ const target = (node.data as CalcFlowData).node;
67
+ if (!target) return;
68
+
69
+ if (target.recipe != null || target.availableRecipes.length > 0)
70
+ setPickerNode(target);
71
+ },
72
+ [setPickerNode],
73
+ );
74
+ const handlePaneClick = useCallback(
75
+ () => applyFocus(null),
76
+ [applyFocus],
77
+ );
78
+ const handleDragOver = useCallback((event: DragEvent) => {
79
+ if (event.dataTransfer.types.includes('application/x-tfg-calc')) {
80
+ event.preventDefault();
81
+ event.dataTransfer.dropEffect = 'copy';
82
+ }
83
+ }, []);
84
+ const handleDrop = useCallback(
85
+ (event: DragEvent) => {
86
+ const raw = event.dataTransfer.getData('application/x-tfg-calc');
87
+ if (!raw) return;
88
+ event.preventDefault();
89
+ try {
90
+ const parsed = JSON.parse(raw) as { id?: string; kind?: CalcKind };
91
+ if (parsed.id && (parsed.kind === 'item' || parsed.kind === 'fluid')) {
92
+ const pos = screenToFlowPosition({
93
+ x: event.clientX,
94
+ y: event.clientY,
95
+ });
96
+ data.onAddCard(parsed.id, parsed.kind, pos);
97
+ }
98
+ } catch {}
99
+ },
100
+ [screenToFlowPosition, data],
101
+ );
102
+
103
+ const fitRecipeNodes = useCallback(
104
+ (node: AnyCalcFlowNode) => node.data.node?.recipe != null,
105
+ [],
106
+ );
107
+
108
+ const handleConnect = useCallback(
109
+ (conn: Connection) => {
110
+ const { source, target, sourceHandle, targetHandle } = conn;
111
+ if (!source || !target || source === target) return;
112
+ const input = Number(/^in-(\d+)$/.exec(targetHandle ?? '')?.[1] ?? -1);
113
+ const outMatch = /^out(?:-(\d+))?$/.exec(sourceHandle ?? '');
114
+ if (input < 0 || !outMatch) return;
115
+ const out = Number(outMatch[1] ?? 0);
116
+ const sNode = (getNode(source)?.data as CalcFlowData | undefined)?.node;
117
+ const tNode = (getNode(target)?.data as CalcFlowData | undefined)?.node;
118
+ const slot = tNode?.inputs[input];
119
+ const srcOut = sNode?.outputs[out];
120
+ if (!sNode || !tNode || !slot || !srcOut) return;
121
+ if (slot.id !== srcOut.id || slot.kind !== srcOut.kind) return;
122
+ data.onWire(source, target, input, out);
123
+ },
124
+ [data, getNode],
125
+ );
126
+
127
+ const handleReconnect = useCallback(
128
+ (oldEdge: Edge, conn: Connection) => {
129
+ const wire = oldEdge.data?.wire as
130
+ | { from: string; to: string; input: number; out?: number }
131
+ | undefined;
132
+ const input = Number(/^in-(\d+)$/.exec(conn.targetHandle ?? '')?.[1] ?? -1);
133
+ const outMatch = /^out(?:-(\d+))?$/.exec(conn.sourceHandle ?? '');
134
+ if (!wire || !conn.source || !conn.target || input < 0 || !outMatch)
135
+ return;
136
+ const out = Number(outMatch[1] ?? 0);
137
+ if (conn.source === conn.target) return;
138
+ const sNode = (getNode(conn.source)?.data as CalcFlowData | undefined)?.node;
139
+ const tNode = (getNode(conn.target)?.data as CalcFlowData | undefined)?.node;
140
+ const slot = tNode?.inputs[input];
141
+ const srcOut = sNode?.outputs[out];
142
+ if (!sNode || !tNode || !slot || !srcOut) return;
143
+ if (slot.id !== srcOut.id || slot.kind !== srcOut.kind) return;
144
+ data.onRewire(wire.from, wire.to, wire.input, {
145
+ from: conn.source,
146
+ to: conn.target,
147
+ input,
148
+ out,
149
+ });
150
+ },
151
+ [data, getNode],
152
+ );
153
+
154
+ const handleEdgesDelete = useCallback(
155
+ (edges: Edge[]) => {
156
+ for (const edge of edges) {
157
+ const wire = edge.data?.wire as
158
+ | { from: string; to: string; input: number; out?: number }
159
+ | undefined;
160
+ if (wire) data.onUnwire(wire.from, wire.to, wire.input, wire.out);
161
+ }
162
+ },
163
+ [data],
164
+ );
165
+
166
+ const handleEdgeClick = useCallback(
167
+ (event: MouseEvent, edge: Edge) => {
168
+ if (!event.shiftKey) return;
169
+ const wire = edge.data?.wire as
170
+ | { from: string; to: string; input: number; out?: number }
171
+ | undefined;
172
+ if (wire) data.onUnwire(wire.from, wire.to, wire.input, wire.out);
173
+ },
174
+ [data],
175
+ );
176
+
177
+ return {
178
+ handleNodeEnter,
179
+ handleNodeLeave,
180
+ handleNodeClick,
181
+ handleNodeDoubleClick,
182
+ handlePaneClick,
183
+ handleDragOver,
184
+ handleDrop,
185
+ handleConnect,
186
+ handleReconnect,
187
+ handleEdgesDelete,
188
+ handleEdgeClick,
189
+ fitRecipeNodes,
190
+ };
191
+ }
192
+