From ec2f383e07ab005d5103669d3aa4c84f5f3c7515 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:05:17 +0000 Subject: [PATCH] Planner: the table as a tree, like FactorioLab The table now starts from each target and nests what feeds it beneath, row under row, indented with dotted lines from each step to its inputs. Every row is the share its parent uses: the machines, power, belts and by-products for just that branch, so the same step shows up under each thing it feeds. Chevrons fold a branch away; Expand all and Collapse all do the whole tree. A step that feeds itself further up stops there. The one-row-per-step list stays as Totals, and the choice is kept with the plan. Its first column no longer leaves a gap under it when a row's inputs wrap onto two lines. --- apps/site/src/tools/planner/index.ts | 28 ++++- apps/site/src/tools/planner/panels.ts | 137 +++++++++++++++++++++++- apps/site/src/tools/planner/planner.css | 67 +++++++++++- apps/site/src/tools/planner/state.ts | 6 ++ 4 files changed, 229 insertions(+), 9 deletions(-) diff --git a/apps/site/src/tools/planner/index.ts b/apps/site/src/tools/planner/index.ts index c048947..5c14d16 100644 --- a/apps/site/src/tools/planner/index.ts +++ b/apps/site/src/tools/planner/index.ts @@ -20,7 +20,7 @@ import { import { escapeHtml } from "../blueprint-editor/html.js"; import { FlowGraph, setFluidCheck } from "./graph.js"; import { UNIT_LABEL } from "./format.js"; -import { inspectorHtml, moduleChooserHtml, pickerHtml, settingsHtml, summaryHtml, tableHtml, welcomeHtml } from "./panels.js"; +import { buildTree, inspectorHtml, moduleChooserHtml, pickerHtml, settingsHtml, summaryHtml, tableHtml, treeParentKeys, welcomeHtml } from "./panels.js"; import { iconsReady, itemColor, loadItemColors, sprite } from "./sprites.js"; import { defaultState, loadState, saveState, shareLink, UNIT_SECONDS, type PlannerState, type TimeUnit } from "./state.js"; @@ -81,6 +81,8 @@ export function mountPlanner(root: HTMLElement): () => void { let state: PlannerState = defaultState(); let result: PlanResult | null = null; let selected: string | null = null; + /** Tree rows folded shut, by row key; kept while the plan changes. */ + const collapsedRows = new Set(); let modalKind: "picker" | "settings" | null = null; let pickerGroup = "intermediate-products"; let pickerQuery = ""; @@ -148,7 +150,7 @@ export function mountPlanner(root: HTMLElement): () => void { graph.render(pd, result, state.unit, belt); graph.select(selected); } else { - main.innerHTML = tableHtml(pd, result, state, selected); + main.innerHTML = tableHtml(pd, result, state, selected, collapsedRows); } renderInspector(); } @@ -182,7 +184,7 @@ export function mountPlanner(root: HTMLElement): () => void { if (id === selected && id !== null) return; selected = id || null; graph.select(selected); - if (state.view === "table" && pd && result) main.innerHTML = tableHtml(pd, result, state, selected); + if (state.view === "table" && pd && result) main.innerHTML = tableHtml(pd, result, state, selected, collapsedRows); if (selected) inspBody.scrollTop = 0; renderInspector(); } @@ -456,6 +458,26 @@ export function mountPlanner(root: HTMLElement): () => void { }); break; } + case "toggle-row": { + const key = a.dataset.key!; + if (collapsedRows.has(key)) collapsedRows.delete(key); + else collapsedRows.add(key); + render(); + break; + } + case "tree-expand": + collapsedRows.clear(); + render(); + break; + case "tree-collapse": + if (result) for (const key of treeParentKeys(buildTree(result))) collapsedRows.add(key); + render(); + break; + case "table-mode": + state.tableMode = a.dataset.mode === "totals" ? "totals" : "tree"; + saveState(state); + render(); + break; case "reset-step": delete s.machineFor[recipe]; delete s.modulesFor[recipe]; diff --git a/apps/site/src/tools/planner/panels.ts b/apps/site/src/tools/planner/panels.ts index 1ebb952..1ea52eb 100644 --- a/apps/site/src/tools/planner/panels.ts +++ b/apps/site/src/tools/planner/panels.ts @@ -260,7 +260,136 @@ export function moduleChooserHtml(pd: PlannerData, names: string[], current: str /* -------------------------------------------------------------------- table */ -export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerState, selected: string | null): string { +/** One row of the tree: a step (or an import) at the share of it that its + * parent row uses. The same step shows up once under every row it feeds. */ +export interface TreeRow { + key: string; + /** Step id, or import:. */ + node: string; + item: string; + /** Per second, for the parent. */ + rate: number; + /** Share of the step this row stands for, 0..1. */ + share: number; + children: TreeRow[]; + /** Stops here because the step feeds itself further up (a loop). */ + loop?: boolean; +} + +/** Most rows the tree builds before it stops descending. */ +const TREE_LIMIT = 2000; + +/** The plan as FactorioLab draws it: each target's producers, then what + * feeds them, nested, each row scaled to the share its parent uses. */ +export function buildTree(result: PlanResult): TreeRow[] { + const steps = new Map(result.steps.map((s) => [s.id, s])); + const inflows = new Map(); + for (const f of result.flows) { + if (!inflows.has(f.to)) inflows.set(f.to, []); + inflows.get(f.to)!.push(f); + } + let count = 0; + const expand = (node: string, share: number, key: string, path: Set): TreeRow[] => { + const step = steps.get(node); + const order = step ? [...step.inputs.map((i) => i.item)] : []; + const flows = [...(inflows.get(node) ?? [])].sort((a, b) => order.indexOf(a.item) - order.indexOf(b.item) || b.rate - a.rate); + const rows: TreeRow[] = []; + for (const f of flows) { + const rate = f.rate * share; + if (rate < 1e-9) continue; + const src = steps.get(f.from); + const childKey = `${key}/${f.from}~${f.item}`; + const out = src?.outputs.find((o) => o.item === f.item)?.rate ?? 0; + const childShare = src && out > 0 ? rate / out : 0; + const loop = path.has(f.from); + count++; + const row: TreeRow = { key: childKey, node: f.from, item: f.item, rate, share: childShare, children: [], ...(loop ? { loop } : {}) }; + if (src && !loop && count < TREE_LIMIT) row.children = expand(f.from, childShare, childKey, new Set(path).add(f.from)); + rows.push(row); + } + return rows; + }; + return result.targets.flatMap((t) => expand(`target:${t.item}`, 1, t.item, new Set())); +} + +/** Every row key that has children, for collapsing the whole tree. */ +export function treeParentKeys(rows: TreeRow[], out: string[] = []): string[] { + for (const r of rows) { + if (r.children.length) { + out.push(r.key); + treeParentKeys(r.children, out); + } + } + return out; +} + +/** Horizontal step per tree level, and where a level's line runs. */ +const TREE_INDENT = 30; +const TREE_LINE = 19; + +function treeHtml(pd: PlannerData, result: PlanResult, state: PlannerState, selected: string | null, collapsed: Set): string { + const unit = state.unit; + const steps = new Map(result.steps.map((s) => [s.id, s])); + let html = ""; + const walk = (rows: TreeRow[], depth: number, guides: boolean[]) => { + rows.forEach((r, i) => { + const last = i === rows.length - 1; + const open = r.children.length > 0 && !collapsed.has(r.key); + const step = steps.get(r.node); + const x = (level: number) => 8 + level * TREE_INDENT + TREE_LINE; + // Dotted lines: ancestors still running, this row's elbow, and the + // stem down to its own children when they are shown. + let lines = guides.map((on, level) => (on ? `` : "")).join(""); + if (depth > 0) { + lines += ``; + lines += ``; + } + if (open) lines += ``; + const icon = step ? recipeIcon(step.recipe) : r.item; + const chevron = r.children.length + ? `` + : ""; + const label = step ? step.recipe.label : pd.items[r.item]?.label ?? r.item; + const machines = step?.machine ? step.machines * r.share : 0; + const whole = Math.ceil(machines - 1e-6); + const machineCell = step?.machine + ? `${sprite(step.machine.icon, 28)}
${fmtMachines(machines)}
` + : r.node.startsWith("import:") + ? `Import` + : `—`; + const extra = step ? step.outputs.filter((o) => o.item !== r.item).map((o) => ({ item: o.item, rate: o.rate * r.share })) : []; + const power = step ? (step.power + step.beaconPower) * r.share : 0; + html += ` + ${chevron} + ${lines}${sprite(icon, 32)} +
${fmtRate(r.rate, unit)}${UNIT_LABEL[unit]} ${beltTag(pd, state, r.item, r.rate)}${e(label)}${r.loop ? " · loops back" : ""}
+ ${machineCell} + ${step ? compressModules(step.modules).map(([m, c]) => `${sprite(m, 18)}${c > 1 ? `${c}` : ""}`).join("") + (step.beacons && step.beaconModule ? `${sprite("beacon", 18)}${step.beacons}` : "") : ""} + ${extra.map((f) => `${sprite(f.item, 20)}${fmtRate(f.rate, unit)}`).join("")} + ${power ? fmtPower(power) : step?.fuel ? `${sprite(step.fuel.item, 16)}${fmtRate(step.fuel.rate * r.share, unit)}` : ""} + `; + if (open) walk(r.children, depth + 1, depth > 0 ? [...guides, !last] : guides); + }); + }; + walk(buildTree(result), 0, []); + return ` + + ${html} +
TreeMakesMachinesModulesAlso makesPower
`; +} + +export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerState, selected: string | null, collapsed: Set): string { + const mode = state.tableMode; + const bar = `
+
+ +
+ ${mode === "tree" ? `` : `Each step once, with its full machine count.`} +
`; + return `
${bar}${mode === "tree" ? treeHtml(pd, result, state, selected, collapsed) : totalsHtml(pd, result, state, selected)}
`; +} + +function totalsHtml(pd: PlannerData, result: PlanResult, state: PlannerState, selected: string | null): string { const unit = state.unit; const steps = [...result.steps].sort((a, b) => a.depth - b.depth || b.machines - a.machines); const rows = steps.map((s) => { @@ -271,7 +400,7 @@ export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerSta `${sprite(f.item, 20)}${fmtRate(f.rate, unit)}`).join(""); const extra = s.outputs.filter((o) => o !== main); return ` - ${sprite(recipeIcon(s.recipe), 32)}
${fmtRate(main?.rate ?? 0, unit)}${UNIT_LABEL[unit]}${e(s.recipe.label)}
+
${sprite(recipeIcon(s.recipe), 32)}
${fmtRate(main?.rate ?? 0, unit)}${UNIT_LABEL[unit]}${e(s.recipe.label)}
${s.machine ? `${sprite(s.machine.icon, 28)}
${fmtMachines(s.machines)}
` : `—`} ${compressModules(s.modules).map(([m, c]) => `${sprite(m, 18)}${c > 1 ? `${c}` : ""}`).join("")}${s.beacons && s.beaconModule ? `${sprite("beacon", 18)}${s.beacons}` : ""} ${chips(s.inputs)} @@ -279,10 +408,10 @@ export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerSta ${s.power ? fmtPower(s.power + s.beaconPower) : s.fuel ? `${sprite(s.fuel.item, 16)}${fmtRate(s.fuel.rate, unit)}` : ""} `; }).join(""); - return `
+ return `
${rows} -
MakesMachinesModulesTakesAlso makesPower
`; + `; } /* ------------------------------------------------------------------- picker */ diff --git a/apps/site/src/tools/planner/planner.css b/apps/site/src/tools/planner/planner.css index 604de7d..000f9a5 100644 --- a/apps/site/src/tools/planner/planner.css +++ b/apps/site/src/tools/planner/planner.css @@ -692,10 +692,10 @@ .pl-table tr { cursor: pointer; } .pl-table tr:hover td { background: var(--row-hover); } .pl-table tr.is-selected td { background: #4f4636; } -.pl-t-out { display: flex; align-items: center; gap: 10px; } +.pl-t-cell { display: flex; align-items: center; gap: 10px; } .pl-t-out b { font: 600 16px var(--mono); color: var(--gold-strong); } .pl-t-out small { font: 500 11px var(--mono); color: var(--text-dim); margin-left: 1px; } -.pl-t-out span { display: block; font-size: 11px; color: var(--text-dim); } +.pl-t-label { display: block; font-size: 11px; color: var(--text-dim); } .pl-t-machine > * { vertical-align: middle; } .pl-t-machine { white-space: nowrap; } .pl-t-machine > div { display: inline-grid; gap: 3px; margin-left: 6px; min-width: 52px; } @@ -717,6 +717,69 @@ } .pl-t-power { font: 500 12px var(--mono); color: var(--text-dim); white-space: nowrap; } +.pl-table-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } +.pl-table-bar .pl-btn { padding: 4px 10px; font-size: 12px; } +.pl-tag { + display: inline-block; + padding: 1px 6px; + font: 600 9px/1.5 var(--mono); + letter-spacing: 0.14em; + text-transform: uppercase; + border-radius: 2px; + border: 1px dashed #5b5a58; + color: var(--text-dim); +} +.pl-tag.is-import { border-color: #4f6d8f; color: var(--link); } + +/* The tree: rows butt up against each other so the dotted lines join. */ +.pl-table.is-tree { border-spacing: 0; } +.pl-table.is-tree td { + border-top: 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.55); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + border-radius: 0; + padding-top: 5px; + padding-bottom: 5px; +} +.pl-table.is-tree td:first-child { border-radius: 0; } +.pl-table.is-tree .pl-t-out .pl-belts { display: inline-flex; margin-left: 4px; } +.pl-table.is-tree .pl-t-out .pl-belts span { font-size: 11px; } +.pl-table.is-tree .pl-t-tree { width: 1%; padding-right: 18px; } +.pl-table.is-tree .pl-t-out { min-width: 190px; white-space: nowrap; } +.pl-t-chev { width: 40px; padding: 0 0 0 6px !important; } +.pl-tree-toggle { + width: 28px; + height: 28px; + padding: 0; + font-size: 20px; + line-height: 1; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(0, 0, 0, 0.5); + box-shadow: none; + color: var(--text); + transition: transform 0.15s; +} +.pl-tree-toggle:hover { background: rgba(255, 255, 255, 0.1); } +.pl-tree-toggle.is-open { transform: rotate(90deg); } +.pl-t-tree { position: relative; white-space: nowrap; min-width: 60px; } +.pl-tree-icon { + position: relative; + z-index: 1; + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + background: #222; + border: 1px solid #0d0d0d; + border-radius: 3px; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06); +} +.pl-tl { position: absolute; pointer-events: none; border-color: rgba(230, 224, 216, 0.55); } +.pl-tl.is-v { top: 0; bottom: 0; border-left: 2px dotted; } +.pl-tl.is-v.is-half { bottom: 50%; } +.pl-tl.is-h { top: 50%; height: 0; border-top: 2px dotted; } +.pl-tl.is-stem { top: calc(50% + 19px); bottom: 0; border-left: 2px dotted; } + /* -------------------------------------------------------------- welcome */ .pl-welcome { position: absolute; inset: 0; display: grid; place-items: center; padding: 20px; overflow: auto; } diff --git a/apps/site/src/tools/planner/state.ts b/apps/site/src/tools/planner/state.ts index c8462cc..bde8bd8 100644 --- a/apps/site/src/tools/planner/state.ts +++ b/apps/site/src/tools/planner/state.ts @@ -6,12 +6,15 @@ import { DEFAULT_SETTINGS, type PlanSettings, type PlannerData } from "@factorio export type TimeUnit = "s" | "min" | "h"; export type View = "flow" | "table"; +/** The table as a FactorioLab-style tree, or one row per step. */ +export type TableMode = "tree" | "totals"; export interface PlannerState { /** Rates are stored per second; the unit only changes how they show. */ targets: { item: string; rate: number }[]; unit: TimeUnit; view: View; + tableMode: TableMode; belt: string; settings: PlanSettings; } @@ -25,6 +28,7 @@ export function defaultState(): PlannerState { targets: [], unit: "min", view: "flow", + tableMode: "tree", belt: "express-transport-belt", settings: structuredClone(DEFAULT_SETTINGS), }; @@ -44,6 +48,7 @@ function sanitise(raw: unknown, pd: PlannerData): PlannerState { } if (r.unit === "s" || r.unit === "min" || r.unit === "h") state.unit = r.unit; if (r.view === "flow" || r.view === "table") state.view = r.view; + if (r.tableMode === "tree" || r.tableMode === "totals") state.tableMode = r.tableMode; if (typeof r.belt === "string" && pd.belts.some((b) => b.name === r.belt)) state.belt = r.belt; const s = r.settings as Partial | undefined; if (s && typeof s === "object") { @@ -110,6 +115,7 @@ function compact(state: PlannerState): unknown { targets: state.targets, ...(state.unit !== d.unit ? { unit: state.unit } : {}), ...(state.view !== d.view ? { view: state.view } : {}), + ...(state.tableMode !== d.tableMode ? { tableMode: state.tableMode } : {}), ...(state.belt !== d.belt ? { belt: state.belt } : {}), ...(Object.keys(settings).length ? { settings } : {}), };