Frontend redesign chunk 5: MapView rewrite — draw the incident path
The flagship feature: a police pursuit has never been drawn as a path. Add an IncidentPathLayer that, for each incident, takes calls with location_coords (now declared on CallRecord as of chunk 3), sorts them by started_at, and draws a <Polyline> with numbered stop markers — first stop hollow, last stop haloed, using the same index the call spine will use in chunk 7 (UI_REDESIGN.md §2.4's "shared index"). Needs no backend; per-call geocodes are already written by intelligence.py. MapView takes a new optional `calls` prop (the caller's already-loaded recent calls) and groups them by incident_id internally, so it stays a pure presentation component. Retheme markers onto the §2.3 encoding: incident pins are a teardrop with the type glyph knocked out (from TypeGlyph's paths, duplicated as raw SVG since Leaflet icons are HTML strings, not React nodes), filled by severity colour and hollow-with-ink-stroke for minor/routine; node markers are NodeMark-style diamonds via a shared nodeDiamondSvg() helper, deleting statusColor() and all its green. Legend rebuilt shape-first (severity glyphs + node diamond weights, never a bare colour swatch) and reads correctly in both themes via the surface/ink tokens instead of the old bg-gray-950/90 that had no light mapping. Removed the three dead placeholder overlays (News Alerts, ADS-B, Meshtastic). Fan-cluster grouping (computeGroups) is unchanged. Per UI_REDESIGN.md chunk 5.
This commit is contained in:
+251
-113
@@ -6,12 +6,14 @@ import {
|
|||||||
LayersControl,
|
LayersControl,
|
||||||
MapContainer,
|
MapContainer,
|
||||||
Marker,
|
Marker,
|
||||||
|
Polyline,
|
||||||
Popup,
|
Popup,
|
||||||
TileLayer,
|
TileLayer,
|
||||||
useMap,
|
useMap,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import L from "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 ──────────────────────────────────────────────────────────
|
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
@@ -21,48 +23,61 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Colors ────────────────────────────────────────────────────────────────────
|
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||||
const INCIDENT_COLORS: Record<string, string> = {
|
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||||
fire: "#ef4444",
|
// type is carried by the glyph knocked out of the pin, never by colour, and
|
||||||
police: "#3b82f6",
|
// node state is carried by the diamond's weight (filled/hollow/dashed), not
|
||||||
ems: "#eab308",
|
// by colour either. No green appears anywhere here any more.
|
||||||
accident: "#f97316",
|
function severityColor(severity: string | null | undefined): string {
|
||||||
other: "#6b7280",
|
return isKnownSeverity(severity) ? SEVERITY_COLORS[severity] : "var(--ink-muted)";
|
||||||
};
|
|
||||||
|
|
||||||
function statusColor(status: string): string {
|
|
||||||
if (status === "online") return "#4ade80";
|
|
||||||
if (status === "recording") return "#fb923c";
|
|
||||||
if (status === "unconfigured") return "#818cf8";
|
|
||||||
return "#6b7280";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Single-node icon (with optional pulsing ring for recording) ───────────────
|
// Inline SVG paths matching components/marks/TypeGlyph.tsx, duplicated here
|
||||||
function nodeIcon(status: string): L.DivIcon {
|
// (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 isRec = status === "recording";
|
||||||
const color = statusColor(status);
|
|
||||||
const ring = isRec
|
const ring = isRec
|
||||||
? `<div class="node-pulse-ring" style="position:absolute;width:28px;height:28px;border-radius:50%;border:2px solid #fb923c;top:-7px;left:-7px;pointer-events:none;"></div>`
|
? `<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({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="position:relative;width:14px;height:14px">${ring}<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #111827;box-shadow:0 0 6px ${isRec ? "#fb923c" : "transparent"};"></div></div>`,
|
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: [14, 14],
|
iconSize: [size, size],
|
||||||
iconAnchor: [7, 7],
|
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: `<div style="width:16px;height:16px;border-radius:3px;background:${color};border:2px solid #111827;display:flex;align-items:center;justify-content:center;font-size:9px;color:#fff;font-weight:bold;line-height:1;">!</div>`,
|
|
||||||
iconSize: [16, 16],
|
|
||||||
iconAnchor: [8, 8],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fan / hand-of-cards icons for clustered markers ───────────────────────────
|
|
||||||
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||||
const n = members.length;
|
const n = members.length;
|
||||||
const CARD = 13;
|
const CARD = 13;
|
||||||
@@ -74,7 +89,7 @@ function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
|||||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||||
const rot = ratio * maxRot;
|
const rot = ratio * maxRot;
|
||||||
const left = i * STEP;
|
const left = i * STEP;
|
||||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${statusColor(m.status)};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);"></div>`;
|
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("");
|
.join("");
|
||||||
return L.divIcon({
|
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 = `
|
||||||
|
<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 {
|
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||||
const n = members.length;
|
const n = members.length;
|
||||||
const CARD = 14;
|
const CARD = 16;
|
||||||
const STEP = 8;
|
const STEP = 9;
|
||||||
const totalW = CARD + (n - 1) * STEP;
|
const totalW = CARD + (n - 1) * STEP;
|
||||||
const maxRot = Math.min(28, n * 7);
|
const maxRot = Math.min(28, n * 7);
|
||||||
const cards = members
|
const cards = members
|
||||||
@@ -96,8 +129,9 @@ function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
|||||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||||
const rot = ratio * maxRot;
|
const rot = ratio * maxRot;
|
||||||
const left = i * STEP;
|
const left = i * STEP;
|
||||||
const color = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = severityColor(m.severity);
|
||||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:2px;background:${color};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;font-size:8px;color:#fff;font-weight:bold;">!</div>`;
|
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("");
|
.join("");
|
||||||
return L.divIcon({
|
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 ? `<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 ──────────────────────────────────────────────────────
|
// ── Fan cluster grouping ──────────────────────────────────────────────────────
|
||||||
const CLUSTER_PX = 32;
|
const CLUSTER_PX = 32;
|
||||||
|
|
||||||
@@ -146,7 +198,6 @@ function computeGroups<T extends { id: string; lat: number; lng: number }>(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
|
// ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
|
||||||
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -196,16 +247,16 @@ function FanNodeLayer({
|
|||||||
position={[rep.lat, rep.lon]}
|
position={[rep.lat, rep.lon]}
|
||||||
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
|
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
|
||||||
>
|
>
|
||||||
<Popup className="font-mono" minWidth={160}>
|
<Popup minWidth={160}>
|
||||||
<div className="text-gray-900 space-y-2">
|
<div className="space-y-2">
|
||||||
{members.map((node, idx) => (
|
{members.map((node, idx) => (
|
||||||
<div
|
<div
|
||||||
key={node.node_id}
|
key={node.node_id}
|
||||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-sm">{node.name}</p>
|
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
|
||||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
||||||
<p className="text-xs capitalize">{node.status}</p>
|
<p className="text-xs capitalize text-gray-700">{node.status}</p>
|
||||||
{activeByNode[node.node_id] && (
|
{activeByNode[node.node_id] && (
|
||||||
<p className="text-xs text-orange-600 mt-0.5">
|
<p className="text-xs text-orange-600 mt-0.5">
|
||||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||||
@@ -273,22 +324,21 @@ function FanIncidentLayer({
|
|||||||
<Marker
|
<Marker
|
||||||
key={repId}
|
key={repId}
|
||||||
position={[repPlot.lat, repPlot.lng]}
|
position={[repPlot.lat, repPlot.lng]}
|
||||||
icon={members.length > 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) }}
|
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||||||
>
|
>
|
||||||
<Popup className="font-mono" minWidth={180}>
|
<Popup minWidth={180}>
|
||||||
<div className="text-gray-900 space-y-2">
|
<div className="space-y-2">
|
||||||
{members.map((inc, idx) => (
|
{members.map((inc, idx) => {
|
||||||
|
const color = severityColor(inc.severity);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-sm">{inc.title ?? "Incident"}</p>
|
<p className="font-semibold text-sm text-gray-900">{inc.title ?? "Incident"}</p>
|
||||||
<p
|
<p className="text-xs capitalize font-medium" style={{ color }}>
|
||||||
className="text-xs capitalize"
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
||||||
style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}
|
|
||||||
>
|
|
||||||
{inc.type ?? "other"}
|
|
||||||
</p>
|
</p>
|
||||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||||
<a
|
<a
|
||||||
@@ -299,7 +349,8 @@ function FanIncidentLayer({
|
|||||||
View incident →
|
View incident →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -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<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>
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</FeatureGroup>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
function timeAgo(date: Date): string {
|
function timeAgo(date: Date): string {
|
||||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
@@ -322,10 +426,18 @@ interface Props {
|
|||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
activeCalls: CallRecord[];
|
activeCalls: CallRecord[];
|
||||||
incidents?: IncidentRecord[];
|
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;
|
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<L.Map | null>(null);
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
@@ -345,7 +457,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
// Live clock for TOC situational awareness
|
// Live clock for TOC situational awareness
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() =>
|
const id = setInterval(() =>
|
||||||
@@ -358,6 +469,18 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
// 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 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(
|
const allPositions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
|
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
|
||||||
@@ -402,8 +525,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
className="w-full h-full rounded-lg"
|
className="w-full h-full"
|
||||||
style={{ background: "#111827" }}
|
style={{ background: "var(--map-bg)" }}
|
||||||
>
|
>
|
||||||
<MapRefCapture onReady={onMapReady} />
|
<MapRefCapture onReady={onMapReady} />
|
||||||
|
|
||||||
@@ -442,6 +565,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
|
{/* Overlay: Incident paths — the flagship feature */}
|
||||||
|
<LayersControl.Overlay checked name="Incident Paths">
|
||||||
|
<FeatureGroup>
|
||||||
|
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||||
<LayersControl.Overlay name="Weather Radar">
|
<LayersControl.Overlay name="Weather Radar">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
@@ -451,28 +581,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
opacity={0.65}
|
opacity={0.65}
|
||||||
/>
|
/>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: News / RSS alerts — placeholder for future integration */}
|
|
||||||
<LayersControl.Overlay name="News Alerts">
|
|
||||||
<FeatureGroup />
|
|
||||||
</LayersControl.Overlay>
|
|
||||||
|
|
||||||
{/* Overlay: ADS-B — placeholder for future integration */}
|
|
||||||
<LayersControl.Overlay name="ADS-B">
|
|
||||||
<FeatureGroup />
|
|
||||||
</LayersControl.Overlay>
|
|
||||||
|
|
||||||
{/* Overlay: Meshtastic — placeholder for future integration */}
|
|
||||||
<LayersControl.Overlay name="Meshtastic">
|
|
||||||
<FeatureGroup />
|
|
||||||
</LayersControl.Overlay>
|
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||||||
{ago && (
|
{ago && (
|
||||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||||
<span className="bg-gray-950/90 border border-gray-700 rounded-full px-3 py-1 text-xs font-mono text-green-400 whitespace-nowrap">
|
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-accent whitespace-nowrap">
|
||||||
● Live · {ago}
|
● Live · {ago}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +599,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
<button
|
<button
|
||||||
onClick={handleFitAll}
|
onClick={handleFitAll}
|
||||||
title="Fit all markers in view"
|
title="Fit all markers in view"
|
||||||
className="w-8 h-8 bg-gray-950/90 border border-gray-700 rounded text-white text-base leading-none hover:bg-gray-800 transition-colors flex items-center justify-center select-none"
|
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>
|
</button>
|
||||||
@@ -492,21 +607,50 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
||||||
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 pointer-events-none">
|
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2 pointer-events-none">
|
||||||
<span className="text-white text-sm font-mono tabular-nums">{clockStr}</span>
|
<span className="text-ink text-sm font-mono tabular-nums">{clockStr}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
|
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||||
<div className="absolute bottom-8 right-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
<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">
|
||||||
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||||
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||||
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
<div key={sev} className="flex items-center gap-2">
|
||||||
<div className="border-t border-gray-800 my-0.5" />
|
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||||||
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
{sev === "major" && (
|
||||||
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
<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>
|
||||||
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
)}
|
||||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
{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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
{/* ── 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 */}
|
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
||||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
|
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
|
||||||
{incidents.map((inc) => {
|
{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 age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||||
const unitCount = inc.units?.length ?? 0;
|
const unitCount = inc.units_active?.length ?? 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 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 = (
|
const cardBody = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<span
|
<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")] }} />
|
||||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
|
||||||
style={{ background: color }}
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="uppercase tracking-wide font-semibold text-[10px]"
|
|
||||||
style={{ color }}
|
|
||||||
>
|
|
||||||
{inc.type ?? "other"}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white font-semibold leading-snug truncate">
|
<p className="text-ink font-semibold leading-snug truncate">
|
||||||
{inc.title ?? "Incident"}
|
{inc.title ?? "Incident"}
|
||||||
</p>
|
</p>
|
||||||
{inc.location && (
|
{inc.location && (
|
||||||
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-0.5">
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
{age && <span className="text-gray-600">{age}</span>}
|
{age && <span className="text-ink-muted font-mono">{age}</span>}
|
||||||
{unitCount > 0 && (
|
{unitCount > 0 && (
|
||||||
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!inc.location_coords && (
|
{!inc.location_coords && (
|
||||||
<p className="text-[10px] text-blue-700 mt-1">View details →</p>
|
<p className="text-[10px] text-accent mt-1">View details →</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -579,22 +717,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
||||||
className="w-full bg-gray-950/95 border-t border-gray-800 px-4 py-2 text-xs font-mono text-gray-300 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 ({incidents.length})</span>
|
||||||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||||
</button>
|
</button>
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
{incidents.map((inc) => {
|
{incidents.map((inc) => {
|
||||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = severityColor(inc.severity);
|
||||||
const label = (
|
const label = (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold" style={{ color }}>
|
<span className="font-semibold" style={{ color }}>
|
||||||
{inc.type ?? "other"}
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
{" — "}
|
{" — "}
|
||||||
<span className="text-white">{inc.title ?? "Incident"}</span>
|
<span className="text-ink">{inc.title ?? "Incident"}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
if (inc.location_coords) {
|
if (inc.location_coords) {
|
||||||
@@ -605,7 +743,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
handleIncidentSelect(inc);
|
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" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -616,7 +754,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], lastUpdate
|
|||||||
<a
|
<a
|
||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
className="block w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: color + "55" }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
Reference in New Issue
Block a user