; onChange(id: LayerId, on: boolean): void; onAll(on: boolean): void;
+ stretch: { levEx: number; layEx: number }; onStretch(s: { levEx: number; layEx: number }): void;
+}) {
+ const [folded, setFolded] = React.useState(false);
+ return (
+
+
+ Layers
+
+
+
+
+
+
+ {!folded && (
+ <>
+ {GROUPS.map((g) => (
+
+
{g.title}
+ {g.items.map((it) => (
+
+ ))}
+
+ ))}
+
+
Height stretch
+ {([["Levels", "levEx", 1, 5, 0.5], ["Layers", "layEx", 1, 10, 1]] as const).map(([label, key, min, max, step]) => (
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/site-map/overlay/nav-hint.tsx b/src/components/site-map/overlay/nav-hint.tsx
new file mode 100644
index 0000000..d24056a
--- /dev/null
+++ b/src/components/site-map/overlay/nav-hint.tsx
@@ -0,0 +1,11 @@
+const K = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+export function NavHint() {
+ return (
+
+ Move: ←↑↓→ or WASD (hold Shift = faster) · Zoom: +− or scroll · Rotate: QE or drag
+
+ );
+}
diff --git a/src/components/site-map/overlay/nav-pad.tsx b/src/components/site-map/overlay/nav-pad.tsx
new file mode 100644
index 0000000..b537ce0
--- /dev/null
+++ b/src/components/site-map/overlay/nav-pad.tsx
@@ -0,0 +1,36 @@
+"use client";
+import type { NavDir, SiteMapHandle } from "../scene/types";
+
+const PAD: [string, string, NavDir | "home" | "zoomIn" | "zoomOut"][] = [
+ ["⟲", "Rotate left (Q)", "rotL"], ["▲", "Forward (↑)", "f"], ["⟳", "Rotate right (E)", "rotR"],
+ ["◀", "Left (←)", "l"], ["⌂", "Whole site", "home"], ["▶", "Right (→)", "r"],
+ ["−", "Zoom out (−)", "zoomOut"], ["▼", "Back (↓)", "b"], ["+", "Zoom in (+)", "zoomIn"],
+];
+
+const DIRS: readonly string[] = ["f", "b", "l", "r", "rotL", "rotR"];
+const isDir = (a: string): a is NavDir => DIRS.includes(a);
+
+export function NavPad({ handle }: { handle: SiteMapHandle | null }) {
+ return (
+
+ {PAD.map(([glyph, title, action]) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/site-map/panels/selection-card.tsx b/src/components/site-map/panels/selection-card.tsx
new file mode 100644
index 0000000..acf487c
--- /dev/null
+++ b/src/components/site-map/panels/selection-card.tsx
@@ -0,0 +1,98 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import { chainageAt } from "@/lib/site-map/chainage";
+import { pegKey, srKey, srPipeKey, swKey, swPipeKey } from "@/lib/site-map/keys";
+import { valueOf } from "@/lib/site-map/progress";
+import { structureKind, trenchVolume } from "@/lib/site-map/quantities";
+import { coverSR, coverSW, groundKnownSR, sewerGap, srName } from "@/lib/site-map/services";
+import { decodeSurface, pegZ, surfZ } from "@/lib/site-map/surface";
+import { PIPE_ST, STRUCT_ST } from "../scene/layers-services";
+import { fmt, lastChange, sectionLabel } from "../format";
+import type { PanelProps } from "./types";
+import type { Selection } from "../scene/types";
+import * as React from "react";
+
+function Kv({ rows }: { rows: [string, React.ReactNode][] }) {
+ return (
+
+ {rows.map(([k, v]) => - {k}
- {v}
)}
+
+ );
+}
+function States({ labels, value, disabled, onPick }: { labels: string[]; value: number; disabled: boolean; onPick(v: number): void }) {
+ return (
+
+ {labels.map((l, i) => )}
+
+ );
+}
+const Hold = ({ text }: { text: string }) => HOLD — {text}
;
+const Warn = ({ text }: { text: string }) => {text}
;
+
+export function SelectionCard(props: PanelProps & { selection: Selection; extra?(sel: Exclude): React.ReactNode }) {
+ const { model, state, canEdit, setProgress, selection: s } = props;
+ const sf = model.surface;
+ const Z = React.useMemo(() => decodeSurface(sf), [sf]);
+ if (!s) return Click anything in the 3D view — road, peg, pipe, manhole, cross-section…
;
+ const sZ = (e: number, n: number) => surfZ(sf, Z, e, n);
+ const last = (key: string) => { const t = lastChange(state, key); return t ? {t}
: null; };
+
+ if (s.type === "section") {
+ const sec = model.road.sections.find((x) => x.key === s.section);
+ return Road section {sec ? sectionLabel(sec.from, sec.to) : s.section} — tick off layers in the section list on the Progress tab.
;
+ }
+ if (s.type === "peg") {
+ const p = model.road.pegs.find((x) => x.name === s.name);
+ if (!p) return null;
+ const c = chainageAt(model.earthworks.alignment, p.e, p.n), key = pegKey(p.name);
+ return (Setting-out peg {p.name}
+ setProgress(key, v)} />
+ 0 ? "left" : "right"}`]]} />
+ {last(key)}
);
+ }
+ const sv = model.services;
+ if (s.type === "swPipe" || s.type === "srPipe") {
+ const isSW = s.type === "swPipe";
+ const pipe = isSW ? sv.stormwater.pipes.find((p) => swPipeKey(p.from, p.to) === s.key) : sv.sewer.pipes.find((p) => srPipeKey(srName(sv, p.from), srName(sv, p.to)) === s.key);
+ if (!pipe) return null;
+ const fromName = isSW ? pipe.from : srName(sv, pipe.from), toName = isSW ? pipe.to : srName(sv, pipe.to);
+ const top = (name: string) => {
+ if (isSW) { const x = sv.stormwater.structures.find((y) => y.name === name); return x ? coverSW(x, sZ) : NaN; }
+ const m = sv.sewer.manholes.find((y) => y.name === name);
+ return m ? coverSR(m, sZ, (e, n) => pegZ(model.road.pegs, e, n), model.road.pegs) : NaN;
+ };
+ const da = top(fromName) - pipe.ilFrom, db = top(toName) - pipe.ilTo;
+ const hold = "hold" in pipe ? pipe.hold : null, gap = isSW ? null : sewerGap(sv, pipe as never);
+ return ({isSW ? "Stormwater" : "Sewer"} pipe {fromName} → {toName}
+ {hold && }{gap && }
+ setProgress(s.key, v)} />
+ 0 ? Math.round(100 / pipe.grade) : "∞"})`],
+ ["Invert in / out", `${pipe.ilFrom.toFixed(3)} / ${pipe.ilTo.toFixed(3)}`], ["Depth to invert", `${da.toFixed(2)} m → ${db.toFixed(2)} m`],
+ ["Trench (est.)", `${fmt(trenchVolume(pipe.length, pipe.dia, da, db))} m³`]]} />
+ {last(s.key)}
);
+ }
+ if (s.type === "sw") {
+ const x = sv.stormwater.structures.find((y) => y.name === s.name);
+ if (!x) return null;
+ const top = coverSW(x, sZ), key = swKey(x.name);
+ return ({structureKind(x.name)} {x.name}
+ setProgress(key, v)} />
+
+ {last(key)}
);
+ }
+ if (s.type === "sr") {
+ const m = sv.sewer.manholes.find((y) => y.name === s.name);
+ if (!m) return null;
+ const top = coverSR(m, sZ, (e, n) => pegZ(model.road.pegs, e, n), model.road.pegs), key = srKey(m.name);
+ return (Sewer manhole {m.name}
+ {m.hold && }
+ setProgress(key, v)} />
+
+ {last(key)}
);
+ }
+ return <>{props.extra?.(s)}>;
+}
diff --git a/src/components/site-map/panels/tab-panel.tsx b/src/components/site-map/panels/tab-panel.tsx
new file mode 100644
index 0000000..816e7be
--- /dev/null
+++ b/src/components/site-map/panels/tab-panel.tsx
@@ -0,0 +1,18 @@
+"use client";
+import type { PanelProps } from "./types";
+import type { Selection, SiteMapHandle } from "../scene/types";
+
+export type TabId = "progress" | "services" | "earthworks" | "schedules" | "rain" | "accuracy";
+export type TabPanelProps = PanelProps & { handle: SiteMapHandle | null; selection: Selection };
+
+/** Content of one left-panel tab. */
+export function TabPanel({ tab }: TabPanelProps & { tab: TabId }) {
+ void tab;
+ return null;
+}
+
+/** Extra selection-card content for selections the base card doesn't know (cross-sections, earthworks strips). */
+export function SelectionExtra({ selection }: PanelProps & { selection: Exclude }) {
+ void selection;
+ return null;
+}
diff --git a/src/components/site-map/panels/types.ts b/src/components/site-map/panels/types.ts
new file mode 100644
index 0000000..06fd7b7
--- /dev/null
+++ b/src/components/site-map/panels/types.ts
@@ -0,0 +1,11 @@
+import type { SiteModelData } from "@/lib/site-map/model-types";
+import type { ProgressState } from "@/lib/site-map/progress";
+import type { Selection } from "../scene/types";
+
+export interface PanelProps {
+ model: SiteModelData; state: ProgressState; canEdit: boolean; isAdmin: boolean; siteId: string; modelId: string;
+ setProgress(key: string, value: number): void;
+ replaceState(state: ProgressState): void;
+ select(sel: Selection): void;
+ focus(e: number, n: number, z: number, dist: number): void;
+}
diff --git a/src/components/site-map/site-map-canvas.tsx b/src/components/site-map/site-map-canvas.tsx
new file mode 100644
index 0000000..7e73620
--- /dev/null
+++ b/src/components/site-map/site-map-canvas.tsx
@@ -0,0 +1,45 @@
+"use client";
+// Mounts the three.js scene. Loaded with next/dynamic({ ssr: false }) — three touches window at import.
+import * as React from "react";
+import type { SiteModelData } from "@/lib/site-map/model-types";
+import type { ProgressState } from "@/lib/site-map/progress";
+import { createSiteMap } from "./scene/engine";
+import type { Selection, SiteMapHandle } from "./scene/types";
+
+function hasWebGL() {
+ try {
+ const c = document.createElement("canvas");
+ return Boolean(c.getContext("webgl2") ?? c.getContext("webgl"));
+ } catch {
+ return false;
+ }
+}
+
+export function SiteMapCanvas({ model, state, onReady, onPick, onHover }: {
+ model: SiteModelData; state: ProgressState;
+ onReady(handle: SiteMapHandle | null): void; onPick(sel: Selection): void; onHover(html: string | null, x: number, y: number): void;
+}) {
+ const ref = React.useRef(null);
+ const handle = React.useRef(null);
+ const [webgl] = React.useState(hasWebGL);
+ const latest = React.useRef({ onPick, onHover, state });
+ React.useEffect(() => { latest.current = { onPick, onHover, state }; });
+
+ React.useEffect(() => {
+ if (!webgl || !ref.current) return;
+ const h = createSiteMap(ref.current, model, latest.current.state, {
+ onPick: (s) => latest.current.onPick(s),
+ onHover: (html, x, y) => latest.current.onHover(html, x, y),
+ });
+ handle.current = h;
+ onReady(h);
+ return () => { onReady(null); h.dispose(); handle.current = null; };
+ // the scene is built once per model; state changes go through setState below
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [model, webgl]);
+
+ React.useEffect(() => { handle.current?.setState(state); }, [state]);
+
+ if (!webgl) return This device can't show 3D maps (WebGL is switched off or not supported).
;
+ return ;
+}
diff --git a/src/components/site-map/site-map-view.tsx b/src/components/site-map/site-map-view.tsx
new file mode 100644
index 0000000..a8469f0
--- /dev/null
+++ b/src/components/site-map/site-map-view.tsx
@@ -0,0 +1,155 @@
+"use client";
+// Client root of the Site map page: loads the model, owns progress state + saving, layer visibility,
+// selection and the left-panel tabs. The 3D scene itself lives in ./scene (loaded client-only).
+import * as React from "react";
+import dynamic from "next/dynamic";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import type { ProgressState } from "@/lib/site-map/progress";
+import { SaveQueue, type SendResult } from "@/lib/site-map/save-queue";
+import { useSiteMapData } from "./use-site-map-data";
+import { LayersPanel } from "./overlay/layers-panel";
+import { NavPad } from "./overlay/nav-pad";
+import { NavHint } from "./overlay/nav-hint";
+import { SelectionCard } from "./panels/selection-card";
+import type { PanelProps } from "./panels/types";
+import { SelectionExtra, TabPanel, type TabId } from "./panels/tab-panel";
+import { LAYER_IDS, type LayerId, type Selection, type SiteMapHandle } from "./scene/types";
+
+const SiteMapCanvas = dynamic(() => import("./site-map-canvas").then((m) => ({ default: m.SiteMapCanvas })), { ssr: false });
+
+const TABS: [TabId, string][] = [["progress", "Progress"], ["services", "Services"], ["earthworks", "Earthworks"], ["schedules", "Signs & studs"], ["rain", "Rain"], ["accuracy", "Accuracy"]];
+const START_LAYERS: LayerId[] = ["surface", "ctxMark", "ctxEdge", "road"];
+const TAB_LAYERS: Partial> = {
+ services: { on: ["stormwater", "sewer", "holds", "water"] },
+ schedules: { on: ["signs", "studs"] },
+ earthworks: { on: ["natground", "xsfences"] },
+ rain: { on: ["rain"], off: ["natground", "xsfences"] },
+};
+
+export function SiteMapView({ siteId, siteName, canEdit, isAdmin }: { siteId: string; siteName: string; canEdit: boolean; isAdmin: boolean }) {
+ const data = useSiteMapData(siteId);
+ const { model, modelId, state, setState } = data;
+ const [handle, setHandle] = React.useState(null);
+ const [visible, setVisible] = React.useState>(() => Object.fromEntries(LAYER_IDS.map((id) => [id, START_LAYERS.includes(id)])) as Record);
+ const [stretch, setStretch] = React.useState({ levEx: 1.5, layEx: 4 });
+ const [selection, setSelection] = React.useState(null);
+ const [tab, setTab] = React.useState("progress");
+ const [save, setSave] = React.useState<{ status: "idle" | "saving" | "saved" | "error" | "stale"; message?: string }>({ status: "idle" });
+ const [tip, setTip] = React.useState<{ html: string; x: number; y: number } | null>(null);
+
+ // ---- saving ----
+ const modelIdRef = React.useRef(modelId);
+ React.useEffect(() => { modelIdRef.current = modelId; }, [modelId]);
+ const queueRef = React.useRef(null);
+ React.useEffect(() => {
+ queueRef.current = new SaveQueue({
+ async send(events): Promise {
+ setSave({ status: "saving" });
+ try {
+ const r = await fetch(`/api/sites/${siteId}/progress`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: modelIdRef.current, events }) });
+ if (r.ok) return { ok: true, state: (await r.json()).state as ProgressState };
+ const body = await r.json().catch(() => ({}));
+ return { ok: false, status: r.status, message: body.message ?? body.error ?? `HTTP ${r.status}` };
+ } catch (e) {
+ return { ok: false, status: 0, message: e instanceof Error ? e.message : "Network error" };
+ }
+ },
+ onSaved: (s) => { setState((cur) => ({ ...cur, ...s })); setSave({ status: "saved" }); },
+ onError: (message) => setSave({ status: "error", message }),
+ onStale: () => setSave({ status: "stale" }),
+ });
+ }, [siteId, setState]);
+
+ React.useEffect(() => {
+ const warn = (e: BeforeUnloadEvent) => { if ((queueRef.current?.pending() ?? 0) > 0) { e.preventDefault(); e.returnValue = ""; } };
+ window.addEventListener("beforeunload", warn);
+ return () => window.removeEventListener("beforeunload", warn);
+ }, []);
+
+ const setProgress = React.useCallback((key: string, value: number) => {
+ if (!canEdit || !model) return;
+ if (value > 0 && (key.startsWith("sr:") || key.startsWith("srpipe:"))) {
+ const name = key.startsWith("sr:") ? key.slice(3) : key.slice(7).split(">")[0];
+ const hold = model.services.sewer.manholes.find((m) => m.name === name)?.hold;
+ if (hold && !window.confirm(`HOLD — ${hold}\n\nMark as done anyway?`)) return;
+ }
+ setState((cur) => ({ ...cur, [key]: { value, user_name: "You", created: new Date().toISOString() } }));
+ queueRef.current?.push(key, value);
+ }, [canEdit, model, setState]);
+
+ // ---- layers & scene sync ----
+ const setLayer = React.useCallback((id: LayerId, on: boolean) => setVisible((v) => ({ ...v, [id]: on })), []);
+ React.useEffect(() => { if (handle) for (const id of LAYER_IDS) handle.setLayerVisible(id, visible[id]); }, [handle, visible]);
+ React.useEffect(() => { handle?.setStretch(stretch.levEx, stretch.layEx); }, [handle, stretch]);
+ React.useEffect(() => { handle?.setSelection(selection); }, [handle, selection]);
+ const onTab = (t: string) => {
+ setTab(t as TabId);
+ const rule = TAB_LAYERS[t as TabId];
+ if (rule) setVisible((v) => ({ ...v, ...Object.fromEntries(rule.on.map((id) => [id, true])), ...Object.fromEntries((rule.off ?? []).map((id) => [id, false])) }));
+ };
+ const select = React.useCallback((sel: Selection) => {
+ setSelection(sel);
+ if (sel?.type === "peg") setLayer("pegs", true);
+ }, [setLayer]);
+ const focus = React.useCallback((e: number, n: number, z: number, dist: number) => handle?.focus(e, n, z, dist), [handle]);
+
+ if (data.status === "loading") return Loading the site model…
;
+ if (data.status === "no-model") return No model for {siteName} yet. Ask the office to upload one.
;
+ if (data.status === "error" || !model || !modelId) return (
+
+
Couldn't load the site map: {data.error}
+
+ );
+
+ const panelProps: PanelProps = { model, state, canEdit, isAdmin, siteId, modelId, setProgress, replaceState: setState, select, focus };
+ return (
+
+
+
+ setTip(html ? { html, x, y } : null)} />
+ Built work in its real colour · grey outline = still to build · click anything for details
+
+
+
+
+ setVisible(Object.fromEntries(LAYER_IDS.map((id) => [id, on])) as Record)} />
+
+
+ {tip && (
+ // tooltip HTML is built by the scene from model data (names, numbers), escaped where it holds free text
+
+ )}
+
+
+ );
+}
diff --git a/src/components/site-map/use-site-map-data.ts b/src/components/site-map/use-site-map-data.ts
new file mode 100644
index 0000000..59076cf
--- /dev/null
+++ b/src/components/site-map/use-site-map-data.ts
@@ -0,0 +1,37 @@
+"use client";
+import * as React from "react";
+import type { SiteModelData } from "@/lib/site-map/model-types";
+import type { ProgressState } from "@/lib/site-map/progress";
+
+type Status = "loading" | "ready" | "no-model" | "error";
+export function useSiteMapData(siteId: string) {
+ const [status, setStatus] = React.useState("loading");
+ const [model, setModel] = React.useState();
+ const [modelId, setModelId] = React.useState();
+ const [label, setLabel] = React.useState();
+ const [state, setState] = React.useState({});
+ const [error, setError] = React.useState();
+ const [nonce, setNonce] = React.useState(0);
+ React.useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ setStatus("loading");
+ const meta = await fetch(`/api/sites/${siteId}/model`);
+ if (meta.status === 404) { if (!cancelled) setStatus("no-model"); return; }
+ if (!meta.ok) throw new Error(`Model: HTTP ${meta.status}`);
+ const m = await meta.json() as { id: string; label: string; files: Record };
+ const parts = await Promise.all(Object.entries(m.files).map(async ([part, url]) => {
+ const r = await fetch(url);
+ if (!r.ok) throw new Error(`Could not load ${url.split("/").pop()?.split("?")[0]}.json (HTTP ${r.status})`);
+ return [part, await r.json()] as const;
+ }));
+ const prog = await fetch(`/api/sites/${siteId}/progress`);
+ if (!prog.ok) throw new Error(`Progress: HTTP ${prog.status}`);
+ const p = await prog.json() as { state: ProgressState };
+ if (cancelled) return;
+ setModel(Object.fromEntries(parts) as unknown as SiteModelData); setModelId(m.id); setLabel(m.label); setState(p.state); setStatus("ready");
+ })().catch((e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : String(e)); setStatus("error"); } });
+ return () => { cancelled = true; };
+ }, [siteId, nonce]);
+ return { status, model, modelId, label, state, setState, error, reload: () => setNonce((n) => n + 1) };
+}