Phase 4: rules mode, score panel, overlays, drag-time scoring

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 13:03:47 -04:00
parent 5d7df0187a
commit eb04a658b8
7 changed files with 416 additions and 16 deletions
+3 -1
View File
@@ -1,11 +1,13 @@
import { Group, Rect } from "react-konva";
import type { Shape } from "../types";
export type Outline = "normal" | "selected" | "valid" | "invalid";
export type Outline = "normal" | "selected" | "valid" | "invalid" | "flagged" | "highlight";
const STROKE: Record<Outline, string> = {
normal: "#3b3b3b",
selected: "#1c7ed6",
flagged: "#f08c00",
highlight: "#d6336c",
valid: "#2f9e44",
invalid: "#e03131",
};
+3
View File
@@ -20,6 +20,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode)
<button style={seg("room")} onClick={() => onChange("room")}>
Room
</button>
<button style={seg("rules")} onClick={() => onChange("rules")}>
Rules
</button>
</div>
);
}
+38 -8
View File
@@ -13,6 +13,7 @@ import {
wallLength,
wallRect,
} from "../geometry";
import type { ClearanceSegment, Polyline, SightLine } from "../scoring/types";
import type { Door, FurnitureItem, FurnitureTypeConfig, Obstacle, Rect as RectM, Room as RoomT, RoomScreen, Shape, Wall } from "../types";
import { FurnitureShape, type Outline } from "./FurnitureShape";
@@ -26,10 +27,20 @@ export interface Preview {
}
export const WALL_PX = 6; // drawn wall thickness around the floor
export type Mode = "layout" | "room";
export type Mode = "layout" | "room" | "rules";
export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string };
export interface Overlays {
paths: Polyline[] | null; // loading paths from the door
sightLines: SightLine[] | null;
clearance: ClearanceSegment[] | null;
}
interface Props {
flaggedIds: ReadonlySet<string>; // Required-rule violations (orange)
highlightIds: ReadonlySet<string>; // clicked issue
overlays: Overlays;
onDragPreview: (p: { id: string; x: number; y: number } | null) => void;
mode: Mode;
roomSel: RoomSel | null;
onSelectRoom: (sel: RoomSel | null) => void;
@@ -164,8 +175,9 @@ function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }
);
}
export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstacle, room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
export function Room({ flaggedIds, highlightIds, overlays, onDragPreview, mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstacle, room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
const interactive = mode === "layout";
const roomEdit = mode === "room";
const [drag, setDrag] = useState<DragInfo | null>(null);
const [resize, setResize] = useState<ResizeInfo | null>(null);
const resizeStart = useRef<RectM | null>(null);
@@ -313,8 +325,8 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
stroke={state}
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
cornerRadius={2}
listening={!interactive}
draggable={!interactive}
listening={roomEdit}
draggable={roomEdit}
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
dragBoundFunc={(pos) => ({
@@ -337,7 +349,7 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c && !interactive) c.style.cursor = "grab";
if (c && roomEdit) c.style.cursor = "grab";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
@@ -355,7 +367,13 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
? resize.valid ? "valid" : "invalid"
: drag?.id === item.id
? drag.valid ? "valid" : "invalid"
: selectedId === item.id ? "selected" : "normal";
: selectedId === item.id
? "selected"
: highlightIds.has(item.id)
? "highlight"
: flaggedIds.has(item.id)
? "flagged"
: "normal";
const half = boundsOf({ ...r, rotation: item.rotation });
const wpx = r.width * pxPerM;
const hpx = r.height * pxPerM;
@@ -386,6 +404,7 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
onDragMove={(e) => {
const c = candidate(item, e.target);
setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) });
onDragPreview({ id: item.id, x: c.x, y: c.y });
}}
onDragEnd={(e) => {
const node = e.target;
@@ -395,6 +414,7 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
if (ok) onMove(item.id, c.x, c.y);
setDrag(null);
onDragPreview(null);
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
@@ -465,16 +485,26 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
/>
));
})}
{!interactive &&
{roomEdit &&
room.doors.map((d) => {
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? wallDrag.offset : d.offset;
return wallHit("door", d.id, d.wall, live, d.width, doorSwingRect(room, { ...d, offset: live }));
})}
{!interactive &&
{roomEdit &&
room.screens.map((sc) => {
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? wallDrag.offset : sc.offset;
return wallHit("screen", sc.id, sc.wall, live, sc.width, wallRect(room, sc.wall, live, sc.width, SCREEN_DEPTH_M));
})}
{/* score overlays (toggled in the score panel) */}
{overlays.paths?.map((p, n) => (
<Line key={`p${n}`} points={p.points.flatMap((q) => [q.x * pxPerM, q.y * pxPerM])} stroke="#1c7ed6" strokeWidth={2} opacity={0.55} dash={[6, 4]} listening={false} />
))}
{overlays.sightLines?.map((l, n) => (
<Line key={`s${n}`} points={[l.from.x * pxPerM, l.from.y * pxPerM, l.to.x * pxPerM, l.to.y * pxPerM]} stroke={l.blocked ? BAD : GOOD} strokeWidth={l.blocked ? 1.5 : 1} opacity={l.blocked ? 0.9 : 0.45} listening={false} />
))}
{overlays.clearance?.map((c, n) => (
<Line key={`c${n}`} points={[c.from.x * pxPerM, c.from.y * pxPerM, c.to.x * pxPerM, c.to.y * pxPerM]} stroke={c.ok ? GOOD : BAD} strokeWidth={5} lineCap="round" opacity={0.85} listening={false} />
))}
{preview && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
+141
View File
@@ -0,0 +1,141 @@
import type { FurnitureTypeConfig, Priority, Rule, RuleKind } from "../types";
import { describeRule, type Violation } from "../scoring";
import { Wheel } from "./Wheel";
import { btn, hint, label, panelRoot, title } from "./panelStyles";
interface Props {
rules: Rule[];
configs: Record<string, FurnitureTypeConfig>;
typeOrder: string[];
violations: Violation[];
onAdd: () => void;
onUpdate: (id: string, patch: Partial<Rule>) => void;
onRemove: (id: string) => void;
}
const KINDS: [RuleKind, string][] = [
["keepApart", "Keep apart"],
["keepNear", "Keep near"],
["screenDistance", "Screen distance"],
["doorDistance", "Door distance"],
["againstWall", "Against wall"],
];
const PRIORITIES: [Priority, string][] = [
["required", "Required"],
["high", "High"],
["medium", "Medium"],
["low", "Low"],
];
const fmt = (v: number) => v.toFixed(1);
const select: React.CSSProperties = {
height: 30,
minWidth: 0,
width: "100%",
border: "1px solid #c4c4c4",
borderRadius: 6,
background: "#fff",
font: "13px system-ui, sans-serif",
};
export function RulesPanel({ rules, configs, typeOrder, violations, onAdd, onUpdate, onRemove }: Props) {
const typeSelect = (value: string | undefined, onChange: (v: string) => void) => (
<select style={select} value={value} onChange={(e) => onChange(e.target.value)}>
{typeOrder.map((t) => (
<option key={t} value={t}>
{configs[t].name}
</option>
))}
</select>
);
return (
<div style={panelRoot}>
<div style={title}>Placement rules</div>
<div style={{ ...hint, marginTop: -8 }}>
Rules only measure and flag. Required rules outline broken items in orange but never block dragging.
</div>
{rules.length === 0 && <div style={hint}>No rules yet.</div>}
{rules.map((r) => {
const broken = new Set(violations.filter((v) => v.ruleId === r.id).flatMap((v) => v.itemIds)).size;
const needsB = r.kind === "keepApart" || r.kind === "keepNear";
return (
<div key={r.id} style={{ background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 6 }}>
<select style={select} value={r.kind} onChange={(e) => onUpdate(r.id, { kind: e.target.value as RuleKind, typeB: r.typeB ?? r.typeA, min: r.min ?? 1, max: r.max ?? 6 })}>
{KINDS.map(([k, t]) => (
<option key={k} value={k}>
{t}
</option>
))}
</select>
<button style={{ ...btn, padding: "0 10px", color: "#e03131" }} onClick={() => onRemove(r.id)} aria-label="Delete rule">
✕
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: needsB ? "1fr 1fr" : "1fr", gap: 6 }}>
{typeSelect(r.typeA, (v) => onUpdate(r.id, { typeA: v }))}
{needsB && typeSelect(r.typeB, (v) => onUpdate(r.id, { typeB: v }))}
</div>
{r.kind !== "againstWall" && (
<div style={{ display: "grid", gridTemplateColumns: r.kind === "screenDistance" ? "1fr 1fr" : "1fr", gap: 8 }}>
{r.kind !== "keepNear" && (
<div>
<div style={hint}>{r.kind === "screenDistance" ? "Min (m)" : "At least (m)"}</div>
<Wheel
value={r.min ?? 1}
min={0}
max={30}
step={0.1}
rows={3}
format={fmt}
onChange={(v) => onUpdate(r.id, r.kind === "screenDistance" && v > (r.max ?? 6) ? { min: v, max: v } : { min: v })}
/>
</div>
)}
{(r.kind === "keepNear" || r.kind === "screenDistance") && (
<div>
<div style={hint}>{r.kind === "keepNear" ? "Within (m)" : "Max (m)"}</div>
<Wheel
value={r.max ?? 6}
min={0}
max={30}
step={0.1}
rows={3}
format={fmt}
onChange={(v) => onUpdate(r.id, r.kind === "screenDistance" && v < (r.min ?? 1) ? { max: v, min: v } : { max: v })}
/>
</div>
)}
</div>
)}
<div>
<div style={label}>Priority</div>
<select style={select} value={r.priority} onChange={(e) => onUpdate(r.id, { priority: e.target.value as Priority })}>
{PRIORITIES.map(([p, t]) => (
<option key={p} value={p}>
{t}
</option>
))}
</select>
</div>
<div style={{ ...hint, marginTop: 0, color: broken ? "#c05621" : "#888" }}>
{describeRule(r, configs)}
{broken ? ` · ${broken} item${broken > 1 ? "s" : ""} broken` : ""}
</div>
</div>
);
})}
<button style={btn} onClick={onAdd}>
+ Add rule
</button>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useState } from "react";
import type { LayoutScore, ScoreKey } from "../scoring";
import type { ScoreWeights } from "../types";
import { Wheel } from "./Wheel";
export interface OverlayToggles {
loading: boolean;
sight: boolean;
clearance: boolean;
}
interface Props {
result: LayoutScore;
overall: number | null;
weights: ScoreWeights;
overlays: OverlayToggles;
onWeight: (key: ScoreKey, value: number) => void;
onToggleOverlay: (key: keyof OverlayToggles) => void;
onHighlight: (itemIds: string[]) => void;
}
const LABELS: Record<ScoreKey, string> = { loading: "Loading", screen: "Screen", access: "Access", comfort: "Comfort", rules: "Rules" };
const KEYS = Object.keys(LABELS) as ScoreKey[];
const tint = (v: number) => (v >= 0.75 ? "#2f9e44" : v >= 0.45 ? "#f08c00" : "#e03131");
const small: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#666" };
export function ScorePanel({ result, overall, weights, overlays, onWeight, onToggleOverlay, onHighlight }: Props) {
const [open, setOpen] = useState(true);
const required = result.issues.filter((i) => i.text.startsWith("Required:"));
const others = result.issues.filter((i) => !i.text.startsWith("Required:")).slice(0, 5);
const issueBtn = (color: string): React.CSSProperties => ({
textAlign: "left",
border: "none",
background: "none",
padding: "2px 0",
cursor: "pointer",
font: "12px system-ui, sans-serif",
color,
});
return (
<div style={{ gridColumn: "1 / -1", background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 8 }}>
<button
onClick={() => setOpen(!open)}
style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", border: "none", background: "none", padding: 0, cursor: "pointer" }}
>
<span style={{ font: "600 13px system-ui, sans-serif", color: "#444" }}>{open ? "▾" : "▸"} Score</span>
<span style={{ font: "700 22px system-ui, sans-serif", color: overall === null ? "#999" : tint(overall / 100) }}>
{overall === null ? "n/a" : Math.round(overall)}
{overall !== null && <span style={{ ...small, fontWeight: 400 }}> / 100</span>}
</span>
</button>
{open && (
<>
{KEYS.map((k) => {
const v = result.scores[k];
return (
<div key={k} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 52, ...small }}>{LABELS[k]}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: 8, background: "#eee", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${(v ?? 0) * 100}%`, height: "100%", background: v === null ? "transparent" : tint(v) }} />
</div>
<div style={{ ...small, fontSize: 11 }}>{v === null ? "n/a" : `${Math.round(v * 100)}%`}</div>
</div>
<div style={{ width: 50, flexShrink: 0 }} title="Priority 0-10">
<Wheel value={weights[k]} min={0} max={10} rows={1} onChange={(n) => onWeight(k, n)} />
</div>
</div>
);
})}
<div style={{ ...small, fontSize: 11 }}>Right-hand wheels set each score's priority (0-10).</div>
{required.length > 0 && (
<div>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#f08c00" }}>Required rules broken</div>
{required.map((i, n) => (
<button key={n} style={issueBtn("#c05621")} onClick={() => onHighlight(i.itemIds)}>
{i.text.replace("Required: ", "")}
</button>
))}
</div>
)}
{others.length > 0 && (
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#555" }}>Top issues</div>
{others.map((i, n) => (
<button key={n} style={issueBtn("#333")} onClick={() => onHighlight(i.itemIds)}>
• {i.text}
</button>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 2, borderTop: "1px solid #eee", paddingTop: 6 }}>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#555" }}>Overlays</div>
{(
[
["loading", "Loading paths"],
["sight", "Sight lines"],
["clearance", "Table clearance"],
] as const
).map(([key, text]) => (
<label key={key} style={{ ...small, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
<input type="checkbox" checked={overlays[key]} onChange={() => onToggleOverlay(key)} />
{text}
</label>
))}
</div>
</>
)}
</div>
);
}