Implement CPS Studio which can be used to export a Mototrbo CPS / RM compatible XML payload (EXPERIMENTAL)
This commit is contained in:
parent
b62d4226a3
commit
f5eb913162
13 changed files with 916 additions and 25 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,3 +3,4 @@ data/
|
||||||
dmrmap
|
dmrmap
|
||||||
compose.override.yml
|
compose.override.yml
|
||||||
.env
|
.env
|
||||||
|
static/talkgroups.json
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ COPY --from=builder /build/migrations/ ./migrations/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
COPY rptrs.json .
|
COPY rptrs.json .
|
||||||
COPY bmrptrs.json .
|
COPY bmrptrs.json .
|
||||||
|
RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["./dmrmap"]
|
CMD ["./dmrmap"]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
RUN go install github.com/air-verse/air@latest
|
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
|
WORKDIR /app
|
||||||
CMD ["air"]
|
CMD cp -n /usr/local/share/talkgroups.json static/talkgroups.json 2>/dev/null; air
|
||||||
|
|
|
||||||
91
README.md
91
README.md
|
|
@ -1,40 +1,50 @@
|
||||||
# DMRmap
|
# 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
|
**Live:** https://dmrmap.kida.io
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Full-screen OpenStreetMap with DMR repeater markers (blue for 2m, red for 70cm)
|
- Interactive OpenStreetMap with repeater markers (blue = VHF/2m, red = UHF/70cm)
|
||||||
- Band filtering (2m / 70cm / both)
|
- Band filtering (2m / 70cm) and network filtering (BrandMeister, DMR+, TGIF, Other)
|
||||||
- Address-to-address routing with repeater corridor search (10 km)
|
- Toggle visibility of personal hotspots and inactive repeaters
|
||||||
- Address autocomplete via Nominatim
|
- Free-text search by callsign, city, or DMR ID
|
||||||
- Driving routes via OSRM
|
- 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
|
- 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
|
- No external CDN dependencies — all assets served locally
|
||||||
|
|
||||||
## Requirements
|
## Tech Stack
|
||||||
|
|
||||||
- Go 1.25+
|
- **Backend:** Go (stdlib `net/http`), PostgreSQL 17 via `pgx/v5`, migrations via `goose/v3`
|
||||||
- Docker (optional)
|
- **Frontend:** Vanilla JavaScript, Leaflet, i18next — no build step
|
||||||
|
- **Infrastructure:** Docker Compose, multi-stage Dockerfile
|
||||||
|
|
||||||
## Running locally
|
## External Services
|
||||||
|
|
||||||
```sh
|
| Service | Usage | Auth |
|
||||||
go run .
|
|---------|-------|------|
|
||||||
```
|
| [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
|
```sh
|
||||||
docker compose up --build
|
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
|
## Development
|
||||||
|
|
||||||
|
|
@ -44,9 +54,54 @@ Hot-reload with [air](https://github.com/air-verse/air):
|
||||||
docker compose -f compose.dev.yml up --build
|
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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
394
static/app.js
394
static/app.js
|
|
@ -60,6 +60,23 @@
|
||||||
|
|
||||||
clearBtn.style.display = "none";
|
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 ===
|
// === Autocomplete ===
|
||||||
function setupAutocomplete(input, listEl) {
|
function setupAutocomplete(input, listEl) {
|
||||||
var acTimer = null;
|
var acTimer = null;
|
||||||
|
|
@ -233,6 +250,272 @@
|
||||||
.replace(/"/g, """);
|
.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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "×";
|
||||||
|
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) {
|
function showCount(count) {
|
||||||
countEl.textContent = t("repeater_count", { count: count });
|
countEl.textContent = t("repeater_count", { count: count });
|
||||||
}
|
}
|
||||||
|
|
@ -625,6 +908,16 @@
|
||||||
|
|
||||||
function renderRepeaterList(container, repeaters) {
|
function renderRepeaterList(container, repeaters) {
|
||||||
container.innerHTML = "";
|
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) {
|
repeaters.forEach(function (r) {
|
||||||
var item = document.createElement("div");
|
var item = document.createElement("div");
|
||||||
item.className = "pin-list-item";
|
item.className = "pin-list-item";
|
||||||
|
|
@ -971,6 +1264,105 @@
|
||||||
fetchRepeaters();
|
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 ===
|
// === Hash navigation ===
|
||||||
function checkHash() {
|
function checkHash() {
|
||||||
var hash = decodeURIComponent(window.location.hash.replace("#", ""));
|
var hash = decodeURIComponent(window.location.hash.replace("#", ""));
|
||||||
|
|
@ -1007,6 +1399,8 @@
|
||||||
window.addEventListener("hashchange", checkHash);
|
window.addEventListener("hashchange", checkHash);
|
||||||
|
|
||||||
// === Init ===
|
// === Init ===
|
||||||
|
fetchTgRegistry();
|
||||||
|
|
||||||
function initApp() {
|
function initApp() {
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
checkHash();
|
checkHash();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
</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">×</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.min.js"></script>
|
||||||
<script src="lib/i18next-browser-languagedetector.min.js"></script>
|
<script src="lib/i18next-browser-languagedetector.min.js"></script>
|
||||||
<script src="lib/i18next-http-backend.min.js"></script>
|
<script src="lib/i18next-http-backend.min.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,17 @@
|
||||||
"back_to_map": "Zur Karte",
|
"back_to_map": "Zur Karte",
|
||||||
"impressum": "Impressum",
|
"impressum": "Impressum",
|
||||||
"datenschutz": "Datenschutz",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,17 @@
|
||||||
"back_to_map": "Back to map",
|
"back_to_map": "Back to map",
|
||||||
"impressum": "Legal Notice",
|
"impressum": "Legal Notice",
|
||||||
"datenschutz": "Privacy Policy",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,17 @@
|
||||||
"back_to_map": "Volver al mapa",
|
"back_to_map": "Volver al mapa",
|
||||||
"impressum": "Aviso legal",
|
"impressum": "Aviso legal",
|
||||||
"datenschutz": "Política de privacidad",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,17 @@
|
||||||
"back_to_map": "Retour à la carte",
|
"back_to_map": "Retour à la carte",
|
||||||
"impressum": "Mentions légales",
|
"impressum": "Mentions légales",
|
||||||
"datenschutz": "Politique de confidentialité",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,17 @@
|
||||||
"back_to_map": "Torna alla mappa",
|
"back_to_map": "Torna alla mappa",
|
||||||
"impressum": "Note legali",
|
"impressum": "Note legali",
|
||||||
"datenschutz": "Informativa sulla privacy",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,5 +40,17 @@
|
||||||
"back_to_map": "Powrót do mapy",
|
"back_to_map": "Powrót do mapy",
|
||||||
"impressum": "Nota prawna",
|
"impressum": "Nota prawna",
|
||||||
"datenschutz": "Polityka prywatności",
|
"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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
332
static/style.css
332
static/style.css
|
|
@ -325,6 +325,7 @@ html, body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-lighter);
|
border-bottom: 1px solid var(--border-lighter);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.autocomplete-list li:last-child {
|
.autocomplete-list li:last-child {
|
||||||
|
|
@ -560,6 +561,25 @@ html, body {
|
||||||
white-space: nowrap;
|
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 {
|
.attribution {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
|
|
@ -636,6 +656,310 @@ html, body {
|
||||||
animation: heatmap-pulse 2s ease-in-out infinite;
|
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) {
|
@media (max-width: 768px) {
|
||||||
#controls {
|
#controls {
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
|
@ -644,4 +968,12 @@ html, body {
|
||||||
width: auto;
|
width: auto;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cps-modal-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue