diff --git a/.gitignore b/.gitignore
index 057f8d5..5057249 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ data/
dmrmap
compose.override.yml
.env
+static/talkgroups.json
diff --git a/Dockerfile b/Dockerfile
index 0370c4d..ea6f28a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -13,5 +13,6 @@ COPY --from=builder /build/migrations/ ./migrations/
COPY static/ ./static/
COPY rptrs.json .
COPY bmrptrs.json .
+RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup
EXPOSE 8080
CMD ["./dmrmap"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 90158c0..68e035e 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,4 +1,5 @@
FROM golang:1.25-alpine
RUN go install github.com/air-verse/air@latest
+RUN wget -qO /usr/local/share/talkgroups.json https://api.brandmeister.network/v2/talkgroup
WORKDIR /app
-CMD ["air"]
+CMD cp -n /usr/local/share/talkgroups.json static/talkgroups.json 2>/dev/null; air
diff --git a/README.md b/README.md
index ead3742..077df1b 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,50 @@
# DMRmap
-A web app that visualizes DMR repeaters on an interactive map. Filter by band, search by address, and find repeaters along a driving route.
+A web app that visualizes DMR repeaters on an interactive map. Filter by band and network, search by callsign or address, find repeaters along a driving route, and export CPS-ready channel configurations.
**Live:** https://dmrmap.kida.io
## Features
-- Full-screen OpenStreetMap with DMR repeater markers (blue for 2m, red for 70cm)
-- Band filtering (2m / 70cm / both)
-- Address-to-address routing with repeater corridor search (10 km)
-- Address autocomplete via Nominatim
-- Driving routes via OSRM
+- Interactive OpenStreetMap with repeater markers (blue = VHF/2m, red = UHF/70cm)
+- Band filtering (2m / 70cm) and network filtering (BrandMeister, DMR+, TGIF, Other)
+- Toggle visibility of personal hotspots and inactive repeaters
+- Free-text search by callsign, city, or DMR ID
+- Address-to-address routing via OSRM with adjustable corridor width
+- Click-to-pin with adjustable radius search
+- Detailed repeater popups (frequencies, color code, timeslots, hardware, power, antenna height, BrandMeister status)
+- Real-time Last Heard heatmap via BrandMeister WebSocket
+- CPS Studio: manage talk groups, set timeslots, export Motorola CPS 2.0 compatible XML
- Coordinate display with Maidenhead grid locator
-- Dark mode (follows system preference, manual toggle)
+- Dark mode (follows system preference)
+- Internationalization (English, German, Spanish, French, Italian, Polish)
- No external CDN dependencies — all assets served locally
-## Requirements
+## Tech Stack
-- Go 1.25+
-- Docker (optional)
+- **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3`
+- **Frontend:** Vanilla JavaScript, Leaflet, i18next — no build step
+- **Infrastructure:** Docker Compose, multi-stage Dockerfile
-## Running locally
+## External Services
-```sh
-go run .
-```
+| Service | Usage | Auth |
+|---------|-------|------|
+| [RadioID](https://radioid.net/database/dumps) | Repeater database (seeded from `rptrs.json` dump) | None |
+| [BrandMeister API](https://wiki.brandmeister.network/index.php/API) | Device status sync (`/v2/device/{id}`), talk group registry (`/v2/talkgroup`) | None (public read) |
+| [BrandMeister WebSocket](https://wiki.brandmeister.network/index.php/API/Last_Heard) | Real-time Last Heard feed for heatmap via socket.io | None |
+| [Nominatim](https://nominatim.openstreetmap.org) | Address geocoding and autocomplete (client-side) | None |
+| [OSRM](https://project-osrm.org) | Driving route calculation (client-side) | None |
-Open http://localhost:8080. The SQLite database is seeded automatically from `rptrs.json` on first startup.
+The BrandMeister talk group registry is fetched once at Docker build time and bundled as `static/talkgroups.json`. It is used for talk group name resolution and autocomplete in CPS Studio.
-## Docker
+## Running with Docker
```sh
docker compose up --build
```
-The database is persisted in a named volume across restarts.
+Open http://localhost:8080. PostgreSQL data is persisted in a named volume.
## Development
@@ -44,9 +54,54 @@ Hot-reload with [air](https://github.com/air-verse/air):
docker compose -f compose.dev.yml up --build
```
+The dev container mounts the project directory, so changes to Go and frontend files are picked up automatically.
+
+## Environment Variables
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `DATABASE_URL` | `postgres://dmrmap:dmrmap@localhost:5432/dmrmap?sslmode=disable` | PostgreSQL connection string |
+| `JSON_PATH` | `rptrs.json` | Path to RadioID repeater dump |
+| `BMRPTRS_PATH` | `bmrptrs.json` | Path to BrandMeister repeater dump |
+| `LISTEN_ADDR` | `:8080` | HTTP listen address |
+| `BM_SYNC` | *(disabled)* | Set to `true` to enable periodic BrandMeister device sync |
+| `ADMIN_TOKEN` | *(disabled)* | Set to enable the admin interface at `/admin/` |
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/api/repeaters` | Repeaters within map bounding box (query: `minLat`, `maxLat`, `minLng`, `maxLng`, `band`, `network`, `hotspots`, `inactive`) |
+| GET | `/api/repeaters/radius` | Repeaters within radius of a point (query: `lat`, `lng`, `radius`) |
+| POST | `/api/repeaters/route` | Repeaters along a route corridor (body: GeoJSON coordinates + `corridor` width) |
+| GET | `/api/repeater` | Single repeater by ID (query: `id`) |
+| GET | `/api/repeaters/search` | Free-text search (query: `q`) |
+
+## Project Structure
+
+```
+├── main.go # Route registration, env config
+├── db.go # DB queries, Repeater struct, geo helpers
+├── handlers.go # Public API handlers
+├── admin.go # Admin auth middleware + admin API
+├── bmdevice.go # BrandMeister device sync
+├── migrations/ # SQL migrations (goose)
+├── static/
+│ ├── app.js # Frontend application logic
+│ ├── style.css # Styles with dark mode support
+│ ├── index.html # Single-page HTML shell
+│ ├── i18n.js # i18next initialization
+│ ├── locales/ # Translation files (en, de, es, fr, it, pl)
+│ └── lib/ # Vendored JS libraries (Leaflet, i18next, socket.io)
+├── Dockerfile # Production multi-stage build
+├── Dockerfile.dev # Development image with air
+├── compose.yml # Production Docker Compose
+└── compose.dev.yml # Development Docker Compose
+```
+
## Data
-Repeater data provided by [RadioID](https://radioid.net/database/dumps). This project is not affiliated with or endorsed by RadioID.
+Repeater data provided by [RadioID](https://radioid.net/database/dumps). Availability and talk group data by [BrandMeister](https://brandmeister.network). This project is not affiliated with or endorsed by RadioID or BrandMeister.
## License
diff --git a/static/app.js b/static/app.js
index 76d0ea1..0efa0de 100644
--- a/static/app.js
+++ b/static/app.js
@@ -60,6 +60,23 @@
clearBtn.style.display = "none";
+ // === CPS Studio DOM + State ===
+ var cpsModal = document.getElementById("cps-modal");
+ var cpsModalClose = document.getElementById("cps-modal-close");
+ var cpsTgTbody = document.getElementById("cps-tg-tbody");
+ var cpsRepeaterTags = document.getElementById("cps-repeater-tags");
+ var cpsAddTgInput = document.getElementById("cps-add-tg-input");
+ var cpsAcList = document.getElementById("cps-ac-list");
+ var cpsDownloadBtn = document.getElementById("cps-download-btn");
+ var cpsCopyBtn = document.getElementById("cps-copy-btn");
+ var cpsChannelCount = document.getElementById("cps-channel-count");
+
+ var cpsRepeaters = [];
+ var cpsActiveRepeaters = {};
+ var cpsTalkgroups = [];
+ var tgRegistry = null;
+ var tgRegistryPromise = null;
+
// === Autocomplete ===
function setupAutocomplete(input, listEl) {
var acTimer = null;
@@ -233,6 +250,272 @@
.replace(/"/g, """);
}
+ // Fallback TG names from wiki.bm262.de for German-speaking region
+ var BM262_TALKGROUPS = {
+ "262": "Deutschland", "263": "MultiMode DL",
+ "2620": "Sachsen-Anhalt/Mecklenburg-Vorpommern", "2621": "Berlin/Brandenburg",
+ "2622": "Hamburg/Schleswig-Holstein", "2623": "Niedersachsen/Bremen",
+ "2624": "Nordrhein-Westfalen", "2625": "Rheinland-Pfalz/Saarland",
+ "2626": "Hessen", "2627": "Baden-W\u00fcrttemberg", "2628": "Bayern",
+ "2629": "Sachsen/Th\u00fcringen", "26200": "TAC 1", "26209": "Brandenburg",
+ "26212": "Berlin-City", "26220": "Grossraum Hamburg", "26221": "Hamburg-City",
+ "26222": "Ostholstein-Nord", "26223": "Chaoswelle", "26224": "Elbe-Weser",
+ "26225": "AFU-Nord", "26226": "DMR Netzverbund Nord", "26228": "Ostholstein S\u00fcd",
+ "26231": "NI Mitte", "26232": "Dreil\u00e4ndereck Mitte Deutschland", "26233": "TAC 3",
+ "26234": "NI-Sued", "26236": "NI-Nord", "26239": "NI Ost",
+ "26241": "Rheinland", "26242": "Muensterland", "26243": "Ruhrgebiet",
+ "26245": "Rheinland-Sued", "26249": "Siebengebirge", "26250": "Saarland",
+ "26256": "Eifel-Hunsrueck", "26257": "Siegerland", "26260": "Mittelhessen",
+ "26261": "Nordhessen", "26262": "Rhein-Main-Neckar", "26263": "Bergstrasse",
+ "26266": "TAC 4", "26270": "Stuttgart", "26271": "Baden",
+ "26272": "Neckar-Odenwald", "26273": "BW-Ostalb", "26274": "BW B\u00f6blingen",
+ "26275": "Schwarzwald Nord", "26276": "Neckar-Alb", "26277": "Schwarzwald",
+ "26278": "BW Herrenberg", "26279": "BW Mittlerer Neckar", "26280": "Niederbayern",
+ "26282": "Schwaben", "26283": "Region M\u00fcnchen", "26284": "Region Franken",
+ "26285": "Region Ingolstadt", "26286": "Coburg-Rennsteig",
+ "26287": "Allg\u00e4u-Bodensee", "26288": "Region Bayern Oberland",
+ "26289": "Oberpfalz", "26298": "Th\u00fcringen", "26299": "TAC 2",
+ "26300": "Multimode TAC 1", "26301": "Sachsen-Erzgebirge",
+ "26322": "D22 - Neue Medien", "26331": "NI Ost", "26333": "Multimode TAC 3",
+ "26338": "afu38", "26345": "Paderborn", "26346": "Ostwestfalen-Lippe",
+ "26347": "IGA Rhein-Erft", "26348": "Westmuensterland", "26349": "Hochsauerland",
+ "26366": "Multimode TAC 4", "26375": "Bodensee-Oberschwaben", "26377": "Ortenau",
+ "26384": "Schrobenhausen", "26399": "Multimode TAC2", "26426": "FM-Funknetz",
+ "26429": "DL-Nordwest", "262810": "Projekt Pegasus", "263112": "HiOrg-Talk EmComm",
+ "263113": "(Un)Wetter Netz", "263333": "Twitterrunde",
+ "263852": "DARC Dachau - C06 Runde", "264022": "Whitesticker"
+ };
+
+ function tgName(id) {
+ var key = String(id);
+ if (tgRegistry && tgRegistry[key]) return tgRegistry[key];
+ if (BM262_TALKGROUPS[key]) return BM262_TALKGROUPS[key];
+ return "";
+ }
+
+ function escapeXml(str) {
+ if (!str) return "";
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ function buildChannel(alias, slot, colorCode, txHz, rxHz) {
+ var slotName = slot === "SLOT1" ? "1" : "2";
+ return ' \n' +
+ ' DGTLCONV6PT25\n' +
+ ' ' + escapeXml(alias) + '\n' +
+ ' ' + slot + '\n' +
+ ' ' + colorCode + '\n' +
+ ' ' + txHz + '\n' +
+ ' ' + rxHz + '\n' +
+ ' MTCHCLRCD\n' +
+ ' 180\n' +
+ ' \n';
+ }
+
+ function generateCpsXml(repeaters, talkgroups) {
+ var channels = "";
+ repeaters.forEach(function (r) {
+ var txHz = Math.round(r.freq_tx * 1000000);
+ var rxHz = Math.round(r.freq_rx * 1000000);
+ var cc = r.color_code;
+ talkgroups.forEach(function (tg) {
+ var slot = tg.slot === "1" ? "SLOT1" : "SLOT2";
+ var alias = (r.callsign + " TG" + tg.id).substring(0, 16);
+ channels += buildChannel(alias, slot, cc, txHz, rxHz);
+ });
+ });
+ return '\n' +
+ "\n" +
+ ' \n' +
+ ' \n' +
+ ' DMRmap\n' +
+ ' NORMAL\n' +
+ ' \n' +
+ channels +
+ " \n" +
+ " \n" +
+ " \n" +
+ "";
+ }
+
+ // === BrandMeister API ===
+ function fetchTgRegistry() {
+ if (tgRegistry) return Promise.resolve(tgRegistry);
+ if (tgRegistryPromise) return tgRegistryPromise;
+ tgRegistryPromise = fetch("/talkgroups.json")
+ .then(function (r) { return r.json(); })
+ .then(function (data) { tgRegistry = data; return data; })
+ .catch(function () { tgRegistryPromise = null; return {}; });
+ return tgRegistryPromise;
+ }
+
+ // === CPS Studio Modal ===
+ function openCpsModal(repeaters) {
+ cpsRepeaters = repeaters;
+ cpsActiveRepeaters = {};
+ repeaters.forEach(function (r) { cpsActiveRepeaters[r.id] = true; });
+ cpsTalkgroups = [];
+ cpsModal.style.display = "flex";
+ document.body.style.overflow = "hidden";
+ cpsTgTbody.innerHTML = "";
+ renderRepeaterTags();
+ renderTgTable();
+ updateChannelCount();
+ updateCpsButtons();
+ translateDOM();
+ }
+
+ function renderRepeaterTags() {
+ cpsRepeaterTags.innerHTML = "";
+ cpsRepeaters.forEach(function (r) {
+ var tag = document.createElement("button");
+ tag.type = "button";
+ tag.className = "cps-rptr-tag" + (cpsActiveRepeaters[r.id] ? " active" : "");
+ tag.textContent = r.callsign;
+ tag.addEventListener("click", function () {
+ if (cpsActiveRepeaters[r.id]) {
+ delete cpsActiveRepeaters[r.id];
+ } else {
+ cpsActiveRepeaters[r.id] = true;
+ }
+ tag.classList.toggle("active");
+ updateChannelCount();
+ updateCpsButtons();
+ });
+ cpsRepeaterTags.appendChild(tag);
+ });
+ }
+
+ function getActiveRepeaters() {
+ return cpsRepeaters.filter(function (r) { return cpsActiveRepeaters[r.id]; });
+ }
+
+ function updateCpsButtons() {
+ var hasData = getActiveRepeaters().length > 0 && cpsTalkgroups.length > 0;
+ cpsDownloadBtn.disabled = !hasData;
+ cpsCopyBtn.disabled = !hasData;
+ }
+
+ function closeCpsModal() {
+ cpsModal.style.display = "none";
+ document.body.style.overflow = "";
+ cpsAddTgInput.value = "";
+ cpsAcList.innerHTML = "";
+ }
+
+ function renderTgTable() {
+ cpsTgTbody.innerHTML = "";
+ cpsTalkgroups.forEach(function (tg, idx) {
+ var tr = document.createElement("tr");
+
+ var tdId = document.createElement("td");
+ tdId.className = "tg-id";
+ tdId.textContent = tg.id;
+ tr.appendChild(tdId);
+
+ var tdName = document.createElement("td");
+ tdName.className = "tg-name";
+ tdName.textContent = tg.name || t("cps_unknown_tg");
+ tr.appendChild(tdName);
+
+ var tdSlot = document.createElement("td");
+ var toggle = document.createElement("div");
+ toggle.className = "ts-toggle";
+ var btn1 = document.createElement("button");
+ btn1.type = "button";
+ btn1.textContent = "TS1";
+ if (tg.slot === "1") btn1.className = "active";
+ var btn2 = document.createElement("button");
+ btn2.type = "button";
+ btn2.textContent = "TS2";
+ if (tg.slot === "2") btn2.className = "active";
+ btn1.addEventListener("click", (function (i) {
+ return function () { cpsTalkgroups[i].slot = "1"; renderTgTable(); updateChannelCount(); };
+ })(idx));
+ btn2.addEventListener("click", (function (i) {
+ return function () { cpsTalkgroups[i].slot = "2"; renderTgTable(); updateChannelCount(); };
+ })(idx));
+ toggle.appendChild(btn1);
+ toggle.appendChild(btn2);
+ tdSlot.appendChild(toggle);
+ tr.appendChild(tdSlot);
+
+ var tdRemove = document.createElement("td");
+ var removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className = "tg-remove-btn";
+ removeBtn.innerHTML = "×";
+ removeBtn.addEventListener("click", (function (i) {
+ return function () { cpsTalkgroups.splice(i, 1); renderTgTable(); updateChannelCount(); updateCpsButtons(); };
+ })(idx));
+ tdRemove.appendChild(removeBtn);
+ tr.appendChild(tdRemove);
+
+ cpsTgTbody.appendChild(tr);
+ });
+ }
+
+ function updateChannelCount() {
+ var active = getActiveRepeaters().length;
+ var total = active * cpsTalkgroups.length;
+ cpsChannelCount.textContent = t("cps_channel_count", {
+ repeaters: active,
+ tgs: cpsTalkgroups.length,
+ channels: total
+ });
+ }
+
+ function addTalkgroupById(tgId) {
+ if (cpsTalkgroups.some(function (tg) { return tg.id === tgId; })) return;
+ cpsTalkgroups.push({ id: tgId, name: tgName(tgId), slot: "2" });
+ cpsTalkgroups.sort(function (a, b) { return a.id - b.id; });
+ renderTgTable();
+ updateChannelCount();
+ updateCpsButtons();
+ }
+
+ function downloadCpsXml() {
+ var active = getActiveRepeaters();
+ if (!cpsTalkgroups.length || !active.length) return;
+ var xml = generateCpsXml(active, cpsTalkgroups);
+ var blob = new Blob([xml], { type: "application/xml" });
+ var url = URL.createObjectURL(blob);
+ var a = document.createElement("a");
+ a.href = url;
+ a.download = "dmrmap-cps.xml";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ function copyCpsXml() {
+ var active = getActiveRepeaters();
+ if (!cpsTalkgroups.length || !active.length) return;
+ var xml = generateCpsXml(active, cpsTalkgroups);
+ var origText = cpsCopyBtn.textContent;
+ navigator.clipboard.writeText(xml).then(function () {
+ cpsCopyBtn.textContent = t("cps_copied");
+ setTimeout(function () { cpsCopyBtn.textContent = origText; }, 2000);
+ }).catch(function () {
+ var ta = document.createElement("textarea");
+ ta.value = xml;
+ ta.style.position = "fixed";
+ ta.style.opacity = "0";
+ document.body.appendChild(ta);
+ ta.select();
+ document.execCommand("copy");
+ document.body.removeChild(ta);
+ cpsCopyBtn.textContent = t("cps_copied");
+ setTimeout(function () { cpsCopyBtn.textContent = origText; }, 2000);
+ });
+ }
+
function showCount(count) {
countEl.textContent = t("repeater_count", { count: count });
}
@@ -625,6 +908,16 @@
function renderRepeaterList(container, repeaters) {
container.innerHTML = "";
+ if (repeaters.length > 0) {
+ var cpsBtn = document.createElement("button");
+ cpsBtn.className = "cps-copy-btn";
+ cpsBtn.textContent = t("cps_studio_btn");
+ cpsBtn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ openCpsModal(repeaters);
+ });
+ container.appendChild(cpsBtn);
+ }
repeaters.forEach(function (r) {
var item = document.createElement("div");
item.className = "pin-list-item";
@@ -971,6 +1264,105 @@
fetchRepeaters();
});
+ // === CPS Studio Events ===
+ cpsModalClose.addEventListener("click", closeCpsModal);
+ document.querySelector(".cps-modal-backdrop").addEventListener("click", closeCpsModal);
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape" && cpsModal.style.display !== "none") {
+ if (cpsAcList.children.length) {
+ cpsAcList.innerHTML = "";
+ cpsAcIdx = -1;
+ } else {
+ closeCpsModal();
+ }
+ }
+ });
+ cpsDownloadBtn.addEventListener("click", downloadCpsXml);
+ cpsCopyBtn.addEventListener("click", copyCpsXml);
+
+ // TG autocomplete
+ var cpsAcIdx = -1;
+ var cpsAcTimer = null;
+
+ function searchTalkgroups(query) {
+ if (!tgRegistry) return [];
+ var q = query.toLowerCase();
+ var isNum = /^\d+$/.test(query);
+ var results = [];
+ for (var id in tgRegistry) {
+ if (isNum ? id.indexOf(query) === 0 : tgRegistry[id].toLowerCase().indexOf(q) !== -1) {
+ results.push({ id: parseInt(id), name: tgRegistry[id] });
+ }
+ if (results.length >= 8) break;
+ }
+ // also search BM262 fallback
+ for (var bmId in BM262_TALKGROUPS) {
+ if (results.some(function (r) { return r.id === parseInt(bmId); })) continue;
+ if (isNum ? bmId.indexOf(query) === 0 : BM262_TALKGROUPS[bmId].toLowerCase().indexOf(q) !== -1) {
+ results.push({ id: parseInt(bmId), name: BM262_TALKGROUPS[bmId] });
+ }
+ if (results.length >= 8) break;
+ }
+ return results;
+ }
+
+ function renderCpsAc(results) {
+ cpsAcList.innerHTML = "";
+ cpsAcIdx = -1;
+ results.forEach(function (r) {
+ var li = document.createElement("li");
+ li.textContent = r.id + " — " + (r.name || t("cps_unknown_tg"));
+ if (cpsTalkgroups.some(function (tg) { return tg.id === r.id; })) {
+ li.className = "ac-disabled";
+ } else {
+ li.addEventListener("click", function () {
+ addTalkgroupById(r.id);
+ cpsAddTgInput.value = "";
+ cpsAcList.innerHTML = "";
+ cpsAcIdx = -1;
+ cpsAddTgInput.focus();
+ });
+ }
+ cpsAcList.appendChild(li);
+ });
+ }
+
+ cpsAddTgInput.addEventListener("input", function () {
+ clearTimeout(cpsAcTimer);
+ var val = cpsAddTgInput.value.trim();
+ if (!val) { cpsAcList.innerHTML = ""; cpsAcIdx = -1; return; }
+ cpsAcTimer = setTimeout(function () {
+ renderCpsAc(searchTalkgroups(val));
+ }, 120);
+ });
+
+ cpsAddTgInput.addEventListener("keydown", function (e) {
+ var items = cpsAcList.querySelectorAll("li:not(.ac-disabled)");
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ cpsAcIdx = Math.min(cpsAcIdx + 1, items.length - 1);
+ items.forEach(function (li, i) { li.classList.toggle("active", i === cpsAcIdx); });
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ cpsAcIdx = Math.max(cpsAcIdx - 1, 0);
+ items.forEach(function (li, i) { li.classList.toggle("active", i === cpsAcIdx); });
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ if (cpsAcIdx >= 0 && items[cpsAcIdx]) {
+ items[cpsAcIdx].click();
+ } else {
+ var val = cpsAddTgInput.value.trim();
+ var tgId = parseInt(val);
+ if (!isNaN(tgId) && tgId > 0) {
+ addTalkgroupById(tgId);
+ cpsAddTgInput.value = "";
+ cpsAcList.innerHTML = "";
+ cpsAcIdx = -1;
+ }
+ }
+ }
+ });
+
// === Hash navigation ===
function checkHash() {
var hash = decodeURIComponent(window.location.hash.replace("#", ""));
@@ -1007,6 +1399,8 @@
window.addEventListener("hashchange", checkHash);
// === Init ===
+ fetchTgRegistry();
+
function initApp() {
if (window.location.hash) {
checkHash();
diff --git a/static/index.html b/static/index.html
index 00d19e1..582da7f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -76,6 +76,41 @@
Impressum · Datenschutz · Source
+
+
+
+
+
+
This feature is still in development. Please review the generated output before importing.
+
+
+
+
+
+ | TG |
+ Name |
+ Timeslot |
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/static/locales/de.json b/static/locales/de.json
index f51c8b9..f2e0fc6 100644
--- a/static/locales/de.json
+++ b/static/locales/de.json
@@ -38,5 +38,17 @@
"back_to_map": "Zur Karte",
"impressum": "Impressum",
"datenschutz": "Datenschutz",
- "source": "Quellcode"
+ "source": "Quellcode",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "Diese Funktion befindet sich noch in der Entwicklung. Bitte überprüfe die generierte Ausgabe vor dem Import.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Name",
+ "cps_tg_slot": "Zeitschlitz",
+ "cps_add_tg_placeholder": "TG per ID oder Name hinzufügen...",
+ "cps_download_xml": "XML herunterladen",
+ "cps_copy_xml": "In Zwischenablage",
+ "cps_copied": "Kopiert!",
+ "cps_unknown_tg": "Unbekannt",
+ "cps_channel_count": "{{channels}} Kanäle ({{repeaters}} Relais × {{tgs}} Talkgroups)"
}
diff --git a/static/locales/en.json b/static/locales/en.json
index aafc65b..35a724e 100644
--- a/static/locales/en.json
+++ b/static/locales/en.json
@@ -38,5 +38,17 @@
"back_to_map": "Back to map",
"impressum": "Legal Notice",
"datenschutz": "Privacy Policy",
- "source": "Source"
+ "source": "Source",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "This feature is still in development. Please review the generated output before importing.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Name",
+ "cps_tg_slot": "Timeslot",
+ "cps_add_tg_placeholder": "Add TG by ID or name...",
+ "cps_download_xml": "Download XML",
+ "cps_copy_xml": "Copy to Clipboard",
+ "cps_copied": "Copied!",
+ "cps_unknown_tg": "Unknown",
+ "cps_channel_count": "{{channels}} channels ({{repeaters}} repeaters × {{tgs}} talk groups)"
}
diff --git a/static/locales/es.json b/static/locales/es.json
index 4c95f92..b735105 100644
--- a/static/locales/es.json
+++ b/static/locales/es.json
@@ -38,5 +38,17 @@
"back_to_map": "Volver al mapa",
"impressum": "Aviso legal",
"datenschutz": "Política de privacidad",
- "source": "Código fuente"
+ "source": "Código fuente",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "Esta función aún está en desarrollo. Revisa el resultado generado antes de importarlo.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Nombre",
+ "cps_tg_slot": "Timeslot",
+ "cps_add_tg_placeholder": "Añadir TG por ID o nombre...",
+ "cps_download_xml": "Descargar XML",
+ "cps_copy_xml": "Copiar al portapapeles",
+ "cps_copied": "¡Copiado!",
+ "cps_unknown_tg": "Desconocido",
+ "cps_channel_count": "{{channels}} canales ({{repeaters}} repetidores × {{tgs}} grupos)"
}
diff --git a/static/locales/fr.json b/static/locales/fr.json
index b9a2be8..a24fcc7 100644
--- a/static/locales/fr.json
+++ b/static/locales/fr.json
@@ -38,5 +38,17 @@
"back_to_map": "Retour à la carte",
"impressum": "Mentions légales",
"datenschutz": "Politique de confidentialité",
- "source": "Code source"
+ "source": "Code source",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "Cette fonctionnalité est encore en développement. Veuillez vérifier le résultat généré avant de l'importer.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Nom",
+ "cps_tg_slot": "Timeslot",
+ "cps_add_tg_placeholder": "Ajouter TG par ID ou nom...",
+ "cps_download_xml": "Télécharger XML",
+ "cps_copy_xml": "Copier dans le presse-papiers",
+ "cps_copied": "Copié !",
+ "cps_unknown_tg": "Inconnu",
+ "cps_channel_count": "{{channels}} canaux ({{repeaters}} relais × {{tgs}} groupes)"
}
diff --git a/static/locales/it.json b/static/locales/it.json
index 2f11ad7..99b63ca 100644
--- a/static/locales/it.json
+++ b/static/locales/it.json
@@ -38,5 +38,17 @@
"back_to_map": "Torna alla mappa",
"impressum": "Note legali",
"datenschutz": "Informativa sulla privacy",
- "source": "Codice sorgente"
+ "source": "Codice sorgente",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "Questa funzione è ancora in fase di sviluppo. Verifica l'output generato prima dell'importazione.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Nome",
+ "cps_tg_slot": "Timeslot",
+ "cps_add_tg_placeholder": "Aggiungi TG per ID o nome...",
+ "cps_download_xml": "Scarica XML",
+ "cps_copy_xml": "Copia negli appunti",
+ "cps_copied": "Copiato!",
+ "cps_unknown_tg": "Sconosciuto",
+ "cps_channel_count": "{{channels}} canali ({{repeaters}} ripetitori × {{tgs}} gruppi)"
}
diff --git a/static/locales/pl.json b/static/locales/pl.json
index 1daec47..c569497 100644
--- a/static/locales/pl.json
+++ b/static/locales/pl.json
@@ -40,5 +40,17 @@
"back_to_map": "Powrót do mapy",
"impressum": "Nota prawna",
"datenschutz": "Polityka prywatności",
- "source": "Kod źródłowy"
+ "source": "Kod źródłowy",
+ "cps_studio_btn": "CPS Studio",
+ "cps_studio_title": "CPS Studio",
+ "cps_experimental_note": "Ta funkcja jest wciąż w fazie rozwoju. Sprawdź wygenerowany wynik przed importem.",
+ "cps_tg_id": "TG",
+ "cps_tg_name": "Nazwa",
+ "cps_tg_slot": "Timeslot",
+ "cps_add_tg_placeholder": "Dodaj TG po ID lub nazwie...",
+ "cps_download_xml": "Pobierz XML",
+ "cps_copy_xml": "Kopiuj do schowka",
+ "cps_copied": "Skopiowano!",
+ "cps_unknown_tg": "Nieznany",
+ "cps_channel_count": "{{channels}} kanałów ({{repeaters}} przemienników × {{tgs}} grup)"
}
diff --git a/static/style.css b/static/style.css
index 7b7760f..42ee629 100644
--- a/static/style.css
+++ b/static/style.css
@@ -325,6 +325,7 @@ html, body {
cursor: pointer;
border-bottom: 1px solid var(--border-lighter);
line-height: 1.3;
+ color: var(--text);
}
.autocomplete-list li:last-child {
@@ -560,6 +561,25 @@ html, body {
white-space: nowrap;
}
+.cps-copy-btn {
+ display: block;
+ width: 100%;
+ padding: 5px 8px;
+ margin-bottom: 4px;
+ background: var(--clear-btn-bg);
+ color: var(--text-muted);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ text-align: center;
+}
+
+.cps-copy-btn:hover {
+ background: var(--clear-btn-hover);
+ color: var(--text);
+}
+
.attribution {
margin-top: 8px;
padding-top: 8px;
@@ -636,6 +656,310 @@ html, body {
animation: heatmap-pulse 2s ease-in-out infinite;
}
+/* CPS Studio Modal */
+
+.cps-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.cps-modal-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+}
+
+.cps-modal-dialog {
+ position: relative;
+ background: var(--bg);
+ border-radius: 10px;
+ box-shadow: 0 8px 32px var(--shadow);
+ width: 90%;
+ max-width: 600px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.cps-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border-light);
+}
+
+.cps-modal-header h2 {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text);
+ margin: 0;
+}
+
+.cps-exp-badge {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: #ff9800;
+ color: #fff;
+ padding: 2px 6px;
+ border-radius: 3px;
+ margin-right: auto;
+ margin-left: 8px;
+}
+
+.cps-exp-note {
+ font-size: 11px;
+ color: var(--text-faint);
+ margin: 0 0 10px 0;
+ line-height: 1.4;
+}
+
+.cps-modal-close {
+ background: none;
+ border: none;
+ font-size: 22px;
+ color: var(--text-faint);
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ line-height: 1;
+}
+
+.cps-modal-close:hover {
+ color: var(--text);
+ background: var(--hover-bg);
+}
+
+.cps-modal-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 14px 18px;
+}
+
+.cps-repeater-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.cps-rptr-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ border: 1px solid var(--border);
+ background: var(--input-bg);
+ color: var(--text);
+ transition: opacity 0.15s, background 0.15s;
+ user-select: none;
+}
+
+.cps-rptr-tag.active {
+ background: #4CAF50;
+ border-color: #4CAF50;
+ color: white;
+}
+
+.cps-rptr-tag:not(.active) {
+ opacity: 0.45;
+}
+
+.cps-rptr-tag:hover {
+ opacity: 1;
+}
+
+.cps-tg-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.cps-tg-table th {
+ text-align: left;
+ font-weight: 600;
+ color: var(--text-muted);
+ padding: 6px 4px;
+ border-bottom: 2px solid var(--border);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.cps-tg-table td {
+ padding: 6px 4px;
+ border-bottom: 1px solid var(--border-lighter);
+ color: var(--text);
+ vertical-align: middle;
+}
+
+.cps-tg-table .tg-id {
+ font-weight: 600;
+ font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace;
+ min-width: 60px;
+}
+
+.cps-tg-table .tg-name {
+ color: var(--text-muted);
+}
+
+.ts-toggle {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.ts-toggle button {
+ padding: 3px 10px;
+ border: none;
+ background: none;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ color: var(--text-faint);
+}
+
+.ts-toggle button.active {
+ background: #4CAF50;
+ color: white;
+}
+
+.ts-toggle button:not(.active):hover {
+ background: var(--hover-bg);
+}
+
+.tg-remove-btn {
+ background: none;
+ border: none;
+ color: var(--text-fainter);
+ cursor: pointer;
+ font-size: 16px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ line-height: 1;
+}
+
+.tg-remove-btn:hover {
+ color: #ef5350;
+ background: var(--hover-bg);
+}
+
+.cps-add-tg-row {
+ margin-top: 8px;
+}
+
+.cps-add-tg-row input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ font-size: 12px;
+ background: var(--input-bg);
+ color: var(--text);
+ outline: none;
+}
+
+.cps-add-tg-row input:focus {
+ border-color: #4CAF50;
+}
+
+.cps-ac-list:not(:empty) {
+ display: block;
+}
+
+.autocomplete-list li.ac-disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.autocomplete-list li.ac-disabled:hover {
+ background: none;
+}
+
+.cps-channel-count {
+ margin-top: 8px;
+ font-size: 11px;
+ color: var(--text-fainter);
+}
+
+.cps-modal-footer {
+ display: flex;
+ gap: 8px;
+ padding: 12px 18px;
+ border-top: 1px solid var(--border-light);
+}
+
+.cps-btn-primary {
+ flex: 1;
+ padding: 8px 16px;
+ background: #4CAF50;
+ color: white;
+ border: none;
+ border-radius: 5px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.cps-btn-primary:hover {
+ background: #43A047;
+}
+
+.cps-btn-primary:disabled {
+ background: #A5D6A7;
+ cursor: default;
+}
+
+.cps-btn-secondary {
+ flex: 1;
+ padding: 8px 16px;
+ background: var(--clear-btn-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.cps-btn-secondary:hover {
+ background: var(--clear-btn-hover);
+}
+
+.cps-btn-secondary:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cps-btn-primary:disabled {
+ background: #2e7d32;
+ color: #a5d6a7;
+ }
+
+ .cps-modal-backdrop {
+ background: rgba(0, 0, 0, 0.7);
+ }
+}
+
@media (max-width: 768px) {
#controls {
top: 10px;
@@ -644,4 +968,12 @@ html, body {
width: auto;
padding: 8px 10px;
}
+
+ .cps-modal-dialog {
+ width: 100%;
+ max-width: none;
+ max-height: 100vh;
+ border-radius: 0;
+ height: 100%;
+ }
}