second commit

This commit is contained in:
2026-10-17 12:30:46 -04:00
parent 56046a63fc
commit abc3800db2
14 changed files with 533 additions and 136 deletions
+22 -13
View File
@@ -6,17 +6,20 @@ A web tool for venues (events, banquets, conferences) to lay out furniture in a
The ONLY feature right now is drag-and-drop layout:
- Left palette with two source tiles: chair and table.
- Large rectangular room on the right (fixed 10m x 8m, drawn to scale, 1m = 60px, faint 0.5m grid).
- Click (no drag) a palette tile to open a settings menu for that furniture type:
- Available: how many of that type the venue has (number stepper, min = number already placed).
- Tables only: Seats per table (number stepper, min 1).
- Palette tiles show a badge with remaining count (available minus placed).
- 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.
- Distinguish click from drag with a small movement threshold (about 5px).
- 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. Invalid position = red outline while dragging; on drop, snap back to last valid position (or cancel the add if new).
- Snap to the 0.5m grid on drop.
- 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.
@@ -29,7 +32,7 @@ Do not build anything beyond this unless asked. Do not design in a way that bloc
## Data model
```ts
type FurnitureType = "chair" | "table";
type FurnitureType = "chair" | "table" | "screen";
interface FurnitureItem {
id: string;
@@ -55,16 +58,22 @@ interface FurnitureTypeConfig {
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.
Default sizes: chair 0.5 x 0.5 m, table 1.8 x 0.8 m, screen 2 x 0.2 m.
## Conventions
- Store everything in meters. Convert to pixels only at render time (one `PX_PER_M` constant).
- 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.
- 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`.
## Roadmap (not built yet, don't block)
Venue-focused features planned for later phases:
+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
+1 -1
View File
@@ -4,7 +4,7 @@
<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-Cp-y6ttL.js"></script>
<script type="module" crossorigin src="/assets/index-BhQDCmrl.js"></script>
</head>
<body>
<div id="root"></div>
+10 -7
View File
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { DEFAULT_SIZES, fitScale, isValidPlacement, clampToRoom } from "./geometry";
import { initialState, reducer } from "./store";
import type { FurnitureType } from "./types";
import { FURNITURE_TYPES, type FurnitureType } from "./types";
import { remaining, placedCount } from "./inventory";
import { Palette, ChairIcon, TableIcon } from "./components/Palette";
import { Palette, TYPE_ICON } from "./components/Palette";
import { Room, type Preview } from "./components/Room";
import { ScaleBar } from "./components/ScaleBar";
import { TypeMenu } from "./components/TypeMenu";
@@ -96,10 +96,9 @@ export default function App() {
setPaletteDrag({ type, clientX, clientY });
};
const remainingByType = {
chair: remaining(state.items, "chair", state.configs.chair.available),
table: remaining(state.items, "table", state.configs.table.available),
};
const remainingByType = Object.fromEntries(
FURNITURE_TYPES.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
) as Record<FurnitureType, number>;
return (
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
@@ -131,6 +130,7 @@ export default function App() {
preview={preview}
floorRef={floorRef}
onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })}
onResize={(id, r) => dispatch({ kind: "resize", id, ...r })}
/>
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
</main>
@@ -145,7 +145,10 @@ export default function App() {
opacity: 0.8,
}}
>
{paletteDrag.type === "chair" ? <ChairIcon /> : <TableIcon />}
{(() => {
const Icon = TYPE_ICON[paletteDrag.type];
return <Icon />;
})()}
</div>
)}
</div>
+11 -1
View File
@@ -1,10 +1,11 @@
import { Group, Rect } from "react-konva";
import type { FurnitureType } from "../types";
export type Outline = "normal" | "valid" | "invalid";
export type Outline = "normal" | "selected" | "valid" | "invalid";
const STROKE: Record<Outline, string> = {
normal: "#3b3b3b",
selected: "#1c7ed6",
valid: "#2f9e44",
invalid: "#e03131",
};
@@ -25,6 +26,15 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
const stroke = STROKE[outline];
const strokeWidth = outline === "normal" ? 1.5 : 3;
if (type === "screen") {
const inset = Math.min(3, h / 4);
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#2d3748" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={2} />
<Rect x={inset} y={inset} width={Math.max(0, w - inset * 2)} height={Math.max(0, h - inset * 2)} fill="#63b3ed" listening={false} />
</Group>
);
}
if (type === "table") {
return (
<Group opacity={opacity}>
+22 -4
View File
@@ -1,5 +1,5 @@
import { useState, type PointerEvent, type ReactNode } from "react";
import type { FurnitureType } from "../types";
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types";
const CLICK_THRESHOLD_PX = 5;
@@ -112,12 +112,30 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
>
{remaining}
</span>
{type === "chair" ? <ChairIcon /> : <TableIcon />}
{type === "chair" ? "Chair" : "Table"}
{(() => {
const Icon = TYPE_ICON[type];
return <Icon />;
})()}
{TYPE_LABEL[type]}
</div>
);
}
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 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) {
const [width, setWidth] = useState(264);
@@ -151,7 +169,7 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
background: "#ececec",
}}
>
{panel ?? (["chair", "table"] as const).map((type) => (
{panel ?? FURNITURE_TYPES.map((type) => (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<div
+90 -11
View File
@@ -1,8 +1,8 @@
import { useState, type RefObject } from "react";
import { Stage, Layer, Group } from "react-konva";
import { useRef, useState, type RefObject } from "react";
import { Stage, Layer, Group, Rect } from "react-konva";
import type Konva from "konva";
import { isValidPlacement, clampToRoom } from "../geometry";
import type { FurnitureItem, FurnitureType, Room as RoomT } from "../types";
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";
export interface Preview {
@@ -21,6 +21,7 @@ interface Props {
pxPerM: number;
floorRef: RefObject<HTMLDivElement | null>;
onMove: (id: string, x: number, y: number) => void;
onResize: (id: string, rect: RectM) => void;
}
interface DragInfo {
@@ -28,8 +29,21 @@ interface DragInfo {
valid: boolean;
}
export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props) {
interface ResizeInfo {
id: string;
rect: RectM;
valid: boolean;
}
const HANDLE_PX = 10;
// Top-down view: a screen only changes in length, its thickness is fixed.
const EDGES: ("left" | "right")[] = ["left", "right"];
export function Room({ room, items, preview, pxPerM, floorRef, 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;
@@ -53,16 +67,28 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props)
touchAction: "none",
}}
>
<Stage width={w} height={h}>
<Stage
width={w}
height={h}
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
>
<Layer>
{items.map((item) => {
const outline =
drag?.id === item.id ? (drag.valid ? "valid" : "invalid") : "normal";
const isResizing = resize?.id === item.id;
const r: RectM = isResizing ? resize.rect : item;
const outline = isResizing
? resize.valid ? "valid" : "invalid"
: drag?.id === item.id
? drag.valid ? "valid" : "invalid"
: selectedId === item.id ? "selected" : "normal";
return (
<Group
key={item.id}
x={item.x * pxPerM}
y={item.y * pxPerM}
x={r.x * pxPerM}
y={r.y * pxPerM}
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
draggable={!item.locked}
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
dragBoundFunc={(pos) => ({
@@ -94,10 +120,63 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props)
if (c) c.style.cursor = "";
}}
>
<FurnitureShape type={item.type} width={item.width} height={item.height} pxPerM={pxPerM} outline={outline} />
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
</Group>
);
})}
{items.map((item) => {
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
const r: RectM = resize?.id === item.id ? resize.rect : item;
const mid: Record<"left" | "right", readonly [number, number]> = {
left: [r.x, r.y + r.height / 2],
right: [r.x + r.width, r.y + r.height / 2],
};
return EDGES.map((edge) => (
<Rect
key={`${item.id}-${edge}`}
x={mid[edge][0] * pxPerM}
y={mid[edge][1] * pxPerM}
offsetX={HANDLE_PX / 2}
offsetY={HANDLE_PX / 2}
width={HANDLE_PX}
height={HANDLE_PX}
fill="#fff"
stroke="#1c7ed6"
strokeWidth={2}
cornerRadius={2}
hitStrokeWidth={12}
draggable
// The handle is pinned in place; the pointer drives the new size, React moves the handle.
dragBoundFunc={function (this: Konva.Node) {
return this.absolutePosition();
}}
onDragStart={(e) => {
e.cancelBubble = true;
resizeStart.current = { x: item.x, y: item.y, width: item.width, height: item.height };
}}
onDragMove={(e) => {
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) });
}}
onDragEnd={() => {
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
setResize(null);
resizeStart.current = null;
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "ew-resize";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
/>
));
})}
{preview && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
-52
View File
@@ -1,52 +0,0 @@
import { useEffect, useState } from "react";
interface Props {
value: number;
min: number;
onChange: (v: number) => void;
}
const btn: React.CSSProperties = {
width: 28,
height: 28,
flexShrink: 0,
border: "1px solid #c4c4c4",
background: "#fff",
borderRadius: 6,
cursor: "pointer",
font: "16px system-ui, sans-serif",
lineHeight: 1,
};
/** - / input / + . Typing is committed on blur or Enter so intermediate values aren't clamped. */
export function Stepper({ value, min, onChange }: Props) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
const n = Math.round(Number(draft));
if (draft.trim() === "" || Number.isNaN(n)) return setDraft(String(value));
const v = Math.max(min, n);
setDraft(String(v));
if (v !== value) onChange(v);
};
return (
<div style={{ display: "flex", gap: 6, alignItems: "center", width: "100%" }}>
<button style={btn} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 1))} aria-label="Decrease">
−
</button>
<input
value={draft}
inputMode="numeric"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
style={{ flex: 1, minWidth: 0, height: 28, boxSizing: "border-box", textAlign: "center", border: "1px solid #c4c4c4", borderRadius: 6, font: "14px system-ui, sans-serif" }}
/>
<button style={btn} onClick={() => onChange(value + 1)} aria-label="Increase">
+
</button>
</div>
);
}
+8 -5
View File
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import type { FurnitureType, FurnitureTypeConfig } from "../types";
import { Stepper } from "./Stepper";
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
import { Wheel } from "./Wheel";
interface Props {
type: FurnitureType;
@@ -29,16 +29,19 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
>
← Back
</button>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{type === "chair" ? "Chair" : "Table"}</div>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
<div>
<div style={label}>Available</div>
<Stepper value={config.available} min={placed} onChange={onSetAvailable} />
<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>
)}
{type === "table" && (
<div>
<div style={label}>Seats per table</div>
<Stepper value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
</div>
)}
</div>
+271
View File
@@ -0,0 +1,271 @@
import { useEffect, useRef, useState } from "react";
interface Props {
value: number;
min: number;
max?: number;
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
const SLOW_VEL = 4; // rows/s; releases slower than this settle with the spring, faster ones fling
const MAX_VEL = 40; // rows/s
const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), smaller are trackpad
/**
* 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.
*/
export function Wheel({ value, min, max = 999, onChange }: Props) {
const [pos, setPos] = useState(value);
const [dragging, setDragging] = useState(false);
const posRef = useRef(value); // fractional position in "value" units
const lastEmitted = useRef(value);
const raf = useRef(0);
const settleTimer = useRef(0);
const targetRef = useRef(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 clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, 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);
}
return c;
};
const stop = () => {
cancelAnimationFrame(raf.current);
raf.current = 0;
window.clearTimeout(settleTimer.current);
settleTimer.current = 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 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;
return;
}
setPosition(next);
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
/**
* 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) => {
stop();
const target = clamp(Math.round(rawTarget));
targetRef.current = target;
const x0 = posRef.current;
const a = x0 - target;
// Moving toward the target faster than the spring allows would overshoot: stiffen to avoid it.
const dist = Math.abs(a);
const toward = dist > 0 && Math.sign(v0) === -Math.sign(a);
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 t = (now - t0) / 1000;
const decay = Math.exp(-w * t);
const x = target + (a + b * t) * decay;
const vel = (b - w * (a + b * t)) * decay;
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
setPosition(target);
raf.current = 0;
return;
}
setPosition(x);
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
// Follow outside changes (e.g. min rising) when idle.
useEffect(() => {
if (drag.current || raf.current) return;
posRef.current = value;
lastEmitted.current = value;
setPos(value);
}, [value]);
useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
stop(); // stopping power: touching freezes the wheel exactly where it is
drag.current = { y0: e.clientY, pos0: posRef.current, moved: false, wasMoving };
samples.current = [{ t: performance.now(), pos: posRef.current }];
setDragging(true);
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
const dy = e.clientY - d.y0;
if (Math.abs(dy) > CLICK_PX) d.moved = true;
const p = setPosition(d.pos0 - dy / ROW_H); // drag up = bigger number, 1:1 from the first pixel
const now = performance.now();
samples.current.push({ t: now, pos: p });
samples.current = samples.current.filter((s) => now - s.t < 100);
};
const onPointerUp = (e: React.PointerEvent) => {
const d = drag.current;
drag.current = null;
setDragging(false);
if (!d) return;
if (!d.moved) {
if (d.wasMoving) {
settleTo(posRef.current, 0); // tap that caught a rolling wheel just stops it on a value
} 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);
posRef.current = d.pos0;
animateTo(Math.round(d.pos0) + rows);
}
return;
}
// Release velocity from the last ~100ms of movement (zero if the finger rested before lifting).
const now = performance.now();
const s = samples.current;
let v = 0;
if (s.length > 1 && now - s[s.length - 1].t < 80) {
const first = s[0];
const dt = (s[s.length - 1].t - first.t) / 1000;
if (dt > 0.005) v = (s[s.length - 1].pos - first.pos) / dt;
}
v = Math.max(-MAX_VEL, Math.min(MAX_VEL, v));
if (Math.abs(v) < SLOW_VEL) settleTo(posRef.current + v * 0.1, v);
else animateTo(posRef.current + v / SETTLE_K);
};
const onWheel = (e: React.WheelEvent) => {
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);
animateTo(base + Math.sign(e.deltaY));
return;
}
// trackpad: follow continuously, settle when it goes quiet
stop();
setPosition(posRef.current + e.deltaY / (ROW_H * 1.5));
settleTimer.current = window.setTimeout(() => {
settleTimer.current = 0;
settleTo(posRef.current, 0);
}, 120);
};
const onKeyDown = (e: React.KeyboardEvent) => {
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 centre = Math.round(pos);
const rows = [];
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
const off = v - pos;
rows.push(
<div
key={v}
style={{
position: "absolute",
left: 0,
right: 0,
height: ROW_H,
display: "flex",
alignItems: "center",
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})`,
pointerEvents: "none",
}}
>
{v}
</div>,
);
}
return (
<div
ref={box}
tabIndex={0}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onWheel={onWheel}
onKeyDown={onKeyDown}
style={{
position: "relative",
width: "100%",
height: VISIBLE * ROW_H,
overflow: "hidden",
background: "#f4f4f4",
borderRadius: 10,
cursor: dragging ? "grabbing" : "ns-resize",
userSelect: "none",
touchAction: "none",
outline: "none",
}}
>
<div
style={{
position: "absolute",
left: 6,
right: 6,
top: ((VISIBLE - 1) / 2) * ROW_H,
height: ROW_H,
background: "rgba(0,0,0,0.08)",
borderRadius: 8,
pointerEvents: "none",
}}
/>
{rows}
</div>
);
}
+37
View File
@@ -8,8 +8,45 @@ export const fitScale = (room: Room, availW: number, availH: number): number =>
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 };
const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max);
+11
View File
@@ -11,6 +11,7 @@ export interface LayoutState {
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: "setAvailable"; type: FurnitureType; value: number }
| { kind: "setSeats"; value: number };
@@ -18,6 +19,7 @@ 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 },
},
};
@@ -44,6 +46,15 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
i.id === action.id ? { ...i, x: action.x, y: action.y } : i,
),
};
case "resize":
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,
),
};
case "setAvailable":
return {
...state,
+9 -1
View File
@@ -1,4 +1,12 @@
export type FurnitureType = "chair" | "table";
export type FurnitureType = "chair" | "table" | "screen";
export const FURNITURE_TYPES: FurnitureType[] = ["chair", "table", "screen"];
export const TYPE_LABEL: Record<FurnitureType, string> = {
chair: "Chair",
table: "Table",
screen: "Screen",
};
export interface FurnitureItem {
id: string;