landing
All checks were successful
Deploy to Pages / build (push) Successful in 26s

This commit is contained in:
Andras Schmelczer 2026-09-11 20:34:36 +01:00
commit b9485e4097
32 changed files with 4692 additions and 65 deletions

View file

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

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

View file

@ -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
View 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
View 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');

View file

@ -43,6 +43,7 @@ export function defaultProject(): Project {
width: 190,
gap: 10,
pattern: 'brick',
optimisation: 'cuts',
minOffset: 300,
maxOffset: 600,
minLength: 200,

View file

@ -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);

View file

@ -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,
);

View file

@ -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;

View file

@ -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: {

View file

@ -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 {

View file

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

View file

@ -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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />