Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234) #265
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { Nav } from "@/components/Nav";
|
import { Nav } from "@/components/Nav";
|
||||||
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
||||||
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
||||||
|
import { useKioskFlag } from "@/lib/useKiosk";
|
||||||
|
|
||||||
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
||||||
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
||||||
@@ -36,6 +37,7 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, loading, orgId } = useAuth();
|
const { user, loading, orgId } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const kiosk = useKioskFlag();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -62,7 +64,11 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Live ("/") is full-bleed under its own top bar, not the padded
|
// Live ("/") is full-bleed under its own top bar, not the padded
|
||||||
// max-width container the rest of the app uses.
|
// max-width container the rest of the app uses. In kiosk mode (?kiosk=1 or
|
||||||
|
// #kiosk, server-26#234) the wall gets the whole screen: no nav at all.
|
||||||
|
if (pathname === "/" && user && kiosk) {
|
||||||
|
return <main className="h-screen">{children}</main>;
|
||||||
|
}
|
||||||
if (pathname === "/" && user) {
|
if (pathname === "/" && user) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,8 +7,14 @@
|
|||||||
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
|
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
|
||||||
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
|
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
|
||||||
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
|
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
|
||||||
|
*
|
||||||
|
* Saved views (server-26#234): overlays, basemap and viewport live in the
|
||||||
|
* URL hash too (lib/liveWall.ts wallHash), so a reload or a copied link comes
|
||||||
|
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
||||||
|
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
||||||
|
* wall-display mode (lib/useKiosk.ts).
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||||
@@ -21,8 +27,12 @@ import { severityRank } from "@/lib/severity";
|
|||||||
import type { Camera, Ny511Response } from "@/lib/types";
|
import type { Camera, Ny511Response } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
||||||
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||||
} from "@/lib/liveWall";
|
} from "@/lib/liveWall";
|
||||||
|
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
||||||
|
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
||||||
|
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
||||||
|
import type { MapViewState } from "@/components/MapView";
|
||||||
import { TimeScrubber } from "@/components/TimeScrubber";
|
import { TimeScrubber } from "@/components/TimeScrubber";
|
||||||
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
||||||
import { CameraDrill } from "@/components/live/CameraDrill";
|
import { CameraDrill } from "@/components/live/CameraDrill";
|
||||||
@@ -33,6 +43,17 @@ import { usePipelineHealth } from "@/lib/usePipelineHealth";
|
|||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TOUR_MS = 20_000;
|
const TOUR_MS = 20_000;
|
||||||
|
// 10-foot type for kiosk walls (server-26#234): scales every small text class
|
||||||
|
// under the wall root — including MapView's rail and WallPanels, which are not
|
||||||
|
// edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them.
|
||||||
|
const KIOSK_TYPE = [
|
||||||
|
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
|
||||||
|
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
|
||||||
|
"[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_.w-56]:w-80",
|
||||||
|
].join(" ");
|
||||||
const PIN_KEY = "drb.wall.pinnedCameras";
|
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||||
const MAX_PINNED = 6;
|
const MAX_PINNED = 6;
|
||||||
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
||||||
@@ -92,7 +113,7 @@ export function LiveView() {
|
|||||||
const activeIncidents = useActiveIncidents();
|
const activeIncidents = useActiveIncidents();
|
||||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500);
|
||||||
const { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
const { vessels } = useVessels();
|
||||||
const [clock, setClock] = useState<string>("");
|
const [clock, setClock] = useState<string>("");
|
||||||
@@ -108,19 +129,78 @@ export function LiveView() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
|
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
|
||||||
|
// `link` = where the wall is (incident/camera/tour/kiosk/filters);
|
||||||
|
// `mapState` = what the map looks like (layers/base/viewport). Both are
|
||||||
|
// written back to the hash by one effect, so the address bar is always a
|
||||||
|
// working "this exact view" link. null until the hash has been read, so
|
||||||
|
// MapView never mounts with the wrong initial view.
|
||||||
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
|
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
|
||||||
|
const [mapState, setMapState] = useState<MapViewState | null>(null);
|
||||||
|
const mapStateRef = useRef<MapViewState | null>(null);
|
||||||
|
mapStateRef.current = mapState;
|
||||||
|
/** Bumped when a hash edit changes layers/base/view: MapView remounts with them. */
|
||||||
|
const [mapGen, setMapGen] = useState(0);
|
||||||
|
const [kioskQuery, setKioskQuery] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const read = () => setLinkState(parseWallHash(window.location.hash));
|
const read = (first: boolean) => {
|
||||||
read();
|
const parsed = parseWallHash(window.location.hash);
|
||||||
window.addEventListener("hashchange", read);
|
const { layers, base, view, ...nav } = parsed;
|
||||||
return () => window.removeEventListener("hashchange", read);
|
setLinkState(nav);
|
||||||
|
const cur = mapStateRef.current;
|
||||||
|
const next = resolveWallView({ layers, base, view }, first || !cur ? loadWallPrefs() : cur);
|
||||||
|
if (first || !cur) setMapState(next);
|
||||||
|
else if (!sameView(cur, next)) { setMapState(next); setMapGen((g) => g + 1); }
|
||||||
|
};
|
||||||
|
setKioskQuery(kioskInQuery());
|
||||||
|
read(true);
|
||||||
|
const onHash = () => read(false);
|
||||||
|
window.addEventListener("hashchange", onHash);
|
||||||
|
return () => window.removeEventListener("hashchange", onHash);
|
||||||
}, []);
|
}, []);
|
||||||
const setLink = useCallback((next: WallLink) => {
|
useEffect(() => {
|
||||||
setLinkState(next);
|
if (!mapState) return;
|
||||||
const h = wallHash(next);
|
const h = wallHash({ ...link, ...mapState });
|
||||||
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
|
if (h !== window.location.hash) {
|
||||||
|
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search + h);
|
||||||
|
}
|
||||||
|
announceKioskChange();
|
||||||
|
}, [link, mapState]);
|
||||||
|
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
||||||
|
const onViewStateChange = useCallback((s: MapViewState) => {
|
||||||
|
setMapState((cur) => {
|
||||||
|
// Keep a known viewport if the map hasn't reported one yet.
|
||||||
|
const next = { ...s, view: s.view ?? cur?.view };
|
||||||
|
saveWallPrefs(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
|
||||||
|
const copyViewLink = useCallback(() => {
|
||||||
|
const done = (r: "ok" | "fail") => { setCopied(r); setTimeout(() => setCopied(null), 2000); };
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => done("ok"), () => done("fail"));
|
||||||
|
} catch {
|
||||||
|
done("fail");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Kiosk ─────────────────────────────────────────────────────────────────
|
||||||
|
const kiosk = kioskQuery || !!link.kiosk;
|
||||||
|
const kioskState = useKiosk(kiosk, callsError);
|
||||||
|
const toggleKiosk = useCallback(() => {
|
||||||
|
if (kiosk) {
|
||||||
|
stripKioskQuery();
|
||||||
|
setKioskQuery(false);
|
||||||
|
patchLink({ kiosk: false });
|
||||||
|
} else {
|
||||||
|
patchLink({ kiosk: true });
|
||||||
|
// This click is the user gesture fullscreen needs.
|
||||||
|
if (!kioskState.fullscreen) kioskState.toggleFullscreen();
|
||||||
|
}
|
||||||
|
}, [kiosk, kioskState, patchLink]);
|
||||||
|
const tourMs = link.tourMs ?? TOUR_MS;
|
||||||
|
|
||||||
const [pinned, setPinned] = useState<Camera[]>([]);
|
const [pinned, setPinned] = useState<Camera[]>([]);
|
||||||
useEffect(() => { setPinned(loadPinned()); }, []);
|
useEffect(() => { setPinned(loadPinned()); }, []);
|
||||||
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
||||||
@@ -185,13 +265,13 @@ export function LiveView() {
|
|||||||
}, [servingPinned, sorted, nearById]);
|
}, [servingPinned, sorted, nearById]);
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
|
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
|
||||||
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
|
const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]);
|
||||||
const stepUp = useCallback(() => {
|
const stepUp = useCallback(() => {
|
||||||
if (link.cam) setLink({ ...link, cam: null, tour: false });
|
if (link.cam) patchLink({ cam: null, tour: false });
|
||||||
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
|
else if (link.inc) patchLink({ inc: null, cam: null, tour: false });
|
||||||
else if (link.tour) setLink({ ...link, tour: false });
|
else if (link.tour) patchLink({ tour: false });
|
||||||
}, [link, setLink]);
|
}, [link, patchLink]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -211,20 +291,18 @@ export function LiveView() {
|
|||||||
const next = () => {
|
const next = () => {
|
||||||
setLinkState((cur) => {
|
setLinkState((cur) => {
|
||||||
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
|
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
|
||||||
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
|
return { ...cur, inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
|
||||||
window.history.replaceState(null, "", wallHash(nxt));
|
|
||||||
return nxt;
|
|
||||||
});
|
});
|
||||||
setTourStart(Date.now());
|
setTourStart(Date.now());
|
||||||
};
|
};
|
||||||
if (!link.inc || !tourList.includes(link.inc)) next();
|
if (!link.inc || !tourList.includes(link.inc)) next();
|
||||||
else setTourStart(Date.now());
|
else setTourStart(Date.now());
|
||||||
const id = setInterval(next, TOUR_MS);
|
const id = setInterval(next, tourMs);
|
||||||
const prog = setInterval(() => setTourNow(Date.now()), 500);
|
const prog = setInterval(() => setTourNow(Date.now()), 500);
|
||||||
return () => { clearInterval(id); clearInterval(prog); };
|
return () => { clearInterval(id); clearInterval(prog); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [link.tour, tourList.join(",")]);
|
}, [link.tour, tourList.join(","), tourMs]);
|
||||||
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
|
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
|
||||||
|
|
||||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
||||||
@@ -245,14 +323,14 @@ export function LiveView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div data-kiosk={kiosk ? "" : undefined} className={`flex flex-col h-full ${KIOSK_TYPE}`}>
|
||||||
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
|
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
|
||||||
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
||||||
<nav aria-label="Drill level" className="flex items-center gap-0.5">
|
<nav aria-label="Drill level" className="flex items-center gap-0.5">
|
||||||
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
|
{crumb("Region", !link.inc, true, () => patchLink({ inc: null, cam: null, tour: false }))}
|
||||||
<span className="text-ink-muted">›</span>
|
<span className="text-ink-muted">›</span>
|
||||||
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
|
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
|
||||||
() => setLink({ ...link, cam: null, tour: false }))}
|
() => patchLink({ cam: null, tour: false }))}
|
||||||
<span className="text-ink-muted">›</span>
|
<span className="text-ink-muted">›</span>
|
||||||
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
|
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -268,7 +346,7 @@ export function LiveView() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<TruthStrip health={health} placement="chips" />
|
<TruthStrip health={health} placement="chips" />
|
||||||
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
<button type="button" aria-pressed={link.tour} onClick={() => patchLink({ cam: null, tour: !link.tour })}
|
||||||
title="Wall mode: cycle through active incidents, worst first"
|
title="Wall mode: cycle through active incidents, worst first"
|
||||||
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||||
Auto-tour
|
Auto-tour
|
||||||
@@ -278,9 +356,15 @@ export function LiveView() {
|
|||||||
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
|
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" onClick={copyViewLink} title="Copy a link to this exact view: layers, map position, selection"
|
||||||
|
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
|
||||||
|
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
|
||||||
|
</button>
|
||||||
|
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
|
||||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
||||||
<TruthStrip health={health} placement="bar" />
|
<TruthStrip health={health} placement="bar" />
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
||||||
@@ -288,7 +372,7 @@ export function LiveView() {
|
|||||||
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||||
MapView, so it is wrapped here rather than edited). */}
|
MapView, so it is wrapped here rather than edited). */}
|
||||||
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
||||||
{nodesLoading || callsLoading ? (
|
{nodesLoading || callsLoading || !mapState ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
<MapView
|
<MapView
|
||||||
@@ -302,6 +386,11 @@ export function LiveView() {
|
|||||||
onOpenCamera={openCamera}
|
onOpenCamera={openCamera}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
camCountByIncident={camCountByIncident}
|
camCountByIncident={camCountByIncident}
|
||||||
|
key={mapGen}
|
||||||
|
initialLayers={mapState.layers}
|
||||||
|
initialBase={mapState.base}
|
||||||
|
initialView={mapState.view}
|
||||||
|
onViewStateChange={onViewStateChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +406,7 @@ export function LiveView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
|
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
|
||||||
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
|
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<IncidentPanel
|
<IncidentPanel
|
||||||
@@ -365,7 +454,7 @@ export function LiveView() {
|
|||||||
incidentTitle={selected?.title}
|
incidentTitle={selected?.title}
|
||||||
pinned={pinnedIds.has(drillCam.id)}
|
pinned={pinnedIds.has(drillCam.id)}
|
||||||
onTogglePin={() => togglePin(drillCam)}
|
onTogglePin={() => togglePin(drillCam)}
|
||||||
onClose={() => setLink({ ...link, cam: null })}
|
onClose={() => patchLink({ cam: null })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
@@ -28,7 +28,11 @@ import { useMapCameras } from "@/lib/useCameras";
|
|||||||
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
|
import {
|
||||||
|
BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident,
|
||||||
|
incidentPoints, layerKeyForName, normaliseLayers, roundViewport, viewshedWedge, worstFirst,
|
||||||
|
type BaseKey, type LayerKey, type MapViewport,
|
||||||
|
} from "@/lib/liveWall";
|
||||||
import { PlayButton } from "@/components/InlinePlayer";
|
import { PlayButton } from "@/components/InlinePlayer";
|
||||||
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
||||||
|
|
||||||
@@ -346,13 +350,22 @@ function RadarLayer() {
|
|||||||
|
|
||||||
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||||||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam;
|
||||||
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic;
|
||||||
const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)";
|
const OVERLAY_VIEWSHEDS = LAYER_NAMES.vs;
|
||||||
|
|
||||||
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
/**
|
||||||
|
* Overlay names checked when the map mounts (server-26#234 saved views).
|
||||||
|
* Needed because react-leaflet adds a `checked` overlay to the map BEFORE it
|
||||||
|
* registers it with the LayersControl, so no `overlayadd` fires for it — a
|
||||||
|
* layer that waited for that event would sit empty until toggled.
|
||||||
|
*/
|
||||||
|
const InitialOverlays = createContext<ReadonlySet<string>>(new Set(DEFAULT_LAYERS.map((k) => LAYER_NAMES[k])));
|
||||||
|
|
||||||
|
/** True while the named LayersControl overlay is checked (initially: whether it mounted checked). */
|
||||||
function useOverlayShown(map: L.Map, name: string): boolean {
|
function useOverlayShown(map: L.Map, name: string): boolean {
|
||||||
const [shown, setShown] = useState(false);
|
const initial = useContext(InitialOverlays);
|
||||||
|
const [shown, setShown] = useState(() => initial.has(name));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
||||||
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
||||||
@@ -1120,23 +1133,51 @@ interface Props {
|
|||||||
onAir?: Set<string>;
|
onAir?: Set<string>;
|
||||||
/** Cameras within range of each incident (rail "N cams"). */
|
/** Cameras within range of each incident (rail "N cams"). */
|
||||||
camCountByIncident?: Map<string, number>;
|
camCountByIncident?: Map<string, number>;
|
||||||
|
// ── Saved views (server-26#234). Read once at mount — remount (key) to apply new ones. ──
|
||||||
|
/** Overlays switched on at mount; default = Nodes, Active Incidents, Incident Paths. */
|
||||||
|
initialLayers?: LayerKey[];
|
||||||
|
initialBase?: BaseKey;
|
||||||
|
/** Viewport at mount; default = centred on the first node (or first incident). */
|
||||||
|
initialView?: MapViewport;
|
||||||
|
/**
|
||||||
|
* Fired (debounced) when overlays, basemap or viewport change. `view` is the
|
||||||
|
* REGION view: while an incident is selected the map is fitted to it, so the
|
||||||
|
* view last seen with nothing selected is reported instead.
|
||||||
|
*/
|
||||||
|
onViewStateChange?: (state: MapViewState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
|
||||||
|
|
||||||
|
const VIEW_STATE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
function currentViewport(map: L.Map): MapViewport {
|
||||||
|
const c = map.getCenter();
|
||||||
|
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
||||||
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
||||||
|
initialLayers, initialBase, initialView, onViewStateChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
const [aircraftShown, setAircraftShown] = useState(false);
|
// Initial props are frozen at mount, like MapContainer's own center/zoom.
|
||||||
|
const [startLayers] = useState(() => new Set<LayerKey>(normaliseLayers(initialLayers ?? DEFAULT_LAYERS)));
|
||||||
|
const [startBase] = useState<BaseKey>(() => initialBase ?? DEFAULT_BASE);
|
||||||
|
const [startView] = useState(() => initialView);
|
||||||
|
const startOverlayNames = useMemo(() => new Set(Array.from(startLayers).map((k) => LAYER_NAMES[k])), [startLayers]);
|
||||||
|
const layerOn = (k: LayerKey) => startLayers.has(k);
|
||||||
|
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
||||||
|
|
||||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||||
// overlay is actually on (server-26#185).
|
// overlay is actually on (server-26#185).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstance) return;
|
if (!mapInstance) return;
|
||||||
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
|
const on = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(true);
|
||||||
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
|
const off = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(false);
|
||||||
mapInstance.on("overlayadd", on);
|
mapInstance.on("overlayadd", on);
|
||||||
mapInstance.on("overlayremove", off);
|
mapInstance.on("overlayremove", off);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1171,14 +1212,15 @@ export default function MapView({
|
|||||||
[nodes, incidents]
|
[nodes, incidents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const center: [number, number] =
|
const center: [number, number] = startView
|
||||||
nodes.length > 0
|
? [startView.lat, startView.lon]
|
||||||
|
: nodes.length > 0
|
||||||
? [nodes[0].lat, nodes[0].lon]
|
? [nodes[0].lat, nodes[0].lon]
|
||||||
: allPositions.length > 0
|
: allPositions.length > 0
|
||||||
? allPositions[0]
|
? allPositions[0]
|
||||||
: [39.5, -98.35];
|
: [39.5, -98.35];
|
||||||
|
|
||||||
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
const zoom = startView ? startView.z : nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
||||||
|
|
||||||
const handleFitAll = useCallback(() => {
|
const handleFitAll = useCallback(() => {
|
||||||
if (!mapInstance || allPositions.length === 0) return;
|
if (!mapInstance || allPositions.length === 0) return;
|
||||||
@@ -1198,6 +1240,63 @@ export default function MapView({
|
|||||||
[mapInstance, onSelectIncident]
|
[mapInstance, onSelectIncident]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Region view + saved-view reporting (server-26#234) ────────────────────
|
||||||
|
// `regionView` is where the map was the last time nothing was selected. It
|
||||||
|
// is what gets persisted (a reload shouldn't come back zoomed into one
|
||||||
|
// incident) and where closing an incident flies back to (server-26#137).
|
||||||
|
// Declared before the fit effect below so it captures the view before the fly.
|
||||||
|
const regionView = useRef<MapViewport | null>(null);
|
||||||
|
const selectedRef = useRef(selectedIncidentId);
|
||||||
|
selectedRef.current = selectedIncidentId;
|
||||||
|
const onViewStateRef = useRef(onViewStateChange);
|
||||||
|
onViewStateRef.current = onViewStateChange;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapInstance) return;
|
||||||
|
regionView.current = currentViewport(mapInstance);
|
||||||
|
const layers = new Set(startLayers);
|
||||||
|
let base = startBase;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const emit = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
onViewStateRef.current?.({ layers: normaliseLayers(Array.from(layers)), base, view: regionView.current ?? undefined });
|
||||||
|
}, VIEW_STATE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
const onAdd = (e: L.LayersControlEvent) => { const k = layerKeyForName(e.name); if (k) { layers.add(k); emit(); } };
|
||||||
|
const onRemove = (e: L.LayersControlEvent) => { const k = layerKeyForName(e.name); if (k) { layers.delete(k); emit(); } };
|
||||||
|
const onBase = (e: L.LayersControlEvent) => { base = baseKeyForName(e.name) ?? base; emit(); };
|
||||||
|
const onMove = () => {
|
||||||
|
if (selectedRef.current) return;
|
||||||
|
regionView.current = currentViewport(mapInstance);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
mapInstance.on("overlayadd", onAdd);
|
||||||
|
mapInstance.on("overlayremove", onRemove);
|
||||||
|
mapInstance.on("baselayerchange", onBase);
|
||||||
|
mapInstance.on("moveend", onMove);
|
||||||
|
// Report the starting state once, so the URL carries the viewport even if
|
||||||
|
// the viewer never pans: a copied link must not fall back to the other
|
||||||
|
// browser's remembered view.
|
||||||
|
emit();
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
mapInstance.off("overlayadd", onAdd);
|
||||||
|
mapInstance.off("overlayremove", onRemove);
|
||||||
|
mapInstance.off("baselayerchange", onBase);
|
||||||
|
mapInstance.off("moveend", onMove);
|
||||||
|
};
|
||||||
|
}, [mapInstance, startLayers, startBase]);
|
||||||
|
|
||||||
|
// server-26#137: closing an incident flies back to where the viewer was.
|
||||||
|
const prevSelected = useRef(selectedIncidentId);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevSelected.current;
|
||||||
|
prevSelected.current = selectedIncidentId;
|
||||||
|
if (!mapInstance || !prev || selectedIncidentId || !regionView.current) return;
|
||||||
|
const v = regionView.current;
|
||||||
|
mapInstance.flyTo([v.lat, v.lon], v.z, { duration: 1.0 });
|
||||||
|
}, [mapInstance, selectedIncidentId]);
|
||||||
|
|
||||||
// Wall drill-down: fit the selected incident's whole path (or its pin).
|
// Wall drill-down: fit the selected incident's whole path (or its pin).
|
||||||
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
|
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1220,21 +1319,22 @@ export default function MapView({
|
|||||||
>
|
>
|
||||||
<MapRefCapture onReady={onMapReady} />
|
<MapRefCapture onReady={onMapReady} />
|
||||||
|
|
||||||
|
<InitialOverlays.Provider value={startOverlayNames}>
|
||||||
<LayersControl position="topright">
|
<LayersControl position="topright">
|
||||||
{/* Base layers */}
|
{/* Base layers */}
|
||||||
<LayersControl.BaseLayer checked name="Dark">
|
<LayersControl.BaseLayer checked={startBase === "dark"} name={BASE_NAMES.dark}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url={MAP_TILE_URL}
|
url={MAP_TILE_URL}
|
||||||
attribution={MAP_TILE_ATTRIBUTION}
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Light">
|
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution={MAP_TILE_ATTRIBUTION}
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Streets">
|
<LayersControl.BaseLayer checked={startBase === "streets"} name={BASE_NAMES.streets}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
@@ -1242,62 +1342,63 @@ export default function MapView({
|
|||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
|
|
||||||
{/* Overlay: Nodes */}
|
{/* Overlay: Nodes */}
|
||||||
<LayersControl.Overlay checked name="Nodes">
|
<LayersControl.Overlay checked={layerOn("nodes")} name={LAYER_NAMES.nodes}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Active Incidents */}
|
{/* Overlay: Active Incidents */}
|
||||||
<LayersControl.Overlay checked name="Active Incidents">
|
<LayersControl.Overlay checked={layerOn("inc")} name={LAYER_NAMES.inc}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
|
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Incident paths — the flagship feature */}
|
{/* Overlay: Incident paths — the flagship feature */}
|
||||||
<LayersControl.Overlay checked name="Incident Paths">
|
<LayersControl.Overlay checked={layerOn("paths")} name={LAYER_NAMES.paths}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
|
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
|
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
|
||||||
<LayersControl.Overlay name="Aircraft">
|
<LayersControl.Overlay checked={layerOn("air")} name={LAYER_NAMES.air}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<AircraftLayer />
|
<AircraftLayer />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
|
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
|
||||||
<LayersControl.Overlay name="Vessels">
|
<LayersControl.Overlay checked={layerOn("ais")} name={LAYER_NAMES.ais}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<VesselLayer />
|
<VesselLayer />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||||
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
|
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<CameraViewshedLayer highlightIds={highlightIds} />
|
<CameraViewshedLayer highlightIds={highlightIds} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
<LayersControl.Overlay checked={layerOn("traffic")} name={OVERLAY_TRAFFIC_EVENTS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<TrafficEventLayer />
|
<TrafficEventLayer />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
||||||
<LayersControl.Overlay name="Weather Radar">
|
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
||||||
<RadarLayer />
|
<RadarLayer />
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
|
</InitialOverlays.Provider>
|
||||||
|
|
||||||
{selectedIncidentId && (
|
{selectedIncidentId && (
|
||||||
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
|
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live wall kiosk controls + reconnect banner (server-26#234). State lives in
|
||||||
|
* lib/useKiosk.ts; this file is presentation only.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { KioskBanner as Banner, KioskState } from "@/lib/useKiosk";
|
||||||
|
|
||||||
|
const btn = "px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap";
|
||||||
|
|
||||||
|
export function KioskControls({ kiosk, state, onToggleKiosk }: {
|
||||||
|
kiosk: boolean;
|
||||||
|
state: KioskState;
|
||||||
|
onToggleKiosk: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.canFullscreen && (
|
||||||
|
<button type="button" onClick={state.toggleFullscreen} aria-pressed={state.fullscreen} className={btn}
|
||||||
|
title={state.fullscreen ? "Leave full screen" : "Full screen"}>
|
||||||
|
{state.fullscreen ? "Exit full screen" : "Full screen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{kiosk && state.wake === "on" && (
|
||||||
|
<span className="text-ink-muted whitespace-nowrap" title="Screen Wake Lock held — this display will not sleep">Awake</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onToggleKiosk} aria-pressed={kiosk} className={btn}
|
||||||
|
title={kiosk ? "Back to the normal app layout" : "Wall display: no nav bar, large type, screen kept awake"}>
|
||||||
|
{kiosk ? "Exit kiosk" : "Kiosk"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KioskBanner({ banner, onRetry }: { banner: Banner | null; onRetry: () => void }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!banner?.retryAt) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [banner?.retryAt]);
|
||||||
|
if (!banner) return null;
|
||||||
|
const secs = banner.retryAt ? Math.max(0, Math.ceil((banner.retryAt - now) / 1000)) : null;
|
||||||
|
return (
|
||||||
|
<div role="alert" className="shrink-0 flex items-center gap-3 px-3 py-1.5 border-b border-line bg-raised text-xs text-ink">
|
||||||
|
<span className="font-semibold">{banner.tone === "offline" ? "Offline" : "Reconnecting"}</span>
|
||||||
|
<span className="text-ink-2">{banner.message}{secs != null ? ` Retrying in ${secs}s.` : ""}</span>
|
||||||
|
{banner.tone === "error" && (
|
||||||
|
<button type="button" onClick={onRetry} className={`${btn} ml-auto`}>Retry now</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -152,22 +152,144 @@ export function bboxAround(points: LatLon[], padKm: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Deep links: #inc=<id>&cam=<cameraId>&tour ───────────────────────────────
|
// ── Deep links / saved views ────────────────────────────────────────────────
|
||||||
export interface WallLink { inc: string | null; cam: string | null; tour: boolean }
|
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
|
||||||
|
// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light
|
||||||
|
// &f.<name>=<value>
|
||||||
|
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
|
||||||
|
// only) still parses and a newer one degrades on an older build. Pure string
|
||||||
|
// in/out — no window access — so it can be unit-tested once a JS runner exists.
|
||||||
|
|
||||||
|
/** Map overlays, by short stable hash key → the LayersControl overlay name. */
|
||||||
|
export const LAYER_NAMES = {
|
||||||
|
nodes: "Nodes",
|
||||||
|
inc: "Active Incidents",
|
||||||
|
paths: "Incident Paths",
|
||||||
|
air: "Aircraft",
|
||||||
|
ais: "Vessels",
|
||||||
|
dotcam: "DOT Cameras",
|
||||||
|
vs: "Camera Viewsheds (approx.)",
|
||||||
|
traffic: "Traffic Events",
|
||||||
|
radar: "Weather Radar",
|
||||||
|
} as const;
|
||||||
|
export type LayerKey = keyof typeof LAYER_NAMES;
|
||||||
|
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
|
||||||
|
/** What the map shows with no hash and nothing remembered (unchanged from before #234). */
|
||||||
|
export const DEFAULT_LAYERS: LayerKey[] = ["nodes", "inc", "paths"];
|
||||||
|
|
||||||
|
export const BASE_NAMES = { dark: "Dark", light: "Light", streets: "Streets" } as const;
|
||||||
|
export type BaseKey = keyof typeof BASE_NAMES;
|
||||||
|
export const DEFAULT_BASE: BaseKey = "dark";
|
||||||
|
|
||||||
|
export function isLayerKey(k: string): k is LayerKey {
|
||||||
|
return Object.prototype.hasOwnProperty.call(LAYER_NAMES, k);
|
||||||
|
}
|
||||||
|
export function isBaseKey(k: string): k is BaseKey {
|
||||||
|
return Object.prototype.hasOwnProperty.call(BASE_NAMES, k);
|
||||||
|
}
|
||||||
|
export function layerKeyForName(name: string): LayerKey | null {
|
||||||
|
return LAYER_KEYS.find((k) => LAYER_NAMES[k] === name) ?? null;
|
||||||
|
}
|
||||||
|
export function baseKeyForName(name: string): BaseKey | null {
|
||||||
|
return (Object.keys(BASE_NAMES) as BaseKey[]).find((k) => BASE_NAMES[k] === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapViewport { z: number; lat: number; lon: number }
|
||||||
|
|
||||||
|
/** What the map looks like — the part a viewer's browser also remembers (lib/wallPrefs.ts). */
|
||||||
|
export interface WallView {
|
||||||
|
/** Overlays switched on, in LAYER_KEYS order. [] = all off; absent = not specified. */
|
||||||
|
layers?: LayerKey[];
|
||||||
|
view?: MapViewport;
|
||||||
|
base?: BaseKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WallLink extends WallView {
|
||||||
|
inc: string | null;
|
||||||
|
cam: string | null;
|
||||||
|
tour: boolean;
|
||||||
|
/** Auto-tour dwell per incident; absent = the default. */
|
||||||
|
tourMs?: number;
|
||||||
|
/** Wall/kiosk presentation (no nav, large type, wake lock). */
|
||||||
|
kiosk?: boolean;
|
||||||
|
/**
|
||||||
|
* Extension point for the filter bar (server-26#235): every `f.<name>=<v>`
|
||||||
|
* hash key round-trips here untouched, so filters can be added without
|
||||||
|
* reshaping WallLink or breaking older links.
|
||||||
|
*/
|
||||||
|
filter?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TOUR_MS_MIN = 5_000;
|
||||||
|
export const TOUR_MS_MAX = 10 * 60_000;
|
||||||
|
|
||||||
|
function num(v: string | null): number | null {
|
||||||
|
if (v == null || v.trim() === "") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalises a layer list: known keys only, de-duplicated, in LAYER_KEYS order. */
|
||||||
|
export function normaliseLayers(keys: readonly string[]): LayerKey[] {
|
||||||
|
const set = new Set(keys);
|
||||||
|
return LAYER_KEYS.filter((k) => set.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseViewport(z: unknown, lat: unknown, lon: unknown): MapViewport | undefined {
|
||||||
|
if (typeof z !== "number" || typeof lat !== "number" || typeof lon !== "number") return undefined;
|
||||||
|
if (!Number.isFinite(z) || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
|
||||||
|
if (z < 0 || z > 22 || lat < -90 || lat > 90 || lon < -180 || lon > 180) return undefined;
|
||||||
|
return { z, lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rounds a viewport for the URL: ~1 m position, 1/100 zoom. */
|
||||||
|
export function roundViewport(v: MapViewport): MapViewport {
|
||||||
|
return { z: Math.round(v.z * 100) / 100, lat: Math.round(v.lat * 1e5) / 1e5, lon: Math.round(v.lon * 1e5) / 1e5 };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseWallHash(hash: string): WallLink {
|
export function parseWallHash(hash: string): WallLink {
|
||||||
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
return { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
|
const link: WallLink = { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
|
||||||
|
const tourMs = num(p.get("tourMs"));
|
||||||
|
if (tourMs != null) link.tourMs = Math.min(TOUR_MS_MAX, Math.max(TOUR_MS_MIN, Math.round(tourMs)));
|
||||||
|
if (p.has("kiosk") && p.get("kiosk") !== "0") link.kiosk = true;
|
||||||
|
if (p.has("layers")) link.layers = normaliseLayers((p.get("layers") ?? "").split(","));
|
||||||
|
const view = parseViewport(num(p.get("z")), num(p.get("lat")), num(p.get("lon")));
|
||||||
|
if (view) link.view = view;
|
||||||
|
const base = p.get("base");
|
||||||
|
if (base && isBaseKey(base)) link.base = base;
|
||||||
|
const filter: Record<string, string> = {};
|
||||||
|
p.forEach((v, k) => { if (k.startsWith("f.") && k.length > 2) filter[k.slice(2)] = v; });
|
||||||
|
if (Object.keys(filter).length) link.filter = filter;
|
||||||
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wallHash(link: WallLink): string {
|
export function wallHash(link: WallLink): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (link.inc) parts.push(`inc=${encodeURIComponent(link.inc)}`);
|
const enc = encodeURIComponent;
|
||||||
if (link.cam) parts.push(`cam=${encodeURIComponent(link.cam)}`);
|
if (link.inc) parts.push(`inc=${enc(link.inc)}`);
|
||||||
|
if (link.cam) parts.push(`cam=${enc(link.cam)}`);
|
||||||
if (link.tour) parts.push("tour");
|
if (link.tour) parts.push("tour");
|
||||||
|
if (link.tourMs != null) parts.push(`tourMs=${Math.round(link.tourMs)}`);
|
||||||
|
if (link.kiosk) parts.push("kiosk");
|
||||||
|
// Commas stay literal (URLSearchParams reads them back fine) so the link is readable.
|
||||||
|
if (link.layers) parts.push(`layers=${normaliseLayers(link.layers).join(",")}`);
|
||||||
|
if (link.view) {
|
||||||
|
const v = roundViewport(link.view);
|
||||||
|
parts.push(`z=${v.z}`, `lat=${v.lat}`, `lon=${v.lon}`);
|
||||||
|
}
|
||||||
|
if (link.base) parts.push(`base=${link.base}`);
|
||||||
|
if (link.filter) {
|
||||||
|
for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k])}`);
|
||||||
|
}
|
||||||
return parts.length ? `#${parts.join("&")}` : "";
|
return parts.length ? `#${parts.join("&")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when two views would serialise identically (for "did this actually change"). */
|
||||||
|
export function sameView(a: WallView, b: WallView): boolean {
|
||||||
|
return wallHash({ inc: null, cam: null, tour: false, ...a }) === wallHash({ inc: null, cam: null, tour: false, ...b });
|
||||||
|
}
|
||||||
|
|
||||||
/** "On air": a live call is on one of the incident's talkgroups right now. */
|
/** "On air": a live call is on one of the incident's talkgroups right now. */
|
||||||
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
|
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
|
||||||
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));
|
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiosk / wall-display support for the live wall (server-26#234).
|
||||||
|
*
|
||||||
|
* Kiosk is URL-driven only — `?kiosk=1` or `#…&kiosk` — never remembered in
|
||||||
|
* storage, so a normal viewer can't get stuck without the nav. A wall PC's
|
||||||
|
* startup URL carries it, which is what makes it survive a browser restart.
|
||||||
|
*
|
||||||
|
* Everything here degrades silently: no Fullscreen API, no Screen Wake Lock,
|
||||||
|
* blocked sessionStorage — the wall still renders and still updates.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/** Fired on window whenever kiosk is switched in-page (replaceState fires no hashchange). */
|
||||||
|
export const KIOSK_EVENT = "drb:kiosk-change";
|
||||||
|
|
||||||
|
function queryKiosk(search: string): boolean {
|
||||||
|
const v = new URLSearchParams(search).get("kiosk");
|
||||||
|
return v != null && v !== "0" && v !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashKiosk(hash: string): boolean {
|
||||||
|
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
|
return p.has("kiosk") && p.get("kiosk") !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the current URL asks for kiosk mode (query or hash). Client-side only. */
|
||||||
|
export function readKioskFlag(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return queryKiosk(window.location.search) || hashKiosk(window.location.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `?kiosk=` is in the query (the hash part is LiveView's to manage). */
|
||||||
|
export function kioskInQuery(): boolean {
|
||||||
|
return typeof window !== "undefined" && queryKiosk(window.location.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops `?kiosk` from the query string in place; the caller clears the hash flag. */
|
||||||
|
export function stripKioskQuery(): void {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("kiosk");
|
||||||
|
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
|
||||||
|
} catch { /* nothing to strip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function announceKioskChange(): void {
|
||||||
|
window.dispatchEvent(new Event(KIOSK_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live view of the URL's kiosk flag, for chrome outside LiveView (ChromeSwitcher). */
|
||||||
|
export function useKioskFlag(): boolean {
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () => setOn(readKioskFlag());
|
||||||
|
read();
|
||||||
|
window.addEventListener("hashchange", read);
|
||||||
|
window.addEventListener("popstate", read);
|
||||||
|
window.addEventListener(KIOSK_EVENT, read);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("hashchange", read);
|
||||||
|
window.removeEventListener("popstate", read);
|
||||||
|
window.removeEventListener(KIOSK_EVENT, read);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return on;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen Wake Lock (typed locally; not in every TS lib.dom) ────────────────
|
||||||
|
interface WakeLockSentinelLike extends EventTarget { released: boolean; release(): Promise<void> }
|
||||||
|
interface WakeLockLike { request(type: "screen"): Promise<WakeLockSentinelLike> }
|
||||||
|
|
||||||
|
export type WakeState = "on" | "off" | "unsupported";
|
||||||
|
|
||||||
|
const RETRY_KEY = "drb.wall.retryAttempts";
|
||||||
|
const RETRY_BASE_MS = 30_000;
|
||||||
|
const RETRY_MAX_MS = 10 * 60_000;
|
||||||
|
/** Healthy this long after a load → the retry backoff resets. */
|
||||||
|
const HEALTHY_RESET_MS = 2 * 60_000;
|
||||||
|
/** Offline at least this long → a kiosk reloads on reconnect for fresh listeners. */
|
||||||
|
const OFFLINE_RELOAD_MS = 60_000;
|
||||||
|
|
||||||
|
function readAttempts(): number {
|
||||||
|
try { return Math.max(0, Number(window.sessionStorage.getItem(RETRY_KEY)) || 0); } catch { return 0; }
|
||||||
|
}
|
||||||
|
function writeAttempts(n: number): void {
|
||||||
|
try {
|
||||||
|
if (n > 0) window.sessionStorage.setItem(RETRY_KEY, String(n));
|
||||||
|
else window.sessionStorage.removeItem(RETRY_KEY);
|
||||||
|
} catch { /* storage blocked — backoff restarts at the base delay */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KioskBanner { tone: "offline" | "error"; message: string; retryAt: number | null }
|
||||||
|
|
||||||
|
export interface KioskState {
|
||||||
|
fullscreen: boolean;
|
||||||
|
canFullscreen: boolean;
|
||||||
|
toggleFullscreen: () => void;
|
||||||
|
wake: WakeState;
|
||||||
|
online: boolean;
|
||||||
|
banner: KioskBanner | null;
|
||||||
|
retryNow: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param kiosk kiosk presentation is on (wake lock, auto-fullscreen, auto-reload)
|
||||||
|
* @param dataError the live-data listener's error (e.g. useCalls().error) — a
|
||||||
|
* Firestore snapshot error is terminal for that listener, so
|
||||||
|
* the recovery is a reload; the hooks themselves are untouched.
|
||||||
|
*/
|
||||||
|
export function useKiosk(kiosk: boolean, dataError: string | null): KioskState {
|
||||||
|
// ── Fullscreen ──────────────────────────────────────────────────────────
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
const [canFullscreen, setCanFullscreen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setCanFullscreen(typeof document !== "undefined" && !!document.fullscreenEnabled);
|
||||||
|
const on = () => setFullscreen(!!document.fullscreenElement);
|
||||||
|
on();
|
||||||
|
document.addEventListener("fullscreenchange", on);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", on);
|
||||||
|
}, []);
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
|
||||||
|
else void document.documentElement.requestFullscreen().catch(() => undefined);
|
||||||
|
} catch { /* unsupported */ }
|
||||||
|
}, []);
|
||||||
|
// Browsers only allow fullscreen from a user gesture, so in kiosk the first
|
||||||
|
// touch/key anywhere enters it. A wall PC launched with the browser's own
|
||||||
|
// kiosk flag is already fullscreen and needs no click at all.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!kiosk || !document.fullscreenEnabled) return;
|
||||||
|
const arm = () => {
|
||||||
|
if (!document.fullscreenElement) void document.documentElement.requestFullscreen().catch(() => undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", arm, { once: true });
|
||||||
|
window.addEventListener("keydown", arm, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", arm);
|
||||||
|
window.removeEventListener("keydown", arm);
|
||||||
|
};
|
||||||
|
}, [kiosk]);
|
||||||
|
|
||||||
|
// ── Screen Wake Lock — held while kiosk is on, re-acquired when visible again ──
|
||||||
|
const [wake, setWake] = useState<WakeState>("off");
|
||||||
|
useEffect(() => {
|
||||||
|
const wl = (navigator as Navigator & { wakeLock?: WakeLockLike }).wakeLock;
|
||||||
|
if (!kiosk) { setWake("off"); return; }
|
||||||
|
if (!wl) { setWake("unsupported"); return; }
|
||||||
|
let sentinel: WakeLockSentinelLike | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
const onRelease = () => { if (!cancelled) setWake("off"); };
|
||||||
|
const acquire = async () => {
|
||||||
|
if (cancelled || document.visibilityState !== "visible" || (sentinel && !sentinel.released)) return;
|
||||||
|
try {
|
||||||
|
const s = await wl.request("screen");
|
||||||
|
if (cancelled) { void s.release().catch(() => undefined); return; }
|
||||||
|
sentinel = s;
|
||||||
|
s.addEventListener("release", onRelease);
|
||||||
|
setWake("on");
|
||||||
|
} catch {
|
||||||
|
setWake("off"); // denied (battery saver, policy) — the wall still works
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void acquire();
|
||||||
|
const onVis = () => { void acquire(); };
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
|
if (sentinel) {
|
||||||
|
sentinel.removeEventListener("release", onRelease);
|
||||||
|
void sentinel.release().catch(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [kiosk]);
|
||||||
|
|
||||||
|
// ── Reconnect watchdog ─────────────────────────────────────────────────
|
||||||
|
const [online, setOnline] = useState(true);
|
||||||
|
const offlineSince = useRef<number | null>(null);
|
||||||
|
const kioskRef = useRef(kiosk);
|
||||||
|
kioskRef.current = kiosk;
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const now = navigator.onLine;
|
||||||
|
setOnline(now);
|
||||||
|
if (!now) {
|
||||||
|
if (offlineSince.current == null) offlineSince.current = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const since = offlineSince.current;
|
||||||
|
offlineSince.current = null;
|
||||||
|
// Firestore resumes its listeners on its own after a blip; after a long
|
||||||
|
// outage an unattended wall reloads rather than trust that it did.
|
||||||
|
if (kioskRef.current && since != null && Date.now() - since >= OFFLINE_RELOAD_MS) window.location.reload();
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
window.addEventListener("online", sync);
|
||||||
|
window.addEventListener("offline", sync);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", sync);
|
||||||
|
window.removeEventListener("offline", sync);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const retryNow = useCallback(() => {
|
||||||
|
writeAttempts(readAttempts() + 1);
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Healthy for a while → forget earlier failures.
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataError) return;
|
||||||
|
const id = setTimeout(() => writeAttempts(0), HEALTHY_RESET_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [dataError]);
|
||||||
|
|
||||||
|
// Data error in kiosk → reload after an exponential backoff (30 s → 10 min).
|
||||||
|
const [retryAt, setRetryAt] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dataError || !kiosk || !online) { setRetryAt(null); return; }
|
||||||
|
const delay = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** readAttempts());
|
||||||
|
setRetryAt(Date.now() + delay);
|
||||||
|
const id = setTimeout(retryNow, delay);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [dataError, kiosk, online, retryNow]);
|
||||||
|
|
||||||
|
let banner: KioskBanner | null = null;
|
||||||
|
if (!online) {
|
||||||
|
banner = { tone: "offline", message: "Offline — showing the last data received. Reconnects automatically.", retryAt: null };
|
||||||
|
} else if (dataError) {
|
||||||
|
banner = { tone: "error", message: "Live data stopped updating.", retryAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fullscreen, canFullscreen, toggleFullscreen, wake, online, banner, retryNow };
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Per-viewer memory of the live wall's map view (server-26#234): the last
|
||||||
|
* overlays, basemap and viewport this browser used. A convenience only —
|
||||||
|
* every access is wrapped, and a blocked/empty store (private window, cleared
|
||||||
|
* site data) simply yields {} so the wall falls back to its defaults.
|
||||||
|
*
|
||||||
|
* Precedence when the wall opens: URL hash > this store > defaults
|
||||||
|
* (resolveWallView below). Nothing here is shared with other viewers.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DEFAULT_BASE, DEFAULT_LAYERS, isBaseKey, normaliseLayers, parseViewport,
|
||||||
|
type WallView,
|
||||||
|
} from "@/lib/liveWall";
|
||||||
|
|
||||||
|
const KEY = "drb.wall.view.v1";
|
||||||
|
|
||||||
|
export function loadWallPrefs(): WallView {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const v: unknown = JSON.parse(raw);
|
||||||
|
if (!v || typeof v !== "object") return {};
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
const out: WallView = {};
|
||||||
|
if (Array.isArray(o.layers)) out.layers = normaliseLayers(o.layers.filter((x): x is string => typeof x === "string"));
|
||||||
|
if (typeof o.base === "string" && isBaseKey(o.base)) out.base = o.base;
|
||||||
|
const view = o.view && typeof o.view === "object" ? (o.view as Record<string, unknown>) : null;
|
||||||
|
const vp = view ? parseViewport(view.z, view.lat, view.lon) : undefined;
|
||||||
|
if (vp) out.view = vp;
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWallPrefs(v: WallView): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(KEY, JSON.stringify({ layers: v.layers, base: v.base, view: v.view }));
|
||||||
|
} catch {
|
||||||
|
/* storage blocked or full — the URL hash still carries the view */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Field-by-field: hash wins, then what this browser remembers, then defaults. */
|
||||||
|
export function resolveWallView(hash: WallView, stored: WallView): Required<Pick<WallView, "layers" | "base">> & Pick<WallView, "view"> {
|
||||||
|
return {
|
||||||
|
layers: hash.layers ?? stored.layers ?? DEFAULT_LAYERS,
|
||||||
|
base: hash.base ?? stored.base ?? DEFAULT_BASE,
|
||||||
|
view: hash.view ?? stored.view,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user