This commit is contained in:
Andras Schmelczer 2026-09-11 16:58:56 +01:00
commit 9689bfcc18
23 changed files with 1815 additions and 955 deletions

View file

@ -54,25 +54,18 @@ export function readyOffcutInstructions(piece: FlooringPiece): string {
return `Use offcut; no new cut. Required factory short ends: ${ends}.`;
}
/** Batch identical cuts without losing any placement codes or changing factory-edge orientation. */
export function groupFlooringCuts(plan: FlooringPlan): FlooringPiece[][] {
const groups = new Map<string, FlooringPiece[]>();
const precise = (value: number) => Math.round(value * 1000);
for (const piece of flooringCuts(plan)) {
const key = JSON.stringify([
piece.roomId,
precise(piece.length),
precise(piece.width),
piece.shaped,
piece.cut,
piece.factoryLeft,
piece.factoryRight,
piece.ripEdge,
piece.shaped ? piece.template.map((point) => [precise(point.x), precise(point.y)]) : null,
]);
const group = groups.get(key);
/** Keep every cut from a purchased board together, in allocation order across rooms. */
function groupByStock<T extends { stockId: string }>(pieces: T[], stocks: { id: string }[]): T[][] {
const groups = new Map<string, T[]>(stocks.map((stock) => [stock.id, []]));
for (const piece of pieces) {
const group = groups.get(piece.stockId);
if (group) group.push(piece);
else groups.set(key, [piece]);
else groups.set(piece.stockId, [piece]);
}
return [...groups.values()];
return [...groups.values()].filter((group) => group.length > 0);
}
export const groupFlooringCuts = (plan: FlooringPlan) =>
groupByStock(flooringCuts(plan), plan.stocks);
export const groupSkirtingCuts = (plan: SkirtingPlan) =>
groupByStock(skirtingCuts(plan), plan.stocks);

View file

@ -10,6 +10,142 @@ export const exactMm = (value: number) =>
new Intl.NumberFormat('en-GB', { maximumFractionDigits: 3 }).format(value);
export const viewBox = (box: PreviewBox) => `${box.x} ${box.y} ${box.width} ${box.height}`;
export interface EdgeDimension {
edge: number;
length: number;
text: string;
/** Acute angle to the horizontal board edge; the diagram supplies its slope direction. */
angle?: number;
start: Point;
end: Point;
lineStart: Point;
lineEnd: Point;
normal: Point;
label: Point;
labelBox: PreviewBox;
}
const moved = (point: Point, direction: Point, distance: number): Point => ({
x: point.x + direction.x * distance,
y: point.y + direction.y * distance,
});
const overlaps = (a: PreviewBox, b: PreviewBox, gap: number) =>
a.x < b.x + b.width + gap &&
a.x + a.width + gap > b.x &&
a.y < b.y + b.height + gap &&
a.y + a.height + gap > b.y;
/** Dimensions stay outside the outline. Short adjacent edges share a lane with staggered text. */
export function dimensionedOutline(points: Point[], edges?: number[]) {
if (points.length < 3)
return { dimensions: [] as EdgeDimension[], font: 1, box: fullPreviewBox(1, 1) };
const span = Math.max(
1,
Math.max(...points.map((point) => point.x)) - Math.min(...points.map((point) => point.x)),
Math.max(...points.map((point) => point.y)) - Math.min(...points.map((point) => point.y)),
);
const font = span * 0.065;
const orientation =
Math.sign(
points.reduce((sum, point, i) => {
const next = points[(i + 1) % points.length];
return sum + point.x * next.y - next.x * point.y;
}, 0),
) || 1;
const dimensions: EdgeDimension[] = [];
const selected = (edges ?? points.map((_, i) => i))
.map((edge) => ({
edge,
start: points[edge],
end: points[(edge + 1) % points.length],
}))
.map((edge) => ({
...edge,
length: Math.hypot(edge.end.x - edge.start.x, edge.end.y - edge.start.y),
}))
.filter((edge) => edge.length > 0.000001)
.sort((a, b) => b.length - a.length || a.edge - b.edge);
for (const edge of selected) {
const direction = {
x: (edge.end.x - edge.start.x) / edge.length,
y: (edge.end.y - edge.start.y) / edge.length,
};
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 text = exactMm(edge.length);
const angle =
Math.abs(direction.x) > 0.000001 && Math.abs(direction.y) > 0.000001
? (Math.atan2(Math.abs(direction.y), Math.abs(direction.x)) * 180) / Math.PI
: undefined;
const angleText = angle === undefined ? '' : `${exactMm(angle)}°`;
const width = Math.max(font * 1.2, Math.max(text.length, angleText.length) * font * 0.64),
height = font * (angle === undefined ? 1.3 : 2.5);
const outside = Math.max(
0,
...points.map((point) => (point.x - center.x) * normal.x + (point.y - center.y) * normal.y),
);
let offset = outside + font * 0.65;
let label = center,
labelBox: PreviewBox = { x: 0, y: 0, width, height };
let placed = false;
for (let lane = 0; lane < 30 && !placed; lane++) {
const base = moved(
center,
normal,
offset + (Math.abs(normal.x) * width + Math.abs(normal.y) * height) / 2 + font * 0.18,
);
for (let step = 0; step < 25; step++) {
const shift = step === 0 ? 0 : Math.ceil(step / 2) * (step % 2 ? -1 : 1) * font * 1.55;
label = moved(base, direction, shift);
labelBox = { x: label.x - width / 2, y: label.y - height / 2, width, height };
if (!dimensions.some((dimension) => overlaps(labelBox, dimension.labelBox, font * 0.25))) {
placed = true;
break;
}
}
if (!placed) offset += font * 1.8;
}
dimensions.push({
...edge,
text,
angle,
normal,
lineStart: moved(edge.start, normal, offset),
lineEnd: moved(edge.end, normal, offset),
label,
labelBox,
});
}
const xs = points.map((point) => point.x),
ys = points.map((point) => point.y);
for (const dimension of dimensions) {
xs.push(
dimension.lineStart.x,
dimension.lineEnd.x,
dimension.labelBox.x,
dimension.labelBox.x + dimension.labelBox.width,
);
ys.push(
dimension.lineStart.y,
dimension.lineEnd.y,
dimension.labelBox.y,
dimension.labelBox.y + dimension.labelBox.height,
);
}
const padding = font * 0.4;
return {
dimensions: dimensions.sort((a, b) => a.edge - b.edge),
font,
box: {
x: Math.min(...xs) - padding,
y: Math.min(...ys) - padding,
width: Math.max(...xs) - Math.min(...xs) + padding * 2,
height: Math.max(...ys) - Math.min(...ys) + padding * 2,
},
};
}
export function flooringTemplate(piece: FlooringPiece): Point[] {
if (piece.template.length) return piece.template;
return piece.shaped

View file

@ -1,8 +1,8 @@
import type { FlooringPlan, Project, SkirtingPlan } from './types';
import { flooringInstructions } from './cut-instructions';
import {
flooringCuts,
skirtingCuts,
groupFlooringCuts,
groupSkirtingCuts,
uncutFlooring,
uncutSkirting,
readyOffcutInstructions,
@ -19,10 +19,8 @@ export function cutListCsv(
skirting: SkirtingPlan,
): string {
const roomName = (id: string) => project.rooms.find((room) => room.id === id)?.name ?? id;
const floorCuts = flooringCuts(flooring),
skirtCuts = skirtingCuts(skirting);
const floorIds = new Set(floorCuts.map((piece) => piece.id));
const skirtIds = new Set(skirtCuts.map((piece) => piece.id));
const floorCuts = groupFlooringCuts(flooring).flat(),
skirtCuts = groupSkirtingCuts(skirting).flat();
const wholeFloor = uncutFlooring(flooring, project.flooring);
const rows: (string | number)[][] = [
[
@ -138,40 +136,6 @@ export function cutListCsv(
'',
]);
}
for (const stock of flooring.stocks) {
const pieces = stock.pieceIds.filter((id) => floorIds.has(id));
if (pieces.length)
rows.push([
'Flooring stock',
'',
pieces.join(' / '),
stock.id,
project.flooring.length,
project.flooring.width,
'',
'',
1,
`Remaining length ${stock.remaining.toFixed(1)} mm`,
'',
]);
}
for (const stock of skirting.stocks) {
const pieces = stock.pieceIds.filter((id) => skirtIds.has(id));
if (pieces.length)
rows.push([
'Skirting stock',
'',
pieces.join(' / '),
stock.id,
stock.length,
'',
'',
'',
1,
`Waste including kerf ${stock.waste.toFixed(1)} mm`,
'',
]);
}
return '\uFEFF' + rows.map((row) => row.map(cell).join(',')).join('\r\n');
}

View file

@ -31,6 +31,7 @@ interface Layout {
stocks: AvailableStock[];
cuts: number;
reusedCount: number;
patternPenalty: number;
}
interface CutPiece {
polygon: Point[];
@ -250,6 +251,7 @@ function appendRow(
rows: [...base.rows],
cuts: base.cuts,
reusedCount: base.reusedCount,
patternPenalty: base.patternPenalty + randomPatternPenalty(base.rows, room.id, phase, s),
stocks: base.stocks.map((stock) => ({ ...stock, pieceIds: [...stock.pieceIds] })),
};
const pieceIds: string[] = [];
@ -315,6 +317,45 @@ function staggerAllowed(a: number, b: number, s: FlooringSettings): boolean {
return nearest >= s.minOffset - EPSILON && nearest <= s.maxOffset + EPSILON;
}
/** Signed nearest-joint movement handles a step crossing the board-length boundary. */
function jointStep(from: number, to: number, length: number): number {
return phaseOf(to - from + length / 2, length) - length / 2;
}
/** Aesthetic preferences are penalties, never reasons to reject a physically valid row. */
function randomPatternPenalty(
rows: FlooringRow[],
roomId: string,
phase: number,
s: FlooringSettings,
): number {
if (s.pattern !== 'random') return 0;
const recent = rows.filter((row) => row.roomId === roomId).slice(-4);
if (recent.length < 2) return 0;
const previous = recent.at(-1)!.offset;
const twoBack = recent.at(-2)!.offset;
const step = jointStep(previous, phase, s.length);
const previousStep = jointStep(twoBack, previous, s.length);
// A few millimetres of kerf drift still looks like the same joint line or step.
const similar = Math.max(s.kerf * 2, s.length * 0.03);
// Half-board steps have equally near joints in both directions and tend to look brick-like.
let penalty = close(Math.abs(step), s.length / 2) ? 1 : 0;
for (let distance = 2; distance <= recent.length; distance++) {
if (Math.abs(jointStep(recent.at(-distance)!.offset, phase, s.length)) <= similar)
penalty += 5 - distance;
}
if (step * previousStep > 0) {
penalty += 1;
if (Math.abs(step - previousStep) <= similar) penalty += 3;
if (
recent.length >= 3 &&
previousStep * jointStep(recent.at(-3)!.offset, twoBack, s.length) > 0
)
penalty += 2;
}
return penalty;
}
/** Small fixed hash, never time or Math.random: edits and shared URLs reproduce the same result. */
function fraction(seed: string): number {
let hash = 2166136261;
@ -338,13 +379,9 @@ function candidatePhases(
if (s.pattern === 'brick' && firstPhase !== undefined)
return [phaseOf(firstPhase + ((row % 2) * s.length) / 2, s.length)];
const candidates: number[] = [];
const twoRowsBack = layout.rows.filter((r) => r.roomId === room.id).at(-2)?.offset;
const avoidRepeatedPair =
s.pattern === 'random' && s.minOffset < s.length / 2 - EPSILON && s.maxOffset > EPSILON;
const add = (value: number) => {
const phase = phaseOf(value, s.length);
if (previousPhase !== undefined && !staggerAllowed(phase, previousPhase, s)) return;
if (avoidRepeatedPair && twoRowsBack !== undefined && close(phase, twoRowsBack)) return;
if (!candidates.some((p) => close(p, phase))) candidates.push(phase);
};
// Reusing a suitable end is usually the most effective way to save a board and a crosscut.
@ -386,6 +423,7 @@ function candidatePhases(
function compareLayouts(a: Layout, b: Layout): number {
return (
a.stocks.length - b.stocks.length ||
a.patternPenalty - b.patternPenalty ||
a.cuts - b.cuts ||
b.reusedCount - a.reusedCount ||
compareText(
@ -418,13 +456,22 @@ function validateSettings(s: FlooringSettings): string | null {
}
/**
* Deterministic bounded beam search, ordered by boards consumed, then saw passes.
* 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.
* 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.
*/
export function planFlooring(rooms: Room[], s: FlooringSettings, tolerance: number): FlooringPlan {
let layout: Layout = { pieces: [], rows: [], stocks: [], cuts: 0, reusedCount: 0 };
let layout: Layout = {
pieces: [],
rows: [],
stocks: [],
cuts: 0,
reusedCount: 0,
patternPenalty: 0,
};
const issues: Issue[] = [];
const invalid = validateSettings(s);
if (invalid) issues.push({ severity: 'error', message: invalid });

View file

@ -4,6 +4,7 @@ import type { Door, FlooringSettings, Room, SkirtingSettings, UrlState, Wall } f
/** 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,
@ -159,46 +160,190 @@ function parseState(value: unknown): UrlState {
};
}
/** Stable URLs: canonical property order, no timestamps, random seeds or results. */
/** Explicit keys in a fixed order: no compressed payload or derived results. */
export function encodeState(state: UrlState): string {
const json = JSON.stringify(parseState(state));
if (json.length > URL_LIMITS.jsonLength)
throw new Error('This plan is too large for a link. Split it into smaller projects.');
const hash = `#plan=${LZString.compressToEncodedURIComponent(json)}`;
if (hash.length > URL_LIMITS.hashLength)
const { project, view } = parseState(state);
const params = new URLSearchParams();
const put = (key: string, value: string | number) => params.append(key, String(value));
put('version', project.version);
put('name', project.name);
put('tolerance', project.tolerance);
for (const [key, value] of Object.entries(project.flooring)) put(`flooring.${key}`, value);
// An empty value explicitly represents an empty stock list during editing.
if (!project.skirting.stockLengths.length) put('skirting.stock', '');
for (const length of project.skirting.stockLengths) put('skirting.stock', length);
put('skirting.thickness', project.skirting.thickness);
put('skirting.minLength', project.skirting.minLength);
put('skirting.kerf', project.skirting.kerf);
project.rooms.forEach((room, roomIndex) => {
const prefix = `room.${roomIndex + 1}`;
put(`${prefix}.id`, room.id);
put(`${prefix}.name`, room.name);
put(`${prefix}.direction`, room.direction);
room.walls.forEach((wall, wallIndex) => {
const wallPrefix = `${prefix}.wall.${wallIndex + 1}`;
put(`${wallPrefix}.id`, wall.id);
put(`${wallPrefix}.length`, wall.length);
put(`${wallPrefix}.turn`, wall.turn);
wall.doors.forEach((door, doorIndex) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex + 1}`;
put(`${doorPrefix}.id`, door.id);
put(`${doorPrefix}.offset`, door.offset);
put(`${doorPrefix}.width`, door.width);
});
});
});
put('selectedRoom', view.roomId);
if (view.selectedPiece !== null) put('selectedCut', view.selectedPiece);
const query = `?${params}`;
if (query.length > URL_LIMITS.queryLength)
throw new Error('This plan link is too large. Split it into smaller projects.');
return hash;
return query;
}
/** Never throw for an untrusted link. Return a usable starter plan and a visible error. */
export function decodeState(hash: string): { state: UrlState; error: string | null } {
if (hash === '' || hash === '#') return { state: defaultState(), error: null };
function parseQuery(query: string): UrlState {
if (query.length > URL_LIMITS.queryLength)
throw new Error('The link is too large. Use a smaller project link.');
// URLSearchParams otherwise replaces damaged percent escapes silently.
decodeURIComponent(query);
const params = new URLSearchParams(query);
const defaults = defaultState();
const roomFields = [...params.keys()].filter((key) =>
/^room\.[^.]+\.(?:id|name|direction|wall\.[^.]+\.(?:id|length|turn|door\.[^.]+\.(?:id|offset|width)))$/.test(
key,
),
);
const text = (key: string, fallback?: string): string => {
const values = params.getAll(key);
if (values.length > 1) throw new Error(`${key} must appear only once.`);
if (!values.length) {
if (fallback !== undefined) return fallback;
throw new Error(`The link is missing ${key}.`);
}
return values[0];
};
const measurement = (value: string, key: string) => {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))
throw new Error(`${key} must be a finite number.`);
return number(Number(value), key);
};
const numeric = (key: string, fallback?: number) =>
measurement(text(key, fallback === undefined ? undefined : String(fallback)), key);
const indices = (prefix: string, limit: number) => {
const found = new Set<number>();
for (const key of roomFields) {
if (!key.startsWith(`${prefix}.`)) continue;
const index = key.slice(prefix.length + 1).split('.')[0];
if (!/^[1-9]\d*$/.test(index))
throw new Error(`${prefix} indices must be positive whole numbers, starting at 1.`);
const value = Number(index);
if (value > limit) throw new Error(`${prefix} supports at most ${limit} entries.`);
found.add(value);
}
const ordered = [...found].sort((a, b) => a - b);
if (ordered.some((value, index) => value !== index + 1))
throw new Error(`${prefix} indices must be consecutive, starting at 1.`);
return ordered;
};
const roomIndices = indices('room', URL_LIMITS.rooms);
const rooms = roomIndices.length
? roomIndices.map((roomIndex) => {
const prefix = `room.${roomIndex}`;
const id = text(`${prefix}.id`, `room-${roomIndex}`);
return {
id,
name: text(`${prefix}.name`, `Room ${roomIndex}`),
direction: numeric(`${prefix}.direction`, 0),
walls: indices(`${prefix}.wall`, URL_LIMITS.walls).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) => {
const doorPrefix = `${wallPrefix}.door.${doorIndex}`;
return {
id: text(`${doorPrefix}.id`, `${wallId}-door-${doorIndex}`),
offset: numeric(`${doorPrefix}.offset`),
width: numeric(`${doorPrefix}.width`),
};
}),
};
}),
};
})
: defaults.project.rooms;
const stockValues = params.getAll('skirting.stock');
if (stockValues.length > URL_LIMITS.stockLengths)
throw new Error(`Skirting stock lengths supports at most ${URL_LIMITS.stockLengths} entries.`);
const stockLengths =
stockValues.length === 1 && stockValues[0] === ''
? []
: stockValues.length
? stockValues.map((value) => measurement(value, 'skirting.stock'))
: defaults.project.skirting.stockLengths;
const floor = defaults.project.flooring;
return parseState({
project: {
version: numeric('version', 1),
name: text('name', defaults.project.name),
tolerance: numeric('tolerance', defaults.project.tolerance),
rooms,
flooring: Object.fromEntries(
Object.entries(floor).map(([key, value]) => [
key,
typeof value === 'number'
? numeric(`flooring.${key}`, value)
: text(`flooring.${key}`, value),
]),
),
skirting: {
stockLengths,
thickness: numeric('skirting.thickness', defaults.project.skirting.thickness),
minLength: numeric('skirting.minLength', defaults.project.skirting.minLength),
kerf: numeric('skirting.kerf', defaults.project.skirting.kerf),
},
},
view: {
roomId: text('selectedRoom', rooms[0].id),
selectedPiece: params.has('selectedCut') ? text('selectedCut') : null,
},
});
}
/** 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 {
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('The link must start with #plan=. Copy the complete 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.');
// LZ decoding alone accepts ignored trailing characters. Require the exact
// encoding of the decoded text so a truncated or appended link is rejected.
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 { state: parseState(value), error: null };
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. */
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 };
} catch (error) {
const detail =
error instanceof URIError

View file

@ -2,14 +2,31 @@ import { useCallback, useEffect, useState } from 'react';
import type { UrlState } from './types';
import { decodeState, encodeState } from './url-state';
/** The fragment is the durable source of truth. No local storage or server state. */
const readLocation = () => decodeState(window.location.search || window.location.hash);
const queryUrl = (query: string) => `${window.location.pathname}${query}`;
/** Readable query parameters are the durable source of truth. */
export function useProject() {
const [loaded, setLoaded] = useState(() => decodeState(window.location.hash));
const [loaded, setLoaded] = useState(readLocation);
useEffect(() => {
const restore = () => setLoaded(decodeState(window.location.hash));
const restore = () => {
const next = readLocation();
if (!next.error) {
try {
// Canonicalize editable queries and migrate existing fragment links
// without creating an extra entry in browser history.
const query = encodeState(next.state);
if (query !== window.location.search || window.location.hash)
window.history.replaceState(null, '', queryUrl(query));
} catch (error) {
next.error = error instanceof Error ? error.message : 'This plan could not be saved.';
}
}
setLoaded(next);
};
window.addEventListener('hashchange', restore);
window.addEventListener('popstate', restore);
if (!window.location.hash) window.history.replaceState(null, '', encodeState(loaded.state));
restore();
return () => {
window.removeEventListener('hashchange', restore);
window.removeEventListener('popstate', restore);
@ -19,10 +36,10 @@ export function useProject() {
setLoaded((current) => {
const next = transform(current.state);
try {
const hash = encodeState(next);
if (hash !== window.location.hash)
window.history[milestone ? 'pushState' : 'replaceState'](null, '', hash);
const canonical = decodeState(hash).state;
const query = encodeState(next);
if (query !== window.location.search || window.location.hash)
window.history[milestone ? 'pushState' : 'replaceState'](null, '', queryUrl(query));
const canonical = decodeState(query).state;
if (next.project === current.state.project) canonical.project = current.state.project;
return { state: canonical, error: null };
} catch (error) {