wip
This commit is contained in:
commit
d8f2407ddf
44 changed files with 10377 additions and 0 deletions
259
src/App.tsx
Normal file
259
src/App.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue