feat(site-map): map page with layers panel, navigation pad and saving
This commit is contained in:
parent
f583814776
commit
de727e7129
11 changed files with 510 additions and 0 deletions
23
src/app/dashboard/sites/[id]/map/page.tsx
Normal file
23
src/app/dashboard/sites/[id]/map/page.tsx
Normal 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"} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
src/components/site-map/format.ts
Normal file
15
src/components/site-map/format.ts
Normal 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")}`;
|
||||
}
|
||||
61
src/components/site-map/overlay/layers-panel.tsx
Normal file
61
src/components/site-map/overlay/layers-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/site-map/overlay/nav-hint.tsx
Normal file
11
src/components/site-map/overlay/nav-hint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/site-map/overlay/nav-pad.tsx
Normal file
36
src/components/site-map/overlay/nav-pad.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/site-map/panels/selection-card.tsx
Normal file
98
src/components/site-map/panels/selection-card.tsx
Normal 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)}</>;
|
||||
}
|
||||
18
src/components/site-map/panels/tab-panel.tsx
Normal file
18
src/components/site-map/panels/tab-panel.tsx
Normal 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;
|
||||
}
|
||||
11
src/components/site-map/panels/types.ts
Normal file
11
src/components/site-map/panels/types.ts
Normal 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;
|
||||
}
|
||||
45
src/components/site-map/site-map-canvas.tsx
Normal file
45
src/components/site-map/site-map-canvas.tsx
Normal 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't show 3D maps (WebGL is switched off or not supported).</div>;
|
||||
return <div ref={ref} className="absolute inset-0" />;
|
||||
}
|
||||
155
src/components/site-map/site-map-view.tsx
Normal file
155
src/components/site-map/site-map-view.tsx
Normal 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'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'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'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>
|
||||
);
|
||||
}
|
||||
37
src/components/site-map/use-site-map-data.ts
Normal file
37
src/components/site-map/use-site-map-data.ts
Normal 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) };
|
||||
}
|
||||
Loading…
Reference in a new issue