Refactor frontend to be built from TS
This commit is contained in:
parent
14f76ab30e
commit
2a2e98901c
27 changed files with 3163 additions and 397 deletions
|
|
@ -4,7 +4,7 @@ root = "."
|
||||||
cmd = "go build -o ./tmp/dmrmap ."
|
cmd = "go build -o ./tmp/dmrmap ."
|
||||||
bin = "./tmp/dmrmap"
|
bin = "./tmp/dmrmap"
|
||||||
include_ext = ["go"]
|
include_ext = ["go"]
|
||||||
exclude_dir = ["tmp", "static", "data", "migrations"]
|
exclude_dir = ["tmp", "static", "data", "migrations", "src", "node_modules"]
|
||||||
delay = 500
|
delay = 500
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@ dmrmap
|
||||||
compose.override.yml
|
compose.override.yml
|
||||||
.env
|
.env
|
||||||
static/talkgroups.json
|
static/talkgroups.json
|
||||||
|
node_modules/
|
||||||
|
static/app.js
|
||||||
|
static/app.js.map
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
FROM node:22-alpine AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
RUN npm run build:prod
|
||||||
|
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
@ -13,6 +21,7 @@ WORKDIR /app
|
||||||
COPY --from=builder /build/dmrmap .
|
COPY --from=builder /build/dmrmap .
|
||||||
COPY --from=builder /build/migrations/ ./migrations/
|
COPY --from=builder /build/migrations/ ./migrations/
|
||||||
COPY static/ ./static/
|
COPY static/ ./static/
|
||||||
|
COPY --from=frontend /build/static/app.js ./static/app.js
|
||||||
COPY rptrs.json .
|
COPY rptrs.json .
|
||||||
COPY bmrptrs.json .
|
COPY bmrptrs.json .
|
||||||
RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup
|
RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
|
RUN apk add --no-cache nodejs npm
|
||||||
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
|
RUN wget -qO /usr/local/share/talkgroups.json https://api.brandmeister.network/v2/talkgroup
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD cp -n /usr/local/share/talkgroups.json static/talkgroups.json 2>/dev/null; air
|
CMD cp -n /usr/local/share/talkgroups.json static/talkgroups.json 2>/dev/null; \
|
||||||
|
npm install --silent && npm run watch & \
|
||||||
|
air
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ services:
|
||||||
- .:/app
|
- .:/app
|
||||||
- go-mod-cache:/go/pkg/mod
|
- go-mod-cache:/go/pkg/mod
|
||||||
- go-build-cache:/root/.cache/go-build
|
- go-build-cache:/root/.cache/go-build
|
||||||
|
- node_modules:/app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -32,4 +33,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
go-mod-cache:
|
go-mod-cache:
|
||||||
go-build-cache:
|
go-build-cache:
|
||||||
|
node_modules:
|
||||||
pgdata-dev:
|
pgdata-dev:
|
||||||
|
|
|
||||||
11
compose.test.yml
Normal file
11
compose.test.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
services:
|
||||||
|
test:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- node_modules_test:/app/node_modules
|
||||||
|
command: sh -c "npm install --silent && npm test"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules_test:
|
||||||
1919
package-lock.json
generated
Normal file
1919
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "dmrmap-frontend",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "esbuild src/main.ts --bundle --outfile=static/app.js --format=iife --target=es2017 --sourcemap",
|
||||||
|
"build:prod": "esbuild src/main.ts --bundle --outfile=static/app.js --format=iife --target=es2017 --minify",
|
||||||
|
"watch": "esbuild src/main.ts --bundle --outfile=static/app.js --format=iife --target=es2017 --sourcemap --watch",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.0",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/cps-anytone.test.ts
Normal file
104
src/cps-anytone.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone";
|
||||||
|
import type { Repeater, CpsTalkgroup } from "./types";
|
||||||
|
|
||||||
|
const makeRepeater = (overrides: Partial<Repeater> = {}): Repeater => ({
|
||||||
|
id: 1, callsign: "DB0ABC", freq_tx: 439.5, freq_rx: 431.9,
|
||||||
|
freq_offset: "-7.6", band: "70cm", lat: 52.5, lng: 13.4,
|
||||||
|
city: "Berlin", state: "Berlin", country: "Germany", color_code: 1,
|
||||||
|
ts_linked: "1,2", trustee: "DL1ABC", ipsc_network: "BrandMeister",
|
||||||
|
networks: ["BrandMeister"], hotspot: 0, status: "On-air",
|
||||||
|
last_seen: null, bm_status: 1, bm_status_text: "Connected",
|
||||||
|
hardware: "", firmware: "", pep: 0, agl: 0, website: "", description: "",
|
||||||
|
import_freq_inconsistent: false, inactive: false, last_polled: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateAnytoneContactsCsv", () => {
|
||||||
|
const tgs: CpsTalkgroup[] = [
|
||||||
|
{ id: 262, name: "TG262 Deutschlan", slot: "1" },
|
||||||
|
{ id: 2628, name: "TG2628 Bayern", slot: "1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("generates CSV with correct header", () => {
|
||||||
|
const csv = generateAnytoneContactsCsv(tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines[0]).toBe("No.,Radio ID,Name,Call Type,Call Alert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates correct data rows", () => {
|
||||||
|
const csv = generateAnytoneContactsCsv(tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines).toHaveLength(3); // header + 2 rows
|
||||||
|
expect(lines[1]).toBe("1,262,TG262 Deutschlan,Group Call,None");
|
||||||
|
expect(lines[2]).toBe("2,2628,TG2628 Bayern,Group Call,None");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses TG ID as fallback name", () => {
|
||||||
|
const tg: CpsTalkgroup[] = [{ id: 99999, name: "", slot: "1" }];
|
||||||
|
const csv = generateAnytoneContactsCsv(tg);
|
||||||
|
expect(csv).toContain("TG99999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has 5 columns per row", () => {
|
||||||
|
const csv = generateAnytoneContactsCsv(tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line.split(",")).toHaveLength(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateAnytoneChannelsCsv", () => {
|
||||||
|
const tgs: CpsTalkgroup[] = [
|
||||||
|
{ id: 262, name: "TG262", slot: "1" },
|
||||||
|
];
|
||||||
|
const repeaters = [makeRepeater()];
|
||||||
|
|
||||||
|
it("generates CSV with correct header", () => {
|
||||||
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines[0]).toBe(ANYTONE_CH_HEADER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates one row per repeater × talkgroup", () => {
|
||||||
|
const tgs2: CpsTalkgroup[] = [
|
||||||
|
{ id: 262, name: "TG262", slot: "1" },
|
||||||
|
{ id: 2628, name: "TG2628", slot: "2" },
|
||||||
|
];
|
||||||
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs2);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines).toHaveLength(3); // header + 2 channels
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has 55 columns per row (matching header)", () => {
|
||||||
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
const headerCols = lines[0].split(",").length;
|
||||||
|
expect(headerCols).toBe(55);
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
expect(lines[i].split(",")).toHaveLength(headerCols);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes correct frequencies", () => {
|
||||||
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
||||||
|
expect(csv).toContain("439.500000");
|
||||||
|
expect(csv).toContain("431.900000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes color code and timeslot", () => {
|
||||||
|
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
const cols = lines[1].split(",");
|
||||||
|
// Color code at index 20, slot at index 21
|
||||||
|
expect(cols[20]).toBe("1");
|
||||||
|
expect(cols[21]).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ANYTONE_CH_HEADER", () => {
|
||||||
|
it("has 55 columns", () => {
|
||||||
|
expect(ANYTONE_CH_HEADER.split(",")).toHaveLength(55);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/cps-anytone.ts
Normal file
56
src/cps-anytone.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { Repeater, CpsTalkgroup } from "./types";
|
||||||
|
import { formatFreq } from "./format";
|
||||||
|
|
||||||
|
export const ANYTONE_CH_HEADER = "No.,Channel Name,Receive Frequency,Transmit Frequency," +
|
||||||
|
"Channel Type,Transmit Power,Band Width,CTCSS/DCS Decode,CTCSS/DCS Encode," +
|
||||||
|
"Contact,Contact Call Type,Contact TG/DMR ID,Radio ID," +
|
||||||
|
"Busy Lock/TX Permit,Squelch Mode,Optional Signal,DTMF ID,2Tone ID,5Tone ID,PTT ID," +
|
||||||
|
"Color Code,Slot,Scan List,Receive Group List,PTT Prohibit,Reverse," +
|
||||||
|
"Simplex TDMA,Slot Suit,AES Digital Encryption,Digital Encryption," +
|
||||||
|
"Call Confirmation,Talk Around(Simplex),Work Alone,Custom CTCSS," +
|
||||||
|
"2TONE Decode,Ranging,Through Mode,APRS RX,Analog APRS PTT Mode," +
|
||||||
|
"Digital APRS PTT Mode,APRS Report Type,Digital APRS Report Channel," +
|
||||||
|
"Correct Frequency[Hz],SMS Confirmation,Exclude channel from roaming," +
|
||||||
|
"DMR MODE,DataACK Disable,R5toneBot,R5ToneEot,Auto Scan," +
|
||||||
|
"Ana APRS TX Path,APRS TX Tone,APRS Signal Path,Digi APRS TX CH,Alert Tone";
|
||||||
|
|
||||||
|
/** Generate an Anytone CPS talkgroups CSV. */
|
||||||
|
export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string {
|
||||||
|
const rows = ["No.,Radio ID,Name,Call Type,Call Alert"];
|
||||||
|
talkgroups.forEach(function (tg, idx) {
|
||||||
|
const name = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
||||||
|
rows.push((idx + 1) + "," + tg.id + "," + name + ",Group Call,None");
|
||||||
|
});
|
||||||
|
return rows.join("\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate an Anytone CPS channels CSV. */
|
||||||
|
export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
|
||||||
|
const rows = [ANYTONE_CH_HEADER];
|
||||||
|
let num = 0;
|
||||||
|
repeaters.forEach(function (r) {
|
||||||
|
const rxFreq = formatFreq(r.freq_tx);
|
||||||
|
const txFreq = formatFreq(r.freq_rx);
|
||||||
|
const cc = r.color_code || 1;
|
||||||
|
talkgroups.forEach(function (tg) {
|
||||||
|
num++;
|
||||||
|
const chName = (r.callsign + " " + tg.name).substring(0, 16);
|
||||||
|
const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
||||||
|
rows.push(
|
||||||
|
num + "," + chName + "," + rxFreq + "," + txFreq + "," +
|
||||||
|
"D-Digital,High,12.5K,Off,Off," +
|
||||||
|
contactName + ",Group Call," + tg.id + ",1," +
|
||||||
|
"Same Color Code,Carrier,Off,1,1,1,Off," +
|
||||||
|
cc + "," + tg.slot + ",None,None,Off,Off," +
|
||||||
|
"Off,Off,Normal Encryption,Off," +
|
||||||
|
"Off,Off,Off,251.1," +
|
||||||
|
"0,Off,Off,Off,Off," +
|
||||||
|
"Off,Off,1," +
|
||||||
|
"0,Off,0," +
|
||||||
|
"0,0,0,0,Off," +
|
||||||
|
"Off,Off,Off,1,Off"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return rows.join("\r\n");
|
||||||
|
}
|
||||||
113
src/cps-motorola.test.ts
Normal file
113
src/cps-motorola.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_LOCALE } from "./cps-motorola";
|
||||||
|
import type { Repeater, CpsTalkgroup } from "./types";
|
||||||
|
|
||||||
|
const makeRepeater = (overrides: Partial<Repeater> = {}): Repeater => ({
|
||||||
|
id: 1, callsign: "DB0ABC", freq_tx: 439.5, freq_rx: 431.9,
|
||||||
|
freq_offset: "-7.6", band: "70cm", lat: 52.5, lng: 13.4,
|
||||||
|
city: "Berlin", state: "Berlin", country: "Germany", color_code: 1,
|
||||||
|
ts_linked: "1,2", trustee: "DL1ABC", ipsc_network: "BrandMeister",
|
||||||
|
networks: ["BrandMeister"], hotspot: 0, status: "On-air",
|
||||||
|
last_seen: null, bm_status: 1, bm_status_text: "Connected",
|
||||||
|
hardware: "", firmware: "", pep: 0, agl: 0, website: "", description: "",
|
||||||
|
import_freq_inconsistent: false, inactive: false, last_polled: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildChannel", () => {
|
||||||
|
it("generates valid XML for a channel personality", () => {
|
||||||
|
const xml = buildChannel("TG262-TS1", "SLOT1", 1, "439.500000", "431.900000");
|
||||||
|
expect(xml).toContain('alias="TG262-TS1"');
|
||||||
|
expect(xml).toContain('<field name="CP_SLTASSGMNT" Name="1">SLOT1</field>');
|
||||||
|
expect(xml).toContain("<field name=\"CP_COLORCODE\">1</field>");
|
||||||
|
expect(xml).toContain("<field name=\"CP_TXFREQ\">431.900000</field>");
|
||||||
|
expect(xml).toContain("<field name=\"CP_RXFREQ\">439.500000</field>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes XML characters in alias", () => {
|
||||||
|
const xml = buildChannel('TG<"test">', "SLOT2", 3, "145.000000", "145.600000");
|
||||||
|
expect(xml).toContain("TG<"test">");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps SLOT2 to display name 2", () => {
|
||||||
|
const xml = buildChannel("Test", "SLOT2", 1, "439.500000", "431.900000");
|
||||||
|
expect(xml).toContain('Name="2">SLOT2</field>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateCpsXml", () => {
|
||||||
|
it("generates a valid XML document", () => {
|
||||||
|
const repeaters = [makeRepeater()];
|
||||||
|
const tgs: CpsTalkgroup[] = [{ id: 262, name: "TG262 Deutschlan", slot: "1" }];
|
||||||
|
const xml = generateCpsXml(repeaters, tgs);
|
||||||
|
expect(xml).toContain('<?xml version="1.0"');
|
||||||
|
expect(xml).toContain("<config>");
|
||||||
|
expect(xml).toContain("</config>");
|
||||||
|
expect(xml).toContain('name="ZoneItems"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates one channel per repeater × talkgroup", () => {
|
||||||
|
const repeaters = [makeRepeater(), makeRepeater({ callsign: "DB0XYZ" })];
|
||||||
|
const tgs: CpsTalkgroup[] = [
|
||||||
|
{ id: 262, name: "TG262", slot: "1" },
|
||||||
|
{ id: 2628, name: "TG2628", slot: "1" },
|
||||||
|
];
|
||||||
|
const xml = generateCpsXml(repeaters, tgs);
|
||||||
|
const matches = xml.match(/ConventionalPersonality/g);
|
||||||
|
expect(matches).toHaveLength(4); // 2 repeaters × 2 TGs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateContactsCsv", () => {
|
||||||
|
const tgs: CpsTalkgroup[] = [
|
||||||
|
{ id: 262, name: "TG262 Deutschlan", slot: "1" },
|
||||||
|
{ id: 2628, name: "TG2628 Bayern", slot: "1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("generates German locale CSV by default", () => {
|
||||||
|
const csv = generateContactsCsv(tgs, "de");
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines).toHaveLength(4); // header1 + header2 + 2 contacts
|
||||||
|
expect(lines[0]).toBe(CPS_CSV_HEADER);
|
||||||
|
expect(lines[1]).toContain("Kontaktname");
|
||||||
|
expect(lines[2]).toContain("TG262 Deutschlan");
|
||||||
|
expect(lines[2]).toContain("Gruppenruf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates English locale CSV", () => {
|
||||||
|
const csv = generateContactsCsv(tgs, "en");
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines[1]).toContain("Contact Name");
|
||||||
|
expect(lines[2]).toContain("Group Call");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to German for unknown locale", () => {
|
||||||
|
const csv = generateContactsCsv(tgs, "fr");
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
expect(lines[1]).toContain("Kontaktname");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses TG ID as fallback name", () => {
|
||||||
|
const tg: CpsTalkgroup[] = [{ id: 99999, name: "", slot: "1" }];
|
||||||
|
const csv = generateContactsCsv(tg, "de");
|
||||||
|
expect(csv).toContain("TG99999");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CPS_LOCALE", () => {
|
||||||
|
it("has German and English locales", () => {
|
||||||
|
expect(CPS_LOCALE).toHaveProperty("de");
|
||||||
|
expect(CPS_LOCALE).toHaveProperty("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("each locale has all required fields", () => {
|
||||||
|
for (const lang of ["de", "en"]) {
|
||||||
|
const l = CPS_LOCALE[lang];
|
||||||
|
expect(l.header2).toBeTruthy();
|
||||||
|
expect(l.routeType).toBeTruthy();
|
||||||
|
expect(l.ringType).toBeTruthy();
|
||||||
|
expect(l.txtMsgAlertType).toBeTruthy();
|
||||||
|
expect(l.callType).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/cps-motorola.ts
Normal file
101
src/cps-motorola.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Repeater, CpsTalkgroup } from "./types";
|
||||||
|
import { escapeXml, formatFreq } from "./format";
|
||||||
|
|
||||||
|
export const CPS_CSV_HEADER = "ContactName,Delete_Contact,Rename_Contact,Comments," +
|
||||||
|
"Delete_FiveToneCalls,FiveToneCalls-S5CLDLL_5TTELEGRAM,FiveToneCalls-S5CLDLL_5TCALLADD," +
|
||||||
|
"Delete_MDCCalls,MDCCalls-AU_CALLLSTID,MDCCalls-AU_MDCSYS,MDCCalls-AU_RVRTPERS_Zone,MDCCalls-AU_RVRTPERS,MDCCalls-AU_SPTPLDPL,MDCCalls-AU_CALLTYPE," +
|
||||||
|
"Delete_QuikCallIICalls,QuikCallIICalls-QU_QCIISYS,QuikCallIICalls-QU_RVRTPERS_Zone,QuikCallIICalls-QU_RVRTPERS,QuikCallIICalls-QU_CALLFORMAT,QuikCallIICalls-QU_TONEATXFRE,QuikCallIICalls-QU_CODEA,QuikCallIICalls-QU_TONEBTXFRE,QuikCallIICalls-QU_CODEB,QuikCallIICalls-QU_STRIPPLDPL," +
|
||||||
|
"Delete_DigitalCalls,DigitalCalls-DU_CALLLSTID,DigitalCalls-DU_ROUTETYPE,DigitalCalls-DU_CALLPRCDTNEN,DigitalCalls-DU_RINGTYPE,DigitalCalls-DU_TXTMSGALTTNTP,DigitalCalls-DU_CALLTYPE,DigitalCalls-DU_OVCMCALL," +
|
||||||
|
"Delete_CapacityPlusCalls,CapacityPlusCalls-CAPPLUSUCL_CALLLSTID,CapacityPlusCalls-CAPPLUSUCL_ROUTETYPE,CapacityPlusCalls-CAPPLUSUCL_CALLPRCDTNEN,CapacityPlusCalls-CAPPLUSUCL_RINGTYPE,CapacityPlusCalls-CAPPLUSUCL_TXTMSGALTTNTP,CapacityPlusCalls-CAPPLUSUCL_CALLTYPE," +
|
||||||
|
"Delete_PhoneCalls,PhoneCalls-PHNUCLELL_CALLID,PhoneCalls-PHNUCLELL_RINGTYPE";
|
||||||
|
|
||||||
|
export interface CpsLocaleStrings {
|
||||||
|
header2: string;
|
||||||
|
routeType: string;
|
||||||
|
ringType: string;
|
||||||
|
txtMsgAlertType: string;
|
||||||
|
callType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CPS_LOCALE: Record<string, CpsLocaleStrings> = {
|
||||||
|
de: {
|
||||||
|
header2: "Kontaktname,Delete_Contact,Rename_Contact,Kommentare," +
|
||||||
|
"Delete_FiveToneCalls,F\u00fcnf-Ton-Rufe - Telegramm,F\u00fcnf-Ton-Rufe - Adresse," +
|
||||||
|
"Delete_MDCCalls,MDC-Rufe - Ruf-ID (Hex),MDC-Rufe - MDC-System,MDC-Rufe - Revert-Kanalzone,MDC-Rufe - Quittungskanal,MDC-Rufe - TPL/DPL ausschlie\u00dfen,MDC-Rufe - Rufart," +
|
||||||
|
"Delete_QuikCallIICalls,Quik Call II-Rufe - Quik-Call II-System,Quik Call II-Rufe - Revert-Kanalzone,Quik Call II-Rufe - Quittungskanal,Quik Call II-Rufe - Rufformat,Quik Call II-Rufe - Freq. Ton A (Hz),Quik Call II-Rufe - Code Ton A,Quik Call II-Rufe - Freq. Ton B (Hz),Quik Call II-Rufe - Code Ton B,Quik Call II-Rufe - TPL/DPL ausschlie\u00dfen," +
|
||||||
|
"Delete_DigitalCalls,Digitale Rufe - Ruf-ID,Digitale Rufe - Routentyp,Digitale Rufe - Rufempfangston,Digitale Rufe - Ruftonart,Digitale Rufe - Hinweiston Textnachricht,Digitale Rufe - Rufart,Digitale Rufe - DU_OVCMCALL," +
|
||||||
|
"Delete_CapacityPlusCalls,Capacity Plus-Rufe - Ruf-ID,Capacity Plus-Rufe - Routentyp,Capacity Plus-Rufe - Rufempfangston,Capacity Plus-Rufe - Ruftonart,Capacity Plus-Rufe - Hinweiston Textnachricht,Capacity Plus-Rufe - Rufart," +
|
||||||
|
"Delete_PhoneCalls,Telefonanrufe - Nummer,Telefonanrufe - Klingelton",
|
||||||
|
routeType: "Regul\u00e4r",
|
||||||
|
ringType: "Keine Art",
|
||||||
|
txtMsgAlertType: "Wiederholt",
|
||||||
|
callType: "Gruppenruf"
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
header2: "Contact Name,Delete_Contact,Rename_Contact,Comments," +
|
||||||
|
"Delete_FiveToneCalls,Five Tone Calls - Telegram,Five Tone Calls - Address," +
|
||||||
|
"Delete_MDCCalls,MDC Calls - Call ID (Hex),MDC Calls - MDC System,MDC Calls - Revert Channel Zone,MDC Calls - Revert Channel,MDC Calls - Strip TPL/DPL,MDC Calls - Call Type," +
|
||||||
|
"Delete_QuikCallIICalls,Quik Call II Calls - Quik Call II System,Quik Call II Calls - Revert Channel Zone,Quik Call II Calls - Revert Channel,Quik Call II Calls - Call Format,Quik Call II Calls - Tone A TX Freq (Hz),Quik Call II Calls - Code A,Quik Call II Calls - Tone B TX Freq (Hz),Quik Call II Calls - Code B,Quik Call II Calls - Strip TPL/DPL," +
|
||||||
|
"Delete_DigitalCalls,Digital Calls - Call ID,Digital Calls - Route Type,Digital Calls - Call Receive Tone,Digital Calls - Ring Style,Digital Calls - Text Message Alert Tone,Digital Calls - Call Type,Digital Calls - DU_OVCMCALL," +
|
||||||
|
"Delete_CapacityPlusCalls,Capacity Plus Calls - Call ID,Capacity Plus Calls - Route Type,Capacity Plus Calls - Call Receive Tone,Capacity Plus Calls - Ring Style,Capacity Plus Calls - Text Message Alert Tone,Capacity Plus Calls - Call Type," +
|
||||||
|
"Delete_PhoneCalls,Phone Calls - Number,Phone Calls - Ring Tone",
|
||||||
|
routeType: "Regular",
|
||||||
|
ringType: "No Style",
|
||||||
|
txtMsgAlertType: "Repetitive",
|
||||||
|
callType: "Group Call"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build a single Motorola CPS XML channel personality. */
|
||||||
|
export function buildChannel(alias: string, slot: string, colorCode: number, txFreq: string, rxFreq: string): string {
|
||||||
|
const 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">' + rxFreq + '</field>\n' +
|
||||||
|
' <field name="CP_RXFREQ">' + txFreq + '</field>\n' +
|
||||||
|
' <field name="CP_TXINHXPLEN" Name="Color Code Free">MTCHCLRCD</field>\n' +
|
||||||
|
' <field name="CP_TOT">180</field>\n' +
|
||||||
|
' </set>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a complete Motorola CPS zone XML document. */
|
||||||
|
export function generateCpsXml(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
|
||||||
|
let channels = "";
|
||||||
|
repeaters.forEach(function (r) {
|
||||||
|
const txFreq = formatFreq(r.freq_tx);
|
||||||
|
const rxFreq = formatFreq(r.freq_rx);
|
||||||
|
const cc = r.color_code;
|
||||||
|
talkgroups.forEach(function (tg) {
|
||||||
|
const slot = tg.slot === "1" ? "SLOT1" : "SLOT2";
|
||||||
|
const alias = tg.name.substring(0, 16);
|
||||||
|
channels += buildChannel(alias, slot, cc, txFreq, rxFreq);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return '<?xml version="1.0" encoding="utf-8" standalone="yes"?>\n' +
|
||||||
|
"<config>\n" +
|
||||||
|
' <collection name="ZoneItems">\n' +
|
||||||
|
channels +
|
||||||
|
" </collection>\n" +
|
||||||
|
"</config>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a Motorola CPS contacts CSV with locale-aware headers. */
|
||||||
|
export function generateContactsCsv(talkgroups: CpsTalkgroup[], lang: string): string {
|
||||||
|
const locale = CPS_LOCALE[lang] || CPS_LOCALE["de"];
|
||||||
|
const rows = [CPS_CSV_HEADER, locale.header2];
|
||||||
|
talkgroups.forEach(function (tg) {
|
||||||
|
const name = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
||||||
|
const row = name + ",False,,," +
|
||||||
|
"False,,," +
|
||||||
|
"False,,,,,,," +
|
||||||
|
"False,,,,,,,,,," +
|
||||||
|
"False," + tg.id + "," + locale.routeType + ",False," + locale.ringType + "," + locale.txtMsgAlertType + "," + locale.callType + ",False," +
|
||||||
|
"False,,,,,,," +
|
||||||
|
"False,,";
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
return rows.join("\r\n");
|
||||||
|
}
|
||||||
42
src/filters.test.ts
Normal file
42
src/filters.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getSelectedBand, getSelectedNetworks } from "./filters";
|
||||||
|
|
||||||
|
describe("getSelectedBand", () => {
|
||||||
|
it("returns 'all' when both bands are selected", () => {
|
||||||
|
expect(getSelectedBand(true, true)).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '2m' when only 2m is selected", () => {
|
||||||
|
expect(getSelectedBand(true, false)).toBe("2m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '70cm' when only 70cm is selected", () => {
|
||||||
|
expect(getSelectedBand(false, true)).toBe("70cm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'all' when neither band is selected", () => {
|
||||||
|
expect(getSelectedBand(false, false)).toBe("all");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSelectedNetworks", () => {
|
||||||
|
it("returns 'all' when all networks are selected", () => {
|
||||||
|
expect(getSelectedNetworks(true, true, true, true)).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'none' when no networks are selected", () => {
|
||||||
|
expect(getSelectedNetworks(false, false, false, false)).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns single network when only one is selected", () => {
|
||||||
|
expect(getSelectedNetworks(true, false, false, false)).toBe("BM");
|
||||||
|
expect(getSelectedNetworks(false, true, false, false)).toBe("DMR+");
|
||||||
|
expect(getSelectedNetworks(false, false, true, false)).toBe("TGIF");
|
||||||
|
expect(getSelectedNetworks(false, false, false, true)).toBe("Other");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns comma-separated networks when multiple selected", () => {
|
||||||
|
expect(getSelectedNetworks(true, true, false, false)).toBe("BM,DMR+");
|
||||||
|
expect(getSelectedNetworks(true, false, true, false)).toBe("BM,TGIF");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/filters.ts
Normal file
19
src/filters.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/** Determine selected band filter from checkbox states. */
|
||||||
|
export function getSelectedBand(has2m: boolean, has70cm: boolean): string {
|
||||||
|
if (has2m && has70cm) return "all";
|
||||||
|
if (has2m) return "2m";
|
||||||
|
if (has70cm) return "70cm";
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine selected network filter from checkbox states. */
|
||||||
|
export function getSelectedNetworks(bm: boolean, dmrplus: boolean, tgif: boolean, other: boolean): string {
|
||||||
|
const nets: string[] = [];
|
||||||
|
if (bm) nets.push("BM");
|
||||||
|
if (dmrplus) nets.push("DMR+");
|
||||||
|
if (tgif) nets.push("TGIF");
|
||||||
|
if (other) nets.push("Other");
|
||||||
|
if (nets.length === 4) return "all";
|
||||||
|
if (nets.length === 0) return "none";
|
||||||
|
return nets.join(",");
|
||||||
|
}
|
||||||
69
src/format.test.ts
Normal file
69
src/format.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { escapeHtml, escapeXml, formatFreq, formatTgAlias } from "./format";
|
||||||
|
|
||||||
|
describe("escapeHtml", () => {
|
||||||
|
it("escapes &, <, >, and quotes", () => {
|
||||||
|
expect(escapeHtml('a & b < c > "d"')).toBe('a & b < c > "d"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for falsy input", () => {
|
||||||
|
expect(escapeHtml("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through plain text unchanged", () => {
|
||||||
|
expect(escapeHtml("Hello World")).toBe("Hello World");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("escapeXml", () => {
|
||||||
|
it("escapes &, <, >, quotes, and apostrophes", () => {
|
||||||
|
expect(escapeXml("a & b < c > \"d\" 'e'")).toBe("a & b < c > "d" 'e'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for falsy input", () => {
|
||||||
|
expect(escapeXml("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatFreq", () => {
|
||||||
|
it("formats a number to 6 decimal places", () => {
|
||||||
|
expect(formatFreq(439.5)).toBe("439.500000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats a string number to 6 decimal places", () => {
|
||||||
|
expect(formatFreq("145.625")).toBe("145.625000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles integers", () => {
|
||||||
|
expect(formatFreq(440)).toBe("440.000000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTgAlias", () => {
|
||||||
|
const mockTgName = (id: number) => {
|
||||||
|
const names: Record<number, string> = { 262: "Deutschland", 2628: "Bayern" };
|
||||||
|
return names[id] || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
it("formats as TG-TS when format is 'tg-ts'", () => {
|
||||||
|
expect(formatTgAlias(262, "1", "tg-ts", mockTgName)).toBe("TG262-TS1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats as TG + name when format is 'tg-name'", () => {
|
||||||
|
expect(formatTgAlias(262, "1", "tg-name", mockTgName)).toBe("TG262 Deutschlan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes full name when it fits in 16 chars", () => {
|
||||||
|
expect(formatTgAlias(2628, "1", "tg-name", mockTgName)).toBe("TG2628 Bayern");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to TG ID only when name is unknown", () => {
|
||||||
|
expect(formatTgAlias(99999, "1", "tg-name", mockTgName)).toBe("TG99999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates to 16 characters", () => {
|
||||||
|
const longName = (_id: number) => "Very Long Talkgroup Name";
|
||||||
|
expect(formatTgAlias(12345, "1", "tg-name", longName)).toBe("TG12345 Very Lon");
|
||||||
|
expect(formatTgAlias(12345, "1", "tg-name", longName)).toHaveLength(16);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/format.ts
Normal file
44
src/format.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/** Escape a string for safe HTML insertion. */
|
||||||
|
export function escapeHtml(str: string): string {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a string for safe XML attribute/content insertion. */
|
||||||
|
export function escapeXml(str: string): string {
|
||||||
|
if (!str) return "";
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a frequency in MHz to 6 decimal places. */
|
||||||
|
export function formatFreq(mhz: number | string): string {
|
||||||
|
return parseFloat(String(mhz)).toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a talkgroup alias for CPS channel naming.
|
||||||
|
* @param tgId - Talkgroup ID
|
||||||
|
* @param slot - Timeslot ("1" or "2")
|
||||||
|
* @param format - Alias format: "tg-ts" or "tg-name"
|
||||||
|
* @param tgNameFn - Function that resolves a TG ID to its name
|
||||||
|
*/
|
||||||
|
export function formatTgAlias(
|
||||||
|
tgId: number,
|
||||||
|
slot: string,
|
||||||
|
format: string,
|
||||||
|
tgNameFn: (id: number) => string,
|
||||||
|
): string {
|
||||||
|
if (format === "tg-ts") {
|
||||||
|
return ("TG" + tgId + "-TS" + slot).substring(0, 16);
|
||||||
|
}
|
||||||
|
return ("TG" + tgId + " " + (tgNameFn(tgId) || "")).trim().substring(0, 16);
|
||||||
|
}
|
||||||
126
src/geo.test.ts
Normal file
126
src/geo.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
toMaidenhead,
|
||||||
|
offsetPoint,
|
||||||
|
getBearing,
|
||||||
|
averageBearing,
|
||||||
|
computeCorridorPolygon,
|
||||||
|
heatColor,
|
||||||
|
} from "./geo";
|
||||||
|
import type { LatLngTuple } from "./types";
|
||||||
|
|
||||||
|
describe("toMaidenhead", () => {
|
||||||
|
it("returns JN88ee for Vienna (48.2, 16.37)", () => {
|
||||||
|
expect(toMaidenhead(48.2, 16.37)).toBe("JN88ee");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns FN31pr for New York (40.75, -73.99)", () => {
|
||||||
|
expect(toMaidenhead(40.75, -73.99)).toBe("FN30as");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns JO62qm for Berlin (52.52, 13.4)", () => {
|
||||||
|
expect(toMaidenhead(52.52, 13.4)).toBe("JO62qm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 0,0 (Atlantic Ocean)", () => {
|
||||||
|
expect(toMaidenhead(0, 0)).toBe("JJ00aa");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBearing", () => {
|
||||||
|
it("returns ~0° for due north", () => {
|
||||||
|
const b = getBearing([0, 0], [1, 0]);
|
||||||
|
expect(b).toBeCloseTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~90° for due east", () => {
|
||||||
|
const b = getBearing([0, 0], [0, 1]);
|
||||||
|
expect(b).toBeCloseTo(90, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~180° for due south", () => {
|
||||||
|
const b = getBearing([1, 0], [0, 0]);
|
||||||
|
expect(b).toBeCloseTo(180, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ~270° for due west", () => {
|
||||||
|
const b = getBearing([0, 0], [0, -1]);
|
||||||
|
expect(b).toBeCloseTo(270, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("averageBearing", () => {
|
||||||
|
it("averages 0° and 90° to ~45°", () => {
|
||||||
|
expect(averageBearing(0, 90)).toBeCloseTo(45, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("averages 350° and 10° across north to ~0°", () => {
|
||||||
|
expect(averageBearing(350, 10)).toBeCloseTo(0, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("averages two identical bearings to the same bearing", () => {
|
||||||
|
expect(averageBearing(120, 120)).toBeCloseTo(120, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offsetPoint", () => {
|
||||||
|
it("moves north by ~111 km (1 degree of latitude)", () => {
|
||||||
|
const [lat, lng] = offsetPoint(0, 0, 0, 111.195);
|
||||||
|
expect(lat).toBeCloseTo(1, 0);
|
||||||
|
expect(lng).toBeCloseTo(0, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves east by ~111 km at the equator (1 degree of longitude)", () => {
|
||||||
|
const [lat, lng] = offsetPoint(0, 0, 90, 111.195);
|
||||||
|
expect(lat).toBeCloseTo(0, 1);
|
||||||
|
expect(lng).toBeCloseTo(1, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeCorridorPolygon", () => {
|
||||||
|
it("returns empty array for fewer than 2 points", () => {
|
||||||
|
expect(computeCorridorPolygon([], 10)).toEqual([]);
|
||||||
|
expect(computeCorridorPolygon([[0, 0]], 10)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a closed polygon for a 2-point line", () => {
|
||||||
|
const points: LatLngTuple[] = [[0, 0], [1, 0]];
|
||||||
|
const polygon = computeCorridorPolygon(points, 10);
|
||||||
|
// 2 points → 2 left + 2 right = 4 points
|
||||||
|
expect(polygon).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct number of points for a 3-point line", () => {
|
||||||
|
const points: LatLngTuple[] = [[0, 0], [1, 0], [2, 0]];
|
||||||
|
const polygon = computeCorridorPolygon(points, 10);
|
||||||
|
// 3 points → 3 left + 3 right = 6 points
|
||||||
|
expect(polygon).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("polygon points are offset from the original line", () => {
|
||||||
|
const points: LatLngTuple[] = [[0, 0], [1, 0]];
|
||||||
|
const polygon = computeCorridorPolygon(points, 50);
|
||||||
|
// All polygon points should have non-zero longitude (offset east/west)
|
||||||
|
for (const pt of polygon) {
|
||||||
|
expect(Math.abs(pt[1])).toBeGreaterThan(0.01);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("heatColor", () => {
|
||||||
|
it("returns green for intensity 0", () => {
|
||||||
|
expect(heatColor(0)).toBe("rgb(0,255,0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns yellow for intensity 0.5", () => {
|
||||||
|
expect(heatColor(0.5)).toBe("rgb(255,255,0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns red for intensity 1", () => {
|
||||||
|
expect(heatColor(1)).toBe("rgb(255,0,0)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns intermediate value for intensity 0.25", () => {
|
||||||
|
expect(heatColor(0.25)).toBe("rgb(128,255,0)");
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/geo.ts
Normal file
89
src/geo.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { LatLngTuple } from "./types";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_KM = 6371;
|
||||||
|
const DEG_TO_RAD = Math.PI / 180;
|
||||||
|
const RAD_TO_DEG = 180 / Math.PI;
|
||||||
|
|
||||||
|
/** Convert lat/lng to 6-character Maidenhead grid locator. */
|
||||||
|
export function toMaidenhead(lat: number, lng: number): string {
|
||||||
|
lng = lng + 180;
|
||||||
|
lat = lat + 90;
|
||||||
|
let loc = "";
|
||||||
|
loc += String.fromCharCode(65 + Math.floor(lng / 20));
|
||||||
|
loc += String.fromCharCode(65 + Math.floor(lat / 10));
|
||||||
|
lng = lng % 20;
|
||||||
|
lat = lat % 10;
|
||||||
|
loc += Math.floor(lng / 2);
|
||||||
|
loc += Math.floor(lat);
|
||||||
|
lng = (lng % 2) * 60;
|
||||||
|
lat = (lat % 1) * 60;
|
||||||
|
loc += String.fromCharCode(97 + Math.floor(lng / 5));
|
||||||
|
loc += String.fromCharCode(97 + Math.floor(lat / 2.5));
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a point along a bearing by a distance (great-circle). */
|
||||||
|
export function offsetPoint(lat: number, lng: number, bearing: number, distKm: number): LatLngTuple {
|
||||||
|
const lat1 = lat * DEG_TO_RAD;
|
||||||
|
const lng1 = lng * DEG_TO_RAD;
|
||||||
|
const brng = bearing * DEG_TO_RAD;
|
||||||
|
const d = distKm / EARTH_RADIUS_KM;
|
||||||
|
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng));
|
||||||
|
const lng2 = lng1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
|
||||||
|
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2));
|
||||||
|
return [lat2 * RAD_TO_DEG, lng2 * RAD_TO_DEG];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial bearing (forward azimuth) from p1 to p2 in degrees [0, 360). */
|
||||||
|
export function getBearing(p1: LatLngTuple, p2: LatLngTuple): number {
|
||||||
|
const lat1 = p1[0] * DEG_TO_RAD;
|
||||||
|
const lat2 = p2[0] * DEG_TO_RAD;
|
||||||
|
const dLng = (p2[1] - p1[1]) * DEG_TO_RAD;
|
||||||
|
const y = Math.sin(dLng) * Math.cos(lat2);
|
||||||
|
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
||||||
|
return (Math.atan2(y, x) * RAD_TO_DEG + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Average of two bearings via unit-vector mean. */
|
||||||
|
export function averageBearing(b1: number, b2: number): number {
|
||||||
|
const r1 = b1 * DEG_TO_RAD;
|
||||||
|
const r2 = b2 * DEG_TO_RAD;
|
||||||
|
const x = Math.cos(r1) + Math.cos(r2);
|
||||||
|
const y = Math.sin(r1) + Math.sin(r2);
|
||||||
|
return (Math.atan2(y, x) * RAD_TO_DEG + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a closed polygon outlining the corridor around a polyline. */
|
||||||
|
export function computeCorridorPolygon(points: LatLngTuple[], distKm: number): LatLngTuple[] {
|
||||||
|
if (points.length < 2) return [];
|
||||||
|
const left: LatLngTuple[] = [];
|
||||||
|
const right: LatLngTuple[] = [];
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
let bearing: number;
|
||||||
|
if (i === 0) {
|
||||||
|
bearing = getBearing(points[0], points[1]);
|
||||||
|
} else if (i === points.length - 1) {
|
||||||
|
bearing = getBearing(points[i - 1], points[i]);
|
||||||
|
} else {
|
||||||
|
const b1 = getBearing(points[i - 1], points[i]);
|
||||||
|
const b2 = getBearing(points[i], points[i + 1]);
|
||||||
|
bearing = averageBearing(b1, b2);
|
||||||
|
}
|
||||||
|
left.push(offsetPoint(points[i][0], points[i][1], bearing - 90, distKm));
|
||||||
|
right.push(offsetPoint(points[i][0], points[i][1], bearing + 90, distKm));
|
||||||
|
}
|
||||||
|
return left.concat(right.reverse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a 0–1 intensity value to an RGB color string (green→yellow→red). */
|
||||||
|
export function heatColor(intensity: number): string {
|
||||||
|
let r: number, g: number;
|
||||||
|
if (intensity < 0.5) {
|
||||||
|
r = Math.round(255 * (intensity * 2));
|
||||||
|
g = 255;
|
||||||
|
} else {
|
||||||
|
r = 255;
|
||||||
|
g = Math.round(255 * (1 - (intensity - 0.5) * 2));
|
||||||
|
}
|
||||||
|
return "rgb(" + r + "," + g + ",0)";
|
||||||
|
}
|
||||||
18
src/globals.d.ts
vendored
Normal file
18
src/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// i18next globals (from i18n.js loaded before app.js)
|
||||||
|
declare function t(key: string, opts?: Record<string, unknown>): string;
|
||||||
|
declare function getLocale(): string;
|
||||||
|
declare function translateDOM(): void;
|
||||||
|
declare const i18next: {
|
||||||
|
isInitialized: boolean;
|
||||||
|
t: typeof t;
|
||||||
|
resolvedLanguage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// socket.io global
|
||||||
|
declare function io(url: string, opts?: {
|
||||||
|
path?: string;
|
||||||
|
transports?: string[];
|
||||||
|
}): {
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
disconnect(): void;
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { toMaidenhead, offsetPoint, getBearing, averageBearing, computeCorridorPolygon, heatColor } from "./geo";
|
||||||
|
import { escapeHtml, escapeXml, formatFreq, formatTgAlias } from "./format";
|
||||||
|
import { BM262_TALKGROUPS, tgName as _tgName, defaultSlot, searchTalkgroups as _searchTalkgroups } from "./talkgroups";
|
||||||
|
import { buildChannel, generateCpsXml, generateContactsCsv, CPS_CSV_HEADER, CPS_LOCALE } from "./cps-motorola";
|
||||||
|
import { generateAnytoneContactsCsv, generateAnytoneChannelsCsv, ANYTONE_CH_HEADER } from "./cps-anytone";
|
||||||
|
import { getSelectedBand as _getSelectedBand, getSelectedNetworks as _getSelectedNetworks } from "./filters";
|
||||||
|
import { buildPopup as _buildPopup } from "./popup";
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
|
@ -199,23 +207,6 @@
|
||||||
// === Coordinates display ===
|
// === Coordinates display ===
|
||||||
var coordsEl = document.getElementById("coords");
|
var coordsEl = document.getElementById("coords");
|
||||||
|
|
||||||
function toMaidenhead(lat, lng) {
|
|
||||||
lng = lng + 180;
|
|
||||||
lat = lat + 90;
|
|
||||||
var loc = "";
|
|
||||||
loc += String.fromCharCode(65 + Math.floor(lng / 20));
|
|
||||||
loc += String.fromCharCode(65 + Math.floor(lat / 10));
|
|
||||||
lng = (lng % 20);
|
|
||||||
lat = (lat % 10);
|
|
||||||
loc += Math.floor(lng / 2);
|
|
||||||
loc += Math.floor(lat);
|
|
||||||
lng = (lng % 2) * 60;
|
|
||||||
lat = (lat % 1) * 60;
|
|
||||||
loc += String.fromCharCode(97 + Math.floor(lng / 5));
|
|
||||||
loc += String.fromCharCode(97 + Math.floor(lat / 2.5));
|
|
||||||
return loc;
|
|
||||||
}
|
|
||||||
|
|
||||||
map.on("mousemove", function (e) {
|
map.on("mousemove", function (e) {
|
||||||
var lat = e.latlng.lat;
|
var lat = e.latlng.lat;
|
||||||
var lng = e.latlng.lng;
|
var lng = e.latlng.lng;
|
||||||
|
|
@ -230,125 +221,12 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Utilities ===
|
// === Utilities ===
|
||||||
function getSelectedBand() {
|
function getSelectedBand() { return _getSelectedBand(band2m.checked, band70cm.checked); }
|
||||||
var has2m = band2m.checked;
|
function getSelectedNetworks() { return _getSelectedNetworks(netBm.checked, netDmrplus.checked, netTgif.checked, netOther.checked); }
|
||||||
var has70cm = band70cm.checked;
|
|
||||||
if (has2m && has70cm) return "all";
|
|
||||||
if (has2m) return "2m";
|
|
||||||
if (has70cm) return "70cm";
|
|
||||||
return "all";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectedNetworks() {
|
function tgName(id) { return _tgName(id, tgRegistry); }
|
||||||
var nets = [];
|
function searchTalkgroups(query) { return _searchTalkgroups(query, tgRegistry); }
|
||||||
if (netBm.checked) nets.push("BM");
|
|
||||||
if (netDmrplus.checked) nets.push("DMR+");
|
|
||||||
if (netTgif.checked) nets.push("TGIF");
|
|
||||||
if (netOther.checked) nets.push("Other");
|
|
||||||
if (nets.length === 4) return "all";
|
|
||||||
if (nets.length === 0) return "none";
|
|
||||||
return nets.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return "";
|
|
||||||
return str
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.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 formatFreq(mhz) {
|
|
||||||
return parseFloat(mhz).toFixed(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChannel(alias, slot, colorCode, txFreq, rxFreq) {
|
|
||||||
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">' + rxFreq + '</field>\n' +
|
|
||||||
' <field name="CP_RXFREQ">' + txFreq + '</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 txFreq = formatFreq(r.freq_tx);
|
|
||||||
var rxFreq = formatFreq(r.freq_rx);
|
|
||||||
var cc = r.color_code;
|
|
||||||
talkgroups.forEach(function (tg) {
|
|
||||||
var slot = tg.slot === "1" ? "SLOT1" : "SLOT2";
|
|
||||||
var alias = tg.name.substring(0, 16);
|
|
||||||
channels += buildChannel(alias, slot, cc, txFreq, rxFreq);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return '<?xml version="1.0" encoding="utf-8" standalone="yes"?>\n' +
|
|
||||||
"<config>\n" +
|
|
||||||
' <collection name="ZoneItems">\n' +
|
|
||||||
channels +
|
|
||||||
" </collection>\n" +
|
|
||||||
"</config>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// === BrandMeister API ===
|
// === BrandMeister API ===
|
||||||
function fetchTgRegistry() {
|
function fetchTgRegistry() {
|
||||||
|
|
@ -434,7 +312,7 @@
|
||||||
function setSlot(idx, slot) {
|
function setSlot(idx, slot) {
|
||||||
cpsTalkgroups[idx].slot = slot;
|
cpsTalkgroups[idx].slot = slot;
|
||||||
if (cpsAliasFormat.value === "tg-ts") {
|
if (cpsAliasFormat.value === "tg-ts") {
|
||||||
cpsTalkgroups[idx].name = formatTgAlias(cpsTalkgroups[idx].id, slot);
|
cpsTalkgroups[idx].name = formatTgAlias(cpsTalkgroups[idx].id, slot, cpsAliasFormat.value, tgName);
|
||||||
}
|
}
|
||||||
renderTgTable();
|
renderTgTable();
|
||||||
updateChannelCount();
|
updateChannelCount();
|
||||||
|
|
@ -514,94 +392,26 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTgAlias(tgId, slot) {
|
|
||||||
var fmt = cpsAliasFormat.value;
|
|
||||||
if (fmt === "tg-ts") {
|
|
||||||
return ("TG" + tgId + "-TS" + slot).substring(0, 16);
|
|
||||||
}
|
|
||||||
return ("TG" + tgId + " " + (tgName(tgId) || "")).trim().substring(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reformatAllAliases() {
|
function reformatAllAliases() {
|
||||||
cpsTalkgroups.forEach(function (tg) {
|
cpsTalkgroups.forEach(function (tg) {
|
||||||
tg.name = formatTgAlias(tg.id, tg.slot);
|
tg.name = formatTgAlias(tg.id, tg.slot, cpsAliasFormat.value, tgName);
|
||||||
});
|
});
|
||||||
renderTgTable();
|
renderTgTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultSlot(tgId) {
|
|
||||||
var first = String(tgId).charAt(0);
|
|
||||||
return (first === "8" || first === "9") ? "2" : "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function addTalkgroupById(tgId) {
|
function addTalkgroupById(tgId) {
|
||||||
if (cpsTalkgroups.some(function (tg) { return tg.id === tgId; })) return;
|
if (cpsTalkgroups.some(function (tg) { return tg.id === tgId; })) return;
|
||||||
var slot = defaultSlot(tgId);
|
var slot = defaultSlot(tgId);
|
||||||
cpsTalkgroups.push({ id: tgId, name: formatTgAlias(tgId, slot), slot: slot });
|
cpsTalkgroups.push({ id: tgId, name: formatTgAlias(tgId, slot, cpsAliasFormat.value, tgName), slot: slot });
|
||||||
cpsTalkgroups.sort(function (a, b) { return a.id - b.id; });
|
cpsTalkgroups.sort(function (a, b) { return a.id - b.id; });
|
||||||
renderTgTable();
|
renderTgTable();
|
||||||
updateChannelCount();
|
updateChannelCount();
|
||||||
updateCpsButtons();
|
updateCpsButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
var CPS_CSV_HEADER = "ContactName,Delete_Contact,Rename_Contact,Comments," +
|
|
||||||
"Delete_FiveToneCalls,FiveToneCalls-S5CLDLL_5TTELEGRAM,FiveToneCalls-S5CLDLL_5TCALLADD," +
|
|
||||||
"Delete_MDCCalls,MDCCalls-AU_CALLLSTID,MDCCalls-AU_MDCSYS,MDCCalls-AU_RVRTPERS_Zone,MDCCalls-AU_RVRTPERS,MDCCalls-AU_SPTPLDPL,MDCCalls-AU_CALLTYPE," +
|
|
||||||
"Delete_QuikCallIICalls,QuikCallIICalls-QU_QCIISYS,QuikCallIICalls-QU_RVRTPERS_Zone,QuikCallIICalls-QU_RVRTPERS,QuikCallIICalls-QU_CALLFORMAT,QuikCallIICalls-QU_TONEATXFRE,QuikCallIICalls-QU_CODEA,QuikCallIICalls-QU_TONEBTXFRE,QuikCallIICalls-QU_CODEB,QuikCallIICalls-QU_STRIPPLDPL," +
|
|
||||||
"Delete_DigitalCalls,DigitalCalls-DU_CALLLSTID,DigitalCalls-DU_ROUTETYPE,DigitalCalls-DU_CALLPRCDTNEN,DigitalCalls-DU_RINGTYPE,DigitalCalls-DU_TXTMSGALTTNTP,DigitalCalls-DU_CALLTYPE,DigitalCalls-DU_OVCMCALL," +
|
|
||||||
"Delete_CapacityPlusCalls,CapacityPlusCalls-CAPPLUSUCL_CALLLSTID,CapacityPlusCalls-CAPPLUSUCL_ROUTETYPE,CapacityPlusCalls-CAPPLUSUCL_CALLPRCDTNEN,CapacityPlusCalls-CAPPLUSUCL_RINGTYPE,CapacityPlusCalls-CAPPLUSUCL_TXTMSGALTTNTP,CapacityPlusCalls-CAPPLUSUCL_CALLTYPE," +
|
|
||||||
"Delete_PhoneCalls,PhoneCalls-PHNUCLELL_CALLID,PhoneCalls-PHNUCLELL_RINGTYPE";
|
|
||||||
|
|
||||||
var CPS_LOCALE = {
|
|
||||||
de: {
|
|
||||||
header2: "Kontaktname,Delete_Contact,Rename_Contact,Kommentare," +
|
|
||||||
"Delete_FiveToneCalls,F\u00fcnf-Ton-Rufe - Telegramm,F\u00fcnf-Ton-Rufe - Adresse," +
|
|
||||||
"Delete_MDCCalls,MDC-Rufe - Ruf-ID (Hex),MDC-Rufe - MDC-System,MDC-Rufe - Revert-Kanalzone,MDC-Rufe - Quittungskanal,MDC-Rufe - TPL/DPL ausschlie\u00dfen,MDC-Rufe - Rufart," +
|
|
||||||
"Delete_QuikCallIICalls,Quik Call II-Rufe - Quik-Call II-System,Quik Call II-Rufe - Revert-Kanalzone,Quik Call II-Rufe - Quittungskanal,Quik Call II-Rufe - Rufformat,Quik Call II-Rufe - Freq. Ton A (Hz),Quik Call II-Rufe - Code Ton A,Quik Call II-Rufe - Freq. Ton B (Hz),Quik Call II-Rufe - Code Ton B,Quik Call II-Rufe - TPL/DPL ausschlie\u00dfen," +
|
|
||||||
"Delete_DigitalCalls,Digitale Rufe - Ruf-ID,Digitale Rufe - Routentyp,Digitale Rufe - Rufempfangston,Digitale Rufe - Ruftonart,Digitale Rufe - Hinweiston Textnachricht,Digitale Rufe - Rufart,Digitale Rufe - DU_OVCMCALL," +
|
|
||||||
"Delete_CapacityPlusCalls,Capacity Plus-Rufe - Ruf-ID,Capacity Plus-Rufe - Routentyp,Capacity Plus-Rufe - Rufempfangston,Capacity Plus-Rufe - Ruftonart,Capacity Plus-Rufe - Hinweiston Textnachricht,Capacity Plus-Rufe - Rufart," +
|
|
||||||
"Delete_PhoneCalls,Telefonanrufe - Nummer,Telefonanrufe - Klingelton",
|
|
||||||
routeType: "Regul\u00e4r",
|
|
||||||
ringType: "Keine Art",
|
|
||||||
txtMsgAlertType: "Wiederholt",
|
|
||||||
callType: "Gruppenruf"
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
header2: "Contact Name,Delete_Contact,Rename_Contact,Comments," +
|
|
||||||
"Delete_FiveToneCalls,Five Tone Calls - Telegram,Five Tone Calls - Address," +
|
|
||||||
"Delete_MDCCalls,MDC Calls - Call ID (Hex),MDC Calls - MDC System,MDC Calls - Revert Channel Zone,MDC Calls - Revert Channel,MDC Calls - Strip TPL/DPL,MDC Calls - Call Type," +
|
|
||||||
"Delete_QuikCallIICalls,Quik Call II Calls - Quik Call II System,Quik Call II Calls - Revert Channel Zone,Quik Call II Calls - Revert Channel,Quik Call II Calls - Call Format,Quik Call II Calls - Tone A TX Freq (Hz),Quik Call II Calls - Code A,Quik Call II Calls - Tone B TX Freq (Hz),Quik Call II Calls - Code B,Quik Call II Calls - Strip TPL/DPL," +
|
|
||||||
"Delete_DigitalCalls,Digital Calls - Call ID,Digital Calls - Route Type,Digital Calls - Call Receive Tone,Digital Calls - Ring Style,Digital Calls - Text Message Alert Tone,Digital Calls - Call Type,Digital Calls - DU_OVCMCALL," +
|
|
||||||
"Delete_CapacityPlusCalls,Capacity Plus Calls - Call ID,Capacity Plus Calls - Route Type,Capacity Plus Calls - Call Receive Tone,Capacity Plus Calls - Ring Style,Capacity Plus Calls - Text Message Alert Tone,Capacity Plus Calls - Call Type," +
|
|
||||||
"Delete_PhoneCalls,Phone Calls - Number,Phone Calls - Ring Tone",
|
|
||||||
routeType: "Regular",
|
|
||||||
ringType: "No Style",
|
|
||||||
txtMsgAlertType: "Repetitive",
|
|
||||||
callType: "Group Call"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function generateContactsCsv(talkgroups) {
|
|
||||||
var lang = cpsLanguage.value || "de";
|
|
||||||
var locale = CPS_LOCALE[lang] || CPS_LOCALE["de"];
|
|
||||||
var rows = [CPS_CSV_HEADER, locale.header2];
|
|
||||||
talkgroups.forEach(function (tg) {
|
|
||||||
var name = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
|
||||||
var row = name + ",False,,," +
|
|
||||||
"False,,," +
|
|
||||||
"False,,,,,,," +
|
|
||||||
"False,,,,,,,,,," +
|
|
||||||
"False," + tg.id + "," + locale.routeType + ",False," + locale.ringType + "," + locale.txtMsgAlertType + "," + locale.callType + ",False," +
|
|
||||||
"False,,,,,,," +
|
|
||||||
"False,,";
|
|
||||||
rows.push(row);
|
|
||||||
});
|
|
||||||
return rows.join("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadContactsCsv() {
|
function downloadContactsCsv() {
|
||||||
if (!cpsTalkgroups.length) return;
|
if (!cpsTalkgroups.length) return;
|
||||||
var csv = generateContactsCsv(cpsTalkgroups);
|
var csv = generateContactsCsv(cpsTalkgroups, cpsLanguage.value || "de");
|
||||||
var blob = new Blob([csv], { type: "text/csv" });
|
var blob = new Blob([csv], { type: "text/csv" });
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
|
|
@ -652,58 +462,6 @@
|
||||||
|
|
||||||
// === Anytone CPS Export ===
|
// === Anytone CPS Export ===
|
||||||
|
|
||||||
function generateAnytoneContactsCsv(talkgroups) {
|
|
||||||
var rows = ["No.,Radio ID,Name,Call Type,Call Alert"];
|
|
||||||
talkgroups.forEach(function (tg, idx) {
|
|
||||||
var name = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
|
||||||
rows.push((idx + 1) + "," + tg.id + "," + name + ",Group Call,None");
|
|
||||||
});
|
|
||||||
return rows.join("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
var ANYTONE_CH_HEADER = "No.,Channel Name,Receive Frequency,Transmit Frequency," +
|
|
||||||
"Channel Type,Transmit Power,Band Width,CTCSS/DCS Decode,CTCSS/DCS Encode," +
|
|
||||||
"Contact,Contact Call Type,Contact TG/DMR ID,Radio ID," +
|
|
||||||
"Busy Lock/TX Permit,Squelch Mode,Optional Signal,DTMF ID,2Tone ID,5Tone ID,PTT ID," +
|
|
||||||
"Color Code,Slot,Scan List,Receive Group List,PTT Prohibit,Reverse," +
|
|
||||||
"Simplex TDMA,Slot Suit,AES Digital Encryption,Digital Encryption," +
|
|
||||||
"Call Confirmation,Talk Around(Simplex),Work Alone,Custom CTCSS," +
|
|
||||||
"2TONE Decode,Ranging,Through Mode,APRS RX,Analog APRS PTT Mode," +
|
|
||||||
"Digital APRS PTT Mode,APRS Report Type,Digital APRS Report Channel," +
|
|
||||||
"Correct Frequency[Hz],SMS Confirmation,Exclude channel from roaming," +
|
|
||||||
"DMR MODE,DataACK Disable,R5toneBot,R5ToneEot,Auto Scan," +
|
|
||||||
"Ana APRS TX Path,APRS TX Tone,APRS Signal Path,Digi APRS TX CH,Alert Tone";
|
|
||||||
|
|
||||||
function generateAnytoneChannelsCsv(repeaters, talkgroups) {
|
|
||||||
var rows = [ANYTONE_CH_HEADER];
|
|
||||||
var num = 0;
|
|
||||||
repeaters.forEach(function (r) {
|
|
||||||
var rxFreq = formatFreq(r.freq_tx);
|
|
||||||
var txFreq = formatFreq(r.freq_rx);
|
|
||||||
var cc = r.color_code || 1;
|
|
||||||
talkgroups.forEach(function (tg) {
|
|
||||||
num++;
|
|
||||||
var chName = (r.callsign + " " + tg.name).substring(0, 16);
|
|
||||||
var contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
|
|
||||||
rows.push(
|
|
||||||
num + "," + chName + "," + rxFreq + "," + txFreq + "," +
|
|
||||||
"D-Digital,High,12.5K,Off,Off," +
|
|
||||||
contactName + ",Group Call," + tg.id + ",1," +
|
|
||||||
"Same Color Code,Carrier,Off,1,1,1,Off," +
|
|
||||||
cc + "," + tg.slot + ",None,None,Off,Off," +
|
|
||||||
"Off,Off,Normal Encryption,Off," +
|
|
||||||
"Off,Off,Off,251.1," +
|
|
||||||
"0,Off,Off,Off,Off," +
|
|
||||||
"Off,Off,1," +
|
|
||||||
"0,Off,0," +
|
|
||||||
"0,0,0,0,Off," +
|
|
||||||
"Off,Off,Off,1,Off,Off"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return rows.join("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadAnytoneContactsCsv() {
|
function downloadAnytoneContactsCsv() {
|
||||||
if (!cpsTalkgroups.length) return;
|
if (!cpsTalkgroups.length) return;
|
||||||
var csv = generateAnytoneContactsCsv(cpsTalkgroups);
|
var csv = generateAnytoneContactsCsv(cpsTalkgroups);
|
||||||
|
|
@ -742,58 +500,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Popup ===
|
// === Popup ===
|
||||||
function buildPopup(r) {
|
function buildPopup(r) { return _buildPopup(r, t); }
|
||||||
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
|
||||||
var html = '<div class="rptr-popup">';
|
|
||||||
html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + '</a> <span class="band-tag ' + bandClass + '">' + escapeHtml(r.band) + "</span></h3>";
|
|
||||||
html += "<table>";
|
|
||||||
html += "<tr><td>" + t("popup_tx") + "</td><td>" + r.freq_tx.toFixed(4) + " MHz</td></tr>";
|
|
||||||
if (r.freq_rx)
|
|
||||||
html += "<tr><td>" + t("popup_rx") + "</td><td>" + r.freq_rx.toFixed(4) + " MHz</td></tr>";
|
|
||||||
if (r.freq_offset)
|
|
||||||
html +=
|
|
||||||
"<tr><td>" + t("popup_offset") + "</td><td>" +
|
|
||||||
escapeHtml(r.freq_offset) +
|
|
||||||
" MHz</td></tr>";
|
|
||||||
html += "<tr><td>" + t("popup_cc") + "</td><td>" + r.color_code + "</td></tr>";
|
|
||||||
var loc = escapeHtml(r.city);
|
|
||||||
if (r.state) loc += ", " + escapeHtml(r.state);
|
|
||||||
if (r.country) loc += ", " + escapeHtml(r.country);
|
|
||||||
html += "<tr><td>" + t("popup_location") + "</td><td>" + loc + "</td></tr>";
|
|
||||||
if (r.networks && r.networks.length)
|
|
||||||
html +=
|
|
||||||
"<tr><td>" + t("popup_network") + "</td><td>" +
|
|
||||||
r.networks.map(escapeHtml).join(", ") +
|
|
||||||
"</td></tr>";
|
|
||||||
if (r.trustee)
|
|
||||||
html +=
|
|
||||||
"<tr><td>" + t("popup_trustee") + "</td><td>" +
|
|
||||||
escapeHtml(r.trustee) +
|
|
||||||
"</td></tr>";
|
|
||||||
if (r.ts_linked)
|
|
||||||
html +=
|
|
||||||
"<tr><td>" + t("popup_timeslots") + "</td><td>" +
|
|
||||||
escapeHtml(r.ts_linked) +
|
|
||||||
"</td></tr>";
|
|
||||||
html +=
|
|
||||||
"<tr><td>" + t("popup_status") + "</td><td>" +
|
|
||||||
escapeHtml(r.status) +
|
|
||||||
"</td></tr>";
|
|
||||||
if (r.bm_status_text)
|
|
||||||
html += "<tr><td>" + t("popup_bm_status") + "</td><td>" + escapeHtml(r.bm_status_text) + "</td></tr>";
|
|
||||||
if (r.last_seen)
|
|
||||||
html += "<tr><td>" + t("popup_last_seen") + "</td><td>" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "</td></tr>";
|
|
||||||
if (r.hardware)
|
|
||||||
html += "<tr><td>" + t("popup_hardware") + "</td><td>" + escapeHtml(r.hardware) + "</td></tr>";
|
|
||||||
if (r.pep)
|
|
||||||
html += "<tr><td>" + t("popup_power") + "</td><td>" + r.pep + " W</td></tr>";
|
|
||||||
if (r.agl)
|
|
||||||
html += "<tr><td>" + t("popup_antenna") + "</td><td>" + r.agl + " m AGL</td></tr>";
|
|
||||||
if (r.inactive)
|
|
||||||
html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>';
|
|
||||||
html += "</table></div>";
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Display markers ===
|
// === Display markers ===
|
||||||
function displayRepeaters(repeaters) {
|
function displayRepeaters(repeaters) {
|
||||||
|
|
@ -945,57 +652,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Corridor visualization ===
|
// === Corridor visualization ===
|
||||||
function offsetPoint(lat, lng, bearing, distKm) {
|
|
||||||
var R = 6371;
|
|
||||||
var lat1 = lat * Math.PI / 180;
|
|
||||||
var lng1 = lng * Math.PI / 180;
|
|
||||||
var brng = bearing * Math.PI / 180;
|
|
||||||
var d = distKm / R;
|
|
||||||
var lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng));
|
|
||||||
var lng2 = lng1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1),
|
|
||||||
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2));
|
|
||||||
return [lat2 * 180 / Math.PI, lng2 * 180 / Math.PI];
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeCorridorPolygon(points, distKm) {
|
|
||||||
if (points.length < 2) return [];
|
|
||||||
var left = [];
|
|
||||||
var right = [];
|
|
||||||
for (var i = 0; i < points.length; i++) {
|
|
||||||
var bearing;
|
|
||||||
if (i === 0) {
|
|
||||||
bearing = getBearing(points[0], points[1]);
|
|
||||||
} else if (i === points.length - 1) {
|
|
||||||
bearing = getBearing(points[i - 1], points[i]);
|
|
||||||
} else {
|
|
||||||
var b1 = getBearing(points[i - 1], points[i]);
|
|
||||||
var b2 = getBearing(points[i], points[i + 1]);
|
|
||||||
bearing = averageBearing(b1, b2);
|
|
||||||
}
|
|
||||||
left.push(offsetPoint(points[i][0], points[i][1], bearing - 90, distKm));
|
|
||||||
right.push(offsetPoint(points[i][0], points[i][1], bearing + 90, distKm));
|
|
||||||
}
|
|
||||||
// Form a closed polygon: left side forward, right side backward
|
|
||||||
return left.concat(right.reverse());
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBearing(p1, p2) {
|
|
||||||
var lat1 = p1[0] * Math.PI / 180;
|
|
||||||
var lat2 = p2[0] * Math.PI / 180;
|
|
||||||
var dLng = (p2[1] - p1[1]) * Math.PI / 180;
|
|
||||||
var y = Math.sin(dLng) * Math.cos(lat2);
|
|
||||||
var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
||||||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageBearing(b1, b2) {
|
|
||||||
var r1 = b1 * Math.PI / 180;
|
|
||||||
var r2 = b2 * Math.PI / 180;
|
|
||||||
var x = Math.cos(r1) + Math.cos(r2);
|
|
||||||
var y = Math.sin(r1) + Math.sin(r2);
|
|
||||||
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCorridor() {
|
function drawCorridor() {
|
||||||
if (corridorLayer) {
|
if (corridorLayer) {
|
||||||
map.removeLayer(corridorLayer);
|
map.removeLayer(corridorLayer);
|
||||||
|
|
@ -1203,18 +859,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Heatmap ===
|
// === Heatmap ===
|
||||||
function heatColor(intensity) {
|
|
||||||
var r, g;
|
|
||||||
if (intensity < 0.5) {
|
|
||||||
r = Math.round(255 * (intensity * 2));
|
|
||||||
g = 255;
|
|
||||||
} else {
|
|
||||||
r = 255;
|
|
||||||
g = Math.round(255 * (1 - (intensity - 0.5) * 2));
|
|
||||||
}
|
|
||||||
return "rgb(" + r + "," + g + ",0)";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyHeatmapGlow(repeaterId, lat, lng) {
|
function applyHeatmapGlow(repeaterId, lat, lng) {
|
||||||
var count = heatmapCounts[repeaterId] || 0;
|
var count = heatmapCounts[repeaterId] || 0;
|
||||||
if (count === 0) return;
|
if (count === 0) return;
|
||||||
|
|
@ -1506,28 +1150,6 @@
|
||||||
var cpsAcIdx = -1;
|
var cpsAcIdx = -1;
|
||||||
var cpsAcTimer = null;
|
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) {
|
function renderCpsAc(results) {
|
||||||
cpsAcList.innerHTML = "";
|
cpsAcList.innerHTML = "";
|
||||||
cpsAcIdx = -1;
|
cpsAcIdx = -1;
|
||||||
98
src/popup.test.ts
Normal file
98
src/popup.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildPopup } from "./popup";
|
||||||
|
import type { Repeater } from "./types";
|
||||||
|
|
||||||
|
const makeRepeater = (overrides: Partial<Repeater> = {}): Repeater => ({
|
||||||
|
id: 1, callsign: "DB0ABC", freq_tx: 439.5, freq_rx: 431.9,
|
||||||
|
freq_offset: "-7.6", band: "70cm", lat: 52.5, lng: 13.4,
|
||||||
|
city: "Berlin", state: "Berlin", country: "Germany", color_code: 1,
|
||||||
|
ts_linked: "1,2", trustee: "DL1ABC", ipsc_network: "BrandMeister",
|
||||||
|
networks: ["BrandMeister"], hotspot: 0, status: "On-air",
|
||||||
|
last_seen: null, bm_status: 1, bm_status_text: "Connected",
|
||||||
|
hardware: "Motorola", firmware: "", pep: 50, agl: 30, website: "", description: "",
|
||||||
|
import_freq_inconsistent: false, inactive: false, last_polled: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
|
||||||
|
describe("buildPopup", () => {
|
||||||
|
it("contains callsign as a link", () => {
|
||||||
|
const html = buildPopup(makeRepeater(), mockT);
|
||||||
|
expect(html).toContain("DB0ABC");
|
||||||
|
expect(html).toContain("brandmeister.network/?page=repeater&id=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains TX and RX frequencies", () => {
|
||||||
|
const html = buildPopup(makeRepeater(), mockT);
|
||||||
|
expect(html).toContain("439.5000 MHz");
|
||||||
|
expect(html).toContain("431.9000 MHz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains color code", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ color_code: 3 }), mockT);
|
||||||
|
expect(html).toContain(">3<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains location info", () => {
|
||||||
|
const html = buildPopup(makeRepeater(), mockT);
|
||||||
|
expect(html).toContain("Berlin, Berlin, Germany");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains network info", () => {
|
||||||
|
const html = buildPopup(makeRepeater(), mockT);
|
||||||
|
expect(html).toContain("BrandMeister");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows band tag for 2m", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ band: "2m" }), mockT);
|
||||||
|
expect(html).toContain("band-2m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows band tag for 70cm", () => {
|
||||||
|
const html = buildPopup(makeRepeater(), mockT);
|
||||||
|
expect(html).toContain("band-70cm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows inactive status when inactive", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ inactive: true }), mockT);
|
||||||
|
expect(html).toContain("popup_inactive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show inactive text when active", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ inactive: false }), mockT);
|
||||||
|
expect(html).not.toContain("popup_inactive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows hardware when present", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ hardware: "Hytera RD985" }), mockT);
|
||||||
|
expect(html).toContain("Hytera RD985");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows power and antenna when present", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ pep: 50, agl: 30 }), mockT);
|
||||||
|
expect(html).toContain("50 W");
|
||||||
|
expect(html).toContain("30 m AGL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows last seen when present", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ last_seen: "2024-01-15T12:30:00Z" }), mockT);
|
||||||
|
expect(html).toContain("2024-01-15 12:30:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in callsign", () => {
|
||||||
|
const html = buildPopup(makeRepeater({ callsign: "<script>alert(1)</script>" }), mockT);
|
||||||
|
expect(html).not.toContain("<script>");
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses translate function for labels", () => {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const captureT = (key: string) => { keys.push(key); return key; };
|
||||||
|
buildPopup(makeRepeater(), captureT);
|
||||||
|
expect(keys).toContain("popup_tx");
|
||||||
|
expect(keys).toContain("popup_rx");
|
||||||
|
expect(keys).toContain("popup_cc");
|
||||||
|
expect(keys).toContain("popup_location");
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/popup.ts
Normal file
56
src/popup.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { Repeater, TranslateFunction } from "./types";
|
||||||
|
import { escapeHtml } from "./format";
|
||||||
|
|
||||||
|
/** Build the HTML popup content for a repeater marker. */
|
||||||
|
export function buildPopup(r: Repeater, t: TranslateFunction): string {
|
||||||
|
const bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
||||||
|
let html = '<div class="rptr-popup">';
|
||||||
|
html += '<h3><a href="https://brandmeister.network/?page=repeater&id=' + r.id + '" target="_blank" rel="noopener">' + escapeHtml(r.callsign) + '</a> <span class="band-tag ' + bandClass + '">' + escapeHtml(r.band) + "</span></h3>";
|
||||||
|
html += "<table>";
|
||||||
|
html += "<tr><td>" + t("popup_tx") + "</td><td>" + r.freq_tx.toFixed(4) + " MHz</td></tr>";
|
||||||
|
if (r.freq_rx)
|
||||||
|
html += "<tr><td>" + t("popup_rx") + "</td><td>" + r.freq_rx.toFixed(4) + " MHz</td></tr>";
|
||||||
|
if (r.freq_offset)
|
||||||
|
html +=
|
||||||
|
"<tr><td>" + t("popup_offset") + "</td><td>" +
|
||||||
|
escapeHtml(r.freq_offset) +
|
||||||
|
" MHz</td></tr>";
|
||||||
|
html += "<tr><td>" + t("popup_cc") + "</td><td>" + r.color_code + "</td></tr>";
|
||||||
|
let loc = escapeHtml(r.city);
|
||||||
|
if (r.state) loc += ", " + escapeHtml(r.state);
|
||||||
|
if (r.country) loc += ", " + escapeHtml(r.country);
|
||||||
|
html += "<tr><td>" + t("popup_location") + "</td><td>" + loc + "</td></tr>";
|
||||||
|
if (r.networks && r.networks.length)
|
||||||
|
html +=
|
||||||
|
"<tr><td>" + t("popup_network") + "</td><td>" +
|
||||||
|
r.networks.map(escapeHtml).join(", ") +
|
||||||
|
"</td></tr>";
|
||||||
|
if (r.trustee)
|
||||||
|
html +=
|
||||||
|
"<tr><td>" + t("popup_trustee") + "</td><td>" +
|
||||||
|
escapeHtml(r.trustee) +
|
||||||
|
"</td></tr>";
|
||||||
|
if (r.ts_linked)
|
||||||
|
html +=
|
||||||
|
"<tr><td>" + t("popup_timeslots") + "</td><td>" +
|
||||||
|
escapeHtml(r.ts_linked) +
|
||||||
|
"</td></tr>";
|
||||||
|
html +=
|
||||||
|
"<tr><td>" + t("popup_status") + "</td><td>" +
|
||||||
|
escapeHtml(r.status) +
|
||||||
|
"</td></tr>";
|
||||||
|
if (r.bm_status_text)
|
||||||
|
html += "<tr><td>" + t("popup_bm_status") + "</td><td>" + escapeHtml(r.bm_status_text) + "</td></tr>";
|
||||||
|
if (r.last_seen)
|
||||||
|
html += "<tr><td>" + t("popup_last_seen") + "</td><td>" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "</td></tr>";
|
||||||
|
if (r.hardware)
|
||||||
|
html += "<tr><td>" + t("popup_hardware") + "</td><td>" + escapeHtml(r.hardware) + "</td></tr>";
|
||||||
|
if (r.pep)
|
||||||
|
html += "<tr><td>" + t("popup_power") + "</td><td>" + r.pep + " W</td></tr>";
|
||||||
|
if (r.agl)
|
||||||
|
html += "<tr><td>" + t("popup_antenna") + "</td><td>" + r.agl + " m AGL</td></tr>";
|
||||||
|
if (r.inactive)
|
||||||
|
html += '<tr><td></td><td style="color:#ef5350;font-weight:600">' + t("popup_inactive") + '</td></tr>';
|
||||||
|
html += "</table></div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
96
src/talkgroups.test.ts
Normal file
96
src/talkgroups.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { BM262_TALKGROUPS, tgName, defaultSlot, searchTalkgroups } from "./talkgroups";
|
||||||
|
import type { TgRegistry } from "./types";
|
||||||
|
|
||||||
|
describe("tgName", () => {
|
||||||
|
const registry: TgRegistry = { "1": "Worldwide", "2": "Europe" };
|
||||||
|
|
||||||
|
it("resolves from registry first", () => {
|
||||||
|
expect(tgName(1, registry)).toBe("Worldwide");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to BM262 if not in registry", () => {
|
||||||
|
expect(tgName(262, null)).toBe("Deutschland");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for unknown ID", () => {
|
||||||
|
expect(tgName(99999, registry)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers registry over BM262 fallback", () => {
|
||||||
|
const custom: TgRegistry = { "262": "Custom Name" };
|
||||||
|
expect(tgName(262, custom)).toBe("Custom Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles string IDs", () => {
|
||||||
|
expect(tgName("2628", null)).toBe("Bayern");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defaultSlot", () => {
|
||||||
|
it("returns '1' for TGs starting with 1-7", () => {
|
||||||
|
expect(defaultSlot(262)).toBe("1");
|
||||||
|
expect(defaultSlot(1)).toBe("1");
|
||||||
|
expect(defaultSlot(7777)).toBe("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '2' for TGs starting with 8", () => {
|
||||||
|
expect(defaultSlot(8888)).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns '2' for TGs starting with 9", () => {
|
||||||
|
expect(defaultSlot(9990)).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("searchTalkgroups", () => {
|
||||||
|
const registry: TgRegistry = {
|
||||||
|
"1": "Worldwide",
|
||||||
|
"2": "Europe",
|
||||||
|
"262": "Germany",
|
||||||
|
"91": "Personal 91",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns empty array when registry is null", () => {
|
||||||
|
expect(searchTalkgroups("262", null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches by ID prefix for numeric queries", () => {
|
||||||
|
const results = searchTalkgroups("262", registry);
|
||||||
|
expect(results.some(r => r.id === 262)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("searches by name substring for text queries", () => {
|
||||||
|
const results = searchTalkgroups("World", registry);
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns at most 8 results", () => {
|
||||||
|
const bigRegistry: TgRegistry = {};
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
bigRegistry[String(i)] = "TG" + i;
|
||||||
|
}
|
||||||
|
const results = searchTalkgroups("TG", bigRegistry);
|
||||||
|
expect(results.length).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes BM262 fallback results", () => {
|
||||||
|
const emptyRegistry: TgRegistry = {};
|
||||||
|
const results = searchTalkgroups("Bayern", emptyRegistry);
|
||||||
|
expect(results.some(r => r.id === 2628)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate IDs between registry and BM262", () => {
|
||||||
|
const results = searchTalkgroups("262", registry);
|
||||||
|
const ids = results.map(r => r.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BM262_TALKGROUPS", () => {
|
||||||
|
it("contains key German TGs", () => {
|
||||||
|
expect(BM262_TALKGROUPS["262"]).toBe("Deutschland");
|
||||||
|
expect(BM262_TALKGROUPS["2628"]).toBe("Bayern");
|
||||||
|
});
|
||||||
|
});
|
||||||
78
src/talkgroups.ts
Normal file
78
src/talkgroups.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import type { TgRegistry } from "./types";
|
||||||
|
|
||||||
|
/** Fallback TG names from wiki.bm262.de for the German-speaking region. */
|
||||||
|
export const BM262_TALKGROUPS: TgRegistry = {
|
||||||
|
"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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a talkgroup ID to its name using the registry + BM262 fallback. */
|
||||||
|
export function tgName(id: number | string, registry: TgRegistry | null): string {
|
||||||
|
const key = String(id);
|
||||||
|
if (registry && registry[key]) return registry[key];
|
||||||
|
if (BM262_TALKGROUPS[key]) return BM262_TALKGROUPS[key];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default timeslot: TGs starting with 8 or 9 default to TS2, others to TS1. */
|
||||||
|
export function defaultSlot(tgId: number | string): string {
|
||||||
|
const first = String(tgId).charAt(0);
|
||||||
|
return (first === "8" || first === "9") ? "2" : "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TgSearchResult {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search talkgroups by ID prefix or name substring. Returns up to 8 results. */
|
||||||
|
export function searchTalkgroups(query: string, registry: TgRegistry | null): TgSearchResult[] {
|
||||||
|
if (!registry) return [];
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const isNum = /^\d+$/.test(query);
|
||||||
|
const results: TgSearchResult[] = [];
|
||||||
|
for (const id in registry) {
|
||||||
|
if (isNum ? id.indexOf(query) === 0 : registry[id].toLowerCase().indexOf(q) !== -1) {
|
||||||
|
results.push({ id: parseInt(id), name: registry[id] });
|
||||||
|
}
|
||||||
|
if (results.length >= 8) break;
|
||||||
|
}
|
||||||
|
for (const 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;
|
||||||
|
}
|
||||||
46
src/types.ts
Normal file
46
src/types.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/** Matches the Go Repeater struct JSON serialization (db.go) */
|
||||||
|
export interface Repeater {
|
||||||
|
id: number;
|
||||||
|
callsign: string;
|
||||||
|
freq_tx: number;
|
||||||
|
freq_rx: number;
|
||||||
|
freq_offset: string;
|
||||||
|
band: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
country: string;
|
||||||
|
color_code: number;
|
||||||
|
ts_linked: string;
|
||||||
|
trustee: string;
|
||||||
|
ipsc_network: string;
|
||||||
|
networks: string[];
|
||||||
|
hotspot: number;
|
||||||
|
status: string;
|
||||||
|
last_seen: string | null;
|
||||||
|
bm_status: number | null;
|
||||||
|
bm_status_text: string;
|
||||||
|
hardware: string;
|
||||||
|
firmware: string;
|
||||||
|
pep: number;
|
||||||
|
agl: number;
|
||||||
|
website: string;
|
||||||
|
description: string;
|
||||||
|
import_freq_inconsistent: boolean;
|
||||||
|
inactive: boolean;
|
||||||
|
last_polled: string | null;
|
||||||
|
distance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CpsTalkgroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TgRegistry = Record<string, string>;
|
||||||
|
|
||||||
|
export type TranslateFunction = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export type LatLngTuple = [number, number];
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["leaflet"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "static"]
|
||||||
|
}
|
||||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue