..
This commit is contained in:
parent
9689bfcc18
commit
62a385e4c9
33 changed files with 2649 additions and 809 deletions
347
src/App.tsx
347
src/App.tsx
|
|
@ -1,21 +1,64 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { Copy, Plus, Printer, ArrowDown } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Copy, Plus, Printer } from 'lucide-react';
|
||||
import { useProject } from './lib/use-project';
|
||||
import { rectangle, nextId } from './lib/defaults';
|
||||
import { buildGeometry } from './lib/geometry';
|
||||
import { planFlooring } from './lib/flooring';
|
||||
import { planSkirting } from './lib/skirting';
|
||||
import { area, mm } from './lib/format';
|
||||
import { isSkirtingCut } from './lib/cut-list';
|
||||
import type { Project, Room } from './lib/types';
|
||||
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||
import type { PlannerSection, Project, Room } from './lib/types';
|
||||
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
|
||||
import { OutlineEditor } from './components/OutlineEditor';
|
||||
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||
import { FloorPlan } from './components/FloorPlan';
|
||||
import { CutList } from './components/CutList';
|
||||
import { CutDialog } from './components/CutDialog';
|
||||
import { PrintPlan } from './components/PrintPlan';
|
||||
import { Issues } from './components/Fields';
|
||||
|
||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||||
function PlannerPanel({
|
||||
section,
|
||||
title,
|
||||
open,
|
||||
toggle,
|
||||
children,
|
||||
}: {
|
||||
section: PlannerSection;
|
||||
title: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={`planner-panel ${open ? 'open' : ''}`} data-section={section}>
|
||||
<h2 className="panel-toggle-heading">
|
||||
<button
|
||||
className="panel-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${section}-content`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id={`${section}-content`} hidden={!open} className="panel-content">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Completion refers to this exact allocation; names and view preferences do not change it.
|
||||
const allocationInputs = (project: Project) =>
|
||||
JSON.stringify({
|
||||
rooms: project.rooms.map(({ id, walls, direction }) => ({ id, walls, direction })),
|
||||
flooring: project.flooring,
|
||||
skirting: project.skirting,
|
||||
tolerance: project.tolerance,
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const { state, error, update } = useProject();
|
||||
|
|
@ -50,7 +93,50 @@ export default function App() {
|
|||
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
|
||||
|
||||
const changeProject = (fn: (p: Project) => Project) =>
|
||||
update((s) => ({ ...s, project: fn(s.project), view: { ...s.view, selectedPiece: null } }));
|
||||
update((s) => {
|
||||
const project = fn(s.project);
|
||||
const changed = allocationInputs(project) !== allocationInputs(s.project);
|
||||
return {
|
||||
...s,
|
||||
project: changed ? { ...project, completedBoards: [] } : project,
|
||||
view: { ...s.view, selectedPiece: null, search: changed ? '' : s.view.search },
|
||||
};
|
||||
});
|
||||
const matches = useMemo(
|
||||
() => findBoardMatches(project, flooring, skirting, view.search),
|
||||
[project, flooring, skirting, view.search],
|
||||
);
|
||||
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
|
||||
const setSearch = (search: string) =>
|
||||
update((s) => {
|
||||
const found = findBoardMatches(s.project, flooring, skirting, search);
|
||||
const roomId = found.roomIds.includes(s.view.roomId)
|
||||
? s.view.roomId
|
||||
: (found.roomIds[0] ?? s.view.roomId);
|
||||
return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } };
|
||||
});
|
||||
const togglePanel = (section: PlannerSection) =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: {
|
||||
...s.view,
|
||||
collapsedSections: s.view.collapsedSections.includes(section)
|
||||
? s.view.collapsedSections.filter((item) => item !== section)
|
||||
: [...s.view.collapsedSections, section],
|
||||
},
|
||||
}));
|
||||
const toggleBoard = (key: string) =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
project: {
|
||||
...s.project,
|
||||
completedBoards: s.project.completedBoards.includes(key)
|
||||
? s.project.completedBoards.filter((item) => item !== key)
|
||||
: [...s.project.completedBoards, key],
|
||||
},
|
||||
}));
|
||||
const locatePiece = (id: string, roomId: string) =>
|
||||
update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } }));
|
||||
const changeRoom = (fn: (r: Room) => Room) =>
|
||||
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
|
||||
const select = (id: string | null, roomId?: string) =>
|
||||
|
|
@ -68,9 +154,18 @@ export default function App() {
|
|||
...s,
|
||||
project: {
|
||||
...s.project,
|
||||
completedBoards: [],
|
||||
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
|
||||
},
|
||||
view: { ...s.view, roomId: id, selectedPiece: null },
|
||||
view: {
|
||||
...s.view,
|
||||
roomId: id,
|
||||
selectedPiece: null,
|
||||
search: '',
|
||||
collapsedSections: s.view.collapsedSections.filter(
|
||||
(section) => section !== 'measurements',
|
||||
),
|
||||
},
|
||||
};
|
||||
}, true);
|
||||
const removeRoom = () =>
|
||||
|
|
@ -78,8 +173,8 @@ export default function App() {
|
|||
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
||||
return {
|
||||
...s,
|
||||
project: { ...s.project, rooms },
|
||||
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null },
|
||||
project: { ...s.project, rooms, completedBoards: [] },
|
||||
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
|
||||
};
|
||||
}, true);
|
||||
const copyLink = async () => {
|
||||
|
|
@ -148,51 +243,116 @@ export default function App() {
|
|||
</div>
|
||||
<span className="project-area">{area(totalArea)} m² total</span>
|
||||
</div>
|
||||
<nav className="page-jumps" aria-label="Jump to section">
|
||||
<button onClick={() => jump('measurements')}>
|
||||
Room <ArrowDown size={13} />
|
||||
</button>
|
||||
<button onClick={() => jump('materials')}>
|
||||
Materials <ArrowDown size={13} />
|
||||
</button>
|
||||
<button onClick={() => jump('drawing')}>
|
||||
Drawing <ArrowDown size={13} />
|
||||
</button>
|
||||
<button onClick={() => jump('cut-plan')}>
|
||||
Cuts <ArrowDown size={13} />
|
||||
</button>
|
||||
</nav>
|
||||
<div className="workspace">
|
||||
<div className="input-columns">
|
||||
<section
|
||||
className="editor-panel room-editor"
|
||||
id="measurements"
|
||||
aria-label="Room measurements"
|
||||
<div className="planning-panels">
|
||||
<PlannerPanel
|
||||
section="measurements"
|
||||
title="Room measurements"
|
||||
open={!view.collapsedSections.includes('measurements')}
|
||||
toggle={() => togglePanel('measurements')}
|
||||
>
|
||||
<OutlineEditor
|
||||
room={room}
|
||||
geometry={geometry}
|
||||
tolerance={project.tolerance}
|
||||
changeRoom={changeRoom}
|
||||
changeProject={changeProject}
|
||||
remove={removeRoom}
|
||||
canRemove={project.rooms.length > 1}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
className="editor-panel materials-editor"
|
||||
id="materials"
|
||||
aria-label="Materials"
|
||||
<section
|
||||
className="editor-panel room-editor"
|
||||
id="measurements"
|
||||
aria-label="Room measurements"
|
||||
>
|
||||
<OutlineEditor
|
||||
key={room.id}
|
||||
room={room}
|
||||
geometry={geometry}
|
||||
tolerance={project.tolerance}
|
||||
changeRoom={changeRoom}
|
||||
changeProject={changeProject}
|
||||
remove={removeRoom}
|
||||
canRemove={project.rooms.length > 1}
|
||||
/>
|
||||
</section>
|
||||
<Issues issues={roomErrors} />
|
||||
</PlannerPanel>
|
||||
<PlannerPanel
|
||||
section="materials"
|
||||
title="Materials"
|
||||
open={!view.collapsedSections.includes('materials')}
|
||||
toggle={() => togglePanel('materials')}
|
||||
>
|
||||
<MaterialsEditor
|
||||
project={project}
|
||||
room={room}
|
||||
changeRoom={changeRoom}
|
||||
changeProject={changeProject}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
className="editor-panel materials-editor"
|
||||
id="materials"
|
||||
aria-label="Materials"
|
||||
>
|
||||
<MaterialsEditor
|
||||
project={project}
|
||||
room={room}
|
||||
changeRoom={changeRoom}
|
||||
changeProject={changeProject}
|
||||
/>
|
||||
</section>
|
||||
<section className="purchase-summary" aria-label="Materials to buy">
|
||||
<h2>
|
||||
Materials to buy{' '}
|
||||
<small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
|
||||
</h2>
|
||||
<div className="purchase-item flooring">
|
||||
<span className="material-key floor-key" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
|
||||
</strong>
|
||||
<p>
|
||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm ·{' '}
|
||||
{wholeBoards} used whole
|
||||
</p>
|
||||
</div>
|
||||
<span className="waste-stat">
|
||||
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="purchase-item skirting">
|
||||
<span className="material-key skirt-key" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
||||
</strong>
|
||||
<p>
|
||||
{[...new Set(skirting.stocks.map((s) => s.length))]
|
||||
.sort((a, b) => a - b)
|
||||
.map(
|
||||
(l) =>
|
||||
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
||||
)
|
||||
.join(' + ') || 'Check settings'}{' '}
|
||||
· {wholeSkirting} used whole
|
||||
</p>
|
||||
</div>
|
||||
<span className="waste-stat">
|
||||
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</PlannerPanel>
|
||||
<PlannerPanel
|
||||
section="cuts"
|
||||
title="Cut plan"
|
||||
open={!view.collapsedSections.includes('cuts')}
|
||||
toggle={() => togglePanel('cuts')}
|
||||
>
|
||||
<div id="cut-plan">
|
||||
<CutList
|
||||
project={project}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
select={select}
|
||||
completedBoards={project.completedBoards}
|
||||
toggleBoard={toggleBoard}
|
||||
search={view.search}
|
||||
setSearch={setSearch}
|
||||
locateBoard={setSearch}
|
||||
locatePiece={locatePiece}
|
||||
/>
|
||||
</div>
|
||||
</PlannerPanel>
|
||||
</div>
|
||||
<div className="drawing-column" id="drawing">
|
||||
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
|
||||
<FloorPlan
|
||||
room={room}
|
||||
geometry={geometry}
|
||||
|
|
@ -200,61 +360,48 @@ export default function App() {
|
|||
skirting={skirting}
|
||||
selectedPiece={view.selectedPiece}
|
||||
select={(id) => select(id)}
|
||||
completedBoards={project.completedBoards}
|
||||
highlightedPieces={matches.pieceIds}
|
||||
searchActive={!!view.search.trim()}
|
||||
/>
|
||||
<section className="purchase-summary" aria-label="Materials to buy">
|
||||
<h2>
|
||||
Materials to buy <small>{project.rooms.length > 1 ? 'All rooms' : room.name}</small>
|
||||
</h2>
|
||||
<div className="purchase-item flooring">
|
||||
<span className="material-key floor-key" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{floorErrors.length ? '—' : flooring.boardCount} <span>floorboards</span>
|
||||
</strong>
|
||||
<p>
|
||||
{mm(project.flooring.length)} × {mm(project.flooring.width)} mm · {wholeBoards}{' '}
|
||||
used whole
|
||||
</p>
|
||||
</div>
|
||||
<span className="waste-stat">
|
||||
{floorErrors.length ? '—' : mm(flooring.wastePercent)}%<small>waste</small>
|
||||
</span>
|
||||
{view.search.trim() && (
|
||||
<div className="plan-search-status" role="status">
|
||||
<p>
|
||||
{matches.pieceIds.length
|
||||
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'} found for “${searchLabel}”`
|
||||
: `No boards or cuts match “${searchLabel}”`}
|
||||
</p>
|
||||
{matches.roomIds.length > 1 && (
|
||||
<div className="matching-rooms">
|
||||
{project.rooms
|
||||
.filter((r) => matches.roomIds.includes(r.id))
|
||||
.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
aria-pressed={r.id === room.id}
|
||||
onClick={() =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: { ...s.view, roomId: r.id, selectedPiece: null },
|
||||
}))
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="purchase-item skirting">
|
||||
<span className="material-key skirt-key" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{skirtErrors.length ? '—' : skirting.stockCount} <span>skirting lengths</span>
|
||||
</strong>
|
||||
<p>
|
||||
{[...new Set(skirting.stocks.map((s) => s.length))]
|
||||
.sort((a, b) => a - b)
|
||||
.map(
|
||||
(l) =>
|
||||
`${skirting.stocks.filter((s) => s.length === l).length} × ${mm(l)} mm`,
|
||||
)
|
||||
.join(' + ') || 'Check settings'}{' '}
|
||||
· {wholeSkirting} used whole
|
||||
</p>
|
||||
</div>
|
||||
<span className="waste-stat">
|
||||
{skirtErrors.length ? '—' : mm(skirting.waste / 1000)} m<small>waste</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
<Issues issues={roomErrors} />
|
||||
</div>
|
||||
</div>
|
||||
<div id="cut-plan">
|
||||
<CutList
|
||||
project={project}
|
||||
flooring={flooring}
|
||||
skirting={skirting}
|
||||
selectedPiece={view.selectedPiece}
|
||||
select={select}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<CutDialog
|
||||
floor={flooringCuts(flooring).find((piece) => piece.id === view.selectedPiece)}
|
||||
skirt={skirtingCuts(skirting).find((piece) => piece.id === view.selectedPiece)}
|
||||
project={project}
|
||||
close={() => select(null)}
|
||||
/>
|
||||
<PrintPlan
|
||||
project={project}
|
||||
geometries={geometries}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
73
src/lib/board-tracking.ts
Normal file
73
src/lib/board-tracking.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||
import { flooringCuts, skirtingCuts } from './cut-list';
|
||||
|
||||
export interface BoardMatches {
|
||||
pieceIds: string[];
|
||||
roomIds: string[];
|
||||
boardKeys: string[];
|
||||
}
|
||||
|
||||
/** Codes match exactly; ordinary text searches room names. Qualified keys identify source boards. */
|
||||
export function findBoardMatches(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
query: string,
|
||||
): BoardMatches {
|
||||
const text = query.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
if (!text) return { pieceIds: [], roomIds: [], boardKeys: [] };
|
||||
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/.exec(text);
|
||||
const codes = text.split(/\s*[+,]\s*/);
|
||||
const isCode = codes.every((code) => /^[a-z]+\d+$/.test(code));
|
||||
const matchingRooms = new Set(
|
||||
project.rooms
|
||||
.filter((room) => room.name.toLowerCase().replace(/\s+/g, ' ').includes(text))
|
||||
.map((room) => room.id),
|
||||
);
|
||||
const pieceIds: string[] = [];
|
||||
const roomIds = new Set<string>();
|
||||
const boardKeys = new Set<string>();
|
||||
const materials = [
|
||||
{ key: 'floor', pieces: flooring.pieces },
|
||||
{ key: 'skirting', pieces: skirting.pieces },
|
||||
];
|
||||
for (const material of materials) {
|
||||
for (const piece of material.pieces) {
|
||||
const matches = qualified
|
||||
? material.key === qualified[1] && piece.stockId.toLowerCase() === qualified[2]
|
||||
: isCode
|
||||
? codes.includes(piece.id.toLowerCase()) ||
|
||||
(codes.length === 1 && codes.includes(piece.stockId.toLowerCase()))
|
||||
: matchingRooms.has(piece.roomId);
|
||||
if (!matches) continue;
|
||||
pieceIds.push(piece.id);
|
||||
roomIds.add(piece.roomId);
|
||||
boardKeys.add(`${material.key}:${piece.stockId}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
pieceIds,
|
||||
roomIds: project.rooms.filter((room) => roomIds.has(room.id)).map((room) => room.id),
|
||||
boardKeys: [...boardKeys],
|
||||
};
|
||||
}
|
||||
|
||||
/** Source IDs are bookkeeping; show the cut codes when locating a whole source board. */
|
||||
export function boardSearchLabel(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
query: string,
|
||||
): string {
|
||||
const qualified = /^(floor|skirting)\s*:\s*([a-z]+\d+)$/i.exec(query.trim());
|
||||
if (!qualified) return query;
|
||||
const matches = new Set(findBoardMatches(project, flooring, skirting, query).pieceIds);
|
||||
const codes = [...flooringCuts(flooring), ...skirtingCuts(skirting)]
|
||||
.filter((piece) => matches.has(piece.id))
|
||||
.map((piece) => piece.id);
|
||||
return codes.length
|
||||
? codes.join(' + ')
|
||||
: qualified[1].toLowerCase() === 'floor'
|
||||
? 'Uncut floorboard'
|
||||
: 'Uncut skirting board';
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||
import { cutMm, degree } from './format';
|
||||
|
||||
export interface PreviewBox {
|
||||
x: number;
|
||||
|
|
@ -6,8 +7,6 @@ export interface PreviewBox {
|
|||
width: number;
|
||||
height: number;
|
||||
}
|
||||
export const exactMm = (value: number) =>
|
||||
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
|
||||
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
|
||||
|
||||
export interface EdgeDimension {
|
||||
|
|
@ -73,12 +72,12 @@ export function dimensionedOutline(points: Point[], edges?: number[]) {
|
|||
};
|
||||
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
||||
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
||||
const text = exactMm(edge.length);
|
||||
const text = cutMm(edge.length);
|
||||
const angle =
|
||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||
: undefined;
|
||||
const angleText = angle === undefined ? '' : `${exactMm(angle)}°`;
|
||||
const angleText = angle === undefined ? '' : degree(angle);
|
||||
const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64),
|
||||
height = font * (angle === undefined ? 1.3 : 2.5);
|
||||
const outside = Math.max(
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function defaultProject(): Project {
|
|||
name: 'My flooring project',
|
||||
rooms: [rectangle()],
|
||||
tolerance: 10,
|
||||
completedBoards: [],
|
||||
flooring: {
|
||||
length: 1200,
|
||||
width: 190,
|
||||
|
|
@ -54,7 +55,7 @@ export function defaultProject(): Project {
|
|||
export function defaultState(): UrlState {
|
||||
return {
|
||||
project: defaultProject(),
|
||||
view: { roomId: 'room-1', selectedPiece: null },
|
||||
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
|
||||
};
|
||||
}
|
||||
export function nextId(prefix: string, existing: string[]): string {
|
||||
|
|
|
|||
|
|
@ -56,3 +56,19 @@ export function dimensionLabels(
|
|||
return label;
|
||||
});
|
||||
}
|
||||
|
||||
/** The drawing must include extension-line endpoints as well as the text itself. */
|
||||
export function wallDimensionBox(edge: WallEdge, label: DimensionLabel, span: number): LabelBox {
|
||||
const ends = [edge.start, edge.end].flatMap((point) =>
|
||||
[label.offset - span * 0.012, label.offset + span * 0.012].map((offset) => ({
|
||||
x: point.x + label.normal.x * offset,
|
||||
y: point.y + label.normal.y * offset,
|
||||
})),
|
||||
);
|
||||
return {
|
||||
minX: Math.min(label.box.minX, ...ends.map((point) => point.x)),
|
||||
maxX: Math.max(label.box.maxX, ...ends.map((point) => point.x)),
|
||||
minY: Math.min(label.box.minY, ...ends.map((point) => point.y)),
|
||||
maxY: Math.max(label.box.maxY, ...ends.map((point) => point.y)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import type { FlooringPlan, Project, SkirtingPlan } from './types';
|
||||
import { flooringInstructions } from './cut-instructions';
|
||||
import {
|
||||
groupFlooringCuts,
|
||||
groupSkirtingCuts,
|
||||
uncutFlooring,
|
||||
uncutSkirting,
|
||||
readyOffcutInstructions,
|
||||
} from './cut-list';
|
||||
|
||||
const cell = (value: string | number) => {
|
||||
const str = String(value);
|
||||
return `"${(/^[=+@\-]/.test(str) ? "'" : '') + str.replaceAll('"', '""')}"`;
|
||||
};
|
||||
|
||||
export function cutListCsv(
|
||||
project: Project,
|
||||
flooring: FlooringPlan,
|
||||
skirting: SkirtingPlan,
|
||||
): string {
|
||||
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
|
||||
const floorCuts = groupFlooringCuts(flooring).flat(),
|
||||
skirtCuts = groupSkirtingCuts(skirting).flat();
|
||||
const wholeFloor = uncutFlooring(flooring, project.flooring);
|
||||
const rows: (string | number)[][] = [
|
||||
[
|
||||
'Material',
|
||||
'Room',
|
||||
'Piece',
|
||||
'Stock',
|
||||
'Length mm',
|
||||
'Width mm',
|
||||
'Left mitre deg',
|
||||
'Right mitre deg',
|
||||
'Quantity',
|
||||
'Notes',
|
||||
'Template x:y mm',
|
||||
],
|
||||
[
|
||||
'Flooring total',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.flooring.length,
|
||||
project.flooring.width,
|
||||
'',
|
||||
'',
|
||||
flooring.boardCount,
|
||||
`Boards to buy; ${flooring.pieces.length} installed pieces; ${flooring.cuts} saw cuts`,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'Flooring uncut boards',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.flooring.length,
|
||||
project.flooring.width,
|
||||
'',
|
||||
'',
|
||||
wholeFloor.wholeBoards,
|
||||
'Full boards laid without cuts',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'Skirting total',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
project.skirting.thickness,
|
||||
'',
|
||||
'',
|
||||
skirting.stockCount,
|
||||
`Lengths to buy; ${skirting.pieces.length} installed pieces; ${skirting.cuts} saw cuts`,
|
||||
'',
|
||||
],
|
||||
];
|
||||
if (wholeFloor.readyOffcuts)
|
||||
rows.push([
|
||||
'Flooring ready offcuts',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
wholeFloor.readyOffcuts,
|
||||
'Already sized; no further cuts',
|
||||
'',
|
||||
]);
|
||||
for (const group of uncutSkirting(skirting)) {
|
||||
rows.push([
|
||||
'Skirting uncut lengths',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
group.length,
|
||||
project.skirting.thickness,
|
||||
'',
|
||||
'',
|
||||
group.count,
|
||||
'Full stock lengths installed without cuts',
|
||||
'',
|
||||
]);
|
||||
}
|
||||
for (const piece of floorCuts) {
|
||||
rows.push([
|
||||
'Flooring',
|
||||
roomName(piece.roomId),
|
||||
piece.id,
|
||||
piece.stockId,
|
||||
piece.length,
|
||||
piece.width,
|
||||
'',
|
||||
'',
|
||||
1,
|
||||
`${piece.reused ? 'Reused offcut; ' : ''}${piece.cut ? `${piece.shaped ? 'Shaped cut' : 'Straight cut'}; ${flooringInstructions(piece)}` : readyOffcutInstructions(piece)}`,
|
||||
piece.template.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | '),
|
||||
]);
|
||||
}
|
||||
for (const piece of skirtCuts) {
|
||||
rows.push([
|
||||
'Skirting',
|
||||
roomName(piece.roomId),
|
||||
piece.id,
|
||||
piece.stockId,
|
||||
piece.length,
|
||||
project.skirting.thickness,
|
||||
piece.leftMitre,
|
||||
piece.rightMitre,
|
||||
1,
|
||||
`Back length ${piece.backLength.toFixed(1)} mm; left ${piece.leftType}; right ${piece.rightType}; signed front-to-back bevels ${piece.leftBevel ?? piece.leftMitre} / ${piece.rightBevel ?? piece.rightMitre} deg; template back y=0, front y=thickness`,
|
||||
piece.template?.map((point) => `${point.x.toFixed(1)}:${point.y.toFixed(1)}`).join(' | ') ??
|
||||
'',
|
||||
]);
|
||||
}
|
||||
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(project: Project, flooring: FlooringPlan, skirting: SkirtingPlan) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([cutListCsv(project, flooring, skirting)], { type: 'text/csv;charset=utf-8;' }),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'flooring-cut-plan.csv';
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ function intersect(paths: Point[][], rectangle: Bounds): Point[][] {
|
|||
}
|
||||
|
||||
/** Only perimeter rows may be ripped: interior rows must retain both factory long edges. */
|
||||
function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
||||
function rowWidthOptions(height: number, s: FlooringSettings, cornerOffsets: number[] = []): number[][] {
|
||||
const count = Math.ceil((height - EPSILON) / s.width);
|
||||
if (count <= 0 || count > 500) return [];
|
||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||
|
|
@ -139,9 +139,23 @@ function rowWidthOptions(height: number, s: FlooringSettings): number[][] {
|
|||
add(last);
|
||||
add(edgeTotal / 2);
|
||||
}
|
||||
// Place a long-edge seam on an inward corner when both perimeter rows still fit.
|
||||
// This avoids thin toes made solely by a row crossing just beyond that corner.
|
||||
for (const offset of cornerOffsets.slice(0, 8)) {
|
||||
const first = phaseOf(offset, s.width);
|
||||
add(close(first, 0) ? s.width : first);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function inwardCorners(paths: Point[][]): Point[] {
|
||||
return paths.flatMap(path => path.filter((point, index) => {
|
||||
const previous = path[(index + path.length - 1) % path.length];
|
||||
const next = path[(index + 1) % path.length];
|
||||
return (point.x - previous.x) * (next.y - point.y) - (point.y - previous.y) * (next.x - point.x) < -EPSILON;
|
||||
}));
|
||||
}
|
||||
|
||||
function hasVerticalEdge(path: Point[], x: number): boolean {
|
||||
return path.some(
|
||||
(p, i) =>
|
||||
|
|
@ -497,7 +511,8 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
continue;
|
||||
}
|
||||
const roomBounds = bounds(paths.flat());
|
||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s);
|
||||
const widthOptions = rowWidthOptions(roomBounds.maxY - roomBounds.minY, s,
|
||||
inwardCorners(paths).map(point => point.y - roomBounds.minY));
|
||||
const estimatedBoards = paths.reduce((sum, path) => sum + area(path), 0) / (s.length * s.width);
|
||||
if (estimatedBoards > MAX_BOARDS || !widthOptions.length) {
|
||||
issues.push({
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ export const mm = (value: number) =>
|
|||
Number.isFinite(value)
|
||||
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||
: '—';
|
||||
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||
export const cutMm = (value: number) => mm(cutLength(value));
|
||||
export const area = (value: number) =>
|
||||
new Intl.NumberFormat('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
|
||||
value / 1e6,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||
import { isSkirtingCut } from './cut-list';
|
||||
import { cutLength } from './format';
|
||||
import type {
|
||||
Issue,
|
||||
Point,
|
||||
|
|
@ -86,14 +87,19 @@ function endCuts(piece: SkirtingPiece) {
|
|||
return Number(Math.abs(piece.leftMitre) > EPS) + Number(Math.abs(piece.rightMitre) > EPS);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
|
||||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
return endCuts(piece) + Number(Math.abs(available - blankLength(piece, available)) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
||||
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
||||
return Math.min(available, piece.length + (Math.abs(available - piece.length) <= EPS ? 0 : kerf));
|
||||
const length = blankLength(piece, available);
|
||||
return Math.min(available, length + (Math.abs(available - length) <= EPS ? 0 : kerf));
|
||||
}
|
||||
|
||||
function makePiece(
|
||||
|
|
@ -141,7 +147,7 @@ function shortestEdge(piece: SkirtingPiece) {
|
|||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
// The final saw pass may extend beyond the stock's tail.
|
||||
return piece.length <= available + EPS;
|
||||
return blankLength(piece, available) <= available + EPS;
|
||||
}
|
||||
|
||||
/** Zero means that no number of pieces can satisfy the end allowances. */
|
||||
|
|
|
|||
|
|
@ -44,10 +44,15 @@ export interface Project {
|
|||
flooring: FlooringSettings;
|
||||
skirting: SkirtingSettings;
|
||||
tolerance: number;
|
||||
/** Explicit progress marks for purchased boards, e.g. floor:B4 or skirting:S6. */
|
||||
completedBoards: string[];
|
||||
}
|
||||
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
|
||||
export interface ViewState {
|
||||
roomId: string;
|
||||
selectedPiece: string | null;
|
||||
search: string;
|
||||
collapsedSections: PlannerSection[];
|
||||
}
|
||||
export interface UrlState {
|
||||
project: Project;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import LZString from 'lz-string';
|
||||
import { defaultState } from './defaults';
|
||||
import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } from './types';
|
||||
import type {
|
||||
Door,
|
||||
FlooringSettings,
|
||||
PlannerSection,
|
||||
Room,
|
||||
SkirtingSettings,
|
||||
UrlState,
|
||||
Wall,
|
||||
} from './types';
|
||||
|
||||
/** Resource limits apply to links as well as edits made inside the app. */
|
||||
export const URL_LIMITS = {
|
||||
|
|
@ -13,8 +21,12 @@ export const URL_LIMITS = {
|
|||
stockLengths: 30,
|
||||
identifierLength: 120,
|
||||
nameLength: 200,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${field} must be an object.`);
|
||||
|
|
@ -71,6 +83,34 @@ function uniqueIds<T extends { id: string }>(items: T[], field: string): T[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
function uniqueValues<T extends string>(items: T[], field: string): T[] {
|
||||
if (new Set(items).size !== items.length) throw new Error(`${field} contains duplicate entries.`);
|
||||
return items.sort();
|
||||
}
|
||||
|
||||
function completedBoards(value: unknown): string[] {
|
||||
const entries = array(
|
||||
value === undefined ? [] : value,
|
||||
'Completed boards',
|
||||
URL_LIMITS.completedBoards,
|
||||
).map((value) => {
|
||||
const key = string(value, 'Completed board');
|
||||
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||
return key;
|
||||
});
|
||||
return uniqueValues(entries, 'Completed boards');
|
||||
}
|
||||
|
||||
function collapsedSections(value: unknown): PlannerSection[] {
|
||||
return uniqueValues(
|
||||
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
choice(value, SECTIONS, 'Collapsed section'),
|
||||
),
|
||||
'Collapsed sections',
|
||||
);
|
||||
}
|
||||
|
||||
function door(value: unknown): Door {
|
||||
const data = object(value, 'Door');
|
||||
return {
|
||||
|
|
@ -151,11 +191,18 @@ function parseState(value: unknown): UrlState {
|
|||
flooring: flooring(project.flooring),
|
||||
skirting: skirting(project.skirting),
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: completedBoards(project.completedBoards),
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
selectedPiece:
|
||||
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||
search: string(
|
||||
view.search === undefined ? '' : view.search,
|
||||
'Cut search',
|
||||
URL_LIMITS.searchLength,
|
||||
),
|
||||
collapsedSections: collapsedSections(view.collapsedSections),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -193,9 +240,12 @@ export function encodeState(state: UrlState): string {
|
|||
});
|
||||
});
|
||||
});
|
||||
for (const board of project.completedBoards) put('done', board);
|
||||
put('selectedRoom', view.roomId);
|
||||
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
||||
const query = `?${params}`;
|
||||
if (view.search) put('search', view.search);
|
||||
for (const section of view.collapsedSections) put('collapsed', section);
|
||||
const query = `?${params}`.replaceAll('%3A', ':');
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||
return query;
|
||||
|
|
@ -289,6 +339,7 @@ function parseQuery(query: string): UrlState {
|
|||
version: numeric('version', 1),
|
||||
name: text('name', defaults.project.name),
|
||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||
completedBoards: params.getAll('done'),
|
||||
rooms,
|
||||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
|
|
@ -308,6 +359,8 @@ function parseQuery(query: string): UrlState {
|
|||
view: {
|
||||
roomId: text('selectedRoom', rooms[0].id),
|
||||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||
search: text('search', ''),
|
||||
collapsedSections: params.getAll('collapsed'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ export function useProject() {
|
|||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||
const canonical = decodeState(query).state;
|
||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||
else {
|
||||
// Progress changes should not rerun geometry and stock allocation.
|
||||
if (next.project.rooms === current.state.project.rooms)
|
||||
canonical.project.rooms = current.state.project.rooms;
|
||||
if (next.project.flooring === current.state.project.flooring)
|
||||
canonical.project.flooring = current.state.project.flooring;
|
||||
if (next.project.skirting === current.state.project.skirting)
|
||||
canonical.project.skirting = current.state.project.skirting;
|
||||
}
|
||||
return { state: canonical, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
33
src/lib/wall-editing.ts
Normal file
33
src/lib/wall-editing.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { nextId } from './defaults';
|
||||
import type { Room } from './types';
|
||||
|
||||
/** Inserting a segment never renumbers walls or moves their attached doors. */
|
||||
export function insertWallAfter(room: Room, wallId: string): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || room.walls.length >= 100) return room;
|
||||
const wall = {
|
||||
id: nextId(
|
||||
`${room.id}-w`,
|
||||
room.walls.map((wall) => wall.id),
|
||||
),
|
||||
length: 1000,
|
||||
turn: 90,
|
||||
doors: [],
|
||||
};
|
||||
return {
|
||||
...room,
|
||||
walls: [...room.walls.slice(0, index + 1), wall, ...room.walls.slice(index + 1)],
|
||||
};
|
||||
}
|
||||
|
||||
/** The destination is the wall's final zero-based position, after removing it from the list. */
|
||||
export function moveWall(room: Room, wallId: string, destination: number): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || !Number.isFinite(destination)) return room;
|
||||
const target = Math.max(0, Math.min(room.walls.length - 1, Math.trunc(destination)));
|
||||
if (target === index) return room;
|
||||
const walls = [...room.walls];
|
||||
const [wall] = walls.splice(index, 1);
|
||||
walls.splice(target, 0, wall);
|
||||
return { ...room, walls };
|
||||
}
|
||||
451
src/styles.css
451
src/styles.css
|
|
@ -204,29 +204,10 @@ small {
|
|||
font: 12px var(--mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.page-jumps {
|
||||
display: none;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(570px, 1fr) minmax(450px, 1fr);
|
||||
align-items: start;
|
||||
gap: 30px;
|
||||
}
|
||||
.input-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 25px;
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-panel {
|
||||
min-width: 0;
|
||||
scroll-margin-top: 65px;
|
||||
}
|
||||
.materials-editor {
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: 25px;
|
||||
}
|
||||
.editor-heading {
|
||||
min-height: 64px;
|
||||
margin-bottom: 14px;
|
||||
|
|
@ -591,11 +572,6 @@ summary span {
|
|||
.stock-input + .text-button {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.drawing-column {
|
||||
min-width: 0;
|
||||
position: static;
|
||||
scroll-margin-top: 65px;
|
||||
}
|
||||
.drawing-card {
|
||||
border: 1px solid var(--ink);
|
||||
background: white;
|
||||
|
|
@ -846,10 +822,6 @@ summary span {
|
|||
padding-left: max(32px, calc((100vw - 1656px) / 2));
|
||||
padding-right: max(32px, calc((100vw - 1656px) / 2));
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: minmax(680px, 1fr) minmax(0, 1fr);
|
||||
gap: 35px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1199px) {
|
||||
.app-main {
|
||||
|
|
@ -858,29 +830,10 @@ summary span {
|
|||
.app-header {
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
.input-columns {
|
||||
gap: 30px;
|
||||
}
|
||||
.materials-editor {
|
||||
padding-left: 30px;
|
||||
}
|
||||
.drawing-column {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 25px;
|
||||
}
|
||||
.purchase-summary {
|
||||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
.drawing-column > .issues {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.cut-list-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 22px;
|
||||
|
|
@ -947,41 +900,6 @@ summary span {
|
|||
padding: 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.page-jumps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: #fffdf7;
|
||||
padding: 6px 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
.page-jumps button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.page-jumps button + button {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.workspace,
|
||||
.input-columns,
|
||||
.drawing-column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
.materials-editor {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.editor-heading {
|
||||
min-height: 58px;
|
||||
margin-bottom: 15px;
|
||||
|
|
@ -1248,6 +1166,7 @@ summary span {
|
|||
}
|
||||
.stock-cut-group h4 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
|
@ -1262,18 +1181,39 @@ summary span {
|
|||
text-align: right;
|
||||
}
|
||||
.board-cuts {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.board-cut {
|
||||
flex: 1 1 170px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e4e9;
|
||||
}
|
||||
.board-cut + .board-cut {
|
||||
border-top: 1px solid #e0e4e9;
|
||||
.board-cut:only-child {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.board-cuts {
|
||||
display: block;
|
||||
}
|
||||
.board-cut + .board-cut {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.board-cut {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.cut-size {
|
||||
font-size: 14px;
|
||||
|
|
@ -1386,9 +1326,13 @@ summary span {
|
|||
color: #17263f;
|
||||
}
|
||||
.app-header,
|
||||
.app-main {
|
||||
.app-main,
|
||||
.cut-dialog {
|
||||
display: none !important;
|
||||
}
|
||||
.cut-dialog::backdrop {
|
||||
display: none;
|
||||
}
|
||||
.print-plan {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -1504,3 +1448,332 @@ summary span {
|
|||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(370px, 0.85fr) minmax(0, 1.15fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
.planning-panels {
|
||||
min-width: 0;
|
||||
}
|
||||
.planner-panel {
|
||||
border-top: 2px solid var(--ink);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.panel-toggle-heading {
|
||||
margin: 0;
|
||||
}
|
||||
.panel-toggle {
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
.panel-toggle > span:last-child {
|
||||
font-size: 25px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.panel-content {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.panel-content[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.planner-panel .editor-heading {
|
||||
display: none;
|
||||
}
|
||||
.planner-panel .materials-editor {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.planner-panel .cut-list-card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.planner-panel .cut-list-header {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.planner-panel .cut-list-header h2 {
|
||||
display: none;
|
||||
}
|
||||
.planner-panel .cut-list-header .helper {
|
||||
font-size: 12px;
|
||||
}
|
||||
.planner-panel #cut-plan {
|
||||
margin: 0;
|
||||
}
|
||||
.planner-panel .purchase-summary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.drawing-column {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
min-width: 0;
|
||||
align-self: start;
|
||||
background: #fffdf7;
|
||||
}
|
||||
.drawing-column .drawing-viewport {
|
||||
height: clamp(180px, calc(100dvh - 330px), 780px);
|
||||
}
|
||||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(160px, calc(100dvh - 390px), 720px);
|
||||
}
|
||||
.drawing-column .room-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.drawing-column .drawing-header h2 small {
|
||||
display: block;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.plan-search-status {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--ink);
|
||||
border-top: 0;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
.matching-rooms {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.matching-rooms button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.matching-rooms button[aria-pressed='true'] {
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
}
|
||||
.search-muted {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.is-highlighted > polygon {
|
||||
fill: #ffe69b;
|
||||
stroke: #17263f;
|
||||
stroke-width: 3px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.is-highlighted.is-done > polygon {
|
||||
fill: #c4e6df;
|
||||
}
|
||||
.is-done .cut-preview-code {
|
||||
color: #20594f;
|
||||
}
|
||||
.is-done .cut-preview-button {
|
||||
background: #eaf4f1;
|
||||
border-color: #27655d;
|
||||
}
|
||||
.is-done .cut-location {
|
||||
color: #345c55;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.app-main {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.planning-panels {
|
||||
width: 100%;
|
||||
}
|
||||
.drawing-column {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.drawing-column .drawing-viewport,
|
||||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(150px, 25dvh, 240px);
|
||||
}
|
||||
.drawing-column .drawing-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.drawing-column .drawing-header h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.drawing-column .drawing-header h2 small {
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.drawing-column .drawing-legend {
|
||||
padding: 0 12px 7px;
|
||||
font-size: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
.drawing-column .drawing-help {
|
||||
display: none;
|
||||
}
|
||||
.plan-search-status {
|
||||
padding: 7px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.panel-toggle {
|
||||
font-size: 18px;
|
||||
min-height: 50px;
|
||||
}
|
||||
.planner-panel {
|
||||
scroll-margin-top: 330px;
|
||||
}
|
||||
.board-cuts {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.board-cut {
|
||||
gap: 12px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
summary {
|
||||
scroll-margin-top: 350px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.drawing-column {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.wall-head,
|
||||
.wall-row {
|
||||
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||
}
|
||||
.wall-head > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.wall-row {
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
}
|
||||
.wall-actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.wall-actions .icon-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wall-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.wall-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.wall-row.drag-over {
|
||||
border-top: 3px solid var(--blue);
|
||||
background: #e9f0fc;
|
||||
}
|
||||
.wall-row input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cut-search {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.cut-search label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cut-search .search-results {
|
||||
font-size: 12px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.board-group-header {
|
||||
background: #f1f3f6;
|
||||
}
|
||||
.board-group-header h4 {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.board-group-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px 8px;
|
||||
}
|
||||
.board-done-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-done-control input {
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
accent-color: #27655d;
|
||||
}
|
||||
.board-locate {
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.cut-locate {
|
||||
min-height: 40px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
color: var(--blue);
|
||||
}
|
||||
.board-cut-info {
|
||||
min-width: 0;
|
||||
}
|
||||
.board-cut.search-match {
|
||||
box-shadow: inset 3px 0 #17263f;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.stock-cut-group.is-done .board-group-header {
|
||||
background: #e4efeb;
|
||||
}
|
||||
.stock-cut-group.is-done h4 {
|
||||
background: transparent;
|
||||
}
|
||||
.uncut-board-note {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cut-search-control {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.cut-search-control input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.clear-search {
|
||||
flex-shrink: 0;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.completion-mark {
|
||||
pointer-events: none;
|
||||
paint-order: stroke;
|
||||
stroke: white;
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue