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:
2026-10-17 12:53:40 -04:00
parent d98ce8ddb7
commit 7ec02ed9ed
10 changed files with 1050 additions and 39 deletions
+25
View File
@@ -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>
);
}
+3 -1
View File
@@ -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
View File
@@ -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
+209
View File
@@ -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>
);
}