This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:58 +01:00
commit 62a385e4c9
33 changed files with 2649 additions and 809 deletions

View file

@ -1,7 +1,6 @@
import { Download } from 'lucide-react';
import { MapPin, X } from 'lucide-react';
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
import { degree, mm } from '../lib/format';
import { downloadCsv } from '../lib/export';
import { cutMm, degree, mm } from '../lib/format';
import {
flooringCuts,
groupFlooringCuts,
@ -10,26 +9,99 @@ import {
uncutFlooring,
uncutSkirting,
} from '../lib/cut-list';
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
import { FlooringPreview, SkirtingPreview } from './CutPreview';
import { CutDialog } from './CutDialog';
function BoardHeader({
label,
size,
done,
toggle,
locate,
}: {
label: string;
size: string;
done: boolean;
toggle: () => void;
locate: () => void;
}) {
return (
<header className="board-group-header">
<h4>
{label.charAt(0).toUpperCase() + label.slice(1)}
<span>{size}</span>
</h4>
<div className="board-group-actions">
<label className="board-done-control">
<input
type="checkbox"
aria-label={`Mark ${label} done`}
checked={done}
onChange={toggle}
/>
<span>{done ? 'Done' : 'Mark done'}</span>
</label>
<button
className="button board-locate"
aria-label={`Show ${label} on plan`}
onClick={locate}
>
<MapPin size={15} aria-hidden="true" />
Show on plan
</button>
</div>
</header>
);
}
interface Props {
project: Project;
flooring: FlooringPlan;
skirting: SkirtingPlan;
selectedPiece: string | null;
select: (id: string | null, roomId?: string) => void;
completedBoards: string[];
toggleBoard: (key: string) => void;
search: string;
setSearch: (query: string) => void;
locateBoard: (key: string) => void;
locatePiece: (id: string, roomId: string) => void;
}
export function CutList({ project, flooring, skirting, selectedPiece, select }: Props) {
export function CutList({
project,
flooring,
skirting,
select,
completedBoards,
toggleBoard,
search,
setSearch,
locateBoard,
locatePiece,
}: Props) {
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const wholeFloor = uncutFlooring(flooring, project.flooring);
const wholeSkirt = uncutSkirting(skirting);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const errors = [...flooring.issues, ...skirting.issues].some(
(issue) => issue.severity === 'error',
const wholeFloor = uncutFlooring(flooring, project.flooring),
wholeSkirt = uncutSkirting(skirting);
const floorGroups = new Map(
groupFlooringCuts(flooring).map((group) => [group[0].stockId, group]),
);
const skirtGroups = new Map(
groupSkirtingCuts(skirting).map((group) => [group[0].stockId, group]),
);
const searching = search.trim().length > 0;
const matches = findBoardMatches(project, flooring, skirting, search);
const matchedBoards = new Set(matches.boardKeys),
matchedPieces = new Set(matches.pieceIds);
const completed = new Set(completedBoards);
// Keep every cut from a matching source together, even when only one piece or room matched.
const floorStocks = flooring.stocks.filter((stock) =>
searching ? matchedBoards.has(`floor:${stock.id}`) : floorGroups.has(stock.id),
);
const skirtStocks = skirting.stocks.filter((stock) =>
searching ? matchedBoards.has(`skirting:${stock.id}`) : skirtGroups.has(stock.id),
);
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
return (
<section className="cut-list-card" aria-labelledby="cut-plan-title">
<div className="cut-list-header">
@ -39,15 +111,39 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Each group comes from one board. Select a piece for its cutting diagram.
</p>
</div>
<button
className="button"
onClick={() => downloadCsv(project, flooring, skirting)}
disabled={errors || (!flooring.pieces.length && !skirting.pieces.length)}
>
<Download size={16} />
Export CSV
</button>
</div>
<div className="cut-search">
<label htmlFor="board-search">Find a cut or room</label>
<div className="cut-search-control">
<input
id="board-search"
type="search"
value={boardSearchLabel(project, flooring, skirting, search)}
maxLength={200}
onChange={(event) => setSearch(event.target.value)}
placeholder="F12, A2, or a room name"
autoComplete="off"
/>
{search.length > 0 && (
<button
className="button clear-search"
aria-label="Clear search"
onClick={() => setSearch('')}
>
<X size={16} aria-hidden="true" />
Clear
</button>
)}
</div>
{searching && (
<p className="search-results" role="status">
{matches.boardKeys.length
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
: 'No matching boards or cuts.'}
</p>
)}
</div>
<section className="material-cuts flooring-cuts" aria-labelledby="flooring-cuts-title">
<h3 id="flooring-cuts-title">
Flooring cuts <span className="count-badge">{floorCuts.length}</span>
@ -57,61 +153,98 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
{wholeFloor.readyOffcuts > 0 &&
` · ${wholeFloor.readyOffcuts} offcuts need no further cuts`}
</p>
{groupFlooringCuts(flooring).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Floorboard ${group[0].stockId}`}
>
<h4>
Floorboard {group[0].stockId}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} × {mm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut ? 'Cut from the remaining offcut' : 'Use the offcut as it is'}
</p>
)}
</div>
</li>
))}
</ul>
</section>
))}
{!floorCuts.length && (
{floorStocks.map((stock) => {
const key = `floor:${stock.id}`,
done = completed.has(key),
group = floorGroups.get(stock.id) ?? [],
label = group.length
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
: 'uncut floorboard';
return (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Flooring ${label}` : 'Uncut floorboard'}
>
<BoardHeader
label={label}
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button flooring-preview-button"
aria-label={`Preview flooring cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<FlooringPreview piece={piece} compact />
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} × {cutMm(piece.width)} mm
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Row {piece.row}
</p>
{piece.reused && (
<p className="cut-note">
{piece.cut
? 'Cut from the remaining offcut'
: 'Use the offcut as it is'}
</p>
)}
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{flooring.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!floorStocks.length && (
<p className="empty-state">
{flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
{searching
? 'No matching floorboards.'
: flooring.pieces.length
? 'No flooring cuts needed.'
: 'Complete the outline and flooring measurements to calculate cuts.'}
</p>
)}
</section>
<section className="material-cuts skirting-cuts" aria-labelledby="skirting-cuts-title">
<h3 id="skirting-cuts-title">
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
@ -120,69 +253,97 @@ export function CutList({ project, flooring, skirting, selectedPiece, select }:
Buy {skirting.stockCount} lengths ·{' '}
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
</p>
{groupSkirtingCuts(skirting).map((group) => (
<section
className="stock-cut-group"
data-stock-id={group[0].stockId}
key={group[0].stockId}
aria-label={`Skirting board ${group[0].stockId}`}
>
<h4>
Skirting board {group[0].stockId}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>
</h4>
<ul className="board-cuts">
{group.map((piece) => (
<li className="board-cut" key={piece.id} data-cut-id={piece.id}>
<button
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div>
<p className="cut-size">
{mm(piece.length)} mm <span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
</div>
</li>
))}
</ul>
</section>
))}
{!skirtCuts.length && (
{skirtStocks.map((stock) => {
const key = `skirting:${stock.id}`,
done = completed.has(key),
group = skirtGroups.get(stock.id) ?? [],
label = group.length
? `cuts ${group.map((piece) => piece.id).join(' + ')}`
: 'uncut skirting board';
return (
<section
className={`stock-cut-group ${done ? 'is-done' : ''}`}
data-stock-id={stock.id}
data-board-key={key}
key={key}
aria-label={group.length ? `Skirting ${label}` : 'Uncut skirting board'}
>
<BoardHeader
label={label}
size={`${mm(stock.length)} mm`}
done={done}
toggle={() => toggleBoard(key)}
locate={() => locateBoard(key)}
/>
{group.length ? (
<ul className="board-cuts">
{group.map((piece) => (
<li
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
key={piece.id}
data-cut-id={piece.id}
>
<button
className="cut-preview-button skirting-preview-button"
aria-label={`Preview skirting cut ${piece.id}`}
aria-haspopup="dialog"
onClick={() => select(piece.id, piece.roomId)}
>
<span className="cut-thumbnail" aria-hidden="true">
<SkirtingPreview
piece={piece}
thickness={project.skirting.thickness}
compact
/>
</span>
<span className="cut-preview-code">
{piece.id} <span aria-hidden="true">↗</span>
</span>
</button>
<div className="board-cut-info">
<p className="cut-size">
{cutMm(piece.length)} mm <span className="cut-note">long point</span>
</p>
<p className="cut-location">
{roomName(piece.roomId)} · Wall {piece.wallIndex + 1}
</p>
<p className="cut-note">
Left {degree(piece.leftMitre)} · Right {degree(piece.rightMitre)}
</p>
<button
className="button cut-locate"
aria-label={`Show cut ${piece.id} on plan`}
onClick={() => locatePiece(piece.id, piece.roomId)}
>
<MapPin size={14} aria-hidden="true" />
Show on plan
</button>
</div>
</li>
))}
</ul>
) : (
<p className="uncut-board-note">
Installed uncut ·{' '}
{skirting.pieces
.filter((piece) => piece.stockId === stock.id)
.map((piece) => roomName(piece.roomId))
.join(', ')}
</p>
)}
</section>
);
})}
{!skirtStocks.length && (
<p className="empty-state">
{skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
{searching
? 'No matching skirting boards.'
: skirting.pieces.length
? 'No skirting cuts needed.'
: 'Complete the outline and skirting measurements to calculate cuts.'}
</p>
)}
</section>
<CutDialog
floor={floorCuts.find((piece) => piece.id === selectedPiece)}
skirt={skirtCuts.find((piece) => piece.id === selectedPiece)}
project={project}
close={() => select(null)}
/>
</section>
);
}

View file

@ -1,7 +1,7 @@
import { Fragment } from 'react';
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
import { cutMm, degree } from '../lib/format';
import {
exactMm,
dimensionedOutline,
flooringTemplate,
fullPreviewBox,
@ -93,7 +93,7 @@ function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: n
{dimension.text}
</tspan>{' '}
<tspan x={dimension.label.x} dy={font * 1.2}>
{exactMm(dimension.angle)}°
{degree(dimension.angle)}
</tspan>
</>
)}
@ -137,7 +137,7 @@ export function FlooringPreview({
</svg>
{!compact && (
<figcaption>
{exactMm(piece.length)} × {exactMm(piece.width)} mm
{cutMm(piece.length)} × {cutMm(piece.width)} mm
{dimensions?.dimensions.some((dimension) => dimension.angle !== undefined) &&
' · angles from horizontal'}
</figcaption>
@ -198,7 +198,7 @@ export function SkirtingPreview({
<Dimensions dimensions={dimensions!.dimensions} font={dimensions!.font} />
</svg>
<figcaption>
{exactMm(piece.length)} × {exactMm(thickness)} mm · wall side above
{cutMm(piece.length)} × {cutMm(thickness)} mm · wall side above
</figcaption>
</figure>
)}
@ -242,7 +242,7 @@ export function SkirtingPreview({
{!compact && (
<figcaption>
{index === 0 ? 'Left' : 'Right'} ·{' '}
{exactMm(Math.abs(index === 0 ? piece.leftMitre : piece.rightMitre))}°{' '}
{degree(index === 0 ? piece.leftMitre : piece.rightMitre)}{' '}
{index === 0 ? piece.leftType : piece.rightType}
</figcaption>
)}

View file

@ -1,6 +1,61 @@
import { useId, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import type { InputHTMLAttributes } from 'react';
import type { Issue } from '../lib/types';
import { AlertCircle, Info, Check } from 'lucide-react';
const numberFromDraft = (text: string): number | null => {
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null;
const number = Number(text);
return Number.isFinite(number) ? number : null;
};
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
export function NumericInput({
value,
onChange,
onBlur,
onKeyDown,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
value: number;
onChange: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
const emitted = useRef(value);
useEffect(() => {
if (value !== emitted.current) setDraft(String(value));
emitted.current = value;
}, [value]);
return (
<input
{...props}
type="text"
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
value={draft}
onChange={(event) => {
const text = event.target.value;
setDraft(text);
const number = numberFromDraft(text);
if (number !== null && number !== value) {
emitted.current = number;
onChange(number);
}
}}
onBlur={(event) => {
setDraft(String(value));
onBlur?.(event);
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
event.preventDefault();
event.currentTarget.blur();
}
}}
/>
);
}
export function NumberField({
label,
value,
@ -54,16 +109,14 @@ export function NumberField({
)}
</span>
<span className="number-input">
<input
<NumericInput
id={id}
type="number"
inputMode="decimal"
value={value}
min={min}
max={max}
step={step}
aria-describedby={hint ? `${id}-hint` : undefined}
onChange={(e) => onChange(Number(e.target.value))}
onChange={onChange}
/>
<span>{unit}</span>
</span>

View file

@ -1,10 +1,10 @@
import { useId } from 'react';
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
import { area, mm } from '../lib/format';
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 } from '../lib/dimension-labels';
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
import { ClosedBadge } from './Fields';
@ -113,6 +113,9 @@ interface Props {
selectedPiece: string | null;
select?: (id: string) => void;
interactive?: boolean;
completedBoards?: string[];
highlightedPieces?: string[];
searchActive?: boolean;
}
export function FloorPlan({
@ -123,6 +126,9 @@ export function FloorPlan({
selectedPiece,
select,
interactive = true,
completedBoards = [],
highlightedPieces = [],
searchActive = false,
}: Props) {
const hatch = useId().replaceAll(':', '');
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
@ -145,14 +151,17 @@ export function FloorPlan({
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,
minX: x - font * 1.4,
maxX: x + font * 1.4,
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 boxes = [
...reserved,
...labels.map((label, i) => wallDimensionBox(geometry.edges[i], label, span)),
];
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;
@ -161,7 +170,10 @@ export function FloorPlan({
<section className="drawing-card" aria-label="Floor plan preview">
<div className="drawing-header">
<h2>
Floor plan <small>{area(geometry.area)} m²</small>
Floor plan{' '}
<small>
{room.name} · {area(geometry.area)} m²
</small>
</h2>
<ClosedBadge closed={geometry.closed && geometry.valid} />
</div>
@ -212,6 +224,8 @@ export function FloorPlan({
{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))) /
@ -223,8 +237,11 @@ export function FloorPlan({
return (
<g
key={piece.id}
className={`floor-piece ${cut ? 'svg-cut floor-cut' : 'uncut'}`}
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}
@ -246,11 +263,30 @@ export function FloorPlan({
<polygon
points={points(piece.polygon)}
fill={
cut ? (selectedPiece === piece.id ? '#ceddf9' : `url(#${hatch})`) : '#f5f6f8'
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 && (
<>
<g
@ -258,11 +294,11 @@ export function FloorPlan({
pointerEvents="none"
>
<rect
x={-font * 1.1}
x={-font * 1.3}
y={-font * 0.55}
width={font * 2.2}
width={font * 2.6}
height={font * 1.1}
fill="#1647ab"
fill={done ? '#27655d' : '#1647ab'}
/>
<text
textAnchor="middle"
@ -272,10 +308,11 @@ export function FloorPlan({
fontWeight="600"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
<title>
{piece.id}: {mm(piece.length)} × {mm(piece.width)} mm
{piece.id}: {cutMm(piece.length)} × {cutMm(piece.width)} mm
</title>
</>
)}
@ -287,6 +324,8 @@ export function FloorPlan({
))}
{skirtPieces.map((piece) => {
const cut = isSkirtingCut(piece, skirting);
const done = completedBoards.includes(`skirting:${piece.stockId}`);
const highlighted = highlightedPieces.includes(piece.id);
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;
@ -294,8 +333,11 @@ export function FloorPlan({
return (
<g
key={piece.id}
className={`skirt-piece ${cut ? 'svg-cut skirt-cut' : 'uncut'}`}
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}
@ -316,7 +358,15 @@ export function FloorPlan({
>
<polygon
points={points(polygon)}
fill={cut ? (selectedPiece === piece.id ? '#17263f' : '#b74900') : '#d8d5cd'}
fill={
done
? '#27655d'
: cut
? selectedPiece === piece.id
? '#17263f'
: '#b74900'
: '#d8d5cd'
}
stroke={cut ? '#8d3800' : '#79756b'}
strokeWidth={span * 0.001}
/>
@ -330,14 +380,27 @@ export function FloorPlan({
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>
)}
{label && (
<g className="skirting-code" transform={`translate(${label.x} ${label.y})`}>
<rect
x={-font * 1.1}
x={-font * 1.3}
y={-font * 0.58}
width={font * 2.2}
width={font * 2.6}
height={font * 1.16}
fill="#b74900"
fill={done ? '#27655d' : '#b74900'}
stroke="#8d3800"
strokeWidth={span * 0.0012}
/>
@ -349,12 +412,13 @@ export function FloorPlan({
fontWeight="700"
>
{piece.id}
{done ? ' ✓' : ''}
</text>
</g>
)}
{cut && (
<title>
{piece.id}: {mm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{piece.id}: {cutMm(piece.length)} mm · {mm(piece.leftMitre)}° /{' '}
{mm(piece.rightMitre)}°
</title>
)}

View file

@ -1,10 +1,21 @@
import { Plus, Trash2, DoorOpen, Check, MoveUpRight } from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import {
Plus,
Trash2,
DoorOpen,
Check,
MoveUpRight,
GripVertical,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import type { Geometry, Project, Room, Wall } from '../lib/types';
import { fireplace, rectangle, nextId } from '../lib/defaults';
import { normalizeAngle } from '../lib/geometry';
import { mm } from '../lib/format';
import { NumberField, Issues } from './Fields';
import { NumberField, NumericInput, Issues } from './Fields';
import { DoorPosition } from './DoorPosition';
import { insertWallAfter, moveWall } from '../lib/wall-editing';
interface Props {
room: Room;
geometry: Geometry;
@ -23,6 +34,8 @@ export function OutlineEditor({
remove,
canRemove,
}: Props) {
const draggedWall = useRef<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
const isRectangle =
@ -85,7 +98,7 @@ export function OutlineEditor({
}));
};
return (
<>
<Fragment key={room.id}>
<div className="editor-heading">
<h2>Room measurements</h2>
</div>
@ -168,23 +181,39 @@ export function OutlineEditor({
<span />
</div>
{room.walls.map((wall, i) => (
<div className="wall-row" key={wall.id}>
<div
className={`wall-row${dropTarget === wall.id ? ' drag-over' : ''}`}
key={wall.id}
data-wall-id={wall.id}
onDragOver={(event) => {
if (!draggedWall.current || draggedWall.current === wall.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setDropTarget(wall.id);
}}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
setDropTarget(null);
}}
onDrop={(event) => {
const id = draggedWall.current;
if (!id) return;
event.preventDefault();
changeRoom((r) => moveWall(r, id, i));
draggedWall.current = null;
setDropTarget(null);
}}
>
<span className="wall-code">W{i + 1}</span>
<input
<NumericInput
aria-label={`Wall ${i + 1} length`}
type="number"
inputMode="decimal"
min={1}
step={1}
value={wall.length}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, length: Number(e.target.value) }))
}
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
/>
<input
<NumericInput
aria-label={`Wall ${i + 1} turn`}
type="number"
inputMode="decimal"
min={-179}
max={179}
step={0.1}
@ -199,20 +228,64 @@ export function OutlineEditor({
: 'Turn after this wall; positive turns right.'
}
disabled={geometry.closed && i === room.walls.length - 1}
onChange={(e) =>
changeWall(wall.id, (w) => ({ ...w, turn: Number(e.target.value) }))
}
onChange={(turn) => changeWall(wall.id, (w) => ({ ...w, turn }))}
/>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
<div className="wall-actions">
<button
className="icon-button wall-drag-handle"
draggable
aria-label={`Drag wall ${i + 1} to reorder`}
title="Drag to reorder"
onDragStart={(event) => {
draggedWall.current = wall.id;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', wall.id);
}}
onDragEnd={() => {
draggedWall.current = null;
setDropTarget(null);
}}
>
<GripVertical size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} up`}
title="Move wall up"
disabled={i === 0}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i - 1))}
>
<ArrowUp size={16} />
</button>
<button
className="icon-button"
aria-label={`Move wall ${i + 1} down`}
title="Move wall down"
disabled={i === room.walls.length - 1}
onClick={() => changeRoom((r) => moveWall(r, wall.id, i + 1))}
>
<ArrowDown size={16} />
</button>
<button
className="icon-button"
aria-label={`Insert wall after W${i + 1}`}
title={`Insert wall after W${i + 1}`}
disabled={room.walls.length >= 100}
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
>
<Plus size={16} />
</button>
<button
className="icon-button"
aria-label={`Remove wall ${i + 1}`}
disabled={room.walls.length === 1}
onClick={() =>
changeRoom((r) => ({ ...r, walls: r.walls.filter((w) => w.id !== wall.id) }))
}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
@ -364,6 +437,6 @@ export function OutlineEditor({
Remove this room
</button>
)}
</>
</Fragment>
);
}

View file

@ -38,6 +38,7 @@ export function PrintPlan({
Buy {flooring.boardCount} floorboards and {skirting.stockCount} skirting lengths. Install{' '}
{wholeFloor} floorboards and {wholeSkirt} skirting lengths uncut.
</p>
<p>Cut each group from one board.</p>
</header>
{project.rooms.map((room, index) => (
<section className="print-room" key={room.id} data-room-id={room.id}>
@ -51,15 +52,23 @@ export function PrintPlan({
skirting={skirting}
selectedPiece={null}
interactive={false}
completedBoards={project.completedBoards}
/>
</section>
))}
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
<h2>Flooring cuts</h2>
{groupFlooringCuts(flooring).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<section
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
key={group[0].stockId}
>
<h3>
Floorboard {group[0].stockId}{' '}
Cuts {group.map((piece) => piece.id).join(' + ')}
{project.completedBoards.includes(`floor:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm
</span>
@ -85,9 +94,16 @@ export function PrintPlan({
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
<h2>Skirting cuts</h2>
{groupSkirtingCuts(skirting).map((group) => (
<section className="print-stock" data-stock-id={group[0].stockId} key={group[0].stockId}>
<section
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
data-stock-id={group[0].stockId}
key={group[0].stockId}
>
<h3>
Skirting board {group[0].stockId}{' '}
Cuts {group.map((piece) => piece.id).join(' + ')}
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
? ' · Done ✓'
: ''}{' '}
<span>
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
</span>