Add venue planner: OOP furniture, shapes, conditions, room/birds-eye UI, layout+optimize modes, pathfinding, random search + NN stub
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import type { ConditionKind, Entrance, FurnitureConfig, LayoutState, RoomConfig } from '../types';
|
||||
|
||||
interface ControlPanelProps {
|
||||
state: LayoutState;
|
||||
onStateChange: (state: LayoutState) => void;
|
||||
mode: 'layout' | 'optimize';
|
||||
onModeChange: (mode: 'layout' | 'optimize') => void;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onOptimize: () => void;
|
||||
optimizing: boolean;
|
||||
timeLeft: number;
|
||||
}
|
||||
|
||||
export function ControlPanel({
|
||||
state,
|
||||
onStateChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onOptimize,
|
||||
optimizing,
|
||||
timeLeft,
|
||||
}: ControlPanelProps) {
|
||||
const addFurniture = () => {
|
||||
const id = 'f_' + Date.now();
|
||||
const newItem: FurnitureConfig = {
|
||||
id,
|
||||
name: 'Table ' + state.furniture.length,
|
||||
shape: { type: 'rectangle', x: 50, y: 50, width: 60, height: 40 },
|
||||
conditions: [
|
||||
{ kind: 'walk_to_and_sit', weight: 1 },
|
||||
{ kind: 'accessible', weight: 1 },
|
||||
],
|
||||
color: '#a1887f',
|
||||
lockedForOptimization: false,
|
||||
placed: true,
|
||||
};
|
||||
onStateChange({ ...state, furniture: [...state.furniture, newItem] });
|
||||
onSelect(id);
|
||||
};
|
||||
|
||||
const setRoom = (room: Partial<RoomConfig>) => {
|
||||
onStateChange({ ...state, room: { ...state.room, ...room } });
|
||||
};
|
||||
|
||||
const setEntrance = (entrance: Partial<Entrance>) => {
|
||||
if (!state.entrance) return;
|
||||
onStateChange({ ...state, entrance: { ...state.entrance, ...entrance } });
|
||||
};
|
||||
|
||||
const placeEntrance = () => {
|
||||
onStateChange({ ...state, entrance: { x: 20, y: 20, width: 8, height: 8 } });
|
||||
};
|
||||
|
||||
const toggleLock = () => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) =>
|
||||
f.id === selectedId ? { ...f, lockedForOptimization: !f.lockedForOptimization } : f,
|
||||
);
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const updateSelectedCondition = (kind: ConditionKind, enabled: boolean) => {
|
||||
if (!selectedId) return;
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== selectedId) return f;
|
||||
const has = f.conditions.some((c) => c.kind === kind);
|
||||
if (enabled && !has) {
|
||||
return { ...f, conditions: [...f.conditions, { kind, weight: 1 }] };
|
||||
}
|
||||
if (!enabled && has) {
|
||||
return { ...f, conditions: f.conditions.filter((c) => c.kind !== kind) };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const selected = state.furniture.find((f) => f.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: 12, borderRight: '1px solid #ccc', minWidth: 260 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => onModeChange('layout')} disabled={mode === 'layout'}>
|
||||
Layout
|
||||
</button>
|
||||
<button onClick={() => onModeChange('optimize')} disabled={mode === 'optimize'} style={{ marginLeft: 8 }}>
|
||||
Optimize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'layout' && (
|
||||
<div>
|
||||
<h3>Room</h3>
|
||||
<label>
|
||||
Width:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.width}
|
||||
onChange={(e) => setRoom({ width: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 8 }}>
|
||||
Height:
|
||||
<input
|
||||
type="number"
|
||||
value={state.room.height}
|
||||
onChange={(e) => setRoom({ height: Number(e.target.value) })}
|
||||
style={{ width: 80, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={addFurniture}>Add Furniture</button>
|
||||
<button onClick={placeEntrance} style={{ marginLeft: 8 }}>
|
||||
Place Entrance
|
||||
</button>
|
||||
</div>
|
||||
{state.entrance && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<label>
|
||||
Ex:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.x}
|
||||
onChange={(e) => setEntrance({ x: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ marginLeft: 4 }}>
|
||||
Ey:
|
||||
<input
|
||||
type="number"
|
||||
value={state.entrance.y}
|
||||
onChange={(e) => setEntrance({ y: Number(e.target.value) })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ marginTop: 12 }}>Objects</h3>
|
||||
<div>
|
||||
{state.furniture.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
onClick={() => onSelect(f.id)}
|
||||
style={{
|
||||
padding: 4,
|
||||
background: selectedId === f.id ? '#eee' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{f.name} {f.lockedForOptimization ? '(locked)' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selected && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<label>
|
||||
Name:
|
||||
<input
|
||||
value={selected.name}
|
||||
onChange={(e) => {
|
||||
const updated = state.furniture.map((f) => (f.id === selectedId ? { ...f, name: e.target.value } : f));
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
}}
|
||||
style={{ width: 120, marginLeft: 4 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button onClick={toggleLock}>{selected.lockedForOptimization ? 'Unlock' : 'Lock'} for opt</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div>Conditions:</div>
|
||||
{(['walk_to_and_sit', 'around_open_area', 'near_entrance', 'accessible', 'not_blocking_path', 'open_circulation'] as ConditionKind[]).map(
|
||||
(k) => (
|
||||
<label key={k} style={{ display: 'block' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.conditions.some((c) => c.kind === k)}
|
||||
onChange={(e) => updateSelectedCondition(k, e.target.checked)}
|
||||
/>
|
||||
{k}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'optimize' && (
|
||||
<div>
|
||||
<h3>Optimize</h3>
|
||||
<p>Runs random search (15s) then NN-informed placement heuristic.</p>
|
||||
<button onClick={onOptimize} disabled={optimizing}>
|
||||
{optimizing ? `Optimizing... ${timeLeft}s` : 'Optimize'}
|
||||
</button>
|
||||
{optimizing && <div style={{ marginTop: 8 }}>Randomizing layouts, keeping best...</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { LayoutState, Point } from '../types';
|
||||
|
||||
interface RoomCanvasProps {
|
||||
state: LayoutState;
|
||||
onStateChange: (state: LayoutState) => void;
|
||||
mode: 'layout' | 'optimize';
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
resizing?: boolean;
|
||||
edge?: 'se' | 'e' | 's' | 'nw' | 'ne' | 'sw' | 'n' | 'w';
|
||||
}
|
||||
|
||||
const MIN_SIZE = 10;
|
||||
|
||||
export function RoomCanvas({ state, onStateChange, mode, selectedId, onSelect }: RoomCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [zoom] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [state, selectedId, mode, zoom]);
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.save();
|
||||
ctx.scale(zoom, zoom);
|
||||
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
||||
|
||||
if (state.entrance) {
|
||||
ctx.fillStyle = '#4caf50';
|
||||
ctx.fillRect(state.entrance.x - 4, state.entrance.y - 4, 8, 8);
|
||||
ctx.fillText('ENTRANCE', state.entrance.x + 6, state.entrance.y + 4);
|
||||
}
|
||||
|
||||
for (const f of state.furniture) {
|
||||
ctx.fillStyle = f.color || '#9aa0a6';
|
||||
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
||||
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.5 : 0.9;
|
||||
ctx.fillRect(b.x, b.y, b.w, b.h);
|
||||
ctx.strokeStyle = selectedId === f.id ? '#ff5722' : '#333';
|
||||
ctx.lineWidth = selectedId === f.id ? 3 : 1;
|
||||
ctx.strokeRect(b.x, b.y, b.w, b.h);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillText(f.name.substring(0, 12), b.x + 2, b.y + 12);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
const getMousePos = (e: React.MouseEvent<HTMLCanvasElement>): Point => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return { x: 0, y: 0 };
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
return { x: ((e.clientX - rect.left) * scaleX) / zoom, y: ((e.clientY - rect.top) * scaleY) / zoom };
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const pos = getMousePos(e);
|
||||
if (selectedId) {
|
||||
const f = state.furniture.find((x) => x.id === selectedId);
|
||||
if (f) {
|
||||
const seX = f.shape.x + f.shape.width;
|
||||
const seY = f.shape.y + f.shape.height;
|
||||
if (Math.abs(pos.x - seX) < 8 && Math.abs(pos.y - seY) < 8) {
|
||||
setDrag({ id: selectedId, offsetX: 0, offsetY: 0, resizing: true, edge: 'se' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = state.furniture.length - 1; i >= 0; i--) {
|
||||
const f = state.furniture[i];
|
||||
if (pointInRect(pos, { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height })) {
|
||||
onSelect(f.id);
|
||||
setDrag({ id: f.id, offsetX: pos.x - f.shape.x, offsetY: pos.y - f.shape.y });
|
||||
return;
|
||||
}
|
||||
}
|
||||
onSelect(null);
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drag) return;
|
||||
const pos = getMousePos(e);
|
||||
const updated = state.furniture.map((f) => {
|
||||
if (f.id !== drag.id) return f;
|
||||
if (drag.resizing && drag.edge === 'se') {
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
width: Math.max(MIN_SIZE, pos.x - f.shape.x),
|
||||
height: Math.max(MIN_SIZE, pos.y - f.shape.y),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
shape: {
|
||||
...f.shape,
|
||||
x: pos.x - drag.offsetX,
|
||||
y: pos.y - drag.offsetY,
|
||||
},
|
||||
};
|
||||
});
|
||||
onStateChange({ ...state, furniture: updated });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDrag(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={Math.max(state.room.width, 400)}
|
||||
height={Math.max(state.room.height, 300)}
|
||||
style={{ border: '1px solid #ccc', maxWidth: '100%', height: 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function pointInRect(p: Point, r: { x: number; y: number; w: number; h: number }): boolean {
|
||||
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
|
||||
}
|
||||
Reference in New Issue
Block a user