third commit

This commit is contained in:
2026-10-17 12:43:25 -04:00
parent abc3800db2
commit 803d3fded5
19 changed files with 825 additions and 356 deletions
+38 -35
View File
@@ -2,26 +2,27 @@
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: Phase 1 (UI only) ## Current scope: Phase 1 + Phase 2 (UI only)
The ONLY feature right now is drag-and-drop layout: Layout and editing UI. No optimizer yet.
- Left sidebar (palette) with three source tiles: chair, table, screen. **Layout (Phase 1)**
- Resizable by dragging its right edge (default 264px, min 152px, max 600px). Tiles live in an auto-fill grid: they stretch in width only (fixed 84px height) until another column fits. - Left sidebar (palette): one tile per furniture type (built-in chair, table, screen + any custom) and a dashed "Custom" tile. Resizable by dragging its right edge (default 264px, min 152, max 600); tiles live in an auto-fill grid and stretch in width only (84px tall). The sidebar scrolls vertically.
- Large rectangular room in the center (fixed 10m x 8m). It is scaled to fill the available space (`pxPerM` computed via `fitScale` and passed down; a ResizeObserver in `App.tsx` recomputes it). A "1 m" scale bar sits under the room. No grid lines. - Room in the center (fixed 10m x 8m), scaled to fill the space (`pxPerM` from `fitScale`, recomputed by a ResizeObserver in `App.tsx`), with a "1 m" scale bar. No grid, NO snapping: items land exactly where dropped.
- Click (no drag) a palette tile to open its settings menu. The menu takes over the sidebar (replaces the tiles); close with Escape or the "Back" button: - Drag a tile into the room to create an item (tile stays). Click (<5px movement) a tile to open its type menu (takes over the sidebar; Escape / Back closes): "Available" wheel (min = placed) and, if the type has seats, a seats wheel. Badge = available - placed; at 0 the tile greys out and can't be dragged.
- Available: how many of that type the venue has. Min = number already placed. - Items are free-placed, clamped to the room, no overlap (touching is fine). Red outline while the position is invalid, green when valid; invalid drop snaps back (or cancels a new add).
- Tables only: Seats per table (min 1). - Wheels (`components/Wheel.tsx`) are iOS-timer style: 1:1 drag, momentum fling, press to stop, spring settle, mouse wheel/trackpad, tap a row, arrow keys. Props: `step`, `wrap`, `rows`, `disabled`, `format`. If the parent rejects a change the wheel glides back to the real value.
- Both are iOS-timer-style scroll wheels (`components/Wheel.tsx`): drag 1:1, momentum fling, press to stop mid-roll, spring settle for slow releases, mouse wheel / trackpad, tap a row, arrow keys. Max 999.
- Palette tiles show a badge with remaining count (available minus placed). Defaults: 20 chairs, 6 tables, 8 seats per table.
- Drag a tile into the room to create an item. The tile stays in the palette. If remaining is 0, the tile is greyed out and can't be dragged (click still opens the menu).
- Distinguish click from drag with a small movement threshold (5px).
- Screens (2 x 0.2 m default, 2 available by default) are the only resizable type: click a placed screen to select it, then drag its left/right end handles to change its length only (top-down view, so thickness stays 0.2 m; min length 0.5 m, stays inside the room; red + snap back to the old size if it would overlap). `RESIZABLE` and `resizeRect` live in `geometry.ts`.
- Drag existing items to move them. Items are clamped inside the room walls.
- No overlap allowed (touching edges is fine). Invalid position = red outline while dragging, green when valid; on drop, snap back to last valid position (or cancel the add if new).
- NO grid snapping: items land exactly where dropped (snapping was removed on request).
Do not build anything beyond this unless asked. Do not design in a way that blocks the roadmap below. **Editing (Phase 2)**
- Click an item to select it (blue outline); click empty floor to deselect (Escape/Done too). Dragging still moves. Selecting opens the item panel in the sidebar (replaces tiles / type menu).
- Item panel: Rotate 90 button + free angle wheel (0-359, wraps); Lock/Unlock; Delete (also Delete/Backspace key, ignored while typing in an input); "Seats at this table" wheel for tables (per-item override, "Reset to default" link when overridden); Size wheels (0.3-6 m, 0.1 steps; screens show only Length).
- Rotation turns around the item's center and collision/wall clamping use the rotated bounding box (`boundsOf`). A rotate/size change is pushed back inside the walls, then rejected if it would overlap (`tryUpdate`).
- Locked items can't be dragged or rotated and show a padlock. (Size, seats and delete are still allowed on locked items.)
- Delete frees inventory automatically (remaining is derived from `items`).
- Screens: while selected, unlocked and unrotated, left/right end handles change their length (`resizeLength`, min 0.5 m). Thickness is only changed by size wheels, never handles (top-down view).
- Custom furniture: "Custom" tile opens a form (name, Seating/Table, width, length, seats, available). Saving adds a config + palette tile. Custom seating draws as a chair, custom tables as a table.
Out of scope: room features (doors, room screen), room import, placement rules, optimization, scanning. Do not design in a way that blocks the roadmap below.
## Tech stack ## Tech stack
@@ -32,47 +33,49 @@ Do not build anything beyond this unless asked. Do not design in a way that bloc
## Data model ## Data model
```ts ```ts
type FurnitureType = "chair" | "table" | "screen"; type FurnitureType = string; // config id: "chair" | "table" | "screen" | "custom-1" ...
type Shape = "chair" | "table" | "screen"; // how a type is drawn
interface FurnitureItem { interface FurnitureItem {
id: string; id: string;
type: FurnitureType; type: FurnitureType; // config id
x: number; // meters, top-left, room coordinates x: number; // meters, top-left of the UNROTATED rect, room coordinates
y: number; y: number;
width: number; // meters width: number; // meters
height: number; height: number;
rotation: number; // degrees, always 0 for now rotation: number; // degrees, around the rect's center
locked: boolean; // always false for now, reserved locked: boolean;
seatsOverride?: number; // tables: overrides config.seatsPerUnit
} }
interface Room { width: number; height: number; } // meters interface Room { width: number; height: number; } // meters
// Per-type settings, edited via the palette menu
interface FurnitureTypeConfig { interface FurnitureTypeConfig {
type: FurnitureType; id: FurnitureType; name: string; shape: Shape;
defaultWidth: number; defaultHeight: number; // meters
isTable: boolean;
available: number; // inventory count available: number; // inventory count
seatsPerUnit?: number; // tables only: people who can sit at one seatsPerUnit?: number; // type-level seats
} }
// remaining = available - items.filter(i => i.type === type).length // remaining = available - items.filter(i => i.type === type).length (derived, never stored)
// seats for an item = item.seatsOverride ?? config.seatsPerUnit (effectiveSeats)
``` ```
Seats per table is a type-level setting for now (applies to all tables). Per-table overrides come later. Chairs and table seats are independent for now. Store (`store.ts`): `{ 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.
Default sizes: chair 0.5 x 0.5 m, table 1.8 x 0.8 m, screen 2 x 0.2 m.
## Conventions ## Conventions
- Store everything in meters. Convert to pixels only at render time using the `pxPerM` scale (dynamic, computed by `fitScale` in `geometry.ts`; there is no fixed px-per-meter constant). - Store everything in meters. Convert to pixels only at render time using the dynamic `pxPerM` scale (`fitScale` in `geometry.ts`).
- Collision = axis-aligned bounding boxes. Keep collision and clamping logic in pure functions (no React or Konva imports) so the optimizer can reuse them. - Collision = axis-aligned bounding boxes of the rotated rect (`boundsOf`). Keep collision and clamping logic in pure functions (no React or Konva imports) so the optimizer can reuse them.
- Keep geometry/rules logic separate from rendering components. - Keep geometry/rules logic separate from rendering components.
- Small, typed, readable code. No new dependencies without a good reason. - Small, typed, readable code. No new dependencies without a good reason.
- Keep the UI minimal: light grey app background, room centered in the remaining space. - Keep the UI minimal: light grey app background, room centered in the remaining space.
## File layout ## File layout
- `src/types.ts`, `src/geometry.ts` (pure: clamp, overlap, validity, fitScale, default sizes), `src/inventory.ts` (pure: placedCount, remaining), `src/store.ts` (reducer: items, room, configs). - `src/types.ts`; `src/geometry.ts` (pure: `boundsOf`, `clampToRoom`, `overlaps`, `isValidPlacement`, `tryUpdate`, `resizeLength`, `fitScale`); `src/inventory.ts` (pure: `placedCount`, `remaining`, `effectiveSeats`); `src/store.ts` (reducer + built-in configs).
- `src/App.tsx`: owns the store, palette drag, room scale. - `src/App.tsx`: owns the store, selection, sidebar mode, palette drag, room scale, Delete key.
- `src/components/`: `Palette` (sidebar, tiles, resize), `TypeMenu`, `Wheel`, `Room` (Konva), `FurnitureShape`, `ScaleBar`. - `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`.
## Roadmap (not built yet, don't block) ## Roadmap (not built yet, don't block)
@@ -99,6 +102,6 @@ npx tsc --noEmit # type check
## Working agreement ## Working agreement
- Ask before expanding scope past Phase 1. - Ask before expanding scope past Phase 2.
- 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.
-41
View File
File diff suppressed because one or more lines are too long
+41
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furniture Optimizer</title> <title>FurnOp</title>
<script type="module" crossorigin src="/assets/index-BhQDCmrl.js"></script> <script type="module" crossorigin src="/assets/index-DDOav9ki.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furniture Optimizer</title> <title>FurnOp</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+128 -37
View File
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { DEFAULT_SIZES, fitScale, isValidPlacement, clampToRoom } from "./geometry"; import { clampToRoom, fitScale, isValidPlacement, tryUpdate } from "./geometry";
import { initialState, reducer } from "./store"; import { initialState, reducer } from "./store";
import { FURNITURE_TYPES, type FurnitureType } from "./types"; import type { FurnitureItem, FurnitureType, Rect } from "./types";
import { remaining, placedCount } from "./inventory"; import { placedCount, remaining } from "./inventory";
import { Palette, TYPE_ICON } from "./components/Palette"; import { Palette } from "./components/Palette";
import { Room, type Preview } from "./components/Room"; import { Room, type Preview } from "./components/Room";
import { ScaleBar } from "./components/ScaleBar"; import { ScaleBar } from "./components/ScaleBar";
import { TypeMenu } from "./components/TypeMenu"; import { TypeMenu } from "./components/TypeMenu";
import { ItemPanel } from "./components/ItemPanel";
import { CustomForm, type CustomDraft } from "./components/CustomForm";
import { ShapeIcon } from "./components/Icons";
const WALL_PX = 6; // room wall border, must match Room.tsx const WALL_PX = 6; // room wall border, must match Room.tsx
const SCALE_BAR_PX = 40; const SCALE_BAR_PX = 40;
@@ -17,14 +20,53 @@ interface PaletteDrag {
clientY: number; clientY: number;
} }
// What the sidebar shows besides the tiles. An item panel appears whenever an item is selected.
type Sidebar = { kind: "tiles" } | { kind: "type"; id: FurnitureType } | { kind: "custom" };
export default function App() { export default function App() {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null); const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
const [menu, setMenu] = useState<FurnitureType | null>(null); const [sidebar, setSidebar] = useState<Sidebar>({ kind: "tiles" });
const closeMenu = useCallback(() => setMenu(null), []); const [selectedId, setSelectedId] = useState<string | null>(null);
const floorRef = useRef<HTMLDivElement>(null); const floorRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null); const mainRef = useRef<HTMLElement>(null);
const [pxPerM, setPxPerM] = useState(60); const [pxPerM, setPxPerM] = useState(60);
const nextItemId = useRef(1);
const nextCustomId = useRef(1);
const selectedItem = state.items.find((i) => i.id === selectedId) ?? null;
// Selecting an item, opening a menu and dragging from the palette each replace the others.
const select = useCallback((id: string | null) => {
setSelectedId(id);
if (id) setSidebar({ kind: "tiles" });
}, []);
const openSidebar = (s: Sidebar) => {
setSelectedId(null);
setSidebar(s);
};
const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []);
const deselect = useCallback(() => setSelectedId(null), []);
const deleteSelected = useCallback(() => {
if (!selectedId) return;
dispatch({ kind: "remove", id: selectedId });
setSelectedId(null);
}, [selectedId]);
// Delete / Backspace (Mac "delete") removes the selected item, unless the user is typing.
useEffect(() => {
const key = (e: KeyboardEvent) => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
if (!selectedId) return;
e.preventDefault();
deleteSelected();
};
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [selectedId, deleteSelected]);
// Maximise the room inside the center area (minus wall border and the scale bar). // Maximise the room inside the center area (minus wall border and the scale bar).
useEffect(() => { useEffect(() => {
@@ -37,10 +79,9 @@ export default function App() {
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
}, [state.room]); }, [state.room]);
const nextId = useRef(1);
// Cursor position -> room meters (item centered on cursor), or null if outside the floor. // Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor.
const toRoomRect = (d: PaletteDrag) => { const toRoomRect = (d: PaletteDrag): Rect | null => {
const box = floorRef.current?.getBoundingClientRect(); const box = floorRef.current?.getBoundingClientRect();
if (!box) return null; if (!box) return null;
// Floor excludes the CSS border: the Stage sits inside it. // Floor excludes the CSS border: the Stage sits inside it.
@@ -48,11 +89,8 @@ export default function App() {
const px = d.clientX - box.left - border; const px = d.clientX - box.left - border;
const py = d.clientY - box.top - border; const py = d.clientY - box.top - border;
if (px < 0 || py < 0 || px > state.room.width * pxPerM || py > state.room.height * pxPerM) return null; if (px < 0 || py < 0 || px > state.room.width * pxPerM || py > state.room.height * pxPerM) return null;
const { width, height } = DEFAULT_SIZES[d.type]; const { defaultWidth: width, defaultHeight: height } = state.configs[d.type];
return clampToRoom( return clampToRoom({ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height }, state.room);
{ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height },
state.room,
);
}; };
useEffect(() => { useEffect(() => {
@@ -67,7 +105,7 @@ export default function App() {
remaining(state.items, type, state.configs[type].available) > 0 && remaining(state.items, type, state.configs[type].available) > 0 &&
isValidPlacement(r, state.items, state.room) isValidPlacement(r, state.items, state.room)
) { ) {
dispatch({ kind: "add", id: `item-${nextId.current++}`, type, x: r.x, y: r.y }); dispatch({ kind: "add", id: `item-${nextItemId.current++}`, type, x: r.x, y: r.y });
} // outside the room or invalid: cancel } // outside the room or invalid: cancel
setPaletteDrag(null); setPaletteDrag(null);
}; };
@@ -87,37 +125,90 @@ export default function App() {
if (!paletteDrag) return null; if (!paletteDrag) return null;
const r = toRoomRect(paletteDrag); const r = toRoomRect(paletteDrag);
if (!r) return null; if (!r) return null;
return { type: paletteDrag.type, ...r, valid: isValidPlacement(r, state.items, state.room) }; return {
shape: state.configs[paletteDrag.type].shape,
...r,
valid: isValidPlacement(r, state.items, state.room),
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [paletteDrag, state.items, state.room, pxPerM]); }, [paletteDrag, state.items, state.room, pxPerM]);
const startDrag = (type: FurnitureType, clientX: number, clientY: number) => { const startDrag = (type: FurnitureType, clientX: number, clientY: number) => {
setMenu(null); setSidebar({ kind: "tiles" });
setPaletteDrag({ type, clientX, clientY }); setPaletteDrag({ type, clientX, clientY });
}; };
/** Rotate / resize / lock / seats on one item. Geometry changes are checked; returns false if rejected. */
const patchItem = (item: FurnitureItem, patch: Partial<FurnitureItem>): boolean => {
const geometric = "rotation" in patch || "width" in patch || "height" in patch;
if (!geometric) {
dispatch({ kind: "update", id: item.id, patch });
return true;
}
const next = tryUpdate(item, patch, state.items, state.room);
if (!next) return false;
dispatch({ kind: "update", id: item.id, patch: { ...patch, x: next.x, y: next.y } });
return true;
};
const saveCustom = (d: CustomDraft) => {
const id = `custom-${nextCustomId.current++}`;
dispatch({
kind: "addConfig",
config: {
id,
name: d.name,
shape: d.isTable ? "table" : "chair",
defaultWidth: d.width,
defaultHeight: d.height,
isTable: d.isTable,
available: d.available,
seatsPerUnit: d.seats,
},
});
setSidebar({ kind: "tiles" });
};
const remainingByType = Object.fromEntries( const remainingByType = Object.fromEntries(
FURNITURE_TYPES.map((t) => [t, remaining(state.items, t, state.configs[t].available)]), state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
) as Record<FurnitureType, number>; );
let panel = null;
if (selectedItem) {
panel = (
<ItemPanel
key={selectedItem.id}
item={selectedItem}
config={state.configs[selectedItem.type]}
onPatch={(p) => patchItem(selectedItem, p)}
onDelete={deleteSelected}
onClose={deselect}
/>
);
} else if (sidebar.kind === "type") {
const id = sidebar.id;
panel = (
<TypeMenu
config={state.configs[id]}
placed={placedCount(state.items, id)}
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: id, value })}
onSetSeats={(value) => dispatch({ kind: "setSeats", type: id, value })}
onClose={closeSidebar}
/>
);
} else if (sidebar.kind === "custom") {
panel = <CustomForm onSave={saveCustom} onCancel={closeSidebar} />;
}
return ( return (
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}> <div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
<Palette <Palette
types={state.typeOrder.map((t) => state.configs[t])}
remaining={remainingByType} remaining={remainingByType}
onStartDrag={startDrag} onStartDrag={startDrag}
onOpenMenu={setMenu} onOpenMenu={(id) => openSidebar({ kind: "type", id })}
panel={ onNewCustom={() => openSidebar({ kind: "custom" })}
menu && ( panel={panel}
<TypeMenu
type={menu}
config={state.configs[menu]}
placed={placedCount(state.items, menu)}
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: menu, value })}
onSetSeats={(value) => dispatch({ kind: "setSeats", value })}
onClose={closeMenu}
/>
)
}
/> />
<main <main
ref={mainRef} ref={mainRef}
@@ -127,10 +218,13 @@ export default function App() {
pxPerM={pxPerM} pxPerM={pxPerM}
room={state.room} room={state.room}
items={state.items} items={state.items}
configs={state.configs}
preview={preview} preview={preview}
floorRef={floorRef} floorRef={floorRef}
selectedId={selectedId}
onSelect={select}
onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })} onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })}
onResize={(id, r) => dispatch({ kind: "resize", id, ...r })} onResize={(id, r) => dispatch({ kind: "update", id, patch: { ...r } })}
/> />
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} /> <ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
</main> </main>
@@ -145,10 +239,7 @@ export default function App() {
opacity: 0.8, opacity: 0.8,
}} }}
> >
{(() => { <ShapeIcon shape={state.configs[paletteDrag.type].shape} />
const Icon = TYPE_ICON[paletteDrag.type];
return <Icon />;
})()}
</div> </div>
)} )}
</div> </div>
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
export interface CustomDraft {
name: string;
isTable: boolean;
width: number;
height: number;
seats: number;
available: number;
}
interface Props {
onSave: (draft: CustomDraft) => void;
onCancel: () => void;
}
const fmt = (v: number) => v.toFixed(1);
const SEATING = { width: 0.5, height: 0.5, seats: 1 };
const TABLE = { width: 1.8, height: 0.8, seats: 8 };
export function CustomForm({ onSave, onCancel }: Props) {
const [name, setName] = useState("");
const [isTable, setIsTable] = useState(false);
const [width, setWidth] = useState(SEATING.width);
const [height, setHeight] = useState(SEATING.height);
const [seats, setSeats] = useState(SEATING.seats);
const [available, setAvailable] = useState(10);
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onCancel]);
const pickKind = (table: boolean) => {
setIsTable(table);
const d = table ? TABLE : SEATING;
setWidth(d.width);
setHeight(d.height);
setSeats(d.seats);
};
const canSave = name.trim().length > 0;
const seg = (active: boolean): React.CSSProperties => ({
...btn,
flex: 1,
background: active ? "#2b2b2b" : "#fff",
color: active ? "#fff" : "#222",
});
return (
<div style={panelRoot}>
<button onClick={onCancel} style={backBtn}>
← Cancel
</button>
<div style={title}>New furniture</div>
<div>
<div style={label}>Name</div>
<input
value={name}
autoFocus
maxLength={24}
placeholder="e.g. Couch"
onChange={(e) => setName(e.target.value)}
style={{ width: "100%", boxSizing: "border-box", height: 32, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "14px system-ui, sans-serif" }}
/>
</div>
<div>
<div style={label}>Type</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={seg(!isTable)} onClick={() => pickKind(false)}>
Seating
</button>
<button style={seg(isTable)} onClick={() => pickKind(true)}>
Table
</button>
</div>
</div>
<div style={twoCol}>
<div>
<div style={label}>Width (m)</div>
<Wheel value={width} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setWidth} />
</div>
<div>
<div style={label}>Length (m)</div>
<Wheel value={height} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setHeight} />
</div>
<div>
<div style={label}>Seats</div>
<Wheel value={seats} min={1} max={99} rows={3} onChange={setSeats} />
</div>
<div>
<div style={label}>Available</div>
<Wheel value={available} min={1} max={999} rows={3} onChange={setAvailable} />
</div>
</div>
{!canSave && <div style={hint}>Enter a name to save.</div>}
<button
style={{ ...btn, background: canSave ? "#2b2b2b" : "#ddd", color: canSave ? "#fff" : "#999", cursor: canSave ? "pointer" : "not-allowed" }}
disabled={!canSave}
onClick={() => onSave({ name: name.trim(), isTable, width, height, seats, available })}
>
Save
</button>
</div>
);
}
+5 -5
View File
@@ -1,5 +1,5 @@
import { Group, Rect } from "react-konva"; import { Group, Rect } from "react-konva";
import type { FurnitureType } from "../types"; import type { Shape } from "../types";
export type Outline = "normal" | "selected" | "valid" | "invalid"; export type Outline = "normal" | "selected" | "valid" | "invalid";
@@ -11,7 +11,7 @@ const STROKE: Record<Outline, string> = {
}; };
interface Props { interface Props {
type: FurnitureType; shape: Shape;
width: number; // meters width: number; // meters
height: number; height: number;
pxPerM: number; pxPerM: number;
@@ -20,13 +20,13 @@ interface Props {
} }
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */ /** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
export function FurnitureShape({ type, width, height, pxPerM, outline, opacity = 1 }: Props) { export function FurnitureShape({ shape, width, height, pxPerM, outline, opacity = 1 }: Props) {
const w = width * pxPerM; const w = width * pxPerM;
const h = height * pxPerM; const h = height * pxPerM;
const stroke = STROKE[outline]; const stroke = STROKE[outline];
const strokeWidth = outline === "normal" ? 1.5 : 3; const strokeWidth = outline === "normal" ? 1.5 : 3;
if (type === "screen") { if (shape === "screen") {
const inset = Math.min(3, h / 4); const inset = Math.min(3, h / 4);
return ( return (
<Group opacity={opacity}> <Group opacity={opacity}>
@@ -35,7 +35,7 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
</Group> </Group>
); );
} }
if (type === "table") { if (shape === "table") {
return ( return (
<Group opacity={opacity}> <Group opacity={opacity}>
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} /> <Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
+33
View File
@@ -0,0 +1,33 @@
import type { Shape } from "../types";
export function ChairIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
</svg>
);
}
export function TableIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
</svg>
);
}
export function ScreenIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
</svg>
);
}
export function ShapeIcon({ shape, size }: { shape: Shape; size?: number }) {
if (shape === "table") return <TableIcon size={size} />;
if (shape === "screen") return <ScreenIcon size={size} />;
return <ChairIcon size={size} />;
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
import { effectiveSeats } from "../inventory";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
interface Props {
item: FurnitureItem;
config: FurnitureTypeConfig;
/** Try to apply a change. Returns false if it was rejected (overlap / no room). */
onPatch: (patch: Partial<FurnitureItem>) => boolean;
onDelete: () => void;
onClose: () => void;
}
const MIN_SIZE = 0.3;
const MAX_SIZE = 6;
const fmt = (v: number) => v.toFixed(1);
export function ItemPanel({ item, config, onPatch, onDelete, onClose }: Props) {
const [notice, setNotice] = useState("");
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
const patch = (p: Partial<FurnitureItem>) => {
const ok = onPatch(p);
setNotice(ok ? "" : "No room for that change.");
};
const isScreen = config.shape === "screen";
const seats = effectiveSeats(item, config) ?? 1;
const overridden = item.seatsOverride !== undefined;
// Never let the wheel's range exclude the current value (e.g. a 0.2 m default).
const minOf = (v: number, floor: number) => Math.min(floor, v);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Done
</button>
<div style={title}>
{config.name}
{item.locked ? " 🔒" : ""}
</div>
<div>
<div style={label}>Rotation</div>
<Wheel
value={item.rotation}
min={0}
max={359}
wrap
rows={3}
disabled={item.locked}
format={(v) => `${v}°`}
onChange={(v) => patch({ rotation: v })}
/>
<button
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
disabled={item.locked}
onClick={() => patch({ rotation: (item.rotation + 90) % 360 })}
>
Rotate 90°
</button>
</div>
{config.isTable && (
<div>
<div style={label}>Seats at this table</div>
<Wheel value={seats} min={1} max={99} rows={3} format={(v) => String(v)} onChange={(v) => patch({ seatsOverride: v })} />
{overridden && (
<button
onClick={() => patch({ seatsOverride: undefined })}
style={{ ...hint, border: "none", background: "none", padding: 0, cursor: "pointer", color: "#1c7ed6", textDecoration: "underline" }}
>
Reset to default ({config.seatsPerUnit ?? 1})
</button>
)}
</div>
)}
<div>
<div style={label}>Size (m)</div>
<div style={twoCol}>
{isScreen ? (
<div>
<div style={hint}>Length</div>
<Wheel value={item.width} min={0.5} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
</div>
) : (
<>
<div>
<div style={hint}>Width</div>
<Wheel value={item.width} min={minOf(item.width, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
</div>
<div>
<div style={hint}>Length</div>
<Wheel value={item.height} min={minOf(item.height, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ height: v })} />
</div>
</>
)}
</div>
</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1 }} onClick={() => patch({ locked: !item.locked })}>
{item.locked ? "Unlock" : "Lock"}
</button>
<button style={{ ...btn, flex: 1, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onDelete}>
Delete
</button>
</div>
</div>
);
}
+39 -44
View File
@@ -1,13 +1,16 @@
import { useState, type PointerEvent, type ReactNode } from "react"; import { useState, type PointerEvent, type ReactNode } from "react";
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types"; import type { FurnitureType, FurnitureTypeConfig } from "../types";
import { ShapeIcon } from "./Icons";
const CLICK_THRESHOLD_PX = 5; const CLICK_THRESHOLD_PX = 5;
interface Props { interface Props {
types: FurnitureTypeConfig[]; // in palette order
remaining: Record<FurnitureType, number>; remaining: Record<FurnitureType, number>;
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void; onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
onOpenMenu: (type: FurnitureType) => void; onOpenMenu: (type: FurnitureType) => void;
panel: ReactNode; // when set, replaces the tiles (type settings menu) onNewCustom: () => void;
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
} }
const tile: React.CSSProperties = { const tile: React.CSSProperties = {
@@ -29,35 +32,19 @@ const tile: React.CSSProperties = {
color: "#444", color: "#444",
}; };
export function ChairIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
</svg>
);
}
export function TableIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
</svg>
);
}
const MIN_W = 152; const MIN_W = 152;
const MAX_W = 600; const MAX_W = 600;
interface TileProps { interface TileProps {
type: FurnitureType; config: FurnitureTypeConfig;
remaining: number; remaining: number;
onStartDrag: Props["onStartDrag"]; onStartDrag: Props["onStartDrag"];
onOpenMenu: Props["onOpenMenu"]; onOpenMenu: Props["onOpenMenu"];
} }
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */ /** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) { function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
const type = config.id;
const empty = remaining <= 0; const empty = remaining <= 0;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => { const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
@@ -90,7 +77,7 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
return ( return (
<div <div
role="button" role="button"
aria-label={`${type} (${remaining} remaining)`} aria-label={`${config.name} (${remaining} remaining)`}
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }} style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
> >
@@ -112,31 +99,27 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
> >
{remaining} {remaining}
</span> </span>
{(() => { <ShapeIcon shape={config.shape} />
const Icon = TYPE_ICON[type]; {config.name}
return <Icon />;
})()}
{TYPE_LABEL[type]}
</div> </div>
); );
} }
export function ScreenIcon({ size = 36 }: { size?: number }) { function NewCustomTile({ onClick }: { onClick: () => void }) {
return ( return (
<svg width={size} height={size} viewBox="0 0 36 36"> <div
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" /> role="button"
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" /> aria-label="New custom furniture"
</svg> onClick={onClick}
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
>
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
Custom
</div>
); );
} }
export const TYPE_ICON: Record<FurnitureType, (p: { size?: number }) => React.JSX.Element> = { export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
chair: ChairIcon,
table: TableIcon,
screen: ScreenIcon,
};
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
const [width, setWidth] = useState(264); const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => { const startResize = (e: PointerEvent) => {
@@ -158,20 +141,32 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
style={{ style={{
position: "relative", position: "relative",
width, width,
boxSizing: "border-box",
flexShrink: 0, flexShrink: 0,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
<div
style={{
height: "100%",
boxSizing: "border-box",
overflowY: "auto",
padding: 16, padding: 16,
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start", alignContent: "start",
gap: 12, gap: 12,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}} }}
> >
{panel ?? FURNITURE_TYPES.map((type) => ( {panel ?? (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} /> <>
{types.map((cfg) => (
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))} ))}
<NewCustomTile onClick={onNewCustom} />
</>
)}
</div>
<div <div
onPointerDown={startResize} onPointerDown={startResize}
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }} style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
+64 -35
View File
@@ -1,12 +1,12 @@
import { useRef, useState, type RefObject } from "react"; import { useRef, useState, type RefObject } from "react";
import { Stage, Layer, Group, Rect } from "react-konva"; import { Stage, Layer, Group, Rect, Path } from "react-konva";
import type Konva from "konva"; import type Konva from "konva";
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry"; import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types"; import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
import { FurnitureShape } from "./FurnitureShape"; import { FurnitureShape, type Outline } from "./FurnitureShape";
export interface Preview { export interface Preview {
type: FurnitureType; shape: Shape;
x: number; // meters x: number; // meters
y: number; y: number;
width: number; width: number;
@@ -17,9 +17,12 @@ export interface Preview {
interface Props { interface Props {
room: RoomT; room: RoomT;
items: FurnitureItem[]; items: FurnitureItem[];
configs: Record<string, FurnitureTypeConfig>;
preview: Preview | null; // new item being dragged in from the palette preview: Preview | null; // new item being dragged in from the palette
pxPerM: number; pxPerM: number;
floorRef: RefObject<HTMLDivElement | null>; floorRef: RefObject<HTMLDivElement | null>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onMove: (id: string, x: number, y: number) => void; onMove: (id: string, x: number, y: number) => void;
onResize: (id: string, rect: RectM) => void; onResize: (id: string, rect: RectM) => void;
} }
@@ -36,21 +39,34 @@ interface ResizeInfo {
} }
const HANDLE_PX = 10; const HANDLE_PX = 10;
// Top-down view: a screen only changes in length, its thickness is fixed. const ENDS: ("left" | "right")[] = ["left", "right"];
const EDGES: ("left" | "right")[] = ["left", "right"];
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) { function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
// 24x24 padlock centered in the item, counter-rotated so it stays upright.
const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24));
return (
<Group x={w / 2} y={h / 2} offsetX={12} offsetY={12} scaleX={s} scaleY={s} rotation={-rotation} listening={false}>
<Path data="M8 11V8a4 4 0 0 1 8 0v3" stroke="#1a1a1a" strokeWidth={2.5} />
<Rect x={5} y={11} width={14} height={10} cornerRadius={2} fill="#fff" stroke="#1a1a1a" strokeWidth={2} />
</Group>
);
}
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
const [drag, setDrag] = useState<DragInfo | null>(null); const [drag, setDrag] = useState<DragInfo | null>(null);
const [selectedId, setSelectedId] = useState<string | 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);
const w = room.width * pxPerM; const w = room.width * pxPerM;
const h = room.height * pxPerM; const h = room.height * pxPerM;
// Where the item would land if dropped now: clamped to walls. // The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it.
const candidate = (item: FurnitureItem, node: Konva.Node) => const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem =>
clampToRoom( clampToRoom(
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height }, {
...item,
x: node.x() / pxPerM - item.width / 2,
y: node.y() / pxPerM - item.height / 2,
},
room, room,
); );
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
<Stage <Stage
width={w} width={w}
height={h} height={h}
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)} onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)} onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
> >
<Layer> <Layer>
{items.map((item) => { {items.map((item) => {
const shape = configs[item.type]?.shape ?? "chair";
const isResizing = resize?.id === item.id; const isResizing = resize?.id === item.id;
const r: RectM = isResizing ? resize.rect : item; const r: RectM = isResizing ? resize.rect : item;
const outline = isResizing const outline: Outline = isResizing
? 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" : "normal";
const half = boundsOf({ ...r, rotation: item.rotation });
const wpx = r.width * pxPerM;
const hpx = r.height * pxPerM;
return ( return (
<Group <Group
key={item.id} key={item.id}
x={r.x * pxPerM} x={(r.x + r.width / 2) * pxPerM}
y={r.y * pxPerM} y={(r.y + r.height / 2) * pxPerM}
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)} offsetX={wpx / 2}
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)} offsetY={hpx / 2}
rotation={item.rotation}
draggable={!item.locked} draggable={!item.locked}
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local). onMouseDown={() => onSelect(item.id)}
dragBoundFunc={(pos) => ({ onTouchStart={() => onSelect(item.id)}
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM), // Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM), dragBoundFunc={(pos) => {
})} const hx = (half.width / 2) * pxPerM;
const hy = (half.height / 2) * pxPerM;
return {
x: Math.min(Math.max(pos.x, hx), w - hx),
y: Math.min(Math.max(pos.y, hy), h - hy),
};
}}
onDragStart={(e) => { onDragStart={(e) => {
e.target.moveToTop(); e.target.moveToTop();
}} }}
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
const c = candidate(item, node); const c = candidate(item, node);
const ok = isValidPlacement(c, items, room, item.id); const ok = isValidPlacement(c, items, room, item.id);
const final = ok ? c : item; // snap back to last valid position const final = ok ? c : item; // snap back to last valid position
node.position({ x: final.x * pxPerM, y: final.y * 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);
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const c = e.target.getStage()?.container(); const c = e.target.getStage()?.container();
if (c) c.style.cursor = "grab"; if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
const c = e.target.getStage()?.container(); const c = e.target.getStage()?.container();
if (c) c.style.cursor = ""; if (c) c.style.cursor = "";
}} }}
> >
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} /> <FurnitureShape shape={shape} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
{item.locked && <LockIcon w={wpx} h={hpx} rotation={item.rotation} />}
</Group> </Group>
); );
})} })}
{items.map((item) => { {items.map((item) => {
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null; // Screens get length handles on their ends while selected, unlocked and unrotated.
const isScreen = configs[item.type]?.shape === "screen";
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
const r: RectM = resize?.id === item.id ? resize.rect : item; const r: RectM = resize?.id === item.id ? resize.rect : item;
const mid: Record<"left" | "right", readonly [number, number]> = { const mid = {
left: [r.x, r.y + r.height / 2], left: [r.x, r.y + r.height / 2],
right: [r.x + r.width, r.y + r.height / 2], right: [r.x + r.width, r.y + r.height / 2],
}; } as const;
return EDGES.map((edge) => ( return ENDS.map((edge) => (
<Rect <Rect
key={`${item.id}-${edge}`} key={`${item.id}-${edge}`}
x={mid[edge][0] * pxPerM} x={mid[edge][0] * pxPerM}
@@ -158,8 +188,8 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
const p = e.target.getStage()?.getPointerPosition(); const p = e.target.getStage()?.getPointerPosition();
const start = resizeStart.current; const start = resizeStart.current;
if (!p || !start) return; if (!p || !start) return;
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE); const next = resizeLength(start, edge, p.x / pxPerM, room);
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) }); setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
}} }}
onDragEnd={() => { onDragEnd={() => {
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
@@ -180,7 +210,7 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
{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
type={preview.type} shape={preview.shape}
width={preview.width} width={preview.width}
height={preview.height} height={preview.height}
pxPerM={pxPerM} pxPerM={pxPerM}
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
</div> </div>
); );
} }
+11 -17
View File
@@ -1,9 +1,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types"; import type { FurnitureTypeConfig } from "../types";
import { Wheel } from "./Wheel"; import { Wheel } from "./Wheel";
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
interface Props { interface Props {
type: FurnitureType;
config: FurnitureTypeConfig; config: FurnitureTypeConfig;
placed: number; placed: number;
onSetAvailable: (v: number) => void; onSetAvailable: (v: number) => void;
@@ -11,10 +11,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 }; export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
useEffect(() => { useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose(); const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key); window.addEventListener("keydown", key);
@@ -22,26 +19,23 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
}, [onClose]); }, [onClose]);
return ( return (
<div style={{ gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 }}> <div style={panelRoot}>
<button <button onClick={onClose} style={backBtn}>
onClick={onClose}
style={{ alignSelf: "flex-start", border: "none", background: "none", padding: 0, cursor: "pointer", font: "13px system-ui, sans-serif", color: "#555" }}
>
← Back ← Back
</button> </button>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div> <div style={title}>{config.name}</div>
<div> <div>
<div style={label}>Available</div> <div style={label}>Available</div>
<Wheel value={config.available} min={placed} onChange={onSetAvailable} /> <Wheel value={config.available} min={placed} onChange={onSetAvailable} />
<div style={hint}>{placed} placed</div> <div style={hint}>{placed} placed</div>
</div> </div>
{type === "screen" && ( {config.shape === "screen" && (
<div style={hint}>Click a placed screen, then drag an end handle to change its length.</div> <div style={hint}>Click a placed screen, then drag an end handle (or use the Length wheel) to change its length.</div>
)} )}
{type === "table" && ( {config.seatsPerUnit !== undefined && (
<div> <div>
<div style={label}>Seats per table</div> <div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} /> <Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
</div> </div>
)} )}
</div> </div>
+85 -39
View File
@@ -4,12 +4,15 @@ interface Props {
value: number; value: number;
min: number; min: number;
max?: number; max?: number;
step?: number; // value units per wheel notch (default 1)
wrap?: boolean; // loop from max back to min (angles)
rows?: number; // visible rows, odd (default 5)
disabled?: boolean;
format?: (v: number) => string;
onChange: (v: number) => void; onChange: (v: number) => void;
} }
const ROW_H = 34; const ROW_H = 34;
const VISIBLE = 5; // odd, so one row sits in the middle
const REACH = Math.floor(VISIBLE / 2) + 1;
const CLICK_PX = 4; // total movement under this = a tap const CLICK_PX = 4; // total movement under this = a tap
const SETTLE_K = 7; // 1/s; higher = snappier settle (fling distance = velocity / k) const SETTLE_K = 7; // 1/s; higher = snappier settle (fling distance = velocity / k)
const SPRING_W = 12; // 1/s; stiffness of the critically damped settle spring const SPRING_W = 12; // 1/s; stiffness of the critically damped settle spring
@@ -21,34 +24,44 @@ const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), s
* Scroll-wheel number picker (like the iOS timer). * Scroll-wheel number picker (like the iOS timer).
* Drag follows the pointer 1:1, release flings with momentum and settles on a value, * Drag follows the pointer 1:1, release flings with momentum and settles on a value,
* touching it mid-roll stops it instantly. Also: mouse wheel, tap a row, arrow keys. * touching it mid-roll stops it instantly. Also: mouse wheel, tap a row, arrow keys.
* Internally works in integer "notch" space; `step` maps notches to real values.
* If the parent doesn't accept a change (value prop stays put) the wheel settles back to the real value.
*/ */
export function Wheel({ value, min, max = 999, onChange }: Props) { export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: visible = 5, disabled = false, format, onChange }: Props) {
const [pos, setPos] = useState(value); const idx = (v: number) => Math.round(v / step);
const decimals = (String(step).split(".")[1] ?? "").length;
const [pos, setPos] = useState(idx(value));
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const posRef = useRef(value); // fractional position in "value" units const posRef = useRef(idx(value)); // fractional position in notches
const lastEmitted = useRef(value); const lastEmitted = useRef(idx(value));
const raf = useRef(0); const raf = useRef(0);
const settleTimer = useRef(0); const settleTimer = useRef(0);
const targetRef = useRef(value); // where the running animation is heading const targetRef = useRef(idx(value)); // where the running animation is heading
const box = useRef<HTMLDivElement>(null); const box = useRef<HTMLDivElement>(null);
const drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null); const drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null);
const samples = useRef<{ t: number; pos: number }[]>([]); const samples = useRef<{ t: number; pos: number }[]>([]);
// Keep latest props visible to the animation loop. // Keep latest props visible to the animation loop.
const cfg = useRef({ min, max, onChange }); const imin = idx(min);
cfg.current = { min, max, onChange }; const imax = idx(max);
const n = imax - imin + 1;
const cfg = useRef({ imin, imax, n, wrap, step, onChange, valueIdx: idx(value) });
cfg.current = { imin, imax, n, wrap, step, onChange, valueIdx: idx(value) };
const clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, v)); const wrapIdx = (i: number) => cfg.current.imin + ((((i - cfg.current.imin) % cfg.current.n) + cfg.current.n) % cfg.current.n);
const keyOf = (i: number) => (cfg.current.wrap ? wrapIdx(i) : i);
const clamp = (v: number) => (cfg.current.wrap ? v : Math.min(cfg.current.imax, Math.max(cfg.current.imin, v)));
const setPosition = (p: number) => { const setPosition = (p: number) => {
const c = clamp(p); const c = clamp(p);
posRef.current = c; posRef.current = c;
setPos(c); setPos(c);
const v = Math.round(c); const k = keyOf(Math.round(c));
if (v !== lastEmitted.current) { if (k !== lastEmitted.current) {
lastEmitted.current = v; lastEmitted.current = k;
cfg.current.onChange(v); cfg.current.onChange(Math.round(k * cfg.current.step * 1000) / 1000);
} }
return c; return c;
}; };
@@ -60,32 +73,53 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
settleTimer.current = 0; settleTimer.current = 0;
}; };
/** Where the real value sits relative to the current position (shortest way round when wrapping). */
const valueTarget = () => {
const have = Math.round(posRef.current);
if (!cfg.current.wrap) return cfg.current.valueIdx;
const d = wrapIdx(cfg.current.valueIdx - have + cfg.current.imin) - cfg.current.imin; // 0..n-1
return have + (d > cfg.current.n / 2 ? d - cfg.current.n : d);
};
/** One frame after a settle ends, check the parent actually took our value; if not, glide back. */
const reconcileSoon = () => {
raf.current = requestAnimationFrame(() => {
raf.current = 0;
if (drag.current) return;
if (keyOf(Math.round(posRef.current)) !== cfg.current.valueIdx) {
lastEmitted.current = cfg.current.valueIdx;
settleTo(valueTarget(), 0);
}
});
};
/** Ease from the current position to `target` with exponential decay (iOS-style deceleration). */ /** Ease from the current position to `target` with exponential decay (iOS-style deceleration). */
const animateTo = (rawTarget: number) => { const animateTo = (rawTarget: number) => {
stop(); stop();
const target = clamp(Math.round(rawTarget)); const target = clamp(Math.round(rawTarget));
targetRef.current = target; targetRef.current = target;
let last = performance.now(); let last = performance.now();
const step = (now: number) => { const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
last = now; last = now;
const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt)); const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt));
if (Math.abs(target - next) < 0.003) { if (Math.abs(target - next) < 0.003) {
setPosition(target); setPosition(target);
raf.current = 0; raf.current = 0;
reconcileSoon();
return; return;
} }
setPosition(next); setPosition(next);
raf.current = requestAnimationFrame(step); raf.current = requestAnimationFrame(frame);
}; };
raf.current = requestAnimationFrame(step); raf.current = requestAnimationFrame(frame);
}; };
/** /**
* Settle onto `rawTarget` with a critically damped spring that starts at the current release * Settle onto `rawTarget` with a critically damped spring that starts at the current release
* velocity `v0` (rows/s). Smooth start (no velocity jump) and no overshoot, so slow releases glide in. * velocity `v0` (rows/s). Smooth start (no velocity jump) and no overshoot, so slow releases glide in.
*/ */
const settleTo = (rawTarget: number, v0: number) => { function settleTo(rawTarget: number, v0: number) {
stop(); stop();
const target = clamp(Math.round(rawTarget)); const target = clamp(Math.round(rawTarget));
targetRef.current = target; targetRef.current = target;
@@ -97,7 +131,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
const w = toward ? Math.max(SPRING_W, Math.abs(v0) / dist) : SPRING_W; const w = toward ? Math.max(SPRING_W, Math.abs(v0) / dist) : SPRING_W;
const b = v0 + w * a; const b = v0 + w * a;
const t0 = performance.now(); const t0 = performance.now();
const step = (now: number) => { const frame = (now: number) => {
const t = (now - t0) / 1000; const t = (now - t0) / 1000;
const decay = Math.exp(-w * t); const decay = Math.exp(-w * t);
const x = target + (a + b * t) * decay; const x = target + (a + b * t) * decay;
@@ -105,25 +139,29 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) { if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
setPosition(target); setPosition(target);
raf.current = 0; raf.current = 0;
reconcileSoon();
return; return;
} }
setPosition(x); setPosition(x);
raf.current = requestAnimationFrame(step); raf.current = requestAnimationFrame(frame);
};
raf.current = requestAnimationFrame(step);
}; };
raf.current = requestAnimationFrame(frame);
}
// Follow outside changes (e.g. min rising) when idle. // Follow outside changes (rotate button, min rising, selection change) when idle.
useEffect(() => { useEffect(() => {
if (drag.current || raf.current) return; if (drag.current || raf.current) return;
posRef.current = value; const p = valueTarget();
lastEmitted.current = value; posRef.current = p;
setPos(value); lastEmitted.current = keyOf(Math.round(p));
setPos(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); }, [value]);
useEffect(() => stop, []); useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent) => { const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
const wasMoving = raf.current !== 0 || settleTimer.current !== 0; const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
stop(); // stopping power: touching freezes the wheel exactly where it is stop(); // stopping power: touching freezes the wheel exactly where it is
@@ -155,9 +193,9 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
} else { } else {
// plain tap: jump to the tapped row // plain tap: jump to the tapped row
const rect = box.current!.getBoundingClientRect(); const rect = box.current!.getBoundingClientRect();
const rows = Math.round((e.clientY - rect.top - (VISIBLE * ROW_H) / 2) / ROW_H); const r = Math.round((e.clientY - rect.top - (visible * ROW_H) / 2) / ROW_H);
posRef.current = d.pos0; posRef.current = d.pos0;
animateTo(Math.round(d.pos0) + rows); animateTo(Math.round(d.pos0) + r);
} }
return; return;
} }
@@ -177,6 +215,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
}; };
const onWheel = (e: React.WheelEvent) => { const onWheel = (e: React.WheelEvent) => {
if (disabled) return;
if (Math.abs(e.deltaY) >= NOTCH_DELTA) { if (Math.abs(e.deltaY) >= NOTCH_DELTA) {
// mouse notch: one value per notch, repeated notches build on the pending target // mouse notch: one value per notch, repeated notches build on the pending target
const base = raf.current ? targetRef.current : Math.round(posRef.current); const base = raf.current ? targetRef.current : Math.round(posRef.current);
@@ -193,17 +232,22 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
}; };
const onKeyDown = (e: React.KeyboardEvent) => { const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1); if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1);
else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1); else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1);
else return; else return;
e.preventDefault(); e.preventDefault();
}; };
const reach = Math.floor(visible / 2) + 1;
const centre = Math.round(pos); const centre = Math.round(pos);
const rows = []; const lo = wrap ? centre - reach : Math.max(imin, centre - reach);
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) { const hi = wrap ? centre + reach : Math.min(imax, centre + reach);
const items = [];
for (let v = lo; v <= hi; v++) {
const off = v - pos; const off = v - pos;
rows.push( const real = (wrap ? wrapIdx(v) : v) * step;
items.push(
<div <div
key={v} key={v}
style={{ style={{
@@ -216,12 +260,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
justifyContent: "center", justifyContent: "center",
font: "600 20px system-ui, sans-serif", font: "600 20px system-ui, sans-serif",
color: "#222", color: "#222",
opacity: Math.max(0, 1 - Math.abs(off) * 0.38), opacity: Math.max(0, 1 - Math.abs(off) * (visible <= 3 ? 0.55 : 0.38)),
transform: `translateY(${((VISIBLE - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`, transform: `translateY(${((visible - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
{v} {format ? format(real) : real.toFixed(decimals)}
</div>, </div>,
); );
} }
@@ -229,11 +273,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
return ( return (
<div <div
ref={box} ref={box}
tabIndex={0} tabIndex={disabled ? -1 : 0}
role="spinbutton" role="spinbutton"
aria-valuenow={value} aria-valuenow={value}
aria-valuemin={min} aria-valuemin={min}
aria-valuemax={max} aria-valuemax={max}
aria-disabled={disabled}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
@@ -243,11 +288,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
style={{ style={{
position: "relative", position: "relative",
width: "100%", width: "100%",
height: VISIBLE * ROW_H, height: visible * ROW_H,
overflow: "hidden", overflow: "hidden",
background: "#f4f4f4", background: "#f4f4f4",
borderRadius: 10, borderRadius: 10,
cursor: dragging ? "grabbing" : "ns-resize", cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
opacity: disabled ? 0.45 : 1,
userSelect: "none", userSelect: "none",
touchAction: "none", touchAction: "none",
outline: "none", outline: "none",
@@ -258,14 +304,14 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
position: "absolute", position: "absolute",
left: 6, left: 6,
right: 6, right: 6,
top: ((VISIBLE - 1) / 2) * ROW_H, top: ((visible - 1) / 2) * ROW_H,
height: ROW_H, height: ROW_H,
background: "rgba(0,0,0,0.08)", background: "rgba(0,0,0,0.08)",
borderRadius: 8, borderRadius: 8,
pointerEvents: "none", pointerEvents: "none",
}} }}
/> />
{rows} {items}
</div> </div>
); );
} }
+24
View File
@@ -0,0 +1,24 @@
export const panelRoot: React.CSSProperties = { gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 };
export const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
export const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
export const title: React.CSSProperties = { font: "600 15px system-ui, sans-serif", color: "#222" };
export const backBtn: React.CSSProperties = {
alignSelf: "flex-start",
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
font: "13px system-ui, sans-serif",
color: "#555",
};
export const btn: React.CSSProperties = {
height: 32,
padding: "0 12px",
border: "1px solid #c4c4c4",
background: "#fff",
borderRadius: 8,
cursor: "pointer",
font: "13px system-ui, sans-serif",
color: "#222",
};
export const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(96px, 1fr))", gap: 12 };
+61 -53
View File
@@ -1,63 +1,37 @@
// Pure geometry/rules. No React or Konva imports so an optimizer can reuse this. // Pure geometry/rules. No React or Konva imports so an optimizer can reuse this.
import type { FurnitureItem, FurnitureType, Rect, Room } from "./types"; import type { FurnitureItem, Rect, Room } from "./types";
/** Largest px-per-meter that fits the room inside the given pixel box. */ /** Largest px-per-meter that fits the room inside the given pixel box. */
export const fitScale = (room: Room, availW: number, availH: number): number => export const fitScale = (room: Room, availW: number, availH: number): number =>
Math.max(1, Math.min(availW / room.width, availH / room.height)); Math.max(1, Math.min(availW / room.width, availH / room.height));
export const DEFAULT_SIZES: Record<FurnitureType, { width: number; height: number }> = {
chair: { width: 0.5, height: 0.5 },
table: { width: 1.8, height: 0.8 },
screen: { width: 2, height: 0.2 },
};
/** Types the user can resize after placing. */
export const RESIZABLE: Record<FurnitureType, boolean> = { chair: false, table: false, screen: true };
export const MIN_SCREEN_SIZE = { width: 0.5, height: 0.1 };
export type Edge = "left" | "right" | "top" | "bottom";
/**
* New rect when dragging one edge of `start` to the pointer (meters). The opposite edge stays fixed;
* the result never shrinks below `min` and never leaves the room.
*/
export function resizeRect(
start: Rect,
edge: Edge,
px: number,
py: number,
room: Room,
min: { width: number; height: number },
): Rect {
const r = { ...start };
if (edge === "right") {
r.width = clamp(px - start.x, min.width, room.width - start.x);
} else if (edge === "left") {
const right = start.x + start.width;
r.x = clamp(px, 0, right - min.width);
r.width = right - r.x;
} else if (edge === "bottom") {
r.height = clamp(py - start.y, min.height, room.height - start.y);
} else {
const bottom = start.y + start.height;
r.y = clamp(py, 0, bottom - min.height);
r.height = bottom - r.y;
}
return r;
}
export const DEFAULT_ROOM: Room = { width: 10, height: 8 }; export const DEFAULT_ROOM: Room = { width: 10, height: 8 };
/** A rect with an optional rotation (degrees) around its center. */
export type Box = Rect & { rotation?: number };
const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max); const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max);
/** Keep a rect fully inside the room walls. */ export const normalizeAngle = (deg: number): number => ((Math.round(deg) % 360) + 360) % 360;
export function clampToRoom(r: Rect, room: Room): Rect {
return { /** Axis-aligned bounding box of the rotated rect. All collision and wall checks use this. */
...r, export function boundsOf(b: Box): Rect {
x: clamp(r.x, 0, room.width - r.width), const t = ((b.rotation ?? 0) * Math.PI) / 180;
y: clamp(r.y, 0, room.height - r.height), const c = Math.abs(Math.cos(t));
}; const s = Math.abs(Math.sin(t));
const width = b.width * c + b.height * s;
const height = b.width * s + b.height * c;
const cx = b.x + b.width / 2;
const cy = b.y + b.height / 2;
return { x: cx - width / 2, y: cy - height / 2, width, height };
}
/** Shift a box so its (rotated) bounds sit fully inside the room walls. */
export function clampToRoom<T extends Box>(b: T, room: Room): T {
const r = boundsOf(b);
const dx = clamp(r.x, 0, room.width - r.width) - r.x;
const dy = clamp(r.y, 0, room.height - r.height) - r.y;
return { ...b, x: b.x + dx, y: b.y + dy };
} }
/** Axis-aligned overlap. Touching edges do not count as overlap. */ /** Axis-aligned overlap. Touching edges do not count as overlap. */
@@ -81,12 +55,46 @@ export function isInsideRoom(r: Rect, room: Room): boolean {
); );
} }
/** Valid = inside the room and not overlapping any other item (ignoring `ignoreId`). */ /** Valid = bounds inside the room and not overlapping any other item (ignoring `ignoreId`). */
export function isValidPlacement( export function isValidPlacement(
r: Rect, c: Box,
items: FurnitureItem[], items: FurnitureItem[],
room: Room, room: Room,
ignoreId?: string, ignoreId?: string,
): boolean { ): boolean {
return isInsideRoom(r, room) && !items.some((o) => o.id !== ignoreId && overlaps(r, o)); const cb = boundsOf(c);
return isInsideRoom(cb, room) && !items.some((o) => o.id !== ignoreId && overlaps(cb, boundsOf(o)));
}
/**
* Apply a patch (rotation, size, ...) to an item. A size change keeps the center fixed; the result is
* pushed back inside the walls, then rejected (null) if it would overlap anything.
*/
export function tryUpdate(
item: FurnitureItem,
patch: Partial<FurnitureItem>,
items: FurnitureItem[],
room: Room,
): FurnitureItem | null {
let next = { ...item, ...patch };
if (patch.x === undefined && patch.y === undefined) {
next.x = item.x + item.width / 2 - next.width / 2;
next.y = item.y + item.height / 2 - next.height / 2;
}
next = clampToRoom(next, room);
return isValidPlacement(next, items, room, item.id) ? next : null;
}
// ---- Screens: drag an end handle to change length (only while unrotated) ----
export const MIN_SCREEN_LENGTH = 0.5;
/** New rect when dragging the left/right edge of `start` to pointer x (meters). Opposite edge stays fixed. */
export function resizeLength(start: Rect, edge: "left" | "right", px: number, room: Room): Rect {
if (edge === "right") {
return { ...start, width: clamp(px - start.x, MIN_SCREEN_LENGTH, room.width - start.x) };
}
const right = start.x + start.width;
const x = clamp(px, 0, right - MIN_SCREEN_LENGTH);
return { ...start, x, width: right - x };
} }
+5 -1
View File
@@ -1,8 +1,12 @@
// Pure inventory rules. Everything is derived from the items array. // Pure inventory rules. Everything is derived from the items array.
import type { FurnitureItem, FurnitureType } from "./types"; import type { FurnitureItem, FurnitureType, FurnitureTypeConfig } from "./types";
export const placedCount = (items: FurnitureItem[], type: FurnitureType): number => export const placedCount = (items: FurnitureItem[], type: FurnitureType): number =>
items.filter((i) => i.type === type).length; items.filter((i) => i.type === type).length;
export const remaining = (items: FurnitureItem[], type: FurnitureType, available: number): number => export const remaining = (items: FurnitureItem[], type: FurnitureType, available: number): number =>
available - placedCount(items, type); available - placedCount(items, type);
/** Seats for one item: per-table override if set, else the type-level default. */
export const effectiveSeats = (item: FurnitureItem, config: FurnitureTypeConfig): number | undefined =>
item.seatsOverride ?? config.seatsPerUnit;
+33 -26
View File
@@ -1,39 +1,47 @@
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room } from "./types"; import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room } from "./types";
import { DEFAULT_ROOM, DEFAULT_SIZES } from "./geometry"; import { DEFAULT_ROOM } from "./geometry";
// Single source of truth. An optimizer can read `state` and dispatch actions. // Single source of truth. An optimizer can read `state` and dispatch actions.
export interface LayoutState { export interface LayoutState {
room: Room; room: Room;
items: FurnitureItem[]; items: FurnitureItem[];
configs: Record<FurnitureType, FurnitureTypeConfig>; configs: Record<FurnitureType, FurnitureTypeConfig>;
typeOrder: FurnitureType[]; // palette order
} }
export type Action = export type Action =
| { kind: "add"; id: string; type: FurnitureType; x: number; y: number } | { kind: "add"; id: string; type: FurnitureType; x: number; y: number }
| { kind: "move"; id: string; x: number; y: number } | { kind: "move"; id: string; x: number; y: number }
| { kind: "resize"; id: string; x: number; y: number; width: number; height: number } | { kind: "update"; id: string; patch: Partial<FurnitureItem> }
| { kind: "remove"; id: string }
| { kind: "setAvailable"; type: FurnitureType; value: number } | { kind: "setAvailable"; type: FurnitureType; value: number }
| { kind: "setSeats"; value: number }; | { kind: "setSeats"; type: FurnitureType; value: number }
| { kind: "addConfig"; config: FurnitureTypeConfig };
export const initialState: LayoutState = { room: DEFAULT_ROOM, items: [], const BUILT_IN: FurnitureTypeConfig[] = [
configs: { { id: "chair", name: "Chair", shape: "chair", defaultWidth: 0.5, defaultHeight: 0.5, isTable: false, available: 20 },
chair: { type: "chair", available: 20 }, { id: "table", name: "Table", shape: "table", defaultWidth: 1.8, defaultHeight: 0.8, isTable: true, available: 6, seatsPerUnit: 8 },
table: { type: "table", available: 6, seatsPerUnit: 8 }, { id: "screen", name: "Screen", shape: "screen", defaultWidth: 2, defaultHeight: 0.2, isTable: false, available: 2 },
screen: { type: "screen", available: 2 }, ];
},
export const initialState: LayoutState = {
room: DEFAULT_ROOM,
items: [],
configs: Object.fromEntries(BUILT_IN.map((c) => [c.id, c])),
typeOrder: BUILT_IN.map((c) => c.id),
}; };
export function reducer(state: LayoutState, action: Action): LayoutState { export function reducer(state: LayoutState, action: Action): LayoutState {
switch (action.kind) { switch (action.kind) {
case "add": { case "add": {
const { width, height } = DEFAULT_SIZES[action.type]; const cfg = state.configs[action.type];
const item: FurnitureItem = { const item: FurnitureItem = {
id: action.id, id: action.id,
type: action.type, type: action.type,
x: action.x, x: action.x,
y: action.y, y: action.y,
width, width: cfg.defaultWidth,
height, height: cfg.defaultHeight,
rotation: 0, rotation: 0,
locked: false, locked: false,
}; };
@@ -42,31 +50,30 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
case "move": case "move":
return { return {
...state, ...state,
items: state.items.map((i) => items: state.items.map((i) => (i.id === action.id ? { ...i, x: action.x, y: action.y } : i)),
i.id === action.id ? { ...i, x: action.x, y: action.y } : i,
),
}; };
case "resize": case "update":
return { return {
...state, ...state,
items: state.items.map((i) => items: state.items.map((i) => (i.id === action.id ? { ...i, ...action.patch } : i)),
i.id === action.id
? { ...i, x: action.x, y: action.y, width: action.width, height: action.height }
: i,
),
}; };
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "setAvailable": case "setAvailable":
return { return {
...state, ...state,
configs: { configs: { ...state.configs, [action.type]: { ...state.configs[action.type], available: action.value } },
...state.configs,
[action.type]: { ...state.configs[action.type], available: action.value },
},
}; };
case "setSeats": case "setSeats":
return { return {
...state, ...state,
configs: { ...state.configs, table: { ...state.configs.table, seatsPerUnit: action.value } }, configs: { ...state.configs, [action.type]: { ...state.configs[action.type], seatsPerUnit: action.value } },
};
case "addConfig":
return {
...state,
configs: { ...state.configs, [action.config.id]: action.config },
typeOrder: [...state.typeOrder, action.config.id],
}; };
} }
} }
+17 -15
View File
@@ -1,22 +1,19 @@
export type FurnitureType = "chair" | "table" | "screen"; /** Id of a furniture config ("chair", "table", "screen", "custom-1", ...). */
export type FurnitureType = string;
export const FURNITURE_TYPES: FurnitureType[] = ["chair", "table", "screen"]; /** How a type is drawn. Custom seating/tables reuse the chair/table looks. */
export type Shape = "chair" | "table" | "screen";
export const TYPE_LABEL: Record<FurnitureType, string> = {
chair: "Chair",
table: "Table",
screen: "Screen",
};
export interface FurnitureItem { export interface FurnitureItem {
id: string; id: string;
type: FurnitureType; type: FurnitureType; // config id
x: number; // meters, top-left, room coordinates x: number; // meters, top-left of the UNROTATED rect, room coordinates
y: number; y: number;
width: number; // meters width: number; // meters
height: number; height: number;
rotation: number; // degrees, always 0 for now rotation: number; // degrees, rotates around the rect's center
locked: boolean; // always false for now, reserved locked: boolean;
seatsOverride?: number; // tables only: overrides the type-level seatsPerUnit
} }
export interface Room { export interface Room {
@@ -31,9 +28,14 @@ export interface Rect {
height: number; height: number;
} }
// Per-type settings, edited via the palette menu. // Per-type settings. Built-in and custom furniture are just more configs.
export interface FurnitureTypeConfig { export interface FurnitureTypeConfig {
type: FurnitureType; id: FurnitureType;
name: string;
shape: Shape;
defaultWidth: number; // meters
defaultHeight: number;
isTable: boolean;
available: number; // inventory count available: number; // inventory count
seatsPerUnit?: number; // tables only seatsPerUnit?: number; // type-level seats (tables, couches...)
} }