first commit
This commit is contained in:
+153
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 />);
|
||||
@@ -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 } },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user