floor-planner/src/components/FloorPlan.tsx
2026-09-11 22:14:24 +01:00

535 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useId } from 'react';
import type {
FlooringPlan,
Geometry,
PlanMaterial,
Point,
Room,
SkirtingPlan,
WallEdge,
} from '../lib/types';
import { area, cutMm, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import { floorPieceLabels } from '../lib/piece-labels';
import { PRECISION } from '../lib/limits';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: DimensionLabel }) {
const { normal: n, offset } = label;
const dx = edge.end.x - edge.start.x,
dy = edge.end.y - edge.start.y;
const length = Math.hypot(dx, dy) || 1;
return (
<g className="wall-drawing">
<line
x1={edge.start.x}
y1={edge.start.y}
x2={edge.end.x}
y2={edge.end.y}
stroke="#27364a"
strokeWidth={span * 0.003}
/>
<line
x1={edge.start.x + n.x * offset}
y1={edge.start.y + n.y * offset}
x2={edge.end.x + n.x * offset}
y2={edge.end.y + n.y * offset}
stroke="#8591a3"
strokeWidth={span * 0.001}
/>
{[edge.start, edge.end].map((p, i) => (
<line
key={i}
x1={p.x + n.x * (offset - span * 0.01)}
y1={p.y + n.y * (offset - span * 0.01)}
x2={p.x + n.x * (offset + span * 0.01)}
y2={p.y + n.y * (offset + span * 0.01)}
stroke="#8591a3"
strokeWidth={span * 0.001}
/>
))}
<g
className="wall-label"
transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}
>
<rect
x={-label.width / 2}
y={-label.font * 0.75}
width={label.width}
height={label.font * 1.5}
fill="#fffdf7"
/>
<text textAnchor="middle" dominantBaseline="middle" fontSize={label.font} fill="#17263f">
{label.text}
</text>
</g>
{edge.doors
.filter(
(d) =>
d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + PRECISION.clipping,
)
.map((door) => {
const a = {
x: edge.start.x + (dx / length) * door.offset,
y: edge.start.y + (dy / length) * door.offset,
};
const b = { x: a.x + (dx / length) * door.width, y: a.y + (dy / length) * door.width };
const swing = { x: a.x - n.x * door.width, y: a.y - n.y * door.width };
const clockwise = dx * -n.y - dy * -n.x > 0;
return (
<g key={door.id}>
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="#fffdf7"
strokeWidth={span * 0.012}
/>
<line
x1={a.x}
y1={a.y}
x2={swing.x}
y2={swing.y}
stroke="#53647c"
strokeWidth={span * 0.002}
/>
<path
d={`M ${b.x} ${b.y} A ${door.width} ${door.width} 0 0 ${clockwise ? 1 : 0} ${swing.x} ${swing.y}`}
fill="none"
stroke="#53647c"
strokeWidth={span * 0.0015}
strokeDasharray={`${span * 0.01} ${span * 0.008}`}
/>
<title>
Door: {mm(door.width)} mm, {mm(door.offset)} mm from W{edge.index + 1} start
</title>
</g>
);
})}
</g>
);
}
interface Props {
room: Room;
geometry: Geometry;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select?: (id: string) => void;
interactive?: boolean;
completedBoards?: string[];
highlightedPieces?: string[];
searchActive?: boolean;
material?: PlanMaterial;
}
export function FloorPlan({
room,
geometry,
flooring,
skirting,
selectedPiece,
select,
interactive = true,
completedBoards = [],
highlightedPieces = [],
searchActive = false,
material,
}: Props) {
const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
const minX = Math.min(0, ...allPoints.map((p) => p.x)),
maxX = Math.max(100, ...allPoints.map((p) => p.x));
const minY = Math.min(0, ...allPoints.map((p) => p.y)),
maxY = Math.max(100, ...allPoints.map((p) => p.y));
const span = Math.max(maxX - minX, maxY - minY, 1000),
font = span * 0.024;
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
// Printing includes both materials; the interactive drawing shows the chosen view.
const floorPieces =
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces =
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
const reserved: LabelBox[] = [];
for (const piece of skirtPieces) {
if (!isSkirtingCut(piece, skirting)) continue;
const center = midpoint(piece.start, piece.end);
const length = Math.hypot(piece.end.x - piece.start.x, piece.end.y - piece.start.y) || 1;
const text = piece.id + (completedBoards.includes(`skirting:${piece.stockId}`) ? ' ✓' : '');
const width = Math.max(font * 1.8, text.length * font * 0.8 * 0.64 + font * 0.55);
let box!: LabelBox;
for (let lane = 0; lane < 24; lane++) {
const offset = span * (0.032 + lane * 0.035);
const x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * offset;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * offset;
box = {
minX: x - width / 2,
maxX: x + width / 2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
};
skirtLabels.set(piece.id, { x, y, width, text });
if (
!reserved.some(
(other) =>
box.minX < other.maxX &&
box.maxX > other.minX &&
box.minY < other.maxY &&
box.maxY > other.minY,
)
)
break;
}
reserved.push(box);
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const floorLabels = floorPieceLabels(
floorPieces,
geometry.vertices,
room.direction,
font,
[...reserved, ...labels.map((label) => label.box)],
completedBoards,
);
const boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
...floorLabels.map((label) => label.box),
];
const left = Math.min(minX, ...boxes.map((b) => b.minX)) - span * 0.035;
const top = Math.min(minY, ...boxes.map((b) => b.minY)) - span * 0.035;
const width = Math.max(maxX, ...boxes.map((b) => b.maxX)) + span * 0.035 - left;
const height = Math.max(maxY, ...boxes.map((b) => b.maxY)) + span * 0.035 - top;
return (
<section
className="drawing-card"
aria-label="Floor plan preview"
data-search-active={searchActive}
data-view={material ?? 'both'}
>
<div className="drawing-header">
<h2>
{room.name} <small>{area(geometry.area)} m²</small>
</h2>
<div className="drawing-legend">
{material !== 'skirting' && (
<span>
<i className="floor-key" />
Flooring cuts
</span>
)}
{material !== 'flooring' && (
<span>
<i className="skirt-key" />
Skirting cuts
</span>
)}
<span>
<i className="uncut-key" />
Uncut
</span>
</div>
</div>
<div className="drawing-viewport">
<svg
className="room-svg"
role="img"
aria-label={`${room.name} floor plan with ${geometry.edges.length} walls, ${floorPieces.length} floorboards and ${skirtPieces.length} skirting pieces`}
viewBox={`${left} ${top} ${width} ${height}`}
width={width}
height={height}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<pattern
id={hatch}
width={span * 0.02}
height={span * 0.02}
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line
x1="0"
y1="0"
x2="0"
y2={span * 0.02}
stroke="#1647ab"
strokeOpacity=".16"
strokeWidth={span * 0.003}
/>
</pattern>
</defs>
{geometry.closed && <polygon points={points(geometry.vertices)} fill="#fff" />}
{floorPieces.map((piece) => {
const cut = piece.cut || piece.reused;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
const x =
(Math.min(...piece.polygon.map((p) => p.x)) +
Math.max(...piece.polygon.map((p) => p.x))) /
2;
const y =
(Math.min(...piece.polygon.map((p) => p.y)) +
Math.max(...piece.polygon.map((p) => p.y))) /
2;
return (
<g
key={piece.id}
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-material="flooring"
data-board-key={`floor:${piece.stockId}`}
data-done={done}
data-highlighted={highlighted}
data-piece-id={piece.id}
role={cut && interactive ? 'button' : undefined}
tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select flooring cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
}
}
: undefined
}
>
<polygon
points={points(piece.polygon)}
fill={
done
? '#dbe9e7'
: cut
? selectedPiece === piece.id
? '#ceddf9'
: `url(#${hatch})`
: '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
/>
{done && !cut && (
<text
className="completion-mark"
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={font}
fill="#20594f"
>
✓
</text>
)}
{cut && (
<>
<title>
{piece.id}: {cutMm(piece.length, piece.blankLength)} ×{' '}
{cutMm(piece.width, piece.blankWidth)} mm
</title>
</>
)}
</g>
);
})}
{geometry.edges.map((edge, i) => (
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
// Each template uses its true wall-facing and room-facing edges, including mitres.
const polygon = skirtingPolygon(piece, orientation);
return (
<g
key={piece.id}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'} ${done ? 'is-done' : ''} ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-material="skirting"
data-board-key={`skirting:${piece.stockId}`}
data-done={done}
data-highlighted={highlighted}
data-piece-id={piece.id}
role={cut && interactive ? 'button' : undefined}
tabIndex={cut && interactive ? 0 : undefined}
aria-hidden={cut ? undefined : true}
aria-label={cut ? `Select skirting cut ${piece.id}` : undefined}
aria-pressed={cut ? selectedPiece === piece.id : undefined}
onClick={cut && interactive ? () => select?.(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select?.(piece.id);
}
}
: undefined
}
>
<polygon
points={points(polygon)}
fill={
done
? '#27655d'
: cut
? selectedPiece === piece.id
? '#17263f'
: '#b74900'
: '#d8d5cd'
}
stroke={cut ? '#8d3800' : '#79756b'}
strokeWidth={span * 0.001}
/>
{cut && (
<line
x1={piece.start.x}
y1={piece.start.y}
x2={piece.end.x}
y2={piece.end.y}
stroke="transparent"
strokeWidth={span * 0.025}
/>
)}
{done && !cut && (
<text
className="completion-mark"
x={(piece.start.x + piece.end.x) / 2}
y={(piece.start.y + piece.end.y) / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize={font}
fill="#20594f"
>
✓
</text>
)}
{cut && (
<title>
{piece.id}: {cutMm(piece.length, piece.blankLength)} mm · {mm(piece.leftMitre)}°
/ {mm(piece.rightMitre)}°
</title>
)}
</g>
);
})}
{!geometry.closed && geometry.edges.length > 1 && (
<line
x1={geometry.edges.at(-1)!.end.x}
y1={geometry.edges.at(-1)!.end.y}
x2="0"
y2="0"
stroke="#b74900"
strokeWidth={span * 0.003}
strokeDasharray={`${span * 0.015} ${span * 0.008}`}
/>
)}
<g className="skirting-labels" aria-hidden="true">
{skirtPieces.map((piece) => {
const label = skirtLabels.get(piece.id);
if (!label) return null;
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
return (
<g
key={piece.id}
className={`skirting-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={piece.id}
transform={`translate(${label.x} ${label.y})`}
onClick={interactive ? () => select?.(piece.id) : undefined}
>
<rect
x={-label.width / 2}
y={-font * 0.58}
width={label.width}
height={font * 1.16}
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.8}
fontWeight="700"
>
{label.text}
</text>
</g>
);
})}
</g>
<g className="floor-labels" aria-hidden="true">
{floorLabels.map((label) => {
const piece = floorPieces.find((piece) => piece.id === label.id)!;
const done = completedBoards.includes(`floor:${piece.stockId}`);
const highlighted = highlightedPieces.includes(label.id);
return (
<g
key={label.id}
className={`floor-code ${highlighted ? 'is-highlighted' : searchActive ? 'search-muted' : ''}`}
data-piece-id={label.id}
onClick={interactive ? () => select?.(label.id) : undefined}
>
{label.leader && (
<>
<line
className="piece-label-leader"
x1={label.anchor.x}
y1={label.anchor.y}
x2={label.x}
y2={label.y}
stroke="#1647ab"
strokeWidth={span * 0.0015}
pointerEvents="none"
/>
<circle
cx={label.anchor.x}
cy={label.anchor.y}
r={font * 0.09}
fill="#1647ab"
pointerEvents="none"
/>
</>
)}
<g transform={`translate(${label.x} ${label.y}) rotate(${label.angle})`}>
<rect
x={-label.width / 2}
y={-label.height / 2}
width={label.width}
height={label.height}
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={label.font}
fontWeight="600"
>
{label.text}
</text>
</g>
</g>
);
})}
</g>
</svg>
</div>
</section>
);
}