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
+7 -1
View File
@@ -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 <main className="h-screen">{children}</main>;
}
if (pathname === "/" && user) {
return (
<>
+119 -30
View File
@@ -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<string>("");
@@ -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<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(() => {
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<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[]>([]);
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 (
<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 ── */}
<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">
{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>
{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>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav>
@@ -268,7 +346,7 @@ export function LiveView() {
</div>
<div className="ml-auto flex items-center gap-3">
<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"
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
@@ -278,9 +356,15 @@ export function LiveView() {
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</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>
</div>
</div>
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
<TruthStrip health={health} placement="bar" />
<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
MapView, so it is wrapped here rather than edited). */}
<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>
) : (
<MapView
@@ -302,6 +386,11 @@ export function LiveView() {
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
key={mapGen}
initialLayers={mapState.layers}
initialBase={mapState.base}
initialView={mapState.view}
onViewStateChange={onViewStateChange}
/>
)}
</div>
@@ -317,7 +406,7 @@ export function LiveView() {
)}
</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"}>
{selected ? (
<IncidentPanel
@@ -365,7 +454,7 @@ export function LiveView() {
incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })}
onClose={() => patchLink({ cam: null })}
/>
)}
</div>
+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} />
@@ -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>
);
}