Implement CPS Studio which can be used to export a Mototrbo CPS / RM compatible XML payload (EXPERIMENTAL)

This commit is contained in:
Marcus Kida 2026-02-17 23:33:34 +01:00
parent b62d4226a3
commit f5eb913162
13 changed files with 916 additions and 25 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ data/
dmrmap
compose.override.yml
.env
static/talkgroups.json

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function buildChannel(alias, slot, colorCode, txHz, rxHz) {
var slotName = slot === "SLOT1" ? "1" : "2";
return ' <set name="ConventionalPersonality" alias="' + escapeXml(alias) + '" key="DGTLCONV6PT25">\n' +
' <field name="CP_PERSTYPE" Name="Digital">DGTLCONV6PT25</field>\n' +
' <field name="CP_CNVPERSALIAS">' + escapeXml(alias) + '</field>\n' +
' <field name="CP_SLTASSGMNT" Name="' + slotName + '">' + slot + '</field>\n' +
' <field name="CP_COLORCODE">' + colorCode + '</field>\n' +
' <field name="CP_TXFREQ">' + txHz + '</field>\n' +
' <field name="CP_RXFREQ">' + rxHz + '</field>\n' +
' <field name="CP_TXINHXPLEN" Name="Color Code Free">MTCHCLRCD</field>\n' +
' <field name="CP_TOT">180</field>\n' +
' </set>\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 '<?xml version="1.0" encoding="utf-8" standalone="yes"?>\n' +
"<config>\n" +
' <category name="Zone">\n' +
' <set name="Zone" alias="DMRmap">\n' +
' <field name="ZP_ZONEALIAS">DMRmap</field>\n' +
' <field name="ZP_ZONETYPE" Name="Normal">NORMAL</field>\n' +
' <collection name="ZoneItems">\n' +
channels +
" </collection>\n" +
" </set>\n" +
" </category>\n" +
"</config>";
}
// === 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 = "&times;";
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();

View file

@ -76,6 +76,41 @@
<a href="/impressum.html" id="link-impressum" data-i18n="impressum">Impressum</a> · <a href="/datenschutz.html" id="link-datenschutz" data-i18n="datenschutz">Datenschutz</a> · <a href="https://git.kida.io/marcus/DMRmap" target="_blank" rel="noopener" data-i18n="source">Source</a><br>
</div>
</div>
<div id="cps-modal" class="cps-modal" style="display:none">
<div class="cps-modal-backdrop"></div>
<div class="cps-modal-dialog">
<div class="cps-modal-header">
<h2 data-i18n="cps_studio_title">CPS Studio</h2> <span class="cps-exp-badge" data-i18n="experimental">Experimental</span>
<button type="button" class="cps-modal-close" id="cps-modal-close">&times;</button>
</div>
<div class="cps-modal-body">
<p class="cps-exp-note" data-i18n="cps_experimental_note">This feature is still in development. Please review the generated output before importing.</p>
<div class="cps-repeater-tags" id="cps-repeater-tags"></div>
<div id="cps-tg-section">
<table class="cps-tg-table">
<thead>
<tr>
<th data-i18n="cps_tg_id">TG</th>
<th data-i18n="cps_tg_name">Name</th>
<th data-i18n="cps_tg_slot">Timeslot</th>
<th></th>
</tr>
</thead>
<tbody id="cps-tg-tbody"></tbody>
</table>
<div class="cps-add-tg-row autocomplete-wrap">
<input type="text" id="cps-add-tg-input" data-i18n-placeholder="cps_add_tg_placeholder" placeholder="Add TG by ID or name..." autocomplete="off" />
<ul class="autocomplete-list cps-ac-list" id="cps-ac-list"></ul>
</div>
<div class="cps-channel-count" id="cps-channel-count"></div>
</div>
</div>
<div class="cps-modal-footer">
<button type="button" id="cps-download-btn" class="cps-btn-primary" disabled data-i18n="cps_download_xml">Download XML</button>
<button type="button" id="cps-copy-btn" class="cps-btn-secondary" disabled data-i18n="cps_copy_xml">Copy to Clipboard</button>
</div>
</div>
</div>
<script src="lib/i18next.min.js"></script>
<script src="lib/i18next-browser-languagedetector.min.js"></script>
<script src="lib/i18next-http-backend.min.js"></script>

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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)"
}

View file

@ -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%;
}
}