first commit

This commit is contained in:
2026-10-17 12:09:36 -04:00
commit 56046a63fc
1149 changed files with 658055 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { DEFAULT_SIZES, fitScale, isValidPlacement, clampToRoom } from "./geometry";
import { initialState, reducer } from "./store";
import type { FurnitureType } from "./types";
import { remaining, placedCount } from "./inventory";
import { Palette, ChairIcon, TableIcon } from "./components/Palette";
import { Room, type Preview } from "./components/Room";
import { ScaleBar } from "./components/ScaleBar";
import { TypeMenu } from "./components/TypeMenu";
const WALL_PX = 6; // room wall border, must match Room.tsx
const SCALE_BAR_PX = 40;
interface PaletteDrag {
type: FurnitureType;
clientX: number;
clientY: number;
}
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 floorRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
const [pxPerM, setPxPerM] = useState(60);
// Maximise the room inside the center area (minus wall border and the scale bar).
useEffect(() => {
const el = mainRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setPxPerM(fitScale(state.room, width - WALL_PX * 2, height - WALL_PX * 2 - SCALE_BAR_PX));
});
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) => {
const box = floorRef.current?.getBoundingClientRect();
if (!box) return null;
// Floor excludes the CSS border: the Stage sits inside it.
const border = (box.width - state.room.width * pxPerM) / 2;
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,
);
};
useEffect(() => {
if (!paletteDrag) return;
const type = paletteDrag.type;
const move = (e: globalThis.PointerEvent) =>
setPaletteDrag({ type, clientX: e.clientX, clientY: e.clientY });
const up = (e: globalThis.PointerEvent) => {
const r = toRoomRect({ type, clientX: e.clientX, clientY: e.clientY });
if (
r &&
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 });
} // outside the room or invalid: cancel
setPaletteDrag(null);
};
const cancel = () => setPaletteDrag(null);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancel);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paletteDrag?.type, state.items, state.room, state.configs, pxPerM]);
const preview: Preview | null = useMemo(() => {
if (!paletteDrag) return null;
const r = toRoomRect(paletteDrag);
if (!r) return null;
return { type: paletteDrag.type, ...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);
setPaletteDrag({ type, clientX, clientY });
};
const remainingByType = {
chair: remaining(state.items, "chair", state.configs.chair.available),
table: remaining(state.items, "table", state.configs.table.available),
};
return (
<div style={{ display: "flex", height: "100vh", background: "#e9e9e9", cursor: paletteDrag ? "grabbing" : undefined }}>
<Palette
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}
/>
)
}
/>
<main
ref={mainRef}
style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", overflow: "hidden", padding: 24 }}
>
<Room
pxPerM={pxPerM}
room={state.room}
items={state.items}
preview={preview}
floorRef={floorRef}
onMove={(id, x, y) => dispatch({ kind: "move", id, x, y })}
/>
<ScaleBar pxPerM={pxPerM} roomWidthPx={state.room.width * pxPerM + WALL_PX * 2} />
</main>
{paletteDrag && !preview && (
<div
style={{
position: "fixed",
left: paletteDrag.clientX,
top: paletteDrag.clientY,
transform: "translate(-50%, -50%)",
pointerEvents: "none",
opacity: 0.8,
}}
>
{paletteDrag.type === "chair" ? <ChairIcon /> : <TableIcon />}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { Group, Rect } from "react-konva";
import type { FurnitureType } from "../types";
export type Outline = "normal" | "valid" | "invalid";
const STROKE: Record<Outline, string> = {
normal: "#3b3b3b",
valid: "#2f9e44",
invalid: "#e03131",
};
interface Props {
type: FurnitureType;
width: number; // meters
height: number;
pxPerM: number;
outline: Outline;
opacity?: number;
}
/** Top-down shape drawn at local (0,0). Position is set by the parent Group. */
export function FurnitureShape({ type, 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 === "table") {
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#d9b382" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
</Group>
);
}
const bar = Math.max(4, h * 0.2);
return (
<Group opacity={opacity}>
<Rect width={w} height={h} fill="#8fb4d9" stroke={stroke} strokeWidth={strokeWidth} cornerRadius={3} />
<Rect width={w} height={bar} fill="#4a6f96" cornerRadius={[3, 3, 0, 0]} listening={false} />
</Group>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useState, type PointerEvent, type ReactNode } from "react";
import type { FurnitureType } from "../types";
const CLICK_THRESHOLD_PX = 5;
interface Props {
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)
}
const tile: React.CSSProperties = {
width: "100%",
height: 84,
boxSizing: "border-box",
background: "#fff",
border: "1px solid #cfcfcf",
borderRadius: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
cursor: "grab",
userSelect: "none",
touchAction: "none",
font: "12px system-ui, sans-serif",
color: "#444",
};
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;
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) {
const empty = remaining <= 0;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const { clientX: x0, clientY: y0 } = e;
let dragging = false;
const move = (ev: globalThis.PointerEvent) => {
if (dragging || empty) return;
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) >= CLICK_THRESHOLD_PX) {
dragging = true;
cleanup();
onStartDrag(type, ev.clientX, ev.clientY);
}
};
const up = (ev: globalThis.PointerEvent) => {
cleanup();
// Out-of-stock tiles can't be dragged, so any release over the tile counts as a click.
if (Math.hypot(ev.clientX - x0, ev.clientY - y0) < CLICK_THRESHOLD_PX || empty) {
onOpenMenu(type);
}
};
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<div
role="button"
aria-label={`${type} (${remaining} remaining)`}
style={{ ...tile, position: "relative", opacity: empty ? 0.45 : 1, filter: empty ? "grayscale(1)" : undefined, cursor: empty ? "pointer" : "grab" }}
onPointerDown={onPointerDown}
>
<span
style={{
position: "absolute",
top: 6,
right: 6,
minWidth: 20,
height: 20,
padding: "0 6px",
boxSizing: "border-box",
borderRadius: 10,
background: empty ? "#888" : "#2b2b2b",
color: "#fff",
font: "600 11px/20px system-ui, sans-serif",
textAlign: "center",
}}
>
{remaining}
</span>
{type === "chair" ? <ChairIcon /> : <TableIcon />}
{type === "chair" ? "Chair" : "Table"}
</div>
);
}
export function Palette({ remaining, onStartDrag, onOpenMenu, panel }: Props) {
const [width, setWidth] = useState(264);
const startResize = (e: PointerEvent) => {
e.preventDefault();
const startX = e.clientX;
const startW = width;
const move = (ev: globalThis.PointerEvent) =>
setWidth(Math.min(MAX_W, Math.max(MIN_W, startW + ev.clientX - startX)));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
return (
<aside
style={{
position: "relative",
width,
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 ?? (["chair", "table"] as const).map((type) => (
<Tile key={type} type={type} remaining={remaining[type]} onStartDrag={onStartDrag} onOpenMenu={onOpenMenu} />
))}
<div
onPointerDown={startResize}
style={{ position: "absolute", top: 0, right: -3, width: 6, height: "100%", cursor: "col-resize", touchAction: "none" }}
/>
</aside>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useState, type RefObject } from "react";
import { Stage, Layer, Group } from "react-konva";
import type Konva from "konva";
import { isValidPlacement, clampToRoom } from "../geometry";
import type { FurnitureItem, FurnitureType, Room as RoomT } from "../types";
import { FurnitureShape } from "./FurnitureShape";
export interface Preview {
type: FurnitureType;
x: number; // meters
y: number;
width: number;
height: number;
valid: boolean;
}
interface Props {
room: RoomT;
items: FurnitureItem[];
preview: Preview | null; // new item being dragged in from the palette
pxPerM: number;
floorRef: RefObject<HTMLDivElement | null>;
onMove: (id: string, x: number, y: number) => void;
}
interface DragInfo {
id: string;
valid: boolean;
}
export function Room({ room, items, preview, pxPerM, floorRef, onMove }: Props) {
const [drag, setDrag] = useState<DragInfo | 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) =>
clampToRoom(
{ x: node.x() / pxPerM, y: node.y() / pxPerM, width: item.width, height: item.height },
room,
);
return (
<div
ref={floorRef}
style={{
width: w,
height: h,
border: "6px solid #2b2b2b",
boxSizing: "content-box",
background: "#fff",
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
touchAction: "none",
}}
>
<Stage width={w} height={h}>
<Layer>
{items.map((item) => {
const outline =
drag?.id === item.id ? (drag.valid ? "valid" : "invalid") : "normal";
return (
<Group
key={item.id}
x={item.x * pxPerM}
y={item.y * pxPerM}
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),
})}
onDragStart={(e) => {
e.target.moveToTop();
}}
onDragMove={(e) => {
const c = candidate(item, e.target);
setDrag({ id: item.id, valid: isValidPlacement(c, items, room, item.id) });
}}
onDragEnd={(e) => {
const node = e.target;
const c = candidate(item, node);
const ok = isValidPlacement(c, items, room, item.id);
const final = ok ? c : item; // snap back to last valid position
node.position({ x: final.x * pxPerM, y: final.y * 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";
}}
onMouseLeave={(e) => {
const c = e.target.getStage()?.container();
if (c) c.style.cursor = "";
}}
>
<FurnitureShape type={item.type} width={item.width} height={item.height} pxPerM={pxPerM} outline={outline} />
</Group>
);
})}
{preview && (
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
<FurnitureShape
type={preview.type}
width={preview.width}
height={preview.height}
pxPerM={pxPerM}
outline={preview.valid ? "valid" : "invalid"}
opacity={0.85}
/>
</Group>
)}
</Layer>
</Stage>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
// Arbitrary scale indicator: a 1 m bar drawn at the current zoom.
export function ScaleBar({ pxPerM, roomWidthPx }: { pxPerM: number; roomWidthPx: number }) {
return (
<div style={{ width: roomWidthPx, height: 40, display: "flex", alignItems: "flex-end", paddingBottom: 4, boxSizing: "border-box" }}>
<div style={{ font: "12px system-ui, sans-serif", color: "#555" }}>
<div style={{ width: pxPerM, height: 6, borderBottom: "2px solid #555", borderLeft: "2px solid #555", borderRight: "2px solid #555", boxSizing: "border-box", marginBottom: 2 }} />
1 m
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
interface Props {
value: number;
min: number;
onChange: (v: number) => void;
}
const btn: React.CSSProperties = {
width: 28,
height: 28,
flexShrink: 0,
border: "1px solid #c4c4c4",
background: "#fff",
borderRadius: 6,
cursor: "pointer",
font: "16px system-ui, sans-serif",
lineHeight: 1,
};
/** - / input / + . Typing is committed on blur or Enter so intermediate values aren't clamped. */
export function Stepper({ value, min, onChange }: Props) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
const n = Math.round(Number(draft));
if (draft.trim() === "" || Number.isNaN(n)) return setDraft(String(value));
const v = Math.max(min, n);
setDraft(String(v));
if (v !== value) onChange(v);
};
return (
<div style={{ display: "flex", gap: 6, alignItems: "center", width: "100%" }}>
<button style={btn} disabled={value <= min} onClick={() => onChange(Math.max(min, value - 1))} aria-label="Decrease">
−
</button>
<input
value={draft}
inputMode="numeric"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
style={{ flex: 1, minWidth: 0, height: 28, boxSizing: "border-box", textAlign: "center", border: "1px solid #c4c4c4", borderRadius: 6, font: "14px system-ui, sans-serif" }}
/>
<button style={btn} onClick={() => onChange(value + 1)} aria-label="Increase">
+
</button>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect } from "react";
import type { FurnitureType, FurnitureTypeConfig } from "../types";
import { Stepper } from "./Stepper";
interface Props {
type: FurnitureType;
config: FurnitureTypeConfig;
placed: number;
onSetAvailable: (v: number) => void;
onSetSeats: (v: number) => void;
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) {
useEffect(() => {
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", key);
return () => window.removeEventListener("keydown", key);
}, [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" }}
>
← Back
</button>
<div style={{ font: "600 15px system-ui, sans-serif", color: "#222" }}>{type === "chair" ? "Chair" : "Table"}</div>
<div>
<div style={label}>Available</div>
<Stepper value={config.available} min={placed} onChange={onSetAvailable} />
<div style={hint}>{placed} placed</div>
</div>
{type === "table" && (
<div>
<div style={label}>Seats per table</div>
<Stepper value={config.seatsPerUnit ?? 8} min={1} onChange={onSetSeats} />
</div>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Pure geometry/rules. No React or Konva imports so an optimizer can reuse this.
import type { FurnitureItem, FurnitureType, 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 },
};
export const DEFAULT_ROOM: Room = { width: 10, height: 8 };
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),
};
}
/** Axis-aligned overlap. Touching edges do not count as overlap. */
export function overlaps(a: Rect, b: Rect): boolean {
const eps = 1e-9;
return (
a.x < b.x + b.width - eps &&
a.x + a.width > b.x + eps &&
a.y < b.y + b.height - eps &&
a.y + a.height > b.y + eps
);
}
export function isInsideRoom(r: Rect, room: Room): boolean {
const eps = 1e-9;
return (
r.x >= -eps &&
r.y >= -eps &&
r.x + r.width <= room.width + eps &&
r.y + r.height <= room.height + eps
);
}
/** Valid = inside the room and not overlapping any other item (ignoring `ignoreId`). */
export function isValidPlacement(
r: Rect,
items: FurnitureItem[],
room: Room,
ignoreId?: string,
): boolean {
return isInsideRoom(r, room) && !items.some((o) => o.id !== ignoreId && overlaps(r, o));
}
+8
View File
@@ -0,0 +1,8 @@
// Pure inventory rules. Everything is derived from the items array.
import type { FurnitureItem, FurnitureType } 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);
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
document.body.style.margin = "0";
createRoot(document.getElementById("root")!).render(<App />);
+61
View File
@@ -0,0 +1,61 @@
import type { FurnitureItem, FurnitureType, FurnitureTypeConfig, Room } from "./types";
import { DEFAULT_ROOM, DEFAULT_SIZES } 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>;
}
export type Action =
| { kind: "add"; id: string; type: FurnitureType; x: number; y: number }
| { kind: "move"; id: string; x: number; y: number }
| { kind: "setAvailable"; type: FurnitureType; value: number }
| { kind: "setSeats"; value: number };
export const initialState: LayoutState = { room: DEFAULT_ROOM, items: [],
configs: {
chair: { type: "chair", available: 20 },
table: { type: "table", available: 6, seatsPerUnit: 8 },
},
};
export function reducer(state: LayoutState, action: Action): LayoutState {
switch (action.kind) {
case "add": {
const { width, height } = DEFAULT_SIZES[action.type];
const item: FurnitureItem = {
id: action.id,
type: action.type,
x: action.x,
y: action.y,
width,
height,
rotation: 0,
locked: false,
};
return { ...state, items: [...state.items, item] };
}
case "move":
return {
...state,
items: state.items.map((i) =>
i.id === action.id ? { ...i, x: action.x, y: action.y } : i,
),
};
case "setAvailable":
return {
...state,
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 } },
};
}
}
+31
View File
@@ -0,0 +1,31 @@
export type FurnitureType = "chair" | "table";
export interface FurnitureItem {
id: string;
type: FurnitureType;
x: number; // meters, top-left, room coordinates
y: number;
width: number; // meters
height: number;
rotation: number; // degrees, always 0 for now
locked: boolean; // always false for now, reserved
}
export interface Room {
width: number; // meters
height: number;
}
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
// Per-type settings, edited via the palette menu.
export interface FurnitureTypeConfig {
type: FurnitureType;
available: number; // inventory count
seatsPerUnit?: number; // tables only
}