Files
furniture/src/components/RoomCanvas.tsx
T

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();
}