From 2a2e98901cf871c15e90b5f99ff410ab584ff23a Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 19 Feb 2026 16:46:06 +0100 Subject: [PATCH] Refactor frontend to be built from TS --- .air.toml | 2 +- .gitignore | 3 + Dockerfile | 9 + Dockerfile.dev | 5 +- compose.dev.yml | 2 + compose.test.yml | 11 + package-lock.json | 1919 ++++++++++++++++++++++++++++++++++ package.json | 18 + src/cps-anytone.test.ts | 104 ++ src/cps-anytone.ts | 56 + src/cps-motorola.test.ts | 113 ++ src/cps-motorola.ts | 101 ++ src/filters.test.ts | 42 + src/filters.ts | 19 + src/format.test.ts | 69 ++ src/format.ts | 44 + src/geo.test.ts | 126 +++ src/geo.ts | 89 ++ src/globals.d.ts | 18 + static/app.js => src/main.ts | 412 +------- src/popup.test.ts | 98 ++ src/popup.ts | 56 + src/talkgroups.test.ts | 96 ++ src/talkgroups.ts | 78 ++ src/types.ts | 46 + tsconfig.json | 17 + vitest.config.ts | 7 + 27 files changed, 3163 insertions(+), 397 deletions(-) create mode 100644 compose.test.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/cps-anytone.test.ts create mode 100644 src/cps-anytone.ts create mode 100644 src/cps-motorola.test.ts create mode 100644 src/cps-motorola.ts create mode 100644 src/filters.test.ts create mode 100644 src/filters.ts create mode 100644 src/format.test.ts create mode 100644 src/format.ts create mode 100644 src/geo.test.ts create mode 100644 src/geo.ts create mode 100644 src/globals.d.ts rename static/app.js => src/main.ts (69%) create mode 100644 src/popup.test.ts create mode 100644 src/popup.ts create mode 100644 src/talkgroups.test.ts create mode 100644 src/talkgroups.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.air.toml b/.air.toml index d9d23b4..349d796 100644 --- a/.air.toml +++ b/.air.toml @@ -4,7 +4,7 @@ root = "." cmd = "go build -o ./tmp/dmrmap ." bin = "./tmp/dmrmap" include_ext = ["go"] - exclude_dir = ["tmp", "static", "data", "migrations"] + exclude_dir = ["tmp", "static", "data", "migrations", "src", "node_modules"] delay = 500 [misc] diff --git a/.gitignore b/.gitignore index 5057249..ceaf5f9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dmrmap compose.override.yml .env static/talkgroups.json +node_modules/ +static/app.js +static/app.js.map diff --git a/Dockerfile b/Dockerfile index ad9b802..9236a60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 RUN apk add --no-cache git WORKDIR /build @@ -13,6 +21,7 @@ WORKDIR /app COPY --from=builder /build/dmrmap . COPY --from=builder /build/migrations/ ./migrations/ COPY static/ ./static/ +COPY --from=frontend /build/static/app.js ./static/app.js COPY rptrs.json . COPY bmrptrs.json . RUN wget -qO static/talkgroups.json https://api.brandmeister.network/v2/talkgroup diff --git a/Dockerfile.dev b/Dockerfile.dev index 68e035e..92e8cc2 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,5 +1,8 @@ FROM golang:1.25-alpine +RUN apk add --no-cache nodejs npm RUN go install github.com/air-verse/air@latest RUN wget -qO /usr/local/share/talkgroups.json https://api.brandmeister.network/v2/talkgroup WORKDIR /app -CMD 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 diff --git a/compose.dev.yml b/compose.dev.yml index 3e67a6c..c68d55f 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -20,6 +20,7 @@ services: - .:/app - go-mod-cache:/go/pkg/mod - go-build-cache:/root/.cache/go-build + - node_modules:/app/node_modules depends_on: - postgres environment: @@ -32,4 +33,5 @@ services: volumes: go-mod-cache: go-build-cache: + node_modules: pgdata-dev: diff --git a/compose.test.yml b/compose.test.yml new file mode 100644 index 0000000..dbeff31 --- /dev/null +++ b/compose.test.yml @@ -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: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d1c6107 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1919 @@ +{ + "name": "dmrmap-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dmrmap-frontend", + "devDependencies": { + "@types/leaflet": "^1.9.0", + "esbuild": "^0.24.0", + "typescript": "^5.7.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..72d8175 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/cps-anytone.test.ts b/src/cps-anytone.test.ts new file mode 100644 index 0000000..8fdf305 --- /dev/null +++ b/src/cps-anytone.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/cps-anytone.ts b/src/cps-anytone.ts new file mode 100644 index 0000000..a839b08 --- /dev/null +++ b/src/cps-anytone.ts @@ -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"); +} diff --git a/src/cps-motorola.test.ts b/src/cps-motorola.test.ts new file mode 100644 index 0000000..374abc3 --- /dev/null +++ b/src/cps-motorola.test.ts @@ -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 => ({ + 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('SLOT1'); + expect(xml).toContain("1"); + expect(xml).toContain("431.900000"); + expect(xml).toContain("439.500000"); + }); + + 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'); + }); +}); + +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('"); + expect(xml).toContain(""); + 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(); + } + }); +}); diff --git a/src/cps-motorola.ts b/src/cps-motorola.ts new file mode 100644 index 0000000..0a35929 --- /dev/null +++ b/src/cps-motorola.ts @@ -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 = { + 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 ' \n' + + ' DGTLCONV6PT25\n' + + ' ' + escapeXml(alias) + '\n' + + ' ' + slot + '\n' + + ' ' + colorCode + '\n' + + ' ' + rxFreq + '\n' + + ' ' + txFreq + '\n' + + ' MTCHCLRCD\n' + + ' 180\n' + + ' \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 '\n' + + "\n" + + ' \n' + + channels + + " \n" + + "\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"); +} diff --git a/src/filters.test.ts b/src/filters.test.ts new file mode 100644 index 0000000..3c41413 --- /dev/null +++ b/src/filters.test.ts @@ -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"); + }); +}); diff --git a/src/filters.ts b/src/filters.ts new file mode 100644 index 0000000..8c427fe --- /dev/null +++ b/src/filters.ts @@ -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(","); +} diff --git a/src/format.test.ts b/src/format.test.ts new file mode 100644 index 0000000..eceac6a --- /dev/null +++ b/src/format.test.ts @@ -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 = { 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); + }); +}); diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..fc30b10 --- /dev/null +++ b/src/format.ts @@ -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, """); +} + +/** 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, "'"); +} + +/** 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); +} diff --git a/src/geo.test.ts b/src/geo.test.ts new file mode 100644 index 0000000..11a158a --- /dev/null +++ b/src/geo.test.ts @@ -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)"); + }); +}); diff --git a/src/geo.ts b/src/geo.ts new file mode 100644 index 0000000..9b5d660 --- /dev/null +++ b/src/geo.ts @@ -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)"; +} diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..e9d30d9 --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,18 @@ +// i18next globals (from i18n.js loaded before app.js) +declare function t(key: string, opts?: Record): 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; +}; diff --git a/static/app.js b/src/main.ts similarity index 69% rename from static/app.js rename to src/main.ts index 484c423..a8109fb 100644 --- a/static/app.js +++ b/src/main.ts @@ -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 () { "use strict"; @@ -199,23 +207,6 @@ // === Coordinates display === 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) { var lat = e.latlng.lat; var lng = e.latlng.lng; @@ -230,125 +221,12 @@ }); // === Utilities === - function getSelectedBand() { - var has2m = band2m.checked; - var has70cm = band70cm.checked; - if (has2m && has70cm) return "all"; - if (has2m) return "2m"; - if (has70cm) return "70cm"; - return "all"; - } + function getSelectedBand() { return _getSelectedBand(band2m.checked, band70cm.checked); } + function getSelectedNetworks() { return _getSelectedNetworks(netBm.checked, netDmrplus.checked, netTgif.checked, netOther.checked); } - function getSelectedNetworks() { - var nets = []; - 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 tgName(id) { return _tgName(id, tgRegistry); } + function searchTalkgroups(query) { return _searchTalkgroups(query, tgRegistry); } - function escapeHtml(str) { - if (!str) return ""; - return str - .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, "'"); - } - - function formatFreq(mhz) { - return parseFloat(mhz).toFixed(6); - } - - function buildChannel(alias, slot, colorCode, txFreq, rxFreq) { - var slotName = slot === "SLOT1" ? "1" : "2"; - return ' \n' + - ' DGTLCONV6PT25\n' + - ' ' + escapeXml(alias) + '\n' + - ' ' + slot + '\n' + - ' ' + colorCode + '\n' + - ' ' + rxFreq + '\n' + - ' ' + txFreq + '\n' + - ' MTCHCLRCD\n' + - ' 180\n' + - ' \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 '\n' + - "\n" + - ' \n' + - channels + - " \n" + - ""; - } // === BrandMeister API === function fetchTgRegistry() { @@ -434,7 +312,7 @@ function setSlot(idx, slot) { cpsTalkgroups[idx].slot = slot; 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(); 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() { cpsTalkgroups.forEach(function (tg) { - tg.name = formatTgAlias(tg.id, tg.slot); + tg.name = formatTgAlias(tg.id, tg.slot, cpsAliasFormat.value, tgName); }); renderTgTable(); } - function defaultSlot(tgId) { - var first = String(tgId).charAt(0); - return (first === "8" || first === "9") ? "2" : "1"; - } - function addTalkgroupById(tgId) { if (cpsTalkgroups.some(function (tg) { return tg.id === tgId; })) return; 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; }); renderTgTable(); updateChannelCount(); 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() { if (!cpsTalkgroups.length) return; - var csv = generateContactsCsv(cpsTalkgroups); + var csv = generateContactsCsv(cpsTalkgroups, cpsLanguage.value || "de"); var blob = new Blob([csv], { type: "text/csv" }); var url = URL.createObjectURL(blob); var a = document.createElement("a"); @@ -652,58 +462,6 @@ // === 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() { if (!cpsTalkgroups.length) return; var csv = generateAnytoneContactsCsv(cpsTalkgroups); @@ -742,58 +500,7 @@ } // === Popup === - function buildPopup(r) { - var bandClass = r.band === "2m" ? "band-2m" : "band-70cm"; - var html = '
'; - html += '

' + escapeHtml(r.callsign) + ' ' + escapeHtml(r.band) + "

"; - html += ""; - html += ""; - if (r.freq_rx) - html += ""; - if (r.freq_offset) - html += - ""; - html += ""; - var loc = escapeHtml(r.city); - if (r.state) loc += ", " + escapeHtml(r.state); - if (r.country) loc += ", " + escapeHtml(r.country); - html += ""; - if (r.networks && r.networks.length) - html += - ""; - if (r.trustee) - html += - ""; - if (r.ts_linked) - html += - ""; - html += - ""; - if (r.bm_status_text) - html += ""; - if (r.last_seen) - html += ""; - if (r.hardware) - html += ""; - if (r.pep) - html += ""; - if (r.agl) - html += ""; - if (r.inactive) - html += ''; - html += "
" + t("popup_tx") + "" + r.freq_tx.toFixed(4) + " MHz
" + t("popup_rx") + "" + r.freq_rx.toFixed(4) + " MHz
" + t("popup_offset") + "" + - escapeHtml(r.freq_offset) + - " MHz
" + t("popup_cc") + "" + r.color_code + "
" + t("popup_location") + "" + loc + "
" + t("popup_network") + "" + - r.networks.map(escapeHtml).join(", ") + - "
" + t("popup_trustee") + "" + - escapeHtml(r.trustee) + - "
" + t("popup_timeslots") + "" + - escapeHtml(r.ts_linked) + - "
" + t("popup_status") + "" + - escapeHtml(r.status) + - "
" + t("popup_bm_status") + "" + escapeHtml(r.bm_status_text) + "
" + t("popup_last_seen") + "" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "
" + t("popup_hardware") + "" + escapeHtml(r.hardware) + "
" + t("popup_power") + "" + r.pep + " W
" + t("popup_antenna") + "" + r.agl + " m AGL
' + t("popup_inactive") + '
"; - return html; - } + function buildPopup(r) { return _buildPopup(r, t); } // === Display markers === function displayRepeaters(repeaters) { @@ -945,57 +652,6 @@ } // === 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() { if (corridorLayer) { map.removeLayer(corridorLayer); @@ -1203,18 +859,6 @@ } // === 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) { var count = heatmapCounts[repeaterId] || 0; if (count === 0) return; @@ -1506,28 +1150,6 @@ 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; diff --git a/src/popup.test.ts b/src/popup.test.ts new file mode 100644 index 0000000..3616926 --- /dev/null +++ b/src/popup.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { buildPopup } from "./popup"; +import type { Repeater } from "./types"; + +const makeRepeater = (overrides: Partial = {}): 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: "" }), mockT); + expect(html).not.toContain("