Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock

- #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 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-05 04:01:20 -04:00
co-authored by Claude Opus 5.5
parent 3c5496acb1
commit 9698d463ad
3 changed files with 101 additions and 18 deletions
+80 -13
View File
@@ -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<string, CameraCredit>;
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<HTMLDivElement>(null);
useEffect(() => {
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
return createPortal(
<div
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) => (
<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">
@@ -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" />
</div>
))}
</div>
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
</div>,
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 }
</Tooltip>
</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>`;
}
// 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(
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
? `<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 {
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<L.Map | null>(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 && <WeatherWarningsStatus state={wxAlerts} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</>
) : 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 */}
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer onOpenCamera={onOpenCamera} />
<DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
</FeatureGroup>
</LayersControl.Overlay>
<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
(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 && (
<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} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
{weatherStatus}
</div>
)}
@@ -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. */}
<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" />
</div>
<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)}
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>
</button>
{drawerOpen && (