Files
server-26/drb-frontend/components/MapView.tsx
T
Logan CusanoandClaude Opus 5.5 3347ced863 511NY DOT cameras + traffic events map layers (#183)
GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event
feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) --
public data, so no Firestore writes. A failed refresh keeps the last good data
and reports the error; a schema change (nothing parses) is an error, not an
empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that
fetch only while shown, with an on-map notice when the feed is down or stale.
NY511_API_KEY is optional in config (the API answers without one today; the
terms require a registered key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:26:38 -04:00

1185 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
FeatureGroup,
LayersControl,
MapContainer,
Marker,
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 type { Ny511Event, Ny511FeedStatus } from "@/lib/types";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._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 = "&copy; 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<string, string> = {
fire: '<path d="M12 2c1 3-2 4-2 7a3 3 0 0 0 6 0c1.5 1.5 2 3.5 2 5a6 6 0 0 1-12 0c0-3 1.5-4.5 3-6.5C10 5.5 11 4 12 2Z"/>',
police: '<path d="M12 2 4 5v6c0 5 3.4 8.7 8 9 4.6-.3 8-4 8-9V5l-8-3Z"/><path d="M9 12l2 2 4-4"/>',
ems: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 7v10M7 12h10"/>',
collision: '<path d="M3 16l3-7 4 2 2-5 4 3 3-2 2 6"/><path d="M3 16h18M6 16v3M18 16v3"/>',
other:
'<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 4.7-1.2c.5.9.2 1.6-.7 2.3-.9.7-1.5 1.2-1.5 2.4"/><circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none"/>',
};
function typeGlyphSvg(type: string | null | undefined, color: string, size = 13): string {
const key = type === "accident" ? "collision" : type && TYPE_GLYPH_PATHS[type] ? type : "other";
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${TYPE_GLYPH_PATHS[key]}</svg>`;
}
// ── 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 `<polygon points="${pts}" fill="var(--ink)" stroke="var(--accent)" stroke-width="1.5"/>`;
}
if (status === "online") {
return `<polygon points="${pts}" fill="var(--ink)"/>`;
}
if (status === "unconfigured") {
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5" stroke-dasharray="2.5 2"/>`;
}
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5"/>`;
}
function nodeIcon(status: NodeStatus): L.DivIcon {
const size = 14;
const isRec = status === "recording";
const ring = isRec
? `<div class="node-pulse-ring" style="position:absolute;width:${size * 2}px;height:${size * 2}px;border-radius:50%;border:2px solid var(--accent);top:-${size / 2}px;left:-${size / 2}px;pointer-events:none;"></div>`
: "";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px">${ring}<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${nodeDiamondSvg(status, size)}</svg></div>`,
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:
`<div style="width:${size}px;height:${size}px;transform:rotate(${trackDeg ?? 0}deg);filter:${shadow}">` +
`<svg width="${size}" height="${size}" viewBox="0 0 64 64"><path d="${AIRLINER_PATH}" ` +
`fill="${altitudeColor(altFt)}" stroke="${outline}" stroke-width="${selected ? 3 : 2}" stroke-linejoin="round"/></svg></div>`,
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) => (
<Polyline
key={`${icao}-${i}`}
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
interactive={false}
/>
))}
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
<div className="text-ink font-medium tabular-nums">{value}</div>
</div>
);
}
// 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<HTMLDivElement>(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 (
<div
ref={ref}
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
</div>
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
</div>
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
×
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
</div>
</div>
);
}
function AircraftLayer() {
const map = useMap();
const { aircraft } = useAircraft();
const [selected, setSelected] = useState<string | null>(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 && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
{selectedTrack &&
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
{positioned.map((a) => (
<Marker
key={a.icao}
position={[a.lat as number, a.lon as number]}
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
zIndexOffset={a.icao === selected ? 1000 : 0}
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
>
<Tooltip direction="top" offset={[0, -14]}>
{a.callsign || a.icao}
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
</Tooltip>
</Marker>
))}
</>
);
}
// ── 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:
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function aidToNavigationIcon(): L.DivIcon {
const size = 12;
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
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 (
<Marker
key={v.mmsi}
position={[v.lat as number, v.lon as number]}
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
zIndexOffset={aton ? -100 : 0}
>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
// Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
function useOverlayShown(map: L.Map, name: string): boolean {
const [shown, setShown] = useState(false);
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;
}
function cameraIcon(): L.DivIcon {
return L.divIcon({
className: "",
html: `<svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>`,
iconSize: [16, 12],
iconAnchor: [8, 6],
});
}
// Shape + glyph per 511 event type, so the layer reads without colour alone.
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
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" };
function trafficEventIcon(type: string): L.DivIcon {
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
return L.divIcon({
className: "",
html: `<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
}
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?.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(
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
map.getContainer(),
);
}
function DotCameraLayer() {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const { data, error } = use511(map, "cameras", shown);
return (
<>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{(data.cameras ?? []).map((c) => (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}>
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open.
// eslint-disable-next-line @next/next/no-img-element
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
)}
<div className="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div>
</div>
</Popup>
</Marker>
))}
</>
);
}
function TrafficEventLayer() {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
const { data, error } = use511(map, "events", shown);
return (
<>
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
{(data.events ?? []).map((e: Ny511Event) => {
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
const start = fmtLocal(e.start_local);
const end = fmtLocal(e.planned_end_local);
return (
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={e.type === "accidentsAndIncidents" ? 200 : 0}>
<Popup minWidth={220} maxWidth={320}>
<div className="space-y-1">
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
<div className="text-xs whitespace-pre-line">{e.description}</div>
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
</div>
</Popup>
</Marker>
);
})}
</>
);
}
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 `<div style="position:absolute;width:${CARD}px;height:${CARD}px;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;"><svg width="${CARD}" height="${CARD}" viewBox="0 0 ${CARD} ${CARD}">${nodeDiamondSvg(m.status, CARD)}</svg></div>`;
})
.join("");
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
iconSize: [totalW, CARD + 6],
iconAnchor: [totalW / 2, CARD + 6],
});
}
// ── 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.
function incidentIcon(type: string | null, severity: string | null | undefined): L.DivIcon {
const color = severityColor(severity);
const hollow = !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 svg = `
<svg width="28" height="36" viewBox="0 0 28 36">
<path d="M14 1C6.8 1 1 6.8 1 14c0 9.5 13 21 13 21s13-11.5 13-21C27 6.8 21.2 1 14 1Z"
fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<g transform="translate(7.5,7.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
</svg>`;
return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] });
}
function incidentFanIcon(members: IncidentRecord[]): 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 `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${hollow ? "var(--surface)" : color};border:1.5px solid ${color};left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;">${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}</div>`;
})
.join("");
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
iconSize: [totalW, CARD + 6],
iconAnchor: [totalW / 2, CARD + 6],
});
}
// ── Incident path stop marker — numbered, shared index with the call spine ───
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
const size = 22;
const fill = isFirst ? "var(--surface)" : color;
const textColor = isFirst ? color : "var(--page)";
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
${halo}
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
</svg>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
// ── Fan cluster grouping ──────────────────────────────────────────────────────
const CLUSTER_PX = 32;
function computeGroups<T extends { id: string; lat: number; lng: number }>(
items: T[],
map: L.Map
): Map<string, T[]> {
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<number, T[]>();
withPx.forEach(({ item }, i) => {
const root = find(i);
if (!groups.has(root)) groups.set(root, []);
groups.get(root)!.push(item);
});
const result = new Map<string, T[]>();
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 (
<Marker
key={repId}
position={[rep.lat, rep.lon]}
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
>
<Popup minWidth={160}>
<div className="space-y-2">
{members.map((node, idx) => (
<div
key={node.node_id}
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
>
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
<p className="text-xs capitalize text-gray-700">{node.status}</p>
{activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-0.5">
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
{activeByNode[node.node_id].talkgroup_name}
</p>
)}
</div>
))}
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── FanIncidentLayer ──────────────────────────────────────────────────────────
function FanIncidentLayer({
incidents,
onSelect,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
}) {
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 (
<>
{(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 (
<Marker
key={repId}
position={[repPlot.lat, repPlot.lng]}
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
>
<Popup minWidth={180}>
<div className="space-y-2">
{members.map((inc, idx) => {
const color = severityColor(inc.severity);
return (
<div
key={inc.incident_id}
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
>
<p className="font-semibold text-sm text-gray-900">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize font-medium" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
</p>
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
<a
href={`/incidents/${inc.incident_id}`}
onClick={(e) => { 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 →
</a>
</div>
);
})}
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── 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.
function IncidentPathLayer({
incidents,
callsByIncident,
}: {
incidents: IncidentRecord[];
callsByIncident: Map<string, CallRecord[]>;
}) {
return (
<>
{incidents.map((inc) => {
const calls = (callsByIncident.get(inc.incident_id) ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at));
if (calls.length < 2) return null;
const color = severityColor(inc.severity);
const positions = calls.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as [number, number]);
return (
<FeatureGroup key={inc.incident_id}>
<Polyline
positions={positions}
pathOptions={{ color, weight: 3, opacity: 0.85, lineJoin: "round" }}
/>
{calls.map((c, i) => (
<Marker
key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
))}
</FeatureGroup>
);
})}
</>
);
}
// ── 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`;
}
// ── 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;
}
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
const [aircraftShown, setAircraftShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185).
useEffect(() => {
if (!mapInstance) return;
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
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);
}, []);
// Radar tiles are static once loaded — force remount every 5 min to refresh
useEffect(() => {
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const callsByIncident = useMemo(() => {
const map = new Map<string, CallRecord[]>();
for (const c of calls) {
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
for (const id of ids) {
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(c);
}
}
return map;
}, [calls]);
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] =
nodes.length > 0
? [nodes[0].lat, nodes[0].lon]
: allPositions.length > 0
? allPositions[0]
: [39.5, -98.35];
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
const 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 (!mapInstance || !inc.location_coords) return;
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
},
[mapInstance]
);
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
return (
<div className="relative w-full h-full">
{/* ── Map container ───────────────────────────────────────────────────── */}
<MapContainer
center={center}
zoom={zoom}
className="w-full h-full"
style={{ background: "var(--map-bg)" }}
>
<MapRefCapture onReady={onMapReady} />
<LayersControl position="topright">
{/* Base layers */}
<LayersControl.BaseLayer checked name="Dark">
<TileLayer
url={MAP_TILE_URL}
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
{/* Overlay: Nodes */}
<LayersControl.Overlay checked name="Nodes">
<FeatureGroup>
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Incident paths — the flagship feature */}
<LayersControl.Overlay checked name="Incident Paths">
<FeatureGroup>
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
<LayersControl.Overlay name="Aircraft">
<FeatureGroup>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
<LayersControl.Overlay name="Vessels">
<FeatureGroup>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
<FeatureGroup>
<TrafficEventLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
<LayersControl.Overlay name="Weather Radar">
<TileLayer
key={radarEpoch}
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
</LayersControl.Overlay>
</LayersControl>
</MapContainer>
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
{ago && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-accent whitespace-nowrap">
● Live · {ago}
</span>
</div>
)}
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
{mapInstance && allPositions.length > 0 && (
<button
onClick={handleFitAll}
title="Fit all markers in view"
className="w-8 h-8 bg-surface/90 border border-line rounded text-ink text-base leading-none hover:bg-raised transition-colors flex items-center justify-center select-none"
>
⤢
</button>
)}
</div>
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
<div className="space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
<div key={sev} className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
{sev === "major" && (
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill={SEVERITY_COLORS.major} /></svg>
)}
{sev === "moderate" && (
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill="none" stroke={SEVERITY_COLORS.moderate} strokeWidth={1.75} /></svg>
)}
{sev === "minor" && (
<svg width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.minor} strokeWidth={1.75} /></svg>
)}
{sev === "routine" && (
<svg width="12" height="12" viewBox="0 0 16 16" style={{ opacity: 0.6 }}><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.routine} strokeWidth={1.25} /></svg>
)}
</span>
<span className="text-ink-2">{SEVERITY_LABEL[sev]}</span>
</div>
))}
</div>
{aircraftShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
<div
className="h-2 w-32 rounded-sm border border-line"
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
/>
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
</div>
</div>
)}
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
{([
["recording", "Recording"],
["online", "Online"],
["offline", "Offline"],
["unconfigured", "Unconfigured"],
] as [NodeStatus, string][]).map(([status, label]) => (
<div key={status} className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="11" height="11" viewBox="0 0 11 11" dangerouslySetInnerHTML={{ __html: nodeDiamondSvg(status, 11) }} />
</span>
<span className="text-ink-2">{label}</span>
</div>
))}
</div>
</div>
{/* ── 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. */}
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
{/* 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. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
<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">
{incidents.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 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";
const cardBody = (
<>
<div className="flex items-center gap-1.5 mb-0.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
</div>
<p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"}
</p>
{inc.location && (
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
)}
<div className="flex items-center justify-between mt-0.5">
{age && <span className="text-ink-muted font-mono">{age}</span>}
{unitCount > 0 && (
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
)}
</div>
{!inc.location_coords && (
<p className="text-[10px] text-accent mt-1">View details →</p>
)}
</>
);
if (inc.location_coords) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
className={baseClass}
style={{ borderColor: color + "55" }}
>
{cardBody}
</button>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</a>
);
})}
</div>
</div>
{/* Mobile: bottom drawer */}
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
<button
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>{drawerOpen ? "▼" : "▲"}</span>
</button>
{drawerOpen && (
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{incidents.map((inc) => {
const color = severityColor(inc.severity);
const label = (
<>
<span className="font-semibold" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
{" — "}
<span className="text-ink">{inc.title ?? "Incident"}</span>
</>
);
if (inc.location_coords) {
return (
<button
key={inc.incident_id}
onClick={() => {
setDrawerOpen(false);
handleIncidentSelect(inc);
}}
className="w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }}
>
{label}
</button>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }}
>
{label}
</a>
);
})}
</div>
)}
</div>
</>
)}
</div>
);
}