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