Initial React + Vite + TypeScript setup

This commit is contained in:
2026-10-17 18:56:32 -04:00
parent 444d1b855d
commit 767d9502d6
915 changed files with 985 additions and 338568 deletions
-140
View File
@@ -1,140 +0,0 @@
import { useEffect, useRef, useState } from "react";
interface Props {
value: number; // degrees, 0 = knob at the top, clockwise
disabled?: boolean;
onChange: (deg: number) => void;
}
const SIZE = 120;
const R = 46; // knob orbit radius
/** Circular dial: drag the knob around the ring. Shift snaps to 15°. Arrow keys / scroll nudge by 1°. */
export function AngleDial({ value, disabled = false, onChange }: Props) {
const box = useRef<SVGSVGElement>(null);
const dragging = useRef(false);
// Hover + type an angle. Enter / leaving / 1.5 s idle commits, Escape cancels.
const [hover, setHover] = useState(false);
const [typed, setTyped] = useState("");
const typedRef = useRef("");
const timer = useRef(0);
const latest = useRef({ onChange });
latest.current = { onChange };
const setText = (t: string) => {
typedRef.current = t;
setTyped(t);
window.clearTimeout(timer.current);
if (t) timer.current = window.setTimeout(commit, 1500);
};
function commit() {
window.clearTimeout(timer.current);
const n = parseInt(typedRef.current, 10);
typedRef.current = "";
setTyped("");
if (!Number.isNaN(n)) latest.current.onChange(((n % 360) + 360) % 360);
}
useEffect(() => {
if (!hover || disabled) return;
const key = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const buf = typedRef.current;
const swallow = () => {
e.preventDefault();
e.stopImmediatePropagation(); // keep Backspace from deleting the selected item
};
if (/^[0-9]$/.test(e.key)) {
swallow();
if (buf.length < 3) setText(buf + e.key);
} else if (e.key === "Backspace" || e.key === "Delete") {
swallow();
setText(buf.slice(0, -1));
} else if (e.key === "Enter" && buf) {
swallow();
commit();
} else if (e.key === "Escape" && buf) {
swallow();
setText("");
}
};
window.addEventListener("keydown", key, true);
return () => window.removeEventListener("keydown", key, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hover, disabled]);
useEffect(() => () => window.clearTimeout(timer.current), []);
const norm = (d: number) => ((Math.round(d) % 360) + 360) % 360;
const angleAt = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
let deg = (Math.atan2(dx, -dy) * 180) / Math.PI; // 0 at top, clockwise
if (e.shiftKey) deg = Math.round(deg / 15) * 15;
return norm(deg);
};
const down = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
onChange(angleAt(e));
};
const move = (e: React.PointerEvent) => {
if (dragging.current) onChange(angleAt(e));
};
const up = () => {
dragging.current = false;
};
const rad = (value * Math.PI) / 180;
const c = SIZE / 2;
const kx = c + R * Math.sin(rad);
const ky = c - R * Math.cos(rad);
return (
<svg
ref={box}
width="100%"
viewBox={`0 0 ${SIZE} ${SIZE}`}
style={{ maxWidth: 160, display: "block", margin: "0 auto", touchAction: "none", userSelect: "none", opacity: disabled ? 0.45 : 1, cursor: disabled ? "not-allowed" : "pointer", outline: "none" }}
tabIndex={disabled ? -1 : 0}
role="slider"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={359}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
if (typedRef.current) commit();
}}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onWheel={(e) => !disabled && onChange(norm(value + Math.sign(e.deltaY)))}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "ArrowRight" || e.key === "ArrowUp") onChange(norm(value + 1));
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") onChange(norm(value - 1));
else return;
e.preventDefault();
}}
>
<circle cx={c} cy={c} r={R + 10} fill="#f4f4f4" />
<circle cx={c} cy={c} r={R} fill="#fff" stroke="#bbb" strokeWidth={2} />
{[0, 90, 180, 270].map((t) => {
const a = (t * Math.PI) / 180;
return <line key={t} x1={c + (R - 6) * Math.sin(a)} y1={c - (R - 6) * Math.cos(a)} x2={c + R * Math.sin(a)} y2={c - R * Math.cos(a)} stroke="#bbb" strokeWidth={2} />;
})}
<line x1={c} y1={c} x2={kx} y2={ky} stroke="#1c7ed6" strokeWidth={2} opacity={0.5} />
<circle cx={kx} cy={ky} r={9} fill="#1c7ed6" stroke="#fff" strokeWidth={2} />
<text x={c} y={c + 6} textAnchor="middle" style={{ font: "600 18px system-ui, sans-serif", fill: typed ? "#1c7ed6" : "#222" }}>
{typed ? `${typed}|` : `${value}°`}
</text>
</svg>
);
}
-113
View File
@@ -1,113 +0,0 @@
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>
);
}
-54
View File
@@ -1,54 +0,0 @@
import { Group, Rect } from "react-konva";
import type { Shape } from "../types";
export type Outline = "normal" | "selected" | "valid" | "invalid" | "flagged" | "highlight";
const STROKE: Record<Outline, string> = {
normal: "#3b3b3b",
selected: "#1c7ed6",
flagged: "#f08c00",
highlight: "#d6336c",
valid: "#2f9e44",
invalid: "#e03131",
};
interface Props {
shape: Shape;
width: number; // meters
height: number;
pxPerM: number;
outline: Outline;
opacity?: number;
}
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
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 (shape === "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 (shape === "table") {
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
</Group>
);
}
const bar = Math.max(4, h * 0.2);
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#8fb4d9" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
<Rect width={w} height={bar} fill="#4a6f96" cornerRadius={[3, 3, 0, 0]} listening={false} />
</Group>
);
}
-33
View File
@@ -1,33 +0,0 @@
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} />;
}
-107
View File
@@ -1,107 +0,0 @@
import { useEffect, useState } from "react";
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
import { effectiveSeats } from "../inventory";
import { AngleDial } from "./AngleDial";
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>
{config.rotatable && (
<div>
<div style={label}>Rotation</div>
<AngleDial value={item.rotation} disabled={item.locked} onChange={(v) => patch({ rotation: v })} />
</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>
);
}
-30
View File
@@ -1,30 +0,0 @@
import type { Mode } from "./Room";
export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const seg = (m: Mode): React.CSSProperties => ({
height: 30,
border: "none",
borderRadius: 6,
cursor: "pointer",
font: "600 13px system-ui, sans-serif",
background: mode === m ? "#fff" : "transparent",
color: mode === m ? "#222" : "#666",
boxShadow: mode === m ? "0 1px 3px rgba(0,0,0,0.15)" : "none",
});
return (
<div style={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(54px, 1fr))", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
<button style={seg("layout")} onClick={() => onChange("layout")}>
Layout
</button>
<button style={seg("room")} onClick={() => onChange("room")}>
Room
</button>
<button style={seg("rules")} onClick={() => onChange("rules")}>
Rules
</button>
<button style={seg("optimize")} onClick={() => onChange("optimize")}>
Optimize
</button>
</div>
);
}
-160
View File
@@ -1,160 +0,0 @@
import type { OptimizerState } from "../optimizer/useOptimizer";
import type { ScoreKey } from "../scoring";
import { Wheel } from "./Wheel";
import { btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
export interface OptimizeFormValues {
durationS: number;
seatsNeeded: number;
seed: string; // blank = random
onlyNew: boolean;
}
interface Props {
values: OptimizeFormValues;
onChange: (patch: Partial<OptimizeFormValues>) => void;
state: OptimizerState;
lockedCount: number;
canUndo: boolean;
canRedo: boolean;
onStart: () => void;
onCancel: () => void;
onApply: () => void;
onRevert: () => void;
onUndo: () => void;
onRedo: () => void;
}
const ROWS: [ScoreKey, string][] = [
["loading", "Loading"],
["screen", "Screen"],
["access", "Access"],
["comfort", "Comfort"],
["rules", "Rules"],
];
const pct = (v: number | null) => (v === null ? "n/a" : String(Math.round(v * 100)));
const num = (v: number | null) => (v === null ? "n/a" : String(Math.round(v)));
export function OptimizePanel({ values, onChange, state, lockedCount, canUndo, canRedo, onStart, onCancel, onApply, onRevert, onUndo, onRedo }: Props) {
const running = state.status === "running";
const done = state.status === "done" ? state.result : null;
const locked = lockedCount > 0 ? `${lockedCount} locked item${lockedCount > 1 ? "s" : ""} will not move.` : "";
const delta = (before: number | null, after: number | null, scale: number) => {
if (before === null || after === null) return { text: "", color: "#888" };
const d = (after - before) * scale;
return { text: `${d >= 0 ? "+" : ""}${Math.round(d)}`, color: d > 0.5 ? "#2f9e44" : d < -0.5 ? "#e03131" : "#888" };
};
return (
<div style={panelRoot}>
<div style={title}>Optimize</div>
<div style={twoCol}>
<div>
<div style={label}>Duration (s)</div>
<Wheel value={values.durationS} min={5} max={60} rows={3} disabled={running} onChange={(v) => onChange({ durationS: v })} />
</div>
<div>
<div style={label}>Seats needed</div>
<Wheel value={values.seatsNeeded} min={0} max={999} rows={3} disabled={running} format={(v) => (v === 0 ? "off" : String(v))} onChange={(v) => onChange({ seatsNeeded: v })} />
</div>
</div>
<div>
<div style={label}>Seed</div>
<input
value={values.seed}
disabled={running}
placeholder="random"
inputMode="numeric"
onChange={(e) => onChange({ seed: e.target.value.replace(/[^0-9]/g, "").slice(0, 9) })}
style={{ width: "100%", boxSizing: "border-box", height: 30, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "13px system-ui, sans-serif" }}
/>
</div>
<label style={{ ...hint, marginTop: 0, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
<input type="checkbox" checked={values.onlyNew} disabled={running} onChange={(e) => onChange({ onlyNew: e.target.checked })} />
Only place remaining inventory (existing items stay put)
</label>
{locked && <div style={{ ...hint, marginTop: 0 }}>{locked}</div>}
{running ? (
<>
<div>
<div style={{ height: 10, background: "#ddd", borderRadius: 5, overflow: "hidden" }}>
<div style={{ width: `${Math.round(state.fraction * 100)}%`, height: "100%", background: "#1c7ed6", transition: "width 200ms linear" }} />
</div>
<div style={{ ...hint, display: "flex", justifyContent: "space-between" }}>
<span>Best score: {state.best === null ? "n/a" : state.best.toFixed(1)}</span>
<span>{state.iterations.toLocaleString()} steps</span>
</div>
</div>
<button style={{ ...btn, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onCancel}>
Cancel
</button>
</>
) : (
<button style={{ ...btn, background: "#2b2b2b", color: "#fff" }} onClick={onStart}>
{done ? "Optimize again" : "Optimize"}
</button>
)}
{done && (
<div style={{ background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr auto auto auto", columnGap: 8, rowGap: 3, alignItems: "baseline", font: "12px system-ui, sans-serif", color: "#444" }}>
<span />
<span style={{ color: "#888" }}>before</span>
<span style={{ color: "#888" }}>after</span>
<span />
<b>Overall</b>
<span>{num(done.before.overallWeighted)}</span>
<b>{num(done.after.overallWeighted)}</b>
<span style={{ color: delta(done.before.overallWeighted, done.after.overallWeighted, 1).color }}>{delta(done.before.overallWeighted, done.after.overallWeighted, 1).text}</span>
{ROWS.map(([k, name]) => (
<Row key={k} name={name} before={done.before.scores[k]} after={done.after.scores[k]} d={delta(done.before.scores[k], done.after.scores[k], 100)} />
))}
</div>
{done.autofill && <div style={{ ...hint, marginTop: 4 }}>{done.autofill.message}</div>}
{done.remainingViolations > 0 && (
<div style={{ ...hint, color: "#c05621", marginTop: 0 }}>
{done.remainingViolations} Required rule violation{done.remainingViolations > 1 ? "s" : ""} could not be fixed.
</div>
)}
<div style={{ ...hint, marginTop: 0 }}>
{done.skippedLocked} locked item{done.skippedLocked === 1 ? "" : "s"} skipped · seed {done.seed} · {done.iterations.toLocaleString()} steps
</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, background: "#2b2b2b", color: "#fff" }} onClick={onApply}>
Apply
</button>
<button style={{ ...btn, flex: 1 }} onClick={onRevert}>
Revert
</button>
</div>
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1, opacity: canUndo ? 1 : 0.45 }} disabled={!canUndo || running} onClick={onUndo}>
↶ Undo
</button>
<button style={{ ...btn, flex: 1, opacity: canRedo ? 1 : 0.45 }} disabled={!canRedo || running} onClick={onRedo}>
↷ Redo
</button>
</div>
</div>
);
}
function Row({ name, before, after, d }: { name: string; before: number | null; after: number | null; d: { text: string; color: string } }) {
return (
<>
<span>{name}</span>
<span>{pct(before)}</span>
<span>{pct(after)}</span>
<span style={{ color: d.color }}>{d.text}</span>
</>
);
}
-178
View File
@@ -1,178 +0,0 @@
import { useState, type PointerEvent, type ReactNode } from "react";
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;
onNewCustom: () => void;
header?: ReactNode; // always shown first (mode toggle)
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
}
const tile: React.CSSProperties = {
width: "100%",
height: 84,
boxSizing: "border-box",
background: "#fff",
border: "1px solid #cfcfcf",
borderRadius: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
cursor: "grab",
userSelect: "none",
touchAction: "none",
font: "12px system-ui, sans-serif",
color: "#444",
};
const MIN_W = 152;
const MAX_W = 600;
interface TileProps {
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({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
const type = config.id;
const empty = remaining <= 0;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const { clientX: x0, clientY: y0 } = e;
let dragging = false;
const move = (ev: globalThis.PointerEvent) => {
if (dragging || empty) return;
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) >= CLICK_THRESHOLD_PX) {
dragging = true;
cleanup();
onStartDrag(type, ev.clientX, ev.clientY);
}
};
const up = (ev: globalThis.PointerEvent) => {
cleanup();
// Out-of-stock tiles can't be dragged, so any release over the tile counts as a click.
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < CLICK_THRESHOLD_PX || empty) {
onOpenMenu(type);
}
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<div
role="button"
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}
>
<span
style={{
position: "absolute",
top: 6,
right: 6,
minWidth: 20,
height: 20,
padding: "0 6px",
boxSizing: "border-box",
borderRadius: 10,
background: empty ? "#888" : "#2b2b2b",
color: "#fff",
font: "600 11px/20px system-ui, sans-serif",
textAlign: "center",
}}
>
{remaining}
</span>
<ShapeIcon shape={config.shape} />
{config.name}
</div>
);
}
function NewCustomTile({ onClick }: { onClick: () => void }) {
return (
<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 function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, header, panel }: Props) {
const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = width;
const move = (ev: globalThis.PointerEvent) =>
setWidth(Math.min(MAX_W, Math.max(MIN_W, startW + ev.clientX - startX)));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<aside
style={{
position: "relative",
width,
flexShrink: 0,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
<div
style={{
height: "100%",
boxSizing: "border-box",
overflowY: "auto",
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
}}
>
{header}
{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" }}
/>
</aside>
);
}
-524
View File
@@ -1,524 +0,0 @@
import { useRef, useState, type RefObject } from "react";
import { Stage, Layer, Group, Rect, Path, Arc, Line, RegularPolygon } from "react-konva";
import type Konva from "konva";
import {
SCREEN_DEPTH_M,
boundsOf,
clampToRoom,
doorSwingRect,
isValidObstacle,
isValidPlacement,
isValidWallItem,
resizeLength,
wallLength,
wallRect,
} from "../geometry";
import type { ClearanceSegment, Polyline, SightLine } from "../scoring/types";
import type { Door, FurnitureItem, FurnitureTypeConfig, Obstacle, Rect as RectM, Room as RoomT, RoomScreen, Shape, Wall } from "../types";
import { FurnitureShape, type Outline } from "./FurnitureShape";
export interface Preview {
shape: Shape;
x: number; // meters
y: number;
width: number;
height: number;
valid: boolean;
}
export const WALL_PX = 6; // drawn wall thickness around the floor
export type Mode = "layout" | "room" | "rules" | "optimize";
export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string };
export interface Overlays {
paths: Polyline[] | null; // loading paths from the door
sightLines: SightLine[] | null;
clearance: ClearanceSegment[] | null;
}
interface Props {
flaggedIds: ReadonlySet<string>; // Required-rule violations (orange)
highlightIds: ReadonlySet<string>; // clicked issue
overlays: Overlays;
onDragPreview: (p: { id: string; x: number; y: number } | null) => void;
mode: Mode;
roomSel: RoomSel | null;
onSelectRoom: (sel: RoomSel | null) => void;
onMoveWallItem: (kind: "door" | "screen", id: string, offset: number) => void;
onMoveObstacle: (id: string, x: number, y: number) => void;
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;
}
interface DragInfo {
id: string;
valid: boolean;
}
interface ResizeInfo {
id: string;
rect: RectM;
valid: boolean;
}
interface WallDrag {
kind: "door" | "screen";
id: string;
offset: number;
valid: boolean;
}
const HANDLE_PX = 10;
const O = WALL_PX;
const WALL_COLOR = "#2b2b2b";
const SEL = "#1c7ed6";
const BAD = "#e03131";
const GOOD = "#2f9e44";
/** Wall pieces between door openings, in meters along each wall. */
function wallSegments(room: RoomT): { wall: Wall; a: number; b: number }[] {
const out: { wall: Wall; a: number; b: number }[] = [];
for (const wall of ["top", "bottom", "left", "right"] as Wall[]) {
const len = wallLength(room, wall);
const gaps = room.doors.filter((d) => d.wall === wall).sort((p, q) => p.offset - q.offset);
let at = 0;
for (const g of gaps) {
if (g.offset > at + 1e-6) out.push({ wall, a: at, b: g.offset });
at = Math.max(at, g.offset + g.width);
}
if (at < len - 1e-6) out.push({ wall, a: at, b: len });
}
return out;
}
// Quarter-circle swing: hinge at the door's near end, leaf swings into the room.
const SWING = {
top: { rot: 0, dir: [0, 1] },
bottom: { rot: 270, dir: [0, -1] },
left: { rot: 0, dir: [1, 0] },
right: { rot: 90, dir: [-1, 0] },
} as const;
function hinge(room: RoomT, d: Pick<Door, "wall" | "offset">): [number, number] {
switch (d.wall) {
case "top":
return [d.offset, 0];
case "bottom":
return [d.offset, room.height];
case "left":
return [0, d.offset];
case "right":
return [room.width, d.offset];
}
}
function DoorShape({ room, door, px, color }: { room: RoomT; door: Door; px: number; color: string }) {
const [hx, hy] = hinge(room, door);
const sw = SWING[door.wall];
const r = door.width * px;
return (
<Group listening={false}>
<Arc
x={hx * px}
y={hy * px}
innerRadius={0}
outerRadius={r}
angle={90}
rotation={sw.rot}
fill={door.isLoading ? "rgba(237,137,54,0.2)" : "rgba(66,153,225,0.14)"}
stroke={color}
strokeWidth={1}
dash={[4, 4]}
/>
<Line points={[hx * px, hy * px, hx * px + sw.dir[0] * r, hy * px + sw.dir[1] * r]} stroke={color} strokeWidth={3} lineCap="round" />
</Group>
);
}
const ARROW_ROT: Record<Wall, number> = { top: 180, bottom: 0, left: 90, right: -90 };
function ScreenShape({ room, screen, px, outline }: { room: RoomT; screen: RoomScreen; px: number; outline: string }) {
const r = wallRect(room, screen.wall, screen.offset, screen.width, SCREEN_DEPTH_M);
const rad = Math.max(4, SCREEN_DEPTH_M * px * 0.5);
return (
<Group listening={false}>
<Rect x={r.x * px} y={r.y * px} width={r.width * px} height={r.height * px} fill="#2d3748" stroke={outline} strokeWidth={outline === WALL_COLOR ? 1 : 2.5} cornerRadius={2} />
<RegularPolygon
x={(r.x + r.width / 2) * px}
y={(r.y + r.height / 2) * px}
sides={3}
radius={rad}
rotation={ARROW_ROT[screen.wall]}
fill="#63b3ed"
/>
</Group>
);
}
const ENDS: ("left" | "right")[] = ["left", "right"];
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({ flaggedIds, highlightIds, overlays, onDragPreview, mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstacle, room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
const interactive = mode === "layout";
const roomEdit = mode === "room";
const [drag, setDrag] = useState<DragInfo | null>(null);
const [resize, setResize] = useState<ResizeInfo | null>(null);
const resizeStart = useRef<RectM | null>(null);
const [wallDrag, setWallDrag] = useState<WallDrag | null>(null);
const wallStart = useRef<{ along: number; offset: number } | null>(null);
const [obDrag, setObDrag] = useState<{ id: string; valid: boolean } | null>(null);
const w = room.width * pxPerM;
const h = room.height * pxPerM;
// 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(
{
...item,
x: node.x() / pxPerM - item.width / 2,
y: node.y() / pxPerM - item.height / 2,
},
room,
);
const fw = w + O * 2;
const fh = h + O * 2;
// ---- wall items (doors / screens): slide along their wall only ----
const alongOf = (wall: Wall, p: { x: number; y: number }) => ((wall === "top" || wall === "bottom" ? p.x : p.y) - O) / pxPerM;
const wallHit = (kind: "door" | "screen", id: string, wall: Wall, offset: number, width: number, rect: RectM) => {
return (
<Rect
key={`hit-${id}`}
x={rect.x * pxPerM}
y={rect.y * pxPerM}
width={rect.width * pxPerM}
height={rect.height * pxPerM}
fill="transparent"
draggable
dragBoundFunc={function (this: Konva.Node) {
return this.absolutePosition();
}}
onMouseDown={() => onSelectRoom({ kind, id })}
onTouchStart={() => onSelectRoom({ kind, id })}
onDragStart={(e) => {
e.cancelBubble = true;
const p = e.target.getStage()?.getPointerPosition();
if (p) wallStart.current = { along: alongOf(wall, p), offset };
}}
onDragMove={(e) => {
const p = e.target.getStage()?.getPointerPosition();
const st = wallStart.current;
if (!p || !st) return;
const next = Math.min(Math.max(st.offset + alongOf(wall, p) - st.along, 0), wallLength(room, wall) - width);
setWallDrag({ kind, id, offset: next, valid: isValidWallItem(room, items, { id, kind, wall, offset: next, width }) });
}}
onDragEnd={() => {
if (wallDrag?.valid) onMoveWallItem(kind, id, wallDrag.offset); // else snap back
setWallDrag(null);
wallStart.current = null;
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = wall === "top" || wall === "bottom" ? "ew-resize" : "ns-resize";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
/>
);
};
const colorFor = (kind: "door" | "screen", id: string, normal: string) => {
if (wallDrag?.kind === kind && wallDrag.id === id) return wallDrag.valid ? GOOD : BAD;
return roomSel?.kind === kind && roomSel.id === id ? SEL : normal;
};
return (
<div
style={{
width: fw,
height: fh,
background: "#fff",
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
touchAction: "none",
}}
ref={floorRef}
>
<Stage
width={fw}
height={fh}
onMouseDown={(e) => {
if (e.target !== e.target.getStage()) return;
if (interactive) onSelect(null);
else onSelectRoom(null);
}}
onTouchStart={(e) => {
if (e.target !== e.target.getStage()) return;
if (interactive) onSelect(null);
else onSelectRoom(null);
}}
>
<Layer x={O} y={O}>
<Rect width={w} height={h} fill="#fff" listening={false} />
{/* walls with a gap at every door */}
{wallSegments(room).map((sg, n) => {
const a = sg.a * pxPerM;
const len = (sg.b - sg.a) * pxPerM;
const r =
sg.wall === "top"
? { x: a, y: -O, width: len, height: O }
: sg.wall === "bottom"
? { x: a, y: h, width: len, height: O }
: sg.wall === "left"
? { x: -O, y: a, width: O, height: len }
: { x: w, y: a, width: O, height: len };
return <Rect key={n} {...r} fill={WALL_COLOR} listening={false} />;
})}
{[[-O, -O], [w, -O], [-O, h], [w, h]].map(([x, y]) => (
<Rect key={`${x},${y}`} x={x} y={y} width={O} height={O} fill={WALL_COLOR} listening={false} />
))}
{/* doors: swing arc inside the room */}
{room.doors.map((d) => {
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? { ...d, offset: wallDrag.offset } : d;
return <DoorShape key={d.id} room={room} door={live} px={pxPerM} color={colorFor("door", d.id, d.isLoading ? "#c05621" : "#4a5568")} />;
})}
{/* wall screens */}
{room.screens.map((sc) => {
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? { ...sc, offset: wallDrag.offset } : sc;
return <ScreenShape key={sc.id} room={room} screen={live} px={pxPerM} outline={colorFor("screen", sc.id, WALL_COLOR)} />;
})}
{/* obstacles */}
{room.obstacles.map((o: Obstacle) => {
const state = obDrag?.id === o.id ? (obDrag.valid ? GOOD : BAD) : roomSel?.kind === "obstacle" && roomSel.id === o.id ? SEL : "#4a4a4a";
return (
<Rect
key={o.id}
x={o.x * pxPerM}
y={o.y * pxPerM}
width={o.width * pxPerM}
height={o.height * pxPerM}
fill="#9aa0a6"
stroke={state}
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
cornerRadius={2}
listening={roomEdit}
draggable={roomEdit}
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
dragBoundFunc={(pos) => ({
x: Math.min(Math.max(pos.x, O), O + w - o.width * pxPerM),
y: Math.min(Math.max(pos.y, O), O + h - o.height * pxPerM),
})}
onDragMove={(e) =>
setObDrag({
id: o.id,
valid: isValidObstacle(room, items, { ...o, x: e.target.x() / pxPerM, y: e.target.y() / pxPerM }),
})
}
onDragEnd={(e) => {
const nx = e.target.x() / pxPerM;
const ny = e.target.y() / pxPerM;
const ok = isValidObstacle(room, items, { ...o, x: nx, y: ny });
e.target.position({ x: (ok ? nx : o.x) * pxPerM, y: (ok ? ny : o.y) * pxPerM });
if (ok) onMoveObstacle(o.id, nx, ny);
setObDrag(null);
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c && roomEdit) c.style.cursor = "grab";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
/>
);
})}
{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: Outline = isResizing
? resize.valid ? "valid" : "invalid"
: drag?.id === item.id
? drag.valid ? "valid" : "invalid"
: selectedId === item.id
? "selected"
: highlightIds.has(item.id)
? "highlight"
: flaggedIds.has(item.id)
? "flagged"
: "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 + r.width / 2) * pxPerM}
y={(r.y + r.height / 2) * pxPerM}
offsetX={wpx / 2}
offsetY={hpx / 2}
rotation={item.rotation}
listening={interactive}
draggable={interactive && !item.locked}
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, O + hx), O + w - hx),
y: Math.min(Math.max(pos.y, O + hy), O + h - hy),
};
}}
onDragStart={(e) => {
e.target.moveToTop();
}}
onDragMove={(e) => {
const c = candidate(item, e.target);
setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) });
onDragPreview({ id: item.id, x: c.x, y: c.y });
}}
onDragEnd={(e) => {
const node = e.target;
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 + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
if (ok) onMove(item.id, c.x, c.y);
setDrag(null);
onDragPreview(null);
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
>
<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) => {
// Screens get length handles on their ends while selected, unlocked and unrotated.
const isScreen = configs[item.type]?.shape === "screen";
if (!interactive || 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 = {
left: [r.x, r.y + r.height / 2],
right: [r.x + r.width, r.y + r.height / 2],
} as const;
return ENDS.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 = resizeLength(start, edge, (p.x - O) / 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
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 = "";
}}
/>
));
})}
{roomEdit &&
room.doors.map((d) => {
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? wallDrag.offset : d.offset;
return wallHit("door", d.id, d.wall, live, d.width, doorSwingRect(room, { ...d, offset: live }));
})}
{roomEdit &&
room.screens.map((sc) => {
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? wallDrag.offset : sc.offset;
return wallHit("screen", sc.id, sc.wall, live, sc.width, wallRect(room, sc.wall, live, sc.width, SCREEN_DEPTH_M));
})}
{/* score overlays (toggled in the score panel) */}
{overlays.paths?.map((p, n) => (
<Line key={`p${n}`} points={p.points.flatMap((q) => [q.x * pxPerM, q.y * pxPerM])} stroke="#1c7ed6" strokeWidth={2} opacity={0.55} dash={[6, 4]} listening={false} />
))}
{overlays.sightLines?.map((l, n) => (
<Line key={`s${n}`} points={[l.from.x * pxPerM, l.from.y * pxPerM, l.to.x * pxPerM, l.to.y * pxPerM]} stroke={l.blocked ? BAD : GOOD} strokeWidth={l.blocked ? 1.5 : 1} opacity={l.blocked ? 0.9 : 0.45} listening={false} />
))}
{overlays.clearance?.map((c, n) => (
<Line key={`c${n}`} points={[c.from.x * pxPerM, c.from.y * pxPerM, c.to.x * pxPerM, c.to.y * pxPerM]} stroke={c.ok ? GOOD : BAD} strokeWidth={5} lineCap="round" opacity={0.85} listening={false} />
))}
{preview && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
shape={preview.shape}
width={preview.width}
height={preview.height}
pxPerM={pxPerM}
outline={preview.valid ? "valid" : "invalid"}
opacity={0.85}
/>
</Group>
)}
</Layer>
</Stage>
</div>
);
}
-209
View File
@@ -1,209 +0,0 @@
import { useEffect, useRef } from "react";
import type { Door, Obstacle, Room, RoomScreen } from "../types";
import { DOOR_WIDTH, MAX_SCREENS, OBSTACLE_SIZE, ROOM_SIZE, SCREEN_WIDTH } from "../geometry";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
const fmt = (v: number) => v.toFixed(1);
function useEscape(onClose: () => void) {
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
}
const dangerBtn: React.CSSProperties = { ...btn, color: "#e03131", borderColor: "#e8a8a8" };
// ---------------- Room settings (Room mode, nothing selected) ----------------
interface RoomPanelProps {
room: Room;
minSize: { width: number; height: number };
notice: string;
onResize: (size: { width?: number; height?: number }) => void;
onAddDoor: () => void;
onAddScreen: () => void;
onAddObstacle: () => void;
onExport: () => void;
onImport: (file: File) => void;
}
export function RoomPanel({ room, minSize, notice, onResize, onAddDoor, onAddScreen, onAddObstacle, onExport, onImport }: RoomPanelProps) {
const fileRef = useRef<HTMLInputElement>(null);
const limited = minSize.width > ROOM_SIZE.min || minSize.height > ROOM_SIZE.min;
return (
<div style={panelRoot}>
<div style={title}>Room</div>
<div>
<div style={label}>Size (m)</div>
<div style={twoCol}>
<div>
<div style={hint}>Width</div>
<Wheel value={room.width} min={minSize.width} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ width: v })} />
</div>
<div>
<div style={hint}>Length</div>
<Wheel value={room.height} min={minSize.height} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ height: v })} />
</div>
</div>
{limited && (
<div style={hint}>
The room can't shrink past what's placed in it (min {fmt(minSize.width)} × {fmt(minSize.height)} m).
</div>
)}
</div>
<div style={{ display: "grid", gap: 8 }}>
<button style={btn} onClick={onAddDoor}>
Add door
</button>
<button style={{ ...btn, opacity: room.screens.length >= MAX_SCREENS ? 0.45 : 1 }} disabled={room.screens.length >= MAX_SCREENS} onClick={onAddScreen}>
Add screen
</button>
{room.screens.length >= MAX_SCREENS && <div style={{ ...hint, marginTop: -4 }}>Maximum of {MAX_SCREENS} screens.</div>}
<button style={btn} onClick={onAddObstacle}>
Add obstacle
</button>
</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1 }} onClick={onExport}>
Export room
</button>
<button style={{ ...btn, flex: 1 }} onClick={() => fileRef.current?.click()}>
Import room
</button>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = ""; // allow re-importing the same file
if (f) onImport(f);
}}
/>
</div>
<div style={hint}>Furniture isn't included in room.json.</div>
</div>
);
}
// ---------------- Door ----------------
interface DoorPanelProps {
door: Door;
isLast: boolean;
notice: string;
onPatch: (p: Partial<Door>) => void;
onDelete: () => void;
onClose: () => void;
}
export function DoorPanel({ door, isLast, notice, onPatch, onDelete, onClose }: DoorPanelProps) {
useEscape(onClose);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Done
</button>
<div style={title}>Door</div>
<div>
<div style={label}>Width (m)</div>
<Wheel value={door.width} min={DOOR_WIDTH.min} max={DOOR_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
</div>
<div>
<button
style={{ ...btn, width: "100%", background: door.isLoading ? "#2b2b2b" : "#fff", color: door.isLoading ? "#fff" : "#222" }}
onClick={() => onPatch({ isLoading: !door.isLoading })}
>
Loading door: {door.isLoading ? "on" : "off"}
</button>
<div style={hint}>Furniture is carried through a loading door.</div>
</div>
<div style={hint}>The swing area and 0.6 m inside the opening stay clear of furniture.</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<div>
<button style={{ ...dangerBtn, width: "100%", opacity: isLast ? 0.45 : 1, cursor: isLast ? "not-allowed" : "pointer" }} disabled={isLast} onClick={onDelete}>
Delete door
</button>
{isLast && <div style={hint}>A room needs at least one door.</div>}
</div>
</div>
);
}
// ---------------- Wall screen ----------------
interface ScreenPanelProps {
screen: RoomScreen;
notice: string;
onPatch: (p: Partial<RoomScreen>) => void;
onDelete: () => void;
onClose: () => void;
}
export function ScreenPanel({ screen, notice, onPatch, onDelete, onClose }: ScreenPanelProps) {
useEscape(onClose);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Done
</button>
<div style={title}>Screen</div>
<div>
<div style={label}>Width (m)</div>
<Wheel value={screen.width} min={SCREEN_WIDTH.min} max={SCREEN_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
</div>
<div style={hint}>Drag it along its wall. A 0.3 m strip in front of the wall stays clear.</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<button style={dangerBtn} onClick={onDelete}>
Delete screen
</button>
</div>
);
}
// ---------------- Obstacle ----------------
interface ObstaclePanelProps {
obstacle: Obstacle;
notice: string;
onPatch: (p: Partial<Obstacle>) => void;
onDelete: () => void;
onClose: () => void;
}
export function ObstaclePanel({ obstacle, notice, onPatch, onDelete, onClose }: ObstaclePanelProps) {
useEscape(onClose);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Done
</button>
<div style={title}>Obstacle</div>
<div>
<div style={label}>Size (m)</div>
<div style={twoCol}>
<div>
<div style={hint}>Width</div>
<Wheel value={obstacle.width} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
</div>
<div>
<div style={hint}>Length</div>
<Wheel value={obstacle.height} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ height: v })} />
</div>
</div>
</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<button style={dangerBtn} onClick={onDelete}>
Delete obstacle
</button>
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
import type { FurnitureTypeConfig, Priority, Rule, RuleKind } from "../types";
import { describeRule, type Violation } from "../scoring";
import { Wheel } from "./Wheel";
import { btn, hint, label, panelRoot, title } from "./panelStyles";
interface Props {
rules: Rule[];
configs: Record<string, FurnitureTypeConfig>;
typeOrder: string[];
violations: Violation[];
onAdd: () => void;
onUpdate: (id: string, patch: Partial<Rule>) => void;
onRemove: (id: string) => void;
}
const KINDS: [RuleKind, string][] = [
["keepApart", "Keep apart"],
["keepNear", "Keep near"],
["screenDistance", "Screen distance"],
["doorDistance", "Door distance"],
["againstWall", "Against wall"],
];
const PRIORITIES: [Priority, string][] = [
["required", "Required"],
["high", "High"],
["medium", "Medium"],
["low", "Low"],
];
const fmt = (v: number) => v.toFixed(1);
const select: React.CSSProperties = {
height: 30,
minWidth: 0,
width: "100%",
border: "1px solid #c4c4c4",
borderRadius: 6,
background: "#fff",
font: "13px system-ui, sans-serif",
};
export function RulesPanel({ rules, configs, typeOrder, violations, onAdd, onUpdate, onRemove }: Props) {
const typeSelect = (value: string | undefined, onChange: (v: string) => void) => (
<select style={select} value={value} onChange={(e) => onChange(e.target.value)}>
{typeOrder.map((t) => (
<option key={t} value={t}>
{configs[t].name}
</option>
))}
</select>
);
return (
<div style={panelRoot}>
<div style={title}>Placement rules</div>
<div style={{ ...hint, marginTop: -8 }}>
Rules only measure and flag. Required rules outline broken items in orange but never block dragging.
</div>
{rules.length === 0 && <div style={hint}>No rules yet.</div>}
{rules.map((r) => {
const broken = new Set(violations.filter((v) => v.ruleId === r.id).flatMap((v) => v.itemIds)).size;
const needsB = r.kind === "keepApart" || r.kind === "keepNear";
return (
<div key={r.id} style={{ background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 6 }}>
<select style={select} value={r.kind} onChange={(e) => onUpdate(r.id, { kind: e.target.value as RuleKind, typeB: r.typeB ?? r.typeA, min: r.min ?? 1, max: r.max ?? 6 })}>
{KINDS.map(([k, t]) => (
<option key={k} value={k}>
{t}
</option>
))}
</select>
<button style={{ ...btn, padding: "0 10px", color: "#e03131" }} onClick={() => onRemove(r.id)} aria-label="Delete rule">
✕
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: needsB ? "1fr 1fr" : "1fr", gap: 6 }}>
{typeSelect(r.typeA, (v) => onUpdate(r.id, { typeA: v }))}
{needsB && typeSelect(r.typeB, (v) => onUpdate(r.id, { typeB: v }))}
</div>
{r.kind !== "againstWall" && (
<div style={{ display: "grid", gridTemplateColumns: r.kind === "screenDistance" ? "1fr 1fr" : "1fr", gap: 8 }}>
{r.kind !== "keepNear" && (
<div>
<div style={hint}>{r.kind === "screenDistance" ? "Min (m)" : "At least (m)"}</div>
<Wheel
value={r.min ?? 1}
min={0}
max={30}
step={0.1}
rows={3}
format={fmt}
onChange={(v) => onUpdate(r.id, r.kind === "screenDistance" && v > (r.max ?? 6) ? { min: v, max: v } : { min: v })}
/>
</div>
)}
{(r.kind === "keepNear" || r.kind === "screenDistance") && (
<div>
<div style={hint}>{r.kind === "keepNear" ? "Within (m)" : "Max (m)"}</div>
<Wheel
value={r.max ?? 6}
min={0}
max={30}
step={0.1}
rows={3}
format={fmt}
onChange={(v) => onUpdate(r.id, r.kind === "screenDistance" && v < (r.min ?? 1) ? { max: v, min: v } : { max: v })}
/>
</div>
)}
</div>
)}
<div>
<div style={label}>Priority</div>
<select style={select} value={r.priority} onChange={(e) => onUpdate(r.id, { priority: e.target.value as Priority })}>
{PRIORITIES.map(([p, t]) => (
<option key={p} value={p}>
{t}
</option>
))}
</select>
</div>
<div style={{ ...hint, marginTop: 0, color: broken ? "#c05621" : "#888" }}>
{describeRule(r, configs)}
{broken ? ` · ${broken} item${broken > 1 ? "s" : ""} broken` : ""}
</div>
</div>
);
})}
<button style={btn} onClick={onAdd}>
+ Add rule
</button>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
// Arbitrary scale indicator: a 1 m bar drawn at the current zoom.
export function ScaleBar({ pxPerM, roomWidthPx }: { pxPerM: number; roomWidthPx: number }) {
return (
<div style={{ width: roomWidthPx, height: 40, display: "flex", alignItems: "flex-end", paddingBottom: 4, boxSizing: "border-box" }}>
<div style={{ font: "12px system-ui, sans-serif", color: "#555" }}>
<div style={{ width: pxPerM, height: 6, borderBottom: "2px solid #555", borderLeft: "2px solid #555", borderRight: "2px solid #555", boxSizing: "border-box", marginBottom: 2 }} />
1 m
</div>
</div>
);
}
-126
View File
@@ -1,126 +0,0 @@
import { useState } from "react";
import type { LayoutScore, ScoreKey } from "../scoring";
import type { ScoreWeights } from "../types";
export interface OverlayToggles {
loading: boolean;
sight: boolean;
clearance: boolean;
}
interface Props {
result: LayoutScore;
overall: number | null;
weights: ScoreWeights;
overlays: OverlayToggles;
onWeight: (key: ScoreKey, value: number) => void;
onToggleOverlay: (key: keyof OverlayToggles) => void;
onHighlight: (itemIds: string[]) => void;
}
const LABELS: Record<ScoreKey, string> = { loading: "Loading", screen: "Screen", access: "Access", comfort: "Comfort", rules: "Rules" };
const KEYS = Object.keys(LABELS) as ScoreKey[];
const tint = (v: number) => (v >= 0.75 ? "#2f9e44" : v >= 0.45 ? "#f08c00" : "#e03131");
const small: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#666" };
export function ScorePanel({ result, overall, weights, overlays, onWeight, onToggleOverlay, onHighlight }: Props) {
const [open, setOpen] = useState(true);
const required = result.issues.filter((i) => i.text.startsWith("Required:"));
const others = result.issues.filter((i) => !i.text.startsWith("Required:")).slice(0, 5);
const issueBtn = (color: string): React.CSSProperties => ({
textAlign: "left",
border: "none",
background: "none",
padding: "2px 0",
cursor: "pointer",
font: "12px system-ui, sans-serif",
color,
});
return (
<div style={{ gridColumn: "1 / -1", background: "#fff", border: "1px solid #d4d4d4", borderRadius: 10, padding: 10, display: "flex", flexDirection: "column", gap: 8 }}>
<button
onClick={() => setOpen(!open)}
style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", border: "none", background: "none", padding: 0, cursor: "pointer" }}
>
<span style={{ font: "600 13px system-ui, sans-serif", color: "#444" }}>{open ? "▾" : "▸"} Score</span>
<span style={{ font: "700 22px system-ui, sans-serif", color: overall === null ? "#999" : tint(overall / 100) }}>
{overall === null ? "n/a" : Math.round(overall)}
{overall !== null && <span style={{ ...small, fontWeight: 400 }}> / 100</span>}
</span>
</button>
{open && (
<>
{KEYS.map((k) => {
const v = result.scores[k];
return (
<div key={k} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ width: 52, ...small }}>{LABELS[k]}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ height: 8, background: "#eee", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${(v ?? 0) * 100}%`, height: "100%", background: v === null ? "transparent" : tint(v) }} />
</div>
<div style={{ ...small, fontSize: 11 }}>{v === null ? "n/a" : `${Math.round(v * 100)}%`}</div>
</div>
<div style={{ width: 100, flexShrink: 0, display: "flex", alignItems: "center", gap: 4 }} title="Priority 0-10">
<input
type="range"
min={0}
max={10}
step={1}
value={weights[k]}
onChange={(e) => onWeight(k, Number(e.target.value))}
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
/>
<span style={{ ...small, minWidth: 16, textAlign: "right", fontSize: 11 }}>{weights[k]}</span>
</div>
</div>
);
})}
<div style={{ ...small, fontSize: 11 }}>Left-right sliders set each score's priority (0-10).</div>
{required.length > 0 && (
<div>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#f08c00" }}>Required rules broken</div>
{required.map((i, n) => (
<button key={n} style={issueBtn("#c05621")} onClick={() => onHighlight(i.itemIds)}>
{i.text.replace("Required: ", "")}
</button>
))}
</div>
)}
{others.length > 0 && (
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#555" }}>Top issues</div>
{others.map((i, n) => (
<button key={n} style={issueBtn("#333")} onClick={() => onHighlight(i.itemIds)}>
• {i.text}
</button>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 2, borderTop: "1px solid #eee", paddingTop: 6 }}>
<div style={{ font: "600 12px system-ui, sans-serif", color: "#555" }}>Overlays</div>
{(
[
["loading", "Loading paths"],
["sight", "Sight lines"],
["clearance", "Table clearance"],
] as const
).map(([key, text]) => (
<label key={key} style={{ ...small, display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }}>
<input type="checkbox" checked={overlays[key]} onChange={() => onToggleOverlay(key)} />
{text}
</label>
))}
</div>
</>
)}
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
import { useEffect } from "react";
import type { FurnitureTypeConfig } from "../types";
import { Wheel } from "./Wheel";
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
interface Props {
config: FurnitureTypeConfig;
placed: number;
onSetAvailable: (v: number) => void;
onSetSeats: (v: number) => void;
onClose: () => void;
}
export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Back
</button>
<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>
{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>
)}
{config.seatsPerUnit !== undefined && (
<div>
<div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
<Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
</div>
)}
</div>
);
}
-410
View File
@@ -1,410 +0,0 @@
import { useEffect, useRef, useState } from "react";
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 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.
* 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, 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 [hover, setHover] = useState(false);
const [typed, setTyped] = useState(""); // digits typed while hovering
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(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 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 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 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;
};
const stop = () => {
cancelAnimationFrame(raf.current);
raf.current = 0;
window.clearTimeout(settleTimer.current);
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 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(frame);
};
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.
*/
function 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 frame = (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;
reconcileSoon();
return;
}
setPosition(x);
raf.current = requestAnimationFrame(frame);
};
raf.current = requestAnimationFrame(frame);
}
// Follow outside changes (rotate button, min rising, selection change) when idle.
useEffect(() => {
if (drag.current || raf.current) return;
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, []);
// Hover + type a number. Enter / leaving / 1.5 s idle commits, Escape cancels.
const typedRef = useRef("");
const typeTimer = useRef(0);
const commitTyped = () => {
window.clearTimeout(typeTimer.current);
const text = typedRef.current;
typedRef.current = "";
setTyped("");
const num = parseFloat(text);
if (Number.isNaN(num)) return;
const c = cfg.current;
let i = Math.round(num / c.step);
if (c.wrap) {
// go the short way round to the typed angle
const d = wrapIdx(i) - wrapIdx(Math.round(posRef.current));
i = Math.round(posRef.current) + (Math.abs(d) > c.n / 2 ? d - Math.sign(d) * c.n : d);
} else {
i = Math.min(c.imax, Math.max(c.imin, i));
}
animateTo(i);
};
const setTypedText = (t: string) => {
typedRef.current = t;
setTyped(t);
window.clearTimeout(typeTimer.current);
if (t) typeTimer.current = window.setTimeout(commitTyped, 1500);
};
useEffect(() => {
if (!hover || disabled) return;
const key = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const buf = typedRef.current;
const swallow = () => {
e.preventDefault();
e.stopImmediatePropagation(); // keep Backspace from deleting the selected item
};
if (/^[0-9]$/.test(e.key) || (e.key === "." && decimals > 0 && !buf.includes("."))) {
swallow();
if (buf.length < 6) {
stop();
setTypedText(buf + e.key);
}
} else if (e.key === "Backspace" || e.key === "Delete") {
swallow();
setTypedText(buf.slice(0, -1));
} else if (e.key === "Enter" && buf) {
swallow();
commitTyped();
} else if (e.key === "Escape" && buf) {
swallow();
setTypedText("");
}
};
window.addEventListener("keydown", key, true);
return () => window.removeEventListener("keydown", key, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hover, disabled, decimals]);
useEffect(() => () => window.clearTimeout(typeTimer.current), []);
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
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 r = Math.round((e.clientY - rect.top - (visible * ROW_H) / 2) / ROW_H);
posRef.current = d.pos0;
animateTo(Math.round(d.pos0) + r);
}
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 (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);
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 (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 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;
const real = (wrap ? wrapIdx(v) : v) * step;
items.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) * (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",
}}
>
{format ? format(real) : real.toFixed(decimals)}
</div>,
);
}
return (
<div
ref={box}
tabIndex={disabled ? -1 : 0}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onWheel={onWheel}
onKeyDown={onKeyDown}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => {
setHover(false);
if (typedRef.current) commitTyped();
}}
title={disabled ? undefined : "Scroll, drag, or hover and type a number"}
style={{
position: "relative",
width: "100%",
height: visible * ROW_H,
overflow: "hidden",
background: "#f4f4f4",
borderRadius: 10,
cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
opacity: disabled ? 0.45 : 1,
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",
}}
/>
{items}
{typed && (
<div
style={{
position: "absolute",
left: 6,
right: 6,
top: ((visible - 1) / 2) * ROW_H,
height: ROW_H,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#fff",
border: "1.5px solid #1c7ed6",
borderRadius: 8,
boxSizing: "border-box",
font: "600 20px system-ui, sans-serif",
color: "#1c7ed6",
pointerEvents: "none",
}}
>
{typed}
<span style={{ width: 1.5, height: 20, background: "#1c7ed6", marginLeft: 2 }} />
</div>
)}
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
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 };