259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
import { useMemo, useRef } from 'react';
|
||
import { Copy, Plus, Printer, ArrowDown } 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 { OutlineEditor } from './components/OutlineEditor';
|
||
import { MaterialsEditor } from './components/MaterialsEditor';
|
||
import { FloorPlan } from './components/FloorPlan';
|
||
import { CutList } from './components/CutList';
|
||
import { Issues } from './components/Fields';
|
||
|
||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: 'start' });
|
||
|
||
export default function App() {
|
||
const { state, error, update } = useProject();
|
||
const { project, view } = state;
|
||
const copyStatus = useRef<HTMLOutputElement>(null);
|
||
const room = project.rooms.find((r) => r.id === view.roomId) ?? project.rooms[0];
|
||
const geometries = useMemo(
|
||
() => project.rooms.map((r) => buildGeometry(r, project.tolerance)),
|
||
[project.rooms, project.tolerance],
|
||
);
|
||
const geometry = geometries[project.rooms.indexOf(room)];
|
||
const flooring = useMemo(
|
||
() => planFlooring(project.rooms, project.flooring, project.tolerance),
|
||
[project.rooms, project.flooring, project.tolerance],
|
||
);
|
||
const skirting = useMemo(
|
||
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||
[project.rooms, project.skirting, project.tolerance],
|
||
);
|
||
const floorErrors = flooring.issues.filter((i) => i.severity === 'error');
|
||
const skirtErrors = skirting.issues.filter((i) => i.severity === 'error');
|
||
const roomErrors = [...floorErrors, ...skirtErrors].filter(
|
||
(issue) => !issue.roomId || issue.roomId === room.id,
|
||
);
|
||
const totalArea = geometries.reduce((sum, g) => sum + (g.valid ? g.area : 0), 0);
|
||
const wholeBoards = flooring.pieces.filter(
|
||
(p) =>
|
||
!p.cut &&
|
||
Math.abs(p.length - project.flooring.length) < 0.01 &&
|
||
Math.abs(p.width - project.flooring.width) < 0.01,
|
||
).length;
|
||
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 } }));
|
||
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) =>
|
||
update((s) => ({
|
||
...s,
|
||
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
||
}));
|
||
const addRoom = () =>
|
||
update((s) => {
|
||
const id = nextId(
|
||
'room',
|
||
s.project.rooms.map((r) => r.id),
|
||
);
|
||
return {
|
||
...s,
|
||
project: {
|
||
...s.project,
|
||
rooms: [...s.project.rooms, rectangle(id, `Room ${s.project.rooms.length + 1}`)],
|
||
},
|
||
view: { ...s.view, roomId: id, selectedPiece: null },
|
||
};
|
||
}, true);
|
||
const removeRoom = () =>
|
||
update((s) => {
|
||
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 },
|
||
};
|
||
}, true);
|
||
const copyLink = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(window.location.href);
|
||
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
|
||
} catch {
|
||
if (copyStatus.current)
|
||
copyStatus.current.textContent = 'Copy the address from your browser.';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<header className="app-header">
|
||
<div className="app-title">
|
||
<span className="square-mark" aria-hidden="true" />
|
||
<h1>Floor planner</h1>
|
||
</div>
|
||
<div className="header-actions">
|
||
<button className="button" onClick={copyLink}>
|
||
<Copy size={17} />
|
||
Copy link
|
||
</button>
|
||
<button
|
||
className="button"
|
||
onClick={() => window.print()}
|
||
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
|
||
>
|
||
<Printer size={17} />
|
||
<span>Print</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<main className="app-main">
|
||
<output ref={copyStatus} className="copy-status" aria-live="polite" />
|
||
{error && (
|
||
<p className="url-error" role="alert">
|
||
{error}
|
||
</p>
|
||
)}
|
||
<div className="project-bar">
|
||
<div className="room-tabs" aria-label="Rooms">
|
||
{project.rooms.map((r, index) => (
|
||
<button
|
||
key={r.id}
|
||
className={`room-tab ${r.id === room.id ? 'active' : ''}`}
|
||
aria-pressed={r.id === room.id}
|
||
onClick={() =>
|
||
update(
|
||
(s) => ({ ...s, view: { ...s.view, roomId: r.id, selectedPiece: null } }),
|
||
true,
|
||
)
|
||
}
|
||
>
|
||
{r.name || 'Untitled room'}
|
||
<small>
|
||
{geometries[index].valid ? `${area(geometries[index].area)} m²` : 'Check outline'}
|
||
</small>
|
||
</button>
|
||
))}
|
||
<button className="add-room" onClick={addRoom} disabled={project.rooms.length >= 20}>
|
||
<Plus size={17} />
|
||
Add room
|
||
</button>
|
||
</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"
|
||
>
|
||
<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"
|
||
>
|
||
<MaterialsEditor
|
||
project={project}
|
||
room={room}
|
||
changeRoom={changeRoom}
|
||
changeProject={changeProject}
|
||
/>
|
||
</section>
|
||
</div>
|
||
<div className="drawing-column" id="drawing">
|
||
<FloorPlan
|
||
room={room}
|
||
geometry={geometry}
|
||
flooring={flooring}
|
||
skirting={skirting}
|
||
selectedPiece={view.selectedPiece}
|
||
select={(id) => select(id)}
|
||
/>
|
||
<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>
|
||
<Issues issues={roomErrors} />
|
||
</div>
|
||
</div>
|
||
<div id="cut-plan">
|
||
<CutList
|
||
project={project}
|
||
flooring={flooring}
|
||
skirting={skirting}
|
||
selectedPiece={view.selectedPiece}
|
||
select={select}
|
||
/>
|
||
</div>
|
||
</main>
|
||
</>
|
||
);
|
||
}
|