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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user