diff --git a/CLAUDE.md b/CLAUDE.md index f749786..1f36452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ A web tool for venues (events, banquets, conferences) to lay out furniture in a room and, eventually, auto-optimize the layout. -## Current scope: Phases 1-3 (UI only) +## Current scope: Phases 1-4 (UI + scoring, no optimizer) Layout and editing UI. No optimizer yet. @@ -29,7 +29,16 @@ Layout and editing UI. No optimizer yet. - Keep-clear zones: the door swing square and the 0.6 m strip inside the opening, the 0.3 m strip under a wall screen, and obstacles. `getBlockedRects(room)` returns them all and `isValidPlacement` reads it, so furniture can't overlap them (red outline, drop blocked). Doors/screens can't sit on furniture or obstacles or share wall length; obstacles can't overlap furniture, each other, or door/screen zones. - Export downloads `room.json` (width, height, doors, screens, obstacles; no furniture). Import validates with `parseRoom` (`roomSchema.ts`) and shows a clear error; if furniture is placed it asks for confirmation first, and if the placed furniture doesn't fit the imported room it clears all furniture and warns. -Out of scope: non-rectangular rooms, floor plan image import, phone scanning, placement rules, scoring, optimization. Do not design in a way that blocks the roadmap below. +**Rules and scoring (Phase 4)** - measures the layout only; there is NO optimizer and nothing moves automatically. +- All scoring is pure TS in `src/scoring/` (no React/Konva): `scoreLayout(room, items, configs, rules, weights, {debug})` returns the five sub-scores (0..1 or `null` = n/a), the 0-100 overall (`overallScore`, weighted average of the non-null scores by the 0-10 priority weights), issues (text + item ids), rule violations and optional overlay data. Roughly 12 ms for a 10x8 room, 85 ms for 60x60 with 120 items. +- `seats.ts` `getSeatPoints`: N points evenly around a table's perimeter (seatsOverride, else type default); standalone seating = its center, or `seats` points along its long side; a single chair within 0.8 m of a table edge is that table's seat and isn't counted again. +- `occupancy.ts`: internal 0.1 m grid (never drawn, nothing snaps to it) from `getBlockedRects` + the true rotated furniture, with a chamfer clearance transform and a Dial's-algorithm `reachFrom`. Cached per variant and rebuilt only when the room or items arrays change. Door keep-clear zones are walkable for pathfinding. +- loadingScore: paths from loading doors (any door if none is marked) through >= 0.9 m clear width; 0.6 * fraction of items reachable + 0.4 * (1 - normalized avg path length). screenScore: per seat, angle off the screen normal (ideal <= 45 deg), distance (ideal 1.5-6x screen width), obstacles block line of sight (furniture doesn't); null with no screen. accessScore: per table, fraction of perimeter with 0.6 m free clearance reachable from a door; null with no tables. comfortScore: seat spacing (0.9 m), door distance (1.5 m), not flush to obstacles, blended with crowding (> 0.9 seats/m2). +- Rules mode (third tab): list editor for keepApart, keepNear, screenDistance, doorDistance, againstWall, each with a priority (Required, High 3, Medium 2, Low 1). `evaluateRules` returns violations (rule id, item ids, meters off); `rulesScore = 1 - weighted violated fraction` over non-required rules. Required violations outline items in orange and are listed in the score panel but never block dragging. +- Score panel (top of the sidebar in Layout mode, collapsible): overall number, five labeled bars with priority wheels, required violations, top issues (click to highlight items), and overlay toggles (loading paths, sight lines, table clearance; off by default). Weights and rules live in the store. During a drag the score recomputes at most ~10x/s. +- Tests: `npm test` (vitest) covers seat points, empty room, reachable vs walled-off table, blocked line of sight, comfort, rules and the weighted overall. + +Out of scope: the optimizer, auto-moving anything, non-rectangular rooms, floor plan image import, phone scanning, placement rules, scoring, optimization. Do not design in a way that blocks the roadmap below. ## Tech stack @@ -74,7 +83,9 @@ interface FurnitureTypeConfig { // seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats) ``` -Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): `{ room, items, configs: Record, typeOrder: id[] }`. Defaults live in the configs (chair 0.5x0.5, table 1.8x0.8 w/ 8 seats, screen 2x0.2). Chairs and table seats are independent. +Rules/weights: `type Priority = "required"|"high"|"medium"|"low"`; `Rule { id; kind: keepApart|keepNear|screenDistance|doorDistance|againstWall; priority; typeA; typeB?; min?; max? }`; `ScoreWeights { loading, screen, access, comfort, rules }` (0-10, default 5). + +Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): `{ room, items, configs: Record, typeOrder: id[], rules, weights }`. Defaults live in the configs (chair 0.5x0.5, table 1.8x0.8 w/ 8 seats, screen 2x0.2). Chairs and table seats are independent. ## Conventions @@ -87,8 +98,9 @@ Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): ## File layout - `src/types.ts`; `src/geometry.ts` (pure: `boundsOf`, `clampToRoom`, `overlaps`, `isValidPlacement`, `tryUpdate`, `resizeLength`, `fitScale`, plus room rules: `getBlockedRects`, `isValidWallItem`, `isValidObstacle`, `isLayoutValid`, `minRoomSize`, `tryResizeRoom`, `findWallSpot`, `findObstacleSpot`); `src/roomSchema.ts` (pure: `parseRoom`, `exportRoom`); `src/inventory.ts` (pure: `placedCount`, `remaining`, `effectiveSeats`); `src/store.ts` (reducer + built-in configs). +- `src/scoring/`: `index.ts` (`scoreLayout`, `overallScore`), `seats.ts`, `occupancy.ts`, `loading.ts`, `screen.ts`, `access.ts`, `comfort.ts`, `rules.ts`, `geom.ts`, `types.ts`, `scoring.test.ts`. - `src/App.tsx`: owns the store, selection, sidebar mode, palette drag, room scale, Delete key. -- `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `ModeToggle`, `RoomPanels`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`. +- `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `ModeToggle`, `RoomPanels`, `ScorePanel`, `RulesPanel`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`. ## Roadmap (not built yet, don't block) @@ -115,6 +127,6 @@ npx tsc --noEmit # type check ## Working agreement -- Ask before expanding scope past Phase 3. +- Ask before expanding scope past Phase 4. Commit and push after each finished change (a teammate works in the same repo): fetch first, rebase if needed. Don't commit `dist/` or `node_modules/` changes. - After changes, make sure the app builds and type checks. - Hackathon pace: prefer simple and working over clever. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index b03fba8..d3b0fb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,12 +16,15 @@ import { wallLength, } from "./geometry"; import { exportRoom, parseRoom } from "./roomSchema"; +import { overallScore, scoreLayout, type ScoreKey } from "./scoring"; import { initialState, reducer } from "./store"; -import type { Door, FurnitureItem, FurnitureType, Obstacle, Rect, Room as RoomT, RoomScreen, Wall } from "./types"; +import type { Door, FurnitureItem, FurnitureType, Obstacle, Rect, Room as RoomT, RoomScreen, Rule, Wall } from "./types"; import { placedCount, remaining } from "./inventory"; import { Palette } from "./components/Palette"; import { Room, WALL_PX, type Mode, type Preview, type RoomSel } from "./components/Room"; import { ModeToggle } from "./components/ModeToggle"; +import { ScorePanel, type OverlayToggles } from "./components/ScorePanel"; +import { RulesPanel } from "./components/RulesPanel"; import { DoorPanel, ObstaclePanel, RoomPanel, ScreenPanel } from "./components/RoomPanels"; import { ScaleBar } from "./components/ScaleBar"; import { TypeMenu } from "./components/TypeMenu"; @@ -53,6 +56,13 @@ export default function App() { const [mode, setMode] = useState("layout"); const [roomSel, setRoomSel] = useState(null); const [roomNotice, setRoomNotice] = useState(""); + const [overlayToggles, setOverlayToggles] = useState({ loading: false, sight: false, clearance: false }); + const [highlightIds, setHighlightIds] = useState>(new Set()); + const [dragPreview, setDragPreview] = useState<{ id: string; x: number; y: number } | null>(null); + const highlightTimer = useRef(0); + const previewTimer = useRef(0); + const lastPreviewAt = useRef(0); + const latestPreview = useRef<{ id: string; x: number; y: number } | null>(null); const nextItemId = useRef(1); const nextCustomId = useRef(1); @@ -321,6 +331,59 @@ export default function App() { setSidebar({ kind: "tiles" }); }; + // ---- Scoring (pure; recomputed when the layout changes, <= ~10x/s while dragging) ---- + const onDragPreview = useCallback((p: { id: string; x: number; y: number } | null) => { + latestPreview.current = p; + if (p === null) { + window.clearTimeout(previewTimer.current); + previewTimer.current = 0; + setDragPreview(null); + return; + } + const wait = 100 - (performance.now() - lastPreviewAt.current); + if (wait <= 0) { + lastPreviewAt.current = performance.now(); + setDragPreview(p); + } else if (!previewTimer.current) { + previewTimer.current = window.setTimeout(() => { + previewTimer.current = 0; + lastPreviewAt.current = performance.now(); + setDragPreview(latestPreview.current); + }, wait); + } + }, []); + + const scoreItems = useMemo( + () => (dragPreview ? state.items.map((i) => (i.id === dragPreview.id ? { ...i, x: dragPreview.x, y: dragPreview.y } : i)) : state.items), + [state.items, dragPreview], + ); + const wantDebug = mode === "layout" && (overlayToggles.loading || overlayToggles.sight || overlayToggles.clearance); + const layoutScore = useMemo( + () => (mode === "room" ? null : scoreLayout(state.room, scoreItems, state.configs, state.rules, state.weights, { debug: wantDebug })), + // weights only change the overall number, not the sub-scores + // eslint-disable-next-line react-hooks/exhaustive-deps + [mode === "room", state.room, scoreItems, state.configs, state.rules, wantDebug], + ); + const overall = layoutScore ? overallScore(layoutScore.scores, state.weights) : null; + const flaggedIds = useMemo( + () => new Set((layoutScore?.violations ?? []).filter((v) => v.priority === "required").flatMap((v) => v.itemIds)), + [layoutScore], + ); + const overlays = { + paths: mode === "layout" && overlayToggles.loading ? (layoutScore?.debug?.paths ?? null) : null, + sightLines: mode === "layout" && overlayToggles.sight ? (layoutScore?.debug?.sightLines ?? null) : null, + clearance: mode === "layout" && overlayToggles.clearance ? (layoutScore?.debug?.clearance ?? null) : null, + }; + const highlight = (ids: string[]) => { + window.clearTimeout(highlightTimer.current); + setHighlightIds(new Set(ids)); + highlightTimer.current = window.setTimeout(() => setHighlightIds(new Set()), 3000); + }; + const addRule = () => { + const rule: Rule = { id: uid("rule"), kind: "keepApart", priority: "medium", typeA: "table", typeB: "table", min: 1.5 }; + dispatch({ kind: "addRule", rule }); + }; + const remainingByType = Object.fromEntries( state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]), ); @@ -352,6 +415,18 @@ export default function App() { onExport={exportRoomFile} onImport={importRoomFile} /> ); } + } else if (mode === "rules") { + panel = ( + dispatch({ kind: "updateRule", id, patch })} + onRemove={(id) => dispatch({ kind: "removeRule", id })} + /> + ); } else if (selectedItem) { panel = ( openSidebar({ kind: "type", id })} onNewCustom={() => openSidebar({ kind: "custom" })} - header={} + header={ + <> + + {mode === "layout" && layoutScore && ( + dispatch({ kind: "setWeight", key, value })} + onToggleOverlay={(k) => setOverlayToggles((o) => ({ ...o, [k]: !o[k] }))} + onHighlight={highlight} + /> + )} + + } panel={panel} />
{ diff --git a/src/components/FurnitureShape.tsx b/src/components/FurnitureShape.tsx index 1a330ce..aaa04ce 100644 --- a/src/components/FurnitureShape.tsx +++ b/src/components/FurnitureShape.tsx @@ -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 = { normal: "#3b3b3b", selected: "#1c7ed6", + flagged: "#f08c00", + highlight: "#d6336c", valid: "#2f9e44", invalid: "#e03131", }; diff --git a/src/components/ModeToggle.tsx b/src/components/ModeToggle.tsx index 6bd6475..12868e0 100644 --- a/src/components/ModeToggle.tsx +++ b/src/components/ModeToggle.tsx @@ -20,6 +20,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) + ); } diff --git a/src/components/Room.tsx b/src/components/Room.tsx index 2bae964..a642f40 100644 --- a/src/components/Room.tsx +++ b/src/components/Room.tsx @@ -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; // Required-rule violations (orange) + highlightIds: ReadonlySet; // 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(null); const [resize, setResize] = useState(null); const resizeStart = useRef(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) => ( + [q.x * pxPerM, q.y * pxPerM])} stroke="#1c7ed6" strokeWidth={2} opacity={0.55} dash={[6, 4]} listening={false} /> + ))} + {overlays.sightLines?.map((l, n) => ( + + ))} + {overlays.clearance?.map((c, n) => ( + + ))} {preview && ( ; + typeOrder: string[]; + violations: Violation[]; + onAdd: () => void; + onUpdate: (id: string, patch: Partial) => 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) => ( + + ); + + return ( +
+
Placement rules
+
+ Rules only measure and flag. Required rules outline broken items in orange but never block dragging. +
+ + {rules.length === 0 &&
No rules yet.
} + + {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 ( +
+
+ + +
+ +
+ {typeSelect(r.typeA, (v) => onUpdate(r.id, { typeA: v }))} + {needsB && typeSelect(r.typeB, (v) => onUpdate(r.id, { typeB: v }))} +
+ + {r.kind !== "againstWall" && ( +
+ {r.kind !== "keepNear" && ( +
+
{r.kind === "screenDistance" ? "Min (m)" : "At least (m)"}
+ onUpdate(r.id, r.kind === "screenDistance" && v > (r.max ?? 6) ? { min: v, max: v } : { min: v })} + /> +
+ )} + {(r.kind === "keepNear" || r.kind === "screenDistance") && ( +
+
{r.kind === "keepNear" ? "Within (m)" : "Max (m)"}
+ onUpdate(r.id, r.kind === "screenDistance" && v < (r.min ?? 1) ? { max: v, min: v } : { max: v })} + /> +
+ )} +
+ )} + +
+
Priority
+ +
+ +
+ {describeRule(r, configs)} + {broken ? ` · ${broken} item${broken > 1 ? "s" : ""} broken` : ""} +
+
+ ); + })} + + +
+ ); +} diff --git a/src/components/ScorePanel.tsx b/src/components/ScorePanel.tsx new file mode 100644 index 0000000..9cb6fc1 --- /dev/null +++ b/src/components/ScorePanel.tsx @@ -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 = { 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 ( +
+ + + {open && ( + <> + {KEYS.map((k) => { + const v = result.scores[k]; + return ( +
+
{LABELS[k]}
+
+
+
+
+
{v === null ? "n/a" : `${Math.round(v * 100)}%`}
+
+
+ onWeight(k, n)} /> +
+
+ ); + })} +
Right-hand wheels set each score's priority (0-10).
+ + {required.length > 0 && ( +
+
Required rules broken
+ {required.map((i, n) => ( + + ))} +
+ )} + + {others.length > 0 && ( +
+
Top issues
+ {others.map((i, n) => ( + + ))} +
+ )} + +
+
Overlays
+ {( + [ + ["loading", "Loading paths"], + ["sight", "Sight lines"], + ["clearance", "Table clearance"], + ] as const + ).map(([key, text]) => ( + + ))} +
+ + )} +
+ ); +}