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
+39 -36
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.
## 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.
- 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.
- 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.
- 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:
- Available: how many of that type the venue has. Min = number already placed.
- Tables only: Seats per table (min 1).
- 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).
**Layout (Phase 1)**
- 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.
- 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.
- 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.
- 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).
- 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.
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
@@ -32,47 +33,49 @@ Do not build anything beyond this unless asked. Do not design in a way that bloc
## Data model
```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 {
id: string;
type: FurnitureType;
x: number; // meters, top-left, room coordinates
type: FurnitureType; // config id
x: number; // meters, top-left of the UNROTATED rect, room coordinates
y: number;
width: number; // meters
height: number;
rotation: number; // degrees, always 0 for now
locked: boolean; // always false for now, reserved
rotation: number; // degrees, around the rect's center
locked: boolean;
seatsOverride?: number; // tables: overrides config.seatsPerUnit
}
interface Room { width: number; height: number; } // meters
// Per-type settings, edited via the palette menu
interface FurnitureTypeConfig {
type: FurnitureType;
available: number; // inventory count
seatsPerUnit?: number; // tables only: people who can sit at one
id: FurnitureType; name: string; shape: Shape;
defaultWidth: number; defaultHeight: number; // meters
isTable: boolean;
available: number; // inventory count
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.
Default sizes: chair 0.5 x 0.5 m, table 1.8 x 0.8 m, screen 2 x 0.2 m.
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.
## 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).
- Collision = axis-aligned bounding boxes. Keep collision and clamping logic in pure functions (no React or Konva imports) so the optimizer can reuse them.
- 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 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.
- 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.
## 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/App.tsx`: owns the store, palette drag, room scale.
- `src/components/`: `Palette` (sidebar, tiles, resize), `TypeMenu`, `Wheel`, `Room` (Konva), `FurnitureShape`, `ScaleBar`.
- `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, selection, sidebar mode, palette drag, room scale, Delete key.
- `src/components/`: `Palette`, `TypeMenu`, `ItemPanel`, `CustomForm`, `Wheel`, `Room` (Konva), `FurnitureShape`, `Icons`, `ScaleBar`, `panelStyles`.
## Roadmap (not built yet, don't block)
@@ -99,6 +102,6 @@ npx tsc --noEmit # type check
## 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.
- 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>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furniture Optimizer</title>
<script type="module" crossorigin src="/assets/index-BhQDCmrl.js"></script>
<title>FurnOp</title>
<script type="module" crossorigin src="/assets/index-DDOav9ki.js"></script>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Furniture Optimizer</title>
<title>FurnOp</title>
</head>
<body>
<div id="root"></div>
+128 -37
View File
@@ -1,12 +1,15 @@
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 { FURNITURE_TYPES, type FurnitureType } from "./types";
import { remaining, placedCount } from "./inventory";
import { Palette, TYPE_ICON } from "./components/Palette";
import type { FurnitureItem, FurnitureType, Rect } from "./types";
import { placedCount, remaining } from "./inventory";
import { Palette } from "./components/Palette";
import { Room, type Preview } from "./components/Room";
import { ScaleBar } from "./components/ScaleBar";
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 SCALE_BAR_PX = 40;
@@ -17,14 +20,53 @@ interface PaletteDrag {
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() {
const [state, dispatch] = useReducer(reducer, initialState);
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
const [menu, setMenu] = useState<FurnitureType | null>(null);
const closeMenu = useCallback(() => setMenu(null), []);
const [sidebar, setSidebar] = useState<Sidebar>({ kind: "tiles" });
const [selectedId, setSelectedId] = useState<string | null>(null);
const floorRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
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).
useEffect(() => {
@@ -37,10 +79,9 @@ export default function App() {
ro.observe(el);
return () => ro.disconnect();
}, [state.room]);
const nextId = useRef(1);
// Cursor position -> room meters (item centered on cursor), or null if outside the floor.
const toRoomRect = (d: PaletteDrag) => {
// Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor.
const toRoomRect = (d: PaletteDrag): Rect | null => {
const box = floorRef.current?.getBoundingClientRect();
if (!box) return null;
// 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 py = d.clientY - box.top - border;
if (px < 0 || py < 0 || px > state.room.width * pxPerM || py > state.room.height * pxPerM) return null;
const { width, height } = DEFAULT_SIZES[d.type];
return clampToRoom(
{ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height },
state.room,
);
const { defaultWidth: width, defaultHeight: height } = state.configs[d.type];
return clampToRoom({ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height }, state.room);
};
useEffect(() => {
@@ -67,7 +105,7 @@ export default function App() {
remaining(state.items, type, state.configs[type].available) > 0 &&
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
setPaletteDrag(null);
};
@@ -87,37 +125,90 @@ export default function App() {
if (!paletteDrag) return null;
const r = toRoomRect(paletteDrag);
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
}, [paletteDrag, state.items, state.room, pxPerM]);
const startDrag = (type: FurnitureType, clientX: number, clientY: number) => {
setMenu(null);
setSidebar({ kind: "tiles" });
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(
FURNITURE_TYPES.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
) as Record<FurnitureType, number>;
state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
);
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 (
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
<Palette
types={state.typeOrder.map((t) => state.configs[t])}
remaining={remainingByType}
onStartDrag={startDrag}
onOpenMenu={setMenu}
panel={
menu && (
<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}
/>
)
}
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
onNewCustom={() => openSidebar({ kind: "custom" })}
panel={panel}
/>
<main
ref={mainRef}
@@ -127,10 +218,13 @@ export default function App() {
pxPerM={pxPerM}
room={state.room}
items={state.items}
configs={state.configs}
preview={preview}
floorRef={floorRef}
selectedId={selectedId}
onSelect={select}
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} />
</main>
@@ -145,10 +239,7 @@ export default function App() {
opacity: 0.8,
}}
>
{(() => {
const Icon = TYPE_ICON[paletteDrag.type];
return <Icon />;
})()}
<ShapeIcon shape={state.configs[paletteDrag.type].shape} />
</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 type { FurnitureType } from "../types";
import type { Shape } from "../types";
export type Outline = "normal" | "selected" | "valid" | "invalid";
@@ -11,7 +11,7 @@ const STROKE: Record<Outline, string> = {
};
interface Props {
type: FurnitureType;
shape: Shape;
width: number; // meters
height: number;
pxPerM: number;
@@ -20,13 +20,13 @@ interface Props {
}
/** 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 h = height * pxPerM;
const stroke = STROKE[outline];
const strokeWidth = outline === "normal" ? 1.5 : 3;
if (type === "screen") {
if (shape === "screen") {
const inset = Math.min(3, h / 4);
return (
<Group opacity={opacity}>
@@ -35,7 +35,7 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
</Group>
);
}
if (type === "table") {
if (shape === "table") {
return (
<Group opacity={opacity}>
<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>
);
}
+43 -48
View File
@@ -1,13 +1,16 @@
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;
interface Props {
types: FurnitureTypeConfig[]; // in palette order
remaining: Record<FurnitureType, number>;
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => 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 = {
@@ -29,35 +32,19 @@ const tile: React.CSSProperties = {
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 MAX_W = 600;
interface TileProps {
type: FurnitureType;
config: FurnitureTypeConfig;
remaining: number;
onStartDrag: Props["onStartDrag"];
onOpenMenu: Props["onOpenMenu"];
}
/** 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 onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
@@ -90,7 +77,7 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
return (
<div
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" }}
onPointerDown={onPointerDown}
>
@@ -112,31 +99,27 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
>
{remaining}
</span>
{(() => {
const Icon = TYPE_ICON[type];
return <Icon />;
})()}
{TYPE_LABEL[type]}
<ShapeIcon shape={config.shape} />
{config.name}
</div>
);
}
export function ScreenIcon({ size = 36 }: { size?: number }) {
function NewCustomTile({ onClick }: { onClick: () => void }) {
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>
<div
role="button"
aria-label="New custom furniture"
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> = {
chair: ChairIcon,
table: TableIcon,
screen: ScreenIcon,
};
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => {
@@ -158,20 +141,32 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
style={{
position: "relative",
width,
boxSizing: "border-box",
flexShrink: 0,
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
{panel ?? FURNITURE_TYPES.map((type) => (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<div
style={{
height: "100%",
boxSizing: "border-box",
overflowY: "auto",
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
}}
>
{panel ?? (
<>
{types.map((cfg) => (
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<NewCustomTile onClick={onNewCustom} />
</>
)}
</div>
<div
onPointerDown={startResize}
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 { Stage, Layer, Group, Rect } from "react-konva";
import { Stage, Layer, Group, Rect, Path } from "react-konva";
import type Konva from "konva";
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry";
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types";
import { FurnitureShape } from "./FurnitureShape";
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
import { FurnitureShape, type Outline } from "./FurnitureShape";
export interface Preview {
type: FurnitureType;
shape: Shape;
x: number; // meters
y: number;
width: number;
@@ -17,9 +17,12 @@ export interface Preview {
interface Props {
room: RoomT;
items: FurnitureItem[];
configs: Record<string, FurnitureTypeConfig>;
preview: Preview | null; // new item being dragged in from the palette
pxPerM: number;
floorRef: RefObject<HTMLDivElement | null>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onMove: (id: string, x: number, y: number) => void;
onResize: (id: string, rect: RectM) => void;
}
@@ -36,21 +39,34 @@ interface ResizeInfo {
}
const HANDLE_PX = 10;
// Top-down view: a screen only changes in length, its thickness is fixed.
const EDGES: ("left" | "right")[] = ["left", "right"];
const ENDS: ("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 [selectedId, setSelectedId] = useState<string | null>(null);
const [resize, setResize] = useState<ResizeInfo | null>(null);
const resizeStart = useRef<RectM | null>(null);
const w = room.width * pxPerM;
const h = room.height * pxPerM;
// Where the item would land if dropped now: clamped to walls.
const candidate = (item: FurnitureItem, node: Konva.Node) =>
// 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): FurnitureItem =>
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,
);
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
<Stage
width={w}
height={h}
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
>
<Layer>
{items.map((item) => {
const shape = configs[item.type]?.shape ?? "chair";
const isResizing = resize?.id === item.id;
const r: RectM = isResizing ? resize.rect : item;
const outline = isResizing
const outline: Outline = isResizing
? resize.valid ? "valid" : "invalid"
: drag?.id === item.id
? drag.valid ? "valid" : "invalid"
: selectedId === item.id ? "selected" : "normal";
const half = boundsOf({ ...r, rotation: item.rotation });
const wpx = r.width * pxPerM;
const hpx = r.height * pxPerM;
return (
<Group
key={item.id}
x={r.x * pxPerM}
y={r.y * pxPerM}
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
x={(r.x + r.width / 2) * pxPerM}
y={(r.y + r.height / 2) * pxPerM}
offsetX={wpx / 2}
offsetY={hpx / 2}
rotation={item.rotation}
draggable={!item.locked}
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
dragBoundFunc={(pos) => ({
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM),
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM),
})}
onMouseDown={() => onSelect(item.id)}
onTouchStart={() => onSelect(item.id)}
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
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) => {
e.target.moveToTop();
}}
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
const c = candidate(item, node);
const ok = isValidPlacement(c, items, room, item.id);
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);
setDrag(null);
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "grab";
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
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>
);
})}
{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 mid: Record<"left" | "right", readonly [number, number]> = {
const mid = {
left: [r.x, r.y + r.height / 2],
right: [r.x + r.width, r.y + r.height / 2],
};
return EDGES.map((edge) => (
} as const;
return ENDS.map((edge) => (
<Rect
key={`${item.id}-${edge}`}
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 start = resizeStart.current;
if (!p || !start) return;
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE);
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) });
const next = resizeLength(start, edge, p.x / pxPerM, room);
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
}}
onDragEnd={() => {
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 && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
type={preview.type}
shape={preview.shape}
width={preview.width}
height={preview.height}
pxPerM={pxPerM}
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
</div>
);
}
+11 -17
View File
@@ -1,9 +1,9 @@
import { useEffect } from "react";
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
import type { FurnitureTypeConfig } from "../types";
import { Wheel } from "./Wheel";
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
interface Props {
type: FurnitureType;
config: FurnitureTypeConfig;
placed: number;
onSetAvailable: (v: number) => void;
@@ -11,10 +11,7 @@ interface Props {
onClose: () => void;
}
const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
@@ -22,26 +19,23 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
}, [onClose]);
return (
<div style={{ gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 }}>
<button
onClick={onClose}
style={{ alignSelf: "flex-start", border: "none", background: "none", padding: 0, cursor: "pointer", font: "13px system-ui, sans-serif", color: "#555" }}
>
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Back
</button>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
<div style={title}>{config.name}</div>
<div>
<div style={label}>Available</div>
<Wheel value={config.available} min={placed} onChange={onSetAvailable} />
<div style={hint}>{placed} placed</div>
</div>
{type === "screen" && (
<div style={hint}>Click a placed screen, then drag an end handle to change its length.</div>
{config.shape === "screen" && (
<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 style={label}>Seats per table</div>
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
<div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
<Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
</div>
)}
</div>
+85 -39
View File
@@ -4,12 +4,15 @@ interface Props {
value: number;
min: 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;
}
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 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
@@ -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).
* 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.
* 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) {
const [pos, setPos] = useState(value);
export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: visible = 5, disabled = false, format, onChange }: Props) {
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 posRef = useRef(value); // fractional position in "value" units
const lastEmitted = useRef(value);
const posRef = useRef(idx(value)); // fractional position in notches
const lastEmitted = useRef(idx(value));
const raf = 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 drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null);
const samples = useRef<{ t: number; pos: number }[]>([]);
// Keep latest props visible to the animation loop.
const cfg = useRef({ min, max, onChange });
cfg.current = { min, max, onChange };
const imin = idx(min);
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 c = clamp(p);
posRef.current = c;
setPos(c);
const v = Math.round(c);
if (v !== lastEmitted.current) {
lastEmitted.current = v;
cfg.current.onChange(v);
const k = keyOf(Math.round(c));
if (k !== lastEmitted.current) {
lastEmitted.current = k;
cfg.current.onChange(Math.round(k * cfg.current.step * 1000) / 1000);
}
return c;
};
@@ -60,32 +73,53 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
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). */
const animateTo = (rawTarget: number) => {
stop();
const target = clamp(Math.round(rawTarget));
targetRef.current = target;
let last = performance.now();
const step = (now: number) => {
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt));
if (Math.abs(target - next) < 0.003) {
setPosition(target);
raf.current = 0;
reconcileSoon();
return;
}
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
* 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();
const target = clamp(Math.round(rawTarget));
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 b = v0 + w * a;
const t0 = performance.now();
const step = (now: number) => {
const frame = (now: number) => {
const t = (now - t0) / 1000;
const decay = Math.exp(-w * t);
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) {
setPosition(target);
raf.current = 0;
reconcileSoon();
return;
}
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(() => {
if (drag.current || raf.current) return;
posRef.current = value;
lastEmitted.current = value;
setPos(value);
const p = valueTarget();
posRef.current = p;
lastEmitted.current = keyOf(Math.round(p));
setPos(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
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 {
// plain tap: jump to the tapped row
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;
animateTo(Math.round(d.pos0) + rows);
animateTo(Math.round(d.pos0) + r);
}
return;
}
@@ -177,6 +215,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
};
const onWheel = (e: React.WheelEvent) => {
if (disabled) return;
if (Math.abs(e.deltaY) >= NOTCH_DELTA) {
// mouse notch: one value per notch, repeated notches build on the pending target
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) => {
if (disabled) return;
if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1);
else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1);
else return;
e.preventDefault();
};
const reach = Math.floor(visible / 2) + 1;
const centre = Math.round(pos);
const rows = [];
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
const lo = wrap ? centre - reach : Math.max(imin, centre - reach);
const hi = wrap ? centre + reach : Math.min(imax, centre + reach);
const items = [];
for (let v = lo; v <= hi; v++) {
const off = v - pos;
rows.push(
const real = (wrap ? wrapIdx(v) : v) * step;
items.push(
<div
key={v}
style={{
@@ -216,12 +260,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
justifyContent: "center",
font: "600 20px system-ui, sans-serif",
color: "#222",
opacity: Math.max(0, 1 - Math.abs(off) * 0.38),
transform: `translateY(${((VISIBLE - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
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})`,
pointerEvents: "none",
}}
>
{v}
{format ? format(real) : real.toFixed(decimals)}
</div>,
);
}
@@ -229,11 +273,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
return (
<div
ref={box}
tabIndex={0}
tabIndex={disabled ? -1 : 0}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -243,11 +288,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
style={{
position: "relative",
width: "100%",
height: VISIBLE * ROW_H,
height: visible * ROW_H,
overflow: "hidden",
background: "#f4f4f4",
borderRadius: 10,
cursor: dragging ? "grabbing" : "ns-resize",
cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
opacity: disabled ? 0.45 : 1,
userSelect: "none",
touchAction: "none",
outline: "none",
@@ -258,14 +304,14 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
position: "absolute",
left: 6,
right: 6,
top: ((VISIBLE - 1) / 2) * ROW_H,
top: ((visible - 1) / 2) * ROW_H,
height: ROW_H,
background: "rgba(0,0,0,0.08)",
borderRadius: 8,
pointerEvents: "none",
}}
/>
{rows}
{items}
</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.
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. */
export const fitScale = (room: Room, availW: number, availH: number): number =>
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 };
/** 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);
/** Keep a rect fully inside the room walls. */
export function clampToRoom(r: Rect, room: Room): Rect {
return {
...r,
x: clamp(r.x, 0, room.width - r.width),
y: clamp(r.y, 0, room.height - r.height),
};
export const normalizeAngle = (deg: number): number => ((Math.round(deg) % 360) + 360) % 360;
/** Axis-aligned bounding box of the rotated rect. All collision and wall checks use this. */
export function boundsOf(b: Box): Rect {
const t = ((b.rotation ?? 0) * Math.PI) / 180;
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. */
@@ -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(
r: Rect,
c: Box,
items: FurnitureItem[],
room: Room,
ignoreId?: string,
): 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.
import type { FurnitureItem, FurnitureType } from "./types";
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig } from "./types";
export const placedCount = (items: FurnitureItem[], type: FurnitureType): number =>
items.filter((i) => i.type === type).length;
export const remaining = (items: FurnitureItem[], type: FurnitureType, available: number): number =>
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 { DEFAULT_ROOM, DEFAULT_SIZES } from "./geometry";
import { DEFAULT_ROOM } from "./geometry";
// Single source of truth. An optimizer can read `state` and dispatch actions.
export interface LayoutState {
room: Room;
items: FurnitureItem[];
configs: Record<FurnitureType, FurnitureTypeConfig>;
typeOrder: FurnitureType[]; // palette order
}
export type Action =
| { kind: "add"; id: string; type: FurnitureType; 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: "setSeats"; value: number };
| { kind: "setSeats"; type: FurnitureType; value: number }
| { kind: "addConfig"; config: FurnitureTypeConfig };
export const initialState: LayoutState = { room: DEFAULT_ROOM, items: [],
configs: {
chair: { type: "chair", available: 20 },
table: { type: "table", available: 6, seatsPerUnit: 8 },
screen: { type: "screen", available: 2 },
},
const BUILT_IN: FurnitureTypeConfig[] = [
{ id: "chair", name: "Chair", shape: "chair", defaultWidth: 0.5, defaultHeight: 0.5, isTable: false, available: 20 },
{ id: "table", name: "Table", shape: "table", defaultWidth: 1.8, defaultHeight: 0.8, isTable: true, available: 6, seatsPerUnit: 8 },
{ id: "screen", name: "Screen", shape: "screen", defaultWidth: 2, defaultHeight: 0.2, isTable: false, 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 {
switch (action.kind) {
case "add": {
const { width, height } = DEFAULT_SIZES[action.type];
const cfg = state.configs[action.type];
const item: FurnitureItem = {
id: action.id,
type: action.type,
x: action.x,
y: action.y,
width,
height,
width: cfg.defaultWidth,
height: cfg.defaultHeight,
rotation: 0,
locked: false,
};
@@ -42,31 +50,30 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
case "move":
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, x: action.x, y: action.y } : i,
),
items: state.items.map((i) => (i.id === action.id ? { ...i, x: action.x, y: action.y } : i)),
};
case "resize":
case "update":
return {
...state,
items: state.items.map((i) =>
i.id === action.id
? { ...i, x: action.x, y: action.y, width: action.width, height: action.height }
: i,
),
items: state.items.map((i) => (i.id === action.id ? { ...i, ...action.patch } : i)),
};
case "remove":
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case "setAvailable":
return {
...state,
configs: {
...state.configs,
[action.type]: { ...state.configs[action.type], available: action.value },
},
configs: { ...state.configs, [action.type]: { ...state.configs[action.type], available: action.value } },
};
case "setSeats":
return {
...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"];
export const TYPE_LABEL: Record<FurnitureType, string> = {
chair: "Chair",
table: "Table",
screen: "Screen",
};
/** How a type is drawn. Custom seating/tables reuse the chair/table looks. */
export type Shape = "chair" | "table" | "screen";
export interface FurnitureItem {
id: string;
type: FurnitureType;
x: number; // meters, top-left, room coordinates
type: FurnitureType; // config id
x: number; // meters, top-left of the UNROTATED rect, room coordinates
y: number;
width: number; // meters
height: number;
rotation: number; // degrees, always 0 for now
locked: boolean; // always false for now, reserved
rotation: number; // degrees, rotates around the rect's center
locked: boolean;
seatsOverride?: number; // tables only: overrides the type-level seatsPerUnit
}
export interface Room {
@@ -31,9 +28,14 @@ export interface Rect {
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 {
type: FurnitureType;
id: FurnitureType;
name: string;
shape: Shape;
defaultWidth: number; // meters
defaultHeight: number;
isTable: boolean;
available: number; // inventory count
seatsPerUnit?: number; // tables only
seatsPerUnit?: number; // type-level seats (tables, couches...)
}