535 lines
19 KiB
TypeScript
535 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|