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:
parent
5141f7783a
commit
d6cf3d1144
8 changed files with 190 additions and 7 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
49
src/components/site-map-v3/panels/buildprogress-panel.tsx
Normal file
49
src/components/site-map-v3/panels/buildprogress-panel.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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(); },
|
||||
|
|
|
|||
42
src/components/site-map-v3/scene/layers-buildprogress.ts
Normal file
42
src/components/site-map-v3/scene/layers-buildprogress.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
51
src/lib/site-map-v3/layer-progress.ts
Normal file
51
src/lib/site-map-v3/layer-progress.ts
Normal 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);
|
||||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue