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
+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>
);
}