From de727e7129a344a54f4da0be5e754a467bb352d8 Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 14 Oct 2026 07:19:54 +0200 Subject: [PATCH] feat(site-map): map page with layers panel, navigation pad and saving --- src/app/dashboard/sites/[id]/map/page.tsx | 23 +++ src/components/site-map/format.ts | 15 ++ .../site-map/overlay/layers-panel.tsx | 61 +++++++ src/components/site-map/overlay/nav-hint.tsx | 11 ++ src/components/site-map/overlay/nav-pad.tsx | 36 ++++ .../site-map/panels/selection-card.tsx | 98 +++++++++++ src/components/site-map/panels/tab-panel.tsx | 18 ++ src/components/site-map/panels/types.ts | 11 ++ src/components/site-map/site-map-canvas.tsx | 45 +++++ src/components/site-map/site-map-view.tsx | 155 ++++++++++++++++++ src/components/site-map/use-site-map-data.ts | 37 +++++ 11 files changed, 510 insertions(+) create mode 100644 src/app/dashboard/sites/[id]/map/page.tsx create mode 100644 src/components/site-map/format.ts create mode 100644 src/components/site-map/overlay/layers-panel.tsx create mode 100644 src/components/site-map/overlay/nav-hint.tsx create mode 100644 src/components/site-map/overlay/nav-pad.tsx create mode 100644 src/components/site-map/panels/selection-card.tsx create mode 100644 src/components/site-map/panels/tab-panel.tsx create mode 100644 src/components/site-map/panels/types.ts create mode 100644 src/components/site-map/site-map-canvas.tsx create mode 100644 src/components/site-map/site-map-view.tsx create mode 100644 src/components/site-map/use-site-map-data.ts diff --git a/src/app/dashboard/sites/[id]/map/page.tsx b/src/app/dashboard/sites/[id]/map/page.tsx new file mode 100644 index 0000000..120dba1 --- /dev/null +++ b/src/app/dashboard/sites/[id]/map/page.tsx @@ -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 ( + <> + + + + ); +} diff --git a/src/components/site-map/format.ts b/src/components/site-map/format.ts new file mode 100644 index 0000000..4eb5676 --- /dev/null +++ b/src/components/site-map/format.ts @@ -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")}`; +} diff --git a/src/components/site-map/overlay/layers-panel.tsx b/src/components/site-map/overlay/layers-panel.tsx new file mode 100644 index 0000000..eca2922 --- /dev/null +++ b/src/components/site-map/overlay/layers-panel.tsx @@ -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: <>━ 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; 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) }; +}