feat(site-map): Map 3 Progress tab showing pavement-layer progress by section

Strips the map back to the road and tints each section by how many of the chosen build layers are done, with done/shown labels, a layer filter, a per-section checklist and a link into the tick-off popup.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-21 05:11:08 +02:00
parent 5141f7783a
commit d6cf3d1144
8 changed files with 190 additions and 7 deletions

View file

@ -18,3 +18,6 @@ For each 1 m cell inside a road area where both ground and design exist: `diff =
## Extension points
- Aerial imagery: add a raster layer to `render.ts` once the site is georeferenced (no tile requests today).
- Nellmapius: needs terrain extracted from the "Detail Contour Plan" PDF first.
## Progress tab (build layers)
Strips every other map layer and tints each road section by how many of the ticked pavement layers are done (red none, amber some, green all), with a "done/shown" label. The side list gives each section's layer checklist; "Tick layers" or a double-click on the map opens the section's tick-off popup. Logic in `src/lib/site-map-v3/layer-progress.ts`.

View file

@ -0,0 +1,49 @@
"use client";
// Map 3 Progress tab: pavement-layer progress by section, with every other map layer stripped off.
import * as React from "react";
import type { SiteModelData } from "@/lib/site-map-v2/model-types";
import type { ProgressState } from "@/lib/site-map-v2/progress";
import { layerCatalog, layerTotals, sectionSummary } from "@/lib/site-map-v3/layer-progress";
import type { Selection } from "../scene/types";
const css = (n: number) => `#${n.toString(16).padStart(6, "0")}`;
export function BuildProgressPanel({ model, state, selected, setSelected, select, focus, onOpen, canEdit }: {
model: SiteModelData; state: ProgressState; selected: Set<string>; setSelected: (s: Set<string>) => void;
select(sel: Selection): void; focus(e: number, n: number, z: number, dist: number): void; onOpen(sectionKey: string): void; canEdit: boolean;
}) {
const catalog = React.useMemo(() => layerCatalog(model), [model]);
const rows = model.road.sections.map(s => ({ s, sum: sectionSummary(model, state, s.key, selected) })).filter(r => r.sum.layersTotal > 0);
const full = rows.filter(r => r.sum.layersDone === r.sum.layersTotal).length, none = rows.filter(r => r.sum.fraction === 0).length;
const toggle = (id: string) => { const n = new Set(selected); if (n.has(id)) n.delete(id); else n.add(id); setSelected(n); };
const centre = (key: string) => {
const pts = model.road.areas.filter(a => a.section === key).flatMap(a => a.rings[0]);
return pts.length ? [pts.reduce((t, p) => t + p[0], 0) / pts.length, pts.reduce((t, p) => t + p[1], 0) / pts.length] as const : null;
};
return <div className="space-y-5 text-sm">
<div><h2 className="text-lg font-semibold">Build progress</h2>
<p className="mt-1 text-sm text-muted-foreground">The map shows only the road, tinted by how many of the ticked layers are done in each section. Double-click a section to tick its layers off.</p></div>
<section aria-label="Layers to show"><div className="mb-1 flex items-center justify-between"><h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Layers to show</h3>
<span className="flex gap-1"><button type="button" className="rounded border px-2 py-0.5 text-xs hover:bg-muted" onClick={() => setSelected(new Set(catalog.map(l => l.id)))}>All</button><button type="button" className="rounded border px-2 py-0.5 text-xs hover:bg-muted" onClick={() => setSelected(new Set())}>None</button></span></div>
<ul className="space-y-0.5">{catalog.map(l => { const t = layerTotals(model, state, l.id); return <li key={l.id}>
<label className="flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-2 hover:bg-muted"><input type="checkbox" className="h-4 w-4" checked={selected.has(l.id)} onChange={() => toggle(l.id)} />
<i className="h-3 w-3 shrink-0 rounded-sm border" style={{ background: css(l.color) }} /><span className="min-w-0 flex-1 truncate">{l.name}</span><span className="tabular-nums text-xs text-muted-foreground">{Math.round(t.percent)}%</span></label>
<div className="mb-1 ml-9 h-1.5 overflow-hidden rounded bg-muted"><div className="h-full bg-emerald-600" style={{ width: `${t.percent}%` }} /></div></li>; })}</ul>
</section>
<section aria-label="Legend" className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{[["#f87171", "None done"], ["#f59e0b", "Some done"], ["#22c55e", "All done"]].map(([c, t]) => <span key={t} className="flex items-center gap-1.5"><i className="h-3 w-3 rounded-sm" style={{ background: c }} />{t}</span>)}
<span className="text-muted-foreground">Label = layers done / layers shown</span></section>
{rows.length > 0 && <section aria-label="Sections"><h3 className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections · {full} done · {rows.length - full - none} in progress · {none} not started</h3>
<ul className="divide-y rounded-lg border">{rows.map(({ s, sum }) => { const c = centre(s.key); return <li key={s.key} className="flex flex-wrap items-center gap-2 p-2">
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => select({ type: "section", section: s.key })}>
<b className="block truncate">{s.name} {s.from}–{Math.round(s.to)} m</b>
<span className="mt-1 flex items-center gap-1" aria-label={`${sum.layersDone} of ${sum.layersTotal} layers done`}>{sum.layers.map(l => <i key={l.id} title={l.name} className="h-3 w-3 rounded-sm border" style={{ background: l.done >= 0.999 ? "#22c55e" : l.done > 0 ? "#f59e0b" : "transparent" }} />)}<span className="ml-1 text-xs tabular-nums text-muted-foreground">{sum.layersDone}/{sum.layersTotal}</span></span></button>
{c && <button type="button" className="min-h-9 rounded-md border px-2 text-xs hover:bg-muted" onClick={() => { select({ type: "section", section: s.key }); focus(c[0], c[1], 0, 60); }}>Show in 3D</button>}
<button type="button" disabled={!canEdit} className="min-h-9 rounded-md border px-2 text-xs hover:bg-muted disabled:opacity-50" onClick={() => { select({ type: "section", section: s.key }); onOpen(s.key); }}>Tick layers</button>
</li>; })}</ul></section>}
{!rows.length && <p className="text-muted-foreground">Tick at least one layer above to see the sections.</p>}
</div>;
}

View file

@ -13,6 +13,7 @@ import { buildEarthworks } from "./layers-earthworks";
import { buildSigns, signBillboards } from "./layers-signs";
import { createRain } from "./layers-rain";
import { buildCutFill, buildGroundContours, buildHeat, buildStatus } from "./layers-terrain";
import { buildBuildProgress } from "./layers-buildprogress";
import { decodeGrid, hillshade } from "@/lib/site-map-v3/grid";
import { EXTRA_LAYER_IDS, LAYER_IDS, type CreateOptions, type ExtraLayerId, type LayerId, type SceneContext, type Selection, type SiteMapHandle } from "./types";
@ -46,6 +47,8 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
for (const id of LAYER_IDS) { const g = new THREE.Group(); g.name = id; scene.add(g); groups.set(id, g); }
const extra = new Map<ExtraLayerId, THREE.Group>();
for (const id of EXTRA_LAYER_IDS) { const g = new THREE.Group(); g.name = id; g.visible = false; scene.add(g); extra.set(id, g); }
const layerView = new THREE.Group(); layerView.visible = false; scene.add(layerView);
let layerSel: Set<string> | null = null;
const disposeTree = (root: THREE.Object3D) => root.traverse((o) => {
if (o === root) return;
const m = o as THREE.Mesh;
@ -80,6 +83,7 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
gcontours: () => buildGroundContours(ctx),
cutfill: () => buildCutFill(ctx),
status: () => buildStatus(ctx),
layerview: () => buildBuildProgress(ctx, layerView, layerSel),
signs: () => buildSigns(ctx),
};
const opacity: Partial<Record<LayerId, number>> = {};
@ -165,7 +169,7 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
// ---------- picking & hover ----------
const ray = new THREE.Raycaster(), ndc = new THREE.Vector2();
ray.params.Line = { threshold: 0.6 };
const pickable = () => [...LAYER_IDS.filter((id) => !NON_PICKABLE.includes(id)).map((id) => groups.get(id)!), extra.get("status")!, extra.get("cutfill")!].filter((g) => g.visible);
const pickable = () => [...LAYER_IDS.filter((id) => !NON_PICKABLE.includes(id)).map((id) => groups.get(id)!), extra.get("status")!, extra.get("cutfill")!, layerView].filter((g) => g.visible);
function hitAt(ev: PointerEvent) {
const r = renderer.domElement.getBoundingClientRect();
ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
@ -224,6 +228,7 @@ export function createSiteMap(container: HTMLElement, model: SiteModelData, stat
setLayerOpacity(id, value) { opacity[id] = Math.max(0, Math.min(1, value)); applyStyles(); },
setExtraLayer(id, visible, op) { extra.get(id)!.visible = visible; extraOpacity[id] = Math.max(0, Math.min(1, op)); applyExtra(); },
setUnderground(on) { underground = on; controls.maxPolarAngle = on ? Math.PI * 0.97 : Math.PI / 2.05; controls.screenSpacePanning = on; },
setLayerView(sel) { layerSel = sel ? new Set(sel) : null; layerView.visible = !!sel; builders.layerview(); },
setCutFill(result) { ctx.cutfill = result; builders.cutfill(); applyExtra(); },
setPavementVisibility(values) { pavement = values; applyStyles(); },
setSectionCosts(values) { costs = values; applyStyles(); },

View file

@ -0,0 +1,42 @@
// Map 3 Progress tab: every road section tinted by how many of the chosen pavement layers are done, with a "2/5" label.
import type * as THREE from "three";
import type { SceneContext } from "./types";
import { progressColour, sectionSummary } from "@/lib/site-map-v3/layer-progress";
const labelTextures = new Map<string, THREE.CanvasTexture>();
function labelTexture(c: SceneContext, text: string) {
let t = labelTextures.get(text);
if (t) return t;
const cv = document.createElement("canvas"); cv.width = 192; cv.height = 96;
const g = cv.getContext("2d")!;
g.font = "700 56px system-ui, sans-serif"; g.textAlign = "center"; g.textBaseline = "middle";
g.lineWidth = 12; g.strokeStyle = "rgba(255,255,255,.95)"; g.strokeText(text, 96, 50); g.fillStyle = "#111827"; g.fillText(text, 96, 50);
t = new c.THREE.CanvasTexture(cv); labelTextures.set(text, t);
return t;
}
export function buildBuildProgress(c: SceneContext, g: THREE.Group, selected: ReadonlySet<string> | null) {
const { THREE, model, state } = c;
c.clear(g);
if (!selected) return;
for (const sec of model.road.sections) {
const sum = sectionSummary(model, state, sec.key, selected);
if (!sum.layersTotal) continue;
const colour = progressColour(sum.fraction, sum.layersTotal), pickData = { type: "section", section: sec.key };
const checklist = sum.layers.map(l => `${l.done >= 0.999 ? "✔" : l.done > 0 ? "◐" : "○"} ${l.name}`).join("<br>");
const tip = `<b>${sec.name} ${sec.from}–${Math.round(sec.to)} m</b><br>${sum.layersDone} of ${sum.layersTotal} layers done<br>${checklist}`;
let ce = 0, cn = 0, count = 0, top = -Infinity;
for (const a of model.road.areas.filter(x => x.section === sec.key)) {
const ring = a.rings[0], thk = (model.road.builds[a.kind]?.layers ?? []).reduce((t, l) => t + l.thk, 0) * c.layEx;
const geo = new THREE.ShapeGeometry(new THREE.Shape(ring.map(([e, n]) => new THREE.Vector2(e, n)))), pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) { const e = pos.getX(i), n = pos.getY(i); pos.setXYZ(i, e, c.Y(c.designZ(e, n)) + thk + 0.2, -n); }
const m = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: colour, transparent: true, opacity: 0.8, side: THREE.DoubleSide, depthWrite: false, depthTest: false }));
m.renderOrder = 25; m.userData.pick = pickData; m.userData.tip = tip; g.add(m);
for (const [e, n] of ring) { ce += e; cn += n; count++; top = Math.max(top, c.Y(c.designZ(e, n)) + thk); }
}
if (!count) continue;
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: labelTexture(c, `${sum.layersDone}/${sum.layersTotal}`), depthTest: false, transparent: true }));
sprite.scale.set(9, 4.5, 1); sprite.position.set(ce / count, top + 2, -(cn / count)); sprite.renderOrder = 30;
sprite.userData.pick = pickData; sprite.userData.tip = tip; g.add(sprite);
}
}

View file

@ -43,6 +43,8 @@ export interface SiteMapHandle {
setLayerOpacity(id: LayerId, opacity: number): void;
setExtraLayer(id: ExtraLayerId, visible: boolean, opacity: number): void;
setCutFill(result: CutFillResult | null): void;
/** Progress tab: tint sections by these pavement layers (null = off). */
setLayerView(selected: string[] | null): void;
/** Let the camera go below the ground (to look up at cut and fill sections). */
setUnderground(on: boolean): void;
setPavementVisibility(values: Record<string, boolean>): void;

View file

@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import dynamic from "next/dynamic";
import { Map, MousePointer2, CloudRain, ChevronDown, Maximize2, Layers, RotateCcw, Sigma, Mountain, MountainSnow, Droplets } from "lucide-react";
import { Map, MousePointer2, CloudRain, ChevronDown, Maximize2, Layers, RotateCcw, Sigma, Mountain, MountainSnow, Droplets, ListChecks } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ProgressState } from "@/lib/site-map-v2/progress";
import { modelFingerprint } from "@/lib/site-map-v2/scenario";
@ -21,6 +21,8 @@ import { SectionDetail } from "./panels/section-detail";
import { sectionProgress } from "@/lib/site-map-v2/quantities";
import { createDoublePick } from "../site-map/double-pick";
import { RoutesPanel } from "./panels/routes-panel";
import { BuildProgressPanel } from "./panels/buildprogress-panel";
import { layerCatalog } from "@/lib/site-map-v3/layer-progress";
import { routeKey, ROUTE_ST } from "@/lib/site-map-v3/routes";
import { srKey, srPipeKey, swKey, swPipeKey } from "@/lib/site-map-v2/keys";
import { srName } from "@/lib/site-map-v2/services";
@ -35,8 +37,8 @@ import { EXTRA_LAYER_IDS } from "./scene/types";
import { earthKey } from "@/lib/site-map-v2/keys";
const SiteMapCanvas = dynamic(() => import("./site-map-canvas").then(m => ({ default: m.SiteMapCanvas })), { ssr: false });
type Mode = "map" | "ground" | "services" | "details" | "totals" | "earthworks" | "rain";
const MODES = [{ id: "map", label: "Map", icon: Map }, { id: "ground", label: "Ground", icon: MountainSnow }, { id: "services", label: "Services", icon: Droplets }, { id: "details", label: "Section details", icon: MousePointer2 }, { id: "totals", label: "Totals", icon: Sigma }, { id: "earthworks", label: "Cut & fill", icon: Mountain }, { id: "rain", label: "Rain", icon: CloudRain }] as const;
type Mode = "map" | "buildprogress" | "ground" | "services" | "details" | "totals" | "earthworks" | "rain";
const MODES = [{ id: "map", label: "Map", icon: Map }, { id: "buildprogress", label: "Progress", icon: ListChecks }, { id: "ground", label: "Ground", icon: MountainSnow }, { id: "services", label: "Services", icon: Droplets }, { id: "details", label: "Section details", icon: MousePointer2 }, { id: "totals", label: "Totals", icon: Sigma }, { id: "earthworks", label: "Cut & fill", icon: Mountain }, { id: "rain", label: "Rain", icon: CloudRain }] as const;
const QUICK_LAYERS: { id: LayerId; label: string; color: string }[] = [
{ id: "road", label: "Road & paths", color: "#548bc2" },
{ id: "stormwater", label: "Stormwater", color: "#0891b2" },
@ -61,6 +63,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
const [handle, setHandle] = React.useState<SiteMapHandle | null>(null);
const [selection, setSelection] = React.useState<Selection>(null);
const [popup, setPopup] = React.useState<string | null>(null); // section whose progress popup is open
const [layerSel, setLayerSel] = React.useState<Set<string>>(() => new Set(layerCatalog(model).map(l => l.id))); // pavement layers shown on the Progress tab
const [svcPopup, setSvcPopup] = React.useState<Selection>(null); // pipe or manhole whose status popup is open
const [routePopup, setRoutePopup] = React.useState<string | null>(null); // service route whose status popup is open
const [ewPopup, setEwPopup] = React.useState<number | null>(null); // earthworks strip whose status popup is open
@ -92,11 +95,13 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
const fsRef = React.useRef<ReturnType<typeof createFullscreen> | null>(null);
const visible = React.useMemo(() => {
const saved = { ...presetVisibility(scenario.view.preset), natground: !!model.earthworks.groundSurface, ...scenario.view.visible } as Record<LayerId, boolean>; // surveyed ground shows by default
if (mode === "buildprogress") return Object.fromEntries(LAYER_IDS.map(id => [id, id === "surface" || id === "ctxEdge"])) as Record<LayerId, boolean>; // everything else stripped off
const v = workspaceVisibility(saved, mode === "ground" || mode === "services" ? "map" : mode);
return mode === "services" ? { ...v, stormwater: true, sewer: true, water: true, road: false, surface: true } : v;
}, [scenario.view.preset, scenario.view.visible, mode, model.earthworks.groundSurface]);
React.useEffect(() => { handle?.setCutFill(cutfill); }, [handle, cutfill]);
React.useEffect(() => { if (handle) for (const id of EXTRA_LAYER_IDS) handle.setExtraLayer(id, extra.visible[id] && (id === "status" || !!ground), extra.opacity[id]); }, [handle, extra, ground]);
React.useEffect(() => { handle?.setLayerView(mode === "buildprogress" ? [...layerSel] : null); }, [handle, mode, layerSel]);
React.useEffect(() => { if (handle) for (const id of EXTRA_LAYER_IDS) handle.setExtraLayer(id, mode !== "buildprogress" && extra.visible[id] && (id === "status" || !!ground), extra.opacity[id]); }, [handle, extra, ground, mode]);
// "Map built as we go": progress that arrives or changes switches the road layers on and says so
React.useEffect(() => {
const a = autoEnabledLayers({ model, state, previousState: prevState.current });
@ -120,7 +125,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
// Cut & fill tab: taller relief so the ground reads as 3D, and the camera may go below the ground to look up at the sections
React.useEffect(() => { handle?.setStretch(mode === "earthworks" ? Math.max(scenario.view.levEx, 4) : scenario.view.levEx, scenario.view.layEx); handle?.setUnderground(mode === "earthworks"); }, [handle, mode, scenario.view.levEx, scenario.view.layEx]);
React.useEffect(() => { handle?.setSelection(selection); }, [handle, selection]);
React.useEffect(() => { if (handle) for (const id of LAYER_IDS) handle.setLayerOpacity(id, mode === "rain" && id === "rain" ? 1 : mode === "services" && id === "surface" ? 0.3 : scenario.view.opacity[id] ?? (id === "natground" ? 0.5 : 1)); }, [handle, scenario.view.opacity, mode]);
React.useEffect(() => { if (handle) for (const id of LAYER_IDS) handle.setLayerOpacity(id, mode === "rain" && id === "rain" ? 1 : (mode === "services" || mode === "buildprogress") && id === "surface" ? 0.3 : scenario.view.opacity[id] ?? (id === "natground" ? 0.5 : 1)); }, [handle, scenario.view.opacity, mode]);
React.useEffect(() => { handle?.setPavementVisibility(scenario.view.pavement); }, [handle, scenario.view.pavement]);
React.useEffect(() => { handle?.view(scenario.view.camera); }, [handle, scenario.view.camera]);
const setLayer = (id: LayerId, on: boolean) => update(s => ({ ...s, view: { ...s.view, visible: { ...presetVisibility(s.view.preset), ...s.view.visible, [id]: on } } }));
@ -131,7 +136,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
const select = (next: Selection) => {
setSelection(next);
setPopup(open => (next?.type === "section" && next.section === open ? open : null));
if (next) setMode(next.type === "xs" || next.type === "ewseg" ? "earthworks" : next.type === "route" || next.type === "swPipe" || next.type === "srPipe" || next.type === "sw" || next.type === "sr" ? "services" : "details");
if (next && !(mode === "buildprogress" && next.type === "section")) setMode(next.type === "xs" || next.type === "ewseg" ? "earthworks" : next.type === "route" || next.type === "swPipe" || next.type === "srPipe" || next.type === "sw" || next.type === "sr" ? "services" : "details");
if (next?.type === "peg") setLayer("pegs", true);
};
const focus = React.useCallback((e: number, n: number, z: number, dist: number) => handle?.focus(e, n, z, dist), [handle]);
@ -158,6 +163,7 @@ function MapWorkspace({ model, modelId, siteId, canEdit, isAdmin }: { model: Non
<p className="text-xs leading-relaxed text-muted-foreground">Drag to rotate. Scroll to zoom. Use Top view for a plan of the road.</p>
</div>}
{mode === "ground" && <GroundPanel model={model} extra={extra} setExtra={setExtra} source={source} setSource={setSource} hasGround={!!ground} depth={depth} setDepth={setDepth} comparison={comparison} cutfill={cutfill} range={groundRange} />}
{mode === "buildprogress" && <BuildProgressPanel model={model} state={state} selected={layerSel} setSelected={setLayerSel} select={select} focus={focus} onOpen={setPopup} canEdit={canEdit} />}
{mode === "services" && <RoutesPanel {...panelProps} selection={selection} handle={handle} />}
{mode === "details" && <SectionInspector {...panelProps} selection={selection} handle={handle} />}
{mode === "totals" && <AllSectionsTotals model={model} state={state} />}

View file

@ -0,0 +1,51 @@
// Build-layer progress: which pavement layers are done in each road section (Map 3's Progress tab).
import type { SiteModelData } from "@/lib/site-map-v2/model-types";
import { roadKey } from "@/lib/site-map-v2/keys";
import { valueOf, type ProgressState } from "@/lib/site-map-v2/progress";
export interface CatalogLayer { id: string; name: string; color: number; order: number }
export type Model = Pick<SiteModelData, "road">;
/** Every pavement layer in the model once, bottom to top by where it first appears in a build. */
export function layerCatalog(model: Model): CatalogLayer[] {
const seen = new Map<string, CatalogLayer>();
for (const b of Object.values(model.road.builds)) b.layers.forEach((l, i) => { if (!seen.has(l.id)) seen.set(l.id, { id: l.id, name: l.name, color: l.color, order: i }); });
return [...seen.values()].sort((a, b) => a.order - b.order);
}
export interface SectionLayer { id: string; name: string; done: number; area: number } // done 0..1, area-weighted over the section's surfaces that have the layer
/** Per layer: how much of this section's surface that carries the layer is done. */
export function sectionLayers(model: Model, state: ProgressState, sectionKey: string): SectionLayer[] {
const acc = new Map<string, { name: string; order: number; done: number; area: number }>();
for (const a of model.road.areas) {
if (a.section !== sectionKey) continue;
(model.road.builds[a.kind]?.layers ?? []).forEach((l, i) => {
const row = acc.get(l.id) ?? { name: l.name, order: i, done: 0, area: 0 };
row.area += a.area; row.done += valueOf(state, roadKey(sectionKey, a.kind, l.id)) > 0 ? a.area : 0;
acc.set(l.id, row);
});
}
return [...acc.entries()].sort((x, y) => x[1].order - y[1].order).map(([id, r]) => ({ id, name: r.name, done: r.area ? r.done / r.area : 0, area: r.area }));
}
export interface SectionSummary { layersDone: number; layersTotal: number; fraction: number; layers: SectionLayer[] }
/** Progress over the chosen layers only. A layer counts as done when it is done over the whole section. */
export function sectionSummary(model: Model, state: ProgressState, sectionKey: string, selected: ReadonlySet<string>): SectionSummary {
const layers = sectionLayers(model, state, sectionKey).filter(l => selected.has(l.id));
const area = layers.reduce((t, l) => t + l.area, 0);
return { layersDone: layers.filter(l => l.done >= 0.999).length, layersTotal: layers.length, fraction: area ? layers.reduce((t, l) => t + l.done * l.area, 0) / area : 0, layers };
}
/** Whole-site figures for one layer. */
export function layerTotals(model: Model, state: ProgressState, layerId: string) {
let area = 0, done = 0;
for (const a of model.road.areas) {
if (!(model.road.builds[a.kind]?.layers ?? []).some(l => l.id === layerId)) continue;
area += a.area; if (valueOf(state, roadKey(a.section, a.kind, layerId)) > 0) done += a.area;
}
return { area, done, percent: area ? (done / area) * 100 : 0 };
}
/** none = red tint, some = amber, all = green. */
export const progressColour = (fraction: number, total: number): number => (total === 0 ? 0x9ca3af : fraction >= 0.999 ? 0x22c55e : fraction > 0 ? 0xf59e0b : 0xf87171);

View file

@ -152,3 +152,28 @@ describe("pipes and manholes (2026-10-21 model)", () => {
expect(serviceTotals(m2, {})[2].routes).toBe(4); // water stays plan routes
});
});
describe("build-layer progress", () => {
it("lists each pavement layer once, bottom to top", async () => {
const { layerCatalog } = await import("./layer-progress");
const ids = layerCatalog(model).map(l => l.id);
expect(ids[0]).toBe("in-situ-roadbed-g9-150mm"); expect(new Set(ids).size).toBe(ids.length);
expect(ids).toContain("80mm-interlocking-pavers"); expect(ids).toContain("asphalt-30mm");
});
it("counts done layers per section and weights by area", async () => {
const { layerCatalog, sectionSummary, layerTotals, progressColour } = await import("./layer-progress");
const { roadKey } = await import("@/lib/site-map-v2/keys");
const all = new Set(layerCatalog(model).map(l => l.id)), sec = "cedar-gate-ch0-20";
const area = model.road.areas.find(a => a.section === sec && a.kind === "internal_paving")!;
const none = sectionSummary(model, {}, sec, all);
expect(none.layersTotal).toBeGreaterThanOrEqual(5); expect(none.layersDone).toBe(0); expect(none.fraction).toBe(0);
const ids = model.road.builds.internal_paving.layers.map(l => l.id);
const st = Object.fromEntries(ids.slice(0, 2).map(id => [roadKey(sec, "internal_paving", id), { value: 1, user_name: "t", created: "x" }]));
const two = sectionSummary(model, st, sec, all);
expect(two.layersDone).toBe(2); expect(two.layers.filter(l => l.done === 1).map(l => l.id)).toEqual(ids.slice(0, 2));
expect(two.fraction).toBeGreaterThan(0); expect(two.fraction).toBeLessThan(1);
expect(sectionSummary(model, st, sec, new Set([ids[0]])).fraction).toBe(1); // only the first layer chosen
expect(layerTotals(model, st, ids[0]).done).toBeGreaterThanOrEqual(area.area);
expect([progressColour(0, 3), progressColour(0.4, 3), progressColour(1, 3), progressColour(0, 0)]).toEqual([0xf87171, 0xf59e0b, 0x22c55e, 0x9ca3af]);
});
});