This commit is contained in:
Andras Schmelczer 2026-09-11 16:09:28 +01:00
commit d8f2407ddf

View file

@ -0,0 +1,365 @@
import { useId } from 'react';
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
import { area, mm } from '../lib/format';
import { signedArea } from '../lib/geometry';
import { isSkirtingCut } from '../lib/cut-list';
import { skirtingPolygon } from '../lib/skirting-polygon';
import { dimensionLabels } from '../lib/dimension-labels';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
import { ClosedBadge } from './Fields';
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 + 0.001)
.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;
}
export function FloorPlan({ room, geometry, flooring, skirting, selectedPiece, select }: 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;
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
const skirtLabels = new Map<string, Point>();
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 x = center.x + ((orientation * (piece.end.y - piece.start.y)) / length) * span * 0.032;
const y = center.y - ((orientation * (piece.end.x - piece.start.x)) / length) * span * 0.032;
skirtLabels.set(piece.id, { x, y });
reserved.push({
minX: x - font * 1.2,
maxX: x + font * 1.2,
minY: y - font * 0.7,
maxY: y + font * 0.7,
});
}
const labels = dimensionLabels(geometry.edges, span, orientation, reserved);
const boxes = [...reserved, ...labels.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">
<div className="drawing-header">
<h2>
Floor plan <small>{area(geometry.area)} m²</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
</div>
<div className="drawing-legend">
<span>
<i className="floor-key" />
Flooring
</span>
<span>
<i className="skirt-key" />
Skirting
</span>
</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 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'}`}
data-material="flooring"
data-piece-id={piece.id}
role={cut ? 'button' : undefined}
tabIndex={cut ? 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 ? () => 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={
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
}
stroke={cut ? '#1647ab' : '#9baabd'}
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
/>
{cut && (
<>
<g
transform={`translate(${x} ${y})${room.direction === 90 ? ' rotate(90)' : ''}`}
pointerEvents="none"
>
<rect
x={-font * 1.1}
y={-font * 0.55}
width={font * 2.2}
height={font * 1.1}
fill="#1647ab"
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#fff"
fontSize={font * 0.76}
fontWeight="600"
>
{piece.id}
</text>
</g>
<title>
{piece.id}: {mm(piece.length)} × {mm(piece.width)} 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 label = skirtLabels.get(piece.id);
// Each template uses its true wall-facing and room-facing edges, including mitres.
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
const polygon = skirtingPolygon(piece, thickness, orientation);
return (
<g
key={piece.id}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
data-material="skirting"
data-piece-id={piece.id}
role={cut ? 'button' : undefined}
tabIndex={cut ? 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 ? () => select(piece.id) : undefined}
onKeyDown={
cut
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
select(piece.id);
}
}
: undefined
}
>
<polygon
points={points(polygon)}
fill={selectedPiece === piece.id ? '#17263f' : '#b74900'}
stroke="#8d3800"
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}
/>
)}
{label && (
<g transform={`translate(${label.x} ${label.y})`} pointerEvents="none">
<rect
x={-font * 1.1}
y={-font * 0.58}
width={font * 2.2}
height={font * 1.16}
fill="#fffdf7"
stroke="#b74900"
strokeWidth={span * 0.0012}
/>
<text
textAnchor="middle"
dominantBaseline="middle"
fill="#8d3800"
fontSize={font * 0.8}
fontWeight="700"
>
{piece.id}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {mm(piece.length)} 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}`}
/>
)}
</svg>
</div>
</section>
);
}