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:
@@ -96,6 +96,15 @@ html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
|||||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
||||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||||
|
|
||||||
|
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% { transform: scale(1); opacity: 0.85; }
|
||||||
|
100% { transform: scale(2.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
.node-pulse-ring {
|
||||||
|
animation: pulse-ring 1.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
@@ -45,12 +46,55 @@ function IncidentCard({ incident }: { incident: IncidentRecord }) {
|
|||||||
|
|
||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const incidents = useActiveIncidents();
|
const incidents = useActiveIncidents();
|
||||||
|
const [kiosk, setKiosk] = useState(false);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// Track when data last refreshed
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) setLastUpdated(new Date());
|
||||||
|
}, [nodes, activeCalls, incidents, loading]);
|
||||||
|
|
||||||
|
// Kiosk mode: full-viewport fixed overlay sits above the sticky nav (z-40 → z-50)
|
||||||
|
if (kiosk) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-gray-950">
|
||||||
|
<MapView
|
||||||
|
nodes={nodes}
|
||||||
|
activeCalls={activeCalls}
|
||||||
|
incidents={incidents}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setKiosk(false)}
|
||||||
|
title="Exit fullscreen"
|
||||||
|
className="absolute top-3 left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||||
|
</svg>
|
||||||
|
Exit fullscreen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setKiosk(true)}
|
||||||
|
title="Fullscreen / kiosk mode"
|
||||||
|
className="text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
</svg>
|
||||||
|
Fullscreen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
||||||
@@ -58,11 +102,15 @@ export default function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
|
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
|
||||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
<MapView
|
||||||
|
nodes={nodes}
|
||||||
|
activeCalls={activeCalls}
|
||||||
|
incidents={incidents}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active incidents — shown even without geocoded location */}
|
|
||||||
{incidents.length > 0 && (
|
{incidents.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||||
|
|
||||||
// ── P25 structured config types ──────────────────────────────────────────────
|
// ── P25 structured config types ───────────────────────────────────────────────
|
||||||
|
|
||||||
interface TalkgroupEntry {
|
interface TalkgroupEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +72,248 @@ function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── RadioReference parser types ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RRTalkgroup {
|
||||||
|
dec: number;
|
||||||
|
alphaTag: string;
|
||||||
|
description: string;
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RRCategory {
|
||||||
|
name: string;
|
||||||
|
talkgroups: RRTalkgroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RRSystem {
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
sysIds: string;
|
||||||
|
systemType: string;
|
||||||
|
categories: RRCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRRTag(rrTag: string): string {
|
||||||
|
const t = rrTag.toLowerCase();
|
||||||
|
if (t.includes("fire")) return "fire";
|
||||||
|
if (t.includes("law") || t.includes("police")) return "police";
|
||||||
|
if (t.includes("ems") || t.includes("emergency medical")) return "ems";
|
||||||
|
if (t.includes("transport") || t.includes("transit")) return "transit";
|
||||||
|
if (t.includes("public works")) return "public works";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRadioReference(html: string): RRSystem | null {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
// Validate: RadioReference system pages have rrlblue header cells
|
||||||
|
if (!doc.querySelector(".rrlblue")) return null;
|
||||||
|
|
||||||
|
// System info table (first table with rrlblue headers)
|
||||||
|
const infoMap: Record<string, string> = {};
|
||||||
|
const infoTable = doc.querySelector("table.table-sm.table-bordered");
|
||||||
|
if (infoTable) {
|
||||||
|
infoTable.querySelectorAll("tr").forEach((row) => {
|
||||||
|
const th = row.querySelector("th.rrlblue");
|
||||||
|
const td = row.querySelector("td");
|
||||||
|
if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = infoMap["System Name"] ?? doc.title ?? "Unknown System";
|
||||||
|
const location = infoMap["Location"] ?? "";
|
||||||
|
const sysIds = infoMap["System IDs"] ?? "";
|
||||||
|
const systemType = infoMap["System Type"] ?? "";
|
||||||
|
|
||||||
|
// Talkgroup tables — find all with class rrdbTable or datatable-lite
|
||||||
|
// For each, find the nearest preceding h5 to use as category name
|
||||||
|
const tgTables = Array.from(
|
||||||
|
doc.querySelectorAll("table.rrdbTable, table.datatable-lite")
|
||||||
|
) as HTMLTableElement[];
|
||||||
|
|
||||||
|
const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[];
|
||||||
|
|
||||||
|
function categoryForTable(table: HTMLTableElement): string {
|
||||||
|
// Find the last h5 that appears before this table in document order
|
||||||
|
let best: HTMLElement | null = null;
|
||||||
|
for (const h5 of allH5s) {
|
||||||
|
const pos = h5.compareDocumentPosition(table);
|
||||||
|
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5;
|
||||||
|
}
|
||||||
|
if (!best) return "Uncategorized";
|
||||||
|
const clone = best.cloneNode(true) as HTMLElement;
|
||||||
|
clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove());
|
||||||
|
return clone.textContent?.trim() || "Uncategorized";
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: RRCategory[] = [];
|
||||||
|
|
||||||
|
for (const table of tgTables) {
|
||||||
|
// Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …)
|
||||||
|
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
|
||||||
|
th.textContent?.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
if (!headers.includes("dec") && !headers.includes("hex")) continue;
|
||||||
|
|
||||||
|
const catName = categoryForTable(table);
|
||||||
|
const talkgroups: RRTalkgroup[] = [];
|
||||||
|
|
||||||
|
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||||
|
const cells = Array.from(row.querySelectorAll("td"));
|
||||||
|
if (cells.length < 6) return;
|
||||||
|
|
||||||
|
// DEC cell may wrap in a Broadcastify link
|
||||||
|
const decText =
|
||||||
|
cells[0].querySelector("a")?.textContent?.trim() ??
|
||||||
|
cells[0].textContent?.trim() ??
|
||||||
|
"";
|
||||||
|
const dec = parseInt(decText.replace(/\D/g, ""), 10);
|
||||||
|
if (isNaN(dec)) return;
|
||||||
|
|
||||||
|
const alphaTag = cells[3].textContent?.trim() ?? "";
|
||||||
|
const description = cells[4].textContent?.trim() ?? "";
|
||||||
|
const tag = cells[5].textContent?.trim() ?? "";
|
||||||
|
|
||||||
|
talkgroups.push({ dec, alphaTag, description, tag });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (talkgroups.length > 0) {
|
||||||
|
// Merge into an existing category with same name if present
|
||||||
|
const existing = categories.find((c) => c.name === catName);
|
||||||
|
if (existing) {
|
||||||
|
existing.talkgroups.push(...talkgroups);
|
||||||
|
} else {
|
||||||
|
categories.push({ name: catName, talkgroups });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categories.length === 0) return null;
|
||||||
|
|
||||||
|
return { name, location, sysIds, systemType, categories };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RadioReference import modal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RRImportModal({
|
||||||
|
system,
|
||||||
|
onImport,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
system: RRSystem;
|
||||||
|
onImport: (tgs: TalkgroupEntry[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(system.categories.map((c) => c.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(name: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name); else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
const tgs: TalkgroupEntry[] = [];
|
||||||
|
for (const cat of system.categories) {
|
||||||
|
if (!selected.has(cat.name)) continue;
|
||||||
|
for (const tg of cat.talkgroups) {
|
||||||
|
tgs.push({
|
||||||
|
id: String(tg.dec),
|
||||||
|
name: tg.description || tg.alphaTag,
|
||||||
|
tag: mapRRTag(tg.tag),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onImport(tgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0);
|
||||||
|
const selectedCount = system.categories
|
||||||
|
.filter((c) => selected.has(c.name))
|
||||||
|
.reduce((s, c) => s + c.talkgroups.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-white font-semibold">{system.name}</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
||||||
|
</p>
|
||||||
|
{system.sysIds && (
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
||||||
|
<div className="flex gap-3 text-xs text-indigo-400">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(new Set(system.categories.map((c) => c.name)))}
|
||||||
|
className="hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
className="hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{system.categories.map((cat) => (
|
||||||
|
<label key={cat.name} className="flex items-center gap-3 cursor-pointer group py-0.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(cat.name)}
|
||||||
|
onChange={() => toggle(cat.name)}
|
||||||
|
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
|
||||||
|
{cat.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Import {selectedCount} talkgroup{selectedCount !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TalkgroupEditor({
|
function TalkgroupEditor({
|
||||||
@@ -83,6 +325,9 @@ function TalkgroupEditor({
|
|||||||
}) {
|
}) {
|
||||||
const [showPaste, setShowPaste] = useState(false);
|
const [showPaste, setShowPaste] = useState(false);
|
||||||
const [pasteText, setPasteText] = useState("");
|
const [pasteText, setPasteText] = useState("");
|
||||||
|
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
|
||||||
|
const [rrError, setRrError] = useState<string | null>(null);
|
||||||
|
const rrInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
function addRow() {
|
function addRow() {
|
||||||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||||||
@@ -115,13 +360,61 @@ function TalkgroupEditor({
|
|||||||
setShowPaste(false);
|
setShowPaste(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRRFile(file: File) {
|
||||||
|
setRrError(null);
|
||||||
|
const html = await file.text();
|
||||||
|
const parsed = parseRadioReference(html);
|
||||||
|
if (!parsed) {
|
||||||
|
setRrError(
|
||||||
|
"This doesn't look like a RadioReference trunked system page. " +
|
||||||
|
"Download the HTML from a system page on radioreference.com and try again."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRrSystem(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRRImport(newTgs: TalkgroupEntry[]) {
|
||||||
|
onChange([...talkgroups, ...newTgs]);
|
||||||
|
setRrSystem(null);
|
||||||
|
setRrError(null);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{rrSystem && (
|
||||||
|
<RRImportModal
|
||||||
|
system={rrSystem}
|
||||||
|
onImport={handleRRImport}
|
||||||
|
onCancel={() => setRrSystem(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-gray-400">
|
<label className="text-xs text-gray-400">
|
||||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
{/* RadioReference file import */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => rrInputRef.current?.click()}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
RadioReference import
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={rrInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".html,.htm"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleRRFile(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPaste(!showPaste)}
|
onClick={() => setShowPaste(!showPaste)}
|
||||||
@@ -139,10 +432,17 @@ function TalkgroupEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{rrError && (
|
||||||
|
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/50 rounded px-3 py-2">
|
||||||
|
{rrError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{showPaste && (
|
{showPaste && (
|
||||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Paste rows from RadioReference — tab- or comma-separated: <span className="text-gray-400">ID, Name, Tag</span>
|
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
||||||
|
<span className="text-gray-400">ID, Name, Tag</span>
|
||||||
<br />Tags: fire · police · ems · transit · public works · other
|
<br />Tags: fire · police · ems · transit · public works · other
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -219,7 +519,9 @@ function TalkgroupEditor({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 italic py-1">No talkgroups — add rows or paste from RadioReference.</p>
|
<p className="text-xs text-gray-600 italic py-1">
|
||||||
|
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -293,10 +595,12 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
|
|
||||||
function SystemForm({
|
function SystemForm({
|
||||||
initial,
|
initial,
|
||||||
|
createOnly,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: SystemRecord;
|
initial?: SystemRecord;
|
||||||
|
createOnly?: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -313,8 +617,8 @@ function SystemForm({
|
|||||||
: "{}"
|
: "{}"
|
||||||
);
|
);
|
||||||
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
|
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleTypeChange(t: string) {
|
function handleTypeChange(t: string) {
|
||||||
setType(t);
|
setType(t);
|
||||||
@@ -345,7 +649,7 @@ function SystemForm({
|
|||||||
} else {
|
} else {
|
||||||
config = JSON.parse(rawJson);
|
config = JSON.parse(rawJson);
|
||||||
}
|
}
|
||||||
if (initial) {
|
if (initial && !createOnly) {
|
||||||
await c2api.updateSystem(initial.system_id, { name, type, config });
|
await c2api.updateSystem(initial.system_id, { name, type, config });
|
||||||
} else {
|
} else {
|
||||||
await c2api.createSystem({ name, type, config });
|
await c2api.createSystem({ name, type, config });
|
||||||
@@ -358,9 +662,11 @@ function SystemForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
||||||
<h3 className="text-white font-semibold">{initial ? "Edit System" : "New System"}</h3>
|
<h3 className="text-white font-semibold">{title}</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -441,9 +747,9 @@ interface SystemAiFlags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
|
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
|
||||||
const [flags, setFlags] = useState<SystemAiFlags>(initial);
|
const [flags, setFlags] = useState<SystemAiFlags>(initial);
|
||||||
const [saving, setSaving] = useState<string | null>(null);
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
|
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
|
||||||
setSaving(key);
|
setSaving(key);
|
||||||
@@ -466,7 +772,7 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows: { key: keyof SystemAiFlags; label: string }[] = [
|
const rows: { key: keyof SystemAiFlags; label: string }[] = [
|
||||||
{ key: "stt_enabled", label: "Speech-to-Text" },
|
{ key: "stt_enabled", label: "Speech-to-Text" },
|
||||||
{ key: "correlation_enabled", label: "Incident Correlation" },
|
{ key: "correlation_enabled", label: "Incident Correlation" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -487,8 +793,8 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||||
{rows.map(({ key, label }) => {
|
{rows.map(({ key, label }) => {
|
||||||
const override = flags[key];
|
const override = flags[key];
|
||||||
const isSet = override !== undefined;
|
const isSet = override !== undefined;
|
||||||
const isOn = override !== false;
|
const isOn = override !== false;
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center gap-3">
|
<div key={key} className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -523,21 +829,20 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function VocabularyPanel({ systemId }: { systemId: string }) {
|
function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||||
const [vocab, setVocab] = useState<string[] | null>(null);
|
const [vocab, setVocab] = useState<string[] | null>(null);
|
||||||
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
|
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
|
||||||
const [bootstrapped, setBootstrapped] = useState(false);
|
const [bootstrapped, setBootstrapped] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [bootstrapping, setBootstrapping] = useState(false);
|
const [bootstrapping, setBootstrapping] = useState(false);
|
||||||
const [newTerm, setNewTerm] = useState("");
|
const [newTerm, setNewTerm] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (vocab !== null) return; // already loaded
|
if (vocab !== null) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await c2api.getVocabulary(systemId);
|
const data = await c2api.getVocabulary(systemId);
|
||||||
@@ -607,7 +912,11 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Vocabulary
|
Vocabulary
|
||||||
{vocab !== null && <span className="text-gray-600 ml-1">({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})</span>}
|
{vocab !== null && (
|
||||||
|
<span className="text-gray-600 ml-1">
|
||||||
|
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -617,7 +926,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{!loading && vocab !== null && (
|
{!loading && vocab !== null && (
|
||||||
<>
|
<>
|
||||||
{/* Bootstrap button */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleBootstrap}
|
onClick={handleBootstrap}
|
||||||
@@ -629,16 +937,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Approved vocabulary chips */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
||||||
{vocab.length > 0 ? (
|
{vocab.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{vocab.map((term) => (
|
{vocab.map((term) => (
|
||||||
<span
|
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||||
key={term}
|
|
||||||
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
|
|
||||||
>
|
|
||||||
{term}
|
{term}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(term)}
|
onClick={() => handleRemove(term)}
|
||||||
@@ -654,7 +958,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add term */}
|
|
||||||
<form onSubmit={handleAdd} className="flex gap-2">
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={newTerm}
|
value={newTerm}
|
||||||
@@ -671,7 +974,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Pending induction suggestions */}
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
||||||
@@ -682,18 +984,8 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<div key={p.term} className="flex items-center gap-2">
|
<div key={p.term} className="flex items-center gap-2">
|
||||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||||
<span className="text-gray-600">{p.source}</span>
|
<span className="text-gray-600">{p.source}</span>
|
||||||
<button
|
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||||
onClick={() => handleApprove(p.term)}
|
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
||||||
className="text-green-500 hover:text-green-400 transition-colors px-1"
|
|
||||||
>
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDismiss(p.term)}
|
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors px-1"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -711,19 +1003,35 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
export default function SystemsPage() {
|
export default function SystemsPage() {
|
||||||
const { systems, loading } = useSystems();
|
const { systems, loading } = useSystems();
|
||||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||||
|
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Delete this system?")) return;
|
if (!confirm("Delete this system?")) return;
|
||||||
await c2api.deleteSystem(id);
|
await c2api.deleteSystem(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(s: SystemRecord) {
|
||||||
|
setEditing(s);
|
||||||
|
setEditIsDuplicate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDuplicate(s: SystemRecord) {
|
||||||
|
setEditing({ ...s, name: `Copy of ${s.name}` });
|
||||||
|
setEditIsDuplicate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
setEditing(null);
|
||||||
|
setEditIsDuplicate(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing("new")}
|
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
+ New System
|
+ New System
|
||||||
@@ -733,8 +1041,9 @@ export default function SystemsPage() {
|
|||||||
{editing && (
|
{editing && (
|
||||||
<SystemForm
|
<SystemForm
|
||||||
initial={editing === "new" ? undefined : editing}
|
initial={editing === "new" ? undefined : editing}
|
||||||
onSave={() => setEditing(null)}
|
createOnly={editIsDuplicate}
|
||||||
onCancel={() => setEditing(null)}
|
onSave={closeEdit}
|
||||||
|
onCancel={closeEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -771,11 +1080,17 @@ export default function SystemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-3">
|
<div className="mt-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(s)}
|
onClick={() => openEdit(s)}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDuplicate(s)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(s.system_id)}
|
onClick={() => handleDelete(s.system_id)}
|
||||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||||
|
|||||||
@@ -1,92 +1,389 @@
|
|||||||
"use client";
|
"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 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;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
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",
|
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",
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeIcon = (status: string) =>
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
L.divIcon({
|
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: "",
|
className: "",
|
||||||
html: `<div style="
|
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>`,
|
||||||
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],
|
iconSize: [14, 14],
|
||||||
iconAnchor: [7, 7],
|
iconAnchor: [7, 7],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const INCIDENT_COLORS: Record<string, string> = {
|
function incidentIcon(type: string | null): L.DivIcon {
|
||||||
fire: "#ef4444",
|
|
||||||
police: "#3b82f6",
|
|
||||||
ems: "#eab308",
|
|
||||||
accident: "#f97316",
|
|
||||||
other: "#6b7280",
|
|
||||||
};
|
|
||||||
|
|
||||||
const incidentIcon = (type: string | null) => {
|
|
||||||
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="
|
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>`,
|
||||||
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],
|
iconSize: [16, 16],
|
||||||
iconAnchor: [8, 8],
|
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 {
|
interface Props {
|
||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
activeCalls: CallRecord[];
|
activeCalls: CallRecord[];
|
||||||
incidents?: IncidentRecord[];
|
incidents?: IncidentRecord[];
|
||||||
|
lastUpdated?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) {
|
||||||
const activeByNode = Object.fromEntries(
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
activeCalls.map((c) => [c.node_id, c])
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
|
|
||||||
const plottedIncidents = incidents.flatMap((inc) =>
|
useEffect(() => {
|
||||||
inc.location_coords
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
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] =
|
const center: [number, number] =
|
||||||
nodes.length > 0
|
nodes.length > 0
|
||||||
? [nodes[0].lat, nodes[0].lon]
|
? [nodes[0].lat, nodes[0].lon]
|
||||||
: plottedIncidents.length > 0
|
: allPositions.length > 0
|
||||||
? plottedIncidents[0].pos
|
? allPositions[0]
|
||||||
: [39.5, -98.35];
|
: [39.5, -98.35];
|
||||||
|
|
||||||
const zoom =
|
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
||||||
nodes.length > 0
|
|
||||||
? 10
|
const handleFitAll = useCallback(() => {
|
||||||
: plottedIncidents.length > 0
|
if (!mapInstance || allPositions.length === 0) return;
|
||||||
? 14
|
if (allPositions.length === 1) {
|
||||||
: 4;
|
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 (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
|
{/* ── Map container ───────────────────────────────────────────────────── */}
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
className="w-full h-full rounded-lg"
|
className="w-full h-full rounded-lg"
|
||||||
style={{ background: "#111827" }}
|
style={{ background: "#111827" }}
|
||||||
>
|
>
|
||||||
|
<MapRefCapture onReady={onMapReady} />
|
||||||
|
|
||||||
<LayersControl position="topright">
|
<LayersControl position="topright">
|
||||||
{/* Base layers */}
|
{/* Base layers */}
|
||||||
<LayersControl.BaseLayer checked name="Dark">
|
<LayersControl.BaseLayer checked name="Dark">
|
||||||
@@ -111,62 +408,59 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
|||||||
{/* Overlay: Nodes */}
|
{/* Overlay: Nodes */}
|
||||||
<LayersControl.Overlay checked name="Nodes">
|
<LayersControl.Overlay checked name="Nodes">
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
{nodes.map((node) => (
|
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Active Incidents */}
|
{/* Overlay: Active Incidents */}
|
||||||
<LayersControl.Overlay checked name="Active Incidents">
|
<LayersControl.Overlay checked name="Active Incidents">
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
{plottedIncidents.map(({ inc, pos }) => (
|
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</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>
|
</LayersControl>
|
||||||
</MapContainer>
|
</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="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-green-400">●</span> Online</div>
|
||||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</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-yellow-500">■</span> EMS</div>
|
||||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user