diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 5871280..0f4d242 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -6,12 +6,14 @@ import { LayersControl, MapContainer, Marker, + Polyline, Popup, TileLayer, useMap, } from "react-leaflet"; import L from "leaflet"; -import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types"; +import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; +import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; @@ -21,48 +23,61 @@ L.Icon.Default.mergeOptions({ shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", }); -// ── Colors ──────────────────────────────────────────────────────────────────── -const INCIDENT_COLORS: Record = { - fire: "#ef4444", - police: "#3b82f6", - ems: "#eab308", - accident: "#f97316", - other: "#6b7280", -}; - -function statusColor(status: string): string { - if (status === "online") return "#4ade80"; - if (status === "recording") return "#fb923c"; - if (status === "unconfigured") return "#818cf8"; - return "#6b7280"; +// ── 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)"; } -// ── Single-node icon (with optional pulsing ring for recording) ─────────────── -function nodeIcon(status: string): L.DivIcon { +// 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 color = statusColor(status); const ring = isRec - ? `
` + ? `
` : ""; return L.divIcon({ className: "", - html: `
${ring}
`, - iconSize: [14, 14], - iconAnchor: [7, 7], + html: `
${ring}${nodeDiamondSvg(status, size)}
`, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], }); } -function incidentIcon(type: string | null): L.DivIcon { - const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other; - return L.divIcon({ - className: "", - html: `
!
`, - iconSize: [16, 16], - iconAnchor: [8, 8], - }); -} - -// ── Fan / hand-of-cards icons for clustered markers ─────────────────────────── function nodeFanIcon(members: NodeRecord[]): L.DivIcon { const n = members.length; const CARD = 13; @@ -74,7 +89,7 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon { const ratio = n === 1 ? 0 : i / (n - 1) - 0.5; const rot = ratio * maxRot; const left = i * STEP; - return `
`; + return `
${nodeDiamondSvg(m.status, CARD)}
`; }) .join(""); return L.divIcon({ @@ -85,10 +100,28 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon { }); } +// ── 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 = ` + + + ${typeGlyphSvg(type, glyphColor, 13)} + `; + return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] }); +} + function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { const n = members.length; - const CARD = 14; - const STEP = 8; + const CARD = 16; + const STEP = 9; const totalW = CARD + (n - 1) * STEP; const maxRot = Math.min(28, n * 7); const cards = members @@ -96,8 +129,9 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { const ratio = n === 1 ? 0 : i / (n - 1) - 0.5; const rot = ratio * maxRot; const left = i * STEP; - const color = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other; - return `
!
`; + 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({ @@ -108,6 +142,24 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { }); } +// ── 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 ? `` : ""; + return L.divIcon({ + className: "", + html: ` + ${halo} + + ${index} + `, + iconSize: [size, size], + iconAnchor: [size / 2, size / 2], + }); +} + // ── Fan cluster grouping ────────────────────────────────────────────────────── const CLUSTER_PX = 32; @@ -146,7 +198,6 @@ function computeGroups( return result; } - // ── MapRefCapture — exposes L.Map instance to parent ───────────────────────── function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) { const map = useMap(); @@ -196,16 +247,16 @@ function FanNodeLayer({ position={[rep.lat, rep.lon]} icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)} > - -
+ +
{members.map((node, idx) => (
-

{node.name}

-

{node.node_id}

-

{node.status}

+

{node.name}

+

{node.node_id}

+

{node.status}

{activeByNode[node.node_id] && (

● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "} @@ -273,33 +324,33 @@ function FanIncidentLayer({ 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type)} + icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)} eventHandlers={{ click: () => onSelect(repPlot.inc) }} > - -

- {members.map((inc, idx) => ( -
-

{inc.title ?? "Incident"}

-

+

+ {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}

} + { 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 → + +
+ ); + })}
@@ -309,6 +360,59 @@ function FanIncidentLayer({ ); } +// ── 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; +}) { + 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 ( + + + {calls.map((c, i) => ( + + +
+

Stop {i + 1} of {calls.length}

+

{inc.title ?? "Incident"}

+ {c.location &&

{c.location}

} + + View incident → + +
+
+
+ ))} +
+ ); + })} + + ); +} + // ── Helpers ─────────────────────────────────────────────────────────────────── function timeAgo(date: Date): string { const s = Math.floor((Date.now() - date.getTime()) / 1000); @@ -322,10 +426,18 @@ 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 = [], lastUpdated }: Props) { +export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) { const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); @@ -345,7 +457,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate return () => clearInterval(id); }, []); - // Live clock for TOC situational awareness useEffect(() => { const id = setInterval(() => @@ -358,6 +469,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); + const callsByIncident = useMemo(() => { + const map = new Map(); + 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]), @@ -402,8 +525,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate @@ -442,6 +565,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate + {/* Overlay: Incident paths — the flagship feature */} + + + + + + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} - - {/* Overlay: News / RSS alerts — placeholder for future integration */} - - - - - {/* Overlay: ADS-B — placeholder for future integration */} - - - - - {/* Overlay: Meshtastic — placeholder for future integration */} - - - {/* ── Live timestamp ───────────────────────────────────────────────────── */} {ago && (
- + ● Live · {ago}
@@ -484,7 +599,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate @@ -492,21 +607,50 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */} -
- {clockStr} +
+ {clockStr}
- {/* ── Legend — bottom-right to avoid incident panel on left ────────────── */} -
-
● Online
-
● Recording
-
● Unconfigured
-
● Offline
-
-
■ Fire
-
■ Police
-
■ EMS
-
■ Accident
+ {/* ── 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]} +
+ ))} +
+
+

Nodes

+ {([ + ["recording", "Recording"], + ["online", "Online"], + ["offline", "Offline"], + ["unconfigured", "Unconfigured"], + ] as [NodeStatus, string][]).map(([status, label]) => ( +
+ + + + {label} +
+ ))} +
{/* ── Incident overlay panel ───────────────────────────────────────────── */} @@ -515,38 +659,32 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate {/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
{incidents.map((inc) => { - const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; + const color = severityColor(inc.severity); const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; - const unitCount = inc.units?.length ?? 0; - const baseClass = "w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all"; + 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 = ( <>
- - - {inc.type ?? "other"} + + + {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
-

+

{inc.title ?? "Incident"}

{inc.location && ( -

{inc.location}

+

{inc.location}

)}
- {age && {age}} + {age && {age}} {unitCount > 0 && ( - {unitCount} unit{unitCount !== 1 ? "s" : ""} + {unitCount} unit{unitCount !== 1 ? "s" : ""} )}
{!inc.location_coords && ( -

View details →

+

View details →

)} ); @@ -579,22 +717,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
{drawerOpen && ( -
+
{incidents.map((inc) => { - const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; + const color = severityColor(inc.severity); const label = ( <> - {inc.type ?? "other"} + {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {" — "} - {inc.title ?? "Incident"} + {inc.title ?? "Incident"} ); if (inc.location_coords) { @@ -605,7 +743,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate setDrawerOpen(false); handleIncidentSelect(inc); }} - className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono" + className="w-full text-left border rounded px-2 py-1.5 text-xs" style={{ borderColor: color + "55" }} > {label} @@ -616,7 +754,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate {label}