From 9698d463ad85a043d918531f0c808072c83aefdb Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 04:01:20 -0400 Subject: [PATCH] Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #279: cache incident/fan divIcons by their visual inputs and React.memo MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon and the #238 pulse ring completes its cycle. - #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at, and labels live cameras/context as current, not incident-time. - #268: rail header (desktop + mobile drawer) reads "N of M · filtered" via a new filteredFrom prop while the wall filter is on. - #278: while camera tiles are docked bottom-centre, the weather status chip and radar badge stack under the tiles inside the dock instead of being covered. Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/LiveView.tsx | 1 + drb-frontend/components/MapView.tsx | 93 ++++++++++++++++++--- drb-frontend/components/live/WallPanels.tsx | 25 ++++-- 3 files changed, 101 insertions(+), 18 deletions(-) diff --git a/drb-frontend/components/LiveView.tsx b/drb-frontend/components/LiveView.tsx index 1d2a03f..0f04fb9 100644 --- a/drb-frontend/components/LiveView.tsx +++ b/drb-frontend/components/LiveView.tsx @@ -575,6 +575,7 @@ export function LiveView() { onAir={onAir} camCountByIncident={camCountByIncident} pulseIncidentIds={attention.pulseIds} + filteredFrom={filtered.active ? activeIncidents.length : undefined} key={mapGen} initialLayers={mapState.layers} initialBase={mapState.base} diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index d94cd08..1356181 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { FeatureGroup, @@ -754,12 +754,18 @@ 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 }: { +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(() => { @@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: { return createPortal(
+
{open.map((c) => (
@@ -784,12 +791,19 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
))} +
+ {footer &&
{footer}
}
, map.getContainer(), ); } -function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) { +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); @@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void } 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 ( <> @@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void } ))} - o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} /> + ); } @@ -1029,9 +1046,32 @@ function teardropSvg(type: string | null, severity: string | null | undefined, p `; } +// 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 ? `
` : ""; @@ -1044,6 +1084,11 @@ function incidentIcon( } 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; @@ -1534,6 +1579,11 @@ interface Props { * 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 } @@ -1545,10 +1595,15 @@ function currentViewport(map: L.Map): MapViewport { return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng }); } -export default function MapView({ +// 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, + pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom, }: Props) { const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); @@ -1607,6 +1662,14 @@ export default function MapView({ // 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]); @@ -1792,7 +1855,7 @@ export default function MapView({ {/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */} - + @@ -1844,11 +1907,12 @@ export default function MapView({ )} {/* ── Weather status (server-26#237) — bottom centre, clear of the rail - (left), the legend (right) and the 511/outage banners (bottom-left). */} - {(radarShown || wxShown) && ( + (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 && (
- {wxShown && } - {radarShown && } + {weatherStatus}
)} @@ -2020,6 +2084,9 @@ export default function MapView({ 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

+ )}
@@ -2116,7 +2183,7 @@ export default function MapView({ onClick={() => setDrawerOpen((v: boolean) => !v)} className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between" > - Incidents ({incidents.length}) + Incidents ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : incidents.length}) {drawerOpen ? "▼" : "▲"} {drawerOpen && ( diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx index a41a9ba..82fb7c0 100644 --- a/drb-frontend/components/live/WallPanels.tsx +++ b/drb-frontend/components/live/WallPanels.tsx @@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) { function sevLabel(s: string | null | undefined) { return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown"; } -function elapsed(iso: string): string { - const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000)); +/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */ +function elapsed(iso: string, endIso?: string | null): string { + const end = endIso ? new Date(endIso).getTime() : Date.now(); + const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000)); return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`; } function hhmm(iso: string): string { @@ -422,6 +424,13 @@ export function IncidentPanel({ onOpenCamera: (c: Camera) => void; }) { const color = sevColor(inc.severity); + // A resolved incident opened from an #inc= link (server-26#275): nothing in + // the panel may read as still running. Its duration freezes at resolved_at + // (the correlator writes it; the frontend type doesn't declare it yet), and + // the live camera / context feeds are labelled as "now", not incident time. + const resolved = inc.status === "resolved"; + const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null; + const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime()); const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]); const head = points.at(-1) ?? null; const radar = useRadarDbz(head); @@ -455,13 +464,17 @@ export function IncidentPanel({
{sevLabel(inc.severity)} {inc.type ?? "other"} - {onAir ? ON AIR : active} + {resolved ? resolved + : onAir ? ON AIR + : active} {inc.incident_id.slice(0, 8)}

{inc.title ?? "Incident"}

{inc.location && {inc.location}} - {elapsed(inc.started_at)} elapsed + {!resolved ? {elapsed(inc.started_at)} elapsed + : resolvedAtOk ? lasted {elapsed(inc.started_at, resolvedAt)} + : null} {inc.call_ids?.length ?? 0} calls {path.mayBeMerged && (

Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}

+ {resolved &&

Live views as of now, not from when this incident was active.

} {near.length ? (
{near.map(({ cam, km }) => ( @@ -498,6 +512,7 @@ export function IncidentPanel({

Situational context · fused layers

+ {resolved &&

Current conditions near the location, not at incident time.

}
    {ctx.aircraft.map(({ a, km }) => (
  • ADS-B @@ -533,7 +548,7 @@ export function IncidentPanel({
    -

    On scene

    +

    {resolved ? "Last on scene" : "On scene"}

    {unitsOn.length ? unitsOn.map((u) => {u}) : —}