Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock (#279 #275 #268 #278) #280

Merged
logan merged 1 commits from fix/map-followups into main 2026-10-05 04:02:32 -04:00
3 changed files with 101 additions and 18 deletions
+1
View File
@@ -575,6 +575,7 @@ export function LiveView() {
onAir={onAir} onAir={onAir}
camCountByIncident={camCountByIncident} camCountByIncident={camCountByIncident}
pulseIncidentIds={attention.pulseIds} pulseIncidentIds={attention.pulseIds}
filteredFrom={filtered.active ? activeIncidents.length : undefined}
key={mapGen} key={mapGen}
initialLayers={mapState.layers} initialLayers={mapState.layers}
initialBase={mapState.base} initialBase={mapState.base}
+80 -13
View File
@@ -1,6 +1,6 @@
"use client"; "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 { createPortal } from "react-dom";
import { import {
FeatureGroup, 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"; 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; map: L.Map;
open: Camera[]; open: Camera[];
credits: Map<string, CameraCredit>; credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void; onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => 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<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
return createPortal( return createPortal(
<div <div
ref={ref} ref={ref}
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none" className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-col items-center gap-2 max-h-[calc(100%-8rem)] pointer-events-none"
> >
<div className="flex flex-wrap-reverse justify-center gap-2 min-h-0 max-w-full overflow-y-auto">
{open.map((c) => ( {open.map((c) => (
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg"> <div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
<div className="flex items-center gap-1 px-1.5 h-5"> <div className="flex items-center gap-1 px-1.5 h-5">
@@ -784,12 +791,19 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" /> <CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div> </div>
))} ))}
</div>
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
</div>, </div>,
map.getContainer(), 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 map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS); const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map); const zoom = useMapZoom(map);
@@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
const setMode = (id: string, mode: CameraMode) => const setMode = (id: string, mode: CameraMode) =>
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o))); 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 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 ( return (
<> <>
@@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
</Tooltip> </Tooltip>
</Marker> </Marker>
))} ))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} /> <CameraDock map={map} open={docked} credits={feeds} onMode={setMode} onClose={close} footer={dockFooter} />
</> </>
); );
} }
@@ -1029,9 +1046,32 @@ function teardropSvg(type: string | null, severity: string | null | undefined, p
</svg>`; </svg>`;
} }
// 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<string, L.DivIcon>();
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( function incidentIcon(
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false, type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
): L.DivIcon { ): 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 const ring = pulse
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>` ? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
: ""; : "";
@@ -1044,6 +1084,11 @@ function incidentIcon(
} }
function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon { 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 n = members.length;
const CARD = 16; const CARD = 16;
const STEP = 9; const STEP = 9;
@@ -1534,6 +1579,11 @@ interface Props {
* view last seen with nothing selected is reported instead. * view last seen with nothing selected is reported instead.
*/ */
onViewStateChange?: (state: MapViewState) => void; 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 } 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 }); 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, nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident, selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
}: Props) { }: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null); const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -1607,6 +1662,14 @@ export default function MapView({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const [dockedCams, setDockedCams] = useState(0);
const weatherStatus = radarShown || wxShown ? (
<>
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</>
) : null;
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]); const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]); const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]); 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 */} {/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}> <LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup> <FeatureGroup>
<DotCameraLayer onOpenCamera={onOpenCamera} /> <DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
</FeatureGroup> </FeatureGroup>
</LayersControl.Overlay> </LayersControl.Overlay>
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}> <LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
@@ -1844,11 +1907,12 @@ export default function MapView({
)} )}
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail {/* ── Weather status (server-26#237) — bottom centre, clear of the rail
(left), the legend (right) and the 511/outage banners (bottom-left). */} (left), the legend (right) and the 511/outage banners (bottom-left).
{(radarShown || wxShown) && ( 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 && (
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none"> <div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
{wxShown && <WeatherWarningsStatus state={wxAlerts} />} {weatherStatus}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</div> </div>
)} )}
@@ -2020,6 +2084,9 @@ export default function MapView({
unit counts are pipeline output. Pinned above the scroll area unit counts are pipeline output. Pinned above the scroll area
so it cannot be scrolled off the screen it qualifies. */} so it cannot be scrolled off the screen it qualifies. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto"> <div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
{filteredFrom != null && (
<p className="text-[11px] text-ink-2 mb-1"><b className="text-ink font-semibold tabular-nums">{incidents.length} of {filteredFrom}</b> · filtered</p>
)}
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" /> <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div> </div>
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto"> <div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
@@ -2116,7 +2183,7 @@ export default function MapView({
onClick={() => setDrawerOpen((v: boolean) => !v)} 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" className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
> >
<span>Incidents ({incidents.length})</span> <span>Incidents ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : incidents.length})</span>
<span>{drawerOpen ? "▼" : "▲"}</span> <span>{drawerOpen ? "▼" : "▲"}</span>
</button> </button>
{drawerOpen && ( {drawerOpen && (
+20 -5
View File
@@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) {
function sevLabel(s: string | null | undefined) { function sevLabel(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown"; return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
} }
function elapsed(iso: string): string { /** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000)); 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`; return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
} }
function hhmm(iso: string): string { function hhmm(iso: string): string {
@@ -422,6 +424,13 @@ export function IncidentPanel({
onOpenCamera: (c: Camera) => void; onOpenCamera: (c: Camera) => void;
}) { }) {
const color = sevColor(inc.severity); 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 ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const head = points.at(-1) ?? null; const head = points.at(-1) ?? null;
const radar = useRadarDbz(head); const radar = useRadarDbz(head);
@@ -455,13 +464,17 @@ export function IncidentPanel({
<div className="flex items-center gap-2 text-[11px] mb-1"> <div className="flex items-center gap-2 text-[11px] mb-1">
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span> <span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
<span className="text-ink-muted">{inc.type ?? "other"}</span> <span className="text-ink-muted">{inc.type ?? "other"}</span>
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>} {resolved ? <span className="text-ink-muted">resolved</span>
: onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span>
: <span className="text-ink-muted">active</span>}
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span> <span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
</div> </div>
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2> <h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3"> <div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
{inc.location && <span>{inc.location}</span>} {inc.location && <span>{inc.location}</span>}
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span> {!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
: null}
<span>{inc.call_ids?.length ?? 0} calls</span> <span>{inc.call_ids?.length ?? 0} calls</span>
{path.mayBeMerged && ( {path.mayBeMerged && (
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2" <span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
@@ -480,6 +493,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3> <h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
{near.length ? ( {near.length ? (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{near.map(({ cam, km }) => ( {near.map(({ cam, km }) => (
@@ -498,6 +512,7 @@ export function IncidentPanel({
<div className={SEC}> <div className={SEC}>
<h3 className={H3}>Situational context · fused layers</h3> <h3 className={H3}>Situational context · fused layers</h3>
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
<ul className="space-y-1 text-xs text-ink-2"> <ul className="space-y-1 text-xs text-ink-2">
{ctx.aircraft.map(({ a, km }) => ( {ctx.aircraft.map(({ a, km }) => (
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span> <li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
@@ -533,7 +548,7 @@ export function IncidentPanel({
<div className={`${SEC} grid grid-cols-2 gap-3`}> <div className={`${SEC} grid grid-cols-2 gap-3`}>
<div> <div>
<h3 className={H3}>On scene</h3> <h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div> <div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
</div> </div>
<div> <div>