third commit
This commit is contained in:
+128
-37
@@ -1,12 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { DEFAULT_SIZES, fitScale, isValidPlacement, clampToRoom } from "./geometry";
|
||||
import { clampToRoom, fitScale, isValidPlacement, tryUpdate } from "./geometry";
|
||||
import { initialState, reducer } from "./store";
|
||||
import { FURNITURE_TYPES, type FurnitureType } from "./types";
|
||||
import { remaining, placedCount } from "./inventory";
|
||||
import { Palette, TYPE_ICON } from "./components/Palette";
|
||||
import type { FurnitureItem, FurnitureType, Rect } from "./types";
|
||||
import { placedCount, remaining } from "./inventory";
|
||||
import { Palette } from "./components/Palette";
|
||||
import { Room, type Preview } from "./components/Room";
|
||||
import { ScaleBar } from "./components/ScaleBar";
|
||||
import { TypeMenu } from "./components/TypeMenu";
|
||||
import { ItemPanel } from "./components/ItemPanel";
|
||||
import { CustomForm, type CustomDraft } from "./components/CustomForm";
|
||||
import { ShapeIcon } from "./components/Icons";
|
||||
|
||||
const WALL_PX = 6; // room wall border, must match Room.tsx
|
||||
const SCALE_BAR_PX = 40;
|
||||
@@ -17,14 +20,53 @@ interface PaletteDrag {
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
// What the sidebar shows besides the tiles. An item panel appears whenever an item is selected.
|
||||
type Sidebar = { kind: "tiles" } | { kind: "type"; id: FurnitureType } | { kind: "custom" };
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [paletteDrag, setPaletteDrag] = useState<PaletteDrag | null>(null);
|
||||
const [menu, setMenu] = useState<FurnitureType | null>(null);
|
||||
const closeMenu = useCallback(() => setMenu(null), []);
|
||||
const [sidebar, setSidebar] = useState<Sidebar>({ kind: "tiles" });
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const floorRef = useRef<HTMLDivElement>(null);
|
||||
const mainRef = useRef<HTMLElement>(null);
|
||||
const [pxPerM, setPxPerM] = useState(60);
|
||||
const nextItemId = useRef(1);
|
||||
const nextCustomId = useRef(1);
|
||||
|
||||
const selectedItem = state.items.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
// Selecting an item, opening a menu and dragging from the palette each replace the others.
|
||||
const select = useCallback((id: string | null) => {
|
||||
setSelectedId(id);
|
||||
if (id) setSidebar({ kind: "tiles" });
|
||||
}, []);
|
||||
const openSidebar = (s: Sidebar) => {
|
||||
setSelectedId(null);
|
||||
setSidebar(s);
|
||||
};
|
||||
const closeSidebar = useCallback(() => setSidebar({ kind: "tiles" }), []);
|
||||
const deselect = useCallback(() => setSelectedId(null), []);
|
||||
|
||||
const deleteSelected = useCallback(() => {
|
||||
if (!selectedId) return;
|
||||
dispatch({ kind: "remove", id: selectedId });
|
||||
setSelectedId(null);
|
||||
}, [selectedId]);
|
||||
|
||||
// Delete / Backspace (Mac "delete") removes the selected item, unless the user is typing.
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
if (!selectedId) return;
|
||||
e.preventDefault();
|
||||
deleteSelected();
|
||||
};
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [selectedId, deleteSelected]);
|
||||
|
||||
// Maximise the room inside the center area (minus wall border and the scale bar).
|
||||
useEffect(() => {
|
||||
@@ -37,10 +79,9 @@ export default function App() {
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [state.room]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
// Cursor position -> room meters (item centered on cursor), or null if outside the floor.
|
||||
const toRoomRect = (d: PaletteDrag) => {
|
||||
// Cursor position -> a new item's box (centered on cursor, clamped), or null if outside the floor.
|
||||
const toRoomRect = (d: PaletteDrag): Rect | null => {
|
||||
const box = floorRef.current?.getBoundingClientRect();
|
||||
if (!box) return null;
|
||||
// Floor excludes the CSS border: the Stage sits inside it.
|
||||
@@ -48,11 +89,8 @@ export default function App() {
|
||||
const px = d.clientX - box.left - border;
|
||||
const py = d.clientY - box.top - border;
|
||||
if (px < 0 || py < 0 || px > state.room.width * pxPerM || py > state.room.height * pxPerM) return null;
|
||||
const { width, height } = DEFAULT_SIZES[d.type];
|
||||
return clampToRoom(
|
||||
{ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height },
|
||||
state.room,
|
||||
);
|
||||
const { defaultWidth: width, defaultHeight: height } = state.configs[d.type];
|
||||
return clampToRoom({ x: px / pxPerM - width / 2, y: py / pxPerM - height / 2, width, height }, state.room);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +105,7 @@ export default function App() {
|
||||
remaining(state.items, type, state.configs[type].available) > 0 &&
|
||||
isValidPlacement(r, state.items, state.room)
|
||||
) {
|
||||
dispatch({ kind: "add", id: `item-${nextId.current++}`, type, x: r.x, y: r.y });
|
||||
dispatch({ kind: "add", id: `item-${nextItemId.current++}`, type, x: r.x, y: r.y });
|
||||
} // outside the room or invalid: cancel
|
||||
setPaletteDrag(null);
|
||||
};
|
||||
@@ -87,37 +125,90 @@ export default function App() {
|
||||
if (!paletteDrag) return null;
|
||||
const r = toRoomRect(paletteDrag);
|
||||
if (!r) return null;
|
||||
return { type: paletteDrag.type, ...r, valid: isValidPlacement(r, state.items, state.room) };
|
||||
return {
|
||||
shape: state.configs[paletteDrag.type].shape,
|
||||
...r,
|
||||
valid: isValidPlacement(r, state.items, state.room),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [paletteDrag, state.items, state.room, pxPerM]);
|
||||
|
||||
const startDrag = (type: FurnitureType, clientX: number, clientY: number) => {
|
||||
setMenu(null);
|
||||
setSidebar({ kind: "tiles" });
|
||||
setPaletteDrag({ type, clientX, clientY });
|
||||
};
|
||||
|
||||
/** Rotate / resize / lock / seats on one item. Geometry changes are checked; returns false if rejected. */
|
||||
const patchItem = (item: FurnitureItem, patch: Partial<FurnitureItem>): boolean => {
|
||||
const geometric = "rotation" in patch || "width" in patch || "height" in patch;
|
||||
if (!geometric) {
|
||||
dispatch({ kind: "update", id: item.id, patch });
|
||||
return true;
|
||||
}
|
||||
const next = tryUpdate(item, patch, state.items, state.room);
|
||||
if (!next) return false;
|
||||
dispatch({ kind: "update", id: item.id, patch: { ...patch, x: next.x, y: next.y } });
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveCustom = (d: CustomDraft) => {
|
||||
const id = `custom-${nextCustomId.current++}`;
|
||||
dispatch({
|
||||
kind: "addConfig",
|
||||
config: {
|
||||
id,
|
||||
name: d.name,
|
||||
shape: d.isTable ? "table" : "chair",
|
||||
defaultWidth: d.width,
|
||||
defaultHeight: d.height,
|
||||
isTable: d.isTable,
|
||||
available: d.available,
|
||||
seatsPerUnit: d.seats,
|
||||
},
|
||||
});
|
||||
setSidebar({ kind: "tiles" });
|
||||
};
|
||||
|
||||
const remainingByType = Object.fromEntries(
|
||||
FURNITURE_TYPES.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
|
||||
) as Record<FurnitureType, number>;
|
||||
state.typeOrder.map((t) => [t, remaining(state.items, t, state.configs[t].available)]),
|
||||
);
|
||||
|
||||
let panel = null;
|
||||
if (selectedItem) {
|
||||
panel = (
|
||||
<ItemPanel
|
||||
key={selectedItem.id}
|
||||
item={selectedItem}
|
||||
config={state.configs[selectedItem.type]}
|
||||
onPatch={(p) => patchItem(selectedItem, p)}
|
||||
onDelete={deleteSelected}
|
||||
onClose={deselect}
|
||||
/>
|
||||
);
|
||||
} else if (sidebar.kind === "type") {
|
||||
const id = sidebar.id;
|
||||
panel = (
|
||||
<TypeMenu
|
||||
config={state.configs[id]}
|
||||
placed={placedCount(state.items, id)}
|
||||
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: id, value })}
|
||||
onSetSeats={(value) => dispatch({ kind: "setSeats", type: id, value })}
|
||||
onClose={closeSidebar}
|
||||
/>
|
||||
);
|
||||
} else if (sidebar.kind === "custom") {
|
||||
panel = <CustomForm onSave={saveCustom} onCancel={closeSidebar} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
|
||||
<Palette
|
||||
types={state.typeOrder.map((t) => state.configs[t])}
|
||||
remaining={remainingByType}
|
||||
onStartDrag={startDrag}
|
||||
onOpenMenu={setMenu}
|
||||
panel={
|
||||
menu && (
|
||||
<TypeMenu
|
||||
type={menu}
|
||||
config={state.configs[menu]}
|
||||
placed={placedCount(state.items, menu)}
|
||||
onSetAvailable={(value) => dispatch({ kind: "setAvailable", type: menu, value })}
|
||||
onSetSeats={(value) => dispatch({ kind: "setSeats", value })}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onOpenMenu={(id) => openSidebar({ kind: "type", id })}
|
||||
onNewCustom={() => openSidebar({ kind: "custom" })}
|
||||
panel={panel}
|
||||
/>
|
||||
<main
|
||||
ref={mainRef}
|
||||
@@ -127,10 +218,13 @@ export default function App() {
|
||||
pxPerM={pxPerM}
|
||||
room={state.room}
|
||||
items={state.items}
|
||||
configs={state.configs}
|
||||
preview={preview}
|
||||
floorRef={floorRef}
|
||||
selectedId={selectedId}
|
||||
onSelect={select}
|
||||
onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })}
|
||||
onResize={(id, r) => dispatch({ kind: "resize", id, ...r })}
|
||||
onResize={(id, r) => dispatch({ kind: "update", id, patch: { ...r } })}
|
||||
/>
|
||||
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
|
||||
</main>
|
||||
@@ -145,10 +239,7 @@ export default function App() {
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Icon = TYPE_ICON[paletteDrag.type];
|
||||
return <Icon />;
|
||||
})()}
|
||||
<ShapeIcon shape={state.configs[paletteDrag.type].shape} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
export interface CustomDraft {
|
||||
name: string;
|
||||
isTable: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
seats: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSave: (draft: CustomDraft) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
const SEATING = { width: 0.5, height: 0.5, seats: 1 };
|
||||
const TABLE = { width: 1.8, height: 0.8, seats: 8 };
|
||||
|
||||
export function CustomForm({ onSave, onCancel }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [isTable, setIsTable] = useState(false);
|
||||
const [width, setWidth] = useState(SEATING.width);
|
||||
const [height, setHeight] = useState(SEATING.height);
|
||||
const [seats, setSeats] = useState(SEATING.seats);
|
||||
const [available, setAvailable] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onCancel]);
|
||||
|
||||
const pickKind = (table: boolean) => {
|
||||
setIsTable(table);
|
||||
const d = table ? TABLE : SEATING;
|
||||
setWidth(d.width);
|
||||
setHeight(d.height);
|
||||
setSeats(d.seats);
|
||||
};
|
||||
|
||||
const canSave = name.trim().length > 0;
|
||||
const seg = (active: boolean): React.CSSProperties => ({
|
||||
...btn,
|
||||
flex: 1,
|
||||
background: active ? "#2b2b2b" : "#fff",
|
||||
color: active ? "#fff" : "#222",
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onCancel} style={backBtn}>
|
||||
← Cancel
|
||||
</button>
|
||||
<div style={title}>New furniture</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Name</div>
|
||||
<input
|
||||
value={name}
|
||||
autoFocus
|
||||
maxLength={24}
|
||||
placeholder="e.g. Couch"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ width: "100%", boxSizing: "border-box", height: 32, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "14px system-ui, sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Type</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={seg(!isTable)} onClick={() => pickKind(false)}>
|
||||
Seating
|
||||
</button>
|
||||
<button style={seg(isTable)} onClick={() => pickKind(true)}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={width} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setWidth} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Length (m)</div>
|
||||
<Wheel value={height} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setHeight} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Seats</div>
|
||||
<Wheel value={seats} min={1} max={99} rows={3} onChange={setSeats} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={label}>Available</div>
|
||||
<Wheel value={available} min={1} max={999} rows={3} onChange={setAvailable} />
|
||||
</div>
|
||||
</div>
|
||||
{!canSave && <div style={hint}>Enter a name to save.</div>}
|
||||
|
||||
<button
|
||||
style={{ ...btn, background: canSave ? "#2b2b2b" : "#ddd", color: canSave ? "#fff" : "#999", cursor: canSave ? "pointer" : "not-allowed" }}
|
||||
disabled={!canSave}
|
||||
onClick={() => onSave({ name: name.trim(), isTable, width, height, seats, available })}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Group, Rect } from "react-konva";
|
||||
import type { FurnitureType } from "../types";
|
||||
import type { Shape } from "../types";
|
||||
|
||||
export type Outline = "normal" | "selected" | "valid" | "invalid";
|
||||
|
||||
@@ -11,7 +11,7 @@ const STROKE: Record<Outline, string> = {
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: FurnitureType;
|
||||
shape: Shape;
|
||||
width: number; // meters
|
||||
height: number;
|
||||
pxPerM: number;
|
||||
@@ -20,13 +20,13 @@ interface Props {
|
||||
}
|
||||
|
||||
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
|
||||
export function FurnitureShape({ type, width, height, pxPerM, outline, opacity = 1 }: Props) {
|
||||
export function FurnitureShape({ shape, width, height, pxPerM, outline, opacity = 1 }: Props) {
|
||||
const w = width * pxPerM;
|
||||
const h = height * pxPerM;
|
||||
const stroke = STROKE[outline];
|
||||
const strokeWidth = outline === "normal" ? 1.5 : 3;
|
||||
|
||||
if (type === "screen") {
|
||||
if (shape === "screen") {
|
||||
const inset = Math.min(3, h / 4);
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
@@ -35,7 +35,7 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (type === "table") {
|
||||
if (shape === "table") {
|
||||
return (
|
||||
<Group opacity={opacity}>
|
||||
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Shape } from "../types";
|
||||
|
||||
export function ChairIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShapeIcon({ shape, size }: { shape: Shape; size?: number }) {
|
||||
if (shape === "table") return <TableIcon size={size} />;
|
||||
if (shape === "screen") return <ScreenIcon size={size} />;
|
||||
return <ChairIcon size={size} />;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
|
||||
import { effectiveSeats } from "../inventory";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
interface Props {
|
||||
item: FurnitureItem;
|
||||
config: FurnitureTypeConfig;
|
||||
/** Try to apply a change. Returns false if it was rejected (overlap / no room). */
|
||||
onPatch: (patch: Partial<FurnitureItem>) => boolean;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MIN_SIZE = 0.3;
|
||||
const MAX_SIZE = 6;
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
|
||||
export function ItemPanel({ item, config, onPatch, onDelete, onClose }: Props) {
|
||||
const [notice, setNotice] = useState("");
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
|
||||
const patch = (p: Partial<FurnitureItem>) => {
|
||||
const ok = onPatch(p);
|
||||
setNotice(ok ? "" : "No room for that change.");
|
||||
};
|
||||
|
||||
const isScreen = config.shape === "screen";
|
||||
const seats = effectiveSeats(item, config) ?? 1;
|
||||
const overridden = item.seatsOverride !== undefined;
|
||||
// Never let the wheel's range exclude the current value (e.g. a 0.2 m default).
|
||||
const minOf = (v: number, floor: number) => Math.min(floor, v);
|
||||
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>
|
||||
{config.name}
|
||||
{item.locked ? " 🔒" : ""}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={label}>Rotation</div>
|
||||
<Wheel
|
||||
value={item.rotation}
|
||||
min={0}
|
||||
max={359}
|
||||
wrap
|
||||
rows={3}
|
||||
disabled={item.locked}
|
||||
format={(v) => `${v}°`}
|
||||
onChange={(v) => patch({ rotation: v })}
|
||||
/>
|
||||
<button
|
||||
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
|
||||
disabled={item.locked}
|
||||
onClick={() => patch({ rotation: (item.rotation + 90) % 360 })}
|
||||
>
|
||||
Rotate 90°
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.isTable && (
|
||||
<div>
|
||||
<div style={label}>Seats at this table</div>
|
||||
<Wheel value={seats} min={1} max={99} rows={3} format={(v) => String(v)} onChange={(v) => patch({ seatsOverride: v })} />
|
||||
{overridden && (
|
||||
<button
|
||||
onClick={() => patch({ seatsOverride: undefined })}
|
||||
style={{ ...hint, border: "none", background: "none", padding: 0, cursor: "pointer", color: "#1c7ed6", textDecoration: "underline" }}
|
||||
>
|
||||
Reset to default ({config.seatsPerUnit ?? 1})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
{isScreen ? (
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={item.width} min={0.5} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={item.width} min={minOf(item.width, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={item.height} min={minOf(item.height, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ height: v })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={() => patch({ locked: !item.locked })}>
|
||||
{item.locked ? "Unlock" : "Lock"}
|
||||
</button>
|
||||
<button style={{ ...btn, flex: 1, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onDelete}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+43
-48
@@ -1,13 +1,16 @@
|
||||
import { useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types";
|
||||
import type { FurnitureType, FurnitureTypeConfig } from "../types";
|
||||
import { ShapeIcon } from "./Icons";
|
||||
|
||||
const CLICK_THRESHOLD_PX = 5;
|
||||
|
||||
interface Props {
|
||||
types: FurnitureTypeConfig[]; // in palette order
|
||||
remaining: Record<FurnitureType, number>;
|
||||
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
||||
onOpenMenu: (type: FurnitureType) => void;
|
||||
panel: ReactNode; // when set, replaces the tiles (type settings menu)
|
||||
onNewCustom: () => void;
|
||||
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
||||
}
|
||||
|
||||
const tile: React.CSSProperties = {
|
||||
@@ -29,35 +32,19 @@ const tile: React.CSSProperties = {
|
||||
color: "#444",
|
||||
};
|
||||
|
||||
export function ChairIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableIcon({ size = 36 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_W = 152;
|
||||
const MAX_W = 600;
|
||||
|
||||
interface TileProps {
|
||||
type: FurnitureType;
|
||||
config: FurnitureTypeConfig;
|
||||
remaining: number;
|
||||
onStartDrag: Props["onStartDrag"];
|
||||
onOpenMenu: Props["onOpenMenu"];
|
||||
}
|
||||
|
||||
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
|
||||
function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
const type = config.id;
|
||||
const empty = remaining <= 0;
|
||||
|
||||
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
||||
@@ -90,7 +77,7 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`${type} (${remaining} remaining)`}
|
||||
aria-label={`${config.name} (${remaining} remaining)`}
|
||||
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
|
||||
onPointerDown={onPointerDown}
|
||||
>
|
||||
@@ -112,31 +99,27 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
{(() => {
|
||||
const Icon = TYPE_ICON[type];
|
||||
return <Icon />;
|
||||
})()}
|
||||
{TYPE_LABEL[type]}
|
||||
<ShapeIcon shape={config.shape} />
|
||||
{config.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenIcon({ size = 36 }: { size?: number }) {
|
||||
function NewCustomTile({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 36 36">
|
||||
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
|
||||
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
|
||||
</svg>
|
||||
<div
|
||||
role="button"
|
||||
aria-label="New custom furniture"
|
||||
onClick={onClick}
|
||||
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
|
||||
>
|
||||
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
|
||||
Custom
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TYPE_ICON: Record<FurnitureType, (p: { size?: number }) => React.JSX.Element> = {
|
||||
chair: ChairIcon,
|
||||
table: TableIcon,
|
||||
screen: ScreenIcon,
|
||||
};
|
||||
|
||||
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
|
||||
const [width, setWidth] = useState(264);
|
||||
|
||||
const startResize = (e: PointerEvent) => {
|
||||
@@ -158,20 +141,32 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
boxSizing: "border-box",
|
||||
flexShrink: 0,
|
||||
padding: 16,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
|
||||
alignContent: "start",
|
||||
gap: 12,
|
||||
borderRight: "1px solid #d4d4d4",
|
||||
background: "#ececec",
|
||||
}}
|
||||
>
|
||||
{panel ?? FURNITURE_TYPES.map((type) => (
|
||||
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
padding: 16,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
|
||||
alignContent: "start",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{panel ?? (
|
||||
<>
|
||||
{types.map((cfg) => (
|
||||
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
<NewCustomTile onClick={onNewCustom} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
|
||||
|
||||
+64
-35
@@ -1,12 +1,12 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect } from "react-konva";
|
||||
import { Stage, Layer, Group, Rect, Path } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types";
|
||||
import { FurnitureShape } from "./FurnitureShape";
|
||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
||||
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
||||
|
||||
export interface Preview {
|
||||
type: FurnitureType;
|
||||
shape: Shape;
|
||||
x: number; // meters
|
||||
y: number;
|
||||
width: number;
|
||||
@@ -17,9 +17,12 @@ export interface Preview {
|
||||
interface Props {
|
||||
room: RoomT;
|
||||
items: FurnitureItem[];
|
||||
configs: Record<string, FurnitureTypeConfig>;
|
||||
preview: Preview | null; // new item being dragged in from the palette
|
||||
pxPerM: number;
|
||||
floorRef: RefObject<HTMLDivElement | null>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onMove: (id: string, x: number, y: number) => void;
|
||||
onResize: (id: string, rect: RectM) => void;
|
||||
}
|
||||
@@ -36,21 +39,34 @@ interface ResizeInfo {
|
||||
}
|
||||
|
||||
const HANDLE_PX = 10;
|
||||
// Top-down view: a screen only changes in length, its thickness is fixed.
|
||||
const EDGES: ("left" | "right")[] = ["left", "right"];
|
||||
const ENDS: ("left" | "right")[] = ["left", "right"];
|
||||
|
||||
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) {
|
||||
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
|
||||
// 24x24 padlock centered in the item, counter-rotated so it stays upright.
|
||||
const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24));
|
||||
return (
|
||||
<Group x={w / 2} y={h / 2} offsetX={12} offsetY={12} scaleX={s} scaleY={s} rotation={-rotation} listening={false}>
|
||||
<Path data="M8 11V8a4 4 0 0 1 8 0v3" stroke="#1a1a1a" strokeWidth={2.5} />
|
||||
<Rect x={5} y={11} width={14} height={10} cornerRadius={2} fill="#fff" stroke="#1a1a1a" strokeWidth={2} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
||||
const [drag, setDrag] = useState<DragInfo | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
||||
const resizeStart = useRef<RectM | null>(null);
|
||||
const w = room.width * pxPerM;
|
||||
const h = room.height * pxPerM;
|
||||
|
||||
// Where the item would land if dropped now: clamped to walls.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node) =>
|
||||
// The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it.
|
||||
const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem =>
|
||||
clampToRoom(
|
||||
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height },
|
||||
{
|
||||
...item,
|
||||
x: node.x() / pxPerM - item.width / 2,
|
||||
y: node.y() / pxPerM - item.height / 2,
|
||||
},
|
||||
room,
|
||||
);
|
||||
|
||||
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
>
|
||||
<Layer>
|
||||
{items.map((item) => {
|
||||
const shape = configs[item.type]?.shape ?? "chair";
|
||||
const isResizing = resize?.id === item.id;
|
||||
const r: RectM = isResizing ? resize.rect : item;
|
||||
const outline = isResizing
|
||||
const outline: Outline = isResizing
|
||||
? resize.valid ? "valid" : "invalid"
|
||||
: drag?.id === item.id
|
||||
? drag.valid ? "valid" : "invalid"
|
||||
: selectedId === item.id ? "selected" : "normal";
|
||||
const half = boundsOf({ ...r, rotation: item.rotation });
|
||||
const wpx = r.width * pxPerM;
|
||||
const hpx = r.height * pxPerM;
|
||||
return (
|
||||
<Group
|
||||
key={item.id}
|
||||
x={r.x * pxPerM}
|
||||
y={r.y * pxPerM}
|
||||
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
|
||||
x={(r.x + r.width / 2) * pxPerM}
|
||||
y={(r.y + r.height / 2) * pxPerM}
|
||||
offsetX={wpx / 2}
|
||||
offsetY={hpx / 2}
|
||||
rotation={item.rotation}
|
||||
draggable={!item.locked}
|
||||
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => ({
|
||||
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM),
|
||||
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM),
|
||||
})}
|
||||
onMouseDown={() => onSelect(item.id)}
|
||||
onTouchStart={() => onSelect(item.id)}
|
||||
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
|
||||
dragBoundFunc={(pos) => {
|
||||
const hx = (half.width / 2) * pxPerM;
|
||||
const hy = (half.height / 2) * pxPerM;
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
||||
};
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.target.moveToTop();
|
||||
}}
|
||||
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
const c = candidate(item, node);
|
||||
const ok = isValidPlacement(c, items, room, item.id);
|
||||
const final = ok ? c : item; // snap back to last valid position
|
||||
node.position({ x: final.x * pxPerM, y: final.y * pxPerM });
|
||||
node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
|
||||
if (ok) onMove(item.id, c.x, c.y);
|
||||
setDrag(null);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "grab";
|
||||
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const c = e.target.getStage()?.container();
|
||||
if (c) c.style.cursor = "";
|
||||
}}
|
||||
>
|
||||
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
<FurnitureShape shape={shape} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
|
||||
{item.locked && <LockIcon w={wpx} h={hpx} rotation={item.rotation} />}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => {
|
||||
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
|
||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||
const isScreen = configs[item.type]?.shape === "screen";
|
||||
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
const r: RectM = resize?.id === item.id ? resize.rect : item;
|
||||
const mid: Record<"left" | "right", readonly [number, number]> = {
|
||||
const mid = {
|
||||
left: [r.x, r.y + r.height / 2],
|
||||
right: [r.x + r.width, r.y + r.height / 2],
|
||||
};
|
||||
return EDGES.map((edge) => (
|
||||
} as const;
|
||||
return ENDS.map((edge) => (
|
||||
<Rect
|
||||
key={`${item.id}-${edge}`}
|
||||
x={mid[edge][0] * pxPerM}
|
||||
@@ -158,8 +188,8 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const start = resizeStart.current;
|
||||
if (!p || !start) return;
|
||||
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) });
|
||||
const next = resizeLength(start, edge, p.x / pxPerM, room);
|
||||
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
|
||||
@@ -180,7 +210,7 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
{preview && (
|
||||
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
||||
<FurnitureShape
|
||||
type={preview.type}
|
||||
shape={preview.shape}
|
||||
width={preview.width}
|
||||
height={preview.height}
|
||||
pxPerM={pxPerM}
|
||||
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-17
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
|
||||
import type { FurnitureTypeConfig } from "../types";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
|
||||
|
||||
interface Props {
|
||||
type: FurnitureType;
|
||||
config: FurnitureTypeConfig;
|
||||
placed: number;
|
||||
onSetAvailable: (v: number) => void;
|
||||
@@ -11,10 +11,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
|
||||
const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
|
||||
|
||||
export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
|
||||
export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
@@ -22,26 +19,23 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ alignSelf: "flex-start", border: "none", background: "none", padding: 0, cursor: "pointer", font: "13px system-ui, sans-serif", color: "#555" }}
|
||||
>
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Back
|
||||
</button>
|
||||
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
|
||||
<div style={title}>{config.name}</div>
|
||||
<div>
|
||||
<div style={label}>Available</div>
|
||||
<Wheel value={config.available} min={placed} onChange={onSetAvailable} />
|
||||
<div style={hint}>{placed} placed</div>
|
||||
</div>
|
||||
{type === "screen" && (
|
||||
<div style={hint}>Click a placed screen, then drag an end handle to change its length.</div>
|
||||
{config.shape === "screen" && (
|
||||
<div style={hint}>Click a placed screen, then drag an end handle (or use the Length wheel) to change its length.</div>
|
||||
)}
|
||||
{type === "table" && (
|
||||
{config.seatsPerUnit !== undefined && (
|
||||
<div>
|
||||
<div style={label}>Seats per table</div>
|
||||
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
|
||||
<div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
|
||||
<Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+85
-39
@@ -4,12 +4,15 @@ interface Props {
|
||||
value: number;
|
||||
min: number;
|
||||
max?: number;
|
||||
step?: number; // value units per wheel notch (default 1)
|
||||
wrap?: boolean; // loop from max back to min (angles)
|
||||
rows?: number; // visible rows, odd (default 5)
|
||||
disabled?: boolean;
|
||||
format?: (v: number) => string;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
const ROW_H = 34;
|
||||
const VISIBLE = 5; // odd, so one row sits in the middle
|
||||
const REACH = Math.floor(VISIBLE / 2) + 1;
|
||||
const CLICK_PX = 4; // total movement under this = a tap
|
||||
const SETTLE_K = 7; // 1/s; higher = snappier settle (fling distance = velocity / k)
|
||||
const SPRING_W = 12; // 1/s; stiffness of the critically damped settle spring
|
||||
@@ -21,34 +24,44 @@ const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), s
|
||||
* Scroll-wheel number picker (like the iOS timer).
|
||||
* Drag follows the pointer 1:1, release flings with momentum and settles on a value,
|
||||
* touching it mid-roll stops it instantly. Also: mouse wheel, tap a row, arrow keys.
|
||||
* Internally works in integer "notch" space; `step` maps notches to real values.
|
||||
* If the parent doesn't accept a change (value prop stays put) the wheel settles back to the real value.
|
||||
*/
|
||||
export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
const [pos, setPos] = useState(value);
|
||||
export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: visible = 5, disabled = false, format, onChange }: Props) {
|
||||
const idx = (v: number) => Math.round(v / step);
|
||||
const decimals = (String(step).split(".")[1] ?? "").length;
|
||||
|
||||
const [pos, setPos] = useState(idx(value));
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const posRef = useRef(value); // fractional position in "value" units
|
||||
const lastEmitted = useRef(value);
|
||||
const posRef = useRef(idx(value)); // fractional position in notches
|
||||
const lastEmitted = useRef(idx(value));
|
||||
const raf = useRef(0);
|
||||
const settleTimer = useRef(0);
|
||||
const targetRef = useRef(value); // where the running animation is heading
|
||||
const targetRef = useRef(idx(value)); // where the running animation is heading
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null);
|
||||
const samples = useRef<{ t: number; pos: number }[]>([]);
|
||||
|
||||
// Keep latest props visible to the animation loop.
|
||||
const cfg = useRef({ min, max, onChange });
|
||||
cfg.current = { min, max, onChange };
|
||||
const imin = idx(min);
|
||||
const imax = idx(max);
|
||||
const n = imax - imin + 1;
|
||||
const cfg = useRef({ imin, imax, n, wrap, step, onChange, valueIdx: idx(value) });
|
||||
cfg.current = { imin, imax, n, wrap, step, onChange, valueIdx: idx(value) };
|
||||
|
||||
const clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, v));
|
||||
const wrapIdx = (i: number) => cfg.current.imin + ((((i - cfg.current.imin) % cfg.current.n) + cfg.current.n) % cfg.current.n);
|
||||
const keyOf = (i: number) => (cfg.current.wrap ? wrapIdx(i) : i);
|
||||
const clamp = (v: number) => (cfg.current.wrap ? v : Math.min(cfg.current.imax, Math.max(cfg.current.imin, v)));
|
||||
|
||||
const setPosition = (p: number) => {
|
||||
const c = clamp(p);
|
||||
posRef.current = c;
|
||||
setPos(c);
|
||||
const v = Math.round(c);
|
||||
if (v !== lastEmitted.current) {
|
||||
lastEmitted.current = v;
|
||||
cfg.current.onChange(v);
|
||||
const k = keyOf(Math.round(c));
|
||||
if (k !== lastEmitted.current) {
|
||||
lastEmitted.current = k;
|
||||
cfg.current.onChange(Math.round(k * cfg.current.step * 1000) / 1000);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
@@ -60,32 +73,53 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
settleTimer.current = 0;
|
||||
};
|
||||
|
||||
/** Where the real value sits relative to the current position (shortest way round when wrapping). */
|
||||
const valueTarget = () => {
|
||||
const have = Math.round(posRef.current);
|
||||
if (!cfg.current.wrap) return cfg.current.valueIdx;
|
||||
const d = wrapIdx(cfg.current.valueIdx - have + cfg.current.imin) - cfg.current.imin; // 0..n-1
|
||||
return have + (d > cfg.current.n / 2 ? d - cfg.current.n : d);
|
||||
};
|
||||
|
||||
/** One frame after a settle ends, check the parent actually took our value; if not, glide back. */
|
||||
const reconcileSoon = () => {
|
||||
raf.current = requestAnimationFrame(() => {
|
||||
raf.current = 0;
|
||||
if (drag.current) return;
|
||||
if (keyOf(Math.round(posRef.current)) !== cfg.current.valueIdx) {
|
||||
lastEmitted.current = cfg.current.valueIdx;
|
||||
settleTo(valueTarget(), 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Ease from the current position to `target` with exponential decay (iOS-style deceleration). */
|
||||
const animateTo = (rawTarget: number) => {
|
||||
stop();
|
||||
const target = clamp(Math.round(rawTarget));
|
||||
targetRef.current = target;
|
||||
let last = performance.now();
|
||||
const step = (now: number) => {
|
||||
const frame = (now: number) => {
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt));
|
||||
if (Math.abs(target - next) < 0.003) {
|
||||
setPosition(target);
|
||||
raf.current = 0;
|
||||
reconcileSoon();
|
||||
return;
|
||||
}
|
||||
setPosition(next);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
/**
|
||||
* Settle onto `rawTarget` with a critically damped spring that starts at the current release
|
||||
* velocity `v0` (rows/s). Smooth start (no velocity jump) and no overshoot, so slow releases glide in.
|
||||
*/
|
||||
const settleTo = (rawTarget: number, v0: number) => {
|
||||
function settleTo(rawTarget: number, v0: number) {
|
||||
stop();
|
||||
const target = clamp(Math.round(rawTarget));
|
||||
targetRef.current = target;
|
||||
@@ -97,7 +131,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
const w = toward ? Math.max(SPRING_W, Math.abs(v0) / dist) : SPRING_W;
|
||||
const b = v0 + w * a;
|
||||
const t0 = performance.now();
|
||||
const step = (now: number) => {
|
||||
const frame = (now: number) => {
|
||||
const t = (now - t0) / 1000;
|
||||
const decay = Math.exp(-w * t);
|
||||
const x = target + (a + b * t) * decay;
|
||||
@@ -105,25 +139,29 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
|
||||
setPosition(target);
|
||||
raf.current = 0;
|
||||
reconcileSoon();
|
||||
return;
|
||||
}
|
||||
setPosition(x);
|
||||
raf.current = requestAnimationFrame(step);
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
};
|
||||
raf.current = requestAnimationFrame(step);
|
||||
};
|
||||
raf.current = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// Follow outside changes (e.g. min rising) when idle.
|
||||
// Follow outside changes (rotate button, min rising, selection change) when idle.
|
||||
useEffect(() => {
|
||||
if (drag.current || raf.current) return;
|
||||
posRef.current = value;
|
||||
lastEmitted.current = value;
|
||||
setPos(value);
|
||||
const p = valueTarget();
|
||||
posRef.current = p;
|
||||
lastEmitted.current = keyOf(Math.round(p));
|
||||
setPos(p);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => stop, []);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
|
||||
stop(); // stopping power: touching freezes the wheel exactly where it is
|
||||
@@ -155,9 +193,9 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
} else {
|
||||
// plain tap: jump to the tapped row
|
||||
const rect = box.current!.getBoundingClientRect();
|
||||
const rows = Math.round((e.clientY - rect.top - (VISIBLE * ROW_H) / 2) / ROW_H);
|
||||
const r = Math.round((e.clientY - rect.top - (visible * ROW_H) / 2) / ROW_H);
|
||||
posRef.current = d.pos0;
|
||||
animateTo(Math.round(d.pos0) + rows);
|
||||
animateTo(Math.round(d.pos0) + r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -177,6 +215,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
if (disabled) return;
|
||||
if (Math.abs(e.deltaY) >= NOTCH_DELTA) {
|
||||
// mouse notch: one value per notch, repeated notches build on the pending target
|
||||
const base = raf.current ? targetRef.current : Math.round(posRef.current);
|
||||
@@ -193,17 +232,22 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1);
|
||||
else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const reach = Math.floor(visible / 2) + 1;
|
||||
const centre = Math.round(pos);
|
||||
const rows = [];
|
||||
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
|
||||
const lo = wrap ? centre - reach : Math.max(imin, centre - reach);
|
||||
const hi = wrap ? centre + reach : Math.min(imax, centre + reach);
|
||||
const items = [];
|
||||
for (let v = lo; v <= hi; v++) {
|
||||
const off = v - pos;
|
||||
rows.push(
|
||||
const real = (wrap ? wrapIdx(v) : v) * step;
|
||||
items.push(
|
||||
<div
|
||||
key={v}
|
||||
style={{
|
||||
@@ -216,12 +260,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
justifyContent: "center",
|
||||
font: "600 20px system-ui, sans-serif",
|
||||
color: "#222",
|
||||
opacity: Math.max(0, 1 - Math.abs(off) * 0.38),
|
||||
transform: `translateY(${((VISIBLE - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
|
||||
opacity: Math.max(0, 1 - Math.abs(off) * (visible <= 3 ? 0.55 : 0.38)),
|
||||
transform: `translateY(${((visible - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
{format ? format(real) : real.toFixed(decimals)}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
@@ -229,11 +273,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
return (
|
||||
<div
|
||||
ref={box}
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
role="spinbutton"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-disabled={disabled}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
@@ -243,11 +288,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: VISIBLE * ROW_H,
|
||||
height: visible * ROW_H,
|
||||
overflow: "hidden",
|
||||
background: "#f4f4f4",
|
||||
borderRadius: 10,
|
||||
cursor: dragging ? "grabbing" : "ns-resize",
|
||||
cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
|
||||
opacity: disabled ? 0.45 : 1,
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
@@ -258,14 +304,14 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
|
||||
position: "absolute",
|
||||
left: 6,
|
||||
right: 6,
|
||||
top: ((VISIBLE - 1) / 2) * ROW_H,
|
||||
top: ((visible - 1) / 2) * ROW_H,
|
||||
height: ROW_H,
|
||||
background: "rgba(0,0,0,0.08)",
|
||||
borderRadius: 8,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{rows}
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const panelRoot: React.CSSProperties = { gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 };
|
||||
export const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
|
||||
export const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
|
||||
export const title: React.CSSProperties = { font: "600 15px system-ui, sans-serif", color: "#222" };
|
||||
export const backBtn: React.CSSProperties = {
|
||||
alignSelf: "flex-start",
|
||||
border: "none",
|
||||
background: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
font: "13px system-ui, sans-serif",
|
||||
color: "#555",
|
||||
};
|
||||
export const btn: React.CSSProperties = {
|
||||
height: 32,
|
||||
padding: "0 12px",
|
||||
border: "1px solid #c4c4c4",
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
font: "13px system-ui, sans-serif",
|
||||
color: "#222",
|
||||
};
|
||||
export const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(96px, 1fr))", gap: 12 };
|
||||
+61
-53
@@ -1,63 +1,37 @@
|
||||
// Pure geometry/rules. No React or Konva imports so an optimizer can reuse this.
|
||||
import type { FurnitureItem, FurnitureType, Rect, Room } from "./types";
|
||||
import type { FurnitureItem, Rect, Room } from "./types";
|
||||
|
||||
/** Largest px-per-meter that fits the room inside the given pixel box. */
|
||||
export const fitScale = (room: Room, availW: number, availH: number): number =>
|
||||
Math.max(1, Math.min(availW / room.width, availH / room.height));
|
||||
|
||||
export const DEFAULT_SIZES: Record<FurnitureType, { width: number; height: number }> = {
|
||||
chair: { width: 0.5, height: 0.5 },
|
||||
table: { width: 1.8, height: 0.8 },
|
||||
screen: { width: 2, height: 0.2 },
|
||||
};
|
||||
|
||||
/** Types the user can resize after placing. */
|
||||
export const RESIZABLE: Record<FurnitureType, boolean> = { chair: false, table: false, screen: true };
|
||||
|
||||
export const MIN_SCREEN_SIZE = { width: 0.5, height: 0.1 };
|
||||
|
||||
export type Edge = "left" | "right" | "top" | "bottom";
|
||||
|
||||
/**
|
||||
* New rect when dragging one edge of `start` to the pointer (meters). The opposite edge stays fixed;
|
||||
* the result never shrinks below `min` and never leaves the room.
|
||||
*/
|
||||
export function resizeRect(
|
||||
start: Rect,
|
||||
edge: Edge,
|
||||
px: number,
|
||||
py: number,
|
||||
room: Room,
|
||||
min: { width: number; height: number },
|
||||
): Rect {
|
||||
const r = { ...start };
|
||||
if (edge === "right") {
|
||||
r.width = clamp(px - start.x, min.width, room.width - start.x);
|
||||
} else if (edge === "left") {
|
||||
const right = start.x + start.width;
|
||||
r.x = clamp(px, 0, right - min.width);
|
||||
r.width = right - r.x;
|
||||
} else if (edge === "bottom") {
|
||||
r.height = clamp(py - start.y, min.height, room.height - start.y);
|
||||
} else {
|
||||
const bottom = start.y + start.height;
|
||||
r.y = clamp(py, 0, bottom - min.height);
|
||||
r.height = bottom - r.y;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export const DEFAULT_ROOM: Room = { width: 10, height: 8 };
|
||||
|
||||
/** A rect with an optional rotation (degrees) around its center. */
|
||||
export type Box = Rect & { rotation?: number };
|
||||
|
||||
const clamp = (v: number, min: number, max: number): number => Math.min(Math.max(v, min), max);
|
||||
|
||||
/** Keep a rect fully inside the room walls. */
|
||||
export function clampToRoom(r: Rect, room: Room): Rect {
|
||||
return {
|
||||
...r,
|
||||
x: clamp(r.x, 0, room.width - r.width),
|
||||
y: clamp(r.y, 0, room.height - r.height),
|
||||
};
|
||||
export const normalizeAngle = (deg: number): number => ((Math.round(deg) % 360) + 360) % 360;
|
||||
|
||||
/** Axis-aligned bounding box of the rotated rect. All collision and wall checks use this. */
|
||||
export function boundsOf(b: Box): Rect {
|
||||
const t = ((b.rotation ?? 0) * Math.PI) / 180;
|
||||
const c = Math.abs(Math.cos(t));
|
||||
const s = Math.abs(Math.sin(t));
|
||||
const width = b.width * c + b.height * s;
|
||||
const height = b.width * s + b.height * c;
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
return { x: cx - width / 2, y: cy - height / 2, width, height };
|
||||
}
|
||||
|
||||
/** Shift a box so its (rotated) bounds sit fully inside the room walls. */
|
||||
export function clampToRoom<T extends Box>(b: T, room: Room): T {
|
||||
const r = boundsOf(b);
|
||||
const dx = clamp(r.x, 0, room.width - r.width) - r.x;
|
||||
const dy = clamp(r.y, 0, room.height - r.height) - r.y;
|
||||
return { ...b, x: b.x + dx, y: b.y + dy };
|
||||
}
|
||||
|
||||
/** Axis-aligned overlap. Touching edges do not count as overlap. */
|
||||
@@ -81,12 +55,46 @@ export function isInsideRoom(r: Rect, room: Room): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Valid = inside the room and not overlapping any other item (ignoring `ignoreId`). */
|
||||
/** Valid = bounds inside the room and not overlapping any other item (ignoring `ignoreId`). */
|
||||
export function isValidPlacement(
|
||||
r: Rect,
|
||||
c: Box,
|
||||
items: FurnitureItem[],
|
||||
room: Room,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
return isInsideRoom(r, room) && !items.some((o) => o.id !== ignoreId && overlaps(r, o));
|
||||
const cb = boundsOf(c);
|
||||
return isInsideRoom(cb, room) && !items.some((o) => o.id !== ignoreId && overlaps(cb, boundsOf(o)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch (rotation, size, ...) to an item. A size change keeps the center fixed; the result is
|
||||
* pushed back inside the walls, then rejected (null) if it would overlap anything.
|
||||
*/
|
||||
export function tryUpdate(
|
||||
item: FurnitureItem,
|
||||
patch: Partial<FurnitureItem>,
|
||||
items: FurnitureItem[],
|
||||
room: Room,
|
||||
): FurnitureItem | null {
|
||||
let next = { ...item, ...patch };
|
||||
if (patch.x === undefined && patch.y === undefined) {
|
||||
next.x = item.x + item.width / 2 - next.width / 2;
|
||||
next.y = item.y + item.height / 2 - next.height / 2;
|
||||
}
|
||||
next = clampToRoom(next, room);
|
||||
return isValidPlacement(next, items, room, item.id) ? next : null;
|
||||
}
|
||||
|
||||
// ---- Screens: drag an end handle to change length (only while unrotated) ----
|
||||
|
||||
export const MIN_SCREEN_LENGTH = 0.5;
|
||||
|
||||
/** New rect when dragging the left/right edge of `start` to pointer x (meters). Opposite edge stays fixed. */
|
||||
export function resizeLength(start: Rect, edge: "left" | "right", px: number, room: Room): Rect {
|
||||
if (edge === "right") {
|
||||
return { ...start, width: clamp(px - start.x, MIN_SCREEN_LENGTH, room.width - start.x) };
|
||||
}
|
||||
const right = start.x + start.width;
|
||||
const x = clamp(px, 0, right - MIN_SCREEN_LENGTH);
|
||||
return { ...start, x, width: right - x };
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Pure inventory rules. Everything is derived from the items array.
|
||||
import type { FurnitureItem, FurnitureType } from "./types";
|
||||
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig } from "./types";
|
||||
|
||||
export const placedCount = (items: FurnitureItem[], type: FurnitureType): number =>
|
||||
items.filter((i) => i.type === type).length;
|
||||
|
||||
export const remaining = (items: FurnitureItem[], type: FurnitureType, available: number): number =>
|
||||
available - placedCount(items, type);
|
||||
|
||||
/** Seats for one item: per-table override if set, else the type-level default. */
|
||||
export const effectiveSeats = (item: FurnitureItem, config: FurnitureTypeConfig): number | undefined =>
|
||||
item.seatsOverride ?? config.seatsPerUnit;
|
||||
|
||||
+33
-26
@@ -1,39 +1,47 @@
|
||||
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room } from "./types";
|
||||
import { DEFAULT_ROOM, DEFAULT_SIZES } from "./geometry";
|
||||
import { DEFAULT_ROOM } from "./geometry";
|
||||
|
||||
// Single source of truth. An optimizer can read `state` and dispatch actions.
|
||||
export interface LayoutState {
|
||||
room: Room;
|
||||
items: FurnitureItem[];
|
||||
configs: Record<FurnitureType, FurnitureTypeConfig>;
|
||||
typeOrder: FurnitureType[]; // palette order
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { kind: "add"; id: string; type: FurnitureType; x: number; y: number }
|
||||
| { kind: "move"; id: string; x: number; y: number }
|
||||
| { kind: "resize"; id: string; x: number; y: number; width: number; height: number }
|
||||
| { kind: "update"; id: string; patch: Partial<FurnitureItem> }
|
||||
| { kind: "remove"; id: string }
|
||||
| { kind: "setAvailable"; type: FurnitureType; value: number }
|
||||
| { kind: "setSeats"; value: number };
|
||||
| { kind: "setSeats"; type: FurnitureType; value: number }
|
||||
| { kind: "addConfig"; config: FurnitureTypeConfig };
|
||||
|
||||
export const initialState: LayoutState = { room: DEFAULT_ROOM, items: [],
|
||||
configs: {
|
||||
chair: { type: "chair", available: 20 },
|
||||
table: { type: "table", available: 6, seatsPerUnit: 8 },
|
||||
screen: { type: "screen", available: 2 },
|
||||
},
|
||||
const BUILT_IN: FurnitureTypeConfig[] = [
|
||||
{ id: "chair", name: "Chair", shape: "chair", defaultWidth: 0.5, defaultHeight: 0.5, isTable: false, available: 20 },
|
||||
{ id: "table", name: "Table", shape: "table", defaultWidth: 1.8, defaultHeight: 0.8, isTable: true, available: 6, seatsPerUnit: 8 },
|
||||
{ id: "screen", name: "Screen", shape: "screen", defaultWidth: 2, defaultHeight: 0.2, isTable: false, available: 2 },
|
||||
];
|
||||
|
||||
export const initialState: LayoutState = {
|
||||
room: DEFAULT_ROOM,
|
||||
items: [],
|
||||
configs: Object.fromEntries(BUILT_IN.map((c) => [c.id, c])),
|
||||
typeOrder: BUILT_IN.map((c) => c.id),
|
||||
};
|
||||
|
||||
export function reducer(state: LayoutState, action: Action): LayoutState {
|
||||
switch (action.kind) {
|
||||
case "add": {
|
||||
const { width, height } = DEFAULT_SIZES[action.type];
|
||||
const cfg = state.configs[action.type];
|
||||
const item: FurnitureItem = {
|
||||
id: action.id,
|
||||
type: action.type,
|
||||
x: action.x,
|
||||
y: action.y,
|
||||
width,
|
||||
height,
|
||||
width: cfg.defaultWidth,
|
||||
height: cfg.defaultHeight,
|
||||
rotation: 0,
|
||||
locked: false,
|
||||
};
|
||||
@@ -42,31 +50,30 @@ export function reducer(state: LayoutState, action: Action): LayoutState {
|
||||
case "move":
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map((i) =>
|
||||
i.id === action.id ? { ...i, x: action.x, y: action.y } : i,
|
||||
),
|
||||
items: state.items.map((i) => (i.id === action.id ? { ...i, x: action.x, y: action.y } : i)),
|
||||
};
|
||||
case "resize":
|
||||
case "update":
|
||||
return {
|
||||
...state,
|
||||
items: state.items.map((i) =>
|
||||
i.id === action.id
|
||||
? { ...i, x: action.x, y: action.y, width: action.width, height: action.height }
|
||||
: i,
|
||||
),
|
||||
items: state.items.map((i) => (i.id === action.id ? { ...i, ...action.patch } : i)),
|
||||
};
|
||||
case "remove":
|
||||
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
|
||||
case "setAvailable":
|
||||
return {
|
||||
...state,
|
||||
configs: {
|
||||
...state.configs,
|
||||
[action.type]: { ...state.configs[action.type], available: action.value },
|
||||
},
|
||||
configs: { ...state.configs, [action.type]: { ...state.configs[action.type], available: action.value } },
|
||||
};
|
||||
case "setSeats":
|
||||
return {
|
||||
...state,
|
||||
configs: { ...state.configs, table: { ...state.configs.table, seatsPerUnit: action.value } },
|
||||
configs: { ...state.configs, [action.type]: { ...state.configs[action.type], seatsPerUnit: action.value } },
|
||||
};
|
||||
case "addConfig":
|
||||
return {
|
||||
...state,
|
||||
configs: { ...state.configs, [action.config.id]: action.config },
|
||||
typeOrder: [...state.typeOrder, action.config.id],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+17
-15
@@ -1,22 +1,19 @@
|
||||
export type FurnitureType = "chair" | "table" | "screen";
|
||||
/** Id of a furniture config ("chair", "table", "screen", "custom-1", ...). */
|
||||
export type FurnitureType = string;
|
||||
|
||||
export const FURNITURE_TYPES: FurnitureType[] = ["chair", "table", "screen"];
|
||||
|
||||
export const TYPE_LABEL: Record<FurnitureType, string> = {
|
||||
chair: "Chair",
|
||||
table: "Table",
|
||||
screen: "Screen",
|
||||
};
|
||||
/** How a type is drawn. Custom seating/tables reuse the chair/table looks. */
|
||||
export type Shape = "chair" | "table" | "screen";
|
||||
|
||||
export interface FurnitureItem {
|
||||
id: string;
|
||||
type: FurnitureType;
|
||||
x: number; // meters, top-left, room coordinates
|
||||
type: FurnitureType; // config id
|
||||
x: number; // meters, top-left of the UNROTATED rect, room coordinates
|
||||
y: number;
|
||||
width: number; // meters
|
||||
height: number;
|
||||
rotation: number; // degrees, always 0 for now
|
||||
locked: boolean; // always false for now, reserved
|
||||
rotation: number; // degrees, rotates around the rect's center
|
||||
locked: boolean;
|
||||
seatsOverride?: number; // tables only: overrides the type-level seatsPerUnit
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
@@ -31,9 +28,14 @@ export interface Rect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Per-type settings, edited via the palette menu.
|
||||
// Per-type settings. Built-in and custom furniture are just more configs.
|
||||
export interface FurnitureTypeConfig {
|
||||
type: FurnitureType;
|
||||
id: FurnitureType;
|
||||
name: string;
|
||||
shape: Shape;
|
||||
defaultWidth: number; // meters
|
||||
defaultHeight: number;
|
||||
isTable: boolean;
|
||||
available: number; // inventory count
|
||||
seatsPerUnit?: number; // tables only
|
||||
seatsPerUnit?: number; // type-level seats (tables, couches...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user