Skip to content

Commit c3dea9f

Browse files
committed
refactor(app): decompose the block editor's render tree (phase 2)
Extract Block's four render regions into one-component-per-file modules under components/block/: BlockToolbar (name/icon/actions/legend/help), GoalCard, BalanceCard (status, override + spoil strips, warnings, power, imports/exports), and RecipeGrid with its RecipeRow, GroupHeaderRow and MissingRecipeRow children. The grid owns its view-only state (fold state in localStorage, click-to-confirm removal, drag-reorder); rename-on-create stays with the editor since the row context menu starts it. Children subscribe to the doc store with useStore selectors, so a rate edit no longer re-renders the whole grid. block.$id.tsx drops from 2,058 to 778 lines; the Block component from cognitive complexity 149 to 74 (originally 181 before the split series). Refs #90
1 parent f9d69ee commit c3dea9f

10 files changed

Lines changed: 1739 additions & 1370 deletions

File tree

app/.fallow/churn.bin

210 Bytes
Binary file not shown.

app/src/components/block/balance-card.tsx

Lines changed: 403 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { useStore } from "@tanstack/react-store";
2+
import { Check, Copy, Gamepad2, Grid2x2, Hammer, Power, Star } from "lucide-react";
3+
import { Badge } from "#/components/ui/badge.tsx";
4+
import { Button } from "#/components/ui/button.tsx";
5+
import { Input } from "#/components/ui/input.tsx";
6+
import { FieldLabel } from "#/components/ui/label.tsx";
7+
import {
8+
Sheet,
9+
SheetContent,
10+
SheetHeader,
11+
SheetTitle,
12+
SheetTrigger,
13+
} from "#/components/ui/sheet.tsx";
14+
import { HelpButton } from "#/components/help-drawer.tsx";
15+
import { Icon } from "../../lib/icons";
16+
import { Legend } from "./legend.tsx";
17+
import { craftableStyle, linkStyle } from "./item-chip.tsx";
18+
import type { BlockDocStore } from "./doc-store.ts";
19+
import type { SolveResult } from "./solve-view.ts";
20+
import { fmtAmt } from "./format.ts";
21+
import { cellChip } from "./styles.ts";
22+
23+
/** The editor's header row: block icon + name (auto-named from the goal until
24+
* the user types one), the save indicator, the toolbar actions (copy setup,
25+
* show in game, enable/disable, building summary), the chip-colour legend, and
26+
* the "what is a block?" help drawer. */
27+
export function BlockToolbar({
28+
doc,
29+
blockIcon,
30+
titleHealthCls,
31+
saveState,
32+
onNamePinned,
33+
blockEnabled,
34+
onToggleEnabled,
35+
onCopySetup,
36+
showInGame,
37+
buildCost,
38+
onOpenIconPicker,
39+
}: {
40+
doc: BlockDocStore;
41+
/** the block's face (#40): explicit pick, else the first goal's icon */
42+
blockIcon: { kind: string; name: string } | null;
43+
/** health tint for the name input (red broken/infeasible, amber warnings) */
44+
titleHealthCls: string;
45+
saveState: "idle" | "saving" | "saved";
46+
/** typing a name pins it; clearing resumes auto-naming from the goal */
47+
onNamePinned: (pinned: boolean) => void;
48+
blockEnabled: boolean;
49+
onToggleEnabled: () => void;
50+
onCopySetup: () => void;
51+
showInGame: { pending: boolean; sent: boolean | null; onShow: () => void };
52+
buildCost: SolveResult["buildCost"] | undefined;
53+
onOpenIconPicker: () => void;
54+
}) {
55+
const blockName = useStore(doc.store, (s) => s.blockName);
56+
const customIcon = useStore(doc.store, (s) => s.customIcon);
57+
return (
58+
<div className="mb-3 flex flex-wrap items-center gap-2">
59+
<Button
60+
variant="outline"
61+
size="icon-lg"
62+
onClick={onOpenIconPicker}
63+
title={
64+
customIcon
65+
? "block icon (custom) — click to change or reset to auto"
66+
: "block icon — follows the first goal; click to pick your own"
67+
}
68+
className={customIcon ? "border-primary/60" : ""}
69+
>
70+
{blockIcon ? (
71+
<Icon
72+
kind={blockIcon.kind as "item" | "fluid"}
73+
name={blockIcon.name}
74+
size="md"
75+
noHover
76+
noTitle
77+
/>
78+
) : (
79+
<Grid2x2 className="size-4 text-muted-foreground" />
80+
)}
81+
</Button>
82+
<Input
83+
value={blockName}
84+
onChange={(e) => {
85+
const v = e.target.value;
86+
doc.setBlockName(v);
87+
// typing a name pins it; clearing it resumes auto-naming from the goal
88+
onNamePinned(v.trim().length > 0);
89+
}}
90+
placeholder="auto-named from goal…"
91+
className={`w-56 font-semibold ${titleHealthCls}`}
92+
/>
93+
<span className="flex w-14 items-center gap-1 text-xs text-muted-foreground">
94+
{saveState === "saving" ? (
95+
"saving…"
96+
) : saveState === "saved" ? (
97+
<>
98+
saved <Check className="size-3" />
99+
</>
100+
) : (
101+
""
102+
)}
103+
</span>
104+
<Button
105+
variant="outline"
106+
size="icon-sm"
107+
onClick={onCopySetup}
108+
title="Copy setup — copy this block's recipe/module setup to the clipboard"
109+
className="text-muted-foreground"
110+
>
111+
<Copy className="size-4" />
112+
</Button>
113+
<Button
114+
variant="outline"
115+
size="icon-sm"
116+
onClick={showInGame.onShow}
117+
disabled={showInGame.pending}
118+
title="Open in game — show this block as an in-game build sheet; click a building there for a configured blueprint (needs the bridge)"
119+
className="text-muted-foreground"
120+
>
121+
<Gamepad2 className={`size-4 ${showInGame.pending ? "animate-pulse" : ""}`} />
122+
</Button>
123+
<Button
124+
variant="outline"
125+
size="icon-sm"
126+
onClick={onToggleEnabled}
127+
title={
128+
blockEnabled
129+
? "Disable block — keep it here but exclude it from every factory-wide total"
130+
: "Enable block — count this block in the factory totals again"
131+
}
132+
className={
133+
!blockEnabled
134+
? "border-warning/60 bg-warning/10 text-warning hover:bg-warning/20"
135+
: "text-muted-foreground"
136+
}
137+
>
138+
<Power className="size-4" />
139+
</Button>
140+
{!blockEnabled && (
141+
<Badge className="border-transparent bg-warning/15 font-semibold text-warning">
142+
disabled — excluded from factory totals
143+
</Badge>
144+
)}
145+
{buildCost && buildCost.buildings.length > 0 && (
146+
<Sheet>
147+
<SheetTrigger asChild>
148+
<Button
149+
variant="outline"
150+
size="icon-sm"
151+
title="Building summary — the buildings + one-time materials to construct this block"
152+
className="text-muted-foreground"
153+
>
154+
<Hammer className="size-4" />
155+
</Button>
156+
</SheetTrigger>
157+
<SheetContent side="right" className="w-96 max-w-[92vw] font-mono">
158+
<SheetHeader>
159+
<SheetTitle>Building summary</SheetTitle>
160+
</SheetHeader>
161+
<div className="min-h-0 flex-1 space-y-4 overflow-auto p-3">
162+
<p className="text-sm text-muted-foreground">
163+
The buildings to construct this block, and the one-time materials to build them — a
164+
shopping list, separate from the per-second flows.
165+
</p>
166+
<div>
167+
<FieldLabel className="mb-1.5">Buildings</FieldLabel>
168+
<div className="flex flex-wrap gap-2">
169+
{buildCost.buildings.map((b) => (
170+
<span key={b.name} className={cellChip} title={b.display}>
171+
<Icon kind="item" name={b.name} size="sm" />
172+
<span className="tabular-nums">×{b.count}</span>
173+
</span>
174+
))}
175+
</div>
176+
</div>
177+
<div>
178+
<FieldLabel className="mb-1.5">Materials to build them</FieldLabel>
179+
{buildCost.materials.length === 0 ? (
180+
<span className="text-sm text-muted-foreground">
181+
— (no build recipe found for these buildings)
182+
</span>
183+
) : (
184+
<div className="flex flex-wrap gap-2">
185+
{buildCost.materials.map((m) => (
186+
<span key={m.name} className={cellChip} title={m.display}>
187+
<Icon kind={m.kind as "item" | "fluid"} name={m.name} size="sm" />
188+
<span className="tabular-nums">{fmtAmt(m.amount)}</span>
189+
</span>
190+
))}
191+
</div>
192+
)}
193+
</div>
194+
</div>
195+
</SheetContent>
196+
</Sheet>
197+
)}
198+
{showInGame.sent === false && (
199+
<span className="text-sm text-warning">game not connected</span>
200+
)}
201+
{showInGame.sent === true && (
202+
<span className="flex items-center gap-1 text-sm text-success">
203+
opened in game <Check className="size-3" />
204+
</span>
205+
)}
206+
<span className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
207+
<Legend cls={linkStyle.target} label="goal" />
208+
<Legend cls={linkStyle.linked} label="linked" />
209+
<Legend cls={linkStyle.import} label="raw in" />
210+
<Legend cls={craftableStyle} label="craftable" />
211+
<Legend cls={linkStyle.export} label="export" />
212+
<span
213+
className="text-muted-foreground/70"
214+
title="right-click any item for actions (make a goal, lock as sizing input, force import/export/balance, locate in game). Alt-click quick-cycles the disposition."
215+
>
216+
· right-click = menu
217+
</span>
218+
</span>
219+
<HelpButton title="What is a block?">
220+
<p>
221+
A block is <span className="text-foreground">one production unit you design</span>: pick
222+
the recipes to make one or more goal goods, and the solver works out how many of each
223+
building you need (fractional counts and all).
224+
</p>
225+
<div>
226+
<div className="font-semibold text-foreground">Goals</div>
227+
<p className="mt-1">
228+
A block can target several products at once — each goal has a{" "}
229+
<span className="text-foreground">target rate</span> and the block is sized so that good
230+
comes out at exactly that rate. Click a goal&apos;s rate to edit it, and click its unit
231+
to cycle <span className="text-foreground">/s → /min → /h</span> — enter science as
232+
10/min or a slow bootstrap as 0.5/h; the unit sticks per goal while the solver works in
233+
per-second underneath. Not everything is throughput:{" "}
234+
<span className="text-foreground">right-click a goal → Keep in stock</span> turns it
235+
into a buffer goal (&quot;keep 100 on hand&quot;) with a refill window (default 10m,
236+
click to cycle) — machines are sized to rebuild the buffer within the window, and the
237+
factory ledger badges the flow <span className="text-info">↻ stock</span>. So a single
238+
&quot;logistics&quot; block can make belts @10/s, undergrounds @4/s and splitters @2/s
239+
side by side. The first goal <span className="text-info">names the block</span>, anchors
240+
the scale tools, and is the default icon;{" "}
241+
<Star className="inline size-3.5 text-foreground" /> moves a goal to the front. Click
242+
the icon next to the block&apos;s name to pick any item or fluid as its icon instead. A
243+
good you don&apos;t target isn&apos;t a goal — it falls out as a byproduct (export).
244+
</p>
245+
<p className="mt-1">
246+
If your goals can&apos;t all be met at once (e.g. two goods locked to a fixed ratio by
247+
one recipe), the block is <span className="text-destructive">infeasible</span> and says
248+
so — add a recipe to make more of the short good, or change a rate.
249+
</p>
250+
</div>
251+
<p>
252+
<span className="text-foreground">How it solves.</span> Given the goals, every other good
253+
in the block is one of: <span className="text-foreground">balanced</span> (made and used
254+
inside the block), <span className="text-foreground">imported</span> (brought in from
255+
outside or another block), or <span className="text-foreground">exported</span> (surplus
256+
that leaves). The solver sets each recipe&apos;s run-rate to satisfy that — it&apos;s a
257+
linear system, and it handles Py&apos;s cyclic recipe chains.
258+
</p>
259+
<p>
260+
<span className="text-foreground">You drive it, not an optimizer.</span> You choose the
261+
recipes and how to split a good between competing ones; PyOps just solves the system you
262+
describe. <span className="text-foreground">Right-click</span> any item to make it a goal,
263+
lock it as a sizing input, or force import / export / balance — the colored legend shows
264+
each item&apos;s current disposition.
265+
</p>
266+
<div>
267+
<div className="font-semibold text-foreground">Sub-blocks</div>
268+
<p className="mt-1">
269+
<span className="text-foreground">Right-click a recipe&apos;s name</span> to start a
270+
sub-block — a named, collapsible group of rows. Add more rows from the same menu or by
271+
dragging them onto the header; collapse it and the whole chain reads as one line showing
272+
its <span className="text-foreground">net flows</span> (what goes in, what comes out —
273+
intermediates cancel), machines and power. Display-only: the solve is exactly the same
274+
expanded, collapsed, or dissolved. Drag the header to move the whole chain; double-click
275+
its name to rename; × ungroups (the rows stay).
276+
</p>
277+
</div>
278+
<div>
279+
<div className="font-semibold text-foreground">Toolbar (next to the name)</div>
280+
<ul className="mt-1 space-y-1.5">
281+
<li className="flex items-start gap-2">
282+
<Copy className="mt-0.5 size-4 shrink-0 text-foreground" />
283+
<span>copies this block&apos;s recipe/module setup to the clipboard;</span>
284+
</li>
285+
<li className="flex items-start gap-2">
286+
<Gamepad2 className="mt-0.5 size-4 shrink-0 text-foreground" />
287+
<span>
288+
shows this block as an in-game build sheet — click a building there for a configured
289+
blueprint;
290+
</span>
291+
</li>
292+
<li className="flex items-start gap-2">
293+
<Hammer className="mt-0.5 size-4 shrink-0 text-foreground" />
294+
<span>
295+
<span className="text-foreground">Building summary</span> — opens a drawer listing
296+
the buildings and the one-time materials to construct this block (a shopping list,
297+
kept out of the way of the per-second flows).
298+
</span>
299+
</li>
300+
</ul>
301+
</div>
302+
<p>
303+
Per-machine <span className="text-foreground">modules / beacons</span> are tuned in the
304+
block body to cut building count. The Cybersyn request-combinator generator now lives in
305+
the in-game mod panel.
306+
</p>
307+
</HelpButton>
308+
</div>
309+
);
310+
}

0 commit comments

Comments
 (0)