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:
co-authored by
Claude Opus 5.5
parent
7bee87b147
commit
afd2bc463d
@@ -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='© <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} />
|
||||
|
||||
Reference in New Issue
Block a user