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:
Logan
2026-05-23 23:52:49 -04:00
parent 6397e24035
commit 4fc44dcc86
4 changed files with 886 additions and 143 deletions
+362 -47
View File
@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api";
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
// ── P25 structured config types ──────────────────────────────────────────────
// ── P25 structured config types ──────────────────────────────────────────────
interface TalkgroupEntry {
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 ────────────────────────────────────────────────────
function TalkgroupEditor({
@@ -83,6 +325,9 @@ function TalkgroupEditor({
}) {
const [showPaste, setShowPaste] = useState(false);
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() {
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
@@ -115,13 +360,61 @@ function TalkgroupEditor({
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 (
<div className="space-y-2">
{rrSystem && (
<RRImportModal
system={rrSystem}
onImport={handleRRImport}
onCancel={() => setRrSystem(null)}
/>
)}
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
</label>
<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
type="button"
onClick={() => setShowPaste(!showPaste)}
@@ -139,10 +432,17 @@ function TalkgroupEditor({
</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 && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
<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
</p>
<textarea
@@ -219,7 +519,9 @@ function TalkgroupEditor({
</table>
</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>
);
@@ -293,10 +595,12 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
function SystemForm({
initial,
createOnly,
onSave,
onCancel,
}: {
initial?: SystemRecord;
createOnly?: boolean;
onSave: () => void;
onCancel: () => void;
}) {
@@ -313,8 +617,8 @@ function SystemForm({
: "{}"
);
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleTypeChange(t: string) {
setType(t);
@@ -345,7 +649,7 @@ function SystemForm({
} else {
config = JSON.parse(rawJson);
}
if (initial) {
if (initial && !createOnly) {
await c2api.updateSystem(initial.system_id, { name, type, config });
} else {
await c2api.createSystem({ name, type, config });
@@ -358,9 +662,11 @@ function SystemForm({
}
}
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return (
<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>
@@ -441,9 +747,9 @@ interface 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 [open, setOpen] = useState(false);
const [open, setOpen] = useState(false);
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
setSaving(key);
@@ -466,7 +772,7 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
}
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" },
];
@@ -487,8 +793,8 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
<div className="mt-3 space-y-2 font-mono text-xs">
{rows.map(({ key, label }) => {
const override = flags[key];
const isSet = override !== undefined;
const isOn = override !== false;
const isSet = override !== undefined;
const isOn = override !== false;
return (
<div key={key} className="flex items-center gap-3">
<button
@@ -523,21 +829,20 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
);
}
// ── Vocabulary panel ──────────────────────────────────────────────────────────
function VocabularyPanel({ systemId }: { systemId: string }) {
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [vocab, setVocab] = useState<string[] | null>(null);
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
const [bootstrapped, setBootstrapped] = useState(false);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [bootstrapping, setBootstrapping] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
const [newTerm, setNewTerm] = useState("");
const [adding, setAdding] = useState(false);
const [open, setOpen] = useState(false);
async function load() {
if (vocab !== null) return; // already loaded
if (vocab !== null) return;
setLoading(true);
try {
const data = await c2api.getVocabulary(systemId);
@@ -607,7 +912,11 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
<span>{open ? "▲" : "▼"}</span>
<span>
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>
</button>
@@ -617,7 +926,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{!loading && vocab !== null && (
<>
{/* Bootstrap button */}
<div className="flex items-center gap-3">
<button
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>
</div>
{/* Approved vocabulary chips */}
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span
key={term}
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
>
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
{term}
<button
onClick={() => handleRemove(term)}
@@ -654,7 +958,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
)}
</div>
{/* Add term */}
<form onSubmit={handleAdd} className="flex gap-2">
<input
value={newTerm}
@@ -671,7 +974,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
</button>
</form>
{/* Pending induction suggestions */}
{pending.length > 0 && (
<div>
<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">
<span className="text-gray-300 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span>
<button
onClick={() => handleApprove(p.term)}
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>
<button onClick={() => handleApprove(p.term)} 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>
@@ -711,19 +1003,35 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
export default function SystemsPage() {
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) {
if (!confirm("Delete this system?")) return;
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<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"
>
+ New System
@@ -733,8 +1041,9 @@ export default function SystemsPage() {
{editing && (
<SystemForm
initial={editing === "new" ? undefined : editing}
onSave={() => setEditing(null)}
onCancel={() => setEditing(null)}
createOnly={editIsDuplicate}
onSave={closeEdit}
onCancel={closeEdit}
/>
)}
@@ -771,11 +1080,17 @@ export default function SystemsPage() {
</div>
<div className="mt-3 flex gap-3">
<button
onClick={() => setEditing(s)}
onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Edit
</button>
<button
onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Duplicate
</button>
<button
onClick={() => handleDelete(s.system_id)}
className="text-xs text-red-500 hover:text-red-400 transition-colors"