feat: map overhaul, kiosk mode, RR importer, duplicate system
Map (MapView.tsx): - Fan/hand-of-cards marker clustering: groups nearby markers by pixel proximity (union-find), renders as rotated color cards showing all types - Pulsing ring CSS animation on recording nodes (pulse-ring keyframe) - Live incident overlay panel — right sidebar (desktop) / bottom drawer (mobile), clickable to flyTo incident location - Auto-fit button (⤢) fits all markers in view with fitBounds - "Live · Xs ago" timestamp badge (refreshes every 10s) - Weather Radar layer (NEXRAD via Iowa Env Mesonet, no API key) - ADS-B + Meshtastic placeholder layers (off by default) Map page (map/page.tsx): - Fullscreen / kiosk toggle: fixed z-50 overlay covers nav, map fills viewport - lastUpdated tracking passed to MapView for Live timestamp Systems page (systems/page.tsx): - Duplicate System button: opens form pre-filled with Copy of <name> - RadioReference HTML import: file upload → DOMParser validates .rrlblue structure, parses talkgroup categories, modal lets user select which categories to import, auto-maps RR tags to local tags (law→police, etc.)
This commit is contained in:
@@ -1,92 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup, LayersControl, FeatureGroup } from "react-leaflet";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FeatureGroup,
|
||||
LayersControl,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Popup,
|
||||
TileLayer,
|
||||
useMap,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||
import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types";
|
||||
|
||||
// Fix Leaflet default icon paths broken by webpack
|
||||
// ── 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",
|
||||
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({
|
||||
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||
const INCIDENT_COLORS: Record<string, string> = {
|
||||
fire: "#ef4444",
|
||||
police: "#3b82f6",
|
||||
ems: "#eab308",
|
||||
accident: "#f97316",
|
||||
other: "#6b7280",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === "online" || status === "recording") return "#4ade80";
|
||||
if (status === "unconfigured") return "#818cf8";
|
||||
return "#6b7280";
|
||||
}
|
||||
|
||||
// ── Single-node icon (with optional pulsing ring for recording) ───────────────
|
||||
function nodeIcon(status: string): L.DivIcon {
|
||||
const isRec = status === "recording";
|
||||
const color = statusColor(status);
|
||||
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>`
|
||||
: "";
|
||||
return 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>`,
|
||||
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>`,
|
||||
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) => {
|
||||
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:#111827;font-weight:bold;line-height:1;
|
||||
">!</div>`,
|
||||
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 {
|
||||
const n = members.length;
|
||||
const CARD = 13;
|
||||
const STEP = 7;
|
||||
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;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>`;
|
||||
})
|
||||
.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],
|
||||
});
|
||||
}
|
||||
|
||||
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 14;
|
||||
const STEP = 8;
|
||||
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 = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
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>`;
|
||||
})
|
||||
.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],
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 className="font-mono" minWidth={160}>
|
||||
<div className="text-gray-900 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-bold text-sm">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs capitalize">{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) => [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)}
|
||||
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||||
>
|
||||
<Popup className="font-mono" minWidth={180}>
|
||||
<div className="text-gray-900 space-y-2">
|
||||
{members.map((inc, idx) => (
|
||||
<div
|
||||
key={inc.incident_id}
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-bold text-sm">{inc.title ?? "Incident"}</p>
|
||||
<p
|
||||
className="text-xs capitalize"
|
||||
style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||
<a
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||||
>
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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[];
|
||||
lastUpdated?: Date | null;
|
||||
}
|
||||
|
||||
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
const activeByNode = Object.fromEntries(
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
);
|
||||
export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) {
|
||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
|
||||
const plottedIncidents = incidents.flatMap((inc) =>
|
||||
inc.location_coords
|
||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||
: []
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
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]
|
||||
: plottedIncidents.length > 0
|
||||
? plottedIncidents[0].pos
|
||||
: [39.5, -98.35];
|
||||
: allPositions.length > 0
|
||||
? allPositions[0]
|
||||
: [39.5, -98.35];
|
||||
|
||||
const zoom =
|
||||
nodes.length > 0
|
||||
? 10
|
||||
: plottedIncidents.length > 0
|
||||
? 14
|
||||
: 4;
|
||||
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 rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<MapRefCapture onReady={onMapReady} />
|
||||
|
||||
<LayersControl position="topright">
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
@@ -111,62 +408,59 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
{/* Overlay: Nodes */}
|
||||
<LayersControl.Overlay checked name="Nodes">
|
||||
<FeatureGroup>
|
||||
{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>
|
||||
))}
|
||||
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Active Incidents */}
|
||||
<LayersControl.Overlay checked name="Active Incidents">
|
||||
<FeatureGroup>
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={pos}
|
||||
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"] ?? INCIDENT_COLORS.other }}>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</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>}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet (no API key) */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
|
||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||
opacity={0.65}
|
||||
/>
|
||||
</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>
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay — inside the map wrapper, above tiles */}
|
||||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||||
{ago && (
|
||||
<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">
|
||||
● Live · {ago}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Auto-fit button ──────────────────────────────────────────────────── */}
|
||||
{mapInstance && allPositions.length > 0 && (
|
||||
<button
|
||||
onClick={handleFitAll}
|
||||
title="Fit all markers in view"
|
||||
className="absolute bottom-[4.5rem] right-3 z-[1001] 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"
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Legend ──────────────────────────────────────────────────────────── */}
|
||||
<div className="absolute bottom-8 left-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="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||
@@ -178,6 +472,83 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: right sidebar */}
|
||||
<div className="absolute top-14 right-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 overflow-y-auto space-y-1.5">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
return (
|
||||
<button
|
||||
key={inc.incident_id}
|
||||
onClick={() => handleIncidentSelect(inc)}
|
||||
className="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"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span
|
||||
className="uppercase tracking-wide font-semibold text-[10px]"
|
||||
style={{ color }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold leading-snug truncate">
|
||||
{inc.title ?? "Incident"}
|
||||
</p>
|
||||
{inc.location && (
|
||||
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
||||
)}
|
||||
{!inc.location_coords && (
|
||||
<p className="text-gray-700 italic mt-0.5">no coords</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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-gray-950/95 border-t border-gray-800 px-4 py-2 text-xs font-mono text-gray-300 flex items-center justify-between"
|
||||
>
|
||||
<span>Incidents ({incidents.length})</span>
|
||||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||
</button>
|
||||
{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">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
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 font-mono"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
<span className="font-semibold" style={{ color }}>
|
||||
{inc.type ?? "other"}
|
||||
</span>
|
||||
{" — "}
|
||||
<span className="text-white">{inc.title ?? "Incident"}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user