"use client"; import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { FeatureGroup, LayersControl, MapContainer, Circle, Marker, Polygon, Polyline, Popup, TileLayer, Tooltip, useMap, useMapEvents, } from "react-leaflet"; import L from "leaflet"; import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAircraft } from "@/lib/useAircraft"; import { useAircraftTrail } from "@/lib/useAircraftTrail"; import { useVessels } from "@/lib/useVessels"; import { use511 } from "@/lib/use511"; import { useMapCameras } from "@/lib/useCameras"; import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus, WeatherAlert, WeatherAlertsResponse, WeatherGeometry, } from "@/lib/types"; import { useMapWeatherAlerts, useRadarMeta, type Polled, type RadarMeta } from "@/lib/useWeather"; import { CameraFeed } from "@/components/CameraFeed"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident, MAY_BE_MERGED_KM, PATH_GAP_DASH_MIN, incidentPoints, layerKeyForName, normaliseLayers, pathStops, roundViewport, viewshedWedge, worstFirst, RADAR_LOOP_OFFSETS_MIN, RADAR_STALE_MIN, n0qColor, radarTileUrl, wxStyle, wxTime, WX_KIND_DASH, type BaseKey, type LayerKey, type MapViewport, } from "@/lib/liveWall"; import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer"; import { PlayButton } from "@/components/InlinePlayer"; import { hasAudio, useAudio } from "@/lib/audioQueue"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", }); // ── Basemap tiles ───────────────────────────────────────────────────────────── // Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style, // MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map // still renders if that var is missing — CARTO's keyless CDN has proven flaky. // Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme; // the {z}/{x}/{y} tokens below are substituted by Leaflet at runtime. const MAP_TILE_URL = process.env.NEXT_PUBLIC_MAP_TILE_URL || "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; const MAP_TILE_ATTRIBUTION = "© OpenStreetMap contributors"; // ── Colour ──────────────────────────────────────────────────────────────────── // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // type is carried by the glyph knocked out of the pin, never by colour, and // node state is carried by the diamond's weight (filled/hollow/dashed), not // by colour either. No green appears anywhere here any more. function severityColor(severity: string | null | undefined): string { return isKnownSeverity(severity) ? SEVERITY_COLORS[severity] : "var(--ink-muted)"; } // Inline SVG paths matching components/marks/TypeGlyph.tsx, duplicated here // (rather than rendered through the React component) because Leaflet marker // icons are raw HTML strings, not React nodes. const TYPE_GLYPH_PATHS: Record = { fire: '', police: '', ems: '', collision: '', other: '', }; function typeGlyphSvg(type: string | null | undefined, color: string, size = 13): string { const key = type === "accident" ? "collision" : type && TYPE_GLYPH_PATHS[type] ? type : "other"; return `${TYPE_GLYPH_PATHS[key]}`; } // ── Node diamond icon — matches components/marks/NodeMark.tsx ──────────────── function nodeDiamondSvg(status: NodeStatus, size: number): string { const half = size / 2; const pts = `${half},1 ${size - 1},${half} ${half},${size - 1} 1,${half}`; if (status === "recording") { return ``; } if (status === "online") { return ``; } if (status === "unconfigured") { return ``; } return ``; } function nodeIcon(status: NodeStatus): L.DivIcon { const size = 14; const isRec = status === "recording"; const ring = isRec ? `
` : ""; return L.divIcon({ className: "", html: `
${ring}${nodeDiamondSvg(status, size)}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } // ── Aircraft — node-26#9 second-SDR ADS-B overlay ───────────────────────────── // Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a // dark outline, filled by altitude on tar1090's hue ramp, so height reads at a // glance and the icon stands out from OSM's own (purple) airport symbols. const ALT_HUE_STOPS: [number, number][] = [ [0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300], ]; function altitudeColor(altFt: number | null): string { if (altFt == null) return "hsl(0, 0%, 55%)"; if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1]; for (let i = 1; i < ALT_HUE_STOPS.length; i++) { const [a1, h1] = ALT_HUE_STOPS[i]; if (altFt <= a1) { const [a0, h0] = ALT_HUE_STOPS[i - 1]; hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0); break; } } return `hsl(${hue.toFixed(0)}, 88%, 48%)`; } // Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear). const ALTITUDE_LEGEND_TICKS: [number, string][] = [ [1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"], ]; const AIRLINER_PATH = "M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " + "L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z"; function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon { const size = selected ? 36 : 30; const outline = selected ? "#ffffff" : "#000000"; const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))"; return L.divIcon({ className: "", html: `
` + `
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) { const trail = useAircraftTrail(icao); // Extend to the live position so the path always meets the icon. const points = [...trail]; if (current.lat != null && current.lon != null) { points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen }); } // One segment per leg, colored by altitude like tar1090's track. return ( <> {points.slice(1).map((p, i) => ( ))} ); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } // Details dock at the right edge (bottom sheet on phones, above the incident // drawer) instead of a popup over the plane, so the trail stays visible and // the map can be panned to follow it. function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) { const ref = useRef(null); useEffect(() => { // The panel is portaled into the Leaflet container, whose native listeners // would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom. if (!ref.current) return; L.DomEvent.disableClickPropagation(ref.current); L.DomEvent.disableScrollPropagation(ref.current); }, []); const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`); return (
{a.callsign || a.icao}
ICAO {a.icao}
); } function AircraftLayer() { const map = useMap(); const { aircraft } = useAircraft(); const [selected, setSelected] = useState(null); const positioned = aircraft.filter((a) => a.lat != null && a.lon != null); const selectedTrack = positioned.find((a) => a.icao === selected); // Clicking empty map deselects; marker clicks don't reach the map. useMapEvents({ click: () => setSelected(null) }); return ( <> {selectedTrack && } {selectedTrack && createPortal( setSelected(null)} />, map.getContainer())} {positioned.map((a) => ( setSelected((cur) => (cur === a.icao ? null : a.icao)) }} > {a.callsign || a.icao} {a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`} ))} ); } // ── Vessel icon — node-26#9 second-SDR AIS overlay ───────────────────────────── // MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel — // on the Hudson most of what a node hears is buoys (server-26#186). function isAidToNavigation(mmsi: string): boolean { return /^99\d{7}$/.test(mmsi); } // AIS reports heading 511 (and course 360) for "not available". function aisHeading(deg: number | null): number | null { return deg == null || deg >= 360 ? null : deg; } function vesselIcon(headingDeg: number | null): L.DivIcon { const size = 22; return L.divIcon({ className: "", html: `
` + `
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } function aidToNavigationIcon(): L.DivIcon { const size = 12; return L.divIcon({ className: "", html: ``, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } function VesselLayer() { const { vessels } = useVessels(); return ( <> {vessels .filter((v) => v.lat != null && v.lon != null) .map((v) => { const aton = isAidToNavigation(v.mmsi); return (
{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}
MMSI {v.mmsi}
{!aton && v.speed_kt != null &&
Speed: {Math.round(v.speed_kt)} kt
}
); })} ); } // ── Weather radar (IEM n0q; server-26#237) ─────────────────────────────────── // IEM tiles are static once loaded, so refresh them in place with a // cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a // remounted child of an unchecked LayersControl.Overlay is re-registered with // the control but not re-added to the map, which switched radar off every 5 min. // The layers live in a FeatureGroup that never remounts; the loop frames are // that group's children, so adding/removing them is ordinary group membership. // The buster is the composite's valid time when known (so tiles, badge and // incident sample move together), else a 5-min clock. const RADAR_REFRESH_MS = 5 * 60 * 1000; const RADAR_OPACITY = 0.65; const RADAR_ATTRIBUTION = 'Radar © IEM/NWS'; const LOOP_FRAME_MS = 600; const LOOP_HOLD_FRAMES = 3; // the newest frame is held this many ticks before the loop restarts const RADAR_LEGEND_DBZ = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75]; const RADAR_LEGEND_TICKS = [5, 40, 75]; // evenly spaced over RADAR_LEGEND_DBZ, so justify-between places them right /** * The loop's current frame, kept OUTSIDE MapView's state: it ticks every * LOOP_FRAME_MS, and a MapView re-render rebuilds every marker icon on the map * (setIcon on each), so only the radar frames and the badge subscribe to it. */ function createLoopClock() { let frame = 0; let id: ReturnType | undefined; const subs = new Set<() => void>(); const set = (f: number) => { if (f !== frame) { frame = f; subs.forEach((s) => s()); } }; return { subscribe: (cb: () => void) => { subs.add(cb); return () => { subs.delete(cb); }; }, get: () => frame, run(on: boolean) { clearInterval(id); id = undefined; set(0); if (!on) return; const last = RADAR_LOOP_OFFSETS_MIN.length - 1; const cycle = last + LOOP_HOLD_FRAMES; let tick = 0; id = setInterval(() => { if (document.hidden) return; tick = (tick + 1) % cycle; set(Math.min(tick, last)); }, LOOP_FRAME_MS); }, }; } type LoopClock = ReturnType; const useLoopFrame = (c: LoopClock) => useSyncExternalStore(c.subscribe, c.get, c.get); function radarBuster(valid: Date | null, clock: number): string { return `?_=${valid ? valid.getTime() : clock}`; } /** * One n0q tile layer. react-leaflet 4 applies a changed `url` with setUrl on * the same layer (no remount), which is exactly the in-place refresh above. */ function RadarFrame({ offsetMin, buster, opacity }: { offsetMin: number; buster: string; opacity: number }) { return ; } /** * Current composite, plus — while `loop` is on — the 11 earlier 5-min frames. * All frames stay loaded; only the shown one is opaque, so the loop plays * without re-fetching tiles every cycle. */ function RadarLayer({ valid, loop, loopClock }: { valid: Date | null; loop: boolean; loopClock: LoopClock }) { const frame = useLoopFrame(loopClock); const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS)); useEffect(() => { if (valid) return; // the valid time drives refreshes while it is known const id = setInterval(() => setClock(Math.floor(Date.now() / RADAR_REFRESH_MS)), 30_000); return () => clearInterval(id); }, [valid]); const buster = radarBuster(valid, clock); const last = RADAR_LOOP_OFFSETS_MIN.length - 1; return ( <> {loop && RADAR_LOOP_OFFSETS_MIN.slice(0, last).map((off, i) => ( ))} = last ? RADAR_OPACITY : 0} /> ); } function hhmmLocal(d: Date): string { return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } /** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */ function RadarBadge({ meta, loop, loopClock, onLoop }: { meta: RadarMeta; loop: boolean; loopClock: LoopClock; onLoop: (on: boolean) => void; }) { const frame = useLoopFrame(loopClock); const [, tick] = useState(0); useEffect(() => { const id = setInterval(() => tick((t) => t + 1), 30_000); return () => clearInterval(id); }, []); const ageMin = meta.valid ? Math.max(0, Math.round((Date.now() - meta.valid.getTime()) / 60_000)) : null; const stale = ageMin == null ? meta.loaded : ageMin > RADAR_STALE_MIN; const last = RADAR_LOOP_OFFSETS_MIN.length - 1; const off = loop ? RADAR_LOOP_OFFSETS_MIN[Math.min(frame, last)] : 0; let text: string; if (!meta.loaded) text = "Radar: checking valid time…"; else if (!meta.valid) text = `Radar: valid time unknown${meta.error ? " (IEM metadata unreachable)" : ""}`; else if (loop && off > 0) text = `Radar loop: ${hhmmLocal(new Date(meta.valid.getTime() - off * 60_000))} (−${off} min)`; else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`; return (
{stale && STALE} {text} {meta.valid && meta.error && · time not refreshing}
); } // ── NWS weather warnings (server-26#237) ───────────────────────────────────── const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null }; /** GeoJSON (Multi)Polygon → Leaflet positions ([lon, lat] → [lat, lon]). */ function wxPositions(g: WeatherGeometry): L.LatLngExpression[][] | L.LatLngExpression[][][] { const ring = (r: number[][]) => r.map((p) => [p[1], p[0]] as L.LatLngTuple); return g.type === "Polygon" ? g.coordinates.map(ring) : g.coordinates.map((poly) => poly.map(ring)); } function WeatherAlertPopupBody({ a, fetchedAt }: { a: WeatherAlert; fetchedAt: number | null }) { const expires = wxTime(a.expires); const ends = wxTime(a.ends); const onset = wxTime(a.onset); return (
{a.event}
{/* Shown as NWS issued it — never summarised or rewritten. */} {a.headline &&
{a.headline}
}
{onset ? `From ${onset}` : ""}{onset && (expires || ends) ? " · " : ""} {ends ? `ends ${ends}` : ""}{ends && expires ? " · " : ""}{expires ? `expires ${expires}` : ""}
{a.area_desc &&
{a.area_desc}
} {a.sender_name &&
Issued by {a.sender_name}
} {a.geometry_source === "zones" && (
Area drawn from the alert's forecast/county zones{a.zones_unresolved ? ` — ${a.zones_unresolved} zone${a.zones_unresolved !== 1 ? "s" : ""} could not be fetched, so the real area is larger` : ""}.
)}
); } function WeatherWarningsLayer({ data }: { data: WeatherAlertsResponse | null }) { // Weakest first so warnings draw on top (and win the click where they overlap). const alerts = useMemo(() => { const rank = { statement: 0, advisory: 1, watch: 2, warning: 3 } as const; return (data?.alerts ?? []).slice().sort((a, b) => rank[wxStyle(a.event).kind] - rank[wxStyle(b.event).kind]); }, [data]); return ( <> {alerts.map((a) => { const st = wxStyle(a.event); return ( {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""} ); })} ); } /** * Always-present status for the Weather Warnings overlay: never a silently * empty layer. Feed problems use the 511 FeedProblem wording ("could not reach * server" / "feed error, showing data from N min ago"); zone-only alerts — * no shape to draw — are listed here, not dropped. */ function WeatherWarningsStatus({ state }: { state: Polled }) { const { data, error, loaded } = state; const zoneOnly = data?.zone_only ?? []; let msg: string; let problem = false; if (!loaded) msg = "Weather Warnings: loading…"; else if (!data) { msg = "Weather Warnings: could not reach server"; problem = true; } else if (data.error) { problem = true; msg = data.fetched_at ? `Weather Warnings: NWS feed error, showing data from ${Math.round((Date.now() / 1000 - data.fetched_at) / 60)} min ago` : "Weather Warnings: NWS feed error, no data yet"; } else { const n = data.alerts.length; msg = n ? `Weather Warnings: ${n} active in view` : "Weather Warnings: no active NWS alert with a mapped area in view"; } if (data && error) msg += " · could not refresh"; return (
{msg} {data?.fetched_at && !data.error ? ` · NWS, fetched ${fetchedLabel(data.fetched_at)}` : ""}
{zoneOnly.length > 0 && (
{zoneOnly.length} alert{zoneOnly.length !== 1 ? "s" : ""} in the served area with no mapped shape (zone-based)
    {zoneOnly.map((a) => (
  • {a.event}{a.area_desc ? ` · ${a.area_desc}` : ""}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
  • ))}
)}
); } // ── 511NY traffic layers (server-26#183) ───────────────────────────────────── // Overlay names double as the event keys for overlayadd/overlayremove. const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam; const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic; const OVERLAY_VIEWSHEDS = LAYER_NAMES.vs; // Power outages (server-26#184). Not in LAYER_NAMES yet — lib/liveWall.ts was // owned by #235 when this landed — so it isn't saved in the URL hash and always // starts off. Adding `outage: "Power Outages"` to LAYER_NAMES and `checked= // {layerOn("outage")}` below makes it persist like the rest. const OVERLAY_OUTAGES = "Power Outages"; /** * 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 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); map.on("overlayadd", on); map.on("overlayremove", off); return () => { map.off("overlayadd", on); map.off("overlayremove", off); }; }, [map, name]); return shown; } /** Small count badge for an LOD cluster icon; empty when the marker stands alone. */ function countBadge(count: number): string { if (count < 2) return ""; const label = count > 99 ? "99+" : String(count); return `${label}`; } function cameraIcon(count = 1): L.DivIcon { return L.divIcon({ className: "", html: `
${countBadge(count)}
`, iconSize: [16, 12], iconAnchor: [8, 6], }); } // Shape + glyph per 511 event type, so the layer reads without colour alone. // Order is LOD priority: when events share a cell, the earliest type represents it. const EVENT_STYLE: Record = { accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" }, closures: { glyph: "×", fill: "#ea580c", label: "Closure" }, roadwork: { glyph: "W", fill: "#ca8a04", label: "Roadwork" }, specialEvents: { glyph: "E", fill: "#7c3aed", label: "Special event" }, transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" }, }; const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" }; const EVENT_RANK = Object.keys(EVENT_STYLE); const eventRank = (e: Ny511Event) => { const r = EVENT_RANK.indexOf(e.type); return r < 0 ? EVENT_RANK.length : r; }; function trafficEventIcon(type: string, count = 1): L.DivIcon { const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER; return L.divIcon({ className: "", html: `
${st.glyph}${countBadge(count)}
`, iconSize: [16, 16], iconAnchor: [8, 8], }); } // ── 511 level of detail ────────────────────────────────────────────────────── // Zoomed out, a county's worth of cameras/events buries the map. Below // LOD_FULL_ZOOM, markers are bucketed into a grid of LOD_CELL_PX screen pixels // and each cell draws one representative with a count badge; clicking a // cluster zooms in on it. The grid is in world pixels (map.project), so it // only changes on zoom — panning never reshuffles which marker represents a cell. const LOD_CELL_PX = 40; const LOD_FULL_ZOOM = 14; interface LodCell { rep: T; count: number } function useMapZoom(map: L.Map): number { const [zoom, setZoom] = useState(() => map.getZoom()); useEffect(() => { const h = () => setZoom(map.getZoom()); map.on("zoomend", h); return () => { map.off("zoomend", h); }; }, [map]); return zoom; } function lodCells( items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0, ): LodCell[] { if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 })); const cells = new Map>(); for (const it of items) { const p = map.project([it.lat, it.lon], zoom); const key = `${Math.floor(p.x / LOD_CELL_PX)}:${Math.floor(p.y / LOD_CELL_PX)}`; const cell = cells.get(key); if (!cell) cells.set(key, { rep: it, count: 1 }); else { cell.count++; if (rank(it) < rank(cell.rep)) cell.rep = it; } } return Array.from(cells.values()); } function zoomInto(map: L.Map, lat: number, lon: number) { map.setView([lat, lon], Math.min(map.getZoom() + 2, LOD_FULL_ZOOM)); } function fmtLocal(iso: string | null): string | null { if (!iso) return null; const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } /** Surfaces a dead or stale 511 feed on the map instead of an unexplained empty layer. */ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) { let msg: string | null = null; if (fetchError) msg = `${label}: could not reach server`; else if (status?.disabled) msg = `${label}: 511NY feed is off — an admin enables it under Admin → Camera providers`; else if (status?.error) { const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet"; msg = `${label}: 511NY feed error, ${age}`; } else if (status?.truncated) msg = `${label}: showing ${1500} of ${status.total_in_bbox} — zoom in`; if (!msg) return null; return createPortal(
{msg}
, map.getContainer(), ); } /** Provider id → what must render with its imagery. */ function creditsByProvider(providers: CameraProviderFeed[]): Map { return new Map(providers.map((p) => [p.id, p])); } /** Same honesty as FeedProblem, for the multi-provider camera layer. */ function CameraProblem({ map, data, loaded, fetchError }: { map: L.Map; data: CamerasResponse; loaded: boolean; fetchError: string | null }) { let msg: string | null = null; if (fetchError) msg = "Cameras: could not reach server"; else if (loaded && data.serving_count === 0) msg = "Cameras: no camera provider is enabled — Admin → Camera providers"; else { const bad = data.providers.find((p) => p.error); const cut = data.providers.find((p) => p.truncated); if (bad) msg = `${bad.name}: feed error, ${bad.fetched_at ? `showing data from ${fetchedLabel(bad.fetched_at)}` : "no data yet"}`; else if (cut) msg = `Cameras: too many in view — zoom in`; } if (!msg) return null; return createPortal(
{msg}
, map.getContainer(), ); } /** Popup body for one camera: still, credit, catalog freshness, actions. */ function CameraPopupBody({ c, feed, onPin, onDock, onOpen }: { c: Camera; feed?: CameraProviderFeed; onPin: () => void; onDock: () => void; onOpen?: () => void; }) { return (
{c.name}
{c.roadway &&
{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}
} {/* Popup content mounts on open, so the still is fetched per open. */}
{onOpen && }
{c.video_url ? "Live feed available" : "Snapshot"}
); } // Open cameras: each is either pinned (a small tile anchored at the camera, so // several views along one road read against the map) or docked (a larger tile // in a strip over the map, which stays put while you pan and survives the // overlay being switched off). Oldest camera drops past the cap. const MAX_OPEN_CAMERAS = 6; type CameraMode = "pin" | "dock"; interface OpenCamera { cam: Camera; mode: CameraMode } const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5"; function CameraDock({ map, open, credits, onMode, onClose, footer }: { map: L.Map; open: Camera[]; credits: Map; onMode: (id: string, mode: CameraMode) => void; onClose: (id: string) => void; /** * Stacked under the tiles (server-26#278): the weather status chip and radar * badge live in the same bottom-centre spot, so while the dock is open they * ride in its column instead of being covered by it. */ footer?: ReactNode; }) { const ref = useRef(null); useEffect(() => { // Portaled into the Leaflet container — keep clicks/scrolls off the map. if (!ref.current) return; L.DomEvent.disableClickPropagation(ref.current); L.DomEvent.disableScrollPropagation(ref.current); }, [open.length > 0]); // eslint-disable-line react-hooks/exhaustive-deps if (!open.length) return null; return createPortal(
{open.map((c) => (
{c.name}
))}
{footer &&
{footer}
}
, map.getContainer(), ); } function DotCameraLayer({ onOpenCamera, dockFooter, onDockedChange }: { onOpenCamera?: (c: Camera) => void; dockFooter?: ReactNode; /** How many tiles are docked bottom-centre, so MapView can move what sits there (server-26#278). */ onDockedChange?: (n: number) => void; }) { const map = useMap(); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const zoom = useMapZoom(map); const { data, error, loaded } = useMapCameras(map, shown); const [openAll, setOpen] = useState([]); // Credits from the last answer received while the overlay was on. Hiding the // overlay empties `data`, but docked players stay up and still need their // attribution. A pinned/docked camera whose provider an admin has since // switched off (licence gating, server-26#198) drops out on the next answer. const [feeds, setFeeds] = useState>(() => new Map()); useEffect(() => { if (shown && loaded) setFeeds(creditsByProvider(data.providers)); }, [shown, loaded, data.providers]); const open = useMemo(() => openAll.filter((o) => feeds.has(o.cam.provider)), [openAll, feeds]); const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]); const cells = useMemo( () => lodCells(data.cameras.filter((c) => !pinnedIds.has(c.id)), map, zoom), [data.cameras, pinnedIds, map, zoom], ); const show = (cam: Camera, mode: CameraMode) => { map.closePopup(); setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS)); }; const setMode = (id: string, mode: CameraMode) => setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o))); const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id)); const docked = useMemo(() => open.filter((o) => o.mode === "dock").map((o) => o.cam), [open]); useEffect(() => { onDockedChange?.(docked.length); }, [docked.length, onDockedChange]); useEffect(() => () => onDockedChange?.(0), [onDockedChange]); return ( <> {shown && } {cells.map(({ rep: c, count }) => count > 1 ? ( zoomInto(map, c.lat, c.lon) }} /> ) : ( show(c, "pin")} onDock={() => show(c, "dock")} onOpen={onOpenCamera ? () => { map.closePopup(); onOpenCamera(c); } : undefined} /> ))} {/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */} {shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
{c.name}
))} ); } // ── Camera viewsheds — GEV idea #4, coarse 2D cones, APPROXIMATE ───────────── // Built from the published direction of travel, not a surveyed heading/FOV. function CameraViewshedLayer({ highlightIds }: { highlightIds: Set }) { const map = useMap(); const shown = useOverlayShown(map, OVERLAY_VIEWSHEDS); const zoom = useMapZoom(map); const { data } = useMapCameras(map, shown && zoom >= 11); if (!shown) return null; return ( <> {data.cameras.map((c) => { const wedge = viewshedWedge(c); if (!wedge) return null; const hot = highlightIds.has(c.id); return ( {c.name} — approximate view (from the road's direction of travel, not a surveyed heading) ); })} ); } // ── Nearest cameras to the selected incident (live ops wall) ───────────────── // Always drawn while an incident is selected, independent of the DOT Cameras // overlay, so the wall never hides the cameras it is recommending. function nearCameraIcon(): L.DivIcon { return L.divIcon({ className: "", html: `
`, iconSize: [20, 16], iconAnchor: [10, 8], }); } function NearCameraLayer({ cameras, head, onOpenCamera }: { cameras: { cam: Camera; km: number }[]; head: [number, number] | null; onOpenCamera?: (c: Camera) => void; }) { return ( <> {head && ( )} {cameras.map(({ cam, km: d }) => ( onOpenCamera?.(cam) }}> {cam.name} · {d.toFixed(1)} km ))} ); } function TrafficEventLayer() { const map = useMap(); const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS); const zoom = useMapZoom(map); const { data, error } = use511(map, "events", shown); const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]); return ( <> {shown && } {cells.map(({ rep: e, count }) => { const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0; if (count > 1) { return ( zoomInto(map, e.lat, e.lon) }} /> ); } const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER; const start = fmtLocal(e.start_local); const end = fmtLocal(e.planned_end_local); return (
{st.label}{e.subtype ? `: ${e.subtype}` : ""}
{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}
{(start || end) &&
{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}
} {/* DAA: shown exactly as NYSDOT published it — never summarised or rewritten. */}
{e.description}
); })} ); } function nodeFanIcon(members: NodeRecord[]): L.DivIcon { const n = members.length; const CARD = 13; const STEP = 5; const totalW = CARD + (n - 1) * STEP; const maxRot = Math.min(28, n * 7); const cards = members .map((m, i) => { const ratio = n === 1 ? 0 : i / (n - 1) - 0.5; const rot = ratio * maxRot; const left = i * STEP; return `
${nodeDiamondSvg(m.status, CARD)}
`; }) .join(""); return L.divIcon({ className: "", html: `
${cards}
`, iconSize: [totalW, CARD + 6], iconAnchor: [totalW / 2, CARD + 6], }); } // ── Pin precision (server-26#236) ───────────────────────────────────────────── // A pin is only as exact as its geocode. `location_type` rides on the coords // since #236; `location_coords_source` (#23) ties a pin to the label it came // from — an incident without it predates that fix and its pin is unverifiable // (5-in-6 wrong on the 2026-08 dump; DEFERRED.md `_verified_pin` row, #28). type PinPrecision = "rooftop" | "interpolated" | "centre" | "unknown" | "unverified"; function pinPrecision(inc: IncidentRecord): PinPrecision { if (!inc.location_coords_source) return "unverified"; switch (inc.location_coords?.location_type) { case "ROOFTOP": return "rooftop"; case "RANGE_INTERPOLATED": return "interpolated"; case "GEOMETRIC_CENTER": return "centre"; default: return "unknown"; // geocoded before #236 persisted the type } } /** Nominal uncertainty drawn round a GEOMETRIC_CENTER pin. Google gives no error radius; this is a visual "around here", not a measurement. */ const CENTRE_UNCERTAINTY_M = 250; const PIN_PRECISION_LABEL: Record = { rooftop: "Street address (rooftop)", interpolated: "Interpolated along the block", centre: "Centre of a road, junction or place", unknown: "Precision not recorded", unverified: "Unverified pin (older incident)", }; // ── Incident pin — teardrop filled by severity, type glyph knocked out ─────── // Minor/routine (no alarm colour) render hollow with an ink stroke so the map // doesn't imply urgency that isn't there. Precision is carried by what sits at // the tip (server-26#236): nothing = rooftop; a small solid ring = interpolated; // a wide dashed ring = centre of a road/place; the whole pin ghosted and // dashed = unverified. `pulse` adds a pulsing ring round the head. function teardropSvg(type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop"): string { const color = severityColor(severity); const unverified = precision === "unverified"; const hollow = unverified || !isKnownSeverity(severity) || severity === "minor" || severity === "routine"; const glyphColor = hollow ? color : "var(--page)"; const fill = hollow ? "var(--surface)" : color; const stroke = hollow ? color : "var(--page)"; const dash = unverified ? ` stroke-dasharray="3 2"` : ""; const tip = precision === "interpolated" ? `` : precision === "centre" ? `` : ""; return ` ${tip} ${typeGlyphSvg(type, glyphColor, 13)} `; } // Incident icons are cached by their visual inputs (server-26#279). react-leaflet // calls setIcon whenever the icon prop's identity changes, and DivIcon rewrites // innerHTML — so building a fresh icon on every render replaced every pin's DOM // on each parent re-render and restarted the 1.8s pulse ring before it finished. // Same inputs → same L.DivIcon object → no setIcon. Bounded: cleared when large // (keys are a handful of type/severity/precision/pulse combos plus fan stacks). const INCIDENT_ICON_CACHE_MAX = 500; const incidentIconCache = new Map(); function cachedIcon(key: string, build: () => L.DivIcon): L.DivIcon { let icon = incidentIconCache.get(key); if (!icon) { if (incidentIconCache.size >= INCIDENT_ICON_CACHE_MAX) incidentIconCache.clear(); icon = build(); incidentIconCache.set(key, icon); } return icon; } function incidentIcon( type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false, ): L.DivIcon { return cachedIcon(`pin|${type ?? ""}|${severity ?? ""}|${precision}|${pulse ? 1 : 0}`, () => buildIncidentIcon(type, severity, precision, pulse)); } function buildIncidentIcon(type: string | null, severity: string | null | undefined, precision: PinPrecision, pulse: boolean): L.DivIcon { const ring = pulse ? `
` : ""; return L.divIcon({ className: "", html: `
${ring}${teardropSvg(type, severity, precision)}
`, iconSize: [32, 48], iconAnchor: [16, 37], }); } function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon { const key = `fan|${pulse ? 1 : 0}|${members.map((m) => `${m.type ?? ""}:${m.severity ?? ""}`).join(",")}`; return cachedIcon(key, () => buildIncidentFanIcon(members, pulse)); } function buildIncidentFanIcon(members: IncidentRecord[], pulse: boolean): L.DivIcon { const n = members.length; const CARD = 16; const STEP = 9; const totalW = CARD + (n - 1) * STEP; const maxRot = Math.min(28, n * 7); const cards = members .map((m, i) => { const ratio = n === 1 ? 0 : i / (n - 1) - 0.5; const rot = ratio * maxRot; const left = i * STEP; const color = severityColor(m.severity); const hollow = !isKnownSeverity(m.severity) || m.severity === "minor" || m.severity === "routine"; return `
${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}
`; }) .join(""); return L.divIcon({ className: "", html: `
${pulse ? `
` : ""}${cards}
`, iconSize: [totalW, CARD + 6], iconAnchor: [totalW / 2, CARD + 6], }); } // ── Incident path stop marker — numbered, shared index with the call spine ─── // `playing` (server-26#233): the stop whose call is on the speaker gets a // thick accent ring outside the severity halo — "where was he when he said // that", answered while it is being said. function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon { const size = playing ? 30 : 22; const c = size / 2; const fill = isFirst ? "var(--surface)" : color; const textColor = isFirst ? color : "var(--page)"; const halo = isLast ? `` : ""; const ring = playing ? `` : ""; return L.divIcon({ className: "", html: ` ${ring} ${halo} ${index} `, iconSize: [size, size], iconAnchor: [c, c], }); } // ── Fan cluster grouping ────────────────────────────────────────────────────── const CLUSTER_PX = 32; function computeGroups( items: T[], map: L.Map ): Map { if (!items.length) return new Map(); const withPx = items.map((item) => ({ item, px: map.latLngToContainerPoint([item.lat, item.lng]), })); const parent: number[] = items.map((_, i) => i); function find(x: number): number { if (parent[x] !== x) parent[x] = find(parent[x]); return parent[x]; } for (let i = 0; i < withPx.length; i++) { for (let j = i + 1; j < withPx.length; j++) { const dx = withPx[i].px.x - withPx[j].px.x; const dy = withPx[i].px.y - withPx[j].px.y; if (Math.sqrt(dx * dx + dy * dy) < CLUSTER_PX) { const ri = find(i), rj = find(j); if (ri !== rj) parent[ri] = rj; } } } const groups = new Map(); withPx.forEach(({ item }, i) => { const root = find(i); if (!groups.has(root)) groups.set(root, []); groups.get(root)!.push(item); }); const result = new Map(); Array.from(groups.values()).forEach((members) => result.set(members[0].id, members)); return result; } // ── MapRefCapture — exposes L.Map instance to parent ───────────────────────── function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) { const map = useMap(); useEffect(() => { onReady(map); }, [map, onReady]); return null; } // ── FanNodeLayer ────────────────────────────────────────────────────────────── function FanNodeLayer({ nodes, activeCalls, }: { nodes: NodeRecord[]; activeCalls: CallRecord[]; }) { const map = useMap(); const [tick, setTick] = useState(0); useEffect(() => { const h = () => setTick((t: number) => t + 1); map.on("zoomend moveend", h); return () => { map.off("zoomend moveend", h); }; }, [map]); const activeByNode = useMemo( () => Object.fromEntries(activeCalls.map((c) => [c.node_id, c])), [activeCalls] ); const nodeById = useMemo(() => new Map(nodes.map((n) => [n.node_id, n])), [nodes]); const groups = useMemo(() => { const items = nodes.map((n) => ({ id: n.node_id, lat: n.lat, lng: n.lon })); return computeGroups(items, map); // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodes, map, tick]); return ( <> {(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => { const members = raw.map((r) => nodeById.get(r.id)!).filter(Boolean); const rep = nodeById.get(repId); if (!rep) return null; return ( 1 ? nodeFanIcon(members) : nodeIcon(rep.status)} >
{members.map((node, idx) => (

{node.name}

{node.node_id}

{node.status}

{activeByNode[node.node_id] && (

● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "} {activeByNode[node.node_id].talkgroup_name}

)}
))}
); })} ); } // ── FanIncidentLayer ────────────────────────────────────────────────────────── function FanIncidentLayer({ incidents, onSelect, selectedId, pulseIds, }: { incidents: IncidentRecord[]; onSelect: (inc: IncidentRecord) => void; selectedId?: string | null; /** Incident pins that get a pulsing ring (generic — the caller decides why). */ pulseIds?: Set; }) { const map = useMap(); const [tick, setTick] = useState(0); useEffect(() => { const h = () => setTick((t: number) => t + 1); map.on("zoomend moveend", h); return () => { map.off("zoomend moveend", h); }; }, [map]); const plotted = useMemo( () => incidents .filter((i) => i.location_coords) .map((i) => ({ id: i.incident_id, lat: i.location_coords!.lat, lng: i.location_coords!.lng, inc: i, })), [incidents] ); const incById = useMemo( () => new Map(plotted.map((p: { id: string; lat: number; lng: number; inc: IncidentRecord }) => [p.id, p.inc])), [plotted] ); const groups = useMemo( () => computeGroups(plotted, map), // eslint-disable-next-line react-hooks/exhaustive-deps [plotted, map, tick] ); return ( <> {/* GEOMETRIC_CENTER pins (server-26#236): a dashed "around here" ring on the ground, labelled with the place as it was spoken. */} {plotted .filter((p) => pinPrecision(p.inc) === "centre") .map((p) => ( {p.inc.location && ( ≈ {p.inc.location} )} ))} {(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => { const members = raw.map((r) => incById.get(r.id)!).filter(Boolean); const repPlot = plotted.find((p: { id: string }) => p.id === repId); if (!repPlot) return null; return ( 1 ? incidentFanIcon(members, members.some((m) => pulseIds?.has(m.incident_id))) : incidentIcon(repPlot.inc.type, repPlot.inc.severity, pinPrecision(repPlot.inc), !!pulseIds?.has(repPlot.inc.incident_id))} opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1} zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0} eventHandlers={{ click: () => onSelect(repPlot.inc) }} >
{members.map((inc, idx) => { const color = severityColor(inc.severity); return (

{inc.title ?? "Incident"}

{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}

{inc.location &&

{inc.location}

}

Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}

{ e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }} className="text-xs text-blue-600 hover:underline block mt-0.5" > View incident →
); })} {/* Gate A / A2 (server-26#46) — same screen as the output. */}
); })} ); } // ── Incident path layer — the flagship feature: a pursuit as a polyline ────── // Per UI_REDESIGN.md §2.4/§5.1: an incident's map stops use the SAME index as // its call timeline, so "where was he when he said that" is answered by // looking, not cross-referencing timestamps. Needs no backend — per-call // location_coords are already written by intelligence.py. // // server-26#236 — honest paths. Stops come from liveWall.pathStops(), the same // helper the call spine numbers from: // • only a movement incident (police/accident with a pursuit tag) draws a // numbered path; anything else draws small unnumbered call dots, so a fire // with mutual aid "responding from Station 3" never reads as a chase; // • a segment over a long silence or across a talkgroup change is dashed; // • a stop that implies > 8 km/min is ghosted and left off the path, with a // faint dotted spur so the bad geocode is still visible; // • a path longer than MAY_BE_MERGED_KM carries a "may be merged" badge. function ghostStopIcon(color: string): L.DivIcon { return L.divIcon({ className: "", html: ` ? `, iconSize: [16, 16], iconAnchor: [8, 8], }); } function callDotIcon(color: string, playing: boolean): L.DivIcon { const size = playing ? 18 : 10; const c = size / 2; return L.divIcon({ className: "", html: ` ${playing ? `` : ""} `, iconSize: [size, size], iconAnchor: [c, c], }); } function mayBeMergedIcon(lengthKm: number): L.DivIcon { const label = `may be merged · ${Math.round(lengthKm)} km`; const w = 8 + label.length * 5.6; return L.divIcon({ className: "", html: `
${label}
`, iconSize: [w, 16], iconAnchor: [w / 2, -10], }); } function IncidentPathLayer({ incidents, callsByIncident, }: { incidents: IncidentRecord[]; callsByIncident: Map; }) { // Only the playing id, so a timeupdate never re-renders the map. const playingCallId = useAudio((s) => s.playingCallId); const paths = useMemo( () => incidents.map((inc) => ({ inc, path: pathStops(inc, callsByIncident.get(inc.incident_id)) })), [incidents, callsByIncident], ); return ( <> {paths.map(({ inc, path }) => { if (path.stops.length < 2) return null; const color = severityColor(inc.severity); const kept = path.stops.filter((s) => !s.ghost); const numbered = kept.length; // Segments between consecutive kept stops — movement incidents only. const segments = path.movement ? kept.slice(1).map((s, i) => ({ from: kept[i].at, to: s.at, dashed: s.dashedIn, key: s.call.call_id })) : []; // A ghost's spur runs from the kept stop before it (or after it, for a ghosted first stop). const spurs = path.stops.flatMap((s, i) => { if (!s.ghost) return []; const before = path.stops.slice(0, i).reverse().find((x) => !x.ghost); const anchor = before ?? path.stops.slice(i + 1).find((x) => !x.ghost); return anchor ? [{ from: anchor.at, to: s.at, key: s.call.call_id }] : []; }); const head = kept.at(-1) ?? path.stops.at(-1)!; return ( {segments.map((seg) => ( ))} {spurs.map((sp) => ( ))} {path.stops.map((s) => { const c = s.call; const isPlaying = c.call_id === playingCallId; const icon = s.ghost ? ghostStopIcon(color) : s.n != null ? pathStopIcon(s.n, s.n === 1, s.n === numbered, color, isPlaying) : callDotIcon(color, isPlaying); const heading = s.ghost ? "Off the path — this location implies an impossible speed" : s.n != null ? `Stop ${s.n} of ${numbered}` : `Call at ${new Date(c.started_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`; return (

{heading}

{inc.title ?? "Incident"}

{c.location &&

{c.location}

} {c.location_coords?.location_type === "GEOMETRIC_CENTER" && (

Approximate: centre of a road, junction or place

)} {s.dashedIn && !s.ghost && path.movement && (

Dashed in: long gap or talkgroup change — route unknown

)} {hasAudio(c) && (
Play this call
)} View incident → {/* Gate A / A2 (server-26#46) — the stop location and its ordering come from the transcript, not from GPS. */}
); })} {path.mayBeMerged && ( )}
); })} ); } // ── Helpers ─────────────────────────────────────────────────────────────────── function timeAgo(date: Date): string { const s = Math.floor((Date.now() - date.getTime()) / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; return `${Math.floor(s / 3600)}h ago`; } const NO_NEAR: { cam: Camera; km: number }[] = []; // ── Main MapView ────────────────────────────────────────────────────────────── interface Props { nodes: NodeRecord[]; activeCalls: CallRecord[]; incidents?: IncidentRecord[]; /** * Calls to draw incident paths from — needs `location_coords` and * `incident_ids`/`incident_id`. Grouped internally by incident. Pass the * broadest set of recently-loaded calls the caller has (e.g. from * useCalls); an incident with fewer than 2 geocoded calls in this set * simply draws no path. */ calls?: CallRecord[]; lastUpdated?: Date | null; // ── Live ops wall (all optional; without them MapView behaves as before) ── /** The incident drilled into; the map fits its path/pin and dims the rest. */ selectedIncidentId?: string | null; /** When set, rail cards and pins select instead of only flying to the pin. */ onSelectIncident?: (id: string) => void; /** Nearest cameras to the selected incident — always drawn, highlighted. */ nearCameras?: { cam: Camera; km: number }[]; onOpenCamera?: (c: Camera) => void; /** Incident ids with a live call on one of their talkgroups. */ onAir?: Set; /** Cameras within range of each incident (rail "N cams"). */ camCountByIncident?: Map; /** * Incident pins that get a pulsing ring. Generic on purpose — the caller * decides what deserves attention (server-26#236; wired by #238). */ pulseIncidentIds?: Set; // ── 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; /** * Set while the caller's incident filter is on (server-26#268): the * unfiltered active count, so the rail header reads "N of M · filtered". */ filteredFrom?: number; } 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 }); } // Memoised (server-26#279): the live wall re-renders every second for its // clock; with stable props that tick no longer re-renders the whole map tree. // MapView keeps its own 10s tick (agoClock) for the rail's relative ages. export default memo(MapView); function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated, selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident, pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom, }: Props) { const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); // 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")); const [outagesShown, setOutagesShown] = useState(false); const [radarShown, setRadarShown] = useState(() => startLayers.has("radar")); const [wxShown, setWxShown] = useState(() => startLayers.has("wx")); const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted const loopClock = useMemo(createLoopClock, []); // The altitude key only belongs in the legend while the opt-in Aircraft // overlay is actually on (server-26#185); same for the outage key (#184). useEffect(() => { if (!mapInstance) return; const on = (e: L.LayersControlEvent) => { if (e.name === LAYER_NAMES.air) setAircraftShown(true); if (e.name === OVERLAY_OUTAGES) setOutagesShown(true); if (e.name === LAYER_NAMES.radar) setRadarShown(true); if (e.name === LAYER_NAMES.wx) setWxShown(true); }; const off = (e: L.LayersControlEvent) => { if (e.name === LAYER_NAMES.air) setAircraftShown(false); if (e.name === OVERLAY_OUTAGES) setOutagesShown(false); if (e.name === LAYER_NAMES.radar) setRadarShown(false); if (e.name === LAYER_NAMES.wx) setWxShown(false); }; mapInstance.on("overlayadd", on); mapInstance.on("overlayremove", off); return () => { mapInstance.off("overlayadd", on); mapInstance.off("overlayremove", off); }; }, [mapInstance]); useEffect(() => { const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000); return () => clearInterval(id); }, []); // ── Weather (server-26#237): fetched only while the overlay is on ───────── const radarMeta = useRadarMeta(radarShown); const wxAlerts = useMapWeatherAlerts(mapInstance, wxShown); const loopOn = radarShown && radarLoop; useEffect(() => { loopClock.run(loopOn); return () => loopClock.run(false); }, [loopClock, loopOn]); // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const [dockedCams, setDockedCams] = useState(0); const weatherStatus = radarShown || wxShown ? ( <> {wxShown && } {radarShown && } ) : null; const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]); const railIncidents = useMemo(() => worstFirst(incidents), [incidents]); const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]); const selectedPoints = useMemo(() => { const inc = incidents.find((i) => i.incident_id === selectedIncidentId); return inc ? incidentPoints(inc, callsByIncident.get(inc.incident_id)) : []; }, [incidents, selectedIncidentId, callsByIncident]); const allPositions = useMemo( () => [ ...nodes.map((n) => [n.lat, n.lon] as [number, number]), ...incidents .filter((i) => i.location_coords) .map((i) => [i.location_coords!.lat, i.location_coords!.lng] as [number, number]), ], [nodes, incidents] ); 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 = startView ? startView.z : nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4; const handleFitAll = useCallback(() => { if (!mapInstance || allPositions.length === 0) return; if (allPositions.length === 1) { mapInstance.setView(allPositions[0], 14); } else { mapInstance.fitBounds(L.latLngBounds(allPositions), { padding: [40, 40] }); } }, [mapInstance, allPositions]); const handleIncidentSelect = useCallback( (inc: IncidentRecord) => { if (onSelectIncident) { onSelectIncident(inc.incident_id); return; } // the fit happens below if (!mapInstance || !inc.location_coords) return; mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 }); }, [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); // Report the starting state once, so the URL carries the viewport even if // the viewer never pans: a copied link must not fall back to the other // browser's remembered view. emit(); return () => { clearTimeout(timer); mapInstance.off("overlayadd", onAdd); mapInstance.off("overlayremove", onRemove); mapInstance.off("baselayerchange", onBase); mapInstance.off("moveend", onMove); }; }, [mapInstance, startLayers, startBase]); // server-26#137: closing an incident flies back to where the viewer was. const prevSelected = useRef(selectedIncidentId); useEffect(() => { const prev = prevSelected.current; prevSelected.current = selectedIncidentId; if (!mapInstance || !prev || selectedIncidentId || !regionView.current) return; const v = regionView.current; mapInstance.flyTo([v.lat, v.lon], v.z, { duration: 1.0 }); }, [mapInstance, selectedIncidentId]); // Wall drill-down: fit the selected incident's whole path (or its pin). const selectedKey = selectedPoints.map((p) => p.join(",")).join(";"); useEffect(() => { if (!mapInstance || !selectedPoints.length) return; if (selectedPoints.length === 1) mapInstance.flyTo(selectedPoints[0], 14, { duration: 1.0 }); else mapInstance.flyToBounds(L.latLngBounds(selectedPoints), { padding: [60, 60], maxZoom: 15, duration: 1.0 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapInstance, selectedIncidentId, selectedKey]); const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []); return (
{/* ── Map container ───────────────────────────────────────────────────── */} {/* 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: Power outages (server-26#184) — licence-gated providers, opt-in */} {/* Overlay: Weather Radar — NEXRAD n0q via Iowa Env Mesonet (server-26#237) */} {/* Overlay: NWS watches/warnings/advisories (server-26#237) */} {selectedIncidentId && ( )} {/* ── Live timestamp ───────────────────────────────────────────────────── */} {ago && (
● Live · {ago}
)} {/* ── Weather status (server-26#237) — bottom centre, clear of the rail (left), the legend (right) and the 511/outage banners (bottom-left). While camera tiles are docked in that same spot it moves into the dock's column, under the tiles, so STALE / feed errors stay visible (#278). */} {weatherStatus && dockedCams === 0 && (
{weatherStatus}
)} {/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
{mapInstance && allPositions.length > 0 && ( )}
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}

Severity

{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
{sev === "major" && ( )} {sev === "moderate" && ( )} {sev === "minor" && ( )} {sev === "routine" && ( )} {SEVERITY_LABEL[sev]}
))}
{/* Pin precision + path honesty (server-26#236). Collapsed by default: on the 352px incident-page map the full key would overflow a legend that can't scroll (pointer-events are off so the map pans under it). */}
▸▾ Pin precision & paths
{(["rooftop", "interpolated", "centre", "unverified"] as PinPrecision[]).map((p) => (
{p === "rooftop" ? "Street address / not recorded" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}
))}

Paths

1 Pursuit stop (numbered)
Gap > {PATH_GAP_DASH_MIN} min or TG change
Impossible speed — off path
Call location (not a pursuit)
merged? Path > {MAY_BE_MERGED_KM} km — may be 2+ events
{aircraftShown && (

Aircraft altitude

`${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }} />
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => {label})}
)} {radarShown && (

Radar reflectivity (dBZ)

`${n0qColor(d)} ${(i / (RADAR_LEGEND_DBZ.length - 1)) * 100}%`).join(", ")})` }} />
{RADAR_LEGEND_TICKS.map((d) => {d})}

~20 light · 40 moderate · 50+ heavy rain / hail

)} {wxShown && (

NWS alerts

{(["warning", "watch", "advisory"] as const).map((k) => (
{k === "warning" ? "Warning" : k === "watch" ? "Watch" : "Advisory / statement"}
))}

Fill colour = NWS hazard-map colour for the event

)} {outagesShown && (

Power outages

County with outages · size = meters out
Stale upstream rows only
)}

Nodes

{([ ["recording", "Recording"], ["online", "Online"], ["offline", "Offline"], ["unconfigured", "Unconfigured"], ] as [NodeStatus, string][]).map(([status, label]) => (
{label}
))}
{/* ── Incident overlay panel ───────────────────────────────────────────── */} {incidents.length > 0 && ( <> {/* Desktop: left sidebar — offset below the zoom stack + fit-all button so it never overlaps the Leaflet +/- controls (#118). Height is capped and the list scrolls on its own, so the rail never reaches the bottom-right legend. pointer-events are off on the wrapper and back on for the cards, so the map still pans in the gaps. */}
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and unit counts are pipeline output. Pinned above the scroll area so it cannot be scrolled off the screen it qualifies. */}
{filteredFrom != null && (

{incidents.length} of {filteredFrom} · filtered

)}
{railIncidents.map((inc) => { const color = severityColor(inc.severity); const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0; const units = (inc.units_active?.length ? inc.units_active : inc.units) ?? []; const selected = inc.incident_id === selectedIncidentId; const live = onAir?.has(inc.incident_id) ?? false; const cams = camCountByIncident?.get(inc.incident_id) ?? 0; const callCount = inc.call_ids?.length ?? 0; // "Play latest" (server-26#233): newest call of this incident in // the loaded calls that has a recording. const latest = (callsByIncident.get(inc.incident_id) ?? []) .filter(hasAudio) .reduce((best, c) => (!best || c.started_at > best.started_at ? c : best), null); const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`; const cardBody = ( <>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {live && ON AIR}

{inc.title ?? "Incident"}

{inc.location && (

{inc.location}

)} {onSelectIncident && units.length > 0 && (
{units.slice(0, 4).map((u) => ( {u} ))} {units.length > 4 && +{units.length - 4}}
)}
{age && {age}} {onSelectIncident ? ( {callCount} call{callCount !== 1 ? "s" : ""}{cams ? ` · ${cams} cam${cams !== 1 ? "s" : ""}` : ""} ) : unitCount > 0 && ( {unitCount} unit{unitCount !== 1 ? "s" : ""} )}
{!inc.location_coords && !onSelectIncident && (

View details →

)} ); // The play control is a sibling of the card, not inside it — a // {playLatest}
); } return (
{cardBody} {playLatest}
); })}
{/* Mobile: bottom drawer */}
{drawerOpen && (
{/* Gate A / A2 (server-26#46) */} {railIncidents.map((inc) => { const color = severityColor(inc.severity); const label = ( <> {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {" — "} {inc.title ?? "Incident"} ); if (inc.location_coords || onSelectIncident) { return ( ); } return ( {label} ); })}
)}
)}
); }