better
This commit is contained in:
parent
b9485e4097
commit
0c0f08b350
45 changed files with 1565 additions and 1579 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue