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