third commit

This commit is contained in:
2026-10-17 12:43:25 -04:00
parent abc3800db2
commit 803d3fded5
19 changed files with 825 additions and 356 deletions
+128 -37
View File
@@ -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>
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
export interface CustomDraft {
name: string;
isTable: boolean;
width: number;
height: number;
seats: number;
available: number;
}
interface Props {
onSave: (draft: CustomDraft) => void;
onCancel: () => void;
}
const fmt = (v: number) => v.toFixed(1);
const SEATING = { width: 0.5, height: 0.5, seats: 1 };
const TABLE = { width: 1.8, height: 0.8, seats: 8 };
export function CustomForm({ onSave, onCancel }: Props) {
const [name, setName] = useState("");
const [isTable, setIsTable] = useState(false);
const [width, setWidth] = useState(SEATING.width);
const [height, setHeight] = useState(SEATING.height);
const [seats, setSeats] = useState(SEATING.seats);
const [available, setAvailable] = useState(10);
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onCancel]);
const pickKind = (table: boolean) => {
setIsTable(table);
const d = table ? TABLE : SEATING;
setWidth(d.width);
setHeight(d.height);
setSeats(d.seats);
};
const canSave = name.trim().length > 0;
const seg = (active: boolean): React.CSSProperties => ({
...btn,
flex: 1,
background: active ? "#2b2b2b" : "#fff",
color: active ? "#fff" : "#222",
});
return (
<div style={panelRoot}>
<button onClick={onCancel} style={backBtn}>
← Cancel
</button>
<div style={title}>New furniture</div>
<div>
<div style={label}>Name</div>
<input
value={name}
autoFocus
maxLength={24}
placeholder="e.g. Couch"
onChange={(e) => setName(e.target.value)}
style={{ width: "100%", boxSizing: "border-box", height: 32, padding: "0 10px", border: "1px solid #c4c4c4", borderRadius: 8, font: "14px system-ui, sans-serif" }}
/>
</div>
<div>
<div style={label}>Type</div>
<div style={{ display: "flex", gap: 8 }}>
<button style={seg(!isTable)} onClick={() => pickKind(false)}>
Seating
</button>
<button style={seg(isTable)} onClick={() => pickKind(true)}>
Table
</button>
</div>
</div>
<div style={twoCol}>
<div>
<div style={label}>Width (m)</div>
<Wheel value={width} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setWidth} />
</div>
<div>
<div style={label}>Length (m)</div>
<Wheel value={height} min={0.3} max={6} step={0.1} rows={3} format={fmt} onChange={setHeight} />
</div>
<div>
<div style={label}>Seats</div>
<Wheel value={seats} min={1} max={99} rows={3} onChange={setSeats} />
</div>
<div>
<div style={label}>Available</div>
<Wheel value={available} min={1} max={999} rows={3} onChange={setAvailable} />
</div>
</div>
{!canSave && <div style={hint}>Enter a name to save.</div>}
<button
style={{ ...btn, background: canSave ? "#2b2b2b" : "#ddd", color: canSave ? "#fff" : "#999", cursor: canSave ? "pointer" : "not-allowed" }}
disabled={!canSave}
onClick={() => onSave({ name: name.trim(), isTable, width, height, seats, available })}
>
Save
</button>
</div>
);
}
+5 -5
View File
@@ -1,5 +1,5 @@
import { Group, Rect } from "react-konva";
import type { FurnitureType } from "../types";
import type { Shape } from "../types";
export type Outline = "normal" | "selected" | "valid" | "invalid";
@@ -11,7 +11,7 @@ const STROKE: Record<Outline, string> = {
};
interface Props {
type: FurnitureType;
shape: Shape;
width: number; // meters
height: number;
pxPerM: number;
@@ -20,13 +20,13 @@ interface Props {
}
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
export function FurnitureShape({ type, width, height, pxPerM, outline, opacity = 1 }: Props) {
export function FurnitureShape({ shape, width, height, pxPerM, outline, opacity = 1 }: Props) {
const w = width * pxPerM;
const h = height * pxPerM;
const stroke = STROKE[outline];
const strokeWidth = outline === "normal" ? 1.5 : 3;
if (type === "screen") {
if (shape === "screen") {
const inset = Math.min(3, h / 4);
return (
<Group opacity={opacity}>
@@ -35,7 +35,7 @@ export function FurnitureShape({ type, width, height, pxPerM, outline, opacity =
</Group>
);
}
if (type === "table") {
if (shape === "table") {
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
+33
View File
@@ -0,0 +1,33 @@
import type { Shape } from "../types";
export function ChairIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
</svg>
);
}
export function TableIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
</svg>
);
}
export function ScreenIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
</svg>
);
}
export function ShapeIcon({ shape, size }: { shape: Shape; size?: number }) {
if (shape === "table") return <TableIcon size={size} />;
if (shape === "screen") return <ScreenIcon size={size} />;
return <ChairIcon size={size} />;
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import type { FurnitureItem, FurnitureTypeConfig } from "../types";
import { effectiveSeats } from "../inventory";
import { Wheel } from "./Wheel";
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
interface Props {
item: FurnitureItem;
config: FurnitureTypeConfig;
/** Try to apply a change. Returns false if it was rejected (overlap / no room). */
onPatch: (patch: Partial<FurnitureItem>) => boolean;
onDelete: () => void;
onClose: () => void;
}
const MIN_SIZE = 0.3;
const MAX_SIZE = 6;
const fmt = (v: number) => v.toFixed(1);
export function ItemPanel({ item, config, onPatch, onDelete, onClose }: Props) {
const [notice, setNotice] = useState("");
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [onClose]);
const patch = (p: Partial<FurnitureItem>) => {
const ok = onPatch(p);
setNotice(ok ? "" : "No room for that change.");
};
const isScreen = config.shape === "screen";
const seats = effectiveSeats(item, config) ?? 1;
const overridden = item.seatsOverride !== undefined;
// Never let the wheel's range exclude the current value (e.g. a 0.2 m default).
const minOf = (v: number, floor: number) => Math.min(floor, v);
return (
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Done
</button>
<div style={title}>
{config.name}
{item.locked ? " 🔒" : ""}
</div>
<div>
<div style={label}>Rotation</div>
<Wheel
value={item.rotation}
min={0}
max={359}
wrap
rows={3}
disabled={item.locked}
format={(v) => `${v}°`}
onChange={(v) => patch({ rotation: v })}
/>
<button
style={{ ...btn, marginTop: 8, width: "100%", opacity: item.locked ? 0.45 : 1 }}
disabled={item.locked}
onClick={() => patch({ rotation: (item.rotation + 90) % 360 })}
>
Rotate 90°
</button>
</div>
{config.isTable && (
<div>
<div style={label}>Seats at this table</div>
<Wheel value={seats} min={1} max={99} rows={3} format={(v) => String(v)} onChange={(v) => patch({ seatsOverride: v })} />
{overridden && (
<button
onClick={() => patch({ seatsOverride: undefined })}
style={{ ...hint, border: "none", background: "none", padding: 0, cursor: "pointer", color: "#1c7ed6", textDecoration: "underline" }}
>
Reset to default ({config.seatsPerUnit ?? 1})
</button>
)}
</div>
)}
<div>
<div style={label}>Size (m)</div>
<div style={twoCol}>
{isScreen ? (
<div>
<div style={hint}>Length</div>
<Wheel value={item.width} min={0.5} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
</div>
) : (
<>
<div>
<div style={hint}>Width</div>
<Wheel value={item.width} min={minOf(item.width, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ width: v })} />
</div>
<div>
<div style={hint}>Length</div>
<Wheel value={item.height} min={minOf(item.height, MIN_SIZE)} max={MAX_SIZE} step={0.1} rows={3} format={fmt} onChange={(v) => patch({ height: v })} />
</div>
</>
)}
</div>
</div>
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button style={{ ...btn, flex: 1 }} onClick={() => patch({ locked: !item.locked })}>
{item.locked ? "Unlock" : "Lock"}
</button>
<button style={{ ...btn, flex: 1, color: "#e03131", borderColor: "#e8a8a8" }} onClick={onDelete}>
Delete
</button>
</div>
</div>
);
}
+43 -48
View File
@@ -1,13 +1,16 @@
import { useState, type PointerEvent, type ReactNode } from "react";
import { FURNITURE_TYPES, TYPE_LABEL, type FurnitureType } from "../types";
import type { FurnitureType, FurnitureTypeConfig } from "../types";
import { ShapeIcon } from "./Icons";
const CLICK_THRESHOLD_PX = 5;
interface Props {
types: FurnitureTypeConfig[]; // in palette order
remaining: Record<FurnitureType, number>;
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
onOpenMenu: (type: FurnitureType) => void;
panel: ReactNode; // when set, replaces the tiles (type settings menu)
onNewCustom: () => void;
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
}
const tile: React.CSSProperties = {
@@ -29,35 +32,19 @@ const tile: React.CSSProperties = {
color: "#444",
};
export function ChairIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="4" y="4" width="28" height="28" rx="3" fill="#8fb4d9" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="4" width="28" height="8" rx="3" fill="#4a6f96" />
</svg>
);
}
export function TableIcon({ size = 36 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="9" width="32" height="18" rx="3" fill="#d9b382" stroke="#3b3b3b" strokeWidth="1.5" />
</svg>
);
}
const MIN_W = 152;
const MAX_W = 600;
interface TileProps {
type: FurnitureType;
config: FurnitureTypeConfig;
remaining: number;
onStartDrag: Props["onStartDrag"];
onOpenMenu: Props["onOpenMenu"];
}
/** Press + move > threshold = drag a new item out. Press + release without moving = open menu. */
function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
function Tile({ config, remaining, onStartDrag, onOpenMenu }: TileProps) {
const type = config.id;
const empty = remaining <= 0;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
@@ -90,7 +77,7 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
return (
<div
role="button"
aria-label={`${type} (${remaining} remaining)`}
aria-label={`${config.name} (${remaining} remaining)`}
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
onPointerDown={onPointerDown}
>
@@ -112,31 +99,27 @@ function Tile({ type, remaining, onStartDrag, onOpenMenu }: TileProps) {
>
{remaining}
</span>
{(() => {
const Icon = TYPE_ICON[type];
return <Icon />;
})()}
{TYPE_LABEL[type]}
<ShapeIcon shape={config.shape} />
{config.name}
</div>
);
}
export function ScreenIcon({ size = 36 }: { size?: number }) {
function NewCustomTile({ onClick }: { onClick: () => void }) {
return (
<svg width={size} height={size} viewBox="0 0 36 36">
<rect x="2" y="14" width="32" height="8" rx="2" fill="#2d3748" stroke="#3b3b3b" strokeWidth="1.5" />
<rect x="4" y="16" width="28" height="4" fill="#63b3ed" />
</svg>
<div
role="button"
aria-label="New custom furniture"
onClick={onClick}
style={{ ...tile, border: "1.5px dashed #9a9a9a", background: "transparent", cursor: "pointer" }}
>
<span style={{ font: "300 30px/30px system-ui, sans-serif", color: "#666" }}>+</span>
Custom
</div>
);
}
export const TYPE_ICON: Record<FurnitureType, (p: { size?: number }) => React.JSX.Element> = {
chair: ChairIcon,
table: TableIcon,
screen: ScreenIcon,
};
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => {
@@ -158,20 +141,32 @@ export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
style={{
position: "relative",
width,
boxSizing: "border-box",
flexShrink: 0,
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
borderRight: "1px solid #d4d4d4",
background: "#ececec",
}}
>
{panel ?? FURNITURE_TYPES.map((type) => (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<div
style={{
height: "100%",
boxSizing: "border-box",
overflowY: "auto",
padding: 16,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
alignContent: "start",
gap: 12,
}}
>
{panel ?? (
<>
{types.map((cfg) => (
<Tile key={cfg.id} config={cfg} remaining={remaining[cfg.id]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<NewCustomTile onClick={onNewCustom} />
</>
)}
</div>
<div
onPointerDown={startResize}
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
+64 -35
View File
@@ -1,12 +1,12 @@
import { useRef, useState, type RefObject } from "react";
import { Stage, Layer, Group, Rect } from "react-konva";
import { Stage, Layer, Group, Rect, Path } from "react-konva";
import type Konva from "konva";
import { MIN_SCREEN_SIZE, RESIZABLE, isValidPlacement, clampToRoom, resizeRect } from "../geometry";
import type { FurnitureItem, FurnitureType, Rect as RectM, Room as RoomT } from "../types";
import { FurnitureShape } from "./FurnitureShape";
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
import { FurnitureShape, type Outline } from "./FurnitureShape";
export interface Preview {
type: FurnitureType;
shape: Shape;
x: number; // meters
y: number;
width: number;
@@ -17,9 +17,12 @@ export interface Preview {
interface Props {
room: RoomT;
items: FurnitureItem[];
configs: Record<string, FurnitureTypeConfig>;
preview: Preview | null; // new item being dragged in from the palette
pxPerM: number;
floorRef: RefObject<HTMLDivElement | null>;
selectedId: string | null;
onSelect: (id: string | null) => void;
onMove: (id: string, x: number, y: number) => void;
onResize: (id: string, rect: RectM) => void;
}
@@ -36,21 +39,34 @@ interface ResizeInfo {
}
const HANDLE_PX = 10;
// Top-down view: a screen only changes in length, its thickness is fixed.
const EDGES: ("left" | "right")[] = ["left", "right"];
const ENDS: ("left" | "right")[] = ["left", "right"];
export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize }: Props) {
function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }) {
// 24x24 padlock centered in the item, counter-rotated so it stays upright.
const s = Math.min(1, Math.max(0.35, (Math.min(w, h) * 0.7) / 24));
return (
<Group x={w / 2} y={h / 2} offsetX={12} offsetY={12} scaleX={s} scaleY={s} rotation={-rotation} listening={false}>
<Path data="M8 11V8a4 4 0 0 1 8 0v3" stroke="#1a1a1a" strokeWidth={2.5} />
<Rect x={5} y={11} width={14} height={10} cornerRadius={2} fill="#fff" stroke="#1a1a1a" strokeWidth={2} />
</Group>
);
}
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
const [drag, setDrag] = useState<DragInfo | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [resize, setResize] = useState<ResizeInfo | null>(null);
const resizeStart = useRef<RectM | null>(null);
const w = room.width * pxPerM;
const h = room.height * pxPerM;
// Where the item would land if dropped now: clamped to walls.
const candidate = (item: FurnitureItem, node: Konva.Node) =>
// The group's origin is the item's CENTER (offset puts the rect's center there), so rotation pivots on it.
const candidate = (item: FurnitureItem, node: Konva.Node): FurnitureItem =>
clampToRoom(
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height },
{
...item,
x: node.x() / pxPerM - item.width / 2,
y: node.y() / pxPerM - item.height / 2,
},
room,
);
@@ -70,31 +86,42 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
<Stage
width={w}
height={h}
onMouseDown={(e) => e.target === e.target.getStage() && setSelectedId(null)}
onTouchStart={(e) => e.target === e.target.getStage() && setSelectedId(null)}
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
>
<Layer>
{items.map((item) => {
const shape = configs[item.type]?.shape ?? "chair";
const isResizing = resize?.id === item.id;
const r: RectM = isResizing ? resize.rect : item;
const outline = isResizing
const outline: Outline = isResizing
? resize.valid ? "valid" : "invalid"
: drag?.id === item.id
? drag.valid ? "valid" : "invalid"
: selectedId === item.id ? "selected" : "normal";
const half = boundsOf({ ...r, rotation: item.rotation });
const wpx = r.width * pxPerM;
const hpx = r.height * pxPerM;
return (
<Group
key={item.id}
x={r.x * pxPerM}
y={r.y * pxPerM}
onMouseDown={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
onTouchStart={() => setSelectedId(RESIZABLE[item.type] ? item.id : null)}
x={(r.x + r.width / 2) * pxPerM}
y={(r.y + r.height / 2) * pxPerM}
offsetX={wpx / 2}
offsetY={hpx / 2}
rotation={item.rotation}
draggable={!item.locked}
// Hard-clamp to the walls while dragging (stage is unscaled, so absolute = local).
dragBoundFunc={(pos) => ({
x: Math.min(Math.max(pos.x, 0), w - item.width * pxPerM),
y: Math.min(Math.max(pos.y, 0), h - item.height * pxPerM),
})}
onMouseDown={() => onSelect(item.id)}
onTouchStart={() => onSelect(item.id)}
// Keep the rotated bounds inside the walls (stage is unscaled, so absolute = local).
dragBoundFunc={(pos) => {
const hx = (half.width / 2) * pxPerM;
const hy = (half.height / 2) * pxPerM;
return {
x: Math.min(Math.max(pos.x, hx), w - hx),
y: Math.min(Math.max(pos.y, hy), h - hy),
};
}}
onDragStart={(e) => {
e.target.moveToTop();
}}
@@ -107,31 +134,34 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
const c = candidate(item, node);
const ok = isValidPlacement(c, items, room, item.id);
const final = ok ? c : item; // snap back to last valid position
node.position({ x: final.x * pxPerM, y: final.y * pxPerM });
node.position({ x: (final.x + item.width / 2) * pxPerM, y: (final.y + item.height / 2) * pxPerM });
if (ok) onMove(item.id, c.x, c.y);
setDrag(null);
}}
onMouseEnter={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "grab";
if (c) c.style.cursor = item.locked ? "not-allowed" : "grab";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
>
<FurnitureShape type={item.type} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
<FurnitureShape shape={shape} width={r.width} height={r.height} pxPerM={pxPerM} outline={outline} />
{item.locked && <LockIcon w={wpx} h={hpx} rotation={item.rotation} />}
</Group>
);
})}
{items.map((item) => {
if (item.id !== selectedId || !RESIZABLE[item.type] || drag?.id === item.id) return null;
// Screens get length handles on their ends while selected, unlocked and unrotated.
const isScreen = configs[item.type]?.shape === "screen";
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
const r: RectM = resize?.id === item.id ? resize.rect : item;
const mid: Record<"left" | "right", readonly [number, number]> = {
const mid = {
left: [r.x, r.y + r.height / 2],
right: [r.x + r.width, r.y + r.height / 2],
};
return EDGES.map((edge) => (
} as const;
return ENDS.map((edge) => (
<Rect
key={`${item.id}-${edge}`}
x={mid[edge][0] * pxPerM}
@@ -158,8 +188,8 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
const p = e.target.getStage()?.getPointerPosition();
const start = resizeStart.current;
if (!p || !start) return;
const next = resizeRect(start, edge, p.x / pxPerM, p.y / pxPerM, room, MIN_SCREEN_SIZE);
setResize({ id: item.id, rect: next, valid: isValidPlacement(next, items, room, item.id) });
const next = resizeLength(start, edge, p.x / pxPerM, room);
setResize({ id: item.id, rect: next, valid: isValidPlacement({ ...item, ...next }, items, room, item.id) });
}}
onDragEnd={() => {
if (resize?.valid) onResize(item.id, resize.rect); // else snap back to the old size
@@ -180,7 +210,7 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
{preview && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
type={preview.type}
shape={preview.shape}
width={preview.width}
height={preview.height}
pxPerM={pxPerM}
@@ -194,4 +224,3 @@ export function Room({ room, items, preview, pxPerM, floorRef, onMove, onResize
</div>
);
}
+11 -17
View File
@@ -1,9 +1,9 @@
import { useEffect } from "react";
import { TYPE_LABEL, type FurnitureType, type FurnitureTypeConfig } from "../types";
import type { FurnitureTypeConfig } from "../types";
import { Wheel } from "./Wheel";
import { backBtn, hint, label, panelRoot, title } from "./panelStyles";
interface Props {
type: FurnitureType;
config: FurnitureTypeConfig;
placed: number;
onSetAvailable: (v: number) => void;
@@ -11,10 +11,7 @@ interface Props {
onClose: () => void;
}
const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
export function TypeMenu({ config, placed, onSetAvailable, onSetSeats, onClose }: Props) {
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
@@ -22,26 +19,23 @@ export function TypeMenu({ type, config, placed, onSetAvailable, onSetSeats, onC
}, [onClose]);
return (
<div style={{ gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 }}>
<button
onClick={onClose}
style={{ alignSelf: "flex-start", border: "none", background: "none", padding: 0, cursor: "pointer", font: "13px system-ui, sans-serif", color: "#555" }}
>
<div style={panelRoot}>
<button onClick={onClose} style={backBtn}>
← Back
</button>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{TYPE_LABEL[type]}</div>
<div style={title}>{config.name}</div>
<div>
<div style={label}>Available</div>
<Wheel value={config.available} min={placed} onChange={onSetAvailable} />
<div style={hint}>{placed} placed</div>
</div>
{type === "screen" && (
<div style={hint}>Click a placed screen, then drag an end handle to change its length.</div>
{config.shape === "screen" && (
<div style={hint}>Click a placed screen, then drag an end handle (or use the Length wheel) to change its length.</div>
)}
{type === "table" && (
{config.seatsPerUnit !== undefined && (
<div>
<div style={label}>Seats per table</div>
<Wheel value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
<div style={label}>{config.isTable ? "Seats per table" : "Seats each"}</div>
<Wheel value={config.seatsPerUnit} min={1} onChange={onSetSeats} />
</div>
)}
</div>
+85 -39
View File
@@ -4,12 +4,15 @@ interface Props {
value: number;
min: number;
max?: number;
step?: number; // value units per wheel notch (default 1)
wrap?: boolean; // loop from max back to min (angles)
rows?: number; // visible rows, odd (default 5)
disabled?: boolean;
format?: (v: number) => string;
onChange: (v: number) => void;
}
const ROW_H = 34;
const VISIBLE = 5; // odd, so one row sits in the middle
const REACH = Math.floor(VISIBLE / 2) + 1;
const CLICK_PX = 4; // total movement under this = a tap
const SETTLE_K = 7; // 1/s; higher = snappier settle (fling distance = velocity / k)
const SPRING_W = 12; // 1/s; stiffness of the critically damped settle spring
@@ -21,34 +24,44 @@ const NOTCH_DELTA = 40; // wheel deltas this big are mouse notches (one step), s
* Scroll-wheel number picker (like the iOS timer).
* Drag follows the pointer 1:1, release flings with momentum and settles on a value,
* touching it mid-roll stops it instantly. Also: mouse wheel, tap a row, arrow keys.
* Internally works in integer "notch" space; `step` maps notches to real values.
* If the parent doesn't accept a change (value prop stays put) the wheel settles back to the real value.
*/
export function Wheel({ value, min, max = 999, onChange }: Props) {
const [pos, setPos] = useState(value);
export function Wheel({ value, min, max = 999, step = 1, wrap = false, rows: visible = 5, disabled = false, format, onChange }: Props) {
const idx = (v: number) => Math.round(v / step);
const decimals = (String(step).split(".")[1] ?? "").length;
const [pos, setPos] = useState(idx(value));
const [dragging, setDragging] = useState(false);
const posRef = useRef(value); // fractional position in "value" units
const lastEmitted = useRef(value);
const posRef = useRef(idx(value)); // fractional position in notches
const lastEmitted = useRef(idx(value));
const raf = useRef(0);
const settleTimer = useRef(0);
const targetRef = useRef(value); // where the running animation is heading
const targetRef = useRef(idx(value)); // where the running animation is heading
const box = useRef<HTMLDivElement>(null);
const drag = useRef<{ y0: number; pos0: number; moved: boolean; wasMoving: boolean } | null>(null);
const samples = useRef<{ t: number; pos: number }[]>([]);
// Keep latest props visible to the animation loop.
const cfg = useRef({ min, max, onChange });
cfg.current = { min, max, onChange };
const imin = idx(min);
const imax = idx(max);
const n = imax - imin + 1;
const cfg = useRef({ imin, imax, n, wrap, step, onChange, valueIdx: idx(value) });
cfg.current = { imin, imax, n, wrap, step, onChange, valueIdx: idx(value) };
const clamp = (v: number) => Math.min(cfg.current.max, Math.max(cfg.current.min, v));
const wrapIdx = (i: number) => cfg.current.imin + ((((i - cfg.current.imin) % cfg.current.n) + cfg.current.n) % cfg.current.n);
const keyOf = (i: number) => (cfg.current.wrap ? wrapIdx(i) : i);
const clamp = (v: number) => (cfg.current.wrap ? v : Math.min(cfg.current.imax, Math.max(cfg.current.imin, v)));
const setPosition = (p: number) => {
const c = clamp(p);
posRef.current = c;
setPos(c);
const v = Math.round(c);
if (v !== lastEmitted.current) {
lastEmitted.current = v;
cfg.current.onChange(v);
const k = keyOf(Math.round(c));
if (k !== lastEmitted.current) {
lastEmitted.current = k;
cfg.current.onChange(Math.round(k * cfg.current.step * 1000) / 1000);
}
return c;
};
@@ -60,32 +73,53 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
settleTimer.current = 0;
};
/** Where the real value sits relative to the current position (shortest way round when wrapping). */
const valueTarget = () => {
const have = Math.round(posRef.current);
if (!cfg.current.wrap) return cfg.current.valueIdx;
const d = wrapIdx(cfg.current.valueIdx - have + cfg.current.imin) - cfg.current.imin; // 0..n-1
return have + (d > cfg.current.n / 2 ? d - cfg.current.n : d);
};
/** One frame after a settle ends, check the parent actually took our value; if not, glide back. */
const reconcileSoon = () => {
raf.current = requestAnimationFrame(() => {
raf.current = 0;
if (drag.current) return;
if (keyOf(Math.round(posRef.current)) !== cfg.current.valueIdx) {
lastEmitted.current = cfg.current.valueIdx;
settleTo(valueTarget(), 0);
}
});
};
/** Ease from the current position to `target` with exponential decay (iOS-style deceleration). */
const animateTo = (rawTarget: number) => {
stop();
const target = clamp(Math.round(rawTarget));
targetRef.current = target;
let last = performance.now();
const step = (now: number) => {
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const next = posRef.current + (target - posRef.current) * (1 - Math.exp(-SETTLE_K * dt));
if (Math.abs(target - next) < 0.003) {
setPosition(target);
raf.current = 0;
reconcileSoon();
return;
}
setPosition(next);
raf.current = requestAnimationFrame(step);
raf.current = requestAnimationFrame(frame);
};
raf.current = requestAnimationFrame(step);
raf.current = requestAnimationFrame(frame);
};
/**
* Settle onto `rawTarget` with a critically damped spring that starts at the current release
* velocity `v0` (rows/s). Smooth start (no velocity jump) and no overshoot, so slow releases glide in.
*/
const settleTo = (rawTarget: number, v0: number) => {
function settleTo(rawTarget: number, v0: number) {
stop();
const target = clamp(Math.round(rawTarget));
targetRef.current = target;
@@ -97,7 +131,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
const w = toward ? Math.max(SPRING_W, Math.abs(v0) / dist) : SPRING_W;
const b = v0 + w * a;
const t0 = performance.now();
const step = (now: number) => {
const frame = (now: number) => {
const t = (now - t0) / 1000;
const decay = Math.exp(-w * t);
const x = target + (a + b * t) * decay;
@@ -105,25 +139,29 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
if ((Math.abs(x - target) < 0.003 && Math.abs(vel) < 0.05) || t > 1.5) {
setPosition(target);
raf.current = 0;
reconcileSoon();
return;
}
setPosition(x);
raf.current = requestAnimationFrame(step);
raf.current = requestAnimationFrame(frame);
};
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(frame);
}
// Follow outside changes (e.g. min rising) when idle.
// Follow outside changes (rotate button, min rising, selection change) when idle.
useEffect(() => {
if (drag.current || raf.current) return;
posRef.current = value;
lastEmitted.current = value;
setPos(value);
const p = valueTarget();
posRef.current = p;
lastEmitted.current = keyOf(Math.round(p));
setPos(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
const wasMoving = raf.current !== 0 || settleTimer.current !== 0;
stop(); // stopping power: touching freezes the wheel exactly where it is
@@ -155,9 +193,9 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
} else {
// plain tap: jump to the tapped row
const rect = box.current!.getBoundingClientRect();
const rows = Math.round((e.clientY - rect.top - (VISIBLE * ROW_H) / 2) / ROW_H);
const r = Math.round((e.clientY - rect.top - (visible * ROW_H) / 2) / ROW_H);
posRef.current = d.pos0;
animateTo(Math.round(d.pos0) + rows);
animateTo(Math.round(d.pos0) + r);
}
return;
}
@@ -177,6 +215,7 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
};
const onWheel = (e: React.WheelEvent) => {
if (disabled) return;
if (Math.abs(e.deltaY) >= NOTCH_DELTA) {
// mouse notch: one value per notch, repeated notches build on the pending target
const base = raf.current ? targetRef.current : Math.round(posRef.current);
@@ -193,17 +232,22 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
if (e.key === "ArrowUp") animateTo(Math.round(posRef.current) + 1);
else if (e.key === "ArrowDown") animateTo(Math.round(posRef.current) - 1);
else return;
e.preventDefault();
};
const reach = Math.floor(visible / 2) + 1;
const centre = Math.round(pos);
const rows = [];
for (let v = Math.max(min, centre - REACH); v <= Math.min(max, centre + REACH); v++) {
const lo = wrap ? centre - reach : Math.max(imin, centre - reach);
const hi = wrap ? centre + reach : Math.min(imax, centre + reach);
const items = [];
for (let v = lo; v <= hi; v++) {
const off = v - pos;
rows.push(
const real = (wrap ? wrapIdx(v) : v) * step;
items.push(
<div
key={v}
style={{
@@ -216,12 +260,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
justifyContent: "center",
font: "600 20px system-ui, sans-serif",
color: "#222",
opacity: Math.max(0, 1 - Math.abs(off) * 0.38),
transform: `translateY(${((VISIBLE - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
opacity: Math.max(0, 1 - Math.abs(off) * (visible <= 3 ? 0.55 : 0.38)),
transform: `translateY(${((visible - 1) / 2 + off) * ROW_H}px) scale(${1 - Math.min(1, Math.abs(off)) * 0.12})`,
pointerEvents: "none",
}}
>
{v}
{format ? format(real) : real.toFixed(decimals)}
</div>,
);
}
@@ -229,11 +273,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
return (
<div
ref={box}
tabIndex={0}
tabIndex={disabled ? -1 : 0}
role="spinbutton"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@@ -243,11 +288,12 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
style={{
position: "relative",
width: "100%",
height: VISIBLE * ROW_H,
height: visible * ROW_H,
overflow: "hidden",
background: "#f4f4f4",
borderRadius: 10,
cursor: dragging ? "grabbing" : "ns-resize",
cursor: disabled ? "not-allowed" : dragging ? "grabbing" : "ns-resize",
opacity: disabled ? 0.45 : 1,
userSelect: "none",
touchAction: "none",
outline: "none",
@@ -258,14 +304,14 @@ export function Wheel({ value, min, max = 999, onChange }: Props) {
position: "absolute",
left: 6,
right: 6,
top: ((VISIBLE - 1) / 2) * ROW_H,
top: ((visible - 1) / 2) * ROW_H,
height: ROW_H,
background: "rgba(0,0,0,0.08)",
borderRadius: 8,
pointerEvents: "none",
}}
/>
{rows}
{items}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
export const panelRoot: React.CSSProperties = { gridColumn: "1 / -1", display: "flex", flexDirection: "column", gap: 14 };
export const label: React.CSSProperties = { font: "600 12px system-ui, sans-serif", color: "#555", marginBottom: 4 };
export const hint: React.CSSProperties = { font: "12px system-ui, sans-serif", color: "#888", marginTop: 4 };
export const title: React.CSSProperties = { font: "600 15px system-ui, sans-serif", color: "#222" };
export const backBtn: React.CSSProperties = {
alignSelf: "flex-start",
border: "none",
background: "none",
padding: 0,
cursor: "pointer",
font: "13px system-ui, sans-serif",
color: "#555",
};
export const btn: React.CSSProperties = {
height: 32,
padding: "0 12px",
border: "1px solid #c4c4c4",
background: "#fff",
borderRadius: 8,
cursor: "pointer",
font: "13px system-ui, sans-serif",
color: "#222",
};
export const twoCol: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(96px, 1fr))", gap: 12 };
+61 -53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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...)
}