From afd2bc463d22a7aa02d455558b40e982833424b2 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 02:58:22 -0400 Subject: [PATCH] Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234) - liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon, base, tourMs, kiosk and an f. filter pass-through for #235; parse/serialise stay pure and round-trip; old inc/cam/tour links parse. - MapView: overlays/basemap/viewport come from initialLayers/initialBase/ initialView (defaults unchanged); onViewStateChange reports layer, base and region-view changes (debounced). Initially-checked overlays now start "shown" (react-leaflet adds a checked overlay before the control can fire overlayadd). Closing an incident flies back to the pre-drill view (#137). - wallPrefs.ts: per-browser last layers/base/view in localStorage, every access wrapped; precedence hash > remembered > defaults. - LiveView: one effect mirrors wall + map state into the hash; hash edits remount the map with the new view; Copy view link; configurable tour. - Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first touch), Screen Wake Lock re-acquired on visibility, reconnect banner (offline / useCalls error) with backoff reload, 18-20 px type via a data-kiosk attribute and Tailwind arbitrary variants. Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/ChromeSwitcher.tsx | 8 +- drb-frontend/components/LiveView.tsx | 149 ++++++++--- drb-frontend/components/MapView.tsx | 147 +++++++++-- .../components/live/KioskControls.tsx | 54 ++++ drb-frontend/lib/liveWall.ts | 132 +++++++++- drb-frontend/lib/useKiosk.ts | 237 ++++++++++++++++++ drb-frontend/lib/wallPrefs.ts | 51 ++++ 7 files changed, 717 insertions(+), 61 deletions(-) create mode 100644 drb-frontend/components/live/KioskControls.tsx create mode 100644 drb-frontend/lib/useKiosk.ts create mode 100644 drb-frontend/lib/wallPrefs.ts diff --git a/drb-frontend/components/ChromeSwitcher.tsx b/drb-frontend/components/ChromeSwitcher.tsx index c71c6dd..32bbc0a 100644 --- a/drb-frontend/components/ChromeSwitcher.tsx +++ b/drb-frontend/components/ChromeSwitcher.tsx @@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider"; import { Nav } from "@/components/Nav"; import { MarketingHeader } from "@/components/marketing/MarketingHeader"; import { MarketingFooter } from "@/components/marketing/MarketingFooter"; +import { useKioskFlag } from "@/lib/useKiosk"; // Public marketing surface — exact paths, not prefixes, so e.g. /features/x // (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 { user, loading, orgId } = useAuth(); const router = useRouter(); + const kiosk = useKioskFlag(); useEffect(() => { 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 - // 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
{children}
; + } if (pathname === "/" && user) { return ( <> diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index 641d804..02ddf1b 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -7,8 +7,14 @@ * KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep * links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents, * 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 { useNodes } from "@/lib/useNodes"; import { useActiveCalls, useCalls } from "@/lib/useCalls"; @@ -21,8 +27,12 @@ import { severityRank } from "@/lib/severity"; import type { Camera, Ny511Response } from "@/lib/types"; import { 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"; +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 { TranscriptTicker } from "@/components/live/TranscriptTicker"; import { CameraDrill } from "@/components/live/CameraDrill"; @@ -33,6 +43,17 @@ import { usePipelineHealth } from "@/lib/usePipelineHealth"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); 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 MAX_PINNED = 6; const EVENTS_POLL_MS = 2 * 60 * 1000; @@ -92,7 +113,7 @@ export function LiveView() { const activeIncidents = useActiveIncidents(); // Wide-ish recent window: feeds incident paths, the call spine, the ticker // 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 { vessels } = useVessels(); const [clock, setClock] = useState(""); @@ -108,19 +129,78 @@ export function LiveView() { }, []); // ── 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({ inc: null, cam: null, tour: false }); + const [mapState, setMapState] = useState(null); + const mapStateRef = useRef(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(() => { - const read = () => setLinkState(parseWallHash(window.location.hash)); - read(); - window.addEventListener("hashchange", read); - return () => window.removeEventListener("hashchange", read); + const read = (first: boolean) => { + const parsed = parseWallHash(window.location.hash); + const { layers, base, view, ...nav } = parsed; + 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) => { - setLinkState(next); - const h = wallHash(next); - window.history.replaceState(null, "", h || window.location.pathname + window.location.search); + useEffect(() => { + if (!mapState) return; + const h = wallHash({ ...link, ...mapState }); + 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) => 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([]); useEffect(() => { setPinned(loadPinned()); }, []); const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]); @@ -185,13 +265,13 @@ export function LiveView() { }, [servingPinned, sorted, nearById]); // ── Navigation ──────────────────────────────────────────────────────────── - const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]); - const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]); + const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]); + const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]); const stepUp = useCallback(() => { - if (link.cam) setLink({ ...link, cam: null, tour: false }); - else if (link.inc) setLink({ inc: null, cam: null, tour: false }); - else if (link.tour) setLink({ ...link, tour: false }); - }, [link, setLink]); + if (link.cam) patchLink({ cam: null, tour: false }); + else if (link.inc) patchLink({ inc: null, cam: null, tour: false }); + else if (link.tour) patchLink({ tour: false }); + }, [link, patchLink]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); }; window.addEventListener("keydown", onKey); @@ -211,20 +291,18 @@ export function LiveView() { const next = () => { setLinkState((cur) => { const i = cur.inc ? tourList.indexOf(cur.inc) : -1; - const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true }; - window.history.replaceState(null, "", wallHash(nxt)); - return nxt; + return { ...cur, inc: tourList[(i + 1) % tourList.length], cam: null, tour: true }; }); setTourStart(Date.now()); }; if (!link.inc || !tourList.includes(link.inc)) next(); else setTourStart(Date.now()); - const id = setInterval(next, TOUR_MS); + const id = setInterval(next, tourMs); const prog = setInterval(() => setTourNow(Date.now()), 500); return () => { clearInterval(id); clearInterval(prog); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [link.tour, tourList.join(",")]); - const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0; + }, [link.tour, tourList.join(","), tourMs]); + const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; // ── KPIs ────────────────────────────────────────────────────────────────── const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; @@ -245,14 +323,14 @@ export function LiveView() { ); return ( -
+
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
@@ -268,7 +346,7 @@ export function LiveView() {
- + {clock}
+
@@ -288,7 +372,7 @@ export function LiveView() { {/* Degraded → grey the map and its incident rail (the rail lives inside MapView, so it is wrapped here rather than edited). */}
- {nodesLoading || callsLoading ? ( + {nodesLoading || callsLoading || !mapState ? (
Loading map…
) : ( )}
@@ -317,7 +406,7 @@ export function LiveView() { )}
-
diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index a5f173b..a297e02 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -1,6 +1,6 @@ "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 { FeatureGroup, @@ -28,7 +28,11 @@ import { useMapCameras } from "@/lib/useCameras"; import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types"; import { CameraFeed } from "@/components/CameraFeed"; 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 { hasAudio, useAudio } from "@/lib/audioQueue"; @@ -346,13 +350,22 @@ function RadarLayer() { // ── 511NY traffic layers (server-26#183) ───────────────────────────────────── // Overlay names double as the event keys for overlayadd/overlayremove. -const OVERLAY_DOT_CAMERAS = "DOT Cameras"; -const OVERLAY_TRAFFIC_EVENTS = "Traffic Events"; -const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)"; +const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam; +const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic; +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>(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 { - const [shown, setShown] = useState(false); + const initial = useContext(InitialOverlays); + const [shown, setShown] = useState(() => initial.has(name)); useEffect(() => { const on = (e: L.LayersControlEvent) => e.name === name && setShown(true); const off = (e: L.LayersControlEvent) => e.name === name && setShown(false); @@ -1120,23 +1133,51 @@ interface Props { onAir?: Set; /** Cameras within range of each incident (rail "N cams"). */ camCountByIncident?: Map; + // ── 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({ nodes, activeCalls, incidents = [], calls = [], lastUpdated, selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident, + initialLayers, initialBase, initialView, onViewStateChange, }: Props) { const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); 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(normaliseLayers(initialLayers ?? DEFAULT_LAYERS))); + const [startBase] = useState(() => 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 // overlay is actually on (server-26#185). useEffect(() => { if (!mapInstance) return; - const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true); - const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false); + const on = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(true); + const off = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(false); mapInstance.on("overlayadd", on); mapInstance.on("overlayremove", off); return () => { @@ -1171,14 +1212,15 @@ export default function MapView({ [nodes, incidents] ); - const center: [number, number] = - nodes.length > 0 + const center: [number, number] = startView + ? [startView.lat, startView.lon] + : nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : allPositions.length > 0 ? allPositions[0] : [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(() => { if (!mapInstance || allPositions.length === 0) return; @@ -1198,6 +1240,59 @@ export default function MapView({ [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(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 | 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); + 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). const selectedKey = selectedPoints.map((p) => p.join(",")).join(";"); useEffect(() => { @@ -1220,21 +1315,22 @@ export default function MapView({ > + {/* Base layers */} - + - + - + {/* Overlay: Nodes */} - + {/* Overlay: Active Incidents */} - + {/* Overlay: Incident paths — the flagship feature */} - + {/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */} - + {/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */} - + {/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */} - + - + - + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */} - + + {selectedIncidentId && ( diff --git a/drb-frontend/components/live/KioskControls.tsx b/drb-frontend/components/live/KioskControls.tsx new file mode 100644 index 0000000..f3362e4 --- /dev/null +++ b/drb-frontend/components/live/KioskControls.tsx @@ -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 && ( + + )} + {kiosk && state.wake === "on" && ( + Awake + )} + + + ); +} + +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 ( +
+ {banner.tone === "offline" ? "Offline" : "Reconnecting"} + {banner.message}{secs != null ? ` Retrying in ${secs}s.` : ""} + {banner.tone === "error" && ( + + )} +
+ ); +} diff --git a/drb-frontend/lib/liveWall.ts b/drb-frontend/lib/liveWall.ts index 0b6c39d..c765a58 100644 --- a/drb-frontend/lib/liveWall.ts +++ b/drb-frontend/lib/liveWall.ts @@ -152,22 +152,144 @@ export function bboxAround(points: LatLon[], padKm: number) { }; } -// ── Deep links: #inc=&cam=&tour ─────────────────────────────── -export interface WallLink { inc: string | null; cam: string | null; tour: boolean } +// ── Deep links / saved views ──────────────────────────────────────────────── +// #inc=&cam=&tour&tourMs=30000&kiosk +// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light +// &f.= +// 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.=` + * hash key round-trips here untouched, so filters can be added without + * reshaping WallLink or breaking older links. + */ + filter?: Record; +} + +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 { 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 = {}; + 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 { const parts: string[] = []; - if (link.inc) parts.push(`inc=${encodeURIComponent(link.inc)}`); - if (link.cam) parts.push(`cam=${encodeURIComponent(link.cam)}`); + const enc = encodeURIComponent; + 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.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("&")}` : ""; } +/** 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. */ export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set { const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String)); diff --git a/drb-frontend/lib/useKiosk.ts b/drb-frontend/lib/useKiosk.ts new file mode 100644 index 0000000..9160ca2 --- /dev/null +++ b/drb-frontend/lib/useKiosk.ts @@ -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 } +interface WakeLockLike { request(type: "screen"): Promise } + +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("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(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(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 }; +} diff --git a/drb-frontend/lib/wallPrefs.ts b/drb-frontend/lib/wallPrefs.ts new file mode 100644 index 0000000..d97e478 --- /dev/null +++ b/drb-frontend/lib/wallPrefs.ts @@ -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; + 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) : 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 { + return { + layers: hash.layers ?? stored.layers ?? DEFAULT_LAYERS, + base: hash.base ?? stored.base ?? DEFAULT_BASE, + view: hash.view ?? stored.view, + }; +}