This commit is contained in:
parent
7959fe85cc
commit
b9485e4097
32 changed files with 4692 additions and 65 deletions
35
src/App.tsx
35
src/App.tsx
|
|
@ -2,9 +2,10 @@ import { useMemo, useRef } from 'react';
|
|||
import type { ReactNode } from 'react';
|
||||
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
|
||||
import { useProject } from './lib/use-project';
|
||||
import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics';
|
||||
import { rectangle, nextId } from './lib/defaults';
|
||||
import { buildGeometry } from './lib/geometry';
|
||||
import { planFlooring } from './lib/flooring';
|
||||
import { planFlooringOptions } from './lib/flooring';
|
||||
import { planSkirting } from './lib/skirting';
|
||||
import { area, mm } from './lib/format';
|
||||
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||
|
|
@ -70,10 +71,13 @@ export default function App() {
|
|||
[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],
|
||||
// Switching between already calculated options only selects a result.
|
||||
const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined });
|
||||
const flooringOptions = useMemo(
|
||||
() => planFlooringOptions(project.rooms, project.flooring, project.tolerance),
|
||||
[project.rooms, flooringInputs, project.tolerance],
|
||||
);
|
||||
const flooring = flooringOptions[project.flooring.optimisation ?? 'cuts'];
|
||||
const skirting = useMemo(
|
||||
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||
[project.rooms, project.skirting, project.tolerance],
|
||||
|
|
@ -144,7 +148,7 @@ export default function App() {
|
|||
...s,
|
||||
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
||||
}));
|
||||
const addRoom = () =>
|
||||
const addRoom = () => {
|
||||
update((s) => {
|
||||
const id = nextId(
|
||||
'room',
|
||||
|
|
@ -168,7 +172,9 @@ export default function App() {
|
|||
},
|
||||
};
|
||||
}, true);
|
||||
const removeRoom = () =>
|
||||
trackRoomAdded();
|
||||
};
|
||||
const removeRoom = () => {
|
||||
update((s) => {
|
||||
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
||||
return {
|
||||
|
|
@ -177,10 +183,13 @@ export default function App() {
|
|||
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
|
||||
};
|
||||
}, true);
|
||||
trackRoomRemoved();
|
||||
};
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
|
||||
trackCopyLink();
|
||||
} catch {
|
||||
if (copyStatus.current)
|
||||
copyStatus.current.textContent = 'Copy the address from your browser.';
|
||||
|
|
@ -190,10 +199,14 @@ export default function App() {
|
|||
return (
|
||||
<>
|
||||
<header className="app-header">
|
||||
<div className="app-title">
|
||||
<a
|
||||
className="app-title app-title-link"
|
||||
href={window.location.pathname}
|
||||
aria-label="Floor planner home"
|
||||
>
|
||||
<span className="square-mark" aria-hidden="true" />
|
||||
<h1>Floor planner</h1>
|
||||
</div>
|
||||
</a>
|
||||
<div className="header-actions">
|
||||
<button className="button" onClick={copyLink}>
|
||||
<Copy size={17} />
|
||||
|
|
@ -201,7 +214,10 @@ export default function App() {
|
|||
</button>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => window.print()}
|
||||
onClick={() => {
|
||||
trackPrint();
|
||||
window.print();
|
||||
}}
|
||||
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
|
||||
>
|
||||
<Printer size={17} />
|
||||
|
|
@ -282,6 +298,7 @@ export default function App() {
|
|||
>
|
||||
<MaterialsEditor
|
||||
project={project}
|
||||
flooringOptions={flooringOptions}
|
||||
room={room}
|
||||
changeRoom={changeRoom}
|
||||
changeProject={changeProject}
|
||||
|
|
|
|||
39
src/Site.tsx
Normal file
39
src/Site.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { lazy, Suspense, useEffect, useSyncExternalStore } from 'react';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
|
||||
const Planner = lazy(() => import('./App'));
|
||||
|
||||
function subscribeToLocation(onChange: () => void) {
|
||||
window.addEventListener('popstate', onChange);
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', onChange);
|
||||
window.removeEventListener('hashchange', onChange);
|
||||
};
|
||||
}
|
||||
|
||||
// Query links work on static hosts and keep all existing shared plans accessible.
|
||||
const isPlannerLocation = () =>
|
||||
Boolean(window.location.search) || window.location.hash.startsWith('#plan=');
|
||||
|
||||
export default function Site() {
|
||||
const showPlanner = useSyncExternalStore(subscribeToLocation, isPlannerLocation);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = showPlanner ? 'Floor planner' : 'Floor planner — Measure. Plan. Lay.';
|
||||
}, [showPlanner]);
|
||||
|
||||
return showPlanner ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="planner-loading" role="status">
|
||||
Opening your planner…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Planner />
|
||||
</Suspense>
|
||||
) : (
|
||||
<LandingPage />
|
||||
);
|
||||
}
|
||||
491
src/components/LandingPage.tsx
Normal file
491
src/components/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ClipboardList,
|
||||
MoveUpRight,
|
||||
Recycle,
|
||||
Ruler,
|
||||
} from 'lucide-react';
|
||||
import '../landing.css';
|
||||
|
||||
const installationGuide = 'https://www.quick-step.co.uk/en-gb/laminate/installation';
|
||||
const finishingGuide = 'https://www.quick-step.co.uk/en-gb/laminate/finishing';
|
||||
|
||||
function PlannerLink({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<a className={`landing-cta${compact ? ' landing-cta-small' : ''}`} href="?planner">
|
||||
{compact ? 'Open planner' : 'Plan my floor'}
|
||||
<ArrowRight size={compact ? 17 : 21} aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanIllustration() {
|
||||
return (
|
||||
<figure className="landing-plan">
|
||||
<figcaption className="landing-plan-heading">
|
||||
<span>
|
||||
<span className="landing-status-dot" /> A little planning goes a long way.
|
||||
</span>
|
||||
<span className="landing-plan-index">FIG. 01</span>
|
||||
</figcaption>
|
||||
<div className="landing-plan-paper">
|
||||
<div className="landing-plan-room">
|
||||
<span>Living room</span>
|
||||
<span>
|
||||
4.8 × 3.6 m <span aria-hidden="true">/</span> 17.28 m²
|
||||
</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 540 380" role="img" aria-labelledby="landing-plan-title">
|
||||
<title id="landing-plan-title">
|
||||
Illustrative floor plan with staggered floorboards, labelled cuts and skirting around a
|
||||
4.8 by 3.6 metre room.
|
||||
</title>
|
||||
<defs>
|
||||
<pattern id="landing-grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M20 0H0V20" fill="none" stroke="#dfe4df" strokeWidth="0.7" />
|
||||
</pattern>
|
||||
<clipPath id="landing-room-clip">
|
||||
<rect x="91" y="68" width="358" height="264" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect width="540" height="380" fill="url(#landing-grid)" />
|
||||
<g stroke="#748279" strokeWidth="1" fill="none">
|
||||
<path d="M80 48V23M460 48V23M80 32H460M75 37L85 27M455 37L465 27" />
|
||||
<path d="M62 57H37M62 343H37M46 57V343M41 62L51 52M41 348L51 338" />
|
||||
</g>
|
||||
<g fill="#55685d" fontSize="12" fontFamily="monospace" textAnchor="middle">
|
||||
<rect x="224" y="21" width="92" height="22" fill="#f6f7f2" />
|
||||
<text x="270" y="36">
|
||||
4,800 mm
|
||||
</text>
|
||||
<text transform="translate(36 200) rotate(-90)">3,600 mm</text>
|
||||
</g>
|
||||
<rect
|
||||
x="80"
|
||||
y="57"
|
||||
width="380"
|
||||
height="286"
|
||||
fill="#fffdf7"
|
||||
stroke="#17263f"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<rect
|
||||
x="85"
|
||||
y="62"
|
||||
width="370"
|
||||
height="276"
|
||||
fill="none"
|
||||
stroke="#b74900"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<g clipPath="url(#landing-room-clip)">
|
||||
{Array.from({ length: 12 }, (_, row) =>
|
||||
Array.from({ length: 5 }, (_, col) => (
|
||||
<rect
|
||||
key={`${row}-${col}`}
|
||||
x={91 + col * 96 - (row % 2 === 0 ? 24 : 72)}
|
||||
y={68 + row * 22}
|
||||
width="96"
|
||||
height="22"
|
||||
fill={
|
||||
(row + col) % 3 === 0
|
||||
? '#ead9bc'
|
||||
: (row + col) % 3 === 1
|
||||
? '#f1e5d1'
|
||||
: '#e5d0ae'
|
||||
}
|
||||
stroke="#b49a72"
|
||||
strokeWidth="0.8"
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
<rect x="91" y="112" width="72" height="22" fill="#c66935" stroke="#a74617" />
|
||||
<rect x="403" y="178" width="46" height="22" fill="#c66935" stroke="#a74617" />
|
||||
</g>
|
||||
<g fontSize="11" fontFamily="monospace" fill="#fff" textAnchor="middle">
|
||||
<text x="127" y="127">
|
||||
F1
|
||||
</text>
|
||||
<text x="426" y="193">
|
||||
F2
|
||||
</text>
|
||||
</g>
|
||||
<rect x="226" y="177" width="114" height="48" rx="2" fill="#fffdf7" stroke="#b49a72" />
|
||||
<text x="283" y="198" fontSize="13" fontWeight="600" fill="#17263f" textAnchor="middle">
|
||||
Living room
|
||||
</text>
|
||||
<text x="283" y="214" fontSize="10" letterSpacing="1" fill="#58655e" textAnchor="middle">
|
||||
EXAMPLE LAYOUT
|
||||
</text>
|
||||
<path d="M79 257V317" stroke="#f6f7f2" strokeWidth="15" />
|
||||
<path
|
||||
d="M80 317H140M140 317A60 60 0 0 0 80 257"
|
||||
fill="none"
|
||||
stroke="#17263f"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
<path d="M471 321H496M490 315L496 321L490 327" fill="none" stroke="#748279" />
|
||||
<text x="470" y="307" fontSize="10" fontFamily="monospace" fill="#55685d">
|
||||
LAY
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="landing-plan-legend">
|
||||
<span>
|
||||
<i className="legend-floor" /> Floorboards
|
||||
</span>
|
||||
<span>
|
||||
<i className="legend-trim" /> Skirting
|
||||
</span>
|
||||
<span>
|
||||
<i className="legend-cut" /> Labelled cuts
|
||||
</span>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function TrimIllustration({ scotia }: { scotia: boolean }) {
|
||||
const name = scotia ? 'scotia' : 'skirting';
|
||||
return (
|
||||
<svg
|
||||
className="landing-trim-diagram"
|
||||
viewBox="0 0 400 215"
|
||||
role="img"
|
||||
aria-labelledby={`${name}-title`}
|
||||
>
|
||||
<title id={`${name}-title`}>
|
||||
{scotia
|
||||
? 'Side view: scotia sits in front of existing skirting and covers the expansion gap above the floating floor.'
|
||||
: 'Side view: a new skirting board fixed to the wall covers the expansion gap, with the floating floor extending underneath.'}
|
||||
</title>
|
||||
<path d="M48 25H84V181H48" fill="#e3e4df" stroke="#9aaba0" />
|
||||
<path d="M48 181H353V193H48" fill="#dad8ce" />
|
||||
<path
|
||||
d={scotia ? 'M126 161H353V180H126Z' : 'M98 161H353V180H98Z'}
|
||||
fill="#e4c49c"
|
||||
stroke="#a78052"
|
||||
/>
|
||||
<path d="M203 162V180M303 162V180" stroke="#a78052" />
|
||||
{scotia ? (
|
||||
<>
|
||||
<path d="M84 63H100Q110 63 110 74V181H84Z" fill="#fffdf7" stroke="#7f8e84" />
|
||||
<path
|
||||
d="M110 129H116Q116 154 147 155V161H110Z"
|
||||
fill="#c66935"
|
||||
stroke="#9e4419"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M142 143L173 113H225" fill="none" stroke="#a4471c" />
|
||||
<text x="233" y="117" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
Scotia bead
|
||||
</text>
|
||||
<path d="M102 85H225" stroke="#7f8e84" />
|
||||
<text x="233" y="89" fill="#526156" fontSize="12">
|
||||
Existing skirting
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d="M84 54H98Q121 54 121 74V159H84Z"
|
||||
fill="#c66935"
|
||||
stroke="#9e4419"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M113 85H225" stroke="#a4471c" />
|
||||
<text x="233" y="89" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
New skirting
|
||||
</text>
|
||||
<path d="M108 154L175 127H225" fill="none" stroke="#7f8e84" />
|
||||
<text x="233" y="131" fill="#526156" fontSize="12">
|
||||
Floor tucks under
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
<path
|
||||
d={scotia ? 'M118 172V206H189' : 'M91 172V206H189'}
|
||||
fill="none"
|
||||
stroke="#526156"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text x="197" y="210" fill="#526156" fontSize="11">
|
||||
Expansion gap
|
||||
</text>
|
||||
<text x="48" y="15" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
WALL
|
||||
</text>
|
||||
<text x="278" y="151" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
FLOORING
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const benefits = [
|
||||
{
|
||||
icon: Ruler,
|
||||
title: 'See it before you lay it.',
|
||||
text: 'Add your room, doors and board sizes. See how the rows, edges and staggered joints fit together before the first cut.',
|
||||
},
|
||||
{
|
||||
icon: Recycle,
|
||||
title: 'Make more of every board.',
|
||||
text: 'Get quantities from an actual layout, with usable offcuts shared across rooms. Add your own allowance for mistakes and damage.',
|
||||
},
|
||||
{
|
||||
icon: ClipboardList,
|
||||
title: 'Take a plan to the floor.',
|
||||
text: 'Follow labelled floorboard and skirting cuts, tick off finished boards, and print your plan. Save or share the project by copying its link.',
|
||||
},
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Measure & make a plan',
|
||||
text: 'Measure each wall, recess and doorway in millimetres. Enter your board sizes, choose a laying direction, and check the first and last rows before buying.',
|
||||
},
|
||||
{
|
||||
title: 'Get the room ready',
|
||||
text: 'Acclimatise the packs as instructed. Check that the subfloor is dry, clean, flat and stable, then fit the specified underlay and moisture barrier where required.',
|
||||
},
|
||||
{
|
||||
title: 'Lay, check, repeat',
|
||||
text: 'Use spacers for the required expansion gap. Follow the click-system instructions, stagger the end joints, and measure each cut on site. Keep suitable offcuts for later rows.',
|
||||
},
|
||||
{
|
||||
title: 'Finish the edges',
|
||||
text: 'Remove the spacers. Fit skirting or scotia to cover the gap, fixing it to the wall or existing skirting so the floor can move. Fit doorway transitions as instructed.',
|
||||
},
|
||||
];
|
||||
|
||||
export function LandingPage() {
|
||||
return (
|
||||
<div className="landing-page">
|
||||
<a className="landing-skip" href="#main">
|
||||
Skip to content
|
||||
</a>
|
||||
<header className="landing-header landing-container">
|
||||
<a
|
||||
className="landing-brand"
|
||||
href={window.location.pathname}
|
||||
aria-label="Floor planner home"
|
||||
>
|
||||
<span className="square-mark" aria-hidden="true" />
|
||||
<span>
|
||||
Floor planner<span className="landing-brand-dot">.</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav className="landing-nav" aria-label="Main navigation">
|
||||
<a href="#why">Why plan?</a>
|
||||
<a href="#guide">DIY guide</a>
|
||||
<a href="#finishing">The finishing touches</a>
|
||||
</nav>
|
||||
<PlannerLink compact />
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section className="landing-hero landing-container" aria-labelledby="landing-title">
|
||||
<div className="landing-hero-copy">
|
||||
<p className="landing-eyebrow">
|
||||
<span /> MEASURE. PLAN. LAY.
|
||||
</p>
|
||||
<h1 id="landing-title">
|
||||
A great floor
|
||||
<br />
|
||||
starts with
|
||||
<br />
|
||||
<em>a little planning.</em>
|
||||
</h1>
|
||||
<p className="landing-intro">
|
||||
From room measurements to the last cut. Plan your floorboards and skirting, make the
|
||||
most of your materials, and start your DIY project with confidence.
|
||||
</p>
|
||||
<div className="landing-hero-actions">
|
||||
<PlannerLink />
|
||||
<a className="landing-guide-link" href="#guide">
|
||||
First time fitting a floor? <ArrowDown size={16} aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="landing-reassurance">
|
||||
<Check size={14} aria-hidden="true" /> Free to use <span>·</span> No sign-up{' '}
|
||||
<span>·</span> Right in your browser
|
||||
</p>
|
||||
</div>
|
||||
<PlanIllustration />
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="landing-benefits landing-container"
|
||||
id="why"
|
||||
aria-labelledby="benefits-title"
|
||||
>
|
||||
<div className="landing-section-kicker">
|
||||
<span className="landing-eyebrow">LESS GUESSWORK. MORE GETTING IT DONE.</span>
|
||||
<h2 id="benefits-title">Your tape measure’s new best friend.</h2>
|
||||
</div>
|
||||
<div className="landing-benefit-grid">
|
||||
{benefits.map(({ icon: Icon, title, text }, index) => (
|
||||
<article className="landing-benefit" key={title}>
|
||||
<div className="landing-benefit-top">
|
||||
<Icon size={24} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span>0{index + 1}</span>
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-guide" id="guide" aria-labelledby="guide-title">
|
||||
<div className="landing-container landing-guide-layout">
|
||||
<div className="landing-guide-intro">
|
||||
<p className="landing-eyebrow">THE DIY STARTING POINT</p>
|
||||
<h2 id="guide-title">
|
||||
Your first floor?
|
||||
<br />
|
||||
You’ve got this.
|
||||
</h2>
|
||||
<p>
|
||||
A little patience, a good plan, and one row at a time. Here’s the journey for a
|
||||
floating click-fit laminate floor.
|
||||
</p>
|
||||
<a
|
||||
className="landing-text-link"
|
||||
href={installationGuide}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Read a manufacturer’s fitting guide <MoveUpRight size={15} aria-hidden="true" />
|
||||
<span className="landing-sr-only"> (opens in a new tab)</span>
|
||||
</a>
|
||||
<div className="landing-tool-note">
|
||||
<Ruler size={20} aria-hidden="true" />
|
||||
<p>
|
||||
<strong>Get your kit together</strong>Tape measure, pencil, square, spacers, a
|
||||
suitable saw, the recommended fitting tools, and eye, ear and dust protection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ol className="landing-steps">
|
||||
{steps.map(({ title, text }, index) => (
|
||||
<li key={title}>
|
||||
<span className="landing-step-number">0{index + 1}</span>
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="landing-guide-note">
|
||||
Your flooring’s instructions set the expansion gap, acclimatisation time, underlay
|
||||
and minimum joint spacing. Use those values in the planner and check every
|
||||
measurement before cutting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="landing-finishing landing-container"
|
||||
id="finishing"
|
||||
aria-labelledby="finishing-title"
|
||||
>
|
||||
<div className="landing-finishing-heading">
|
||||
<div>
|
||||
<p className="landing-eyebrow">SMALL DETAILS. A PROPER FINISH.</p>
|
||||
<h2 id="finishing-title">Scotia or skirting board?</h2>
|
||||
</div>
|
||||
<p>
|
||||
Both can hide the expansion gap around a floating floor. The difference is what you’re
|
||||
keeping, and the finish you want.
|
||||
</p>
|
||||
</div>
|
||||
<div className="landing-trim-grid">
|
||||
<article className="landing-trim-card">
|
||||
<div className="landing-trim-label">
|
||||
<span>KEEP YOUR EXISTING SKIRTING</span>
|
||||
<span>01</span>
|
||||
</div>
|
||||
<TrimIllustration scotia />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Scotia <span>the small finishing bead</span>
|
||||
</h3>
|
||||
<p>
|
||||
A narrow, curved moulding fitted at the foot of your existing skirting. It covers
|
||||
the flooring’s expansion gap without removing the boards already on your wall.
|
||||
</p>
|
||||
<p className="landing-trim-choice">
|
||||
<Check size={16} aria-hidden="true" /> A practical choice when the skirting is
|
||||
staying.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="landing-trim-card">
|
||||
<div className="landing-trim-label">
|
||||
<span>REPLACE OR REFIT YOUR SKIRTING</span>
|
||||
<span>02</span>
|
||||
</div>
|
||||
<TrimIllustration scotia={false} />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Skirting board <span>also called a baseboard</span>
|
||||
</h3>
|
||||
<p>
|
||||
The taller board along the base of the wall. When fitted after the flooring, it
|
||||
can cover the expansion gap itself, giving you a clean edge without an extra bead.
|
||||
</p>
|
||||
<p className="landing-trim-choice">
|
||||
<Check size={16} aria-hidden="true" /> A neat finish when you’re renovating the
|
||||
whole room.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="landing-finishing-note">
|
||||
<p>
|
||||
<strong>Leave room to move.</strong> Choose trim that covers the required gap, and
|
||||
never fix it to the floating floor. The app plans flooring and skirting cuts; scotia
|
||||
needs its own profile-specific fitting instructions.
|
||||
</p>
|
||||
<a className="landing-text-link" href={finishingGuide} target="_blank" rel="noreferrer">
|
||||
More on finishing <MoveUpRight size={15} aria-hidden="true" />
|
||||
<span className="landing-sr-only"> (opens in a new tab)</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="landing-final-cta landing-container" aria-labelledby="ready-title">
|
||||
<div>
|
||||
<p className="landing-eyebrow">YOUR NEXT PROJECT STARTS HERE</p>
|
||||
<h2 id="ready-title">
|
||||
Measure twice.
|
||||
<br />
|
||||
<em>Start with a plan.</em>
|
||||
</h2>
|
||||
<p>Bring your room measurements. We’ll help with the layout.</p>
|
||||
</div>
|
||||
<div className="landing-final-actions">
|
||||
<PlannerLink />
|
||||
<span>Open the planner. Make it your room.</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="landing-footer landing-container">
|
||||
<a className="landing-brand" href={window.location.pathname}>
|
||||
<span className="square-mark" aria-hidden="true" />
|
||||
<span>Floor planner.</span>
|
||||
</a>
|
||||
<p>A little preparation for a floor you’ll be proud of.</p>
|
||||
<a href="#main">Back to top ↑</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { ArrowRight, ArrowDown } from 'lucide-react';
|
||||
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
|
||||
import { NumberField } from './Fields';
|
||||
import type { FlooringOptions } from '../lib/flooring';
|
||||
import { mm } from '../lib/format';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
room: Room;
|
||||
flooringOptions: FlooringOptions;
|
||||
changeProject: (fn: (p: Project) => Project) => void;
|
||||
changeRoom: (fn: (r: Room) => Room) => void;
|
||||
}
|
||||
|
|
@ -12,7 +15,13 @@ interface Props {
|
|||
const kerfHelp =
|
||||
'Width of material removed by the saw blade. A 3 mm blade turns a 1,000 mm board into 600 mm + 397 mm after one cut.';
|
||||
|
||||
export function MaterialsEditor({ project, room, changeProject, changeRoom }: Props) {
|
||||
export function MaterialsEditor({
|
||||
project,
|
||||
room,
|
||||
flooringOptions,
|
||||
changeProject,
|
||||
changeRoom,
|
||||
}: Props) {
|
||||
const f = project.flooring;
|
||||
const s = project.skirting;
|
||||
const floor = (key: keyof FlooringSettings, value: number | string) =>
|
||||
|
|
@ -63,6 +72,34 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{f.pattern === 'random' && (
|
||||
<div className="field">
|
||||
<span>Optimise random flooring · all rooms</span>
|
||||
<div className="optimisation-options" role="group" aria-label="Flooring optimisation">
|
||||
{(['cuts', 'random'] as const).map((priority) => {
|
||||
const plan = flooringOptions[priority];
|
||||
const valid = !plan.issues.some((issue) => issue.severity === 'error');
|
||||
const selected = (f.optimisation ?? 'cuts') === priority;
|
||||
return (
|
||||
<button
|
||||
key={priority}
|
||||
type="button"
|
||||
className={selected ? 'active' : ''}
|
||||
aria-pressed={selected}
|
||||
onClick={() => floor('optimisation', priority)}
|
||||
>
|
||||
<span>{priority === 'cuts' ? 'Minimise cuts' : 'Maximise randomness'}</span>
|
||||
<small>
|
||||
{valid
|
||||
? `${plan.cuts} cuts · ${mm(plan.wastePercent)}% waste`
|
||||
: 'Check measurements'}
|
||||
</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<span>Board direction · {room.name}</span>
|
||||
<div className="segmented" role="group" aria-label="Board direction">
|
||||
|
|
|
|||
840
src/landing.css
Normal file
840
src/landing.css
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
.landing-page {
|
||||
--landing-muted: #5e665f;
|
||||
--landing-line: #d9ddd3;
|
||||
--landing-accent: #ac451d;
|
||||
background: #fdfcf7;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.landing-container {
|
||||
width: min(1180px, calc(100% - 96px));
|
||||
margin-inline: auto;
|
||||
}
|
||||
.landing-page a {
|
||||
color: inherit;
|
||||
}
|
||||
.landing-page a:focus-visible {
|
||||
outline: 3px solid var(--blue);
|
||||
outline-offset: 5px;
|
||||
}
|
||||
.landing-page section[id],
|
||||
.landing-page main {
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.9px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.landing-brand-dot {
|
||||
color: var(--landing-accent);
|
||||
}
|
||||
.landing-nav {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
.landing-nav a,
|
||||
.landing-footer > a:last-child {
|
||||
padding-block: 12px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.landing-nav a:hover,
|
||||
.landing-footer > a:last-child:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
.landing-page .landing-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 42px;
|
||||
min-height: 64px;
|
||||
padding: 18px 25px;
|
||||
background: var(--landing-accent);
|
||||
border: 1px solid var(--landing-accent);
|
||||
color: #fffdf7;
|
||||
border-radius: 3px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.landing-page .landing-cta:hover {
|
||||
background: #8b3514;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.landing-page .landing-cta-small {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
gap: 18px;
|
||||
}
|
||||
.landing-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
padding-block: 77px 80px;
|
||||
}
|
||||
.landing-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 10px var(--mono);
|
||||
line-height: 1.6;
|
||||
letter-spacing: 1.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-hero .landing-eyebrow > span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--landing-accent);
|
||||
}
|
||||
.landing-hero h1,
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2,
|
||||
.landing-final-cta h2 {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: -2.3px;
|
||||
line-height: 1.07;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
margin-top: 23px;
|
||||
font-size: clamp(46px, 4.7vw, 65px);
|
||||
}
|
||||
.landing-hero h1 em,
|
||||
.landing-final-cta h2 em {
|
||||
color: var(--landing-accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
.landing-intro {
|
||||
max-width: 445px;
|
||||
margin-top: 26px;
|
||||
color: var(--landing-muted);
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 15px 22px;
|
||||
margin-top: 29px;
|
||||
}
|
||||
.landing-guide-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
text-underline-offset: 5px;
|
||||
text-decoration-color: #a3aca2;
|
||||
}
|
||||
.landing-reassurance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 19px;
|
||||
color: var(--landing-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.landing-reassurance svg {
|
||||
color: #426751;
|
||||
}
|
||||
.landing-plan {
|
||||
margin: 16px 0 0;
|
||||
border: 1px solid #cbd2c7;
|
||||
background: #f6f7f2;
|
||||
border-radius: 4px;
|
||||
box-shadow: 9px 9px 0 #eaece4;
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
.landing-plan-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #cbd2c7;
|
||||
font-size: 11px;
|
||||
}
|
||||
.landing-plan-heading > span:first-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-status-dot {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5a7960;
|
||||
}
|
||||
.landing-plan-index {
|
||||
font: 9px var(--mono);
|
||||
color: #687265;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.landing-plan-room {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 22px 24px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-plan-room > span:last-child {
|
||||
font: 10px var(--mono);
|
||||
color: #657260;
|
||||
align-self: center;
|
||||
}
|
||||
.landing-plan-paper > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.landing-plan-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
padding: 17px 24px;
|
||||
border-top: 1px solid #dbe0d5;
|
||||
color: #526156;
|
||||
font-size: 10px;
|
||||
}
|
||||
.landing-plan-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.landing-plan-legend i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1px solid #b49a72;
|
||||
background: #ead9bc;
|
||||
}
|
||||
.landing-plan-legend .legend-trim {
|
||||
background: none;
|
||||
border: 2px solid #b74900;
|
||||
}
|
||||
.landing-plan-legend .legend-cut {
|
||||
background: #c66935;
|
||||
border-color: #a74617;
|
||||
}
|
||||
.landing-benefits {
|
||||
padding-top: 29px;
|
||||
padding-bottom: 68px;
|
||||
border-top: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-section-kicker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-section-kicker > .landing-eyebrow {
|
||||
color: var(--landing-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-section-kicker h2 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.landing-benefit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-top: 34px;
|
||||
}
|
||||
.landing-benefit {
|
||||
padding-inline: 30px;
|
||||
border-left: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-benefit:first-child {
|
||||
padding-left: 0;
|
||||
border: 0;
|
||||
}
|
||||
.landing-benefit:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
.landing-benefit-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 19px;
|
||||
color: var(--landing-accent);
|
||||
}
|
||||
.landing-benefit-top span {
|
||||
color: #667064;
|
||||
font: 10px var(--mono);
|
||||
}
|
||||
.landing-benefit h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.landing-benefit p,
|
||||
.landing-trim-copy > p {
|
||||
color: var(--landing-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-guide {
|
||||
padding-block: 70px;
|
||||
background: #eef0e8;
|
||||
border-block: 1px solid #e0e4d8;
|
||||
}
|
||||
.landing-guide-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1.2fr;
|
||||
gap: 110px;
|
||||
}
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2 {
|
||||
font-size: 43px;
|
||||
letter-spacing: -1.5px;
|
||||
margin-top: 17px;
|
||||
}
|
||||
.landing-guide-intro > p:not(.landing-eyebrow) {
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
margin-top: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.landing-text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
.landing-guide-intro > .landing-text-link {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.landing-tool-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 13px;
|
||||
padding-top: 25px;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid #cdd5c5;
|
||||
color: var(--landing-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-tool-note strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.landing-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.landing-steps li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 23px;
|
||||
padding-block: 23px;
|
||||
border-bottom: 1px solid #cdd5c5;
|
||||
}
|
||||
.landing-steps li:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
.landing-step-number {
|
||||
font: 13px var(--mono);
|
||||
color: var(--landing-accent);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.landing-steps h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.landing-steps p {
|
||||
font-size: 13px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.75;
|
||||
}
|
||||
.landing-guide-note {
|
||||
margin: 18px 0 0 39px;
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 73px 65px;
|
||||
}
|
||||
.landing-finishing-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
align-items: end;
|
||||
gap: 80px;
|
||||
margin-bottom: 35px;
|
||||
}
|
||||
.landing-finishing-heading > p {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-trim-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
.landing-trim-card {
|
||||
border: 1px solid var(--landing-line);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.landing-trim-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 15px;
|
||||
padding: 16px 23px;
|
||||
background: #f0f1e9;
|
||||
font: 9px var(--mono);
|
||||
letter-spacing: 1px;
|
||||
color: #586154;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-trim-diagram {
|
||||
display: block;
|
||||
width: calc(100% - 48px);
|
||||
max-width: 430px;
|
||||
height: auto;
|
||||
margin: 20px auto;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
margin: 0 24px;
|
||||
padding: 22px 0 21px;
|
||||
border-top: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-trim-copy h3 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px 12px;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.6px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.landing-trim-copy h3 span {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
color: var(--landing-muted);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.landing-trim-copy .landing-trim-choice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
.landing-trim-choice svg {
|
||||
margin-top: 2px;
|
||||
color: #527057;
|
||||
}
|
||||
.landing-finishing-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
margin-top: 23px;
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
}
|
||||
.landing-finishing-note p {
|
||||
max-width: 780px;
|
||||
}
|
||||
.landing-finishing-note strong {
|
||||
color: var(--ink);
|
||||
}
|
||||
.landing-finishing-note a {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.landing-final-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
padding: 47px 55px;
|
||||
background: #172c25;
|
||||
color: #faf9ef;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.landing-final-cta .landing-eyebrow {
|
||||
color: #c0cbb8;
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 48px;
|
||||
margin-top: 20px;
|
||||
letter-spacing: -1.7px;
|
||||
}
|
||||
.landing-final-cta h2 em {
|
||||
color: #dfb888;
|
||||
}
|
||||
.landing-final-cta p:not(.landing-eyebrow) {
|
||||
color: #c0cbb8;
|
||||
font-size: 12px;
|
||||
margin-top: 17px;
|
||||
}
|
||||
.landing-final-actions {
|
||||
display: grid;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.landing-final-cta .landing-cta {
|
||||
background: #f6ecd9;
|
||||
border-color: #f6ecd9;
|
||||
color: #172c25;
|
||||
min-width: 260px;
|
||||
}
|
||||
.landing-final-cta .landing-cta:hover {
|
||||
background: #e5cfac;
|
||||
}
|
||||
.landing-final-actions > span {
|
||||
color: #c0cbb8;
|
||||
font-size: 10px;
|
||||
}
|
||||
.landing-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding-block: 38px;
|
||||
}
|
||||
.landing-footer .landing-brand {
|
||||
font-size: 17px;
|
||||
}
|
||||
.landing-footer .square-mark {
|
||||
transform: scale(0.75);
|
||||
transform-origin: left center;
|
||||
margin-right: -6px;
|
||||
}
|
||||
.landing-footer p,
|
||||
.landing-footer > a:last-child {
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.landing-skip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
.landing-skip:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.planner-loading {
|
||||
padding: 32px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.landing-container {
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
.landing-nav {
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-hero {
|
||||
gap: 32px;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-plan-heading,
|
||||
.landing-plan-room {
|
||||
padding-inline: 15px;
|
||||
}
|
||||
.landing-guide-layout {
|
||||
gap: 65px;
|
||||
}
|
||||
.landing-finishing-heading {
|
||||
gap: 35px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.landing-nav {
|
||||
display: none;
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 82px;
|
||||
}
|
||||
.landing-hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 35px;
|
||||
padding-block: 48px 57px;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
font-size: clamp(48px, 8vw, 65px);
|
||||
}
|
||||
.landing-intro {
|
||||
max-width: 540px;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-plan {
|
||||
width: min(100%, 540px);
|
||||
margin: 10px auto 0;
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
.landing-section-kicker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.landing-benefit {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
.landing-guide-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 38px;
|
||||
}
|
||||
.landing-guide-intro {
|
||||
max-width: 540px;
|
||||
}
|
||||
.landing-tool-note {
|
||||
margin-top: 15px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.landing-finishing-heading {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.landing-trim-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
.landing-trim-diagram {
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
.landing-trim-label {
|
||||
padding-inline: 16px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
.landing-final-cta {
|
||||
padding: 38px;
|
||||
gap: 30px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 39px;
|
||||
}
|
||||
.landing-final-cta .landing-cta {
|
||||
min-width: 210px;
|
||||
gap: 25px;
|
||||
}
|
||||
.landing-footer {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 24px;
|
||||
}
|
||||
.landing-footer p {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.landing-container {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
.landing-header {
|
||||
gap: 12px;
|
||||
}
|
||||
.landing-brand {
|
||||
font-size: 19px;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-brand .square-mark {
|
||||
transform: scale(0.85);
|
||||
transform-origin: left center;
|
||||
margin-right: -4px;
|
||||
}
|
||||
.landing-page .landing-cta-small {
|
||||
font-size: 12px;
|
||||
padding-inline: 12px;
|
||||
gap: 9px;
|
||||
}
|
||||
.landing-hero h1 {
|
||||
font-size: clamp(41px, 11vw, 58px);
|
||||
letter-spacing: -1.8px;
|
||||
}
|
||||
.landing-intro {
|
||||
font-size: 15px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.landing-hero-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-hero-actions .landing-cta {
|
||||
width: 100%;
|
||||
}
|
||||
.landing-reassurance {
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.landing-plan-heading {
|
||||
font-size: 10px;
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.landing-plan-index {
|
||||
font-size: 8px;
|
||||
}
|
||||
.landing-plan-room {
|
||||
font-size: 11px;
|
||||
padding: 16px 14px 8px;
|
||||
}
|
||||
.landing-plan-room > span:last-child {
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-plan-legend {
|
||||
padding: 14px;
|
||||
gap: 15px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.landing-benefits {
|
||||
padding-bottom: 42px;
|
||||
}
|
||||
.landing-benefit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.landing-benefit,
|
||||
.landing-benefit:first-child {
|
||||
border: 0;
|
||||
padding: 0 0 24px;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-benefit:last-child {
|
||||
padding-bottom: 0;
|
||||
border: 0;
|
||||
}
|
||||
.landing-benefit-top {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.landing-guide {
|
||||
padding-block: 44px;
|
||||
}
|
||||
.landing-guide h2,
|
||||
.landing-finishing h2 {
|
||||
font-size: 35px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.landing-steps li {
|
||||
gap: 18px;
|
||||
}
|
||||
.landing-guide-note {
|
||||
margin-left: 34px;
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 46px 36px;
|
||||
}
|
||||
.landing-trim-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.landing-trim-label {
|
||||
font-size: 9px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
margin-inline: 20px;
|
||||
}
|
||||
.landing-trim-diagram {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
.landing-finishing-note {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-final-cta {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 32px 25px;
|
||||
gap: 27px;
|
||||
}
|
||||
.landing-final-cta h2 {
|
||||
font-size: 40px;
|
||||
}
|
||||
.landing-footer {
|
||||
padding-block: 28px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-page .landing-cta {
|
||||
transition: none;
|
||||
}
|
||||
.landing-page .landing-cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
.landing-header,
|
||||
.landing-footer,
|
||||
.landing-final-cta,
|
||||
.landing-hero-actions,
|
||||
.landing-skip {
|
||||
display: none;
|
||||
}
|
||||
.landing-container {
|
||||
width: 100%;
|
||||
}
|
||||
.landing-plan {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.landing-trim-card,
|
||||
.landing-steps li {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
52
src/lib/analytics.ts
Normal file
52
src/lib/analytics.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
init as plausibleInit,
|
||||
track as plausibleTrack,
|
||||
type PlausibleEventOptions,
|
||||
} from '@plausible-analytics/tracker';
|
||||
|
||||
const ANALYTICS_AUTO_CAPTURE_PAGEVIEWS = true;
|
||||
const ANALYTICS_DOMAIN = 'floor-planner.schmelczer.dev';
|
||||
const ANALYTICS_ENDPOINT = 'https://stats.schmelczer.dev/status';
|
||||
const ANALYTICS_LOGGING = import.meta.env.DEV;
|
||||
|
||||
let isInitialized = false;
|
||||
|
||||
const track = (eventName: string, options: PlausibleEventOptions = {}) => {
|
||||
try {
|
||||
plausibleTrack(eventName, options);
|
||||
} catch (error) {
|
||||
console.warn(`Could not track analytics event "${eventName}".`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const initAnalytics = () => {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
plausibleInit({
|
||||
domain: ANALYTICS_DOMAIN,
|
||||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
// Query strings and legacy fragments contain the saved plan, including room names.
|
||||
transformRequest: (payload) => ({
|
||||
...payload,
|
||||
u: payload.u.split(/[?#]/)[0],
|
||||
r: payload.r?.split(/[?#]/)[0] ?? payload.r,
|
||||
}),
|
||||
});
|
||||
isInitialized = true;
|
||||
} catch (error) {
|
||||
console.warn('Could not initialize analytics.', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const trackRoomAdded = () => track('Add Room');
|
||||
|
||||
export const trackRoomRemoved = () => track('Remove Room');
|
||||
|
||||
export const trackCopyLink = () => track('Copy Link');
|
||||
|
||||
export const trackPrint = () => track('Print');
|
||||
|
|
@ -43,6 +43,7 @@ export function defaultProject(): Project {
|
|||
width: 190,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
minLength: 200,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,39 @@ const SCALE = 1000;
|
|||
const EPSILON = 0.01;
|
||||
const MAX_BOARDS = 2000;
|
||||
|
||||
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
|
||||
|
||||
/** Compare both searches before presenting their actual cuts and waste to the user. */
|
||||
export function planFlooringOptions(
|
||||
rooms: Room[],
|
||||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
): FlooringOptions {
|
||||
const shapes: ShapeCache = new Map();
|
||||
const cutsSettings = { ...s, optimisation: 'cuts' as const };
|
||||
const randomSettings = { ...s, optimisation: 'random' as const };
|
||||
const cuts = solveFlooring(rooms, cutsSettings, tolerance, shapes);
|
||||
const random =
|
||||
s.pattern === 'random' ? solveFlooring(rooms, randomSettings, tolerance, shapes) : cuts;
|
||||
const errors = (solution: Solution) =>
|
||||
solution.issues.filter((issue) => issue.severity === 'error').length;
|
||||
const better = (settings: FlooringSettings) =>
|
||||
(errors(random) - errors(cuts) || compareLayouts(random.layout, cuts.layout, settings)) < 0
|
||||
? random
|
||||
: cuts;
|
||||
const cutWinner = better(cutsSettings);
|
||||
const randomWinner = better(randomSettings);
|
||||
const cutPlan = finishFlooring(cutWinner, s);
|
||||
return {
|
||||
cuts: cutPlan,
|
||||
random: randomWinner === cutWinner ? cutPlan : finishFlooring(randomWinner, s),
|
||||
};
|
||||
}
|
||||
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
|
|
@ -31,6 +64,8 @@ interface Layout {
|
|||
rows: FlooringRow[];
|
||||
stocks: AvailableStock[];
|
||||
cuts: number;
|
||||
shapingCuts: number;
|
||||
ripCuts: number;
|
||||
reusedCount: number;
|
||||
patternPenalty: number;
|
||||
}
|
||||
|
|
@ -48,6 +83,12 @@ interface RowShape {
|
|||
bounds: Bounds;
|
||||
}
|
||||
|
||||
interface Solution {
|
||||
layout: Layout;
|
||||
issues: Issue[];
|
||||
}
|
||||
type ShapeCache = Map<string, { shape: RowShape; cuts: Map<number, CutPiece[] | null> }>;
|
||||
|
||||
const round = (n: number) => Math.round(n * SCALE) / SCALE;
|
||||
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
|
||||
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
|
||||
|
|
@ -228,7 +269,7 @@ function allocate(
|
|||
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
let stock: AvailableStock | undefined;
|
||||
for (const candidate of layout.stocks) {
|
||||
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
|
||||
if (
|
||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||
(piece.needsLeft && !candidate.left) ||
|
||||
|
|
@ -249,6 +290,18 @@ function allocate(
|
|||
};
|
||||
layout.stocks.push(stock);
|
||||
}
|
||||
const allocation = takeStock(stock, length, piece.needsRight, pieceId, s);
|
||||
if (allocation.reused) layout.reusedCount++;
|
||||
return allocation;
|
||||
}
|
||||
|
||||
function takeStock(
|
||||
stock: AvailableStock,
|
||||
length: number,
|
||||
needsRight: boolean,
|
||||
pieceId: string,
|
||||
s: FlooringSettings,
|
||||
) {
|
||||
const reused = stock.pieceIds.length > 0;
|
||||
const reserved = blankLength(length, stock.remaining);
|
||||
const crosscut = stock.remaining - length > EPSILON;
|
||||
|
|
@ -260,13 +313,88 @@ function allocate(
|
|||
} else {
|
||||
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
|
||||
// Take starters from the right; take finishers from the left. The remainder has one new cut end.
|
||||
if (piece.needsRight) stock.right = false;
|
||||
if (needsRight) stock.right = false;
|
||||
else stock.left = false;
|
||||
}
|
||||
if (reused) layout.reusedCount++;
|
||||
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
|
||||
}
|
||||
|
||||
/** Pair boundary blanks after seeing their future demand, preserving factory ends and cut order. */
|
||||
function repack(layout: Layout, s: FlooringSettings): Layout {
|
||||
const byId = new Map(layout.pieces.map((piece, index) => [piece.id, { piece, index }]));
|
||||
// Neutral pieces can occupy a board's middle. Keep these more general cutting patterns intact.
|
||||
const fixed = layout.stocks.filter((stock) =>
|
||||
stock.pieceIds.some((id) => {
|
||||
const p = byId.get(id)!.piece;
|
||||
return !p.factoryLeft && !p.factoryRight;
|
||||
}),
|
||||
);
|
||||
const fixedIds = new Set(fixed.flatMap((stock) => stock.pieceIds));
|
||||
const movable = layout.pieces.filter((p) => !fixedIds.has(p.id));
|
||||
const left = movable.filter((p) => p.factoryLeft && !p.factoryRight);
|
||||
const right = movable.filter((p) => p.factoryRight && !p.factoryLeft);
|
||||
const paired = new Set<string>();
|
||||
const groups: string[][] = fixed.map((stock) => [...stock.pieceIds]);
|
||||
const pairCuts = (a: FlooringPiece, b: FlooringPiece) => {
|
||||
if (byId.get(a.id)!.index > byId.get(b.id)!.index) [a, b] = [b, a];
|
||||
const remaining = round(Math.max(0, s.length - blankLength(a.length, s.length) - s.kerf));
|
||||
if (blankLength(b.length, remaining) > remaining + EPSILON) return Infinity;
|
||||
return Number(!close(a.length, s.length)) + Number(!close(b.length, remaining));
|
||||
};
|
||||
const pair = (a: FlooringPiece, b: FlooringPiece) => {
|
||||
paired.add(a.id);
|
||||
paired.add(b.id);
|
||||
groups.push([a.id, b.id]);
|
||||
};
|
||||
// An exact complementary tail eliminates a saw pass. Reserve those matches first.
|
||||
for (const a of left) {
|
||||
const b = right.find((b) => !paired.has(b.id) && pairCuts(a, b) === 1);
|
||||
if (b) pair(a, b);
|
||||
}
|
||||
// With saw passes fixed, fit large finishers with small starters to reduce purchased stock.
|
||||
right.sort((a, b) => a.length - b.length);
|
||||
for (const a of left.sort((a, b) => b.length - a.length)) {
|
||||
if (paired.has(a.id)) continue;
|
||||
const b = right.find((b) => !paired.has(b.id) && Number.isFinite(pairCuts(a, b)));
|
||||
if (b) pair(a, b);
|
||||
}
|
||||
for (const p of movable) if (!paired.has(p.id)) groups.push([p.id]);
|
||||
const index = (id: string) => byId.get(id)!.index;
|
||||
groups.forEach((ids) => ids.sort((a, b) => index(a) - index(b)));
|
||||
groups.sort((a, b) => index(a[0]) - index(b[0]));
|
||||
const result: Layout = {
|
||||
...layout,
|
||||
pieces: [...layout.pieces],
|
||||
stocks: [],
|
||||
cuts: layout.shapingCuts + layout.ripCuts,
|
||||
reusedCount: 0,
|
||||
};
|
||||
for (const ids of groups) {
|
||||
const stock: AvailableStock = {
|
||||
id: `B${result.stocks.length + 1}`,
|
||||
pieceIds: [],
|
||||
remaining: s.length,
|
||||
left: true,
|
||||
right: true,
|
||||
};
|
||||
result.stocks.push(stock);
|
||||
for (const id of ids) {
|
||||
const { piece, index } = byId.get(id)!;
|
||||
const allocation = takeStock(stock, piece.length, !!piece.factoryRight, id, s);
|
||||
result.cuts += Number(allocation.crosscut);
|
||||
result.reusedCount += Number(allocation.reused);
|
||||
result.pieces[index] = {
|
||||
...piece,
|
||||
stockId: stock.id,
|
||||
reused: allocation.reused,
|
||||
blankLength: allocation.blankLength,
|
||||
cut: allocation.crosscut || piece.shaped || !!piece.ripEdge,
|
||||
};
|
||||
}
|
||||
}
|
||||
return compareLayouts(result, layout, s) < 0 ? result : layout;
|
||||
}
|
||||
|
||||
function appendRow(
|
||||
base: Layout,
|
||||
cuts: CutPiece[],
|
||||
|
|
@ -280,9 +408,13 @@ function appendRow(
|
|||
pieces: [...base.pieces],
|
||||
rows: [...base.rows],
|
||||
cuts: base.cuts,
|
||||
shapingCuts: base.shapingCuts,
|
||||
ripCuts: base.ripCuts,
|
||||
reusedCount: base.reusedCount,
|
||||
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
|
||||
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
|
||||
stocks: base.stocks.map((stock) =>
|
||||
stock.remaining > 0 ? { ...stock, pieceIds: [...stock.pieceIds] } : stock,
|
||||
),
|
||||
};
|
||||
const pieceIds: string[] = [];
|
||||
cuts.forEach((piece, column) => {
|
||||
|
|
@ -309,6 +441,8 @@ function appendRow(
|
|||
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
|
||||
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
|
||||
layout.cuts += passes;
|
||||
layout.shapingCuts += shapeCuts;
|
||||
layout.ripCuts += Number(ripTop) + Number(ripBottom);
|
||||
layout.pieces.push({
|
||||
id,
|
||||
roomId: room.id,
|
||||
|
|
@ -388,6 +522,104 @@ function randomPatternPenalty(
|
|||
return penalty;
|
||||
}
|
||||
|
||||
/** Reorder interchangeable full-width rectangular rows without adding any cutting operations. */
|
||||
function improveRowOrder(layout: Layout, rooms: Room[], s: FlooringSettings): Layout {
|
||||
if (s.pattern !== 'random' || layout.patternPenalty === 0) return layout;
|
||||
const rows = [...layout.rows];
|
||||
const byId = new Map(layout.pieces.map((piece) => [piece.id, piece]));
|
||||
const moves = new Map<string, { row: number; shift: Point }>();
|
||||
let improvement = 0;
|
||||
for (const room of rooms) {
|
||||
const positions = rows.flatMap((row, index) => (row.roomId === room.id ? [index] : []));
|
||||
const original = positions.map((index) => rows[index]);
|
||||
const order = [...original];
|
||||
const shapes = new Map(
|
||||
original.map((row) => {
|
||||
const pieces = row.pieceIds.map((id) => byId.get(id)!);
|
||||
const b = bounds(
|
||||
pieces.flatMap((p) => p.polygon.map((point) => boardPoint(point, room.direction))),
|
||||
);
|
||||
const rectangular =
|
||||
pieces.every((p) => !p.shaped && !p.ripEdge && close(p.width, s.width)) &&
|
||||
close(
|
||||
pieces.reduce((sum, p) => sum + p.length, 0),
|
||||
b.maxX - b.minX,
|
||||
);
|
||||
return [row, { y: b.minY, key: rectangular ? `${round(b.minX)}:${round(b.maxX)}` : '' }];
|
||||
}),
|
||||
);
|
||||
const scoreAt = (index: number) =>
|
||||
randomPatternPenalty(
|
||||
order.slice(Math.max(0, index - 4), index),
|
||||
room.id,
|
||||
order[index].offset,
|
||||
s,
|
||||
);
|
||||
let attempts = 0;
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
let best: { i: number; j: number; delta: number } | undefined;
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const key = shapes.get(order[i])!.key;
|
||||
if (!key) continue;
|
||||
for (let j = i + 1; j < order.length; j++) {
|
||||
if (++attempts > 4096) break;
|
||||
if (shapes.get(order[j])!.key !== key || close(order[i].offset, order[j].offset))
|
||||
continue;
|
||||
const affected = [
|
||||
...new Set(
|
||||
[i, j].flatMap((index) =>
|
||||
Array.from({ length: Math.min(5, order.length - index) }, (_, n) => index + n),
|
||||
),
|
||||
),
|
||||
];
|
||||
const before = affected.reduce((sum, index) => sum + scoreAt(index), 0);
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
const valid = [i, i + 1, j, j + 1].every(
|
||||
(index) =>
|
||||
index === 0 ||
|
||||
index >= order.length ||
|
||||
staggerAllowed(order[index - 1].offset, order[index].offset, s),
|
||||
);
|
||||
const delta = valid
|
||||
? affected.reduce((sum, index) => sum + scoreAt(index), 0) - before
|
||||
: 0;
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
if (delta < (best?.delta ?? 0)) best = { i, j, delta };
|
||||
}
|
||||
}
|
||||
if (!best) break;
|
||||
[order[best.i], order[best.j]] = [order[best.j], order[best.i]];
|
||||
improvement -= best.delta;
|
||||
}
|
||||
order.forEach((source, index) => {
|
||||
const target = original[index];
|
||||
if (source === target) return;
|
||||
rows[positions[index]] = { ...source, index: target.index };
|
||||
const shift = worldPoint(
|
||||
{ x: 0, y: shapes.get(target)!.y - shapes.get(source)!.y },
|
||||
room.direction,
|
||||
);
|
||||
source.pieceIds.forEach((id) => moves.set(id, { row: target.index, shift }));
|
||||
});
|
||||
}
|
||||
if (!improvement) return layout;
|
||||
return {
|
||||
...layout,
|
||||
rows,
|
||||
patternPenalty: layout.patternPenalty - improvement,
|
||||
pieces: layout.pieces.map((piece) => {
|
||||
const move = moves.get(piece.id);
|
||||
return move
|
||||
? {
|
||||
...piece,
|
||||
row: move.row,
|
||||
polygon: piece.polygon.map((p) => ({ x: p.x + move.shift.x, y: p.y + move.shift.y })),
|
||||
}
|
||||
: piece;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
|
||||
function fraction(seed: string): number {
|
||||
let hash = 2166136261;
|
||||
|
|
@ -452,11 +684,16 @@ function candidatePhases(
|
|||
return candidates.slice(0, 28);
|
||||
}
|
||||
|
||||
function compareLayouts(a: Layout, b: Layout): number {
|
||||
function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
|
||||
return (
|
||||
a.stocks.length - b.stocks.length ||
|
||||
a.patternPenalty - b.patternPenalty ||
|
||||
(s.pattern === 'random' && s.optimisation === 'random'
|
||||
? a.patternPenalty - b.patternPenalty
|
||||
: 0) ||
|
||||
a.cuts - b.cuts ||
|
||||
a.shapingCuts - b.shapingCuts ||
|
||||
a.ripCuts - b.ripCuts ||
|
||||
a.patternPenalty - b.patternPenalty ||
|
||||
a.stocks.length - b.stocks.length ||
|
||||
b.reusedCount - a.reusedCount ||
|
||||
compareText(
|
||||
a.rows.map((r) => r.offset.toFixed(3)).join(','),
|
||||
|
|
@ -495,15 +732,23 @@ function validateSettings(s: FlooringSettings): string | null {
|
|||
* Only full-width end remnants are reused; ripped strips are conservatively waste.
|
||||
* This is a practical cutting plan, not a claim of a global packing optimum.
|
||||
*/
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
function solveFlooring(
|
||||
rooms: Room[],
|
||||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
shapes: ShapeCache,
|
||||
): Solution {
|
||||
let layout: Layout = {
|
||||
pieces: [],
|
||||
rows: [],
|
||||
stocks: [],
|
||||
cuts: 0,
|
||||
shapingCuts: 0,
|
||||
ripCuts: 0,
|
||||
reusedCount: 0,
|
||||
patternPenalty: 0,
|
||||
};
|
||||
let finalists = [layout];
|
||||
const issues: Issue[] = [];
|
||||
const invalid = validateSettings(s);
|
||||
if (invalid) issues.push({ severity: 'error', message: invalid });
|
||||
|
|
@ -553,14 +798,22 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
let y = roomBounds.minY;
|
||||
for (let row = 0; row < widths.length && beam.length; row++) {
|
||||
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
|
||||
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
||||
if (!rowPaths.length) {
|
||||
y = end;
|
||||
continue;
|
||||
const key = `${room.id}:${widths[0]}:${row}`;
|
||||
let cached = shapes.get(key);
|
||||
if (!cached) {
|
||||
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
||||
if (!rowPaths.length) {
|
||||
y = end;
|
||||
continue;
|
||||
}
|
||||
cached = {
|
||||
shape: { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) },
|
||||
cuts: new Map(),
|
||||
};
|
||||
shapes.set(key, cached);
|
||||
}
|
||||
const shape: RowShape = { paths: rowPaths, start: y, end, bounds: bounds(rowPaths.flat()) };
|
||||
const { shape, cuts: cache } = cached;
|
||||
const alternatives: Layout[] = [];
|
||||
const cache = new Map<number, CutPiece[] | null>();
|
||||
for (const base of beam) {
|
||||
const roomRows = base.rows.filter((r) => r.roomId === room.id);
|
||||
const previousPhase = roomRows.at(-1)?.offset;
|
||||
|
|
@ -586,26 +839,32 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||
}
|
||||
}
|
||||
alternatives.sort(compareLayouts);
|
||||
// Keep distinct phases/inventories; identical routes do not earn extra beam slots.
|
||||
beam = alternatives
|
||||
.filter(
|
||||
(candidate, index) =>
|
||||
index === 0 ||
|
||||
candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset ||
|
||||
candidate.stocks
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.join(',') !==
|
||||
alternatives[index - 1].stocks
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.join(','),
|
||||
)
|
||||
.slice(0, beamWidth);
|
||||
alternatives.sort((a, b) => compareLayouts(a, b, s));
|
||||
// Histories with different recent joints can have different future pattern costs.
|
||||
const seen = new Set<string>();
|
||||
beam = [];
|
||||
for (const candidate of alternatives) {
|
||||
const phases = candidate.rows
|
||||
.filter((r) => r.roomId === room.id)
|
||||
.slice(s.pattern === 'random' ? -4 : -1)
|
||||
.map((r) => r.offset)
|
||||
.join(',');
|
||||
const inventory = candidate.stocks
|
||||
.filter((stock) => stock.remaining >= s.minLength - EPSILON)
|
||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||
.sort()
|
||||
.join(',');
|
||||
const key = `${phases}|${inventory}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
beam.push(candidate);
|
||||
if (beam.length === beamWidth) break;
|
||||
}
|
||||
y = end;
|
||||
}
|
||||
if (beam.length) complete.push(...beam);
|
||||
}
|
||||
complete.sort(compareLayouts);
|
||||
complete.sort((a, b) => compareLayouts(a, b, s));
|
||||
if (!complete.length) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
@ -615,6 +874,13 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
continue;
|
||||
}
|
||||
layout = complete[0];
|
||||
finalists = complete;
|
||||
}
|
||||
for (const candidate of finalists) {
|
||||
const improved = improveRowOrder(repack(candidate, s), rooms, s);
|
||||
if (compareLayouts(improved, layout, s) < 0) layout = improved;
|
||||
}
|
||||
for (const room of rooms) {
|
||||
if (layout.pieces.some((p) => p.roomId === room.id && p.shaped))
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
|
|
@ -622,6 +888,10 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
|||
message: `${room.name}: angled/notched cuts use the supplied shape templates. Minimum sizes check each piece's overall length and width; inspect narrow tips and joints against your flooring instructions.`,
|
||||
});
|
||||
}
|
||||
return { layout, issues };
|
||||
}
|
||||
|
||||
function finishFlooring({ layout, issues }: Solution, s: FlooringSettings): FlooringPlan {
|
||||
const coveredArea = layout.pieces.reduce((sum, piece) => sum + area(piece.polygon), 0);
|
||||
const purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0;
|
||||
const wasteArea = Math.max(0, purchasedArea - coveredArea);
|
||||
|
|
|
|||
|
|
@ -350,7 +350,12 @@ interface Packed {
|
|||
}
|
||||
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packed {
|
||||
function pack(
|
||||
source: SkirtingPiece[],
|
||||
stockLength: number,
|
||||
kerf: number,
|
||||
exactTails = false,
|
||||
): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
|
|
@ -360,6 +365,25 @@ function pack(source: SkirtingPiece[], stockLength: number, kerf: number): Packe
|
|||
while (pending.length) {
|
||||
let available = stockLength;
|
||||
const assigned: SkirtingPiece[] = [];
|
||||
if (exactTails && pending.length > 2) {
|
||||
const head = pending[0];
|
||||
const tail = stockLength - consumedLength(head, stockLength, kerf);
|
||||
const options = pending.slice(1, 129).filter((piece) => fits(piece, tail));
|
||||
for (const second of options) {
|
||||
const remaining = tail - consumedLength(second, tail, kerf);
|
||||
const third = options.find(
|
||||
(piece) => piece !== second && Math.abs(piece.length - remaining) <= EPS,
|
||||
);
|
||||
if (!third) continue;
|
||||
pending = [
|
||||
head,
|
||||
second,
|
||||
third,
|
||||
...pending.filter((piece) => piece !== head && piece !== second && piece !== third),
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const piece of pending) {
|
||||
if (!fits(piece, available)) continue;
|
||||
piece.blankLength = blankLength(piece, available);
|
||||
|
|
@ -525,6 +549,7 @@ export function planSkirting(
|
|||
piece.id = `${prefixes.get(piece.roomId)}${count}`;
|
||||
}
|
||||
candidates.push(pack(source, stockLength, settings.kerf));
|
||||
candidates.push(pack(source, stockLength, settings.kerf, true));
|
||||
};
|
||||
// Compare full-stock, balanced, and offcut-led wall joints.
|
||||
{
|
||||
|
|
@ -540,8 +565,8 @@ export function planSkirting(
|
|||
}
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||
a.cuts - b.cuts ||
|
||||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||
a.stocks.length - b.stocks.length ||
|
||||
a.pieces.length - b.pieces.length,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface FlooringSettings {
|
|||
width: number;
|
||||
gap: number;
|
||||
pattern: 'brick' | 'random';
|
||||
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
|
||||
optimisation?: 'cuts' | 'random';
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
minLength: number;
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ function flooring(value: unknown): FlooringSettings {
|
|||
width: number(data.width, 'Board width'),
|
||||
gap: number(data.gap, 'Expansion gap'),
|
||||
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
||||
optimisation: choice(
|
||||
data.optimisation === undefined ? 'cuts' : data.optimisation,
|
||||
['cuts', 'random'] as const,
|
||||
'Flooring optimisation',
|
||||
),
|
||||
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||
minLength: number(data.minLength, 'Minimum board length'),
|
||||
|
|
@ -198,6 +203,8 @@ function parseState(value: unknown): UrlState {
|
|||
const roomId = string(view.roomId, 'Selected room');
|
||||
const skirt = skirting(project.skirting);
|
||||
const completed = completedBoards(project.completedBoards);
|
||||
const previousOptimiser =
|
||||
object(project.flooring, 'Flooring settings').optimisation === undefined;
|
||||
return {
|
||||
project: {
|
||||
version: 1,
|
||||
|
|
@ -206,7 +213,7 @@ function parseState(value: unknown): UrlState {
|
|||
flooring: flooring(project.flooring),
|
||||
skirting: skirt.settings,
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: skirt.legacyCalculations ? [] : completed,
|
||||
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
|
|
@ -362,9 +369,11 @@ function parseQuery(query: string): UrlState {
|
|||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
key === 'optimisation' && !params.has('flooring.optimisation')
|
||||
? undefined
|
||||
: typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
]),
|
||||
),
|
||||
skirting: {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,33 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
import type { UrlState } from './types';
|
||||
import { decodeState, encodeState } from './url-state';
|
||||
|
||||
const readLocation = () => decodeState(window.location.search || window.location.hash);
|
||||
export const PROJECT_STORAGE_KEY = 'floor-planner.project';
|
||||
|
||||
const readLocation = () => {
|
||||
// The landing-page CTA opens the last project; it is not a project override.
|
||||
const link =
|
||||
window.location.search === '?planner' ? '' : window.location.search || window.location.hash;
|
||||
if (link && link !== '?' && link !== '#') return decodeState(link);
|
||||
try {
|
||||
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
|
||||
if (!saved.error) return saved;
|
||||
} catch {
|
||||
// Browser privacy settings can make localStorage unavailable.
|
||||
}
|
||||
return decodeState('');
|
||||
};
|
||||
|
||||
const saveLocal = (query: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(PROJECT_STORAGE_KEY, query);
|
||||
} catch {
|
||||
// Keep URL persistence working if storage is blocked or full.
|
||||
}
|
||||
};
|
||||
|
||||
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
|
||||
|
||||
/** Readable query parameters are the durable source of truth. */
|
||||
/** Persist every parameter in the URL and locally; explicit links take precedence. */
|
||||
export function useProject() {
|
||||
const [loaded, setLoaded] = useState(readLocation);
|
||||
useEffect(() => {
|
||||
|
|
@ -18,6 +41,7 @@ export function useProject() {
|
|||
const query = encodeState(next.state);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history.replaceState(null, '', queryUrl(query));
|
||||
saveLocal(query);
|
||||
} catch (error) {
|
||||
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
|
||||
}
|
||||
|
|
@ -39,6 +63,7 @@ export function useProject() {
|
|||
const query = encodeState(next);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||
saveLocal(query);
|
||||
const canonical = decodeState(query).state;
|
||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import Site from './Site';
|
||||
import { initAnalytics } from './lib/analytics';
|
||||
import './styles.css';
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
|
||||
initAnalytics();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<Site />);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ button:disabled {
|
|||
color: #78818f;
|
||||
background: #f1f1ed;
|
||||
}
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
|
|
@ -112,6 +113,10 @@ small {
|
|||
align-items: center;
|
||||
gap: 13px;
|
||||
}
|
||||
.app-title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.square-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
|
@ -557,6 +562,29 @@ summary span {
|
|||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
.optimisation-options {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.optimisation-options button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
.optimisation-options button.active {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
}
|
||||
.optimisation-options button small {
|
||||
color: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.drawing-card {
|
||||
border: 1px solid var(--ink);
|
||||
background: white;
|
||||
|
|
|
|||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Reference in a new issue