better
This commit is contained in:
parent
b9485e4097
commit
0c0f08b350
45 changed files with 1565 additions and 1579 deletions
113
src/App.tsx
113
src/App.tsx
|
|
@ -9,8 +9,9 @@ import { planFlooringOptions } from './lib/flooring';
|
|||
import { planSkirting } from './lib/skirting';
|
||||
import { area, mm } from './lib/format';
|
||||
import { flooringCuts, isSkirtingCut, skirtingCuts } from './lib/cut-list';
|
||||
import type { PlannerSection, Project, Room } from './lib/types';
|
||||
import type { PlanMaterial, PlannerSection, Project, Room } from './lib/types';
|
||||
import { boardSearchLabel, findBoardMatches } from './lib/board-tracking';
|
||||
import { PRECISION, PROJECT_LIMITS } from './lib/limits';
|
||||
import { OutlineEditor } from './components/OutlineEditor';
|
||||
import { MaterialsEditor } from './components/MaterialsEditor';
|
||||
import { FloorPlan } from './components/FloorPlan';
|
||||
|
|
@ -22,12 +23,16 @@ import { Issues } from './components/Fields';
|
|||
function PlannerPanel({
|
||||
section,
|
||||
title,
|
||||
step,
|
||||
description,
|
||||
open,
|
||||
toggle,
|
||||
children,
|
||||
}: {
|
||||
section: PlannerSection;
|
||||
title: string;
|
||||
step: number;
|
||||
description: string;
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
children: ReactNode;
|
||||
|
|
@ -37,15 +42,22 @@ function PlannerPanel({
|
|||
<h2 className="panel-toggle-heading">
|
||||
<button
|
||||
className="panel-toggle"
|
||||
id={`${section}-toggle`}
|
||||
aria-expanded={open}
|
||||
aria-controls={`${section}-content`}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span className="panel-title">
|
||||
<span className="step-number" aria-hidden="true">
|
||||
{step}
|
||||
</span>
|
||||
{title}
|
||||
</span>
|
||||
<span aria-hidden="true">{open ? '−' : '+'}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id={`${section}-content`} hidden={!open} className="panel-content">
|
||||
<p className="helper panel-description">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -77,7 +89,7 @@ export default function App() {
|
|||
() => planFlooringOptions(project.rooms, project.flooring, project.tolerance),
|
||||
[project.rooms, flooringInputs, project.tolerance],
|
||||
);
|
||||
const flooring = flooringOptions[project.flooring.optimisation ?? 'cuts'];
|
||||
const flooring = flooringOptions[project.flooring.optimisation];
|
||||
const skirting = useMemo(
|
||||
() => planSkirting(project.rooms, project.skirting, project.tolerance),
|
||||
[project.rooms, project.skirting, project.tolerance],
|
||||
|
|
@ -91,8 +103,8 @@ export default function App() {
|
|||
const wholeBoards = flooring.pieces.filter(
|
||||
(p) =>
|
||||
!p.cut &&
|
||||
Math.abs(p.length - project.flooring.length) < 0.01 &&
|
||||
Math.abs(p.width - project.flooring.width) < 0.01,
|
||||
Math.abs(p.length - project.flooring.length) < PRECISION.millimetres &&
|
||||
Math.abs(p.width - project.flooring.width) < PRECISION.millimetres,
|
||||
).length;
|
||||
const wholeSkirting = skirting.pieces.filter((p) => !isSkirtingCut(p, skirting)).length;
|
||||
|
||||
|
|
@ -111,13 +123,30 @@ export default function App() {
|
|||
[project, flooring, skirting, view.search],
|
||||
);
|
||||
const searchLabel = boardSearchLabel(project, flooring, skirting, view.search);
|
||||
const visibleMatchCount = (view.material === 'flooring' ? flooring : skirting).pieces.filter(
|
||||
(piece) => matches.pieceIds.includes(piece.id),
|
||||
).length;
|
||||
const materialForPieces = (ids: string[], current: PlanMaterial): PlanMaterial => {
|
||||
const hasFloor = flooring.pieces.some((piece) => ids.includes(piece.id));
|
||||
const hasSkirting = skirting.pieces.some((piece) => ids.includes(piece.id));
|
||||
return hasFloor && !hasSkirting ? 'flooring' : hasSkirting && !hasFloor ? 'skirting' : current;
|
||||
};
|
||||
const setSearch = (search: string) =>
|
||||
update((s) => {
|
||||
const found = findBoardMatches(s.project, flooring, skirting, search);
|
||||
const roomId = found.roomIds.includes(s.view.roomId)
|
||||
? s.view.roomId
|
||||
: (found.roomIds[0] ?? s.view.roomId);
|
||||
return { ...s, view: { ...s.view, search, roomId, selectedPiece: null } };
|
||||
return {
|
||||
...s,
|
||||
view: {
|
||||
...s.view,
|
||||
search,
|
||||
roomId,
|
||||
selectedPiece: null,
|
||||
material: materialForPieces(found.pieceIds, s.view.material),
|
||||
},
|
||||
};
|
||||
});
|
||||
const togglePanel = (section: PlannerSection) =>
|
||||
update((s) => ({
|
||||
|
|
@ -140,13 +169,27 @@ export default function App() {
|
|||
},
|
||||
}));
|
||||
const locatePiece = (id: string, roomId: string) =>
|
||||
update((s) => ({ ...s, view: { ...s.view, search: id, roomId, selectedPiece: null } }));
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: {
|
||||
...s.view,
|
||||
search: id,
|
||||
roomId,
|
||||
selectedPiece: null,
|
||||
material: materialForPieces([id], s.view.material),
|
||||
},
|
||||
}));
|
||||
const changeRoom = (fn: (r: Room) => Room) =>
|
||||
changeProject((p) => ({ ...p, rooms: p.rooms.map((r) => (r.id === room.id ? fn(r) : r)) }));
|
||||
const select = (id: string | null, roomId?: string) =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: { ...s.view, roomId: roomId ?? s.view.roomId, selectedPiece: id },
|
||||
view: {
|
||||
...s.view,
|
||||
roomId: roomId ?? s.view.roomId,
|
||||
selectedPiece: id,
|
||||
material: id ? materialForPieces([id], s.view.material) : s.view.material,
|
||||
},
|
||||
}));
|
||||
const addRoom = () => {
|
||||
update((s) => {
|
||||
|
|
@ -252,7 +295,11 @@ export default function App() {
|
|||
</small>
|
||||
</button>
|
||||
))}
|
||||
<button className="add-room" onClick={addRoom} disabled={project.rooms.length >= 20}>
|
||||
<button
|
||||
className="add-room"
|
||||
onClick={addRoom}
|
||||
disabled={project.rooms.length >= PROJECT_LIMITS.rooms}
|
||||
>
|
||||
<Plus size={17} />
|
||||
Add room
|
||||
</button>
|
||||
|
|
@ -261,9 +308,18 @@ export default function App() {
|
|||
</div>
|
||||
<div className="workspace">
|
||||
<div className="planning-panels">
|
||||
<div className="setup-intro">
|
||||
<h2>Make this plan yours</h2>
|
||||
<p>
|
||||
Measure your room, choose your boards, then review the cuts. Your plan updates as
|
||||
you go.
|
||||
</p>
|
||||
</div>
|
||||
<PlannerPanel
|
||||
section="measurements"
|
||||
title="Room measurements"
|
||||
step={1}
|
||||
description="Choose a starting shape, then replace the example dimensions with your measurements. All sizes are in millimetres."
|
||||
open={!view.collapsedSections.includes('measurements')}
|
||||
toggle={() => togglePanel('measurements')}
|
||||
>
|
||||
|
|
@ -288,6 +344,8 @@ export default function App() {
|
|||
<PlannerPanel
|
||||
section="materials"
|
||||
title="Materials"
|
||||
step={2}
|
||||
description="Enter the sizes of the boards you will buy. Material settings apply to every room; board direction applies to the selected room."
|
||||
open={!view.collapsedSections.includes('materials')}
|
||||
toggle={() => togglePanel('materials')}
|
||||
>
|
||||
|
|
@ -344,6 +402,8 @@ export default function App() {
|
|||
<PlannerPanel
|
||||
section="cuts"
|
||||
title="Cut plan"
|
||||
step={3}
|
||||
description="Review your boards and cuts. Click a numbered piece in the drawing or below for its cutting dimensions."
|
||||
open={!view.collapsedSections.includes('cuts')}
|
||||
toggle={() => togglePanel('cuts')}
|
||||
>
|
||||
|
|
@ -364,6 +424,34 @@ export default function App() {
|
|||
</PlannerPanel>
|
||||
</div>
|
||||
<aside className="drawing-column" id="drawing" aria-label="Room drawing">
|
||||
<div className="plan-view-toolbar">
|
||||
<span id="plan-view-label">Plan view</span>
|
||||
<div
|
||||
className="segmented plan-view-switch"
|
||||
role="group"
|
||||
aria-labelledby="plan-view-label"
|
||||
>
|
||||
{(['flooring', 'skirting'] as const).map((material) => (
|
||||
<button
|
||||
key={material}
|
||||
className={view.material === material ? 'active' : ''}
|
||||
aria-pressed={view.material === material}
|
||||
onClick={() =>
|
||||
update((s) => ({
|
||||
...s,
|
||||
view: { ...s.view, material, selectedPiece: null, search: '' },
|
||||
}))
|
||||
}
|
||||
>
|
||||
<i
|
||||
className={`material-key ${material === 'flooring' ? 'floor-key' : 'skirt-key'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{material === 'flooring' ? 'Floorboards' : 'Skirting'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{view.search.trim() && (
|
||||
<div className="plan-search-status" role="status">
|
||||
<div className="search-mode-heading">
|
||||
|
|
@ -374,9 +462,9 @@ export default function App() {
|
|||
</strong>
|
||||
<p title={searchLabel}>
|
||||
{searchLabel} ·{' '}
|
||||
{matches.pieceIds.length
|
||||
? `${matches.pieceIds.length} piece${matches.pieceIds.length === 1 ? '' : 's'}`
|
||||
: 'No matching pieces'}
|
||||
{visibleMatchCount
|
||||
? `${visibleMatchCount} ${view.material === 'flooring' ? 'floorboard' : 'skirting'} piece${visibleMatchCount === 1 ? '' : 's'}`
|
||||
: 'No matching pieces in this view'}
|
||||
</p>
|
||||
</div>
|
||||
<button className="button clear-plan-search" onClick={() => setSearch('')}>
|
||||
|
|
@ -416,6 +504,7 @@ export default function App() {
|
|||
completedBoards={project.completedBoards}
|
||||
highlightedPieces={matches.pieceIds}
|
||||
searchActive={!!view.search.trim()}
|
||||
material={view.material}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
|||
16
src/Site.tsx
16
src/Site.tsx
|
|
@ -1,20 +1,18 @@
|
|||
import { lazy, Suspense, useEffect, useSyncExternalStore } from 'react';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
import { PlannerLoading } from './components/PlannerLoading';
|
||||
|
||||
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=');
|
||||
// Query links work on static hosts without server-side routing.
|
||||
const isPlannerLocation = () => Boolean(window.location.search);
|
||||
|
||||
export default function Site() {
|
||||
const showPlanner = useSyncExternalStore(subscribeToLocation, isPlannerLocation);
|
||||
|
|
@ -24,13 +22,7 @@ export default function Site() {
|
|||
}, [showPlanner]);
|
||||
|
||||
return showPlanner ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="planner-loading" role="status">
|
||||
Opening your planner…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<PlannerLoading />}>
|
||||
<Planner />
|
||||
</Suspense>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
uncutSkirting,
|
||||
} from '../lib/cut-list';
|
||||
import { boardSearchLabel, findBoardMatches } from '../lib/board-tracking';
|
||||
import { PERSISTENCE_LIMITS } from '../lib/limits';
|
||||
import { FlooringPreview, SkirtingPreview } from './CutPreview';
|
||||
|
||||
function BoardHeader({
|
||||
|
|
@ -120,7 +121,7 @@ export function CutList({
|
|||
id="board-search"
|
||||
type="search"
|
||||
value={boardSearchLabel(project, flooring, skirting, search)}
|
||||
maxLength={200}
|
||||
maxLength={PERSISTENCE_LIMITS.searchLength}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="F12, A2, or a room name"
|
||||
autoComplete="off"
|
||||
|
|
|
|||
|
|
@ -2,14 +2,7 @@ import { Fragment } from 'react';
|
|||
import type { FlooringPiece, SkirtingPiece } from '../lib/types';
|
||||
import { cutMm, degree } from '../lib/format';
|
||||
import { skirtingEndLabel } from '../lib/cut-list';
|
||||
import {
|
||||
dimensionedOutline,
|
||||
flooringTemplate,
|
||||
fullPreviewBox,
|
||||
skirtingEndBoxes,
|
||||
skirtingTemplate,
|
||||
viewBox,
|
||||
} from '../lib/cut-preview';
|
||||
import { dimensionedOutline, fullPreviewBox, skirtingEndBoxes, viewBox } from '../lib/cut-preview';
|
||||
import type { EdgeDimension } from '../lib/cut-preview';
|
||||
|
||||
function Dimensions({ dimensions, font }: { dimensions: EdgeDimension[]; font: number }) {
|
||||
|
|
@ -112,7 +105,7 @@ export function FlooringPreview({
|
|||
piece: FlooringPiece;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const template = flooringTemplate(piece);
|
||||
const template = piece.template;
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
: dimensionedOutline(template, piece.shaped ? undefined : [0, 1], {
|
||||
|
|
@ -159,11 +152,10 @@ export function SkirtingPreview({
|
|||
thickness: number;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const template = skirtingTemplate(piece, thickness);
|
||||
const template = piece.template;
|
||||
const Wrapper = compact ? 'span' : 'div';
|
||||
const EndWrapper = compact ? 'span' : 'figure';
|
||||
const Grid = compact ? 'span' : 'div';
|
||||
if (template.length !== 4) return <span className="helper">Shape template unavailable</span>;
|
||||
const boxes = skirtingEndBoxes(template, thickness, piece.installedTemplate);
|
||||
const dimensions = compact
|
||||
? undefined
|
||||
|
|
@ -186,8 +178,8 @@ export function SkirtingPreview({
|
|||
front: template[3],
|
||||
box: boxes.left,
|
||||
type: piece.leftType,
|
||||
installedBack: piece.installedTemplate?.[0],
|
||||
installedFront: piece.installedTemplate?.[3],
|
||||
installedBack: piece.installedTemplate[0],
|
||||
installedFront: piece.installedTemplate[3],
|
||||
},
|
||||
{
|
||||
side: 'right',
|
||||
|
|
@ -195,8 +187,8 @@ export function SkirtingPreview({
|
|||
front: template[2],
|
||||
box: boxes.right,
|
||||
type: piece.rightType,
|
||||
installedBack: piece.installedTemplate?.[1],
|
||||
installedFront: piece.installedTemplate?.[2],
|
||||
installedBack: piece.installedTemplate[1],
|
||||
installedFront: piece.installedTemplate[2],
|
||||
},
|
||||
] as const;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { useId } from 'react';
|
||||
import type { FlooringPlan, Geometry, Point, Room, SkirtingPlan, WallEdge } from '../lib/types';
|
||||
import type {
|
||||
FlooringPlan,
|
||||
Geometry,
|
||||
PlanMaterial,
|
||||
Point,
|
||||
Room,
|
||||
SkirtingPlan,
|
||||
WallEdge,
|
||||
} from '../lib/types';
|
||||
import { area, cutMm, mm } from '../lib/format';
|
||||
import { signedArea } from '../lib/geometry';
|
||||
import { isSkirtingCut } from '../lib/cut-list';
|
||||
import { skirtingPolygon } from '../lib/skirting-polygon';
|
||||
import { dimensionLabels, wallDimensionBox } from '../lib/dimension-labels';
|
||||
import { floorPieceLabels } from '../lib/piece-labels';
|
||||
import { PRECISION } from '../lib/limits';
|
||||
import type { DimensionLabel, LabelBox } from '../lib/dimension-labels';
|
||||
|
||||
const points = (path: Point[]) => path.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
|
|
@ -61,7 +70,10 @@ function Wall({ edge, span, label }: { edge: WallEdge; span: number; label: Dime
|
|||
</text>
|
||||
</g>
|
||||
{edge.doors
|
||||
.filter((d) => d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + 0.001)
|
||||
.filter(
|
||||
(d) =>
|
||||
d.offset >= 0 && d.width > 0 && d.offset + d.width <= edge.length + PRECISION.clipping,
|
||||
)
|
||||
.map((door) => {
|
||||
const a = {
|
||||
x: edge.start.x + (dx / length) * door.offset,
|
||||
|
|
@ -116,6 +128,7 @@ interface Props {
|
|||
completedBoards?: string[];
|
||||
highlightedPieces?: string[];
|
||||
searchActive?: boolean;
|
||||
material?: PlanMaterial;
|
||||
}
|
||||
|
||||
export function FloorPlan({
|
||||
|
|
@ -129,6 +142,7 @@ export function FloorPlan({
|
|||
completedBoards = [],
|
||||
highlightedPieces = [],
|
||||
searchActive = false,
|
||||
material,
|
||||
}: Props) {
|
||||
const hatch = useId().replaceAll(':', '');
|
||||
const allPoints = geometry.edges.flatMap((edge) => [edge.start, edge.end]);
|
||||
|
|
@ -139,8 +153,11 @@ export function FloorPlan({
|
|||
const span = Math.max(maxX - minX, maxY - minY, 1000),
|
||||
font = span * 0.024;
|
||||
const orientation = Math.sign(signedArea(geometry.vertices)) || 1;
|
||||
const floorPieces = flooring.pieces.filter((p) => p.roomId === room.id);
|
||||
const skirtPieces = skirting.pieces.filter((p) => p.roomId === room.id);
|
||||
// Printing includes both materials; the interactive drawing shows the chosen view.
|
||||
const floorPieces =
|
||||
material === 'skirting' ? [] : flooring.pieces.filter((p) => p.roomId === room.id);
|
||||
const skirtPieces =
|
||||
material === 'flooring' ? [] : skirting.pieces.filter((p) => p.roomId === room.id);
|
||||
const skirtLabels = new Map<string, Point & { width: number; text: string }>();
|
||||
const reserved: LabelBox[] = [];
|
||||
for (const piece of skirtPieces) {
|
||||
|
|
@ -197,20 +214,25 @@ export function FloorPlan({
|
|||
className="drawing-card"
|
||||
aria-label="Floor plan preview"
|
||||
data-search-active={searchActive}
|
||||
data-view={material ?? 'both'}
|
||||
>
|
||||
<div className="drawing-header">
|
||||
<h2>
|
||||
{room.name} <small>{area(geometry.area)} m²</small>
|
||||
</h2>
|
||||
<div className="drawing-legend">
|
||||
<span>
|
||||
<i className="floor-key" />
|
||||
Flooring cuts
|
||||
</span>
|
||||
<span>
|
||||
<i className="skirt-key" />
|
||||
Skirting cuts
|
||||
</span>
|
||||
{material !== 'skirting' && (
|
||||
<span>
|
||||
<i className="floor-key" />
|
||||
Flooring cuts
|
||||
</span>
|
||||
)}
|
||||
{material !== 'flooring' && (
|
||||
<span>
|
||||
<i className="skirt-key" />
|
||||
Skirting cuts
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<i className="uncut-key" />
|
||||
Uncut
|
||||
|
|
@ -331,8 +353,7 @@ export function FloorPlan({
|
|||
const done = completedBoards.includes(`skirting:${piece.stockId}`);
|
||||
const highlighted = highlightedPieces.includes(piece.id);
|
||||
// Each template uses its true wall-facing and room-facing edges, including mitres.
|
||||
const thickness = piece.template ? Math.max(...piece.template.map((p) => p.y)) : 0;
|
||||
const polygon = skirtingPolygon(piece, thickness, orientation);
|
||||
const polygon = skirtingPolygon(piece, orientation);
|
||||
return (
|
||||
<g
|
||||
key={piece.id}
|
||||
|
|
|
|||
|
|
@ -24,12 +24,7 @@ function PlannerLink({ compact = false }: { compact?: boolean }) {
|
|||
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>
|
||||
<figcaption className="landing-plan-heading">Example floor layout</figcaption>
|
||||
<div className="landing-plan-paper">
|
||||
<div className="landing-plan-room">
|
||||
<span>Living room</span>
|
||||
|
|
@ -55,7 +50,7 @@ function PlanIllustration() {
|
|||
<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">
|
||||
<g fill="#55685d" textAnchor="middle">
|
||||
<rect x="224" y="21" width="92" height="22" fill="#f6f7f2" />
|
||||
<text x="270" y="36">
|
||||
4,800 mm
|
||||
|
|
@ -104,7 +99,7 @@ function PlanIllustration() {
|
|||
<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">
|
||||
<g fill="#fff" textAnchor="middle">
|
||||
<text x="127" y="127">
|
||||
F1
|
||||
</text>
|
||||
|
|
@ -113,11 +108,11 @@ function PlanIllustration() {
|
|||
</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">
|
||||
<text x="283" y="198" fill="#17263f" textAnchor="middle">
|
||||
Living room
|
||||
</text>
|
||||
<text x="283" y="214" fontSize="10" letterSpacing="1" fill="#58655e" textAnchor="middle">
|
||||
EXAMPLE LAYOUT
|
||||
<text x="283" y="214" fill="#58655e" textAnchor="middle">
|
||||
Example layout
|
||||
</text>
|
||||
<path d="M79 257V317" stroke="#f6f7f2" strokeWidth="15" />
|
||||
<path
|
||||
|
|
@ -127,8 +122,8 @@ function PlanIllustration() {
|
|||
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 x="470" y="307" fill="#55685d">
|
||||
Lay
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -179,11 +174,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M142 143L173 113H225" fill="none" stroke="#a4471c" />
|
||||
<text x="233" y="117" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
<text x="233" y="117" fill="#a4471c">
|
||||
Scotia bead
|
||||
</text>
|
||||
<path d="M102 85H225" stroke="#7f8e84" />
|
||||
<text x="233" y="89" fill="#526156" fontSize="12">
|
||||
<text x="233" y="89" fill="#526156">
|
||||
Existing skirting
|
||||
</text>
|
||||
</>
|
||||
|
|
@ -196,11 +191,11 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M113 85H225" stroke="#a4471c" />
|
||||
<text x="233" y="89" fill="#a4471c" fontSize="13" fontWeight="600">
|
||||
<text x="233" y="89" fill="#a4471c">
|
||||
New skirting
|
||||
</text>
|
||||
<path d="M108 154L175 127H225" fill="none" stroke="#7f8e84" />
|
||||
<text x="233" y="131" fill="#526156" fontSize="12">
|
||||
<text x="233" y="131" fill="#526156">
|
||||
Floor tucks under
|
||||
</text>
|
||||
</>
|
||||
|
|
@ -211,14 +206,14 @@ function TrimIllustration({ scotia }: { scotia: boolean }) {
|
|||
stroke="#526156"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text x="197" y="210" fill="#526156" fontSize="11">
|
||||
<text x="197" y="210" fill="#526156">
|
||||
Expansion gap
|
||||
</text>
|
||||
<text x="48" y="15" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
WALL
|
||||
<text x="48" y="15" fill="#526156">
|
||||
Wall
|
||||
</text>
|
||||
<text x="278" y="151" fill="#526156" fontSize="10" letterSpacing="1">
|
||||
FLOORING
|
||||
<text x="278" y="151" fill="#526156">
|
||||
Flooring
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -267,38 +262,24 @@ export function LandingPage() {
|
|||
<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 className="landing-header">
|
||||
<div className="landing-header-content 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>
|
||||
</a>
|
||||
<PlannerLink compact />
|
||||
</div>
|
||||
</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>
|
||||
<h1 id="landing-title">A great floor starts with a little planning.</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.
|
||||
|
|
@ -309,10 +290,6 @@ export function LandingPage() {
|
|||
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>
|
||||
|
|
@ -322,16 +299,12 @@ export function LandingPage() {
|
|||
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>
|
||||
<h2 id="benefits-title">Why plan your floor?</h2>
|
||||
<div className="landing-benefit-grid">
|
||||
{benefits.map(({ icon: Icon, title, text }, index) => (
|
||||
{benefits.map(({ icon: Icon, title, text }) => (
|
||||
<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>
|
||||
|
|
@ -343,12 +316,7 @@ export function LandingPage() {
|
|||
<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>
|
||||
<h2 id="guide-title">Your first floor? 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.
|
||||
|
|
@ -374,7 +342,7 @@ export function LandingPage() {
|
|||
<ol className="landing-steps">
|
||||
{steps.map(({ title, text }, index) => (
|
||||
<li key={title}>
|
||||
<span className="landing-step-number">0{index + 1}</span>
|
||||
<span className="landing-step-number">{index + 1}.</span>
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{text}</p>
|
||||
|
|
@ -397,10 +365,7 @@ export function LandingPage() {
|
|||
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>
|
||||
<h2 id="finishing-title">Scotia or skirting board?</h2>
|
||||
<p>
|
||||
Both can hide the expansion gap around a floating floor. The difference is what you’re
|
||||
keeping, and the finish you want.
|
||||
|
|
@ -408,15 +373,10 @@ export function LandingPage() {
|
|||
</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>
|
||||
<p className="landing-trim-label">Keep your existing skirting</p>
|
||||
<TrimIllustration scotia />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Scotia <span>the small finishing bead</span>
|
||||
</h3>
|
||||
<h3>Scotia</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.
|
||||
|
|
@ -428,18 +388,14 @@ export function LandingPage() {
|
|||
</div>
|
||||
</article>
|
||||
<article className="landing-trim-card">
|
||||
<div className="landing-trim-label">
|
||||
<span>REPLACE OR REFIT YOUR SKIRTING</span>
|
||||
<span>02</span>
|
||||
</div>
|
||||
<p className="landing-trim-label">Replace or refit your skirting</p>
|
||||
<TrimIllustration scotia={false} />
|
||||
<div className="landing-trim-copy">
|
||||
<h3>
|
||||
Skirting board <span>also called a baseboard</span>
|
||||
</h3>
|
||||
<h3>Skirting board</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.
|
||||
Also called a baseboard, this is 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
|
||||
|
|
@ -460,30 +416,10 @@ export function LandingPage() {
|
|||
</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>
|
||||
<span>Floor planner</span>
|
||||
<a href="#main">Back to top ↑</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ export function MaterialsEditor({
|
|||
<h3 id="flooring-materials" className="material-heading">
|
||||
Floorboards
|
||||
</h3>
|
||||
<p className="helper material-help">
|
||||
Use the length and width printed on your flooring pack.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Board length"
|
||||
|
|
@ -56,13 +59,6 @@ export function MaterialsEditor({
|
|||
<div className="field">
|
||||
<span>Laying pattern</span>
|
||||
<div className="segmented" role="group" aria-label="Laying pattern">
|
||||
<button
|
||||
className={f.pattern === 'brick' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'brick'}
|
||||
onClick={() => floor('pattern', 'brick')}
|
||||
>
|
||||
Brick
|
||||
</button>
|
||||
<button
|
||||
className={f.pattern === 'random' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'random'}
|
||||
|
|
@ -70,16 +66,28 @@ export function MaterialsEditor({
|
|||
>
|
||||
Random
|
||||
</button>
|
||||
<button
|
||||
className={f.pattern === 'brick' ? 'active' : ''}
|
||||
aria-pressed={f.pattern === 'brick'}
|
||||
onClick={() => floor('pattern', 'brick')}
|
||||
>
|
||||
Brick
|
||||
</button>
|
||||
</div>
|
||||
<p className="helper">
|
||||
{f.pattern === 'random'
|
||||
? 'Varied joints for a natural look. Choose fewer cuts or a more varied arrangement below.'
|
||||
: 'A regular pattern with each row staggered by half a board.'}
|
||||
</p>
|
||||
</div>
|
||||
{f.pattern === 'random' && (
|
||||
<div className="field">
|
||||
<span>Optimise random flooring · all rooms</span>
|
||||
<span>What matters most? · 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;
|
||||
const selected = f.optimisation === priority;
|
||||
return (
|
||||
<button
|
||||
key={priority}
|
||||
|
|
@ -119,42 +127,56 @@ export function MaterialsEditor({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<NumberField label="Wall spacer" value={f.gap} onChange={(n) => floor('gap', n)} />
|
||||
<NumberField
|
||||
label="Saw kerf"
|
||||
value={f.kerf}
|
||||
onChange={(n) => floor('kerf', n)}
|
||||
tooltip={kerfHelp}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum joint offset"
|
||||
value={f.minOffset}
|
||||
onChange={(n) => floor('minOffset', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum joint offset"
|
||||
value={f.maxOffset}
|
||||
onChange={(n) => floor('maxOffset', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Shortest end piece"
|
||||
value={f.minLength}
|
||||
min={1}
|
||||
onChange={(n) => floor('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Narrowest edge row"
|
||||
value={f.minWidth}
|
||||
min={1}
|
||||
onChange={(n) => floor('minWidth', n)}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Wall spacer"
|
||||
value={f.gap}
|
||||
onChange={(n) => floor('gap', n)}
|
||||
hint="Expansion gap between the floorboards and walls. Check your flooring instructions."
|
||||
/>
|
||||
<details className="advanced-settings">
|
||||
<summary>Advanced flooring settings</summary>
|
||||
<p className="helper material-help">
|
||||
Adjust the saw blade width, joint spacing and smallest pieces to suit your flooring.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Saw kerf"
|
||||
value={f.kerf}
|
||||
onChange={(n) => floor('kerf', n)}
|
||||
tooltip={kerfHelp}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum joint offset"
|
||||
value={f.minOffset}
|
||||
onChange={(n) => floor('minOffset', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Maximum joint offset"
|
||||
value={f.maxOffset}
|
||||
onChange={(n) => floor('maxOffset', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Shortest end piece"
|
||||
value={f.minLength}
|
||||
min={1}
|
||||
onChange={(n) => floor('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Narrowest edge row"
|
||||
value={f.minWidth}
|
||||
min={1}
|
||||
onChange={(n) => floor('minWidth', n)}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<section className="material-section skirting" aria-labelledby="skirting-materials">
|
||||
<h3 id="skirting-materials" className="material-heading">
|
||||
Skirting boards
|
||||
</h3>
|
||||
<p className="helper material-help">
|
||||
Enter one purchased board length. Door openings are left clear automatically.
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Skirting board length"
|
||||
|
|
@ -167,19 +189,24 @@ export function MaterialsEditor({
|
|||
value={s.thickness}
|
||||
onChange={(n) => skirt('thickness', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Minimum piece"
|
||||
value={s.minLength}
|
||||
min={1}
|
||||
onChange={(n) => skirt('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Skirting saw kerf"
|
||||
value={s.kerf}
|
||||
onChange={(n) => skirt('kerf', n)}
|
||||
tooltip={kerfHelp}
|
||||
/>
|
||||
</div>
|
||||
<details className="advanced-settings">
|
||||
<summary>Advanced skirting settings</summary>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="Minimum piece"
|
||||
value={s.minLength}
|
||||
min={1}
|
||||
onChange={(n) => skirt('minLength', n)}
|
||||
/>
|
||||
<NumberField
|
||||
label="Skirting saw kerf"
|
||||
value={s.kerf}
|
||||
onChange={(n) => skirt('kerf', n)}
|
||||
tooltip={kerfHelp}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { mm } from '../lib/format';
|
|||
import { NumberField, NumericInput, Issues } from './Fields';
|
||||
import { DoorPosition } from './DoorPosition';
|
||||
import { insertWallAfter, moveWall } from '../lib/wall-editing';
|
||||
import { PRECISION, PROJECT_LIMITS } from '../lib/limits';
|
||||
interface Props {
|
||||
room: Room;
|
||||
geometry: Geometry;
|
||||
|
|
@ -36,6 +37,7 @@ export function OutlineEditor({
|
|||
}: Props) {
|
||||
const draggedWall = useRef<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [customSelected, setCustomSelected] = useState(false);
|
||||
const changeWall = (id: string, fn: (wall: Wall) => Wall) =>
|
||||
changeRoom((r) => ({ ...r, walls: r.walls.map((w) => (w.id === id ? fn(w) : w)) }));
|
||||
const isRectangle =
|
||||
|
|
@ -43,6 +45,10 @@ export function OutlineEditor({
|
|||
room.walls.every((w) => w.turn === 90) &&
|
||||
room.walls[0].length === room.walls[2].length &&
|
||||
room.walls[1].length === room.walls[3].length;
|
||||
const isFireplace =
|
||||
room.walls.length === 8 &&
|
||||
room.walls.every((wall, index) => wall.turn === [90, 90, 90, -90, -90, 90, 90, 90][index]);
|
||||
const isCustom = customSelected || (!isRectangle && !isFireplace);
|
||||
const lastEdge = geometry.edges.at(-1);
|
||||
const finalExpectedLength = lastEdge ? Math.hypot(lastEdge.start.x, lastEdge.start.y) : 0;
|
||||
const addWall = () =>
|
||||
|
|
@ -106,41 +112,72 @@ export function OutlineEditor({
|
|||
<span>Room name</span>
|
||||
<input
|
||||
value={room.name}
|
||||
maxLength={80}
|
||||
maxLength={PROJECT_LIMITS.nameLength}
|
||||
onChange={(e) => changeRoom((r) => ({ ...r, name: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="template-options">
|
||||
<div className="subheading shape-heading">
|
||||
<h3>Room shape</h3>
|
||||
</div>
|
||||
<div
|
||||
className="template-options"
|
||||
role="group"
|
||||
aria-label="Room shape"
|
||||
aria-describedby="custom-shape-help"
|
||||
>
|
||||
<button
|
||||
className={isRectangle ? 'template active' : 'template'}
|
||||
aria-pressed={isRectangle}
|
||||
onClick={() => changeRoom((r) => rectangle(r.id, r.name))}
|
||||
className={isRectangle && !isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isRectangle && !isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(false);
|
||||
changeRoom((r) => rectangle(r.id, r.name));
|
||||
}}
|
||||
>
|
||||
<span className="rectangle-icon" />
|
||||
Rectangle
|
||||
</button>
|
||||
<button
|
||||
className={room.walls.length === 8 ? 'template active' : 'template'}
|
||||
aria-pressed={room.walls.length === 8}
|
||||
onClick={() => changeRoom((r) => fireplace(r.id, r.name))}
|
||||
className={isFireplace && !isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isFireplace && !isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(false);
|
||||
changeRoom((r) => fireplace(r.id, r.name));
|
||||
}}
|
||||
>
|
||||
<span className="fireplace-icon" />
|
||||
With fireplace
|
||||
</button>
|
||||
<button
|
||||
className="template"
|
||||
onClick={() =>
|
||||
className={isCustom ? 'template active' : 'template'}
|
||||
aria-pressed={isCustom}
|
||||
onClick={() => {
|
||||
setCustomSelected(true);
|
||||
changeRoom((r) => ({
|
||||
...r,
|
||||
walls: [{ id: `${r.id}-w1`, length: 4000, turn: 90, doors: [] }],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<MoveUpRight size={19} />
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{isRectangle && (
|
||||
<p className="shape-help" id="custom-shape-help">
|
||||
{isCustom && geometry.closed ? (
|
||||
<>Your outline is complete. Adjust individual walls, corners or doorways below.</>
|
||||
) : isCustom ? (
|
||||
<>
|
||||
Add your walls in order below, then use <strong>Add closing wall</strong> to complete
|
||||
the outline.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Have an irregular room? Click <strong>Custom</strong> to start a new outline and enter
|
||||
each wall and corner. Choosing a shape replaces the current outline and doorways.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{isRectangle && !isCustom && (
|
||||
<div className="field-grid quick-dimensions">
|
||||
<NumberField
|
||||
label="Room width"
|
||||
|
|
@ -166,7 +203,7 @@ export function OutlineEditor({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
<details className="wall-details" open={!isRectangle}>
|
||||
<details className="wall-details" open={isCustom || !isRectangle}>
|
||||
<summary>
|
||||
Walls & corners <span>{room.walls.length} walls</span>
|
||||
</summary>
|
||||
|
|
@ -270,7 +307,7 @@ export function OutlineEditor({
|
|||
className="icon-button"
|
||||
aria-label={`Insert wall after W${i + 1}`}
|
||||
title={`Insert wall after W${i + 1}`}
|
||||
disabled={room.walls.length >= 100}
|
||||
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
|
||||
onClick={() => changeRoom((r) => insertWallAfter(r, wall.id))}
|
||||
>
|
||||
<Plus size={16} />
|
||||
|
|
@ -289,7 +326,11 @@ export function OutlineEditor({
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="text-button" onClick={addWall} disabled={room.walls.length >= 100}>
|
||||
<button
|
||||
className="text-button"
|
||||
onClick={addWall}
|
||||
disabled={room.walls.length >= PROJECT_LIMITS.wallsPerRoom}
|
||||
>
|
||||
<Plus size={15} /> Add a wall
|
||||
</button>
|
||||
</details>
|
||||
|
|
@ -317,7 +358,10 @@ export function OutlineEditor({
|
|||
<button
|
||||
className="text-button"
|
||||
onClick={closeOutline}
|
||||
disabled={room.walls.length >= 100 || Math.abs(geometry.closingTurn) >= 179.99}
|
||||
disabled={
|
||||
room.walls.length >= PROJECT_LIMITS.wallsPerRoom ||
|
||||
Math.abs(geometry.closingTurn) >= PROJECT_LIMITS.maxTurn - PRECISION.millimetres
|
||||
}
|
||||
>
|
||||
Add closing wall <Plus size={14} />
|
||||
</button>
|
||||
|
|
@ -419,7 +463,9 @@ export function OutlineEditor({
|
|||
<button
|
||||
className="text-button"
|
||||
onClick={addDoor}
|
||||
disabled={!room.walls.length || room.walls.at(-1)!.doors.length >= 30}
|
||||
disabled={
|
||||
!room.walls.length || room.walls.at(-1)!.doors.length >= PROJECT_LIMITS.doorsPerWall
|
||||
}
|
||||
>
|
||||
<Plus size={15} /> Add a doorway
|
||||
</button>
|
||||
|
|
@ -427,7 +473,7 @@ export function OutlineEditor({
|
|||
<NumberField
|
||||
label="Measurement tolerance"
|
||||
value={tolerance}
|
||||
max={50}
|
||||
max={PROJECT_LIMITS.maxTolerance}
|
||||
onChange={(n) => changeProject((p) => ({ ...p, tolerance: n }))}
|
||||
hint="Largest closing gap accepted for the cut plan."
|
||||
/>
|
||||
|
|
|
|||
66
src/components/PlannerLoading.css
Normal file
66
src/components/PlannerLoading.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
.planner-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 32px;
|
||||
background: #fdfcf7;
|
||||
}
|
||||
.planner-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.planner-loading-floor {
|
||||
display: block;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
}
|
||||
.planner-loading-outline {
|
||||
fill: none;
|
||||
stroke: #d9ddd3;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.planner-loading-row {
|
||||
fill: #d7b68a;
|
||||
opacity: 0.25;
|
||||
animation: planner-lay-boards 2.4s ease-in-out infinite;
|
||||
}
|
||||
.planner-loading-row rect:last-child {
|
||||
fill: #ead9bc;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(2) {
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(3) {
|
||||
animation-delay: 360ms;
|
||||
}
|
||||
.planner-loading-row:nth-of-type(4) {
|
||||
animation-delay: 540ms;
|
||||
}
|
||||
.planner-loading-row:first-of-type rect:first-child {
|
||||
fill: #ac451d;
|
||||
}
|
||||
@keyframes planner-lay-boards {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
25%,
|
||||
65% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.planner-loading-row {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
35
src/components/PlannerLoading.tsx
Normal file
35
src/components/PlannerLoading.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import './PlannerLoading.css';
|
||||
|
||||
export function PlannerLoading() {
|
||||
return (
|
||||
<main className="planner-loading">
|
||||
<div className="planner-loading-content" role="status" aria-live="polite" aria-atomic="true">
|
||||
<svg
|
||||
className="planner-loading-floor"
|
||||
viewBox="0 0 112 112"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<rect className="planner-loading-outline" x="4" y="4" width="104" height="104" rx="4" />
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="14" width="30" height="18" rx="1" />
|
||||
<rect x="48" y="14" width="50" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="36" width="50" height="18" rx="1" />
|
||||
<rect x="68" y="36" width="30" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="58" width="30" height="18" rx="1" />
|
||||
<rect x="48" y="58" width="50" height="18" rx="1" />
|
||||
</g>
|
||||
<g className="planner-loading-row">
|
||||
<rect x="14" y="80" width="50" height="18" rx="1" />
|
||||
<rect x="68" y="80" width="30" height="18" rx="1" />
|
||||
</g>
|
||||
</svg>
|
||||
<p>Opening your planner…</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
426
src/landing.css
426
src/landing.css
|
|
@ -2,17 +2,55 @@
|
|||
--landing-muted: #5e665f;
|
||||
--landing-line: #d9ddd3;
|
||||
--landing-accent: #ac451d;
|
||||
--landing-header-height: 80px;
|
||||
--landing-title-size: 48px;
|
||||
--landing-heading-size: 32px;
|
||||
--landing-subheading-size: 18px;
|
||||
--landing-body-size: 16px;
|
||||
--landing-caption-size: 14px;
|
||||
padding-top: var(--landing-header-height);
|
||||
background: #fdfcf7;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: var(--landing-body-size);
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.landing-page h1,
|
||||
.landing-page h2,
|
||||
.landing-page h3 {
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.landing-page h1 {
|
||||
font-size: var(--landing-title-size);
|
||||
}
|
||||
.landing-page h2 {
|
||||
font-size: var(--landing-heading-size);
|
||||
}
|
||||
.landing-page h3 {
|
||||
font-size: var(--landing-subheading-size);
|
||||
}
|
||||
.landing-page p {
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-page strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-page svg text {
|
||||
font-family: inherit;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-container {
|
||||
width: min(1180px, calc(100% - 96px));
|
||||
margin-inline: auto;
|
||||
}
|
||||
.landing-page a {
|
||||
color: inherit;
|
||||
scroll-margin-top: calc(var(--landing-header-height) + 24px);
|
||||
}
|
||||
.landing-page a:focus-visible {
|
||||
outline: 3px solid var(--blue);
|
||||
|
|
@ -20,41 +58,38 @@
|
|||
}
|
||||
.landing-page section[id],
|
||||
.landing-page main {
|
||||
scroll-margin-top: 24px;
|
||||
scroll-margin-top: calc(var(--landing-header-height) + 24px);
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 100px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 20;
|
||||
height: var(--landing-header-height);
|
||||
background: #fdfcf7;
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-header-content {
|
||||
height: 100%;
|
||||
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;
|
||||
font-size: var(--landing-subheading-size);
|
||||
font-weight: 600;
|
||||
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 {
|
||||
.landing-footer a {
|
||||
padding-block: 12px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.landing-nav a:hover,
|
||||
.landing-footer > a:last-child:hover {
|
||||
.landing-footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
|
|
@ -69,7 +104,7 @@
|
|||
border: 1px solid var(--landing-accent);
|
||||
color: #fffdf7;
|
||||
border-radius: 3px;
|
||||
font-size: 17px;
|
||||
font-size: var(--landing-body-size);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
|
|
@ -83,7 +118,7 @@
|
|||
.landing-page .landing-cta-small {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-size: var(--landing-caption-size);
|
||||
gap: 18px;
|
||||
}
|
||||
.landing-hero {
|
||||
|
|
@ -93,46 +128,9 @@
|
|||
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;
|
||||
|
|
@ -146,66 +144,32 @@
|
|||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
font-size: var(--landing-caption-size);
|
||||
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;
|
||||
font-size: var(--landing-caption-size);
|
||||
font-weight: 600;
|
||||
}
|
||||
.landing-plan-room {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 4px 12px;
|
||||
padding: 22px 24px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-plan-room > span:last-child {
|
||||
font: 10px var(--mono);
|
||||
color: #657260;
|
||||
color: var(--landing-muted);
|
||||
align-self: center;
|
||||
}
|
||||
.landing-plan-paper > svg {
|
||||
|
|
@ -219,8 +183,8 @@
|
|||
gap: 20px;
|
||||
padding: 17px 24px;
|
||||
border-top: 1px solid #dbe0d5;
|
||||
color: #526156;
|
||||
font-size: 10px;
|
||||
color: var(--landing-muted);
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-plan-legend span {
|
||||
display: inline-flex;
|
||||
|
|
@ -246,21 +210,6 @@
|
|||
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);
|
||||
|
|
@ -284,22 +233,9 @@
|
|||
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;
|
||||
|
|
@ -310,24 +246,15 @@
|
|||
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;
|
||||
.landing-guide-intro > p {
|
||||
margin-top: 22px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.landing-text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-size: 12px;
|
||||
font-size: var(--landing-caption-size);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
.landing-guide-intro > .landing-text-link {
|
||||
|
|
@ -341,8 +268,7 @@
|
|||
margin-top: 30px;
|
||||
border-top: 1px solid #cdd5c5;
|
||||
color: var(--landing-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-tool-note strong {
|
||||
display: block;
|
||||
|
|
@ -366,25 +292,17 @@
|
|||
padding-top: 0;
|
||||
}
|
||||
.landing-step-number {
|
||||
font: 13px var(--mono);
|
||||
color: var(--landing-accent);
|
||||
padding-top: 4px;
|
||||
min-width: 18px;
|
||||
font-size: var(--landing-caption-size);
|
||||
font-weight: 600;
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.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;
|
||||
margin: 18px 0 0 41px;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 73px 65px;
|
||||
|
|
@ -396,11 +314,6 @@
|
|||
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;
|
||||
|
|
@ -412,14 +325,9 @@
|
|||
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;
|
||||
font-size: var(--landing-caption-size);
|
||||
border-bottom: 1px solid var(--landing-line);
|
||||
}
|
||||
.landing-trim-diagram {
|
||||
|
|
@ -435,30 +343,14 @@
|
|||
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;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-trim-choice svg {
|
||||
margin-top: 2px;
|
||||
|
|
@ -470,9 +362,7 @@
|
|||
justify-content: space-between;
|
||||
gap: 40px;
|
||||
margin-top: 23px;
|
||||
font-size: 11px;
|
||||
color: var(--landing-muted);
|
||||
line-height: 1.8;
|
||||
font-size: var(--landing-caption-size);
|
||||
}
|
||||
.landing-finishing-note p {
|
||||
max-width: 780px;
|
||||
|
|
@ -483,69 +373,14 @@
|
|||
.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;
|
||||
padding-block: 16px;
|
||||
border-top: 1px solid var(--landing-line);
|
||||
font-size: var(--landing-caption-size);
|
||||
color: var(--landing-muted);
|
||||
}
|
||||
.landing-sr-only {
|
||||
|
|
@ -558,27 +393,21 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
.landing-skip {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
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;
|
||||
}
|
||||
|
|
@ -599,20 +428,14 @@
|
|||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.landing-nav {
|
||||
display: none;
|
||||
}
|
||||
.landing-header {
|
||||
min-height: 82px;
|
||||
.landing-page {
|
||||
--landing-header-height: 72px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
|
@ -624,11 +447,6 @@
|
|||
.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;
|
||||
|
|
@ -656,41 +474,23 @@
|
|||
}
|
||||
.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-page {
|
||||
--landing-title-size: 36px;
|
||||
--landing-heading-size: 28px;
|
||||
}
|
||||
.landing-container {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
.landing-header {
|
||||
.landing-header-content {
|
||||
gap: 12px;
|
||||
}
|
||||
.landing-brand {
|
||||
font-size: 19px;
|
||||
gap: 8px;
|
||||
}
|
||||
.landing-brand .square-mark {
|
||||
|
|
@ -699,16 +499,10 @@
|
|||
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 {
|
||||
|
|
@ -717,29 +511,15 @@
|
|||
.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;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.landing-benefits {
|
||||
padding-bottom: 42px;
|
||||
|
|
@ -765,16 +545,11 @@
|
|||
.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;
|
||||
margin-left: 36px;
|
||||
}
|
||||
.landing-finishing {
|
||||
padding-block: 46px 36px;
|
||||
|
|
@ -784,7 +559,6 @@
|
|||
gap: 22px;
|
||||
}
|
||||
.landing-trim-label {
|
||||
font-size: 9px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
.landing-trim-copy {
|
||||
|
|
@ -797,18 +571,6 @@
|
|||
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 {
|
||||
|
|
@ -821,7 +583,6 @@
|
|||
@media print {
|
||||
.landing-header,
|
||||
.landing-footer,
|
||||
.landing-final-cta,
|
||||
.landing-hero-actions,
|
||||
.landing-skip {
|
||||
display: none;
|
||||
|
|
@ -829,9 +590,8 @@
|
|||
.landing-container {
|
||||
width: 100%;
|
||||
}
|
||||
.landing-plan {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
.landing-page {
|
||||
padding-top: 0;
|
||||
}
|
||||
.landing-trim-card,
|
||||
.landing-steps li {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const initAnalytics = () => {
|
|||
endpoint: ANALYTICS_ENDPOINT,
|
||||
autoCapturePageviews: ANALYTICS_AUTO_CAPTURE_PAGEVIEWS,
|
||||
logging: ANALYTICS_LOGGING,
|
||||
// Query strings and legacy fragments contain the saved plan, including room names.
|
||||
// Query strings contain the saved plan, including room names.
|
||||
transformRequest: (payload) => ({
|
||||
...payload,
|
||||
u: payload.u.split(/[?#]/)[0],
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import type {
|
|||
} from './types';
|
||||
|
||||
import { cutMm, degree } from './format';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
const sameLength = (a: number, b: number) => Math.abs(a - b) < 0.01;
|
||||
const sameLength = (a: number, b: number) => Math.abs(a - b) < PRECISION.millimetres;
|
||||
|
||||
/** A ready offcut still needs a placement code; only fresh, uncut whole boards are omitted. */
|
||||
export const flooringCuts = (plan: FlooringPlan) =>
|
||||
|
|
@ -21,8 +22,8 @@ export function isSkirtingCut(piece: SkirtingPiece, plan: SkirtingPlan): boolean
|
|||
piece.rightType === 'cope' ||
|
||||
!stock ||
|
||||
!sameLength(piece.length, stock.length) ||
|
||||
Math.abs(piece.leftMitre) > 0.001 ||
|
||||
Math.abs(piece.rightMitre) > 0.001
|
||||
Math.abs(piece.leftMitre) > PRECISION.cutAngle ||
|
||||
Math.abs(piece.rightMitre) > PRECISION.cutAngle
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -79,7 +80,7 @@ export function skirtingEndLabel(piece: SkirtingPiece, side: 'left' | 'right'):
|
|||
const angle = side === 'left' ? piece.leftMitre : piece.rightMitre;
|
||||
if (type === 'cope') return `Cope · prepare ${degree(angle)}`;
|
||||
if (type === 'internal')
|
||||
return `Receive cope · ${Math.abs(angle) < 0.001 ? 'square' : `wall-fit ${degree(angle)}`}`;
|
||||
return `Receive cope · ${Math.abs(angle) < PRECISION.cutAngle ? 'square' : `wall-fit ${degree(angle)}`}`;
|
||||
return type === 'external' ? `Mitre ${degree(angle)}` : 'Square';
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +99,6 @@ export function skirtingInstructions(piece: SkirtingPiece): string {
|
|||
messages.push(
|
||||
`Prepare the ${copes.join(' and ')} inside mitre, mark your moulding’s exposed profile, then cope and back-cut to fit the receiving face. Keep the front profile line; the dashed end shows the full-thickness body after coping, not the decorative profile.`,
|
||||
);
|
||||
if (piece.visibleLength !== undefined)
|
||||
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
|
||||
messages.push(`Exposed front length: ${cutMm(piece.visibleLength)} mm.`);
|
||||
return messages.join(' ');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FlooringPiece, Point, SkirtingPiece } from './types';
|
||||
import type { Point } from './types';
|
||||
import { cutMm, degree } from './format';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
export interface PreviewBox {
|
||||
x: number;
|
||||
|
|
@ -66,7 +67,7 @@ export function dimensionedOutline(
|
|||
...edge,
|
||||
length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y),
|
||||
}))
|
||||
.filter((edge) => edge.length > 0.000001)
|
||||
.filter((edge) => edge.length > PRECISION.geometry)
|
||||
.sort((a, b) => b.length - a.length || a.edge - b.edge);
|
||||
|
||||
for (const edge of selected) {
|
||||
|
|
@ -77,14 +78,14 @@ export function dimensionedOutline(
|
|||
const normal = { x: orientation * direction.y, y: -orientation * direction.x };
|
||||
const center = { x: (edge.start.x + edge.end.x) / 2, y: (edge.start.y + edge.end.y) / 2 };
|
||||
const limit =
|
||||
Math.abs(direction.y) < 1e-6
|
||||
Math.abs(direction.y) < PRECISION.geometry
|
||||
? blank.length
|
||||
: Math.abs(direction.x) < 1e-6
|
||||
: Math.abs(direction.x) < PRECISION.geometry
|
||||
? blank.width
|
||||
: undefined;
|
||||
const text = cutMm(edge.length, limit);
|
||||
const angle =
|
||||
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
|
||||
Math.abs(direction.x) > PRECISION.geometry && Math.abs(direction.y) > PRECISION.geometry
|
||||
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
|
||||
: undefined;
|
||||
const angleText = angle === undefined ? '' : degree(angle);
|
||||
|
|
@ -155,29 +156,6 @@ export function dimensionedOutline(
|
|||
};
|
||||
}
|
||||
|
||||
export function flooringTemplate(piece: FlooringPiece): Point[] {
|
||||
if (piece.template.length) return piece.template;
|
||||
return piece.shaped
|
||||
? []
|
||||
: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: piece.length, y: 0 },
|
||||
{ x: piece.length, y: piece.width },
|
||||
{ x: 0, y: piece.width },
|
||||
];
|
||||
}
|
||||
|
||||
export function skirtingTemplate(piece: SkirtingPiece, thickness: number): Point[] {
|
||||
if (piece.template?.length === 4) return piece.template;
|
||||
if (piece.leftMitre !== 0 || piece.rightMitre !== 0) return [];
|
||||
return [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: piece.length, y: 0 },
|
||||
{ x: piece.length, y: thickness },
|
||||
{ x: 0, y: thickness },
|
||||
];
|
||||
}
|
||||
|
||||
/** Padding changes the viewport only; all vertices stay in their original millimetres. */
|
||||
export function fullPreviewBox(length: number, width: number): PreviewBox {
|
||||
const padding = Math.max(length, width, 1) * 0.06;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function fireplace(id: string, name: string): Room {
|
|||
}
|
||||
export function defaultProject(): Project {
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
name: 'My flooring project',
|
||||
rooms: [rectangle()],
|
||||
tolerance: 10,
|
||||
|
|
@ -42,7 +42,7 @@ export function defaultProject(): Project {
|
|||
length: 1200,
|
||||
width: 190,
|
||||
gap: 10,
|
||||
pattern: 'brick',
|
||||
pattern: 'random',
|
||||
optimisation: 'cuts',
|
||||
minOffset: 300,
|
||||
maxOffset: 600,
|
||||
|
|
@ -56,7 +56,13 @@ export function defaultProject(): Project {
|
|||
export function defaultState(): UrlState {
|
||||
return {
|
||||
project: defaultProject(),
|
||||
view: { roomId: 'room-1', selectedPiece: null, search: '', collapsedSections: [] },
|
||||
view: {
|
||||
roomId: 'room-1',
|
||||
selectedPiece: null,
|
||||
search: '',
|
||||
collapsedSections: [],
|
||||
material: 'flooring',
|
||||
},
|
||||
};
|
||||
}
|
||||
export function nextId(prefix: string, existing: string[]): string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import ClipperLib from 'clipper-lib';
|
||||
import { buildGeometry, insetPolygon } from './geometry';
|
||||
import { cutLength } from './format';
|
||||
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
FlooringPiece,
|
||||
FlooringPlan,
|
||||
|
|
@ -12,9 +13,9 @@ import type {
|
|||
Room,
|
||||
} from './types';
|
||||
|
||||
const SCALE = 1000;
|
||||
const EPSILON = 0.01;
|
||||
const MAX_BOARDS = 2000;
|
||||
const SCALE = PRECISION.clipperScale;
|
||||
const EPSILON = PRECISION.millimetres;
|
||||
const MAX_BOARDS = PLANNER_LIMITS.flooringBoards;
|
||||
|
||||
export type FlooringOptions = Record<'cuts' | 'random', FlooringPlan>;
|
||||
|
||||
|
|
@ -28,14 +29,25 @@ export function planFlooringOptions(
|
|||
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 variablePattern = s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON;
|
||||
const candidates = [cuts];
|
||||
if (variablePattern) candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes));
|
||||
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;
|
||||
candidates.reduce((best, candidate) =>
|
||||
(errors(candidate) - errors(best) ||
|
||||
compareLayouts(candidate.layout, best.layout, settings)) < 0
|
||||
? candidate
|
||||
: best,
|
||||
);
|
||||
// If repetition remains, try immediate reuse too. Keep the best completed result from either route.
|
||||
if (
|
||||
variablePattern &&
|
||||
(better(randomSettings).layout.patternPenalty > 0 || errors(better(randomSettings)))
|
||||
) {
|
||||
candidates.push(solveFlooring(rooms, randomSettings, tolerance, shapes, false));
|
||||
}
|
||||
const cutWinner = better(cutsSettings);
|
||||
const randomWinner = better(randomSettings);
|
||||
const cutPlan = finishFlooring(cutWinner, s);
|
||||
|
|
@ -46,7 +58,7 @@ export function planFlooringOptions(
|
|||
}
|
||||
|
||||
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation ?? 'cuts'];
|
||||
return planFlooringOptions(rooms, s, tolerance)[s.optimisation];
|
||||
}
|
||||
|
||||
interface Bounds {
|
||||
|
|
@ -164,7 +176,7 @@ function rowWidthOptions(
|
|||
cornerOffsets: number[] = [],
|
||||
): number[][] {
|
||||
const count = Math.ceil((height - EPSILON) / s.width);
|
||||
if (count <= 0 || count > 500) return [];
|
||||
if (count <= 0 || count > PLANNER_LIMITS.flooringRowsPerRoom) return [];
|
||||
if (count === 1) return height + EPSILON >= s.minWidth ? [[height]] : [];
|
||||
const edgeTotal = height - (count - 2) * s.width;
|
||||
if (edgeTotal + EPSILON < 2 * s.minWidth) return [];
|
||||
|
|
@ -260,17 +272,21 @@ function cutRow(
|
|||
return pieces;
|
||||
}
|
||||
|
||||
/** A cut end cannot replace a factory joint. Offcuts retain their original left/right end flags. */
|
||||
/** Preserve factory-ended tails for exact fits; final packing can combine blanks that need trimming. */
|
||||
function allocate(
|
||||
layout: Layout,
|
||||
piece: CutPiece,
|
||||
pieceId: string,
|
||||
s: FlooringSettings,
|
||||
preserveEnds: boolean,
|
||||
): { stockId: string; reused: boolean; crosscut: boolean; blankLength: number } {
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
let stock: AvailableStock | undefined;
|
||||
for (const candidate of piece.needsLeft && piece.needsRight ? [] : layout.stocks) {
|
||||
if (
|
||||
(preserveEnds &&
|
||||
(piece.needsLeft || piece.needsRight) &&
|
||||
!close(candidate.remaining, length)) ||
|
||||
candidate.remaining < blankLength(length, candidate.remaining) - EPSILON ||
|
||||
(piece.needsLeft && !candidate.left) ||
|
||||
(piece.needsRight && !candidate.right)
|
||||
|
|
@ -403,6 +419,7 @@ function appendRow(
|
|||
shape: RowShape,
|
||||
phase: number,
|
||||
s: FlooringSettings,
|
||||
preserveEnds: boolean,
|
||||
): Layout {
|
||||
const layout: Layout = {
|
||||
pieces: [...base.pieces],
|
||||
|
|
@ -419,7 +436,7 @@ function appendRow(
|
|||
const pieceIds: string[] = [];
|
||||
cuts.forEach((piece, column) => {
|
||||
const id = `F${layout.pieces.length + 1}`;
|
||||
const allocation = allocate(layout, piece, id, s);
|
||||
const allocation = allocate(layout, piece, id, s, preserveEnds);
|
||||
const length = round(piece.bounds.maxX - piece.bounds.minX);
|
||||
const width = round(piece.bounds.maxY - piece.bounds.minY);
|
||||
// Count straight saw passes. For a shape, every additional non-bounding edge needs a pass.
|
||||
|
|
@ -705,7 +722,11 @@ function compareLayouts(a: Layout, b: Layout, s: FlooringSettings): number {
|
|||
function validateSettings(s: FlooringSettings): string | null {
|
||||
if (Object.values(s).some((value) => typeof value === 'number' && !Number.isFinite(value)))
|
||||
return 'All flooring measurements must be finite numbers.';
|
||||
if (Object.values(s).some((value) => typeof value === 'number' && Math.abs(value) > 1e6))
|
||||
if (
|
||||
Object.values(s).some(
|
||||
(value) => typeof value === 'number' && Math.abs(value) > PROJECT_LIMITS.maxMeasurement,
|
||||
)
|
||||
)
|
||||
return 'Flooring measurements must not exceed 1,000,000 mm. Check your units.';
|
||||
if (s.length < 0.1 || s.width < 0.1 || s.minLength < 0.1 || s.minWidth < 0.1)
|
||||
return 'Board and minimum piece dimensions must be at least 0.1 mm.';
|
||||
|
|
@ -725,9 +746,9 @@ function validateSettings(s: FlooringSettings): string | null {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deterministic bounded beam search: boards consumed first; random layouts then
|
||||
* prefer less visual repetition before fewer saw passes. This can spend an extra
|
||||
* crosscut to break a staircase without buying an extra board.
|
||||
* Deterministic bounded beam search with selectable cut-first or pattern-first priorities.
|
||||
* Easier cuts break saw-count ties, then visual repetition and finally purchased boards.
|
||||
* Completed candidates are repacked and interchangeable rows can be reordered without new cuts.
|
||||
* Stock is shared between rooms, but layouts and joint phases are independent.
|
||||
* 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.
|
||||
|
|
@ -737,6 +758,7 @@ function solveFlooring(
|
|||
s: FlooringSettings,
|
||||
tolerance: number,
|
||||
shapes: ShapeCache,
|
||||
preserveEnds = true,
|
||||
): Solution {
|
||||
let layout: Layout = {
|
||||
pieces: [],
|
||||
|
|
@ -835,7 +857,8 @@ function solveFlooring(
|
|||
cache.set(phase, cuts);
|
||||
}
|
||||
if (!cuts?.length) continue;
|
||||
const candidate = appendRow(base, cuts, room, row, shape, phase, s);
|
||||
let candidate = appendRow(base, cuts, room, row, shape, phase, s, preserveEnds);
|
||||
if (candidate.stocks.length > MAX_BOARDS) candidate = repack(candidate, s);
|
||||
if (candidate.stocks.length <= MAX_BOARDS) alternatives.push(candidate);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { PRECISION } from './limits';
|
||||
|
||||
export const mm = (value: number) =>
|
||||
Number.isFinite(value)
|
||||
? new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }).format(value)
|
||||
: '—';
|
||||
/** Round cutting marks up without letting floating-point noise add a half millimetre. */
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - 1e-9) / 2 + 0;
|
||||
export const cutLength = (value: number) => Math.ceil(value * 2 - PRECISION.roundingNoise) / 2 + 0;
|
||||
/** An exact-fit stock edge or remnant needs no rounded-up preparation cut. */
|
||||
export const cutMm = (value: number, available = Infinity) =>
|
||||
mm(Math.min(cutLength(value), available));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import ClipperLib from 'clipper-lib';
|
||||
import { PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type { Geometry, Issue, Point, Room, WallEdge } from './types';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const SCALE = 1000;
|
||||
const MAX_WALL = 1_000_000;
|
||||
const EPS = PRECISION.geometry;
|
||||
const SCALE = PRECISION.clipperScale;
|
||||
const MAX_WALL = PROJECT_LIMITS.maxMeasurement;
|
||||
const radians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const degrees = (angle: number) => (angle * 180) / Math.PI;
|
||||
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
|
@ -50,11 +51,13 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
|
|||
let position = origin;
|
||||
let heading = 0;
|
||||
const allowedGap =
|
||||
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= 50 ? tolerance : 0;
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > 50) {
|
||||
Number.isFinite(tolerance) && tolerance >= 0 && tolerance <= PROJECT_LIMITS.maxTolerance
|
||||
? tolerance
|
||||
: 0;
|
||||
if (!Number.isFinite(tolerance) || tolerance < 0 || tolerance > PROJECT_LIMITS.maxTolerance) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
message: 'Measurement tolerance must be between 0 and 50 mm.',
|
||||
message: `Measurement tolerance must be between 0 and ${PROJECT_LIMITS.maxTolerance} mm.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +71,7 @@ export function buildGeometry(room: Room, tolerance: number): Geometry {
|
|||
// The last stored turn only applies if another wall is subsequently added.
|
||||
if (
|
||||
index < room.walls.length - 1 &&
|
||||
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= 180)
|
||||
(!Number.isFinite(wall.turn) || Math.abs(wall.turn) >= PROJECT_LIMITS.maxTurn)
|
||||
) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
|
|||
35
src/lib/limits.ts
Normal file
35
src/lib/limits.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** Limits shared by editing, persisted state validation and the planners. */
|
||||
export const PROJECT_LIMITS = {
|
||||
rooms: 20,
|
||||
wallsPerRoom: 100,
|
||||
doorsPerWall: 30,
|
||||
nameLength: 200,
|
||||
maxMeasurement: 1_000_000,
|
||||
maxTolerance: 50,
|
||||
maxTurn: 180,
|
||||
maxSkirtingThickness: 10_000,
|
||||
maxSkirtingKerf: 10_000,
|
||||
} as const;
|
||||
|
||||
export const PERSISTENCE_LIMITS = {
|
||||
queryLength: 100_000,
|
||||
identifierLength: 120,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
export const PLANNER_LIMITS = {
|
||||
flooringBoards: 2_000,
|
||||
flooringRowsPerRoom: 500,
|
||||
skirtingPieces: 10_000,
|
||||
} as const;
|
||||
|
||||
/** Named numeric tolerances; each represents a distinct calculation boundary. */
|
||||
export const PRECISION = {
|
||||
geometry: 1e-6,
|
||||
millimetres: 0.01,
|
||||
cutAngle: 0.001,
|
||||
clipping: 0.001,
|
||||
clipperScale: 1_000,
|
||||
roundingNoise: 1e-9,
|
||||
} as const;
|
||||
|
|
@ -1,29 +1,17 @@
|
|||
import type { Point, SkirtingPiece } from './types';
|
||||
import { PRECISION } from './limits';
|
||||
|
||||
/** Place the cutting template against its wall, preserving mitres and the coped body setbacks. */
|
||||
export function skirtingPolygon(
|
||||
piece: SkirtingPiece,
|
||||
thickness: number,
|
||||
orientation: number,
|
||||
): Point[] {
|
||||
export function skirtingPolygon(piece: SkirtingPiece, orientation: number): Point[] {
|
||||
const dx = piece.end.x - piece.start.x;
|
||||
const dy = piece.end.y - piece.start.y;
|
||||
const backLength = Math.hypot(dx, dy);
|
||||
if (backLength < 1e-6) return [];
|
||||
if (backLength < PRECISION.geometry) return [];
|
||||
const tangent = { x: dx / backLength, y: dy / backLength };
|
||||
const side = orientation < 0 ? -1 : 1;
|
||||
const inward = { x: -tangent.y * side, y: tangent.x * side };
|
||||
const template = piece.template?.length
|
||||
? piece.template
|
||||
: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: backLength, y: 0 },
|
||||
{ x: backLength, y: thickness },
|
||||
{ x: 0, y: thickness },
|
||||
];
|
||||
const installed = piece.installedTemplate?.length ? piece.installedTemplate : template;
|
||||
return installed.map((point) => ({
|
||||
x: piece.start.x + tangent.x * (point.x - template[0].x) + inward.x * point.y,
|
||||
y: piece.start.y + tangent.y * (point.x - template[0].x) + inward.y * point.y,
|
||||
return piece.installedTemplate.map((point) => ({
|
||||
x: piece.start.x + tangent.x * (point.x - piece.template[0].x) + inward.x * point.y,
|
||||
y: piece.start.y + tangent.y * (point.x - piece.template[0].x) + inward.y * point.y,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { buildGeometry, normalizeAngle, signedArea } from './geometry';
|
||||
import { isSkirtingCut } from './cut-list';
|
||||
import { cutLength } from './format';
|
||||
import { PLANNER_LIMITS, PRECISION, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
Issue,
|
||||
Point,
|
||||
|
|
@ -12,7 +13,8 @@ import type {
|
|||
WallEdge,
|
||||
} from './types';
|
||||
|
||||
const EPS = 1e-6;
|
||||
const EPS = PRECISION.geometry;
|
||||
type UnpackedPiece = Omit<SkirtingPiece, 'blankLength'>;
|
||||
const rad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
type End = {
|
||||
type: SkirtingPiece['leftType'];
|
||||
|
|
@ -117,7 +119,7 @@ function corner(turn: number, thickness: number, side: 'left' | 'right', cope: b
|
|||
};
|
||||
}
|
||||
|
||||
function endCuts(piece: SkirtingPiece) {
|
||||
function endCuts(piece: UnpackedPiece) {
|
||||
return (
|
||||
Number(Math.abs(piece.leftMitre) > EPS) +
|
||||
Number(Math.abs(piece.rightMitre) > EPS) +
|
||||
|
|
@ -126,15 +128,15 @@ function endCuts(piece: SkirtingPiece) {
|
|||
);
|
||||
}
|
||||
|
||||
function blankLength(piece: SkirtingPiece, available: number) {
|
||||
function blankLength(piece: UnpackedPiece, available: number) {
|
||||
return Math.abs(available - piece.length) <= EPS ? available : cutLength(piece.length);
|
||||
}
|
||||
|
||||
function requiredCuts(piece: SkirtingPiece, available: number) {
|
||||
function requiredCuts(piece: UnpackedPiece, available: number) {
|
||||
return endCuts(piece) + Number(Math.abs(available - piece.length) > EPS);
|
||||
}
|
||||
|
||||
function consumedLength(piece: SkirtingPiece, available: number, kerf: number) {
|
||||
function consumedLength(piece: UnpackedPiece, available: number, kerf: number) {
|
||||
// Cut a square blank, then bevel within its longest-point envelope. This leaves
|
||||
// a square reusable offcut. Bevel kerfs remove material inside the blank.
|
||||
const length = blankLength(piece, available);
|
||||
|
|
@ -147,7 +149,7 @@ function makePiece(
|
|||
to: number,
|
||||
first: boolean,
|
||||
last: boolean,
|
||||
): SkirtingPiece {
|
||||
): UnpackedPiece {
|
||||
const left = first ? run.left : square(),
|
||||
right = last ? run.right : square();
|
||||
const backFrom = from + left.back,
|
||||
|
|
@ -189,16 +191,16 @@ function makePiece(
|
|||
};
|
||||
}
|
||||
|
||||
function shortestEdge(piece: SkirtingPiece) {
|
||||
const installed = piece.installedTemplate!;
|
||||
function shortestEdge(piece: UnpackedPiece) {
|
||||
const installed = piece.installedTemplate;
|
||||
return Math.min(
|
||||
installed[1].x - installed[0].x,
|
||||
installed[2].x - installed[3].x,
|
||||
piece.visibleLength!,
|
||||
piece.visibleLength,
|
||||
);
|
||||
}
|
||||
|
||||
function fits(piece: SkirtingPiece, available: number) {
|
||||
function fits(piece: UnpackedPiece, available: number) {
|
||||
// The final saw pass may extend beyond the stock's tail.
|
||||
return blankLength(piece, available) <= available + EPS;
|
||||
}
|
||||
|
|
@ -211,7 +213,7 @@ function splitCount(run: Run, target: number, minimum: number): number {
|
|||
const count = Math.max(2, Math.ceil((full.length - EPS) / target));
|
||||
const minimumSpan = count * minimum + firstAllowance(run.left) - lastAllowance(run.right);
|
||||
if (
|
||||
count > 10000 ||
|
||||
count > PLANNER_LIMITS.skirtingPieces ||
|
||||
run.to - run.from < minimumSpan - EPS ||
|
||||
firstAllowance(run.left) - Math.min(run.left.back, run.left.front) > target - minimum + EPS ||
|
||||
Math.max(run.right.back, run.right.front) - lastAllowance(run.right) > target - minimum + EPS
|
||||
|
|
@ -226,7 +228,7 @@ function splitRun(
|
|||
target: number,
|
||||
settings: SkirtingSettings,
|
||||
balanced: boolean,
|
||||
): SkirtingPiece[] | null {
|
||||
): UnpackedPiece[] | null {
|
||||
const count = splitCount(run, target, settings.minLength);
|
||||
if (!count) return null;
|
||||
if (count === 1) return [makePiece(run, run.from, run.to, true, true)];
|
||||
|
|
@ -291,8 +293,8 @@ function splitWithOffcuts(
|
|||
runs: Run[],
|
||||
target: number,
|
||||
settings: SkirtingSettings,
|
||||
): SkirtingPiece[] | null {
|
||||
const pieces: SkirtingPiece[] = [];
|
||||
): UnpackedPiece[] | null {
|
||||
const pieces: UnpackedPiece[] = [];
|
||||
const offcuts: number[] = [];
|
||||
for (const run of runs) {
|
||||
if (!splitCount(run, target, settings.minLength)) return null;
|
||||
|
|
@ -322,7 +324,7 @@ function splitWithOffcuts(
|
|||
bFits = fits(full, b);
|
||||
return aFits !== bFits ? Number(bFits) - Number(aFits) : aFits ? a - b : b - a;
|
||||
});
|
||||
let next: { piece: SkirtingPiece; to: number } | null = null;
|
||||
let next: { piece: UnpackedPiece; to: number } | null = null;
|
||||
let available = target;
|
||||
for (let index = 0; index < offcuts.length; index++) {
|
||||
next = take(offcuts[index]);
|
||||
|
|
@ -332,7 +334,7 @@ function splitWithOffcuts(
|
|||
}
|
||||
}
|
||||
next ??= take(target);
|
||||
if (!next || pieces.length >= 10000) return null;
|
||||
if (!next || pieces.length >= PLANNER_LIMITS.skirtingPieces) return null;
|
||||
pieces.push(next.piece);
|
||||
const tail = available - consumedLength(next.piece, available, settings.kerf);
|
||||
if (tail >= settings.minLength - EPS) offcuts.push(tail);
|
||||
|
|
@ -349,14 +351,14 @@ interface Packed {
|
|||
waste: number;
|
||||
}
|
||||
|
||||
/** Best-fit decreasing across all rooms, using one purchased board length. */
|
||||
/** Greedy decreasing packing, optionally reserving exact three-piece combinations. */
|
||||
function pack(
|
||||
source: SkirtingPiece[],
|
||||
source: UnpackedPiece[],
|
||||
stockLength: number,
|
||||
kerf: number,
|
||||
exactTails = false,
|
||||
): Packed {
|
||||
const pieces = source.map((piece) => ({ ...piece }));
|
||||
const pieces: SkirtingPiece[] = source.map((piece) => ({ ...piece, blankLength: 0 }));
|
||||
let pending = [...pieces].sort(
|
||||
(a, b) => b.length - a.length || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
|
||||
);
|
||||
|
|
@ -425,16 +427,16 @@ export function planSkirting(
|
|||
if (
|
||||
!Number.isFinite(settings.stockLength) ||
|
||||
settings.stockLength < 0.1 ||
|
||||
settings.stockLength > 1e6 ||
|
||||
settings.stockLength > PROJECT_LIMITS.maxMeasurement ||
|
||||
!Number.isFinite(settings.thickness) ||
|
||||
settings.thickness < 0 ||
|
||||
settings.thickness > 10000 ||
|
||||
settings.thickness > PROJECT_LIMITS.maxSkirtingThickness ||
|
||||
!Number.isFinite(settings.minLength) ||
|
||||
settings.minLength < 0.1 ||
|
||||
settings.minLength > 1e6 ||
|
||||
settings.minLength > PROJECT_LIMITS.maxMeasurement ||
|
||||
!Number.isFinite(settings.kerf) ||
|
||||
settings.kerf < 0 ||
|
||||
settings.kerf > 10000
|
||||
settings.kerf > PROJECT_LIMITS.maxSkirtingKerf
|
||||
) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
|
|
@ -445,7 +447,7 @@ export function planSkirting(
|
|||
}
|
||||
const stockLength = settings.stockLength;
|
||||
let runs: Run[] = [];
|
||||
const fallbackSplits = new Map<Run, SkirtingPiece[]>();
|
||||
const fallbackSplits = new Map<Run, UnpackedPiece[]>();
|
||||
rooms.forEach((room, roomIndex) => {
|
||||
const geometry = buildGeometry(room, tolerance);
|
||||
if (!geometry.valid) {
|
||||
|
|
@ -533,7 +535,7 @@ export function planSkirting(
|
|||
|
||||
const candidates: Packed[] = [];
|
||||
const prefixes = new Map(runs.map((run) => [run.roomId, run.prefix]));
|
||||
const addCandidate = (source: SkirtingPiece[]) => {
|
||||
const addCandidate = (source: UnpackedPiece[]) => {
|
||||
// Raw spans can fit while their displayed half-millimetre blanks do not.
|
||||
// Reject this split without losing other feasible joint arrangements.
|
||||
if (
|
||||
|
|
@ -587,7 +589,7 @@ export function planSkirting(
|
|||
issues.push({
|
||||
severity: 'info',
|
||||
message:
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, then prefers less waste and fewer cuts; it is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
'Deterministic stock packing shares offcuts across rooms. It compares full-stock, balanced and offcut-led joins, prioritising fewer cuts and then less waste. Exact combinations can save a trimming pass. This is a heuristic, not a guaranteed global optimum. Cut square blanks to the longest-point lengths, then prepare their mitres and cope marked ends. Each cope counts one additional shaping operation; profile-specific relief passes are not estimated. Crosscut kerf is allowed between blanks; bevel kerfs stay inside the blanks. Waste is unused stock length including crosscut kerf.',
|
||||
});
|
||||
const plan = { ...best, stockCount: best.stocks.length, issues };
|
||||
const cutNumbers = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export interface FlooringSettings {
|
|||
gap: number;
|
||||
pattern: 'brick' | 'random';
|
||||
/** Random flooring can prioritise fewer saw passes or less visual repetition. */
|
||||
optimisation?: 'cuts' | 'random';
|
||||
optimisation: 'cuts' | 'random';
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
minLength: number;
|
||||
|
|
@ -40,7 +40,7 @@ export interface SkirtingSettings {
|
|||
kerf: number;
|
||||
}
|
||||
export interface Project {
|
||||
version: 1;
|
||||
version: 2;
|
||||
name: string;
|
||||
rooms: Room[];
|
||||
flooring: FlooringSettings;
|
||||
|
|
@ -50,11 +50,13 @@ export interface Project {
|
|||
completedBoards: string[];
|
||||
}
|
||||
export type PlannerSection = 'measurements' | 'materials' | 'cuts';
|
||||
export type PlanMaterial = 'flooring' | 'skirting';
|
||||
export interface ViewState {
|
||||
roomId: string;
|
||||
selectedPiece: string | null;
|
||||
search: string;
|
||||
collapsedSections: PlannerSection[];
|
||||
material: PlanMaterial;
|
||||
}
|
||||
export interface UrlState {
|
||||
project: Project;
|
||||
|
|
@ -99,8 +101,8 @@ export interface FlooringPiece {
|
|||
length: number;
|
||||
width: number;
|
||||
/** Preparation dimensions reserved from stock, including exact-fit remnants. */
|
||||
blankLength?: number;
|
||||
blankWidth?: number;
|
||||
blankLength: number;
|
||||
blankWidth: number;
|
||||
stockId: string;
|
||||
reused: boolean;
|
||||
cut: boolean;
|
||||
|
|
@ -108,9 +110,9 @@ export interface FlooringPiece {
|
|||
/** Cutting template, relative to the bounding-box origin in board coordinates. */
|
||||
template: Point[];
|
||||
/** Ends that must retain factory joints; ripEdge names the board-axis long edge(s) to trim. */
|
||||
factoryLeft?: boolean;
|
||||
factoryRight?: boolean;
|
||||
ripEdge?: 'top' | 'bottom' | 'both' | null;
|
||||
factoryLeft: boolean;
|
||||
factoryRight: boolean;
|
||||
ripEdge: 'top' | 'bottom' | 'both' | null;
|
||||
}
|
||||
export interface FlooringRow {
|
||||
roomId: string;
|
||||
|
|
@ -145,7 +147,7 @@ export interface SkirtingPiece {
|
|||
end: Point;
|
||||
length: number;
|
||||
/** Preparation length reserved from stock; a whole fractional length stays exact. */
|
||||
blankLength?: number;
|
||||
blankLength: number;
|
||||
backLength: number;
|
||||
leftMitre: number;
|
||||
rightMitre: number;
|
||||
|
|
@ -153,17 +155,17 @@ export interface SkirtingPiece {
|
|||
rightType: 'square' | 'external' | 'internal' | 'cope';
|
||||
stockId: string;
|
||||
/** Plan section: x follows installation direction, back y=0, front y=thickness. */
|
||||
template?: Point[];
|
||||
template: [Point, Point, Point, Point];
|
||||
/** Full-thickness installed footprint after coping, separate from the cutting blank. */
|
||||
installedTemplate?: Point[];
|
||||
installedTemplate: [Point, Point, Point, Point];
|
||||
/** Exposed front run used to enforce the minimum installed piece length. */
|
||||
visibleLength?: number;
|
||||
visibleLength: number;
|
||||
/** Extra blank length retained at each end for a coped internal joint. */
|
||||
leftOverlap?: number;
|
||||
rightOverlap?: number;
|
||||
leftOverlap: number;
|
||||
rightOverlap: number;
|
||||
/** Positive means the front cut point is farther right than the back point. */
|
||||
leftBevel?: number;
|
||||
rightBevel?: number;
|
||||
leftBevel: number;
|
||||
rightBevel: number;
|
||||
}
|
||||
export interface SkirtingStock {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import LZString from 'lz-string';
|
||||
import { defaultState } from './defaults';
|
||||
import { PERSISTENCE_LIMITS, PROJECT_LIMITS } from './limits';
|
||||
import type {
|
||||
Door,
|
||||
FlooringSettings,
|
||||
|
|
@ -10,21 +10,6 @@ import type {
|
|||
Wall,
|
||||
} from './types';
|
||||
|
||||
/** Resource limits apply to links as well as edits made inside the app. */
|
||||
export const URL_LIMITS = {
|
||||
queryLength: 100_000,
|
||||
hashLength: 100_000,
|
||||
jsonLength: 1_000_000,
|
||||
rooms: 20,
|
||||
walls: 100,
|
||||
doors: 30,
|
||||
legacyStockLengths: 30,
|
||||
identifierLength: 120,
|
||||
nameLength: 200,
|
||||
completedBoards: 12_000,
|
||||
searchLength: 200,
|
||||
} as const;
|
||||
|
||||
const SECTIONS = ['measurements', 'materials', 'cuts'] as const;
|
||||
|
||||
function object(value: unknown, field: string): Record<string, unknown> {
|
||||
|
|
@ -46,7 +31,7 @@ function number(value: unknown, field: string): number {
|
|||
function string(
|
||||
value: unknown,
|
||||
field: string,
|
||||
maxLength: number = URL_LIMITS.identifierLength,
|
||||
maxLength: number = PERSISTENCE_LIMITS.identifierLength,
|
||||
): string {
|
||||
if (typeof value !== 'string' || value.length > maxLength) {
|
||||
throw new Error(`${field} must be text of at most ${maxLength} characters.`);
|
||||
|
|
@ -89,22 +74,20 @@ function uniqueValues<T extends string>(items: T[], field: string): T[] {
|
|||
}
|
||||
|
||||
function completedBoards(value: unknown): string[] {
|
||||
const entries = array(
|
||||
value === undefined ? [] : value,
|
||||
'Completed boards',
|
||||
URL_LIMITS.completedBoards,
|
||||
).map((value) => {
|
||||
const key = string(value, 'Completed board');
|
||||
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||
return key;
|
||||
});
|
||||
const entries = array(value, 'Completed boards', PERSISTENCE_LIMITS.completedBoards).map(
|
||||
(value) => {
|
||||
const key = string(value, 'Completed board');
|
||||
if (!/^(?:floor:B|skirting:S)[1-9]\d*$/.test(key))
|
||||
throw new Error('Completed boards must use floor:B1 or skirting:S1 identifiers.');
|
||||
return key;
|
||||
},
|
||||
);
|
||||
return uniqueValues(entries, 'Completed boards');
|
||||
}
|
||||
|
||||
function collapsedSections(value: unknown): PlannerSection[] {
|
||||
return uniqueValues(
|
||||
array(value === undefined ? [] : value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
array(value, 'Collapsed sections', SECTIONS.length).map((value) =>
|
||||
choice(value, SECTIONS, 'Collapsed section'),
|
||||
),
|
||||
'Collapsed sections',
|
||||
|
|
@ -126,7 +109,10 @@ function wall(value: unknown): Wall {
|
|||
id: identifier(data.id, 'Wall identifier'),
|
||||
length: number(data.length, 'Wall length'),
|
||||
turn: number(data.turn, 'Wall turn'),
|
||||
doors: uniqueIds(array(data.doors, 'Wall doors', URL_LIMITS.doors).map(door), 'Wall doors'),
|
||||
doors: uniqueIds(
|
||||
array(data.doors, 'Wall doors', PROJECT_LIMITS.doorsPerWall).map(door),
|
||||
'Wall doors',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -134,8 +120,11 @@ function room(value: unknown): Room {
|
|||
const data = object(value, 'Room');
|
||||
return {
|
||||
id: identifier(data.id, 'Room identifier'),
|
||||
name: string(data.name, 'Room name', URL_LIMITS.nameLength),
|
||||
walls: uniqueIds(array(data.walls, 'Room walls', URL_LIMITS.walls).map(wall), 'Room walls'),
|
||||
name: string(data.name, 'Room name', PROJECT_LIMITS.nameLength),
|
||||
walls: uniqueIds(
|
||||
array(data.walls, 'Room walls', PROJECT_LIMITS.wallsPerRoom).map(wall),
|
||||
'Room walls',
|
||||
),
|
||||
direction: choice(data.direction, [0, 90], 'Flooring direction'),
|
||||
};
|
||||
}
|
||||
|
|
@ -147,11 +136,7 @@ function flooring(value: unknown): FlooringSettings {
|
|||
width: number(data.width, 'Board width'),
|
||||
gap: number(data.gap, 'Expansion gap'),
|
||||
pattern: choice(data.pattern, ['brick', 'random'], 'Flooring pattern'),
|
||||
optimisation: choice(
|
||||
data.optimisation === undefined ? 'cuts' : data.optimisation,
|
||||
['cuts', 'random'] as const,
|
||||
'Flooring optimisation',
|
||||
),
|
||||
optimisation: choice(data.optimisation, ['cuts', 'random'] as const, 'Flooring optimisation'),
|
||||
minOffset: number(data.minOffset, 'Minimum offset'),
|
||||
maxOffset: number(data.maxOffset, 'Maximum offset'),
|
||||
minLength: number(data.minLength, 'Minimum board length'),
|
||||
|
|
@ -160,28 +145,13 @@ function flooring(value: unknown): FlooringSettings {
|
|||
};
|
||||
}
|
||||
|
||||
function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculations: boolean } {
|
||||
function skirting(value: unknown): SkirtingSettings {
|
||||
const data = object(value, 'Skirting settings');
|
||||
// Earlier links allowed several purchased lengths. The first remains the chosen size.
|
||||
const legacyLengths =
|
||||
data.stockLength === undefined
|
||||
? array(data.stockLengths, 'Skirting stock lengths', URL_LIMITS.legacyStockLengths).map(
|
||||
(value) => number(value, 'Skirting stock length'),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
settings: {
|
||||
stockLength:
|
||||
data.stockLength === undefined
|
||||
? (legacyLengths[0] ?? 0)
|
||||
: number(data.stockLength, 'Skirting board length'),
|
||||
thickness: number(data.thickness, 'Skirting thickness'),
|
||||
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||
},
|
||||
// This format predates coping blanks and rounded stock allocation for both
|
||||
// materials. Even a single stock size can now assign different cuts to an ID.
|
||||
legacyCalculations: data.stockLength === undefined,
|
||||
stockLength: number(data.stockLength, 'Skirting board length'),
|
||||
thickness: number(data.thickness, 'Skirting thickness'),
|
||||
minLength: number(data.minLength, 'Minimum skirting length'),
|
||||
kerf: number(data.kerf, 'Skirting saw kerf'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -189,42 +159,35 @@ function skirting(value: unknown): { settings: SkirtingSettings; legacyCalculati
|
|||
function parseState(value: unknown): UrlState {
|
||||
const data = object(value, 'Plan');
|
||||
const project = object(data.project, 'Project');
|
||||
if (project.version !== 1) {
|
||||
if (project.version !== 2) {
|
||||
throw new Error(
|
||||
'This project version is unsupported. Open it in the app version that created it.',
|
||||
);
|
||||
}
|
||||
const rooms = uniqueIds(
|
||||
array(project.rooms, 'Project rooms', URL_LIMITS.rooms).map(room),
|
||||
array(project.rooms, 'Project rooms', PROJECT_LIMITS.rooms).map(room),
|
||||
'Project rooms',
|
||||
);
|
||||
if (rooms.length === 0) throw new Error('A project needs at least one room.');
|
||||
const view = object(data.view, 'View settings');
|
||||
const roomId = string(view.roomId, 'Selected room');
|
||||
const skirt = skirting(project.skirting);
|
||||
const completed = completedBoards(project.completedBoards);
|
||||
const previousOptimiser =
|
||||
object(project.flooring, 'Flooring settings').optimisation === undefined;
|
||||
return {
|
||||
project: {
|
||||
version: 1,
|
||||
name: string(project.name, 'Project name', URL_LIMITS.nameLength),
|
||||
version: 2,
|
||||
name: string(project.name, 'Project name', PROJECT_LIMITS.nameLength),
|
||||
rooms,
|
||||
flooring: flooring(project.flooring),
|
||||
skirting: skirt.settings,
|
||||
skirting: skirting(project.skirting),
|
||||
tolerance: number(project.tolerance, 'Measurement tolerance'),
|
||||
completedBoards: skirt.legacyCalculations || previousOptimiser ? [] : completed,
|
||||
completedBoards: completedBoards(project.completedBoards),
|
||||
},
|
||||
view: {
|
||||
roomId: rooms.some((room) => room.id === roomId) ? roomId : rooms[0].id,
|
||||
selectedPiece:
|
||||
view.selectedPiece === null ? null : string(view.selectedPiece, 'Selected piece'),
|
||||
search: string(
|
||||
view.search === undefined ? '' : view.search,
|
||||
'Cut search',
|
||||
URL_LIMITS.searchLength,
|
||||
),
|
||||
search: string(view.search, 'Cut search', PERSISTENCE_LIMITS.searchLength),
|
||||
collapsedSections: collapsedSections(view.collapsedSections),
|
||||
material: choice(view.material, ['flooring', 'skirting'], 'Plan view'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -262,17 +225,18 @@ export function encodeState(state: UrlState): string {
|
|||
});
|
||||
for (const board of project.completedBoards) put('done', board);
|
||||
put('selectedRoom', view.roomId);
|
||||
put('view', view.material);
|
||||
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
|
||||
if (view.search) put('search', view.search);
|
||||
for (const section of view.collapsedSections) put('collapsed', section);
|
||||
const query = `?${params}`.replaceAll('%3A', ':');
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
if (query.length > PERSISTENCE_LIMITS.queryLength)
|
||||
throw new Error('This plan link is too large. Split it into smaller projects.');
|
||||
return query;
|
||||
}
|
||||
|
||||
function parseQuery(query: string): UrlState {
|
||||
if (query.length > URL_LIMITS.queryLength)
|
||||
if (query.length > PERSISTENCE_LIMITS.queryLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
// URLSearchParams otherwise replaces damaged percent escapes silently.
|
||||
decodeURIComponent(query);
|
||||
|
|
@ -315,7 +279,7 @@ function parseQuery(query: string): UrlState {
|
|||
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
|
||||
return ordered;
|
||||
};
|
||||
const roomIndices = indices('room', URL_LIMITS.rooms);
|
||||
const roomIndices = indices('room', PROJECT_LIMITS.rooms);
|
||||
const rooms = roomIndices.length
|
||||
? roomIndices.map((roomIndex) => {
|
||||
const prefix = `room.${roomIndex}`;
|
||||
|
|
@ -324,14 +288,14 @@ function parseQuery(query: string): UrlState {
|
|||
id,
|
||||
name: text(`${prefix}.name`, `Room ${roomIndex}`),
|
||||
direction: numeric(`${prefix}.direction`, 0),
|
||||
walls: indices(`${prefix}.wall`, URL_LIMITS.walls).map((wallIndex) => {
|
||||
walls: indices(`${prefix}.wall`, PROJECT_LIMITS.wallsPerRoom).map((wallIndex) => {
|
||||
const wallPrefix = `${prefix}.wall.${wallIndex}`;
|
||||
const wallId = text(`${wallPrefix}.id`, `${id}-w${wallIndex}`);
|
||||
return {
|
||||
id: wallId,
|
||||
length: numeric(`${wallPrefix}.length`),
|
||||
turn: numeric(`${wallPrefix}.turn`, 90),
|
||||
doors: indices(`${wallPrefix}.door`, URL_LIMITS.doors).map((doorIndex) => {
|
||||
doors: indices(`${wallPrefix}.door`, PROJECT_LIMITS.doorsPerWall).map((doorIndex) => {
|
||||
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
|
||||
return {
|
||||
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
|
||||
|
|
@ -344,24 +308,12 @@ function parseQuery(query: string): UrlState {
|
|||
};
|
||||
})
|
||||
: defaults.project.rooms;
|
||||
const stockValues = params.getAll('skirting.stock');
|
||||
if (stockValues.length > URL_LIMITS.legacyStockLengths)
|
||||
throw new Error(
|
||||
`Skirting stock lengths supports at most ${URL_LIMITS.legacyStockLengths} entries.`,
|
||||
);
|
||||
const stock = params.has('skirting.length')
|
||||
? { stockLength: numeric('skirting.length') }
|
||||
: {
|
||||
stockLengths: !stockValues.length
|
||||
? [defaults.project.skirting.stockLength]
|
||||
: stockValues.length === 1 && stockValues[0] === ''
|
||||
? []
|
||||
: stockValues.map((value) => measurement(value, 'skirting.stock')),
|
||||
};
|
||||
if (params.has('skirting.stock'))
|
||||
throw new Error('This link uses the retired skirting stock-list format.');
|
||||
const floor = defaults.project.flooring;
|
||||
return parseState({
|
||||
project: {
|
||||
version: numeric('version', 1),
|
||||
version: numeric('version', defaults.project.version),
|
||||
name: text('name', defaults.project.name),
|
||||
tolerance: numeric('tolerance', defaults.project.tolerance),
|
||||
completedBoards: params.getAll('done'),
|
||||
|
|
@ -369,15 +321,13 @@ function parseQuery(query: string): UrlState {
|
|||
flooring: Object.fromEntries(
|
||||
Object.entries(floor).map(([key, value]) => [
|
||||
key,
|
||||
key === 'optimisation' && !params.has('flooring.optimisation')
|
||||
? undefined
|
||||
: typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
typeof value === 'number'
|
||||
? numeric(`flooring.${key}`, value)
|
||||
: text(`flooring.${key}`, value),
|
||||
]),
|
||||
),
|
||||
skirting: {
|
||||
...stock,
|
||||
stockLength: numeric('skirting.length', defaults.project.skirting.stockLength),
|
||||
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
|
||||
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
|
||||
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
|
||||
|
|
@ -388,42 +338,17 @@ function parseQuery(query: string): UrlState {
|
|||
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
|
||||
search: text('search', ''),
|
||||
collapsedSections: params.getAll('collapsed'),
|
||||
material: text('view', 'flooring'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Read existing shared links so the hook can replace them with the readable query format. */
|
||||
function parseLegacy(hash: string): UrlState {
|
||||
if (hash.length > URL_LIMITS.hashLength)
|
||||
throw new Error('The link is too large. Use a smaller project link.');
|
||||
if (!hash.startsWith('#plan='))
|
||||
throw new Error('Use a complete plan query or an existing #plan= link.');
|
||||
const compressed = decodeURIComponent(hash.slice(6));
|
||||
if (!compressed || !/^[A-Za-z0-9+$-]+$/.test(compressed))
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
const json = LZString.decompressFromEncodedURIComponent(compressed);
|
||||
if (!json)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
if (json.length > URL_LIMITS.jsonLength)
|
||||
throw new Error('The project in this link is too large. Use a smaller project link.');
|
||||
if (LZString.compressToEncodedURIComponent(json) !== compressed)
|
||||
throw new Error('The link is damaged or incomplete. Copy the complete plan link again.');
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(json);
|
||||
} catch {
|
||||
throw new Error('The project data is damaged. Copy the complete plan link again.');
|
||||
}
|
||||
return parseState(value);
|
||||
}
|
||||
|
||||
/** Accept a readable query or a legacy fragment; never throw for an untrusted link. */
|
||||
/** Accept a readable query and never throw for an untrusted link. */
|
||||
export function decodeState(link: string): { state: UrlState; error: string | null } {
|
||||
if (link === '' || link === '#' || link === '?') return { state: defaultState(), error: null };
|
||||
try {
|
||||
if (!link.startsWith('?') && !link.startsWith('#'))
|
||||
throw new Error('Use the complete plan query, starting with ?.');
|
||||
return { state: link.startsWith('?') ? parseQuery(link) : parseLegacy(link), error: null };
|
||||
if (!link.startsWith('?')) throw new Error('Use the complete plan query, starting with ?.');
|
||||
return { state: parseQuery(link), error: null };
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof URIError
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ 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);
|
||||
const link = window.location.search === '?planner' ? '' : window.location.search;
|
||||
if (link && link !== '?') return decodeState(link);
|
||||
try {
|
||||
const saved = decodeState(window.localStorage.getItem(PROJECT_STORAGE_KEY) ?? '');
|
||||
if (!saved.error) return saved;
|
||||
|
|
@ -36,8 +35,7 @@ export function useProject() {
|
|||
const next = readLocation();
|
||||
if (!next.error) {
|
||||
try {
|
||||
// Canonicalize editable queries and migrate existing fragment links
|
||||
// without creating an extra entry in browser history.
|
||||
// Canonicalize editable queries without creating an extra history entry.
|
||||
const query = encodeState(next.state);
|
||||
if (query !== window.location.search || window.location.hash)
|
||||
window.history.replaceState(null, '', queryUrl(query));
|
||||
|
|
@ -48,11 +46,9 @@ export function useProject() {
|
|||
}
|
||||
setLoaded(next);
|
||||
};
|
||||
window.addEventListener('hashchange', restore);
|
||||
window.addEventListener('popstate', restore);
|
||||
restore();
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', restore);
|
||||
window.removeEventListener('popstate', restore);
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { nextId } from './defaults';
|
||||
import { PROJECT_LIMITS } from './limits';
|
||||
import type { Room } from './types';
|
||||
|
||||
/** Inserting a segment never renumbers walls or moves their attached doors. */
|
||||
export function insertWallAfter(room: Room, wallId: string): Room {
|
||||
const index = room.walls.findIndex((wall) => wall.id === wallId);
|
||||
if (index < 0 || room.walls.length >= 100) return room;
|
||||
if (index < 0 || room.walls.length >= PROJECT_LIMITS.wallsPerRoom) return room;
|
||||
const wall = {
|
||||
id: nextId(
|
||||
`${room.id}-w`,
|
||||
|
|
|
|||
300
src/styles.css
300
src/styles.css
|
|
@ -398,7 +398,7 @@ summary span {
|
|||
.wall-head,
|
||||
.wall-row {
|
||||
display: grid;
|
||||
grid-template-columns: 25px minmax(0, 1fr) minmax(0, 0.8fr) 40px;
|
||||
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -408,9 +408,36 @@ summary span {
|
|||
}
|
||||
.wall-row {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
}
|
||||
.wall-head > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.wall-actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.wall-actions .icon-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wall-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.wall-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.wall-row.drag-over {
|
||||
border-top: 3px solid var(--blue);
|
||||
background: #e9f0fc;
|
||||
}
|
||||
.wall-row input {
|
||||
padding: 8px 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wall-code {
|
||||
font: 12px var(--mono);
|
||||
|
|
@ -626,16 +653,6 @@ summary span {
|
|||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.pending {
|
||||
color: #a13e00;
|
||||
}
|
||||
.drawing-legend > span {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
|
|
@ -770,30 +787,25 @@ summary span {
|
|||
margin-top: 2px;
|
||||
}
|
||||
#cut-plan {
|
||||
margin-top: 35px;
|
||||
margin: 0;
|
||||
scroll-margin-top: 60px;
|
||||
}
|
||||
.cut-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
border-top: 3px solid var(--ink);
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.cut-list-header h2 {
|
||||
font-size: 27px;
|
||||
}
|
||||
.cut-list-header .button {
|
||||
border-color: var(--ink);
|
||||
display: none;
|
||||
}
|
||||
.cut-list-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0 28px;
|
||||
}
|
||||
.cut-list-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.material-cuts {
|
||||
min-width: 0;
|
||||
|
|
@ -833,17 +845,6 @@ summary span {
|
|||
padding: 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
.app-header {
|
||||
padding-left: max(32px, calc((100vw - 1656px) / 2));
|
||||
|
|
@ -861,13 +862,6 @@ summary span {
|
|||
align-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
.cut-list-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 22px;
|
||||
}
|
||||
.cut-list-header {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 699px) {
|
||||
.app-header {
|
||||
|
|
@ -957,12 +951,6 @@ summary span {
|
|||
.wall-head {
|
||||
font-size: 11px;
|
||||
}
|
||||
.wall-row {
|
||||
grid-template-columns: 29px minmax(0, 1fr) minmax(0, 0.8fr) 44px;
|
||||
}
|
||||
.wall-row .icon-button {
|
||||
width: 44px;
|
||||
}
|
||||
.text-button,
|
||||
summary {
|
||||
font-size: 13px;
|
||||
|
|
@ -980,9 +968,6 @@ summary span {
|
|||
.tooltip-content {
|
||||
right: 0;
|
||||
}
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
}
|
||||
.purchase-summary h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
|
@ -1001,26 +986,6 @@ summary span {
|
|||
.waste-stat {
|
||||
font-size: 14px;
|
||||
}
|
||||
#cut-plan {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.cut-list-header {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.cut-list-header h2 {
|
||||
font-size: 25px;
|
||||
}
|
||||
.cut-list-header .helper {
|
||||
font-size: 11px;
|
||||
}
|
||||
.cut-list-header .button {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cut-list-header .button svg {
|
||||
display: none;
|
||||
}
|
||||
.material-cuts > h3 {
|
||||
font-size: 17px;
|
||||
padding: 14px;
|
||||
|
|
@ -1049,10 +1014,14 @@ summary span {
|
|||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
width: 76px;
|
||||
min-width: 76px;
|
||||
padding: 6px;
|
||||
background: #f0f5ff;
|
||||
border-color: #1647ab;
|
||||
color: #1647ab;
|
||||
}
|
||||
.cut-thumbnail {
|
||||
display: block;
|
||||
|
|
@ -1071,7 +1040,9 @@ summary span {
|
|||
height: 100%;
|
||||
}
|
||||
.cut-preview-code {
|
||||
font: 700 12px var(--mono);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.skirting-end-grid {
|
||||
display: grid;
|
||||
|
|
@ -1135,16 +1106,19 @@ summary span {
|
|||
}
|
||||
.cut-dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
padding: 16px 20px 22px;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.exact-preview,
|
||||
.skirting-full-preview {
|
||||
margin: 0;
|
||||
}
|
||||
.exact-preview figcaption {
|
||||
font: 12px var(--mono);
|
||||
font:
|
||||
13px Arial,
|
||||
sans-serif;
|
||||
color: #4b5c73;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
|
@ -1242,20 +1216,11 @@ summary span {
|
|||
.cut-location {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.cut-preview-button {
|
||||
flex-shrink: 0;
|
||||
background: #f0f5ff;
|
||||
border-color: #1647ab;
|
||||
color: #1647ab;
|
||||
}
|
||||
.skirting-preview-button {
|
||||
background: #fff5eb;
|
||||
border-color: #b74900;
|
||||
color: #983e03;
|
||||
}
|
||||
.cut-preview-code {
|
||||
font-family: inherit;
|
||||
}
|
||||
.cut-preview-code > span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
|
@ -1299,20 +1264,10 @@ summary span {
|
|||
sans-serif;
|
||||
fill: #17263f;
|
||||
}
|
||||
.cut-dialog-body {
|
||||
font-size: 14px;
|
||||
gap: 14px;
|
||||
}
|
||||
.cut-origin {
|
||||
font-size: 14px;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.exact-preview figcaption {
|
||||
font:
|
||||
13px Arial,
|
||||
sans-serif;
|
||||
color: #4b5c73;
|
||||
}
|
||||
.cut-dialog-body .helper {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
@ -1463,6 +1418,86 @@ summary span {
|
|||
.planning-panels {
|
||||
min-width: 0;
|
||||
}
|
||||
.setup-intro {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.setup-intro h2 {
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.setup-intro p {
|
||||
color: #4b5c73;
|
||||
font-size: 14px;
|
||||
max-width: 48ch;
|
||||
}
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 50%;
|
||||
font: 13px var(--mono);
|
||||
}
|
||||
.panel-description {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.shape-heading {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.shape-heading + .template-options {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.shape-help {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
background: #eef2f9;
|
||||
border-left: 3px solid var(--blue);
|
||||
}
|
||||
.material-help {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.advanced-settings {
|
||||
border-block: 1px solid var(--line);
|
||||
margin: 16px 0;
|
||||
}
|
||||
.advanced-settings[open] {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.plan-view-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
.plan-view-toolbar > span {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.plan-view-switch {
|
||||
flex: 1;
|
||||
}
|
||||
.plan-view-switch button {
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
}
|
||||
.plan-view-switch button.active .material-key {
|
||||
border: 1px solid white;
|
||||
}
|
||||
.plan-view-switch button.active:has(.skirt-key) {
|
||||
background: var(--orange);
|
||||
}
|
||||
.planner-panel {
|
||||
border-top: 2px solid var(--ink);
|
||||
margin-bottom: 20px;
|
||||
|
|
@ -1482,6 +1517,7 @@ summary span {
|
|||
font-weight: 700;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
.panel-toggle > span:last-child {
|
||||
font-size: 25px;
|
||||
|
|
@ -1500,25 +1536,6 @@ summary span {
|
|||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.planner-panel .cut-list-card {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.planner-panel .cut-list-header {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
.planner-panel .cut-list-header h2 {
|
||||
display: none;
|
||||
}
|
||||
.planner-panel .cut-list-header .helper {
|
||||
font-size: 12px;
|
||||
}
|
||||
.planner-panel #cut-plan {
|
||||
margin: 0;
|
||||
}
|
||||
.planner-panel .purchase-summary {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
|
@ -1530,10 +1547,10 @@ summary span {
|
|||
background: #fffdf7;
|
||||
}
|
||||
.drawing-column .drawing-viewport {
|
||||
height: clamp(180px, calc(100dvh - 260px), 780px);
|
||||
height: clamp(180px, calc(100dvh - 316px), 780px);
|
||||
}
|
||||
.drawing-column:has(.plan-search-status) .drawing-viewport {
|
||||
height: clamp(160px, calc(100dvh - 335px), 720px);
|
||||
height: clamp(160px, calc(100dvh - 391px), 720px);
|
||||
}
|
||||
.drawing-column .room-svg {
|
||||
width: 100%;
|
||||
|
|
@ -1610,6 +1627,17 @@ summary span {
|
|||
color: #345c55;
|
||||
}
|
||||
@media (max-width: 899px) {
|
||||
.plan-view-toolbar {
|
||||
padding-block: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
.plan-view-switch button {
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
}
|
||||
.setup-intro {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.drawing-header {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
|
|
@ -1654,9 +1682,10 @@ summary span {
|
|||
.panel-toggle {
|
||||
font-size: 18px;
|
||||
min-height: 50px;
|
||||
scroll-margin-top: 390px;
|
||||
}
|
||||
.planner-panel {
|
||||
scroll-margin-top: 330px;
|
||||
scroll-margin-top: 390px;
|
||||
}
|
||||
.board-cuts {
|
||||
padding-inline: 12px;
|
||||
|
|
@ -1668,7 +1697,7 @@ summary span {
|
|||
select,
|
||||
button,
|
||||
summary {
|
||||
scroll-margin-top: 350px;
|
||||
scroll-margin-top: 410px;
|
||||
scroll-margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1677,41 +1706,6 @@ summary span {
|
|||
position: static;
|
||||
}
|
||||
}
|
||||
.wall-head,
|
||||
.wall-row {
|
||||
grid-template-columns: 32px minmax(0, 1fr) minmax(0, 0.8fr);
|
||||
}
|
||||
.wall-head > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.wall-row {
|
||||
padding-bottom: 7px;
|
||||
border-bottom: 1px solid #dde2e9;
|
||||
}
|
||||
.wall-actions {
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.wall-actions .icon-button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
.wall-drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.wall-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.wall-row.drag-over {
|
||||
border-top: 3px solid var(--blue);
|
||||
background: #e9f0fc;
|
||||
}
|
||||
.wall-row input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cut-search {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue