Add venue planner: OOP furniture, shapes, conditions, room/birds-eye UI, layout+optimize modes, pathfinding, random search + NN stub

This commit is contained in:
2026-10-17 19:41:09 -04:00
parent 767d9502d6
commit a708a0cfe5
14 changed files with 985 additions and 118 deletions
+147
View File
@@ -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;
}