234 lines
7.0 KiB
TypeScript
234 lines
7.0 KiB
TypeScript
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;
|
|
}
|
|
|
|
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.fillStyle = '#12151b';
|
|
ctx.fillRect(0, 0, state.room.width, state.room.height);
|
|
|
|
// subtle grid
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.035)';
|
|
ctx.lineWidth = 1;
|
|
const step = 25;
|
|
for (let x = 0; x <= state.room.width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, state.room.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= state.room.height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(state.room.width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.strokeStyle = '#2c3342';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(0, 0, state.room.width, state.room.height);
|
|
|
|
if (state.entrance) {
|
|
const e = state.entrance;
|
|
const w = e.width ?? 20;
|
|
const h = e.height ?? 20;
|
|
const selected = selectedId === '__entrance__';
|
|
ctx.fillStyle = '#3fbf7f';
|
|
ctx.fillRect(e.x, e.y, w, h);
|
|
ctx.strokeStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.4)';
|
|
ctx.lineWidth = selected ? 2.5 : 1;
|
|
ctx.strokeRect(e.x, e.y, w, h);
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
|
|
ctx.font = '600 10px Inter, sans-serif';
|
|
ctx.textBaseline = 'middle';
|
|
if (w > 50) ctx.fillText('ENTRANCE', e.x + 6, e.y + h / 2);
|
|
// resize handle (SE)
|
|
ctx.fillStyle = selected ? '#4f8cff' : 'rgba(0, 0, 0, 0.5)';
|
|
ctx.fillRect(e.x + w - 4, e.y + h - 4, 8, 8);
|
|
}
|
|
|
|
for (const f of state.furniture) {
|
|
const b = { x: f.shape.x, y: f.shape.y, w: f.shape.width, h: f.shape.height };
|
|
const r = Math.min(8, b.w / 4, b.h / 4);
|
|
const selected = selectedId === f.id;
|
|
|
|
ctx.globalAlpha = f.lockedForOptimization && mode === 'optimize' ? 0.55 : 1;
|
|
|
|
ctx.beginPath();
|
|
roundRect(ctx, b.x, b.y, b.w, b.h, r);
|
|
ctx.fillStyle = f.color || '#9aa0a6';
|
|
ctx.fill();
|
|
|
|
if (selected) {
|
|
ctx.shadowColor = '#4f8cff';
|
|
ctx.shadowBlur = 14;
|
|
ctx.strokeStyle = '#4f8cff';
|
|
ctx.lineWidth = 2.5;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
} else {
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
|
|
ctx.font = '600 11px Inter, sans-serif';
|
|
ctx.textBaseline = 'middle';
|
|
const label = b.w > 44 ? f.name.substring(0, 14) : '';
|
|
if (label) ctx.fillText(label, b.x + 7, b.y + b.h / 2);
|
|
|
|
if (f.lockedForOptimization) {
|
|
ctx.font = '10px Inter, sans-serif';
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
ctx.fillText('🔒', b.x + b.w - 15, b.y + 9);
|
|
}
|
|
|
|
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 (state.entrance) {
|
|
const en = state.entrance;
|
|
const w = en.width ?? 20;
|
|
const h = en.height ?? 20;
|
|
const handleX = en.x + w - 4;
|
|
const handleY = en.y + h - 4;
|
|
if (Math.abs(pos.x - handleX) <= 6 && Math.abs(pos.y - handleY) <= 6) {
|
|
onSelect('__entrance__');
|
|
setDrag({ id: '__entrance__', offsetX: 0, offsetY: 0, resizing: true });
|
|
return;
|
|
}
|
|
if (pointInRect(pos, { x: en.x, y: en.y, w, h })) {
|
|
onSelect('__entrance__');
|
|
setDrag({ id: '__entrance__', offsetX: pos.x - en.x, offsetY: pos.y - en.y });
|
|
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);
|
|
if (drag.id === '__entrance__' && state.entrance) {
|
|
const en = state.entrance;
|
|
if (drag.resizing) {
|
|
const newW = Math.max(10, pos.x - en.x);
|
|
const newH = Math.max(10, pos.y - en.y);
|
|
onStateChange({ ...state, entrance: { ...en, width: newW, height: newH } });
|
|
} else {
|
|
onStateChange({
|
|
...state,
|
|
entrance: { ...en, x: pos.x - drag.offsetX, y: pos.y - drag.offsetY },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const updated = state.furniture.map((f) => {
|
|
if (f.id !== drag.id) return f;
|
|
return {
|
|
...f,
|
|
shape: {
|
|
...f.shape,
|
|
x: pos.x - drag.offsetX,
|
|
y: pos.y - drag.offsetY,
|
|
},
|
|
};
|
|
});
|
|
onStateChange({ ...state, furniture: updated });
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (drag && drag.id !== '__entrance__') {
|
|
const updated = state.furniture.map((f) => (f.id === drag.id ? { ...f, placed: true } : f));
|
|
onStateChange({ ...state, furniture: updated });
|
|
}
|
|
setDrag(null);
|
|
};
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={Math.max(state.room.width, 400)}
|
|
height={Math.max(state.room.height, 300)}
|
|
style={{ 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;
|
|
}
|
|
|
|
function roundRect(
|
|
ctx: CanvasRenderingContext2D,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
r: number,
|
|
): void {
|
|
ctx.moveTo(x + r, y);
|
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
ctx.arcTo(x, y + h, x, y, r);
|
|
ctx.arcTo(x, y, x + w, y, r);
|
|
ctx.closePath();
|
|
}
|