Phase 5: simulated-annealing optimizer in a Web Worker, auto-fill, undo/redo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:18:55 -04:00
parent 53de44c3db
commit 70c2199d87
20 changed files with 1091 additions and 69 deletions
+4 -2
View File
@@ -2,7 +2,6 @@ import type { Mode } from "./Room";
export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const seg = (m: Mode): React.CSSProperties => ({
flex: 1,
height: 30,
border: "none",
borderRadius: 6,
@@ -13,7 +12,7 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode)
boxShadow: mode === m ? "0 1px 3px rgba(0,0,0,0.15)" : "none",
});
return (
<div style={{ gridColumn: "1 / -1", display: "flex", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
<div style={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(54px, 1fr))", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
<button style={seg("layout")} onClick={() => onChange("layout")}>
Layout
</button>
@@ -23,6 +22,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode)
<button style={seg("rules")} onClick={() => onChange("rules")}>
Rules
</button>
<button style={seg("optimize")} onClick={() => onChange("optimize")}>
Optimize
</button>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
import type { OptimizerState } from "../optimizer/useOptimizer";
import type { ScoreKey } from "../scoring";
import { Wheel } from "./Wheel";
import { btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
export interface OptimizeFormValues {
durationS: number;
seatsNeeded: number;
seed: string; // blank = random
onlyNew: boolean;
}
interface Props {
values: OptimizeFormValues;
onChange: (patch: Partial<OptimizeFormValues>) => void;
state: OptimizerState;
lockedCount: number;
canUndo: boolean;
canRedo: boolean;
onStart: () => void;
onCancel: () => void;
onApply: () => void;
onRevert: () => void;
onUndo: () => void;
onRedo: () => void;
}
const ROWS: [ScoreKey, string][] = [
["loading", "Loading"],
["screen", "Screen"],
["access", "Access"],
["comfort", "Comfort"],
["rules", "Rules"],
];
const pct = (v: number | null) => (v === null ? "n/a" : String(Math.round(v * 100)));
const num = (v: number | null) => (v === null ? "n/a" : String(Math.round(v)));
export function OptimizePanel({ values, onChange, state, lockedCount, canUndo, canRedo, onStart, onCancel, onApply, onRevert, onUndo, onRedo }: Props) {
const running = state.status === "running";
const done = state.status === "done" ? state.result : null;
const locked = lockedCount > 0 ? `${lockedCount} locked item${lockedCount > 1 ? "s" : ""} will not move.` : "";
const delta = (before: number | null, after: number | null, scale: number) => {
if (before === null || after === null) return { text: "", color: "#888" };
const d = (after - before) * scale;
return { text: `${d >= 0 ? "+" : ""}${Math.round(d)}`, color: d > 0.5 ? "#2f9e44" : d < -0.5 ? "#e03131" : "#888" };
};
return (
<div style={panelRoot}>
<div style={title}>Optimize</div>
<div style={twoCol}>
<div>
<div style={label}>Duration (s)</div>
<Wheel value={values.durationS} min={5} max={60} rows={3} disabled={running} onChange={(v) => onChange({ durationS: v })} />
</div>
<div>
<div style={label}>Seats needed</div>
<Wheel value={values.seatsNeeded} min={0} max={999} rows={3} disabled={running} format={(v) => (v === 0 ? "off" : String(v))} onChange={(v) => onChange({ seatsNeeded: v })} />
</div>
</div>
<div>
<div style={label}>Seed</div>
<input
value={values.seed}
disabled={running}
placeholder="random"
inputMode="numeric"
onChange={(e) => onChange({ seed: e.target.value.replace(/[^0-9]/g, "").slice(0, 9) })}
style={{ width: "100%", boxSizing: "border-box", height: 30, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "13px system-ui, sans-serif" }}
/>
</div>
<label style={{ ...hint, marginTop: 0, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
<input type="checkbox" checked={values.onlyNew} disabled={running} onChange={(e) => onChange({ onlyNew: e.target.checked })} />
Only place remaining inventory (existing items stay put)
</label>
{locked && <div style={{ ...hint, marginTop: 0 }}>{locked}</div>}
{running ? (
<>
<div>
<div style={{ height: 10, background: "#ddd", borderRadius: 5, overflow: "hidden" }}>
<div style={{ width: `${Math.round(state.fraction * 100)}%`, height: "100%", background: "#1c7ed6", transition: "width 200ms linear" }} />
</div>
<div style={{ ...hint, display: "flex", justifyContent: "space-between" }}>
<span>Best score: {state.best === null ? "n/a" : state.best.toFixed(1)}</span>
<span>{state.iterations.toLocaleString()} steps</span>
</div>
</div>
<button style={{ ...btn, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onCancel}>
Cancel
</button>
</>
) : (
<button style={{ ...btn, background: "#2b2b2b", color: "#fff" }} onClick={onStart}>
{done ? "Optimize again" : "Optimize"}
</button>
)}
{done && (
<div style={{ background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr auto auto auto", columnGap: 8, rowGap: 3, alignItems: "baseline", font: "12px system-ui, sans-serif", color: "#444" }}>
<span />
<span style={{ color: "#888" }}>before</span>
<span style={{ color: "#888" }}>after</span>
<span />
<b>Overall</b>
<span>{num(done.before.overallWeighted)}</span>
<b>{num(done.after.overallWeighted)}</b>
<span style={{ color: delta(done.before.overallWeighted, done.after.overallWeighted, 1).color }}>{delta(done.before.overallWeighted, done.after.overallWeighted, 1).text}</span>
{ROWS.map(([k, name]) => (
<Row key={k} name={name} before={done.before.scores[k]} after={done.after.scores[k]} d={delta(done.before.scores[k], done.after.scores[k], 100)} />
))}
</div>
{done.autofill && <div style={{ ...hint, marginTop: 4 }}>{done.autofill.message}</div>}
{done.remainingViolations > 0 && (
<div style={{ ...hint, color: "#c05621", marginTop: 0 }}>
{done.remainingViolations} Required rule violation{done.remainingViolations > 1 ? "s" : ""} could not be fixed.
</div>
)}
<div style={{ ...hint, marginTop: 0 }}>
{done.skippedLocked} locked item{done.skippedLocked === 1 ? "" : "s"} skipped · seed {done.seed} · {done.iterations.toLocaleString()} steps
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, background: "#2b2b2b", color: "#fff" }} onClick={onApply}>
Apply
</button>
<button style={{ ...btn, flex: 1 }} onClick={onRevert}>
Revert
</button>
</div>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, opacity: canUndo ? 1 : 0.45 }} disabled={!canUndo || running} onClick={onUndo}>
↶ Undo
</button>
<button style={{ ...btn, flex: 1, opacity: canRedo ? 1 : 0.45 }} disabled={!canRedo || running} onClick={onRedo}>
↷ Redo
</button>
</div>
</div>
);
}
function Row({ name, before, after, d }: { name: string; before: number | null; after: number | null; d: { text: string; color: string } }) {
return (
<>
<span>{name}</span>
<span>{pct(before)}</span>
<span>{pct(after)}</span>
<span style={{ color: d.color }}>{d.text}</span>
</>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export interface Preview {
}
export const WALL_PX = 6; // drawn wall thickness around the floor
export type Mode = "layout" | "room" | "rules";
export type Mode = "layout" | "room" | "rules" | "optimize";
export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string };
export interface Overlays {