This commit is contained in:
parent
171fbf1927
commit
8bf3e3b705
40 changed files with 1443 additions and 1497 deletions
|
|
@ -1,18 +1,20 @@
|
|||
import type { CutSelection } from '../lib/cut-list';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { FlooringPiece, Project, SkirtingPiece } from '../lib/types';
|
||||
import type { Project } from '../lib/types';
|
||||
import { flooringInstructions } from '../lib/cut-instructions';
|
||||
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
interface Props {
|
||||
floor?: FlooringPiece;
|
||||
skirt?: SkirtingPiece;
|
||||
selection?: CutSelection;
|
||||
project: Project;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function CutDialog({ floor, skirt, project, close }: Props) {
|
||||
export function CutDialog({ selection, project, close }: Props) {
|
||||
const floor = selection?.material === 'flooring' ? selection.piece : undefined;
|
||||
const skirt = selection?.material === 'skirting' ? selection.piece : undefined;
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const selected = floor ?? skirt;
|
||||
|
|
@ -27,7 +29,7 @@ export function CutDialog({ floor, skirt, project, close }: Props) {
|
|||
element.close();
|
||||
if (opener.current?.isConnected) opener.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [selected?.id]);
|
||||
}, [selection?.material, selected?.id]);
|
||||
|
||||
const room =
|
||||
selected &&
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
import { MapPin, X } from 'lucide-react';
|
||||
import type { FlooringPlan, Project, SkirtingPlan } from '../lib/types';
|
||||
import type { FlooringPlan, Project, SkirtingPlan, PlanMaterial, PlanFocus } from '../lib/types';
|
||||
import { cutMm, mm } from '../lib/format';
|
||||
import {
|
||||
flooringCuts,
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
skirtingCuts,
|
||||
skirtingEndLabel,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
} from '../lib/cut-list';
|
||||
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
|
||||
import { skirtingEndLabel } from '../lib/cut-list';
|
||||
import type { CutData } from '../lib/cut-list';
|
||||
import { pieceKey } from '../lib/board-tracking';
|
||||
import type { BoardMatches } from '../lib/board-tracking';
|
||||
import { PERSISTENCE_LIMITS } from '../lib/limits';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
|
|
@ -60,13 +54,16 @@ interface Props {
|
|||
project: Project;
|
||||
flooring: FlooringPlan;
|
||||
skirting: SkirtingPlan;
|
||||
select: (id: string | null, roomId?: string) => void;
|
||||
select: (material: PlanMaterial, id: string, 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;
|
||||
locate: (focus: PlanFocus) => void;
|
||||
cuts: CutData;
|
||||
matches: BoardMatches;
|
||||
searchActive: boolean;
|
||||
roomNames: Map<string, string>;
|
||||
}
|
||||
|
||||
export function CutList({
|
||||
|
|
@ -78,23 +75,15 @@ export function CutList({
|
|||
toggleBoard,
|
||||
search,
|
||||
setSearch,
|
||||
locateBoard,
|
||||
locatePiece,
|
||||
locate,
|
||||
cuts,
|
||||
matches,
|
||||
searchActive: searching,
|
||||
roomNames,
|
||||
}: Props) {
|
||||
const floorCuts = flooringCuts(flooring),
|
||||
skirtCuts = skirtingCuts(skirting);
|
||||
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 { floorCuts, skirtCuts, floorGroups, skirtGroups, wholeFloor, wholeSkirt } = cuts;
|
||||
const matchedBoards = matches.boardKeys,
|
||||
matchedPieces = matches.pieceKeys;
|
||||
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) =>
|
||||
|
|
@ -103,7 +92,7 @@ export function CutList({
|
|||
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;
|
||||
const roomName = (id: string) => roomNames.get(id) ?? id;
|
||||
return (
|
||||
<section className="cut-list-card" aria-labelledby="cut-plan-title">
|
||||
<div className="cut-list-header">
|
||||
|
|
@ -120,7 +109,7 @@ export function CutList({
|
|||
<input
|
||||
id="board-search"
|
||||
type="search"
|
||||
value={boardSearchLabel(project, flooring, skirting, search)}
|
||||
value={search}
|
||||
maxLength={PERSISTENCE_LIMITS.searchLength}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="F12, A2, or a room name"
|
||||
|
|
@ -139,8 +128,8 @@ export function CutList({
|
|||
</div>
|
||||
{searching && (
|
||||
<p className="search-results" role="status">
|
||||
{matches.boardKeys.length
|
||||
? `${matches.boardKeys.length} matching board${matches.boardKeys.length === 1 ? '' : 's'}`
|
||||
{matches.boardKeys.size
|
||||
? `${matches.boardKeys.size} matching board${matches.boardKeys.size === 1 ? '' : 's'}`
|
||||
: 'No matching boards or cuts.'}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -175,13 +164,13 @@ export function CutList({
|
|||
size={`${mm(project.flooring.length)} × ${mm(project.flooring.width)} mm`}
|
||||
done={done}
|
||||
toggle={() => toggleBoard(key)}
|
||||
locate={() => locateBoard(key)}
|
||||
locate={() => locate({ kind: 'board', material: 'flooring', id: stock.id })}
|
||||
/>
|
||||
{group.length ? (
|
||||
<ul className="board-cuts">
|
||||
{group.map((piece) => (
|
||||
<li
|
||||
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
|
||||
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('flooring', piece.id)) ? 'search-match' : ''}`}
|
||||
key={piece.id}
|
||||
data-cut-id={piece.id}
|
||||
>
|
||||
|
|
@ -189,7 +178,7 @@ export function CutList({
|
|||
className="cut-preview-button flooring-preview-button"
|
||||
aria-label={`Preview flooring cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
onClick={() => select('flooring', piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<FlooringPreview piece={piece} compact />
|
||||
|
|
@ -216,7 +205,9 @@ export function CutList({
|
|||
<button
|
||||
className="button cut-locate"
|
||||
aria-label={`Show cut ${piece.id} on plan`}
|
||||
onClick={() => locatePiece(piece.id, piece.roomId)}
|
||||
onClick={() =>
|
||||
locate({ kind: 'piece', material: 'flooring', id: piece.id })
|
||||
}
|
||||
>
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
Show on plan
|
||||
|
|
@ -253,8 +244,7 @@ export function CutList({
|
|||
Skirting cuts <span className="count-badge">{skirtCuts.length}</span>
|
||||
</h3>
|
||||
<p className="cut-summary">
|
||||
Buy {skirting.stockCount} lengths ·{' '}
|
||||
{wholeSkirt.reduce((sum, group) => sum + group.count, 0)} installed uncut
|
||||
Buy {skirting.stockCount} lengths · {wholeSkirt} installed uncut
|
||||
</p>
|
||||
{skirtStocks.map((stock) => {
|
||||
const key = `skirting:${stock.id}`,
|
||||
|
|
@ -276,13 +266,13 @@ export function CutList({
|
|||
size={`${mm(stock.length)} mm`}
|
||||
done={done}
|
||||
toggle={() => toggleBoard(key)}
|
||||
locate={() => locateBoard(key)}
|
||||
locate={() => locate({ kind: 'board', material: 'skirting', id: stock.id })}
|
||||
/>
|
||||
{group.length ? (
|
||||
<ul className="board-cuts">
|
||||
{group.map((piece) => (
|
||||
<li
|
||||
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(piece.id) ? 'search-match' : ''}`}
|
||||
className={`board-cut ${done ? 'is-done' : ''} ${searching && matchedPieces.has(pieceKey('skirting', piece.id)) ? 'search-match' : ''}`}
|
||||
key={piece.id}
|
||||
data-cut-id={piece.id}
|
||||
>
|
||||
|
|
@ -290,7 +280,7 @@ export function CutList({
|
|||
className="cut-preview-button skirting-preview-button"
|
||||
aria-label={`Preview skirting cut ${piece.id}`}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => select(piece.id, piece.roomId)}
|
||||
onClick={() => select('skirting', piece.id, piece.roomId)}
|
||||
>
|
||||
<span className="cut-thumbnail" aria-hidden="true">
|
||||
<SkirtingPreview
|
||||
|
|
@ -319,7 +309,9 @@ export function CutList({
|
|||
<button
|
||||
className="button cut-locate"
|
||||
aria-label={`Show cut ${piece.id} on plan`}
|
||||
onClick={() => locatePiece(piece.id, piece.roomId)}
|
||||
onClick={() =>
|
||||
locate({ kind: 'piece', material: 'skirting', id: piece.id })
|
||||
}
|
||||
>
|
||||
<MapPin size={14} aria-hidden="true" />
|
||||
Show on plan
|
||||
|
|
|
|||
|
|
@ -9,46 +9,44 @@ const numberFromDraft = (text: string): number | null => {
|
|||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
/** Only unfinished typing is local; every complete numeric value is committed immediately. */
|
||||
/** Measurements remain local until blur or Enter; Escape discards the draft. */
|
||||
export function NumericInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
signed = false,
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> & {
|
||||
}: Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
'value' | 'onChange' | 'type' | 'min' | 'max' | 'step'
|
||||
> & {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
signed?: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const emitted = useRef(value);
|
||||
useEffect(() => {
|
||||
if (value !== emitted.current) setDraft(String(value));
|
||||
emitted.current = value;
|
||||
}, [value]);
|
||||
const cancel = useRef(false);
|
||||
useEffect(() => setDraft(String(value)), [value]);
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
type="text"
|
||||
inputMode={props.inputMode ?? (Number(props.min) < 0 ? 'text' : 'decimal')}
|
||||
inputMode={signed ? '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);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onBlur={(event) => {
|
||||
setDraft(String(value));
|
||||
const next = cancel.current ? value : (numberFromDraft(event.currentTarget.value) ?? value);
|
||||
cancel.current = false;
|
||||
setDraft(String(next));
|
||||
if (next !== value) onChange(next);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event);
|
||||
if (!event.defaultPrevented && (event.key === 'Enter' || event.key === 'Escape')) {
|
||||
event.preventDefault();
|
||||
cancel.current = event.key === 'Escape';
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
|
|
@ -61,9 +59,6 @@ export function NumberField({
|
|||
value,
|
||||
onChange,
|
||||
unit = 'mm',
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
hint,
|
||||
tooltip,
|
||||
}: {
|
||||
|
|
@ -71,9 +66,6 @@ export function NumberField({
|
|||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
hint?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
|
|
@ -112,9 +104,6 @@ export function NumberField({
|
|||
<NumericInput
|
||||
id={id}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
aria-describedby={hint ? `${id}-hint` : undefined}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -125,9 +114,11 @@ export function NumberField({
|
|||
);
|
||||
}
|
||||
export function Issues({ issues, compact = false }: { issues: Issue[]; compact?: boolean }) {
|
||||
const unique = issues.filter(
|
||||
(issue, i) => issues.findIndex((item) => item.message === issue.message) === i,
|
||||
);
|
||||
const unique = [
|
||||
...new Map(
|
||||
issues.map((issue) => [`${issue.roomId}:${issue.severity}:${issue.message}`, issue]),
|
||||
).values(),
|
||||
];
|
||||
return (
|
||||
<div className={`issues ${compact ? 'compact' : ''}`} role="status">
|
||||
{unique.map((issue, i) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useId } from 'react';
|
||||
import { overlaps } from '../lib/drawing-geometry';
|
||||
import { useId, useMemo } from 'react';
|
||||
import type {
|
||||
FlooringPlan,
|
||||
PieceKey,
|
||||
Geometry,
|
||||
PlanMaterial,
|
||||
Point,
|
||||
|
|
@ -10,7 +12,7 @@ import type {
|
|||
} from '../lib/types';
|
||||
import { area, cutMm, mm } from '../lib/format';
|
||||
import { signedArea } from '../lib/geometry';
|
||||
import { isSkirtingCut } from '../lib/cut-list';
|
||||
import { pieceKey } from '../lib/board-tracking';
|
||||
import { skirtingPolygon } from '../lib/skirting-polygon';
|
||||
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
|
||||
import { floorPieceLabels } from '../lib/piece-labels';
|
||||
|
|
@ -122,11 +124,11 @@ interface Props {
|
|||
geometry: Geometry;
|
||||
flooring: FlooringPlan;
|
||||
skirting: SkirtingPlan;
|
||||
selectedPiece: string | null;
|
||||
select?: (id: string) => void;
|
||||
selectedPiece: PieceKey | null;
|
||||
select?: (material: PlanMaterial, id: string) => void;
|
||||
interactive?: boolean;
|
||||
completedBoards?: string[];
|
||||
highlightedPieces?: string[];
|
||||
highlightedPieces?: Set<PieceKey>;
|
||||
searchActive?: boolean;
|
||||
material?: PlanMaterial;
|
||||
}
|
||||
|
|
@ -140,75 +142,95 @@ export function FloorPlan({
|
|||
select,
|
||||
interactive = true,
|
||||
completedBoards = [],
|
||||
highlightedPieces = [],
|
||||
highlightedPieces = new Set(),
|
||||
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,
|
||||
const {
|
||||
span,
|
||||
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;
|
||||
orientation,
|
||||
floorPieces,
|
||||
skirtPieces,
|
||||
skirtLabels,
|
||||
labels,
|
||||
floorLabels,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
} = useMemo(() => {
|
||||
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 (!piece.cut) 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) => overlaps(box, other))) 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 {
|
||||
span,
|
||||
font,
|
||||
orientation,
|
||||
floorPieces,
|
||||
skirtPieces,
|
||||
skirtLabels,
|
||||
labels,
|
||||
floorLabels,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}, [geometry, room.id, room.direction, flooring, skirting, completedBoards, material]);
|
||||
return (
|
||||
<section
|
||||
className="drawing-card"
|
||||
|
|
@ -272,7 +294,7 @@ export function FloorPlan({
|
|||
{floorPieces.map((piece) => {
|
||||
const cut = piece.cut || piece.reused;
|
||||
const done = completedBoards.includes(`floor:${piece.stockId}`);
|
||||
const highlighted = highlightedPieces.includes(piece.id);
|
||||
const highlighted = highlightedPieces.has(pieceKey('flooring', piece.id));
|
||||
const x =
|
||||
(Math.min(...piece.polygon.map((p) => p.x)) +
|
||||
Math.max(...piece.polygon.map((p) => p.x))) /
|
||||
|
|
@ -294,14 +316,14 @@ export function FloorPlan({
|
|||
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}
|
||||
aria-pressed={cut ? selectedPiece === pieceKey('flooring', piece.id) : undefined}
|
||||
onClick={cut && interactive ? () => select?.('flooring', piece.id) : undefined}
|
||||
onKeyDown={
|
||||
cut
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
select?.(piece.id);
|
||||
select?.('flooring', piece.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -313,13 +335,15 @@ export function FloorPlan({
|
|||
done
|
||||
? '#dbe9e7'
|
||||
: cut
|
||||
? selectedPiece === piece.id
|
||||
? selectedPiece === pieceKey('flooring', piece.id)
|
||||
? '#ceddf9'
|
||||
: `url(#${hatch})`
|
||||
: '#f5f6f8'
|
||||
}
|
||||
stroke={cut ? '#1647ab' : '#9baabd'}
|
||||
strokeWidth={span * (selectedPiece === piece.id ? 0.003 : 0.0012)}
|
||||
strokeWidth={
|
||||
span * (selectedPiece === pieceKey('flooring', piece.id) ? 0.003 : 0.0012)
|
||||
}
|
||||
/>
|
||||
{done && !cut && (
|
||||
<text
|
||||
|
|
@ -349,9 +373,9 @@ export function FloorPlan({
|
|||
<Wall key={edge.wallId} edge={edge} span={span} label={labels[i]} />
|
||||
))}
|
||||
{skirtPieces.map((piece) => {
|
||||
const cut = isSkirtingCut(piece, skirting);
|
||||
const cut = piece.cut;
|
||||
const done = completedBoards.includes(`skirting:${piece.stockId}`);
|
||||
const highlighted = highlightedPieces.includes(piece.id);
|
||||
const highlighted = highlightedPieces.has(pieceKey('skirting', piece.id));
|
||||
// Each template uses its true wall-facing and room-facing edges, including mitres.
|
||||
const polygon = skirtingPolygon(piece, orientation);
|
||||
return (
|
||||
|
|
@ -367,14 +391,14 @@ export function FloorPlan({
|
|||
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}
|
||||
aria-pressed={cut ? selectedPiece === pieceKey('skirting', piece.id) : undefined}
|
||||
onClick={cut && interactive ? () => select?.('skirting', piece.id) : undefined}
|
||||
onKeyDown={
|
||||
cut
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
select?.(piece.id);
|
||||
select?.('skirting', piece.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -386,7 +410,7 @@ export function FloorPlan({
|
|||
done
|
||||
? '#27655d'
|
||||
: cut
|
||||
? selectedPiece === piece.id
|
||||
? selectedPiece === pieceKey('skirting', piece.id)
|
||||
? '#17263f'
|
||||
: '#b74900'
|
||||
: '#d8d5cd'
|
||||
|
|
@ -442,14 +466,14 @@ export function FloorPlan({
|
|||
const label = skirtLabels.get(piece.id);
|
||||
if (!label) return null;
|
||||
const done = completedBoards.includes(`skirting:${piece.stockId}`);
|
||||
const highlighted = highlightedPieces.includes(piece.id);
|
||||
const highlighted = highlightedPieces.has(pieceKey('skirting', 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}
|
||||
onClick={interactive ? () => select?.('skirting', piece.id) : undefined}
|
||||
>
|
||||
<rect
|
||||
x={-label.width / 2}
|
||||
|
|
@ -477,13 +501,13 @@ export function FloorPlan({
|
|||
{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);
|
||||
const highlighted = highlightedPieces.has(pieceKey('flooring', 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}
|
||||
onClick={interactive ? () => select?.('flooring', label.id) : undefined}
|
||||
>
|
||||
{label.leader && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ interface Props {
|
|||
project: Project;
|
||||
room: Room;
|
||||
flooringOptions: FlooringOptions;
|
||||
changeProject: (fn: (p: Project) => Project) => void;
|
||||
setFlooring: (patch: Partial<FlooringSettings>) => void;
|
||||
setSkirting: (patch: Partial<SkirtingSettings>) => void;
|
||||
changeRoom: (fn: (r: Room) => Room) => void;
|
||||
}
|
||||
|
||||
|
|
@ -19,15 +20,15 @@ export function MaterialsEditor({
|
|||
project,
|
||||
room,
|
||||
flooringOptions,
|
||||
changeProject,
|
||||
setFlooring,
|
||||
setSkirting,
|
||||
changeRoom,
|
||||
}: Props) {
|
||||
const f = project.flooring;
|
||||
const s = project.skirting;
|
||||
const floor = (key: keyof FlooringSettings, value: number | string) =>
|
||||
changeProject((p) => ({ ...p, flooring: { ...p.flooring, [key]: value } }));
|
||||
const skirt = (key: keyof SkirtingSettings, value: number) =>
|
||||
changeProject((p) => ({ ...p, skirting: { ...p.skirting, [key]: value } }));
|
||||
const floor = <K extends keyof FlooringSettings>(key: K, value: FlooringSettings[K]) =>
|
||||
setFlooring({ [key]: value });
|
||||
const skirt = (key: keyof SkirtingSettings, value: number) => setSkirting({ [key]: value });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -43,18 +44,8 @@ export function MaterialsEditor({
|
|||
Use the length and width printed on your flooring pack.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Board length"
|
||||
value={f.length}
|
||||
min={1}
|
||||
onChange={(n) => floor('length', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Board width"
|
||||
value={f.width}
|
||||
min={1}
|
||||
onChange={(n) => floor('width', n)}
|
||||
/>
|
||||
<NumberField label="Board length" value={f.length} onChange={(n) => floor('length', n)} />
|
||||
<NumberField label="Board width" value={f.width} onChange={(n) => floor('width', n)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<span>Laying pattern</span>
|
||||
|
|
@ -158,13 +149,11 @@ export function MaterialsEditor({
|
|||
<NumberField
|
||||
label="Shortest end piece"
|
||||
value={f.minLength}
|
||||
min={1}
|
||||
onChange={(n) => floor('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Narrowest edge row"
|
||||
value={f.minWidth}
|
||||
min={1}
|
||||
onChange={(n) => floor('minWidth', n)}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -181,7 +170,6 @@ export function MaterialsEditor({
|
|||
<NumberField
|
||||
label="Skirting board length"
|
||||
value={s.stockLength}
|
||||
min={1}
|
||||
onChange={(n) => skirt('stockLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
|
|
@ -196,7 +184,6 @@ export function MaterialsEditor({
|
|||
<NumberField
|
||||
label="Minimum piece"
|
||||
value={s.minLength}
|
||||
min={1}
|
||||
onChange={(n) => skirt('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react';
|
||||
import type { Geometry, Project, Room, Wall } from '../lib/types';
|
||||
import type { Geometry, Room, Wall } from '../lib/types';
|
||||
import { fireplace, rectangle, nextId } from '../lib/defaults';
|
||||
import { normalizeAngle } from '../lib/geometry';
|
||||
import { mm } from '../lib/format';
|
||||
|
|
@ -22,7 +22,7 @@ interface Props {
|
|||
geometry: Geometry;
|
||||
tolerance: number;
|
||||
changeRoom: (fn: (room: Room) => Room) => void;
|
||||
changeProject: (fn: (project: Project) => Project) => void;
|
||||
setTolerance: (value: number) => void;
|
||||
remove: () => void;
|
||||
canRemove: boolean;
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export function OutlineEditor({
|
|||
geometry,
|
||||
tolerance,
|
||||
changeRoom,
|
||||
changeProject,
|
||||
setTolerance,
|
||||
remove,
|
||||
canRemove,
|
||||
}: Props) {
|
||||
|
|
@ -182,7 +182,6 @@ export function OutlineEditor({
|
|||
<NumberField
|
||||
label="Room width"
|
||||
value={room.walls[0].length}
|
||||
min={1}
|
||||
onChange={(n) =>
|
||||
changeRoom((r) => ({
|
||||
...r,
|
||||
|
|
@ -193,7 +192,6 @@ export function OutlineEditor({
|
|||
<NumberField
|
||||
label="Room length"
|
||||
value={room.walls[1].length}
|
||||
min={1}
|
||||
onChange={(n) =>
|
||||
changeRoom((r) => ({
|
||||
...r,
|
||||
|
|
@ -244,16 +242,12 @@ export function OutlineEditor({
|
|||
<span className="wall-code">W{i + 1}</span>
|
||||
<NumericInput
|
||||
aria-label={`Wall ${i + 1} length`}
|
||||
min={1}
|
||||
step={1}
|
||||
value={wall.length}
|
||||
onChange={(length) => changeWall(wall.id, (w) => ({ ...w, length }))}
|
||||
/>
|
||||
<NumericInput
|
||||
aria-label={`Wall ${i + 1} turn`}
|
||||
min={-179}
|
||||
max={179}
|
||||
step={0.1}
|
||||
signed
|
||||
value={
|
||||
geometry.closed && i === room.walls.length - 1
|
||||
? Math.round(normalizeAngle(-(lastEdge?.heading ?? 0)) * 10) / 10
|
||||
|
|
@ -408,7 +402,11 @@ export function OutlineEditor({
|
|||
}
|
||||
>
|
||||
{room.walls.map((w, i) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
<option
|
||||
key={w.id}
|
||||
value={w.id}
|
||||
disabled={w.id !== wall.id && w.doors.length >= PROJECT_LIMITS.doorsPerWall}
|
||||
>
|
||||
W{i + 1}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -447,7 +445,6 @@ export function OutlineEditor({
|
|||
/>
|
||||
<NumberField
|
||||
label="Door width"
|
||||
min={1}
|
||||
value={door.width}
|
||||
onChange={(n) =>
|
||||
changeWall(wall.id, (w) => ({
|
||||
|
|
@ -473,8 +470,7 @@ export function OutlineEditor({
|
|||
<NumberField
|
||||
label="Measurement tolerance"
|
||||
value={tolerance}
|
||||
max={PROJECT_LIMITS.maxTolerance}
|
||||
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
|
||||
onChange={setTolerance}
|
||||
hint="Largest closing gap accepted for the cut plan."
|
||||
/>
|
||||
{canRemove && (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import type { CutData } from '../lib/cut-list';
|
||||
import type { FlooringPlan, Geometry, Project, SkirtingPlan } from '../lib/types';
|
||||
import { area, mm } from '../lib/format';
|
||||
import {
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
readyOffcutInstructions,
|
||||
skirtingInstructions,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
} from '../lib/cut-list';
|
||||
import { readyOffcutInstructions, skirtingInstructions } from '../lib/cut-list';
|
||||
import { flooringInstructions } from '../lib/cut-instructions';
|
||||
import { FloorPlan } from './FloorPlan';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
|
@ -15,18 +9,22 @@ import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
|||
/** All rooms first, then cutting sheets in purchased-board order, including shared offcuts. */
|
||||
export function PrintPlan({
|
||||
project,
|
||||
cuts,
|
||||
roomNames,
|
||||
geometries,
|
||||
flooring,
|
||||
skirting,
|
||||
}: {
|
||||
project: Project;
|
||||
cuts: CutData;
|
||||
roomNames: Map<string, string>;
|
||||
geometries: Geometry[];
|
||||
flooring: FlooringPlan;
|
||||
skirting: SkirtingPlan;
|
||||
}) {
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring).wholeBoards;
|
||||
const wholeSkirt = uncutSkirting(skirting).reduce((sum, group) => sum + group.count, 0);
|
||||
const roomName = (id: string) => roomNames.get(id) ?? id;
|
||||
const wholeFloor = cuts.wholeFloor.wholeBoards;
|
||||
const wholeSkirt = cuts.wholeSkirt;
|
||||
return (
|
||||
<section className="print-plan" aria-label="Printable plan">
|
||||
<header className="print-title">
|
||||
|
|
@ -59,7 +57,7 @@ export function PrintPlan({
|
|||
))}
|
||||
<section className="print-material print-flooring" aria-label="Printed flooring cuts">
|
||||
<h2>Flooring cuts</h2>
|
||||
{groupFlooringCuts(flooring).map((group) => (
|
||||
{[...cuts.floorGroups.values()].map((group) => (
|
||||
<section
|
||||
className={`print-stock ${project.completedBoards.includes(`floor:${group[0].stockId}`) ? 'is-done' : ''}`}
|
||||
data-stock-id={group[0].stockId}
|
||||
|
|
@ -90,11 +88,11 @@ export function PrintPlan({
|
|||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groupFlooringCuts(flooring).length && <p>No flooring cuts needed.</p>}
|
||||
{!cuts.floorGroups.size && <p>No flooring cuts needed.</p>}
|
||||
</section>
|
||||
<section className="print-material print-skirting" aria-label="Printed skirting cuts">
|
||||
<h2>Skirting cuts</h2>
|
||||
{groupSkirtingCuts(skirting).map((group) => (
|
||||
{[...cuts.skirtGroups.values()].map((group) => (
|
||||
<section
|
||||
className={`print-stock ${project.completedBoards.includes(`skirting:${group[0].stockId}`) ? 'is-done' : ''}`}
|
||||
data-stock-id={group[0].stockId}
|
||||
|
|
@ -105,9 +103,7 @@ export function PrintPlan({
|
|||
{project.completedBoards.includes(`skirting:${group[0].stockId}`)
|
||||
? ' · Done ✓'
|
||||
: ''}{' '}
|
||||
<span>
|
||||
{mm(skirting.stocks.find((stock) => stock.id === group[0].stockId)!.length)} mm
|
||||
</span>
|
||||
<span>{mm(project.skirting.stockLength)} mm</span>
|
||||
</h3>
|
||||
<div className="print-cut-grid">
|
||||
{group.map((piece) => (
|
||||
|
|
@ -125,7 +121,7 @@ export function PrintPlan({
|
|||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!groupSkirtingCuts(skirting).length && <p>No skirting cuts needed.</p>}
|
||||
{!cuts.skirtGroups.size && <p>No skirting cuts needed.</p>}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue