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 } : {}), };