wip
This commit is contained in:
commit
d8f2407ddf
1 changed files with 10377 additions and 0 deletions
365
src/components/FloorPlan.tsx
Normal file
365
src/components/FloorPlan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue