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.<name> 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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 02:58:22 -04:00
co-authored by Claude Opus 5.5
parent 7bee87b147
commit afd2bc463d
7 changed files with 717 additions and 61 deletions
+122 -25
View File
@@ -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<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 {
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<string>;
/** Cameras within range of each incident (rail "N cams"). */
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({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
initialLayers, initialBase, initialView, onViewStateChange,
}: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(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<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
// 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<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);
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({
>
<MapRefCapture onReady={onMapReady} />
<InitialOverlays.Provider value={startOverlayNames}>
<LayersControl position="topright">
{/* Base layers */}
<LayersControl.BaseLayer checked name="Dark">
<LayersControl.BaseLayer checked={startBase === "dark"} name={BASE_NAMES.dark}>
<TileLayer
url={MAP_TILE_URL}
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light">
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets">
<LayersControl.BaseLayer checked={startBase === "streets"} name={BASE_NAMES.streets}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@@ -1242,62 +1338,63 @@ export default function MapView({
</LayersControl.BaseLayer>
{/* Overlay: Nodes */}
<LayersControl.Overlay checked name="Nodes">
<LayersControl.Overlay checked={layerOn("nodes")} name={LAYER_NAMES.nodes}>
<FeatureGroup>
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<LayersControl.Overlay checked={layerOn("inc")} name={LAYER_NAMES.inc}>
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Incident paths — the flagship feature */}
<LayersControl.Overlay checked name="Incident Paths">
<LayersControl.Overlay checked={layerOn("paths")} name={LAYER_NAMES.paths}>
<FeatureGroup>
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
</FeatureGroup>
</LayersControl.Overlay>
{/* 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>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* 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>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* 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>
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
<LayersControl.Overlay checked={layerOn("traffic")} name={OVERLAY_TRAFFIC_EVENTS}>
<FeatureGroup>
<TrafficEventLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
<LayersControl.Overlay name="Weather Radar">
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
</InitialOverlays.Provider>
{selectedIncidentId && (
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />