feat(site-map): map page with layers panel, navigation pad and saving

This commit is contained in:
DEls 2026-10-14 07:19:54 +02:00
parent f583814776
commit de727e7129
11 changed files with 510 additions and 0 deletions

View file

@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { requirePageUser } from "@/lib/auth/guard";
import { canAccessSite } from "@/lib/auth/access";
import { canEditSiteMap } from "@/lib/site-map/auth";
import { SiteMapView } from "@/components/site-map/site-map-view";
export const dynamic = "force-dynamic";
export default async function SiteMapPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await requirePageUser("sites");
if (!canAccessSite(user, id)) notFound();
let site: { id: string; name: string };
try { site = await (await pbAdmin()).collection("sites").getOne(id, { fields: "id,name" }); } catch { notFound(); }
return (
<>
<Topbar title={`${site.name} · Site map`} subtitle="Progress on the 3D model of the works" />
<SiteMapView siteId={site.id} siteName={site.name} canEdit={canEditSiteMap(user)} isAdmin={user.role === "admin"} />
</>
);
}

View file

@ -0,0 +1,15 @@
import { format } from "date-fns";
import type { ProgressState } from "@/lib/site-map/progress";
export const fmt = (n: number) => (Number.isInteger(n) || Math.abs(n) >= 100 ? Math.round(n).toLocaleString() : n.toFixed(1));
export const hex = (c: number) => `#${c.toString(16).padStart(6, "0")}`;
export const km = (ch: number) => (ch / 1000).toFixed(3);
export const sectionLabel = (from: number, to: number) => `km ${km(from)}–${km(to)}`;
/** "Last change: Johan, 14 Oct 09:12" for an item, or null if it was never changed. */
export function lastChange(state: ProgressState, key: string): string | null {
const e = state[key];
if (!e) return null;
const d = new Date(e.created.replace(" ", "T"));
return `Last change: ${e.user_name || "unknown"}, ${Number.isNaN(d.getTime()) ? e.created : format(d, "d MMM HH:mm")}`;
}

View file

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import type { LayerId } from "../scene/types";
const GROUPS: { title: string; items: { id: LayerId; label: React.ReactNode }[] }[] = [
{ title: "Base", items: [
{ id: "surface", label: "Design surface" }, { id: "contours", label: "Design contours" },
{ id: "ctxMark", label: "Road markings" }, { id: "ctxEdge", label: "Kerb & tar edge lines" },
{ id: "ctxToe", label: "Embankment toe" },
{ id: "ctxReserve", label: <><span className="text-[#c94a3a]">━</span> Road reserve boundary</> },
] },
{ title: "Road", items: [{ id: "road", label: "Road layers & kerbs" }, { id: "pegs", label: "Setting-out pegs" }, { id: "signs", label: "Signs" }, { id: "studs", label: "Studs" }] },
{ title: "Services", items: [{ id: "stormwater", label: "Stormwater" }, { id: "sewer", label: "Sewer" }, { id: "holds", label: "HOLD flags" }, { id: "water", label: "Water" }] },
{ title: "Earthworks", items: [{ id: "natground", label: "Natural ground (from sections)" }, { id: "xsfences", label: "Cut / fill at each section" }] },
{ title: "Analysis", items: [{ id: "rain", label: "Rain & ponding" }] },
];
export function LayersPanel({ visible, onChange, onAll, stretch, onStretch }: {
visible: Record<LayerId, boolean>; 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 (
<div className="absolute right-2.5 top-12 z-10 max-h-[calc(100%-15rem)] w-60 overflow-y-auto overflow-x-hidden rounded-xl border bg-background/95 text-xs shadow-lg">
<div className="sticky top-0 flex items-center justify-between border-b bg-background px-2.5 py-2">
<b className="text-sm">Layers</b>
<span className="flex gap-1">
<Button size="sm" variant="outline" className="h-6 px-2" onClick={() => onAll(true)}>All</Button>
<Button size="sm" variant="outline" className="h-6 px-2" onClick={() => onAll(false)}>None</Button>
<Button size="sm" variant="outline" className="h-6 px-2" onClick={() => setFolded((f) => !f)} aria-label={folded ? "Show layers" : "Hide layers"}>{folded ? "+" : "–"}</Button>
</span>
</div>
{!folded && (
<>
{GROUPS.map((g) => (
<div key={g.title} className="border-b px-2.5 pb-2 pt-1.5">
<div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g.title}</div>
{g.items.map((it) => (
<label key={it.id} className="flex cursor-pointer items-center gap-1.5 py-0.5">
<input type="checkbox" checked={visible[it.id]} onChange={(e) => onChange(it.id, e.target.checked)} /> {it.label}
</label>
))}
</div>
))}
<div className="px-2.5 pb-2 pt-1.5">
<div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground">Height stretch</div>
{([["Levels", "levEx", 1, 5, 0.5], ["Layers", "layEx", 1, 10, 1]] as const).map(([label, key, min, max, step]) => (
<label key={key} className="grid grid-cols-[44px_minmax(0,1fr)_30px] items-center gap-1.5 py-0.5">
<span>{label}</span>
<input type="range" className="w-full min-w-0" min={min} max={max} step={step} value={stretch[key]}
onChange={(e) => onStretch({ ...stretch, [key]: Number(e.target.value) })} />
<b>{stretch[key]}×</b>
</label>
))}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
const K = ({ children }: { children: React.ReactNode }) => (
<kbd className="mx-px inline-block min-w-[15px] rounded border border-b-2 bg-muted px-1 text-center font-sans text-[10px] leading-[14px] text-foreground">{children}</kbd>
);
export function NavHint() {
return (
<div className="pointer-events-none absolute bottom-2.5 right-3 z-10 hidden rounded-md border bg-background/95 px-2 py-1 text-[11px] text-muted-foreground md:block">
Move: <K>←</K><K>↑</K><K>↓</K><K>→</K> or <K>W</K><K>A</K><K>S</K><K>D</K> (hold <K>Shift</K> = faster) · Zoom: <K>+</K><K>−</K> or scroll · Rotate: <K>Q</K><K>E</K> or drag
</div>
);
}

View file

@ -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 (
<div className="absolute bottom-11 right-3 z-10 grid grid-cols-3 gap-1" aria-label="Map navigation">
{PAD.map(([glyph, title, action]) => (
<button key={action} type="button" title={title} aria-label={title}
className="h-9 w-9 touch-none select-none rounded-lg border bg-background/95 text-[15px] shadow-sm active:bg-primary active:text-primary-foreground"
onPointerDown={(e) => {
if (!handle) return;
if (action === "zoomIn") return handle.nav.zoom(-0.45);
if (action === "zoomOut") return handle.nav.zoom(0.45);
if (action === "home") return handle.nav.home();
if (!isDir(action)) return;
e.currentTarget.setPointerCapture(e.pointerId);
handle.nav.hold(action, true);
}}
onPointerUp={() => { if (handle && isDir(action)) handle.nav.hold(action, false); }}
onPointerCancel={() => { if (handle && isDir(action)) handle.nav.hold(action, false); }}
onLostPointerCapture={() => { if (handle && isDir(action)) handle.nav.hold(action, false); }}>
{glyph}
</button>
))}
</div>
);
}

View file

@ -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 (
<dl className="mt-2 grid grid-cols-[110px_1fr] gap-x-2 gap-y-0.5 text-xs">
{rows.map(([k, v]) => <React.Fragment key={k}><dt className="text-muted-foreground">{k}</dt><dd className="break-words">{v}</dd></React.Fragment>)}
</dl>
);
}
function States({ labels, value, disabled, onPick }: { labels: string[]; value: number; disabled: boolean; onPick(v: number): void }) {
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{labels.map((l, i) => <Button key={l} size="sm" variant={value === i ? "default" : "outline"} disabled={disabled} onClick={() => onPick(i)}>{l}</Button>)}
</div>
);
}
const Hold = ({ text }: { text: string }) => <div className="mt-2 rounded-md border border-red-300 bg-red-50 p-2 text-xs text-red-800 dark:bg-red-950/40 dark:text-red-200"><b>HOLD</b> — {text}</div>;
const Warn = ({ text }: { text: string }) => <div className="mt-2 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">{text}</div>;
export function SelectionCard(props: PanelProps & { selection: Selection; extra?(sel: Exclude<Selection, null>): React.ReactNode }) {
const { model, state, canEdit, setProgress, selection: s } = props;
const sf = model.surface;
const Z = React.useMemo(() => decodeSurface(sf), [sf]);
if (!s) return <p className="text-xs text-muted-foreground">Click anything in the 3D view — road, peg, pipe, manhole, cross-section…</p>;
const sZ = (e: number, n: number) => surfZ(sf, Z, e, n);
const last = (key: string) => { const t = lastChange(state, key); return t ? <p className="mt-2 text-[11px] text-muted-foreground">{t}</p> : null; };
if (s.type === "section") {
const sec = model.road.sections.find((x) => x.key === s.section);
return <p className="text-xs">Road section <b>{sec ? sectionLabel(sec.from, sec.to) : s.section}</b> — tick off layers in the section list on the Progress tab.</p>;
}
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 (<div className="text-xs"><b className="text-sm">Setting-out peg {p.name}</b>
<States labels={["Not set out", "Pegged", "Checked"]} value={valueOf(state, key)} disabled={!canEdit} onPick={(v) => setProgress(key, v)} />
<Kv rows={[["Y (Lo)", p.Y.toFixed(3)], ["X (Lo)", p.X.toFixed(3)], ["Design level", `${p.z.toFixed(3)} m`], ["Chainage / offset", `${c.ch.toFixed(1)} m · ${Math.abs(c.left).toFixed(2)} m ${c.left > 0 ? "left" : "right"}`]]} />
{last(key)}</div>);
}
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 (<div className="text-xs"><b className="text-sm">{isSW ? "Stormwater" : "Sewer"} pipe {fromName} → {toName}</b>
{hold && <Hold text={hold} />}{gap && <Warn text={gap} />}
<States labels={PIPE_ST} value={valueOf(state, s.key)} disabled={!canEdit} onPick={(v) => setProgress(s.key, v)} />
<Kv rows={[
["Diameter", `${pipe.dia} mm ${isSW ? ("diaSource" in pipe && pipe.diaSource === "drawing label" ? "(from plan label)" : "(default — not labelled)") : "uPVC"}`],
["Length", `${pipe.length.toFixed(2)} m`], ["Gradient", `${pipe.grade.toFixed(3)}% (1:${pipe.grade > 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)}</div>);
}
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 (<div className="text-xs"><b className="text-sm">{structureKind(x.name)} {x.name}</b>
<States labels={STRUCT_ST} value={valueOf(state, key)} disabled={!canEdit} onPick={(v) => setProgress(key, v)} />
<Kv rows={[["Y / X (Lo)", `${x.Y.toFixed(3)} / ${x.X.toFixed(3)}`], ["Invert level", `${x.il.toFixed(3)} m`],
["Cover level", `${top.toFixed(2)} m ${x.gl ? "(long-section table)" : "(design surface)"}`], ["Depth", `${(top - x.il).toFixed(2)} m`]]} />
{last(key)}</div>);
}
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 (<div className="text-xs"><b className="text-sm">Sewer manhole {m.name}</b>
{m.hold && <Hold text={m.hold} />}
<States labels={STRUCT_ST} value={valueOf(state, key)} disabled={!canEdit} onPick={(v) => setProgress(key, v)} />
<Kv rows={[["Y / X (Lo)", m.Y !== undefined ? `${m.Y.toFixed(3)} / ${m.X!.toFixed(3)}` : "not located"], ["Invert level", `${m.il.toFixed(3)} m`],
["Cover level", `${top.toFixed(2)} m ${groundKnownSR(m, sZ, model.road.pegs) ? "(design surface)" : "(not in data — assumed 1.5 m deep)"}`], ["Depth", `${(top - m.il).toFixed(2)} m`]]} />
{last(key)}</div>);
}
return <>{props.extra?.(s)}</>;
}

View file

@ -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<Selection, null> }) {
void selection;
return null;
}

View file

@ -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;
}

View file

@ -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<HTMLDivElement>(null);
const handle = React.useRef<SiteMapHandle | null>(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 <div className="grid h-full place-items-center p-6 text-sm text-muted-foreground">This device can&apos;t show 3D maps (WebGL is switched off or not supported).</div>;
return <div ref={ref} className="absolute inset-0" />;
}

View file

@ -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<Record<TabId, { on: LayerId[]; off?: LayerId[] }>> = {
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<SiteMapHandle | null>(null);
const [visible, setVisible] = React.useState<Record<LayerId, boolean>>(() => Object.fromEntries(LAYER_IDS.map((id) => [id, START_LAYERS.includes(id)])) as Record<LayerId, boolean>);
const [stretch, setStretch] = React.useState({ levEx: 1.5, layEx: 4 });
const [selection, setSelection] = React.useState<Selection>(null);
const [tab, setTab] = React.useState<TabId>("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<SaveQueue | null>(null);
React.useEffect(() => {
queueRef.current = new SaveQueue({
async send(events): Promise<SendResult> {
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 <div className="grid h-[60vh] place-items-center text-sm text-muted-foreground">Loading the site model…</div>;
if (data.status === "no-model") return <div className="grid h-[60vh] place-items-center p-6 text-center text-sm text-muted-foreground">No model for {siteName} yet. Ask the office to upload one.</div>;
if (data.status === "error" || !model || !modelId) return (
<div className="grid h-[60vh] place-items-center p-6 text-center text-sm">
<div><p className="mb-3 text-destructive">Couldn&apos;t load the site map: {data.error}</p><Button variant="outline" onClick={data.reload}>Reload</Button></div>
</div>
);
const panelProps: PanelProps = { model, state, canEdit, isAdmin, siteId, modelId, setProgress, replaceState: setState, select, focus };
return (
<div className="grid h-[calc(100dvh-var(--topbar-h,56px))] grid-rows-[55%_45%] lg:grid-cols-[400px_1fr] lg:grid-rows-1">
<aside className="order-2 overflow-y-auto border-t bg-background p-3 lg:order-1 lg:border-r lg:border-t-0">
{!canEdit && <p className="mb-2 rounded-md border bg-muted px-2 py-1.5 text-xs text-muted-foreground">View only — site managers and admins update progress.</p>}
{save.status === "error" && (
<div className="mb-2 flex items-center justify-between gap-2 rounded-md border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800 dark:bg-red-950/40 dark:text-red-200">
<span>Couldn&apos;t save — {save.message}</span><Button size="sm" variant="outline" onClick={() => void queueRef.current?.retry()}>Retry</Button>
</div>
)}
{save.status === "stale" && (
<div className="mb-2 flex items-center justify-between gap-2 rounded-md border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<span>This site&apos;s model was updated — reload to continue.</span>
<Button size="sm" variant="outline" onClick={() => { if (window.confirm("Reload the new model? Changes not yet saved will be lost.")) data.reload(); }}>Reload</Button>
</div>
)}
<Tabs value={tab} onValueChange={onTab}>
<TabsList className="mb-2 flex h-auto flex-wrap justify-start">
{TABS.map(([id, label]) => <TabsTrigger key={id} value={id}>{label}</TabsTrigger>)}
</TabsList>
<div className="mb-3 rounded-lg border p-2">
<div className="mb-1 flex items-center justify-between text-[11px] uppercase tracking-wider text-muted-foreground">
<span>Selected</span>
<span className="normal-case tracking-normal">{save.status === "saving" ? "Saving…" : save.status === "saved" ? "Saved" : ""}</span>
</div>
<SelectionCard {...panelProps} selection={selection} extra={(s) => <SelectionExtra {...panelProps} selection={s} />} />
</div>
{TABS.map(([id]) => <TabsContent key={id} value={id}><TabPanel tab={id} {...panelProps} handle={handle} selection={selection} /></TabsContent>)}
</Tabs>
</aside>
<main className="relative order-1 overflow-hidden bg-[#f3f2ee] lg:order-2">
<SiteMapCanvas model={model} state={state} onReady={setHandle} onPick={select}
onHover={(html, x, y) => setTip(html ? { html, x, y } : null)} />
<div className="absolute left-2.5 top-2.5 z-10 rounded-md border bg-background/95 px-2 py-1 text-xs">Built work in its real colour · grey outline = still to build · click anything for details</div>
<div className="absolute right-2.5 top-2.5 z-10 flex gap-1.5">
<Button size="sm" variant="outline" className="bg-background/95" onClick={() => handle?.view("top")}>Top view</Button>
<Button size="sm" variant="outline" className="bg-background/95" onClick={() => handle?.view("3d")}>3D view</Button>
</div>
<LayersPanel visible={visible} onChange={setLayer} stretch={stretch} onStretch={setStretch}
onAll={(on) => setVisible(Object.fromEntries(LAYER_IDS.map((id) => [id, on])) as Record<LayerId, boolean>)} />
<NavPad handle={handle} />
<NavHint />
{tip && (
// tooltip HTML is built by the scene from model data (names, numbers), escaped where it holds free text
<div className="pointer-events-none absolute z-20 whitespace-nowrap rounded-md bg-neutral-900/90 px-2 py-1.5 text-xs text-white"
style={{ left: tip.x + 14, top: tip.y + 14 }} dangerouslySetInnerHTML={{ __html: tip.html }} />
)}
</main>
</div>
);
}

View file

@ -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<Status>("loading");
const [model, setModel] = React.useState<SiteModelData>();
const [modelId, setModelId] = React.useState<string>();
const [label, setLabel] = React.useState<string>();
const [state, setState] = React.useState<ProgressState>({});
const [error, setError] = React.useState<string>();
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<keyof SiteModelData, string> };
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) };
}