Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions apps/site/src/tools/planner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string>();
let modalKind: "picker" | "settings" | null = null;
let pickerGroup = "intermediate-products";
let pickerQuery = "";
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
Comment thread
malte9799 marked this conversation as resolved.
Resolved
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];
Expand Down
137 changes: 133 additions & 4 deletions apps/site/src/tools/planner/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<item>. */
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<string, PlanResult["flows"]>();
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<string>): 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()));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/** 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>): 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 ? `<i class="pl-tl is-v" style="left:${x(level)}px"></i>` : "")).join("");
if (depth > 0) {
lines += `<i class="pl-tl is-v${last ? " is-half" : ""}" style="left:${x(depth - 1)}px"></i>`;
lines += `<i class="pl-tl is-h" style="left:${x(depth - 1)}px;width:${TREE_INDENT - TREE_LINE + 6}px"></i>`;
}
if (open) lines += `<i class="pl-tl is-stem" style="left:${x(depth)}px"></i>`;
const icon = step ? recipeIcon(step.recipe) : r.item;
const chevron = r.children.length
? `<button type="button" class="pl-tree-toggle${open ? " is-open" : ""}" data-action="toggle-row" data-key="${e(r.key)}" aria-expanded="${open}" aria-label="${open ? "Collapse" : "Expand"}">›</button>`
: "";
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)}<div><b>${fmtMachines(machines)}</b><div class="pl-meter"><i style="width:${whole ? ((machines / whole) * 100).toFixed(0) : 0}%"></i></div></div>`
: r.node.startsWith("import:")
? `<span class="pl-tag is-import">Import</span>`
: `<span class="pl-dim">—</span>`;
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 += `<tr class="${r.node === selected ? "is-selected" : ""}" data-action="select" data-id="${e(r.node)}" style="--c:${itemColor(r.item)}">
<td class="pl-t-chev">${chevron}</td>
<td class="pl-t-tree" style="padding-left:${8 + depth * TREE_INDENT}px">${lines}<span class="pl-tree-icon">${sprite(icon, 32)}</span></td>
<td class="pl-t-out"><div><b>${fmtRate(r.rate, unit)}</b><small>${UNIT_LABEL[unit]}</small> ${beltTag(pd, state, r.item, r.rate)}<span class="pl-t-label">${e(label)}${r.loop ? " · loops back" : ""}</span></div></td>
<td class="pl-t-machine">${machineCell}</td>
<td class="pl-t-mods">${step ? compressModules(step.modules).map(([m, c]) => `<span class="pl-modchip">${sprite(m, 18)}${c > 1 ? `<i>${c}</i>` : ""}</span>`).join("") + (step.beacons && step.beaconModule ? `<span class="pl-modchip">${sprite("beacon", 18)}<i>${step.beacons}</i></span>` : "") : ""}</td>
<td class="pl-t-flows">${extra.map((f) => `<span class="pl-chip" style="--c:${itemColor(f.item)}" data-tip="${e(pd.items[f.item]?.label ?? f.item)}" data-tip-icon="${e(f.item)}">${sprite(f.item, 20)}${fmtRate(f.rate, unit)}</span>`).join("")}</td>
<td class="pl-t-power">${power ? fmtPower(power) : step?.fuel ? `${sprite(step.fuel.item, 16)}${fmtRate(step.fuel.rate * r.share, unit)}` : ""}</td>
</tr>`;
if (open) walk(r.children, depth + 1, depth > 0 ? [...guides, !last] : guides);
});
};
walk(buildTree(result), 0, []);
return `<table class="pl-table is-tree">
<thead><tr><th></th><th>Tree</th><th>Makes</th><th>Machines</th><th>Modules</th><th>Also makes</th><th>Power</th></tr></thead>
<tbody>${html}</tbody>
</table>`;
}

export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerState, selected: string | null, collapsed: Set<string>): string {
const mode = state.tableMode;
const bar = `<div class="pl-table-bar">
<div class="pl-seg" role="group" aria-label="Table layout">
<button type="button" data-action="table-mode" data-mode="tree" class="${mode === "tree" ? "is-on" : ""}">Tree</button><button type="button" data-action="table-mode" data-mode="totals" class="${mode === "totals" ? "is-on" : ""}">Totals</button>
</div>
${mode === "tree" ? `<button type="button" class="pl-btn" data-action="tree-expand">Expand all</button><button type="button" class="pl-btn" data-action="tree-collapse">Collapse all</button>` : `<span class="pl-dim">Each step once, with its full machine count.</span>`}
</div>`;
return `<div class="pl-table-wrap">${bar}${mode === "tree" ? treeHtml(pd, result, state, selected, collapsed) : totalsHtml(pd, result, state, selected)}</div>`;
}

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) => {
Expand All @@ -271,18 +400,18 @@ export function tableHtml(pd: PlannerData, result: PlanResult, state: PlannerSta
`<span class="pl-chip" style="--c:${itemColor(f.item)}" data-tip="${e(pd.items[f.item]?.label ?? f.item)}" data-tip-sub="${e(beltText(pd, state, f.item, f.rate))}" data-tip-icon="${e(f.item)}">${sprite(f.item, 20)}${fmtRate(f.rate, unit)}</span>`).join("");
const extra = s.outputs.filter((o) => o !== main);
return `<tr class="${s.id === selected ? "is-selected" : ""}" data-action="select" data-id="${e(s.id)}" style="--c:${itemColor(s.item)}">
<td class="pl-t-out">${sprite(recipeIcon(s.recipe), 32)}<div><b>${fmtRate(main?.rate ?? 0, unit)}</b><small>${UNIT_LABEL[unit]}</small><span>${e(s.recipe.label)}</span></div></td>
<td class="pl-t-out"><div class="pl-t-cell">${sprite(recipeIcon(s.recipe), 32)}<div><b>${fmtRate(main?.rate ?? 0, unit)}</b><small>${UNIT_LABEL[unit]}</small><span class="pl-t-label">${e(s.recipe.label)}</span></div></div></td>
<td class="pl-t-machine">${s.machine ? `${sprite(s.machine.icon, 28)}<div><b>${fmtMachines(s.machines)}</b><div class="pl-meter"><i style="width:${(util * 100).toFixed(0)}%"></i></div></div>` : `<span class="pl-dim">—</span>`}</td>
<td class="pl-t-mods">${compressModules(s.modules).map(([m, c]) => `<span class="pl-modchip">${sprite(m, 18)}${c > 1 ? `<i>${c}</i>` : ""}</span>`).join("")}${s.beacons && s.beaconModule ? `<span class="pl-modchip">${sprite("beacon", 18)}<i>${s.beacons}</i></span>` : ""}</td>
<td class="pl-t-flows">${chips(s.inputs)}</td>
<td class="pl-t-flows">${chips(extra)}</td>
<td class="pl-t-power">${s.power ? fmtPower(s.power + s.beaconPower) : s.fuel ? `${sprite(s.fuel.item, 16)}${fmtRate(s.fuel.rate, unit)}` : ""}</td>
</tr>`;
}).join("");
return `<div class="pl-table-wrap"><table class="pl-table">
return `<table class="pl-table">
<thead><tr><th>Makes</th><th>Machines</th><th>Modules</th><th>Takes</th><th>Also makes</th><th>Power</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
</table>`;
}

/* ------------------------------------------------------------------- picker */
Expand Down
67 changes: 65 additions & 2 deletions apps/site/src/tools/planner/planner.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }
Expand Down
6 changes: 6 additions & 0 deletions apps/site/src/tools/planner/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -25,6 +28,7 @@ export function defaultState(): PlannerState {
targets: [],
unit: "min",
view: "flow",
tableMode: "tree",
belt: "express-transport-belt",
settings: structuredClone(DEFAULT_SETTINGS),
};
Expand All @@ -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<PlanSettings> | undefined;
if (s && typeof s === "object") {
Expand Down Expand Up @@ -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 } : {}),
};
Expand Down
Loading