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
+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>
);
}