Initial React + Vite + TypeScript setup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user