floor-planner/src/App.tsx
2026-09-11 16:09:28 +01:00

259 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}