From d6cf3d11449a4263d5feeb96dfda68edfdb81630 Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 21 Oct 2026 05:11:08 +0200 Subject: [PATCH] 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 --- docs/map3-notes.md | 3 ++ .../panels/buildprogress-panel.tsx | 49 ++++++++++++++++++ src/components/site-map-v3/scene/engine.ts | 7 ++- .../site-map-v3/scene/layers-buildprogress.ts | 42 +++++++++++++++ src/components/site-map-v3/scene/types.ts | 2 + src/components/site-map-v3/site-map-view.tsx | 18 ++++--- src/lib/site-map-v3/layer-progress.ts | 51 +++++++++++++++++++ src/lib/site-map-v3/site-map-v3.test.ts | 25 +++++++++ 8 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 src/components/site-map-v3/panels/buildprogress-panel.tsx create mode 100644 src/components/site-map-v3/scene/layers-buildprogress.ts create mode 100644 src/lib/site-map-v3/layer-progress.ts diff --git a/docs/map3-notes.md b/docs/map3-notes.md index 08a3746..9834ea1 100644 --- a/docs/map3-notes.md +++ b/docs/map3-notes.md @@ -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`. diff --git a/src/components/site-map-v3/panels/buildprogress-panel.tsx b/src/components/site-map-v3/panels/buildprogress-panel.tsx new file mode 100644 index 0000000..83e32e0 --- /dev/null +++ b/src/components/site-map-v3/panels/buildprogress-panel.tsx @@ -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; setSelected: (s: Set) => 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
+

Build progress

+

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.

+ +

Layers to show

+
+
    {catalog.map(l => { const t = layerTotals(model, state, l.id); return
  • + +
  • ; })}
+
+ +
+ {[["#f87171", "None done"], ["#f59e0b", "Some done"], ["#22c55e", "All done"]].map(([c, t]) => {t})} + Label = layers done / layers shown
+ + {rows.length > 0 &&

Sections · {full} done · {rows.length - full - none} in progress · {none} not started

+
    {rows.map(({ s, sum }) => { const c = centre(s.key); return
  • + + {c && } + +
  • ; })}
} + {!rows.length &&

Tick at least one layer above to see the sections.

} +
; +} diff --git a/src/components/site-map-v3/scene/engine.ts b/src/components/site-map-v3/scene/engine.ts index c5fd9cc..318cfe8 100644 --- a/src/components/site-map-v3/scene/engine.ts +++ b/src/components/site-map-v3/scene/engine.ts @@ -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(); 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 | 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> = {}; @@ -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(); }, diff --git a/src/components/site-map-v3/scene/layers-buildprogress.ts b/src/components/site-map-v3/scene/layers-buildprogress.ts new file mode 100644 index 0000000..f66370c --- /dev/null +++ b/src/components/site-map-v3/scene/layers-buildprogress.ts @@ -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(); +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 | 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("
"); + const tip = `${sec.name} ${sec.from}–${Math.round(sec.to)} m
${sum.layersDone} of ${sum.layersTotal} layers done
${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); + } +} diff --git a/src/components/site-map-v3/scene/types.ts b/src/components/site-map-v3/scene/types.ts index 93752d0..24d034e 100644 --- a/src/components/site-map-v3/scene/types.ts +++ b/src/components/site-map-v3/scene/types.ts @@ -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): void; diff --git a/src/components/site-map-v3/site-map-view.tsx b/src/components/site-map-v3/site-map-view.tsx index a395379..505c85e 100644 --- a/src/components/site-map-v3/site-map-view.tsx +++ b/src/components/site-map-v3/site-map-view.tsx @@ -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(null); const [selection, setSelection] = React.useState(null); const [popup, setPopup] = React.useState(null); // section whose progress popup is open + const [layerSel, setLayerSel] = React.useState>(() => new Set(layerCatalog(model).map(l => l.id))); // pavement layers shown on the Progress tab const [svcPopup, setSvcPopup] = React.useState(null); // pipe or manhole whose status popup is open const [routePopup, setRoutePopup] = React.useState(null); // service route whose status popup is open const [ewPopup, setEwPopup] = React.useState(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 | null>(null); const visible = React.useMemo(() => { const saved = { ...presetVisibility(scenario.view.preset), natground: !!model.earthworks.groundSurface, ...scenario.view.visible } as Record; // surveyed ground shows by default + if (mode === "buildprogress") return Object.fromEntries(LAYER_IDS.map(id => [id, id === "surface" || id === "ctxEdge"])) as Record; // 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

Drag to rotate. Scroll to zoom. Use Top view for a plan of the road.

} {mode === "ground" && } + {mode === "buildprogress" && } {mode === "services" && } {mode === "details" && } {mode === "totals" && } diff --git a/src/lib/site-map-v3/layer-progress.ts b/src/lib/site-map-v3/layer-progress.ts new file mode 100644 index 0000000..d7536d1 --- /dev/null +++ b/src/lib/site-map-v3/layer-progress.ts @@ -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; + +/** 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(); + 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(); + 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): 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); diff --git a/src/lib/site-map-v3/site-map-v3.test.ts b/src/lib/site-map-v3/site-map-v3.test.ts index 5a5c2ad..bdc219d 100644 --- a/src/lib/site-map-v3/site-map-v3.test.ts +++ b/src/lib/site-map-v3/site-map-v3.test.ts @@ -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]); + }); +});