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:
@@ -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.
|
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.
|
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.
|
- 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.
|
- 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
|
## Tech stack
|
||||||
|
|
||||||
@@ -74,7 +83,9 @@ interface FurnitureTypeConfig {
|
|||||||
// seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats)
|
// seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats)
|
||||||
```
|
```
|
||||||
|
|
||||||
Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture): `{ room, items, configs: Record<id, config>, 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<id, config>, 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
|
## Conventions
|
||||||
|
|
||||||
@@ -87,8 +98,9 @@ Store (`store.ts`; `setRoom` replaces the room, optionally clearing furniture):
|
|||||||
## File layout
|
## 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/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/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)
|
## Roadmap (not built yet, don't block)
|
||||||
|
|
||||||
@@ -115,6 +127,6 @@ npx tsc --noEmit # type check
|
|||||||
|
|
||||||
## Working agreement
|
## 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.
|
- After changes, make sure the app builds and type checks.
|
||||||
- Hackathon pace: prefer simple and working over clever.
|
- Hackathon pace: prefer simple and working over clever.
|
||||||
+96
-2
@@ -16,12 +16,15 @@ import {
|
|||||||
wallLength,
|
wallLength,
|
||||||
} from "./geometry";
|
} from "./geometry";
|
||||||
import { exportRoom, parseRoom } from "./roomSchema";
|
import { exportRoom, parseRoom } from "./roomSchema";
|
||||||
|
import { overallScore, scoreLayout, type ScoreKey } from "./scoring";
|
||||||
import { initialState, reducer } from "./store";
|
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 { placedCount, remaining } from "./inventory";
|
||||||
import { Palette } from "./components/Palette";
|
import { Palette } from "./components/Palette";
|
||||||
import { Room, WALL_PX, type Mode, type Preview, type RoomSel } from "./components/Room";
|
import { Room, WALL_PX, type Mode, type Preview, type RoomSel } from "./components/Room";
|
||||||
import { ModeToggle } from "./components/ModeToggle";
|
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 { DoorPanel, ObstaclePanel, RoomPanel, ScreenPanel } from "./components/RoomPanels";
|
||||||
import { ScaleBar } from "./components/ScaleBar";
|
import { ScaleBar } from "./components/ScaleBar";
|
||||||
import { TypeMenu } from "./components/TypeMenu";
|
import { TypeMenu } from "./components/TypeMenu";
|
||||||
@@ -53,6 +56,13 @@ export default function App() {
|
|||||||
const [mode, setMode] = useState<Mode>("layout");
|
const [mode, setMode] = useState<Mode>("layout");
|
||||||
const [roomSel, setRoomSel] = useState<RoomSel | null>(null);
|
const [roomSel, setRoomSel] = useState<RoomSel | null>(null);
|
||||||
const [roomNotice, setRoomNotice] = useState("");
|
const [roomNotice, setRoomNotice] = useState("");
|
||||||
|
const [overlayToggles, setOverlayToggles] = useState<OverlayToggles>({ loading: false, sight: false, clearance: false });
|
||||||
|
const [highlightIds, setHighlightIds] = useState<ReadonlySet<string>>(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 nextItemId = useRef(1);
|
||||||
const nextCustomId = useRef(1);
|
const nextCustomId = useRef(1);
|
||||||
|
|
||||||
@@ -321,6 +331,59 @@ export default function App() {
|
|||||||
setSidebar({ kind: "tiles" });
|
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(
|
const remainingByType = Object.fromEntries(
|
||||||
state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
|
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} />
|
onExport={exportRoomFile} onImport={importRoomFile} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (mode === "rules") {
|
||||||
|
panel = (
|
||||||
|
<RulesPanel
|
||||||
|
rules={state.rules}
|
||||||
|
configs={state.configs}
|
||||||
|
typeOrder={state.typeOrder}
|
||||||
|
violations={layoutScore?.violations ?? []}
|
||||||
|
onAdd={addRule}
|
||||||
|
onUpdate={(id, patch) => dispatch({ kind: "updateRule", id, patch })}
|
||||||
|
onRemove={(id) => dispatch({ kind: "removeRule", id })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (selectedItem) {
|
} else if (selectedItem) {
|
||||||
panel = (
|
panel = (
|
||||||
<ItemPanel
|
<ItemPanel
|
||||||
@@ -386,7 +461,22 @@ export default function App() {
|
|||||||
onStartDrag={startDrag}
|
onStartDrag={startDrag}
|
||||||
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
||||||
onNewCustom={() => openSidebar({ kind: "custom" })}
|
onNewCustom={() => openSidebar({ kind: "custom" })}
|
||||||
header={<ModeToggle mode={mode} onChange={switchMode} />}
|
header={
|
||||||
|
<>
|
||||||
|
<ModeToggle mode={mode} onChange={switchMode} />
|
||||||
|
{mode === "layout" && layoutScore && (
|
||||||
|
<ScorePanel
|
||||||
|
result={layoutScore}
|
||||||
|
overall={overall}
|
||||||
|
weights={state.weights}
|
||||||
|
overlays={overlayToggles}
|
||||||
|
onWeight={(key: ScoreKey, value) => dispatch({ kind: "setWeight", key, value })}
|
||||||
|
onToggleOverlay={(k) => setOverlayToggles((o) => ({ ...o, [k]: !o[k] }))}
|
||||||
|
onHighlight={highlight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
panel={panel}
|
panel={panel}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
@@ -394,6 +484,10 @@ export default function App() {
|
|||||||
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
|
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
|
||||||
>
|
>
|
||||||
<Room
|
<Room
|
||||||
|
flaggedIds={flaggedIds}
|
||||||
|
highlightIds={highlightIds}
|
||||||
|
overlays={overlays}
|
||||||
|
onDragPreview={onDragPreview}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
roomSel={roomSel}
|
roomSel={roomSel}
|
||||||
onSelectRoom={(sel) => {
|
onSelectRoom={(sel) => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Group, Rect } from "react-konva";
|
import { Group, Rect } from "react-konva";
|
||||||
import type { Shape } from "../types";
|
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> = {
|
const STROKE: Record<Outline, string> = {
|
||||||
normal: "#3b3b3b",
|
normal: "#3b3b3b",
|
||||||
selected: "#1c7ed6",
|
selected: "#1c7ed6",
|
||||||
|
flagged: "#f08c00",
|
||||||
|
highlight: "#d6336c",
|
||||||
valid: "#2f9e44",
|
valid: "#2f9e44",
|
||||||
invalid: "#e03131",
|
invalid: "#e03131",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode)
|
|||||||
<button style={seg("room")} onClick={() => onChange("room")}>
|
<button style={seg("room")} onClick={() => onChange("room")}>
|
||||||
Room
|
Room
|
||||||
</button>
|
</button>
|
||||||
|
<button style={seg("rules")} onClick={() => onChange("rules")}>
|
||||||
|
Rules
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-8
@@ -13,6 +13,7 @@ import {
|
|||||||
wallLength,
|
wallLength,
|
||||||
wallRect,
|
wallRect,
|
||||||
} from "../geometry";
|
} 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 type { Door, FurnitureItem, FurnitureTypeConfig, Obstacle, Rect as RectM, Room as RoomT, RoomScreen, Shape, Wall } from "../types";
|
||||||
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
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 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 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 {
|
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;
|
mode: Mode;
|
||||||
roomSel: RoomSel | null;
|
roomSel: RoomSel | null;
|
||||||
onSelectRoom: (sel: RoomSel | null) => void;
|
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 interactive = mode === "layout";
|
||||||
|
const roomEdit = mode === "room";
|
||||||
const [drag, setDrag] = useState<DragInfo | null>(null);
|
const [drag, setDrag] = useState<DragInfo | null>(null);
|
||||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||||
const resizeStart = useRef<RectM | null>(null);
|
const resizeStart = useRef<RectM | null>(null);
|
||||||
@@ -313,8 +325,8 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
|
|||||||
stroke={state}
|
stroke={state}
|
||||||
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
|
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
|
||||||
cornerRadius={2}
|
cornerRadius={2}
|
||||||
listening={!interactive}
|
listening={roomEdit}
|
||||||
draggable={!interactive}
|
draggable={roomEdit}
|
||||||
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
||||||
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
||||||
dragBoundFunc={(pos) => ({
|
dragBoundFunc={(pos) => ({
|
||||||
@@ -337,7 +349,7 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const c = e.target.getStage()?.container();
|
const c = e.target.getStage()?.container();
|
||||||
if (c && !interactive) c.style.cursor = "grab";
|
if (c && roomEdit) c.style.cursor = "grab";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
const c = e.target.getStage()?.container();
|
const c = e.target.getStage()?.container();
|
||||||
@@ -355,7 +367,13 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
|
|||||||
? resize.valid ? "valid" : "invalid"
|
? resize.valid ? "valid" : "invalid"
|
||||||
: drag?.id === item.id
|
: drag?.id === item.id
|
||||||
? drag.valid ? "valid" : "invalid"
|
? 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 half = boundsOf({ ...r, rotation: item.rotation });
|
||||||
const wpx = r.width * pxPerM;
|
const wpx = r.width * pxPerM;
|
||||||
const hpx = r.height * pxPerM;
|
const hpx = r.height * pxPerM;
|
||||||
@@ -386,6 +404,7 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
|
|||||||
onDragMove={(e) => {
|
onDragMove={(e) => {
|
||||||
const c = candidate(item, e.target);
|
const c = candidate(item, e.target);
|
||||||
setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) });
|
setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) });
|
||||||
|
onDragPreview({ id: item.id, x: c.x, y: c.y });
|
||||||
}}
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
const node = e.target;
|
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 });
|
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);
|
if (ok) onMove(item.id, c.x, c.y);
|
||||||
setDrag(null);
|
setDrag(null);
|
||||||
|
onDragPreview(null);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const c = e.target.getStage()?.container();
|
const c = e.target.getStage()?.container();
|
||||||
@@ -465,16 +485,26 @@ export function Room({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstac
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
})}
|
})}
|
||||||
{!interactive &&
|
{roomEdit &&
|
||||||
room.doors.map((d) => {
|
room.doors.map((d) => {
|
||||||
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? wallDrag.offset : d.offset;
|
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 }));
|
return wallHit("door", d.id, d.wall, live, d.width, doorSwingRect(room, { ...d, offset: live }));
|
||||||
})}
|
})}
|
||||||
{!interactive &&
|
{roomEdit &&
|
||||||
room.screens.map((sc) => {
|
room.screens.map((sc) => {
|
||||||
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? wallDrag.offset : sc.offset;
|
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));
|
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 && (
|
{preview && (
|
||||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||||
<FurnitureShape
|
<FurnitureShape
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user