7ec02ed9ed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
495 lines
19 KiB
TypeScript
495 lines
19 KiB
TypeScript
import { useRef, useState, type RefObject } from "react";
|
|
import { Stage, Layer, Group, Rect, Path, Arc, Line, RegularPolygon } from "react-konva";
|
|
import type Konva from "konva";
|
|
import {
|
|
SCREEN_DEPTH_M,
|
|
boundsOf,
|
|
clampToRoom,
|
|
doorSwingRect,
|
|
isValidObstacle,
|
|
isValidPlacement,
|
|
isValidWallItem,
|
|
resizeLength,
|
|
wallLength,
|
|
wallRect,
|
|
} from "../geometry";
|
|
import type { Door, FurnitureItem, FurnitureTypeConfig, Obstacle, Rect as RectM, Room as RoomT, RoomScreen, Shape, Wall } from "../types";
|
|
import { FurnitureShape, type Outline } from "./FurnitureShape";
|
|
|
|
export interface Preview {
|
|
shape: Shape;
|
|
x: number; // meters
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
valid: boolean;
|
|
}
|
|
|
|
export const WALL_PX = 6; // drawn wall thickness around the floor
|
|
export type Mode = "layout" | "room";
|
|
export type RoomSel = { kind: "door" | "screen" | "obstacle"; id: string };
|
|
|
|
interface Props {
|
|
mode: Mode;
|
|
roomSel: RoomSel | null;
|
|
onSelectRoom: (sel: RoomSel | null) => void;
|
|
onMoveWallItem: (kind: "door" | "screen", id: string, offset: number) => void;
|
|
onMoveObstacle: (id: string, x: number, y: number) => void;
|
|
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;
|
|
}
|
|
|
|
interface DragInfo {
|
|
id: string;
|
|
valid: boolean;
|
|
}
|
|
|
|
interface ResizeInfo {
|
|
id: string;
|
|
rect: RectM;
|
|
valid: boolean;
|
|
}
|
|
|
|
interface WallDrag {
|
|
kind: "door" | "screen";
|
|
id: string;
|
|
offset: number;
|
|
valid: boolean;
|
|
}
|
|
|
|
const HANDLE_PX = 10;
|
|
const O = WALL_PX;
|
|
const WALL_COLOR = "#2b2b2b";
|
|
const SEL = "#1c7ed6";
|
|
const BAD = "#e03131";
|
|
const GOOD = "#2f9e44";
|
|
|
|
/** Wall pieces between door openings, in meters along each wall. */
|
|
function wallSegments(room: RoomT): { wall: Wall; a: number; b: number }[] {
|
|
const out: { wall: Wall; a: number; b: number }[] = [];
|
|
for (const wall of ["top", "bottom", "left", "right"] as Wall[]) {
|
|
const len = wallLength(room, wall);
|
|
const gaps = room.doors.filter((d) => d.wall === wall).sort((p, q) => p.offset - q.offset);
|
|
let at = 0;
|
|
for (const g of gaps) {
|
|
if (g.offset > at + 1e-6) out.push({ wall, a: at, b: g.offset });
|
|
at = Math.max(at, g.offset + g.width);
|
|
}
|
|
if (at < len - 1e-6) out.push({ wall, a: at, b: len });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Quarter-circle swing: hinge at the door's near end, leaf swings into the room.
|
|
const SWING = {
|
|
top: { rot: 0, dir: [0, 1] },
|
|
bottom: { rot: 270, dir: [0, -1] },
|
|
left: { rot: 0, dir: [1, 0] },
|
|
right: { rot: 90, dir: [-1, 0] },
|
|
} as const;
|
|
|
|
function hinge(room: RoomT, d: Pick<Door, "wall" | "offset">): [number, number] {
|
|
switch (d.wall) {
|
|
case "top":
|
|
return [d.offset, 0];
|
|
case "bottom":
|
|
return [d.offset, room.height];
|
|
case "left":
|
|
return [0, d.offset];
|
|
case "right":
|
|
return [room.width, d.offset];
|
|
}
|
|
}
|
|
|
|
function DoorShape({ room, door, px, color }: { room: RoomT; door: Door; px: number; color: string }) {
|
|
const [hx, hy] = hinge(room, door);
|
|
const sw = SWING[door.wall];
|
|
const r = door.width * px;
|
|
return (
|
|
<Group listening={false}>
|
|
<Arc
|
|
x={hx * px}
|
|
y={hy * px}
|
|
innerRadius={0}
|
|
outerRadius={r}
|
|
angle={90}
|
|
rotation={sw.rot}
|
|
fill={door.isLoading ? "rgba(237,137,54,0.2)" : "rgba(66,153,225,0.14)"}
|
|
stroke={color}
|
|
strokeWidth={1}
|
|
dash={[4, 4]}
|
|
/>
|
|
<Line points={[hx * px, hy * px, hx * px + sw.dir[0] * r, hy * px + sw.dir[1] * r]} stroke={color} strokeWidth={3} lineCap="round" />
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const ARROW_ROT: Record<Wall, number> = { top: 180, bottom: 0, left: 90, right: -90 };
|
|
|
|
function ScreenShape({ room, screen, px, outline }: { room: RoomT; screen: RoomScreen; px: number; outline: string }) {
|
|
const r = wallRect(room, screen.wall, screen.offset, screen.width, SCREEN_DEPTH_M);
|
|
const rad = Math.max(4, SCREEN_DEPTH_M * px * 0.5);
|
|
return (
|
|
<Group listening={false}>
|
|
<Rect x={r.x * px} y={r.y * px} width={r.width * px} height={r.height * px} fill="#2d3748" stroke={outline} strokeWidth={outline === WALL_COLOR ? 1 : 2.5} cornerRadius={2} />
|
|
<RegularPolygon
|
|
x={(r.x + r.width / 2) * px}
|
|
y={(r.y + r.height / 2) * px}
|
|
sides={3}
|
|
radius={rad}
|
|
rotation={ARROW_ROT[screen.wall]}
|
|
fill="#63b3ed"
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
const ENDS: ("left" | "right")[] = ["left", "right"];
|
|
|
|
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({ mode, roomSel, onSelectRoom, onMoveWallItem, onMoveObstacle, room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
|
const interactive = mode === "layout";
|
|
const [drag, setDrag] = useState<DragInfo | null>(null);
|
|
const [resize, setResize] = useState<ResizeInfo | null>(null);
|
|
const resizeStart = useRef<RectM | null>(null);
|
|
const [wallDrag, setWallDrag] = useState<WallDrag | null>(null);
|
|
const wallStart = useRef<{ along: number; offset: number } | null>(null);
|
|
const [obDrag, setObDrag] = useState<{ id: string; valid: boolean } | null>(null);
|
|
const w = room.width * pxPerM;
|
|
const h = room.height * pxPerM;
|
|
|
|
// 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(
|
|
{
|
|
...item,
|
|
x: node.x() / pxPerM - item.width / 2,
|
|
y: node.y() / pxPerM - item.height / 2,
|
|
},
|
|
room,
|
|
);
|
|
|
|
const fw = w + O * 2;
|
|
const fh = h + O * 2;
|
|
|
|
// ---- wall items (doors / screens): slide along their wall only ----
|
|
const alongOf = (wall: Wall, p: { x: number; y: number }) => ((wall === "top" || wall === "bottom" ? p.x : p.y) - O) / pxPerM;
|
|
|
|
const wallHit = (kind: "door" | "screen", id: string, wall: Wall, offset: number, width: number, rect: RectM) => {
|
|
return (
|
|
<Rect
|
|
key={`hit-${id}`}
|
|
x={rect.x * pxPerM}
|
|
y={rect.y * pxPerM}
|
|
width={rect.width * pxPerM}
|
|
height={rect.height * pxPerM}
|
|
fill="transparent"
|
|
draggable
|
|
dragBoundFunc={function (this: Konva.Node) {
|
|
return this.absolutePosition();
|
|
}}
|
|
onMouseDown={() => onSelectRoom({ kind, id })}
|
|
onTouchStart={() => onSelectRoom({ kind, id })}
|
|
onDragStart={(e) => {
|
|
e.cancelBubble = true;
|
|
const p = e.target.getStage()?.getPointerPosition();
|
|
if (p) wallStart.current = { along: alongOf(wall, p), offset };
|
|
}}
|
|
onDragMove={(e) => {
|
|
const p = e.target.getStage()?.getPointerPosition();
|
|
const st = wallStart.current;
|
|
if (!p || !st) return;
|
|
const next = Math.min(Math.max(st.offset + alongOf(wall, p) - st.along, 0), wallLength(room, wall) - width);
|
|
setWallDrag({ kind, id, offset: next, valid: isValidWallItem(room, items, { id, kind, wall, offset: next, width }) });
|
|
}}
|
|
onDragEnd={() => {
|
|
if (wallDrag?.valid) onMoveWallItem(kind, id, wallDrag.offset); // else snap back
|
|
setWallDrag(null);
|
|
wallStart.current = null;
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = wall === "top" || wall === "bottom" ? "ew-resize" : "ns-resize";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = "";
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const colorFor = (kind: "door" | "screen", id: string, normal: string) => {
|
|
if (wallDrag?.kind === kind && wallDrag.id === id) return wallDrag.valid ? GOOD : BAD;
|
|
return roomSel?.kind === kind && roomSel.id === id ? SEL : normal;
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
width: fw,
|
|
height: fh,
|
|
background: "#fff",
|
|
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
|
|
touchAction: "none",
|
|
}}
|
|
ref={floorRef}
|
|
>
|
|
<Stage
|
|
width={fw}
|
|
height={fh}
|
|
onMouseDown={(e) => {
|
|
if (e.target !== e.target.getStage()) return;
|
|
if (interactive) onSelect(null);
|
|
else onSelectRoom(null);
|
|
}}
|
|
onTouchStart={(e) => {
|
|
if (e.target !== e.target.getStage()) return;
|
|
if (interactive) onSelect(null);
|
|
else onSelectRoom(null);
|
|
}}
|
|
>
|
|
<Layer x={O} y={O}>
|
|
<Rect width={w} height={h} fill="#fff" listening={false} />
|
|
|
|
{/* walls with a gap at every door */}
|
|
{wallSegments(room).map((sg, n) => {
|
|
const a = sg.a * pxPerM;
|
|
const len = (sg.b - sg.a) * pxPerM;
|
|
const r =
|
|
sg.wall === "top"
|
|
? { x: a, y: -O, width: len, height: O }
|
|
: sg.wall === "bottom"
|
|
? { x: a, y: h, width: len, height: O }
|
|
: sg.wall === "left"
|
|
? { x: -O, y: a, width: O, height: len }
|
|
: { x: w, y: a, width: O, height: len };
|
|
return <Rect key={n} {...r} fill={WALL_COLOR} listening={false} />;
|
|
})}
|
|
{[[-O, -O], [w, -O], [-O, h], [w, h]].map(([x, y]) => (
|
|
<Rect key={`${x},${y}`} x={x} y={y} width={O} height={O} fill={WALL_COLOR} listening={false} />
|
|
))}
|
|
|
|
{/* doors: swing arc inside the room */}
|
|
{room.doors.map((d) => {
|
|
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? { ...d, offset: wallDrag.offset } : d;
|
|
return <DoorShape key={d.id} room={room} door={live} px={pxPerM} color={colorFor("door", d.id, d.isLoading ? "#c05621" : "#4a5568")} />;
|
|
})}
|
|
|
|
{/* wall screens */}
|
|
{room.screens.map((sc) => {
|
|
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? { ...sc, offset: wallDrag.offset } : sc;
|
|
return <ScreenShape key={sc.id} room={room} screen={live} px={pxPerM} outline={colorFor("screen", sc.id, WALL_COLOR)} />;
|
|
})}
|
|
|
|
{/* obstacles */}
|
|
{room.obstacles.map((o: Obstacle) => {
|
|
const state = obDrag?.id === o.id ? (obDrag.valid ? GOOD : BAD) : roomSel?.kind === "obstacle" && roomSel.id === o.id ? SEL : "#4a4a4a";
|
|
return (
|
|
<Rect
|
|
key={o.id}
|
|
x={o.x * pxPerM}
|
|
y={o.y * pxPerM}
|
|
width={o.width * pxPerM}
|
|
height={o.height * pxPerM}
|
|
fill="#9aa0a6"
|
|
stroke={state}
|
|
strokeWidth={state === "#4a4a4a" ? 1.5 : 3}
|
|
cornerRadius={2}
|
|
listening={!interactive}
|
|
draggable={!interactive}
|
|
onMouseDown={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
|
onTouchStart={() => onSelectRoom({ kind: "obstacle", id: o.id })}
|
|
dragBoundFunc={(pos) => ({
|
|
x: Math.min(Math.max(pos.x, O), O + w - o.width * pxPerM),
|
|
y: Math.min(Math.max(pos.y, O), O + h - o.height * pxPerM),
|
|
})}
|
|
onDragMove={(e) =>
|
|
setObDrag({
|
|
id: o.id,
|
|
valid: isValidObstacle(room, items, { ...o, x: e.target.x() / pxPerM, y: e.target.y() / pxPerM }),
|
|
})
|
|
}
|
|
onDragEnd={(e) => {
|
|
const nx = e.target.x() / pxPerM;
|
|
const ny = e.target.y() / pxPerM;
|
|
const ok = isValidObstacle(room, items, { ...o, x: nx, y: ny });
|
|
e.target.position({ x: (ok ? nx : o.x) * pxPerM, y: (ok ? ny : o.y) * pxPerM });
|
|
if (ok) onMoveObstacle(o.id, nx, ny);
|
|
setObDrag(null);
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c && !interactive) c.style.cursor = "grab";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = "";
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{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: 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 + r.width / 2) * pxPerM}
|
|
y={(r.y + r.height / 2) * pxPerM}
|
|
offsetX={wpx / 2}
|
|
offsetY={hpx / 2}
|
|
rotation={item.rotation}
|
|
listening={interactive}
|
|
draggable={interactive && !item.locked}
|
|
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, O + hx), O + w - hx),
|
|
y: Math.min(Math.max(pos.y, O + hy), O + h - hy),
|
|
};
|
|
}}
|
|
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 + 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 = item.locked ? "not-allowed" : "grab";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = "";
|
|
}}
|
|
>
|
|
<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) => {
|
|
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
|
const isScreen = configs[item.type]?.shape === "screen";
|
|
if (!interactive || 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 = {
|
|
left: [r.x, r.y + r.height / 2],
|
|
right: [r.x + r.width, r.y + r.height / 2],
|
|
} as const;
|
|
return ENDS.map((edge) => (
|
|
<Rect
|
|
key={`${item.id}-${edge}`}
|
|
x={mid[edge][0] * pxPerM}
|
|
y={mid[edge][1] * pxPerM}
|
|
offsetX={HANDLE_PX / 2}
|
|
offsetY={HANDLE_PX / 2}
|
|
width={HANDLE_PX}
|
|
height={HANDLE_PX}
|
|
fill="#fff"
|
|
stroke="#1c7ed6"
|
|
strokeWidth={2}
|
|
cornerRadius={2}
|
|
hitStrokeWidth={12}
|
|
draggable
|
|
// The handle is pinned in place; the pointer drives the new size, React moves the handle.
|
|
dragBoundFunc={function (this: Konva.Node) {
|
|
return this.absolutePosition();
|
|
}}
|
|
onDragStart={(e) => {
|
|
e.cancelBubble = true;
|
|
resizeStart.current = { x: item.x, y: item.y, width: item.width, height: item.height };
|
|
}}
|
|
onDragMove={(e) => {
|
|
const p = e.target.getStage()?.getPointerPosition();
|
|
const start = resizeStart.current;
|
|
if (!p || !start) return;
|
|
const next = resizeLength(start, edge, (p.x - O) / 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
|
|
setResize(null);
|
|
resizeStart.current = null;
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = "ew-resize";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const c = e.target.getStage()?.container();
|
|
if (c) c.style.cursor = "";
|
|
}}
|
|
/>
|
|
));
|
|
})}
|
|
{!interactive &&
|
|
room.doors.map((d) => {
|
|
const live = wallDrag?.kind === "door" && wallDrag.id === d.id ? wallDrag.offset : d.offset;
|
|
return wallHit("door", d.id, d.wall, live, d.width, doorSwingRect(room, { ...d, offset: live }));
|
|
})}
|
|
{!interactive &&
|
|
room.screens.map((sc) => {
|
|
const live = wallDrag?.kind === "screen" && wallDrag.id === sc.id ? wallDrag.offset : sc.offset;
|
|
return wallHit("screen", sc.id, sc.wall, live, sc.width, wallRect(room, sc.wall, live, sc.width, SCREEN_DEPTH_M));
|
|
})}
|
|
{preview && (
|
|
<Group x={preview.x * pxPerM} y={preview.y * pxPerM} listening={false}>
|
|
<FurnitureShape
|
|
shape={preview.shape}
|
|
width={preview.width}
|
|
height={preview.height}
|
|
pxPerM={pxPerM}
|
|
outline={preview.valid ? "valid" : "invalid"}
|
|
opacity={0.85}
|
|
/>
|
|
</Group>
|
|
)}
|
|
</Layer>
|
|
</Stage>
|
|
</div>
|
|
);
|
|
}
|