Phase 3: room features (modes, room size, doors, wall screens, obstacles, import/export)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { Mode } from "./Room";
|
||||
|
||||
export function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||
const seg = (m: Mode): React.CSSProperties => ({
|
||||
flex: 1,
|
||||
height: 30,
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
font: "600 13px system-ui, sans-serif",
|
||||
background: mode === m ? "#fff" : "transparent",
|
||||
color: mode === m ? "#222" : "#666",
|
||||
boxShadow: mode === m ? "0 1px 3px rgba(0,0,0,0.15)" : "none",
|
||||
});
|
||||
return (
|
||||
<div style={{ gridColumn: "1 / -1", display: "flex", gap: 2, padding: 3, background: "#d9d9d9", borderRadius: 9 }}>
|
||||
<button style={seg("layout")} onClick={() => onChange("layout")}>
|
||||
Layout
|
||||
</button>
|
||||
<button style={seg("room")} onClick={() => onChange("room")}>
|
||||
Room
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
onStartDrag: (type: FurnitureType, clientX: number, clientY: number) => void;
|
||||
onOpenMenu: (type: FurnitureType) => void;
|
||||
onNewCustom: () => void;
|
||||
header?: ReactNode; // always shown first (mode toggle)
|
||||
panel: ReactNode; // when set, replaces the tiles (type menu, item panel, custom form)
|
||||
}
|
||||
|
||||
@@ -119,7 +120,7 @@ function NewCustomTile({ onClick }: { onClick: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, panel }: Props) {
|
||||
export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom, header, panel }: Props) {
|
||||
const [width, setWidth] = useState(264);
|
||||
|
||||
const startResize = (e: PointerEvent) => {
|
||||
@@ -158,6 +159,7 @@ export function Palette({ types, remaining, onStartDrag, onOpenMenu, onNewCustom
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{panel ?? (
|
||||
<>
|
||||
{types.map((cfg) => (
|
||||
|
||||
+287
-19
@@ -1,8 +1,19 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { Stage, Layer, Group, Rect, Path } from "react-konva";
|
||||
import { Stage, Layer, Group, Rect, Path, Arc, Line, RegularPolygon } from "react-konva";
|
||||
import type Konva from "konva";
|
||||
import { boundsOf, isValidPlacement, clampToRoom, resizeLength } from "../geometry";
|
||||
import type { FurnitureItem, FurnitureTypeConfig, Rect as RectM, Room as RoomT, Shape } from "../types";
|
||||
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 {
|
||||
@@ -14,7 +25,16 @@ export interface Preview {
|
||||
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>;
|
||||
@@ -38,7 +58,99 @@ interface ResizeInfo {
|
||||
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 }) {
|
||||
@@ -52,10 +164,14 @@ function LockIcon({ w, h, rotation }: { w: number; h: number; rotation: number }
|
||||
);
|
||||
}
|
||||
|
||||
export function Room({ room, items, configs, preview, pxPerM, floorRef, selectedId, onSelect, onMove, onResize }: Props) {
|
||||
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;
|
||||
|
||||
@@ -70,26 +186,167 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
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
|
||||
ref={floorRef}
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
border: "6px solid #2b2b2b",
|
||||
boxSizing: "content-box",
|
||||
width: fw,
|
||||
height: fh,
|
||||
background: "#fff",
|
||||
boxShadow: "0 4px 18px rgba(0,0,0,0.12)",
|
||||
touchAction: "none",
|
||||
}}
|
||||
ref={floorRef}
|
||||
>
|
||||
<Stage
|
||||
width={w}
|
||||
height={h}
|
||||
onMouseDown={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
onTouchStart={(e) => e.target === e.target.getStage() && onSelect(null)}
|
||||
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>
|
||||
<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;
|
||||
@@ -110,7 +367,8 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
offsetX={wpx / 2}
|
||||
offsetY={hpx / 2}
|
||||
rotation={item.rotation}
|
||||
draggable={!item.locked}
|
||||
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).
|
||||
@@ -118,8 +376,8 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
const hx = (half.width / 2) * pxPerM;
|
||||
const hy = (half.height / 2) * pxPerM;
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, hx), w - hx),
|
||||
y: Math.min(Math.max(pos.y, hy), h - hy),
|
||||
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) => {
|
||||
@@ -155,7 +413,7 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
{items.map((item) => {
|
||||
// Screens get length handles on their ends while selected, unlocked and unrotated.
|
||||
const isScreen = configs[item.type]?.shape === "screen";
|
||||
if (item.id !== selectedId || !isScreen || item.locked || item.rotation !== 0 || drag?.id === item.id) return null;
|
||||
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],
|
||||
@@ -188,7 +446,7 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
const p = e.target.getStage()?.getPointerPosition();
|
||||
const start = resizeStart.current;
|
||||
if (!p || !start) return;
|
||||
const next = resizeLength(start, edge, p.x / pxPerM, room);
|
||||
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={() => {
|
||||
@@ -207,6 +465,16 @@ export function Room({ room, items, configs, preview, pxPerM, floorRef, selected
|
||||
/>
|
||||
));
|
||||
})}
|
||||
{!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
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Door, Obstacle, Room, RoomScreen } from "../types";
|
||||
import { DOOR_WIDTH, MAX_SCREENS, OBSTACLE_SIZE, ROOM_SIZE, SCREEN_WIDTH } from "../geometry";
|
||||
import { Wheel } from "./Wheel";
|
||||
import { backBtn, btn, hint, label, panelRoot, title, twoCol } from "./panelStyles";
|
||||
|
||||
const fmt = (v: number) => v.toFixed(1);
|
||||
|
||||
function useEscape(onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", key);
|
||||
return () => window.removeEventListener("keydown", key);
|
||||
}, [onClose]);
|
||||
}
|
||||
|
||||
const dangerBtn: React.CSSProperties = { ...btn, color: "#e03131", borderColor: "#e8a8a8" };
|
||||
|
||||
// ---------------- Room settings (Room mode, nothing selected) ----------------
|
||||
|
||||
interface RoomPanelProps {
|
||||
room: Room;
|
||||
minSize: { width: number; height: number };
|
||||
notice: string;
|
||||
onResize: (size: { width?: number; height?: number }) => void;
|
||||
onAddDoor: () => void;
|
||||
onAddScreen: () => void;
|
||||
onAddObstacle: () => void;
|
||||
onExport: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function RoomPanel({ room, minSize, notice, onResize, onAddDoor, onAddScreen, onAddObstacle, onExport, onImport }: RoomPanelProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const limited = minSize.width > ROOM_SIZE.min || minSize.height > ROOM_SIZE.min;
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<div style={title}>Room</div>
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={room.width} min={minSize.width} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={room.height} min={minSize.height} max={ROOM_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onResize({ height: v })} />
|
||||
</div>
|
||||
</div>
|
||||
{limited && (
|
||||
<div style={hint}>
|
||||
The room can't shrink past what's placed in it (min {fmt(minSize.width)} × {fmt(minSize.height)} m).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<button style={btn} onClick={onAddDoor}>
|
||||
Add door
|
||||
</button>
|
||||
<button style={{ ...btn, opacity: room.screens.length >= MAX_SCREENS ? 0.45 : 1 }} disabled={room.screens.length >= MAX_SCREENS} onClick={onAddScreen}>
|
||||
Add screen
|
||||
</button>
|
||||
{room.screens.length >= MAX_SCREENS && <div style={{ ...hint, marginTop: -4 }}>Maximum of {MAX_SCREENS} screens.</div>}
|
||||
<button style={btn} onClick={onAddObstacle}>
|
||||
Add obstacle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={onExport}>
|
||||
Export room
|
||||
</button>
|
||||
<button style={{ ...btn, flex: 1 }} onClick={() => fileRef.current?.click()}>
|
||||
Import room
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = ""; // allow re-importing the same file
|
||||
if (f) onImport(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={hint}>Furniture isn't included in room.json.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Door ----------------
|
||||
|
||||
interface DoorPanelProps {
|
||||
door: Door;
|
||||
isLast: boolean;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<Door>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DoorPanel({ door, isLast, notice, onPatch, onDelete, onClose }: DoorPanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Door</div>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={door.width} min={DOOR_WIDTH.min} max={DOOR_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
style={{ ...btn, width: "100%", background: door.isLoading ? "#2b2b2b" : "#fff", color: door.isLoading ? "#fff" : "#222" }}
|
||||
onClick={() => onPatch({ isLoading: !door.isLoading })}
|
||||
>
|
||||
Loading door: {door.isLoading ? "on" : "off"}
|
||||
</button>
|
||||
<div style={hint}>Furniture is carried through a loading door.</div>
|
||||
</div>
|
||||
<div style={hint}>The swing area and 0.6 m inside the opening stay clear of furniture.</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<div>
|
||||
<button style={{ ...dangerBtn, width: "100%", opacity: isLast ? 0.45 : 1, cursor: isLast ? "not-allowed" : "pointer" }} disabled={isLast} onClick={onDelete}>
|
||||
Delete door
|
||||
</button>
|
||||
{isLast && <div style={hint}>A room needs at least one door.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Wall screen ----------------
|
||||
|
||||
interface ScreenPanelProps {
|
||||
screen: RoomScreen;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<RoomScreen>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ScreenPanel({ screen, notice, onPatch, onDelete, onClose }: ScreenPanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Screen</div>
|
||||
<div>
|
||||
<div style={label}>Width (m)</div>
|
||||
<Wheel value={screen.width} min={SCREEN_WIDTH.min} max={SCREEN_WIDTH.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div style={hint}>Drag it along its wall. A 0.3 m strip in front of the wall stays clear.</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<button style={dangerBtn} onClick={onDelete}>
|
||||
Delete screen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Obstacle ----------------
|
||||
|
||||
interface ObstaclePanelProps {
|
||||
obstacle: Obstacle;
|
||||
notice: string;
|
||||
onPatch: (p: Partial<Obstacle>) => void;
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ObstaclePanel({ obstacle, notice, onPatch, onDelete, onClose }: ObstaclePanelProps) {
|
||||
useEscape(onClose);
|
||||
return (
|
||||
<div style={panelRoot}>
|
||||
<button onClick={onClose} style={backBtn}>
|
||||
← Done
|
||||
</button>
|
||||
<div style={title}>Obstacle</div>
|
||||
<div>
|
||||
<div style={label}>Size (m)</div>
|
||||
<div style={twoCol}>
|
||||
<div>
|
||||
<div style={hint}>Width</div>
|
||||
<Wheel value={obstacle.width} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ width: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={hint}>Length</div>
|
||||
<Wheel value={obstacle.height} min={OBSTACLE_SIZE.min} max={OBSTACLE_SIZE.max} step={0.1} rows={3} format={fmt} onChange={(v) => onPatch({ height: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{notice && <div style={{ ...hint, color: "#e03131", marginTop: 0 }}>{notice}</div>}
|
||||
<button style={dangerBtn} onClick={onDelete}>
|
||||
Delete obstacle
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user