This commit is contained in:
parent
7959fe85cc
commit
b9485e4097
32 changed files with 4692 additions and 65 deletions
57
.forgejo/workflows/deploy.yml
Normal file
57
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: Deploy to Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: 'pages'
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
env:
|
||||||
|
CI: 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
continue-on-error: true
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: On main, copy build to host pages mount
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
||||||
|
with:
|
||||||
|
source: dist
|
||||||
|
target: floor-planner
|
||||||
|
|
||||||
|
- name: Copy build to staging pages mount
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
||||||
|
with:
|
||||||
|
source: dist
|
||||||
|
target: floor-planner-staging
|
||||||
10
README.md
10
README.md
|
|
@ -9,6 +9,12 @@ npm run dev
|
||||||
|
|
||||||
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
|
Use a supported Node.js release (22.12+ or 24+). Open the local URL printed by Vite.
|
||||||
|
|
||||||
|
The home page introduces the planner, a short DIY laminate fitting guide, and the difference
|
||||||
|
between scotia and skirting boards. **Plan my floor** opens the app at `?planner`, resuming the
|
||||||
|
last locally saved project when available. Existing project queries and legacy `#plan=` links
|
||||||
|
still open the planner directly. Both pages work on
|
||||||
|
static hosting without server-side routing. The planner loads only when opened.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm test # Pure geometry, planning, URL and rendering tests
|
npm test # Pure geometry, planning, URL and rendering tests
|
||||||
npx playwright install chromium
|
npx playwright install chromium
|
||||||
|
|
@ -25,7 +31,7 @@ npm run check # Build, unit tests and browser tests
|
||||||
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room.
|
4. Set material dimensions, spacer, kerf and minimum piece sizes alongside the measurements. Kerf means material removed by the blade: a 3 mm cut leaves 600 mm + 397 mm from a 1,000 mm board. Both kerf fields have keyboard and touch tooltips. Set one purchased skirting board length for the whole project. Materials are shared across rooms; direction is per room.
|
||||||
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
|
5. Room measurements, materials and cut plans open and close independently on the same page. The drawing stays pinned on the right while you scroll the panels; on phones a compact drawing stays above them. Flooring and skirting are visible together, with the whole drawing fitting without an internal scrollbar. Warnings follow their room, even when rooms share a name. Every installed board is drawn, including full lengths without labels. Cut codes sit above the wall and skirting drawing; short pieces use nearby labels with pointers so their names remain visible. The compact drawing header shows only the room, area and material legend. Only cuts and reusable offcuts receive sequential codes starting at F1 for flooring and A1 for the first room’s skirting. Flooring and skirting cuts are grouped by the purchased board they come from, including offcuts reused across rooms. Group headings use the cut codes; internal stock IDs stay out of the interface. Pieces wrap side by side when space allows. Each piece has a small preview that opens dimensions directly on its shape. Skirting previews use equal scales on both axes, with close-ups of each end to show the actual mitre angle. Numbered skirting pieces and their labels open cut details; uncut lengths use a neutral fill. Print all room drawings followed by flooring and skirting cut sheets grouped by purchased board. Every cut is included once, with its room and row or wall.
|
||||||
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
|
6. Find a cut by its code, or search by room name. Searching several cut codes together, such as `F27 + F28`, finds their pieces together. **Show on plan** highlights the source board’s pieces or an individual cut, with room buttons when a source is reused across rooms. A visible **Search active** banner stays above the pinned drawing; **Show all pieces** clears the search there. Source boards have **Mark done** checkboxes; completion marks appear on their list entries, installed pieces and printed sheets. Room search results also let you locate and mark uncut boards without adding cut labels to them. Measurements, wall order, flooring direction or material changes clear completion because the allocation changes; renaming rooms keeps it.
|
||||||
7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. There is no local storage, account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
|
7. Copy the link to save or share the complete project. Measurements, materials and view choices live in readable query parameters such as `flooring.length=1200`, `skirting.length=2400`, `room.1.wall.1.length=4800` and `selectedCut=F1`. Older stock-list links retain their first length; their completion marks are cleared because coping blanks and rounded stock allocation have changed, including for single-size links. Progress uses repeated `done=floor:B4` or `done=skirting:S6` parameters; `search=F1` and repeated `collapsed=materials` parameters preserve the search and closed panels. You can edit values directly in the URL. Older compressed fragment links still load and migrate automatically; obsolete step, layer and zoom choices are ignored. Reloading or opening that link reproduces the plan. Browser back/forward restores room changes. The same parameters are saved automatically in localStorage under `floor-planner.project`. Opening the app without URL parameters restores the last saved plan; explicit query or legacy fragment links take precedence and become the saved plan. If localStorage is unavailable or full, URL saving still works. There is no account, API, database or analytics. Fonts are system fonts; the app makes no external font requests.
|
||||||
|
|
||||||
## Calculation rules and practical limits
|
## Calculation rules and practical limits
|
||||||
|
|
||||||
|
|
@ -54,7 +60,7 @@ npm run check # Build, unit tests and browser tests
|
||||||
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
|
| `src/lib/piece-labels.ts` | Collision-free cut-code placement and pointers for small pieces |
|
||||||
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
|
| `src/lib/board-tracking.ts` | Exact board/cut matching and room-name search |
|
||||||
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
|
| `src/lib/wall-editing.ts` | Wall insertion and reordering with stable IDs |
|
||||||
| `src/lib/use-project.ts` | URL and browser-history synchronization |
|
| `src/lib/use-project.ts` | URL, localStorage and browser-history synchronization |
|
||||||
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
|
| `src/components/` | Editors, dimensioned SVGs, board groups and printable cut sheets |
|
||||||
| `tests/` | Pure calculation and state tests |
|
| `tests/` | Pure calculation and state tests |
|
||||||
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
|
| `e2e/` | Actual browser workflows at desktop and mobile sizes |
|
||||||
|
|
|
||||||
2018
benchmarks/baseline.json
Normal file
2018
benchmarks/baseline.json
Normal file
File diff suppressed because it is too large
Load diff
61
benchmarks/cases.ts
Normal file
61
benchmarks/cases.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { defaultProject, fireplace, rectangle } from '../src/lib/defaults';
|
||||||
|
import type { Project, Room } from '../src/lib/types';
|
||||||
|
import angled from '../tests/fixtures/angled-room.json';
|
||||||
|
import angledF8 from '../tests/fixtures/angled-room-f8.json';
|
||||||
|
|
||||||
|
export interface PlannerCase {
|
||||||
|
name: string;
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = (width: number, height: number, id = 'room-1'): Room => {
|
||||||
|
const result = rectangle(id, id, width, height);
|
||||||
|
result.walls.forEach((wall) => (wall.doors = []));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function plannerCases(): PlannerCase[] {
|
||||||
|
const cases: PlannerCase[] = [];
|
||||||
|
const add = (name: string, rooms: Room[], changes: Partial<Project['flooring']> = {}) => {
|
||||||
|
const project = defaultProject();
|
||||||
|
project.rooms = rooms;
|
||||||
|
project.flooring = { ...project.flooring, pattern: 'random', ...changes };
|
||||||
|
cases.push({ name, project });
|
||||||
|
};
|
||||||
|
for (const pattern of ['brick', 'random'] as const) {
|
||||||
|
add(`rectangle-${pattern}`, [rectangle()], { pattern });
|
||||||
|
add(`fireplace-${pattern}`, [fireplace('room-2', 'Fireplace')], { pattern });
|
||||||
|
add(`rotated-${pattern}`, [{ ...fireplace('room-2', 'Fireplace'), direction: 90 }], {
|
||||||
|
pattern,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fixed arithmetic sequences cover a range of remainders without runtime randomness.
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
add(`rectangle-${i + 1}`, [room(1620 + ((i * 733) % 3800), 620 + ((i * 379) % 1900))], {
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const rooms = [
|
||||||
|
room(1320 + ((i * 271) % 1900), 620 + ((i * 317) % 1200), 'r0'),
|
||||||
|
room(1420 + ((i * 653) % 2100), 820 + ((i * 211) % 1200), 'r1'),
|
||||||
|
];
|
||||||
|
add(`two-rooms-${i + 1}`, rooms, { width: 200 });
|
||||||
|
add(`two-rooms-reversed-${i + 1}`, [...rooms].reverse(), { width: 200 });
|
||||||
|
}
|
||||||
|
add('balanced-edges', [room(4020, 2050)], { width: 200 });
|
||||||
|
add('tight-offsets', [room(4020, 2220)], { minOffset: 250, maxOffset: 450 });
|
||||||
|
add('exact-offset', [room(4020, 2220)], { minOffset: 400, maxOffset: 400 });
|
||||||
|
add('forced-alternation', [room(2420, 1620)], { width: 200, minLength: 600 });
|
||||||
|
add('fractional-stock', [room(620, 420, 'r0'), room(617.2, 420, 'r1')], {
|
||||||
|
length: 1200.2,
|
||||||
|
width: 200,
|
||||||
|
});
|
||||||
|
add('large-rectangle', [room(14020, 8020)]);
|
||||||
|
add('long-room', [room(30020, 1820)]);
|
||||||
|
cases.push(
|
||||||
|
{ name: 'angled', project: angled.project as Project },
|
||||||
|
{ name: 'angled-f8', project: angledF8.project as Project },
|
||||||
|
);
|
||||||
|
return cases;
|
||||||
|
}
|
||||||
98
benchmarks/metrics.ts
Normal file
98
benchmarks/metrics.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import type { FlooringPlan, FlooringSettings, SkirtingPlan } from '../src/lib/types';
|
||||||
|
|
||||||
|
/** Evaluate public plan output. Keep these definitions fixed when comparing search changes. */
|
||||||
|
export function flooringMetrics(plan: FlooringPlan, s: FlooringSettings) {
|
||||||
|
let shapingCuts = 0;
|
||||||
|
let ripCuts = 0;
|
||||||
|
let patternPenalty = 0;
|
||||||
|
let repeats = 0;
|
||||||
|
let similarSteps = 0;
|
||||||
|
let staircases = 0;
|
||||||
|
for (const piece of plan.pieces) {
|
||||||
|
ripCuts += piece.ripEdge === 'both' ? 2 : Number(!!piece.ripEdge);
|
||||||
|
if (!piece.shaped) continue;
|
||||||
|
const close = (a: number, b: number) => Math.abs(a - b) <= 0.01;
|
||||||
|
shapingCuts += piece.template.filter((p, i, path) => {
|
||||||
|
const q = path[(i + 1) % path.length];
|
||||||
|
return !(
|
||||||
|
(close(p.x, q.x) && (close(p.x, 0) || close(p.x, piece.length))) ||
|
||||||
|
(close(p.y, q.y) && (close(p.y, 0) || close(p.y, piece.width)))
|
||||||
|
);
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
const history = new Map<string, number[]>();
|
||||||
|
const step = (from: number, to: number) => {
|
||||||
|
const value = (((to - from + s.length / 2) % s.length) + s.length) % s.length;
|
||||||
|
return ((Math.round(value * 1000) / 1000) % s.length) - s.length / 2;
|
||||||
|
};
|
||||||
|
const similar = Math.max(s.kerf * 2, s.length * 0.03);
|
||||||
|
for (const row of s.pattern === 'random' ? plan.rows : []) {
|
||||||
|
const recent = history.get(row.roomId) ?? [];
|
||||||
|
const phase = row.offset;
|
||||||
|
if (recent.length >= 2) {
|
||||||
|
const movement = step(recent.at(-1)!, phase);
|
||||||
|
const previous = step(recent.at(-2)!, recent.at(-1)!);
|
||||||
|
patternPenalty += Number(Math.abs(Math.abs(movement) - s.length / 2) <= 0.01);
|
||||||
|
for (let distance = 2; distance <= recent.length; distance++) {
|
||||||
|
if (Math.abs(step(recent.at(-distance)!, phase)) <= similar) {
|
||||||
|
patternPenalty += 5 - distance;
|
||||||
|
if (distance === 2) repeats++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (movement * previous > 0) {
|
||||||
|
patternPenalty++;
|
||||||
|
if (Math.abs(movement - previous) <= similar) {
|
||||||
|
patternPenalty += 3;
|
||||||
|
similarSteps++;
|
||||||
|
}
|
||||||
|
if (recent.length >= 3 && previous * step(recent.at(-3)!, recent.at(-2)!) > 0) {
|
||||||
|
patternPenalty += 2;
|
||||||
|
staircases++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
history.set(row.roomId, [...recent, phase].slice(-4));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cuts: plan.cuts,
|
||||||
|
shapingCuts,
|
||||||
|
ripCuts,
|
||||||
|
patternPenalty,
|
||||||
|
repeats,
|
||||||
|
similarSteps,
|
||||||
|
staircases,
|
||||||
|
boards: plan.boardCount,
|
||||||
|
waste: plan.wasteArea,
|
||||||
|
errors: plan.issues.filter((issue) => issue.severity === 'error').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skirtingMetrics(plan: SkirtingPlan) {
|
||||||
|
return {
|
||||||
|
cuts: plan.cuts,
|
||||||
|
pieces: plan.pieces.length,
|
||||||
|
boards: plan.stockCount,
|
||||||
|
waste: Math.round(plan.waste * 1000) / 1000,
|
||||||
|
errors: plan.issues.filter((issue) => issue.severity === 'error').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareFlooring(
|
||||||
|
a: ReturnType<typeof flooringMetrics>,
|
||||||
|
b: typeof a,
|
||||||
|
random = false,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
a.errors - b.errors ||
|
||||||
|
(random ? a.patternPenalty - b.patternPenalty : 0) ||
|
||||||
|
a.cuts - b.cuts ||
|
||||||
|
a.shapingCuts - b.shapingCuts ||
|
||||||
|
a.ripCuts - b.ripCuts ||
|
||||||
|
a.patternPenalty - b.patternPenalty ||
|
||||||
|
a.boards - b.boards
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareSkirting(a: ReturnType<typeof skirtingMetrics>, b: typeof a) {
|
||||||
|
return a.errors - b.errors || a.cuts - b.cuts || a.waste - b.waste;
|
||||||
|
}
|
||||||
60
e2e/landing.spec.ts
Normal file
60
e2e/landing.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('the home page explains the app and its guides fit the screen', async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
|
||||||
|
'A great floorstarts witha little planning.',
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Scotia or skirting board?' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'Plan my floor' })).toHaveCount(2);
|
||||||
|
await expect(page.getByRole('link', { name: 'Plan my floor' }).first()).toBeInViewport();
|
||||||
|
await page.getByRole('link', { name: 'First time fitting a floor?' }).click();
|
||||||
|
await expect(page).toHaveURL(/#guide$/);
|
||||||
|
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: /Your first floor/ })).toBeInViewport();
|
||||||
|
await page.getByRole('link', { name: 'Back to top' }).click();
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toBeInViewport();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||||
|
page.viewportSize()!.width,
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, selector] of [
|
||||||
|
['header', '.landing-header'],
|
||||||
|
['hero', '.landing-hero'],
|
||||||
|
['closing', '.landing-final-cta'],
|
||||||
|
]) {
|
||||||
|
test(`the ${name} CTA opens a working planner and browser history returns home`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator(selector).locator('a[href="?planner"]').click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4800');
|
||||||
|
await expect(page).toHaveURL(/\?version=1&/);
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('My first floor');
|
||||||
|
const projectUrl = page.url();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor');
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
|
||||||
|
await page.goForward();
|
||||||
|
await expect(page).toHaveURL(projectUrl);
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('My first floor');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returning through the home page resumes the saved project', async ({ page }) => {
|
||||||
|
await page.goto('/?planner');
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Weekend project');
|
||||||
|
await page.getByLabel('Room width', { exact: true }).fill('4200');
|
||||||
|
await page.getByRole('link', { name: 'Floor planner home' }).click();
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText('A great floor');
|
||||||
|
await page.getByRole('link', { name: 'Plan my floor' }).first().click();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Weekend project');
|
||||||
|
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('4200');
|
||||||
|
});
|
||||||
68
e2e/optimisation.spec.ts
Normal file
68
e2e/optimisation.spec.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { defaultState } from '../src/lib/defaults';
|
||||||
|
import { planFlooringOptions } from '../src/lib/flooring';
|
||||||
|
import { mm } from '../src/lib/format';
|
||||||
|
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||||
|
|
||||||
|
test('random optimisation choices show actual totals, select the plan and survive reload', async ({
|
||||||
|
page,
|
||||||
|
isMobile,
|
||||||
|
}) => {
|
||||||
|
if (isMobile) await page.setViewportSize({ width: 320, height: 844 });
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.flooring.pattern = 'random';
|
||||||
|
const p = state.project;
|
||||||
|
const options = planFlooringOptions(p.rooms, p.flooring, p.tolerance);
|
||||||
|
await page.goto(`/${encodeState(state)}`);
|
||||||
|
const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true });
|
||||||
|
const cuts = choices.getByRole('button', { name: /^Minimise cuts/ });
|
||||||
|
const random = choices.getByRole('button', { name: /^Maximise randomness/ });
|
||||||
|
await expect(cuts).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cuts).toContainText(
|
||||||
|
`${options.cuts.cuts} cuts · ${mm(options.cuts.wastePercent)}% waste`,
|
||||||
|
);
|
||||||
|
await expect(random).toContainText(
|
||||||
|
`${options.random.cuts} cuts · ${mm(options.random.wastePercent)}% waste`,
|
||||||
|
);
|
||||||
|
expect(options.cuts.cuts).toBeLessThan(options.random.cuts);
|
||||||
|
const checkbox = page.locator('.stock-cut-group input[type="checkbox"]').first();
|
||||||
|
await checkbox.check();
|
||||||
|
expect(new URL(page.url()).searchParams.getAll('done')).not.toEqual([]);
|
||||||
|
await random.click();
|
||||||
|
await expect(random).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(cuts).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(new URL(page.url()).searchParams.get('flooring.optimisation')).toBe('random');
|
||||||
|
expect(new URL(page.url()).searchParams.getAll('done')).toEqual([]);
|
||||||
|
const drawn = page.getByRole('region', { name: 'Floor plan preview' }).locator('.floor-piece');
|
||||||
|
await expect(drawn).toHaveCount(options.random.pieces.length);
|
||||||
|
const ids = await drawn.evaluateAll((pieces) =>
|
||||||
|
pieces.map((piece) => piece.getAttribute('data-piece-id')),
|
||||||
|
);
|
||||||
|
expect(ids.sort()).toEqual(options.random.pieces.map((piece) => piece.id).sort());
|
||||||
|
await page.reload();
|
||||||
|
await expect(random).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(decodeState(new URL(page.url()).search).state.project.flooring.optimisation).toBe(
|
||||||
|
'random',
|
||||||
|
);
|
||||||
|
await cuts.click();
|
||||||
|
await expect(cuts).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(drawn).toHaveCount(options.cuts.pieces.length);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
|
||||||
|
page.viewportSize()!.width,
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Brick', exact: true }).click();
|
||||||
|
await expect(choices).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('infeasible measurements do not advertise zero-cut or zero-waste options', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.flooring.pattern = 'random';
|
||||||
|
state.project.flooring.minOffset = 700;
|
||||||
|
await page.goto(`/${encodeState(state)}`);
|
||||||
|
const choices = page.getByRole('group', { name: 'Flooring optimisation', exact: true });
|
||||||
|
await expect(choices.getByText('Check measurements', { exact: true })).toHaveCount(2);
|
||||||
|
await expect(choices).not.toContainText('0 cuts');
|
||||||
|
await expect(choices).not.toContainText('0% waste');
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Locator, Page } from '@playwright/test';
|
||||||
import LZString from 'lz-string';
|
import LZString from 'lz-string';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { decodeState, encodeState } from '../src/lib/url-state';
|
import { decodeState, encodeState } from '../src/lib/url-state';
|
||||||
|
import { PROJECT_STORAGE_KEY } from '../src/lib/use-project';
|
||||||
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
|
import { defaultState, fireplace, rectangle } from '../src/lib/defaults';
|
||||||
import { planFlooring } from '../src/lib/flooring';
|
import { planFlooring } from '../src/lib/flooring';
|
||||||
import { planSkirting } from '../src/lib/skirting';
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
|
|
@ -23,6 +24,8 @@ const state = (page: Page) => {
|
||||||
const url = new URL(page.url());
|
const url = new URL(page.url());
|
||||||
return decodeState(url.search || url.hash).state;
|
return decodeState(url.search || url.hash).state;
|
||||||
};
|
};
|
||||||
|
const savedQuery = (page: Page) =>
|
||||||
|
page.evaluate((key) => localStorage.getItem(key), PROJECT_STORAGE_KEY);
|
||||||
const plans = (page: Page) => {
|
const plans = (page: Page) => {
|
||||||
const { project } = state(page);
|
const { project } = state(page);
|
||||||
return {
|
return {
|
||||||
|
|
@ -37,7 +40,8 @@ function sharedRoomFixture() {
|
||||||
fixture.project.flooring.width = 200;
|
fixture.project.flooring.width = 200;
|
||||||
fixture.project.rooms = [
|
fixture.project.rooms = [
|
||||||
rectangle('room-1', 'Living room', 1820, 1220),
|
rectangle('room-1', 'Living room', 1820, 1220),
|
||||||
rectangle('room-2', 'Study', 1220, 1820),
|
// Its end pieces complement the first room's offcuts after saw kerf.
|
||||||
|
rectangle('room-2', 'Study', 1220, 1814),
|
||||||
];
|
];
|
||||||
fixture.project.rooms.forEach((room) =>
|
fixture.project.rooms.forEach((room) =>
|
||||||
room.walls.forEach((wall) => {
|
room.walls.forEach((wall) => {
|
||||||
|
|
@ -259,7 +263,7 @@ async function noWallLabelOverlap(page: Page) {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
errors.set(page, []);
|
errors.set(page, []);
|
||||||
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
page.on('pageerror', (error) => errors.get(page)!.push(error.message));
|
||||||
await page.goto('/');
|
await page.goto('/?planner');
|
||||||
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Room measurements' })).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\?version=1&/);
|
await expect(page).toHaveURL(/\?version=1&/);
|
||||||
});
|
});
|
||||||
|
|
@ -679,9 +683,115 @@ test('pattern, direction and spacers persist without steps, layers or stored res
|
||||||
).toHaveAttribute('aria-pressed', 'true');
|
).toHaveAttribute('aria-pressed', 'true');
|
||||||
await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true });
|
await expect(page.locator('.flooring-cuts')).toHaveText(table, { useInnerText: true });
|
||||||
await noHorizontalOverflow(page);
|
await noHorizontalOverflow(page);
|
||||||
expect(await page.evaluate(() => Object.keys(localStorage))).toEqual([]);
|
expect(await savedQuery(page)).toBe(new URL(page.url()).search);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('localStorage restores all project and view parameters when reopening without a link', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const fixture = sharedRoomFixture();
|
||||||
|
fixture.project.name = 'Café 北 flooring';
|
||||||
|
fixture.project.tolerance = 7;
|
||||||
|
fixture.project.flooring = {
|
||||||
|
...fixture.project.flooring,
|
||||||
|
length: 1300,
|
||||||
|
width: 180,
|
||||||
|
gap: 12,
|
||||||
|
pattern: 'random',
|
||||||
|
minOffset: 250,
|
||||||
|
maxOffset: 600,
|
||||||
|
minLength: 180,
|
||||||
|
minWidth: 50,
|
||||||
|
kerf: 2.5,
|
||||||
|
};
|
||||||
|
fixture.project.skirting = { stockLength: 3600, thickness: 20, minLength: 250, kerf: 2 };
|
||||||
|
fixture.project.rooms[1].walls[0].doors = [{ id: 'study-door', offset: 300, width: 850 }];
|
||||||
|
fixture.project.completedBoards = ['floor:B1', 'skirting:S1'];
|
||||||
|
fixture.view = {
|
||||||
|
roomId: 'room-2',
|
||||||
|
selectedPiece: 'F1',
|
||||||
|
search: 'Study',
|
||||||
|
collapsedSections: ['cuts', 'materials'],
|
||||||
|
};
|
||||||
|
const query = encodeState(fixture);
|
||||||
|
await page.goto(`/${query}`);
|
||||||
|
await expect(page.getByRole('dialog', { name: 'Flooring cut F1', exact: true })).toBeVisible();
|
||||||
|
expect(await savedQuery(page)).toBe(query);
|
||||||
|
|
||||||
|
const reopened = await context.newPage();
|
||||||
|
await reopened.goto('/?planner');
|
||||||
|
await expect(reopened).toHaveURL(page.url());
|
||||||
|
expect(state(reopened)).toEqual(fixture);
|
||||||
|
await expect(
|
||||||
|
reopened.getByRole('dialog', { name: 'Flooring cut F1', exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await reopened.keyboard.press('Escape');
|
||||||
|
await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||||||
|
await expect(reopened.locator('#materials-content')).toBeHidden();
|
||||||
|
await expect(reopened.locator('#cuts-content')).toBeHidden();
|
||||||
|
await reopened.getByLabel('Room name', { exact: true }).fill('Saved study');
|
||||||
|
fixture.project.rooms[1].name = 'Saved study';
|
||||||
|
fixture.view.selectedPiece = null;
|
||||||
|
expect(await savedQuery(reopened)).toBe(encodeState(fixture));
|
||||||
|
await reopened.goto('/?planner');
|
||||||
|
await expect(reopened.getByLabel('Room name', { exact: true })).toHaveValue('Saved study');
|
||||||
|
expect(state(reopened)).toEqual(fixture);
|
||||||
|
await reopened.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit query parameters override the saved plan and default omitted fields', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('Saved room');
|
||||||
|
await page.getByLabel('Wall spacer', { exact: true }).fill('12');
|
||||||
|
await page.goto('/?flooring.length=1400');
|
||||||
|
await expect(page.getByLabel('Board length', { exact: true })).toHaveValue('1400');
|
||||||
|
const expected = defaultState();
|
||||||
|
expected.project.flooring.length = 1400;
|
||||||
|
expect(state(page)).toEqual(expected);
|
||||||
|
expect(await savedQuery(page)).toBe(encodeState(expected));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const saved of ['damaged data', '?flooring.length=broken']) {
|
||||||
|
test(`invalid localStorage falls back to defaults: ${saved}`, async ({ page }) => {
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => localStorage.setItem(key, value),
|
||||||
|
[PROJECT_STORAGE_KEY, saved],
|
||||||
|
);
|
||||||
|
await page.goto('/?planner');
|
||||||
|
await expect(page).toHaveURL(/\?version=1&/);
|
||||||
|
await expect(page.locator('.url-error')).toHaveCount(0);
|
||||||
|
expect(state(page)).toEqual(defaultState());
|
||||||
|
expect(await savedQuery(page)).toBe(encodeState(defaultState()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failure of ['blocked', 'full']) {
|
||||||
|
test(`URL persistence still works when localStorage is ${failure}`, async ({ page }) => {
|
||||||
|
await page.addInitScript((failure) => {
|
||||||
|
if (failure === 'blocked') {
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
get() {
|
||||||
|
throw new DOMException('Storage blocked', 'SecurityError');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Storage.prototype.setItem = () => {
|
||||||
|
throw new DOMException('Storage full', 'QuotaExceededError');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, failure);
|
||||||
|
await page.goto('/?planner');
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Living room');
|
||||||
|
await page.getByLabel('Room name', { exact: true }).fill('URL still works');
|
||||||
|
expect(state(page).project.rooms[0].name).toBe('URL still works');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('URL still works');
|
||||||
|
await expect(page.locator('.url-error')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => {
|
test('a narrow remainder is shared between the first and last flooring rows', async ({ page }) => {
|
||||||
// 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver.
|
// 3650 minus two 10 mm spacers leaves 19 full rows and a 20 mm sliver.
|
||||||
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
await page.getByLabel('Room length', { exact: true }).fill('3650');
|
||||||
|
|
@ -780,12 +890,15 @@ test('saw kerf help works with keyboard and touch or mouse', async ({ page, isMo
|
||||||
});
|
});
|
||||||
|
|
||||||
test('invalid URLs recover when measurements are edited', async ({ page }) => {
|
test('invalid URLs recover when measurements are edited', async ({ page }) => {
|
||||||
|
const saved = await savedQuery(page);
|
||||||
await page.goto('/?flooring.length=broken');
|
await page.goto('/?flooring.length=broken');
|
||||||
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
|
await expect(page.getByText(/Could not load this plan/)).toBeVisible();
|
||||||
|
expect(await savedQuery(page)).toBe(saved);
|
||||||
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
|
await page.getByLabel('Room name', { exact: true }).fill('Recovered plan');
|
||||||
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
|
await expect(page.getByText(/Could not load this plan/)).toHaveCount(0);
|
||||||
expect(decodeState(new URL(page.url()).search).error).toBeNull();
|
expect(decodeState(new URL(page.url()).search).error).toBeNull();
|
||||||
expect(new URL(page.url()).hash).toBe('');
|
expect(new URL(page.url()).hash).toBe('');
|
||||||
|
expect(await savedQuery(page)).toBe(new URL(page.url()).search);
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Recovered plan');
|
||||||
});
|
});
|
||||||
|
|
@ -818,6 +931,7 @@ test('readable query fields, legacy links and browser history preserve the plan'
|
||||||
await expect(page).toHaveURL(/\?version=1&/);
|
await expect(page).toHaveURL(/\?version=1&/);
|
||||||
expect(new URL(page.url()).hash).toBe('');
|
expect(new URL(page.url()).hash).toBe('');
|
||||||
expect(state(page)).toEqual(legacy);
|
expect(state(page)).toEqual(legacy);
|
||||||
|
expect(await savedQuery(page)).toBe(encodeState(legacy));
|
||||||
const original = page.url();
|
const original = page.url();
|
||||||
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
await page.getByRole('button', { name: 'Add room', exact: true }).click();
|
||||||
await page.getByLabel('Room name', { exact: true }).fill('Study');
|
await page.getByLabel('Room name', { exact: true }).fill('Study');
|
||||||
|
|
@ -833,10 +947,12 @@ test('readable query fields, legacy links and browser history preserve the plan'
|
||||||
await expect(page).toHaveURL(original);
|
await expect(page).toHaveURL(original);
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Legacy lounge');
|
||||||
expect(state(page).project.rooms).toHaveLength(1);
|
expect(state(page).project.rooms).toHaveLength(1);
|
||||||
|
expect(await savedQuery(page)).toBe(new URL(original).search);
|
||||||
await page.goForward();
|
await page.goForward();
|
||||||
await expect(page).toHaveURL(twoRooms);
|
await expect(page).toHaveURL(twoRooms);
|
||||||
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
await expect(page.getByLabel('Room name', { exact: true })).toHaveValue('Study');
|
||||||
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
|
await expect(page.getByLabel('Room width', { exact: true })).toHaveValue('3333');
|
||||||
|
expect(await savedQuery(page)).toBe(new URL(twoRooms).search);
|
||||||
params.set('flooring.width', '205');
|
params.set('flooring.width', '205');
|
||||||
await page.goto(`/?${params}`);
|
await page.goto(`/?${params}`);
|
||||||
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
|
await expect(page.getByLabel('Board width', { exact: true })).toHaveValue('205');
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,10 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#17263f" />
|
<meta name="theme-color" content="#17263f" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Room measurements, flooring and skirting materials, and labelled cutting plans."
|
content="Plan your DIY floor with room layouts, material quantities and labelled cutting plans. Learn how to fit laminate and choose between scotia and skirting boards."
|
||||||
/>
|
/>
|
||||||
<title>Floor planner</title>
|
<title>Floor planner</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "floor-planner",
|
"name": "floor-planner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plausible-analytics/tracker": "^0.4.6",
|
||||||
"clipper-lib": "^6.4.2",
|
"clipper-lib": "^6.4.2",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
|
|
@ -819,6 +820,12 @@
|
||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@plausible-analytics/tracker": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-nbu6xGgSP6jeYuoVklZBzucUnIpo75tVu3Kr+R0EDycVKaMC/jF5Q/YCaM/8gR4fPZ1IDcXt6FdBJFZ1uZwEPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"benchmark": "node scripts/benchmark-planner.mjs",
|
||||||
"check": "npm run build && npm test && npm run test:e2e",
|
"check": "npm run build && npm test && npm run test:e2e",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plausible-analytics/tracker": "^0.4.6",
|
||||||
"clipper-lib": "^6.4.2",
|
"clipper-lib": "^6.4.2",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"lz-string": "^1.5.0",
|
"lz-string": "^1.5.0",
|
||||||
|
|
|
||||||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="8" fill="#fffdf7" />
|
||||||
|
<path d="M12 10v42h42" fill="none" stroke="#b74900" stroke-width="12" />
|
||||||
|
<path d="M34 13h17v17" fill="none" stroke="#17263f" stroke-width="6" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
88
scripts/benchmark-planner.mjs
Normal file
88
scripts/benchmark-planner.mjs
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
|
||||||
|
const server = await createServer({ server: { middlewareMode: true }, appType: 'custom' });
|
||||||
|
try {
|
||||||
|
const [{ planFlooring }, { planSkirting }, { plannerCases }, metrics] = await Promise.all([
|
||||||
|
server.ssrLoadModule('/src/lib/flooring.ts'),
|
||||||
|
server.ssrLoadModule('/src/lib/skirting.ts'),
|
||||||
|
server.ssrLoadModule('/benchmarks/cases.ts'),
|
||||||
|
server.ssrLoadModule('/benchmarks/metrics.ts'),
|
||||||
|
]);
|
||||||
|
const cases = plannerCases();
|
||||||
|
const random = process.argv.includes('--random');
|
||||||
|
if (random) cases.forEach(({ project }) => (project.flooring.optimisation = 'random'));
|
||||||
|
const results = [];
|
||||||
|
// Warm up the geometry and planners before taking timings.
|
||||||
|
planFlooring(cases[0].project.rooms, cases[0].project.flooring, cases[0].project.tolerance);
|
||||||
|
for (const { name, project: p } of cases) {
|
||||||
|
const start = performance.now();
|
||||||
|
const flooring = planFlooring(p.rooms, p.flooring, p.tolerance);
|
||||||
|
const floorMs = performance.now() - start;
|
||||||
|
const skirtStart = performance.now();
|
||||||
|
const skirting = planSkirting(p.rooms, p.skirting, p.tolerance);
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
flooring: metrics.flooringMetrics(flooring, p.flooring),
|
||||||
|
skirting: metrics.skirtingMetrics(skirting),
|
||||||
|
floorMs: Math.round(floorMs * 10) / 10,
|
||||||
|
skirtMs: Math.round((performance.now() - skirtStart) * 10) / 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const baselinePath = new URL('../benchmarks/baseline.json', import.meta.url);
|
||||||
|
if (process.argv.includes('--write-baseline')) {
|
||||||
|
await writeFile(baselinePath, JSON.stringify(results, null, 2) + '\n');
|
||||||
|
console.log(`Recorded ${results.length} baseline cases.`);
|
||||||
|
} else {
|
||||||
|
const comparison = process.argv
|
||||||
|
.find((arg) => arg.startsWith('--compare='))
|
||||||
|
?.slice('--compare='.length);
|
||||||
|
const baseline = JSON.parse(await readFile(comparison ?? baselinePath, 'utf8'));
|
||||||
|
const sum = (rows, key, metric) => rows.reduce((total, row) => total + row[key][metric], 0);
|
||||||
|
const summary = [];
|
||||||
|
for (const kind of ['flooring', 'skirting']) {
|
||||||
|
const compare =
|
||||||
|
kind === 'flooring'
|
||||||
|
? (a, b) => metrics.compareFlooring(a, b, random)
|
||||||
|
: metrics.compareSkirting;
|
||||||
|
const changes = results.map((result) => {
|
||||||
|
const previous = baseline.find((row) => row.name === result.name);
|
||||||
|
if (!previous) throw new Error(`No baseline for ${result.name}`);
|
||||||
|
return { name: result.name, change: compare(result[kind], previous[kind]) };
|
||||||
|
});
|
||||||
|
summary.push({
|
||||||
|
planner: kind,
|
||||||
|
improved: changes.filter((row) => row.change < 0).length,
|
||||||
|
tied: changes.filter((row) => row.change === 0).length,
|
||||||
|
regressed: changes.filter((row) => row.change > 0).length,
|
||||||
|
'cuts before': sum(baseline, kind, 'cuts'),
|
||||||
|
'cuts after': sum(results, kind, 'cuts'),
|
||||||
|
'boards before': sum(baseline, kind, 'boards'),
|
||||||
|
'boards after': sum(results, kind, 'boards'),
|
||||||
|
errors: sum(results, kind, 'errors'),
|
||||||
|
});
|
||||||
|
const regressions = changes.filter((row) => row.change > 0).map((row) => row.name);
|
||||||
|
if (regressions.length) console.log(`${kind} regressions: ${regressions.join(', ')}`);
|
||||||
|
}
|
||||||
|
console.table(summary);
|
||||||
|
console.table(
|
||||||
|
['shapingCuts', 'ripCuts', 'patternPenalty', 'repeats', 'similarSteps', 'staircases'].map(
|
||||||
|
(metric) => ({
|
||||||
|
metric,
|
||||||
|
before: sum(baseline, 'flooring', metric),
|
||||||
|
after: sum(results, 'flooring', metric),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const key of ['floorMs', 'skirtMs']) {
|
||||||
|
const times = results.map((row) => row[key]).sort((a, b) => a - b);
|
||||||
|
console.log(
|
||||||
|
`${key}: total ${times.reduce((a, b) => a + b, 0).toFixed(1)} ms; p95 ${times[Math.floor(times.length * 0.95)]} ms; max ${times.at(-1)} ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const output = process.argv.find((arg) => arg.startsWith('--output='))?.slice('--output='.length);
|
||||||
|
if (output) await writeFile(output, JSON.stringify(results, null, 2) + '\n');
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
35
src/App.tsx
35
src/App.tsx
|
|
@ -2,9 +2,10 @@ import { useMemo, useRef } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
|
import { Copy, Plus, Printer, Search, X } from 'lucide-react';
|
||||||
import { useProject } from './lib/use-project';
|
import { useProject } from './lib/use-project';
|
||||||
|
import { trackCopyLink, trackPrint, trackRoomAdded, trackRoomRemoved } from './lib/analytics';
|
||||||
import { rectangle, nextId } from './lib/defaults';
|
import { rectangle, nextId } from './lib/defaults';
|
||||||
import { buildGeometry } from './lib/geometry';
|
import { buildGeometry } from './lib/geometry';
|
||||||
import { planFlooring } from './lib/flooring';
|
import { planFlooringOptions } from './lib/flooring';
|
||||||
import { planSkirting } from './lib/skirting';
|
import { planSkirting } from './lib/skirting';
|
||||||
import { area, mm } from './lib/format';
|
import { area, mm } from './lib/format';
|
||||||
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||||
|
|
@ -70,10 +71,13 @@ export default function App() {
|
||||||
[project.rooms, project.tolerance],
|
[project.rooms, project.tolerance],
|
||||||
);
|
);
|
||||||
const geometry = geometries[project.rooms.indexOf(room)];
|
const geometry = geometries[project.rooms.indexOf(room)];
|
||||||
const flooring = useMemo(
|
// Switching between already calculated options only selects a result.
|
||||||
() => planFlooring(project.rooms, project.flooring, project.tolerance),
|
const flooringInputs = JSON.stringify({ ...project.flooring, optimisation: undefined });
|
||||||
[project.rooms, project.flooring, project.tolerance],
|
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(
|
const skirting = useMemo(
|
||||||
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||||
[project.rooms, project.skirting, project.tolerance],
|
[project.rooms, project.skirting, project.tolerance],
|
||||||
|
|
@ -144,7 +148,7 @@ export default function App() {
|
||||||
...s,
|
...s,
|
||||||
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
||||||
}));
|
}));
|
||||||
const addRoom = () =>
|
const addRoom = () => {
|
||||||
update((s) => {
|
update((s) => {
|
||||||
const id = nextId(
|
const id = nextId(
|
||||||
'room',
|
'room',
|
||||||
|
|
@ -168,7 +172,9 @@ export default function App() {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, true);
|
}, true);
|
||||||
const removeRoom = () =>
|
trackRoomAdded();
|
||||||
|
};
|
||||||
|
const removeRoom = () => {
|
||||||
update((s) => {
|
update((s) => {
|
||||||
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
const rooms = s.project.rooms.filter((r) => r.id !== room.id);
|
||||||
return {
|
return {
|
||||||
|
|
@ -177,10 +183,13 @@ export default function App() {
|
||||||
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
|
view: { ...s.view, roomId: rooms[0].id, selectedPiece: null, search: '' },
|
||||||
};
|
};
|
||||||
}, true);
|
}, true);
|
||||||
|
trackRoomRemoved();
|
||||||
|
};
|
||||||
const copyLink = async () => {
|
const copyLink = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
|
if (copyStatus.current) copyStatus.current.textContent = 'Link copied';
|
||||||
|
trackCopyLink();
|
||||||
} catch {
|
} catch {
|
||||||
if (copyStatus.current)
|
if (copyStatus.current)
|
||||||
copyStatus.current.textContent = 'Copy the address from your browser.';
|
copyStatus.current.textContent = 'Copy the address from your browser.';
|
||||||
|
|
@ -190,10 +199,14 @@ export default function App() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="app-header">
|
<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" />
|
<span className="square-mark" aria-hidden="true" />
|
||||||
<h1>Floor planner</h1>
|
<h1>Floor planner</h1>
|
||||||
</div>
|
</a>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="button" onClick={copyLink}>
|
<button className="button" onClick={copyLink}>
|
||||||
<Copy size={17} />
|
<Copy size={17} />
|
||||||
|
|
@ -201,7 +214,10 @@ export default function App() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="button"
|
className="button"
|
||||||
onClick={() => window.print()}
|
onClick={() => {
|
||||||
|
trackPrint();
|
||||||
|
window.print();
|
||||||
|
}}
|
||||||
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
|
disabled={floorErrors.length > 0 || skirtErrors.length > 0}
|
||||||
>
|
>
|
||||||
<Printer size={17} />
|
<Printer size={17} />
|
||||||
|
|
@ -282,6 +298,7 @@ export default function App() {
|
||||||
>
|
>
|
||||||
<MaterialsEditor
|
<MaterialsEditor
|
||||||
project={project}
|
project={project}
|
||||||
|
flooringOptions={flooringOptions}
|
||||||
room={room}
|
room={room}
|
||||||
changeRoom={changeRoom}
|
changeRoom={changeRoom}
|
||||||
changeProject={changeProject}
|
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 { ArrowRight, ArrowDown } from 'lucide-react';
|
||||||
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
|
import type { Project, Room, FlooringSettings, SkirtingSettings } from '../lib/types';
|
||||||
import { NumberField } from './Fields';
|
import { NumberField } from './Fields';
|
||||||
|
import type { FlooringOptions } from '../lib/flooring';
|
||||||
|
import { mm } from '../lib/format';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
room: Room;
|
room: Room;
|
||||||
|
flooringOptions: FlooringOptions;
|
||||||
changeProject: (fn: (p: Project) => Project) => void;
|
changeProject: (fn: (p: Project) => Project) => void;
|
||||||
changeRoom: (fn: (r: Room) => Room) => void;
|
changeRoom: (fn: (r: Room) => Room) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -12,7 +15,13 @@ interface Props {
|
||||||
const kerfHelp =
|
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.';
|
'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 f = project.flooring;
|
||||||
const s = project.skirting;
|
const s = project.skirting;
|
||||||
const floor = (key: keyof FlooringSettings, value: number | string) =>
|
const floor = (key: keyof FlooringSettings, value: number | string) =>
|
||||||
|
|
@ -63,6 +72,34 @@ export function MaterialsEditor({ project, room, changeProject, changeRoom }: Pr
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="field">
|
||||||
<span>Board direction · {room.name}</span>
|
<span>Board direction · {room.name}</span>
|
||||||
<div className="segmented" role="group" aria-label="Board direction">
|
<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,
|
width: 190,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
pattern: 'brick',
|
pattern: 'brick',
|
||||||
|
optimisation: 'cuts',
|
||||||
minOffset: 300,
|
minOffset: 300,
|
||||||
maxOffset: 600,
|
maxOffset: 600,
|
||||||
minLength: 200,
|
minLength: 200,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,39 @@ const SCALE = 1000;
|
||||||
const EPSILON = 0.01;
|
const EPSILON = 0.01;
|
||||||
const MAX_BOARDS = 2000;
|
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 {
|
interface Bounds {
|
||||||
minX: number;
|
minX: number;
|
||||||
maxX: number;
|
maxX: number;
|
||||||
|
|
@ -31,6 +64,8 @@ interface Layout {
|
||||||
rows: FlooringRow[];
|
rows: FlooringRow[];
|
||||||
stocks: AvailableStock[];
|
stocks: AvailableStock[];
|
||||||
cuts: number;
|
cuts: number;
|
||||||
|
shapingCuts: number;
|
||||||
|
ripCuts: number;
|
||||||
reusedCount: number;
|
reusedCount: number;
|
||||||
patternPenalty: number;
|
patternPenalty: number;
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +83,12 @@ interface RowShape {
|
||||||
bounds: Bounds;
|
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 round = (n: number) => Math.round(n * SCALE) / SCALE;
|
||||||
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
|
const phaseOf = (n: number, length: number) => round(((n % length) + length) % length) % length;
|
||||||
const close = (a: number, b: number) => Math.abs(a - b) <= EPSILON;
|
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 } {
|
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||||
let stock: AvailableStock | undefined;
|
let stock: AvailableStock | undefined;
|
||||||
for (const candidate of layout.stocks) {
|
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
|
||||||
if (
|
if (
|
||||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||||
(piece.needsLeft && !candidate.left) ||
|
(piece.needsLeft && !candidate.left) ||
|
||||||
|
|
@ -249,6 +290,18 @@ function allocate(
|
||||||
};
|
};
|
||||||
layout.stocks.push(stock);
|
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 reused = stock.pieceIds.length > 0;
|
||||||
const reserved = blankLength(length, stock.remaining);
|
const reserved = blankLength(length, stock.remaining);
|
||||||
const crosscut = stock.remaining - length > EPSILON;
|
const crosscut = stock.remaining - length > EPSILON;
|
||||||
|
|
@ -260,13 +313,88 @@ function allocate(
|
||||||
} else {
|
} else {
|
||||||
stock.remaining = round(Math.max(0, stock.remaining - reserved - s.kerf));
|
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.
|
// 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;
|
else stock.left = false;
|
||||||
}
|
}
|
||||||
if (reused) layout.reusedCount++;
|
|
||||||
return { stockId: stock.id, reused, crosscut, blankLength: reserved };
|
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(
|
function appendRow(
|
||||||
base: Layout,
|
base: Layout,
|
||||||
cuts: CutPiece[],
|
cuts: CutPiece[],
|
||||||
|
|
@ -280,9 +408,13 @@ function appendRow(
|
||||||
pieces: [...base.pieces],
|
pieces: [...base.pieces],
|
||||||
rows: [...base.rows],
|
rows: [...base.rows],
|
||||||
cuts: base.cuts,
|
cuts: base.cuts,
|
||||||
|
shapingCuts: base.shapingCuts,
|
||||||
|
ripCuts: base.ripCuts,
|
||||||
reusedCount: base.reusedCount,
|
reusedCount: base.reusedCount,
|
||||||
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
|
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[] = [];
|
const pieceIds: string[] = [];
|
||||||
cuts.forEach((piece, column) => {
|
cuts.forEach((piece, column) => {
|
||||||
|
|
@ -309,6 +441,8 @@ function appendRow(
|
||||||
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
|
const ripEdge = ripTop && ripBottom ? 'both' : ripTop ? 'top' : ripBottom ? 'bottom' : null;
|
||||||
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
|
const passes = Number(allocation.crosscut) + Number(ripTop) + Number(ripBottom) + shapeCuts;
|
||||||
layout.cuts += passes;
|
layout.cuts += passes;
|
||||||
|
layout.shapingCuts += shapeCuts;
|
||||||
|
layout.ripCuts += Number(ripTop) + Number(ripBottom);
|
||||||
layout.pieces.push({
|
layout.pieces.push({
|
||||||
id,
|
id,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
|
|
@ -388,6 +522,104 @@ function randomPatternPenalty(
|
||||||
return penalty;
|
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. */
|
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
|
||||||
function fraction(seed: string): number {
|
function fraction(seed: string): number {
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
|
|
@ -452,11 +684,16 @@ function candidatePhases(
|
||||||
return candidates.slice(0, 28);
|
return candidates.slice(0, 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareLayouts(a: Layout, b: Layout): number {
|
function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
|
||||||
return (
|
return (
|
||||||
a.stocks.length - b.stocks.length ||
|
(s.pattern === 'random' && s.optimisation === 'random'
|
||||||
a.patternPenalty - b.patternPenalty ||
|
? a.patternPenalty - b.patternPenalty
|
||||||
|
: 0) ||
|
||||||
a.cuts - b.cuts ||
|
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 ||
|
b.reusedCount - a.reusedCount ||
|
||||||
compareText(
|
compareText(
|
||||||
a.rows.map((r) => r.offset.toFixed(3)).join(','),
|
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.
|
* 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.
|
* 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 = {
|
let layout: Layout = {
|
||||||
pieces: [],
|
pieces: [],
|
||||||
rows: [],
|
rows: [],
|
||||||
stocks: [],
|
stocks: [],
|
||||||
cuts: 0,
|
cuts: 0,
|
||||||
|
shapingCuts: 0,
|
||||||
|
ripCuts: 0,
|
||||||
reusedCount: 0,
|
reusedCount: 0,
|
||||||
patternPenalty: 0,
|
patternPenalty: 0,
|
||||||
};
|
};
|
||||||
|
let finalists = [layout];
|
||||||
const issues: Issue[] = [];
|
const issues: Issue[] = [];
|
||||||
const invalid = validateSettings(s);
|
const invalid = validateSettings(s);
|
||||||
if (invalid) issues.push({ severity: 'error', message: invalid });
|
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;
|
let y = roomBounds.minY;
|
||||||
for (let row = 0; row < widths.length && beam.length; row++) {
|
for (let row = 0; row < widths.length && beam.length; row++) {
|
||||||
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
|
const end = row === widths.length - 1 ? roomBounds.maxY : round(y + widths[row]);
|
||||||
const rowPaths = intersect(paths, { ...roomBounds, minY: y, maxY: end });
|
const key = `${room.id}:${widths[0]}:${row}`;
|
||||||
if (!rowPaths.length) {
|
let cached = shapes.get(key);
|
||||||
y = end;
|
if (!cached) {
|
||||||
continue;
|
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 alternatives: Layout[] = [];
|
||||||
const cache = new Map<number, CutPiece[] | null>();
|
|
||||||
for (const base of beam) {
|
for (const base of beam) {
|
||||||
const roomRows = base.rows.filter((r) => r.roomId === room.id);
|
const roomRows = base.rows.filter((r) => r.roomId === room.id);
|
||||||
const previousPhase = roomRows.at(-1)?.offset;
|
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);
|
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alternatives.sort(compareLayouts);
|
alternatives.sort((a, b) => compareLayouts(a, b, s));
|
||||||
// Keep distinct phases/inventories; identical routes do not earn extra beam slots.
|
// Histories with different recent joints can have different future pattern costs.
|
||||||
beam = alternatives
|
const seen = new Set<string>();
|
||||||
.filter(
|
beam = [];
|
||||||
(candidate, index) =>
|
for (const candidate of alternatives) {
|
||||||
index === 0 ||
|
const phases = candidate.rows
|
||||||
candidate.rows.at(-1)?.offset !== alternatives[index - 1].rows.at(-1)?.offset ||
|
.filter((r) => r.roomId === room.id)
|
||||||
candidate.stocks
|
.slice(s.pattern === 'random' ? -4 : -1)
|
||||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
.map((r) => r.offset)
|
||||||
.join(',') !==
|
.join(',');
|
||||||
alternatives[index - 1].stocks
|
const inventory = candidate.stocks
|
||||||
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
.filter((stock) => stock.remaining >= s.minLength - EPSILON)
|
||||||
.join(','),
|
.map((stock) => `${stock.remaining}:${Number(stock.left)}${Number(stock.right)}`)
|
||||||
)
|
.sort()
|
||||||
.slice(0, beamWidth);
|
.join(',');
|
||||||
|
const key = `${phases}|${inventory}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
beam.push(candidate);
|
||||||
|
if (beam.length === beamWidth) break;
|
||||||
|
}
|
||||||
y = end;
|
y = end;
|
||||||
}
|
}
|
||||||
if (beam.length) complete.push(...beam);
|
if (beam.length) complete.push(...beam);
|
||||||
}
|
}
|
||||||
complete.sort(compareLayouts);
|
complete.sort((a, b) => compareLayouts(a, b, s));
|
||||||
if (!complete.length) {
|
if (!complete.length) {
|
||||||
issues.push({
|
issues.push({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|
@ -615,6 +874,13 @@ export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: numb
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
layout = complete[0];
|
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))
|
if (layout.pieces.some((p) => p.roomId === room.id && p.shaped))
|
||||||
issues.push({
|
issues.push({
|
||||||
severity: 'warning',
|
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.`,
|
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 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 purchasedArea = layout.stocks.length ? layout.stocks.length * s.length * s.width : 0;
|
||||||
const wasteArea = Math.max(0, purchasedArea - coveredArea);
|
const wasteArea = Math.max(0, purchasedArea - coveredArea);
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,12 @@ interface Packed {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
/** 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 }));
|
const pieces = source.map((piece) => ({ ...piece }));
|
||||||
let pending = [...pieces].sort(
|
let pending = [...pieces].sort(
|
||||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
(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) {
|
while (pending.length) {
|
||||||
let available = stockLength;
|
let available = stockLength;
|
||||||
const assigned: SkirtingPiece[] = [];
|
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) {
|
for (const piece of pending) {
|
||||||
if (!fits(piece, available)) continue;
|
if (!fits(piece, available)) continue;
|
||||||
piece.blankLength = blankLength(piece, available);
|
piece.blankLength = blankLength(piece, available);
|
||||||
|
|
@ -525,6 +549,7 @@ export function planSkirting(
|
||||||
piece.id = `${prefixes.get(piece.roomId)}${count}`;
|
piece.id = `${prefixes.get(piece.roomId)}${count}`;
|
||||||
}
|
}
|
||||||
candidates.push(pack(source, stockLength, settings.kerf));
|
candidates.push(pack(source, stockLength, settings.kerf));
|
||||||
|
candidates.push(pack(source, stockLength, settings.kerf, true));
|
||||||
};
|
};
|
||||||
// Compare full-stock, balanced, and offcut-led wall joints.
|
// Compare full-stock, balanced, and offcut-led wall joints.
|
||||||
{
|
{
|
||||||
|
|
@ -540,8 +565,8 @@ export function planSkirting(
|
||||||
}
|
}
|
||||||
candidates.sort(
|
candidates.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
|
||||||
a.cuts - b.cuts ||
|
a.cuts - b.cuts ||
|
||||||
|
(Math.abs(a.waste - b.waste) > EPS ? a.waste - b.waste : 0) ||
|
||||||
a.stocks.length - b.stocks.length ||
|
a.stocks.length - b.stocks.length ||
|
||||||
a.pieces.length - b.pieces.length,
|
a.pieces.length - b.pieces.length,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ export interface FlooringSettings {
|
||||||
width: number;
|
width: number;
|
||||||
gap: number;
|
gap: number;
|
||||||
pattern: 'brick' | 'random';
|
pattern: 'brick' | 'random';
|
||||||
|
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
|
||||||
|
optimisation?: 'cuts' | 'random';
|
||||||
minOffset: number;
|
minOffset: number;
|
||||||
maxOffset: number;
|
maxOffset: number;
|
||||||
minLength: number;
|
minLength: number;
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,11 @@ function flooring(value: unknown): FlooringSettings {
|
||||||
width: number(data.width, 'Board width'),
|
width: number(data.width, 'Board width'),
|
||||||
gap: number(data.gap, 'Expansion gap'),
|
gap: number(data.gap, 'Expansion gap'),
|
||||||
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
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'),
|
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||||
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||||
minLength: number(data.minLength, 'Minimum board length'),
|
minLength: number(data.minLength, 'Minimum board length'),
|
||||||
|
|
@ -198,6 +203,8 @@ function parseState(value: unknown): UrlState {
|
||||||
const roomId = string(view.roomId, 'Selected room');
|
const roomId = string(view.roomId, 'Selected room');
|
||||||
const skirt = skirting(project.skirting);
|
const skirt = skirting(project.skirting);
|
||||||
const completed = completedBoards(project.completedBoards);
|
const completed = completedBoards(project.completedBoards);
|
||||||
|
const previousOptimiser =
|
||||||
|
object(project.flooring, 'Flooring settings').optimisation === undefined;
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
|
@ -206,7 +213,7 @@ function parseState(value: unknown): UrlState {
|
||||||
flooring: flooring(project.flooring),
|
flooring: flooring(project.flooring),
|
||||||
skirting: skirt.settings,
|
skirting: skirt.settings,
|
||||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||||
completedBoards: skirt.legacyCalculations ? [] : completed,
|
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||||
|
|
@ -362,9 +369,11 @@ function parseQuery(query: string): UrlState {
|
||||||
flooring: Object.fromEntries(
|
flooring: Object.fromEntries(
|
||||||
Object.entries(floor).map(([key, value]) => [
|
Object.entries(floor).map(([key, value]) => [
|
||||||
key,
|
key,
|
||||||
typeof value === 'number'
|
key === 'optimisation' && !params.has('flooring.optimisation')
|
||||||
? numeric(`flooring.${key}`, value)
|
? undefined
|
||||||
: text(`flooring.${key}`, value),
|
: typeof value === 'number'
|
||||||
|
? numeric(`flooring.${key}`, value)
|
||||||
|
: text(`flooring.${key}`, value),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
skirting: {
|
skirting: {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,33 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { UrlState } from './types';
|
import type { UrlState } from './types';
|
||||||
import { decodeState, encodeState } from './url-state';
|
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}`;
|
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() {
|
export function useProject() {
|
||||||
const [loaded, setLoaded] = useState(readLocation);
|
const [loaded, setLoaded] = useState(readLocation);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -18,6 +41,7 @@ export function useProject() {
|
||||||
const query = encodeState(next.state);
|
const query = encodeState(next.state);
|
||||||
if (query !== window.location.search || window.location.hash)
|
if (query !== window.location.search || window.location.hash)
|
||||||
window.history.replaceState(null, '', queryUrl(query));
|
window.history.replaceState(null, '', queryUrl(query));
|
||||||
|
saveLocal(query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
|
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
|
||||||
}
|
}
|
||||||
|
|
@ -39,6 +63,7 @@ export function useProject() {
|
||||||
const query = encodeState(next);
|
const query = encodeState(next);
|
||||||
if (query !== window.location.search || window.location.hash)
|
if (query !== window.location.search || window.location.hash)
|
||||||
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
|
||||||
|
saveLocal(query);
|
||||||
const canonical = decodeState(query).state;
|
const canonical = decodeState(query).state;
|
||||||
if (next.project === current.state.project) canonical.project = current.state.project;
|
if (next.project === current.state.project) canonical.project = current.state.project;
|
||||||
else {
|
else {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import Site from './Site';
|
||||||
|
import { initAnalytics } from './lib/analytics';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
createRoot(document.getElementById('root')!).render(<App />);
|
|
||||||
|
initAnalytics();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(<Site />);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ button:disabled {
|
||||||
color: #78818f;
|
color: #78818f;
|
||||||
background: #f1f1ed;
|
background: #f1f1ed;
|
||||||
}
|
}
|
||||||
|
a:focus-visible,
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
|
|
@ -112,6 +113,10 @@ small {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
}
|
}
|
||||||
|
.app-title-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
.square-mark {
|
.square-mark {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
|
@ -557,6 +562,29 @@ summary span {
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
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 {
|
.drawing-card {
|
||||||
border: 1px solid var(--ink);
|
border: 1px solid var(--ink);
|
||||||
background: white;
|
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" />
|
||||||
|
|
@ -223,7 +223,12 @@ describe('laminate planning', () => {
|
||||||
] as const)(
|
] as const)(
|
||||||
'breaks random staircases and near-brick repeats in a common %s',
|
'breaks random staircases and near-brick repeats in a common %s',
|
||||||
(_, r, boardBudget, cutBudget) => {
|
(_, r, boardBudget, cutBudget) => {
|
||||||
const s = { ...settings, width: 190, pattern: 'random' as const };
|
const s = {
|
||||||
|
...settings,
|
||||||
|
width: 190,
|
||||||
|
pattern: 'random' as const,
|
||||||
|
optimisation: 'random' as const,
|
||||||
|
};
|
||||||
const plan = planFlooring([r], s, 1);
|
const plan = planFlooring([r], s, 1);
|
||||||
expect(errors(plan)).toEqual([]);
|
expect(errors(plan)).toEqual([]);
|
||||||
expect(plan).toEqual(planFlooring([r], s, 1));
|
expect(plan).toEqual(planFlooring([r], s, 1));
|
||||||
|
|
@ -296,8 +301,9 @@ describe('laminate planning', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
|
it('plans each room independently and can reuse compatible end offcuts across directions', () => {
|
||||||
const a = room(1820, 1220, '1');
|
const a = room(620, 220, '1');
|
||||||
const b = room(1220, 1820, '2');
|
// Its ends complement the first room's remnants after the 3 mm saw kerf.
|
||||||
|
const b = room(220, 617, '2');
|
||||||
b.direction = 90;
|
b.direction = 90;
|
||||||
const combined = planFlooring([a, b], settings, 1);
|
const combined = planFlooring([a, b], settings, 1);
|
||||||
const separately =
|
const separately =
|
||||||
|
|
|
||||||
144
tests/optimisation.test.ts
Normal file
144
tests/optimisation.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { plannerCases } from '../benchmarks/cases';
|
||||||
|
import {
|
||||||
|
compareFlooring,
|
||||||
|
compareSkirting,
|
||||||
|
flooringMetrics,
|
||||||
|
skirtingMetrics,
|
||||||
|
} from '../benchmarks/metrics';
|
||||||
|
import baseline from '../benchmarks/baseline.json';
|
||||||
|
import { planFlooringOptions } from '../src/lib/flooring';
|
||||||
|
import { planSkirting } from '../src/lib/skirting';
|
||||||
|
import { buildGeometry, insetPolygon, signedArea } from '../src/lib/geometry';
|
||||||
|
import type { FlooringPlan, Project } from '../src/lib/types';
|
||||||
|
|
||||||
|
function validateFlooring(plan: FlooringPlan, project: Project) {
|
||||||
|
const s = project.flooring;
|
||||||
|
expect(plan.issues.filter((issue) => issue.severity === 'error')).toEqual([]);
|
||||||
|
const byId = new Map(plan.pieces.map((piece) => [piece.id, piece]));
|
||||||
|
expect(byId.size).toBe(plan.pieces.length);
|
||||||
|
const ids = [...byId.keys()].sort();
|
||||||
|
expect(plan.stocks.flatMap((stock) => stock.pieceIds).sort()).toEqual(ids);
|
||||||
|
expect(plan.rows.flatMap((row) => row.pieceIds).sort()).toEqual(ids);
|
||||||
|
let crosscuts = 0;
|
||||||
|
let reused = 0;
|
||||||
|
for (const stock of plan.stocks) {
|
||||||
|
let remaining = s.length;
|
||||||
|
let left = true;
|
||||||
|
let right = true;
|
||||||
|
stock.pieceIds.forEach((id, index) => {
|
||||||
|
const piece = byId.get(id)!;
|
||||||
|
expect(piece.stockId).toBe(stock.id);
|
||||||
|
expect(piece.blankLength!).toBeLessThanOrEqual(remaining + 0.01);
|
||||||
|
expect(!piece.factoryLeft || left).toBe(true);
|
||||||
|
expect(!piece.factoryRight || right).toBe(true);
|
||||||
|
expect(piece.reused).toBe(index > 0);
|
||||||
|
reused += Number(piece.reused);
|
||||||
|
const cut = remaining - piece.length > 0.01;
|
||||||
|
crosscuts += Number(cut);
|
||||||
|
expect(piece.cut).toBe(cut || piece.shaped || !!piece.ripEdge);
|
||||||
|
if (cut) {
|
||||||
|
remaining = Math.round(Math.max(0, remaining - piece.blankLength! - s.kerf) * 1000) / 1000;
|
||||||
|
if (piece.factoryRight) right = false;
|
||||||
|
else left = false;
|
||||||
|
} else {
|
||||||
|
remaining = 0;
|
||||||
|
left = right = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(stock.remaining).toBeCloseTo(remaining, 3);
|
||||||
|
}
|
||||||
|
const metrics = flooringMetrics(plan, s);
|
||||||
|
expect(plan.cuts).toBe(crosscuts + metrics.shapingCuts + metrics.ripCuts);
|
||||||
|
expect(plan.reusedCount).toBe(reused);
|
||||||
|
for (const room of project.rooms) {
|
||||||
|
const pieces = plan.pieces.filter((piece) => piece.roomId === room.id);
|
||||||
|
const paths = insetPolygon(buildGeometry(room, project.tolerance).vertices, s.gap);
|
||||||
|
const expected = paths.reduce((sum, path) => sum + Math.abs(signedArea(path)), 0);
|
||||||
|
const actual = pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece.polygon)), 0);
|
||||||
|
// Integer clipping rounds intersections to 0.001 mm; bound area drift by boundary length.
|
||||||
|
const perimeter = paths.reduce(
|
||||||
|
(sum, path) =>
|
||||||
|
sum +
|
||||||
|
path.reduce((length, p, i) => {
|
||||||
|
const q = path[(i + 1) % path.length];
|
||||||
|
return length + Math.hypot(p.x - q.x, p.y - q.y);
|
||||||
|
}, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(Math.abs(actual - expected)).toBeLessThan(Math.max(1, perimeter * 0.001));
|
||||||
|
const rows = plan.rows.filter((row) => row.roomId === room.id);
|
||||||
|
let previousEnd = -Infinity;
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const ys = row.pieceIds.flatMap((id) =>
|
||||||
|
byId.get(id)!.polygon.map((p) => (room.direction === 0 ? p.y : -p.x)),
|
||||||
|
);
|
||||||
|
expect(Math.min(...ys)).toBeGreaterThanOrEqual(previousEnd - 0.01);
|
||||||
|
previousEnd = Math.max(...ys);
|
||||||
|
for (const id of row.pieceIds) {
|
||||||
|
const piece = byId.get(id)!;
|
||||||
|
expect(piece.row).toBe(row.index);
|
||||||
|
expect(piece.roomId).toBe(room.id);
|
||||||
|
expect(piece.length).toBeGreaterThanOrEqual(s.minLength - 0.01);
|
||||||
|
expect(piece.width).toBeGreaterThanOrEqual(s.minWidth - 0.01);
|
||||||
|
}
|
||||||
|
if (!index) return;
|
||||||
|
const delta = Math.abs(row.offset - rows[index - 1].offset);
|
||||||
|
const offset = Math.min(delta, s.length - delta);
|
||||||
|
expect(offset).toBeGreaterThanOrEqual(s.minOffset - 0.01);
|
||||||
|
expect(offset).toBeLessThanOrEqual(s.maxOffset + 0.01);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('measured optimiser quality', () => {
|
||||||
|
const cases = plannerCases();
|
||||||
|
const results = new Map<
|
||||||
|
string,
|
||||||
|
{ options: ReturnType<typeof planFlooringOptions>; skirt: ReturnType<typeof skirtingMetrics> }
|
||||||
|
>();
|
||||||
|
const resultFor = ({ name, project }: (typeof cases)[number]) => {
|
||||||
|
let result = results.get(name);
|
||||||
|
if (!result) {
|
||||||
|
result = {
|
||||||
|
options: planFlooringOptions(project.rooms, project.flooring, project.tolerance),
|
||||||
|
skirt: skirtingMetrics(planSkirting(project.rooms, project.skirting, project.tolerance)),
|
||||||
|
};
|
||||||
|
results.set(name, result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
it.each(cases)(
|
||||||
|
'$name preserves coverage, stock, factory ends and priority',
|
||||||
|
({ name, project }) => {
|
||||||
|
const input = JSON.stringify(project);
|
||||||
|
const { options, skirt } = resultFor({ name, project });
|
||||||
|
const cuts = flooringMetrics(options.cuts, project.flooring);
|
||||||
|
const random = flooringMetrics(options.random, project.flooring);
|
||||||
|
const before = baseline.find((row) => row.name === name)!;
|
||||||
|
expect(compareFlooring(cuts, before.flooring)).toBeLessThanOrEqual(0);
|
||||||
|
expect(compareFlooring(cuts, random)).toBeLessThanOrEqual(0);
|
||||||
|
expect(compareFlooring(random, cuts, true)).toBeLessThanOrEqual(0);
|
||||||
|
validateFlooring(options.cuts, project);
|
||||||
|
if (options.random !== options.cuts) validateFlooring(options.random, project);
|
||||||
|
expect(compareSkirting(skirt, before.skirting)).toBeLessThanOrEqual(0);
|
||||||
|
expect(skirt.errors).toBe(0);
|
||||||
|
expect(JSON.stringify(project)).toBe(input);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('retains the measured improvements across the complete fixed corpus', () => {
|
||||||
|
const totals = { cuts: 0, randomCuts: 0, pattern: 0, skirtingCuts: 0 };
|
||||||
|
for (const c of cases) {
|
||||||
|
const { options, skirt } = resultFor(c);
|
||||||
|
totals.cuts += options.cuts.cuts;
|
||||||
|
totals.randomCuts += options.random.cuts;
|
||||||
|
totals.pattern += flooringMetrics(options.random, c.project.flooring).patternPenalty;
|
||||||
|
totals.skirtingCuts += skirt.cuts;
|
||||||
|
}
|
||||||
|
expect(totals.cuts).toBeLessThanOrEqual(1840); // Original: 2177.
|
||||||
|
expect(totals.randomCuts).toBeLessThanOrEqual(2130);
|
||||||
|
expect(totals.pattern).toBeLessThanOrEqual(129); // Original: 265.
|
||||||
|
expect(totals.skirtingCuts).toBeLessThanOrEqual(1436); // Original: 1447.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -221,13 +221,13 @@ describe('skirting cutting plan', () => {
|
||||||
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
|
expect(failures.every((issue) => issue.roomId === invalid.id)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets reused tails set wall joints while counting preparation and coping operations', () => {
|
it('prefers one fewer saw pass over saving a stock board on a square room', () => {
|
||||||
const room = rectangle(4000, 4000);
|
const room = rectangle(4000, 4000);
|
||||||
const plan = planSkirting([room], settings, 5);
|
const plan = planSkirting([room], settings, 5);
|
||||||
expect(plan).toEqual(planSkirting([room], settings, 5));
|
expect(plan).toEqual(planSkirting([room], settings, 5));
|
||||||
expect(plan.stockCount).toBe(7); // Fixed joints used eight lengths for this simple square.
|
expect(plan.stockCount).toBe(8); // Seven boards require thirteen preparation/coping passes.
|
||||||
expect(plan.cuts).toBe(13);
|
expect(plan.cuts).toBe(12);
|
||||||
expect(plan.waste).toBeCloseTo(7 * 2400 - 16000);
|
expect(plan.waste).toBeCloseTo(8 * 2400 - 16000);
|
||||||
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
|
for (let wallIndex = 0; wallIndex < 4; wallIndex++) {
|
||||||
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
|
const pieces = plan.pieces.filter((piece) => piece.wallIndex === wallIndex);
|
||||||
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000);
|
expect(pieces.reduce((sum, piece) => sum + piece.backLength, 0)).toBeCloseTo(4000);
|
||||||
|
|
|
||||||
|
|
@ -393,7 +393,9 @@ describe('URL state', () => {
|
||||||
};
|
};
|
||||||
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
|
expect(decodeState(rawLink(legacy))).toEqual({ state, error: null });
|
||||||
expect(
|
expect(
|
||||||
decodeState('?skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6'),
|
decodeState(
|
||||||
|
'?flooring.optimisation=cuts&skirting.length=4800&skirting.stock=3600&skirting.stock=2400&done=skirting:S6',
|
||||||
|
),
|
||||||
).toEqual({ state, error: null });
|
).toEqual({ state, error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -523,7 +525,9 @@ describe('URL state', () => {
|
||||||
const state = defaultState();
|
const state = defaultState();
|
||||||
state.project.completedBoards = ['floor:B4', 'floor:B4'];
|
state.project.completedBoards = ['floor:B4', 'floor:B4'];
|
||||||
expect(() => encodeState(state)).toThrow(/duplicate entries/);
|
expect(() => encodeState(state)).toThrow(/duplicate entries/);
|
||||||
const distinct = decodeState('?skirting.length=2400&done=floor:B1&done=skirting:S1');
|
const distinct = decodeState(
|
||||||
|
'?flooring.optimisation=cuts&skirting.length=2400&done=floor:B1&done=skirting:S1',
|
||||||
|
);
|
||||||
expect(distinct.error).toBeNull();
|
expect(distinct.error).toBeNull();
|
||||||
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
expect(distinct.state.project.completedBoards).toEqual(['floor:B1', 'skirting:S1']);
|
||||||
});
|
});
|
||||||
|
|
@ -573,6 +577,51 @@ describe('URL state', () => {
|
||||||
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
expectInvalid(`?name=${'x'.repeat(URL_LIMITS.queryLength)}`, /link is too large/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['cuts', 'random'] as const)(
|
||||||
|
'saves the %s optimisation and current completion marks',
|
||||||
|
(optimisation) => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.flooring.optimisation = optimisation;
|
||||||
|
state.project.completedBoards = ['floor:B4'];
|
||||||
|
const query = encodeState(state);
|
||||||
|
expect(query).toContain(`flooring.optimisation=${optimisation}`);
|
||||||
|
expect(decodeState(query)).toEqual({ state, error: null });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('migrates the previous optimiser without carrying completion onto a different allocation', () => {
|
||||||
|
const state = defaultState();
|
||||||
|
state.project.completedBoards = ['floor:B4', 'skirting:S6'];
|
||||||
|
const query = new URLSearchParams(encodeState(state));
|
||||||
|
query.delete('flooring.optimisation');
|
||||||
|
const old = {
|
||||||
|
...state,
|
||||||
|
project: {
|
||||||
|
...state.project,
|
||||||
|
flooring: { ...state.project.flooring, optimisation: undefined },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const link of [`?${query}`, rawLink(old)]) {
|
||||||
|
const migrated = decodeState(link);
|
||||||
|
expect(migrated.error).toBeNull();
|
||||||
|
expect(migrated.state.project.flooring.optimisation).toBe('cuts');
|
||||||
|
expect(migrated.state.project.completedBoards).toEqual([]);
|
||||||
|
migrated.state.project.completedBoards = ['floor:B1'];
|
||||||
|
expect(decodeState(encodeState(migrated.state))).toEqual(migrated);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, 42, true, {}, 'waste'])('rejects unsupported optimisation %j', (optimisation) => {
|
||||||
|
const state = defaultState();
|
||||||
|
expectInvalid(
|
||||||
|
rawLink({
|
||||||
|
...state,
|
||||||
|
project: { ...state.project, flooring: { ...state.project.flooring, optimisation } },
|
||||||
|
}),
|
||||||
|
/Flooring optimisation/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects oversized compressed and expanded payloads', () => {
|
it('rejects oversized compressed and expanded payloads', () => {
|
||||||
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
|
expectInvalid(`#plan=${'a'.repeat(URL_LIMITS.hashLength)}`, /link is too large/);
|
||||||
expectInvalid(
|
expectInvalid(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue