128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
|
import L from "leaflet";
|
|
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
|
|
|
// Fix Leaflet default icon paths broken by webpack
|
|
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",
|
|
});
|
|
|
|
const nodeIcon = (status: string) =>
|
|
L.divIcon({
|
|
className: "",
|
|
html: `<div style="
|
|
width:14px;height:14px;border-radius:50%;
|
|
background:${status === "online" || status === "recording" ? "#4ade80" : status === "unconfigured" ? "#818cf8" : "#6b7280"};
|
|
border:2px solid #111827;
|
|
box-shadow:0 0 6px ${status === "recording" ? "#fb923c" : "transparent"};
|
|
"></div>`,
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7],
|
|
});
|
|
|
|
const INCIDENT_COLORS: Record<string, string> = {
|
|
fire: "#ef4444",
|
|
police: "#3b82f6",
|
|
ems: "#eab308",
|
|
accident: "#f97316",
|
|
other: "#6b7280",
|
|
};
|
|
|
|
const incidentIcon = (type: string | null) => {
|
|
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:#111827;font-weight:bold;line-height:1;
|
|
">!</div>`,
|
|
iconSize: [16, 16],
|
|
iconAnchor: [8, 8],
|
|
});
|
|
};
|
|
|
|
interface Props {
|
|
nodes: NodeRecord[];
|
|
activeCalls: CallRecord[];
|
|
incidents?: IncidentRecord[];
|
|
}
|
|
|
|
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|
const activeByNode = Object.fromEntries(
|
|
activeCalls.map((c) => [c.node_id, c])
|
|
);
|
|
|
|
// Only show incidents that have coordinates
|
|
const mappableIncidents = incidents.filter(
|
|
(i) => i.location && i.location.lat != null && i.location.lng != null
|
|
);
|
|
|
|
const center: [number, number] =
|
|
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
|
|
|
|
return (
|
|
<MapContainer
|
|
center={center}
|
|
zoom={nodes.length > 0 ? 10 : 4}
|
|
className="w-full h-full rounded-lg"
|
|
style={{ background: "#111827" }}
|
|
>
|
|
<TileLayer
|
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
|
/>
|
|
|
|
{/* Node markers */}
|
|
{nodes.map((node) => (
|
|
<Marker
|
|
key={node.node_id}
|
|
position={[node.lat, node.lon]}
|
|
icon={nodeIcon(node.status)}
|
|
>
|
|
<Popup className="font-mono">
|
|
<div className="text-gray-900">
|
|
<p className="font-bold">{node.name}</p>
|
|
<p className="text-xs text-gray-500">{node.node_id}</p>
|
|
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
|
{activeByNode[node.node_id] && (
|
|
<p className="text-xs text-orange-600 mt-1">
|
|
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
|
{activeByNode[node.node_id].talkgroup_name}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
))}
|
|
|
|
{/* Incident markers */}
|
|
{mappableIncidents.map((inc) => (
|
|
<Marker
|
|
key={inc.incident_id}
|
|
position={[inc.location!.lat, inc.location!.lng]}
|
|
icon={incidentIcon(inc.type)}
|
|
>
|
|
<Popup className="font-mono">
|
|
<div className="text-gray-900">
|
|
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
|
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] }}>
|
|
{inc.type ?? "other"}
|
|
</p>
|
|
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
|
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
|
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
))}
|
|
</MapContainer>
|
|
);
|
|
}
|