diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7cc2a4d0..d8fcf43a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,6 +36,38 @@ jobs: - name: Test run: cargo test --workspace --locked + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend + steps: + - uses: actions/checkout@v4 + - name: Cache npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- + - name: Install locked frontend dependencies + run: npm ci + - name: Type-check + run: npm run typecheck + - name: Lint + run: npm run lint + - name: Test + run: npm test + - name: Install browser smoke dependency + run: command -v chromium >/dev/null || (sudo apt-get update && sudo apt-get install -y --no-install-recommends chromium) + - name: Browser smoke test + run: npm run test:browser + - name: Verify generated assets + run: npm run verify-generated + - name: Verify generated-file licensing + working-directory: . + run: reuse lint + reuse: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 7fab3692..88f3a147 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ Cargo.lock coverage/ benchmarks/ +# Frontend dependencies +**/node_modules/ + # Env .env .env.* diff --git a/Cargo.lock b/Cargo.lock index 62da9099..7a157b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2645,6 +2645,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3137,6 +3146,7 @@ dependencies = [ "sgp4", "tokio", "tracing", + "ts-rs", "uuid", ] @@ -3195,6 +3205,7 @@ dependencies = [ "trx-core", "trx-frontend", "trx-protocol", + "ts-rs", "uuid", ] @@ -3238,6 +3249,7 @@ dependencies = [ "serde", "serde_json", "trx-core", + "ts-rs", ] [[package]] @@ -3333,6 +3345,29 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror 2.0.18", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/REUSE.toml b/REUSE.toml index a4fb4239..223a64e1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -18,7 +18,11 @@ path = [ "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json", - "src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js", + "src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json", ] SPDX-FileCopyrightText = "2026 Stan Grams " SPDX-License-Identifier = "GPL-2.0-or-later" diff --git a/container/Containerfile b/container/Containerfile index 1e5b62ca..86f4f292 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -23,7 +23,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl xz-utils git sudo pipx \ build-essential pkg-config cmake clang libclang-dev \ - libopus-dev libasound2-dev libsoapysdr-dev \ + libopus-dev libasound2-dev libsoapysdr-dev chromium \ && rm -rf /var/lib/apt/lists/* # Node.js (JS-based actions need node in PATH under the host executor). diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 00000000..78c4e433 --- /dev/null +++ b/docs/frontend-architecture.md @@ -0,0 +1,78 @@ + + +# Frontend architecture + +The HTTP frontend is a strict TypeScript application built with esbuild. Rust +embeds deterministic JavaScript output from `assets/web/generated`; Cargo does +not run Node.js or contact the network. + +## Runtime graph + +`frontend/src/bootstrap.ts` is the only first-party script referenced by the +HTML document. Its imports establish startup order for WebGL support, shared UI +services, decoder dispatch, the local Leaflet AIS adapter, and the application +coordinator. Leaflet and the Opus decoder remain isolated vendored scripts. + +Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin +loader imports them by feature group and keeps expensive map, scheduling, and +decoder behavior lazy. Shared code is emitted as content-hashed chunks. The +decode-history worker is an independent entry compiled against Web Worker +globals. + +Dependencies point from application and feature code toward `core` and `api`. +`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused +parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is +used as typed application state. + +## Browser host contract + +Separate lazy bundles cannot share module instances with the stable application +entry, so they use three intentional host namespaces: + +| Global | Purpose | Mutation policy | +| --- | --- | --- | +| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. | +| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. | +| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. | + +The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`, +`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet +adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet +namespace. + +The following transitional properties are explicitly part of the lazy-feature +host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`, +`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`, +`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`, +`buildAisVesselUrl`, `trxScheduleUiFrameJob`, +`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`, +and `refreshRdsUi`. Optional callbacks owned by lazy features are +`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`, +`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`, +`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and +`applyCwAutoUiFromServer`. + +This list is closed: new standalone mutable `window` properties are not an +accepted integration mechanism. Extend an existing typed service or introduce +an imported interface instead. Removing transitional properties as feature +boundaries become directly importable remains preferable. + +## Build and verification + +The generated directory is removed before every build, preventing orphaned +compatibility bundles. Stable feature entry names are allowlisted by the Rust +asset manifest; shared chunks use content hashes. The generic Rust handler +rejects unknown names and unsupported MIME types and serves embedded assets +with compression, ETags, and immutable caching. + +CI installs from `package-lock.json`, caches only npm downloads, type-checks the +window and worker environments separately, lints, runs unit and DOM tests, +starts the application in Chromium, regenerates Rust contracts and bundles, +checks for drift, and runs REUSE validation after generation. + +See `frontend/src/README.md` for local commands and +`docs/ts-migration-plan.md` for the migration decisions and completion gates. diff --git a/docs/frontend_improvements.md b/docs/frontend_improvements.md index 1350c2f6..3ae56e86 100644 --- a/docs/frontend_improvements.md +++ b/docs/frontend_improvements.md @@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page application served as embedded static assets (gzip-compressed with ETag caching) from the Actix-Web server. -## Current asset inventory +## Historical asset inventory + +This inventory records the frontend before the TypeScript migration. The +first-party JavaScript copies listed here have since been replaced by strict +TypeScript source and deterministic output under `assets/web/generated`. | File | Lines | Size | |------|------:|-----:| diff --git a/docs/ts-migration-baseline.md b/docs/ts-migration-baseline.md new file mode 100644 index 00000000..214c4472 --- /dev/null +++ b/docs/ts-migration-baseline.md @@ -0,0 +1,50 @@ + + +# TypeScript migration baseline + +This baseline records the browser architecture at the start of the migration. +It is intentionally historical; generated output sizes are tracked separately +by the deterministic frontend build. + +## Startup order + +The initial document loads these scripts in order: + +1. `/vendor/opus-decoder-0.7.11.min.js` +2. `/vendor/leaflet.js` +3. `/leaflet-ais-tracksymbol.js` +4. `/webgl-renderer.js` +5. `/ui-core.js` +6. `/app.js` + +Decoder and feature scripts are then loaded lazily by the inline loader in +`index.html`. The frontend smoke test locks the core ordering and rejects +remote script or stylesheet URLs. + +## Source measurements + +At baseline, first-party JavaScript comprised 23 files and approximately +776 KiB. The largest sources were: + +| Source | Bytes | +|---|---:| +| `app.js` | 337,111 | +| `map-core.js` | 131,899 | +| `plugins/scheduler.js` | 60,883 | +| `plugins/bookmarks.js` | 30,543 | +| `plugins/sat.js` | 22,541 | +| `plugins/vchan.js` | 20,195 | +| `webgl-renderer.js` | 18,993 | + +The migrated source snapshot contained 123 distinct direct `window.*` +assignments. These are compatibility callbacks, shared state, and plugin entry +points. New TypeScript code must not add to that set; the typed plugin registry +and explicit services replace it over the course of the migration. + +All production scripts, stylesheets, fonts, icons, and map assets were already +served from local embedded routes. The automated startup test ensures no +remote runtime script or stylesheet dependency is introduced. diff --git a/docs/ts-migration-plan.md b/docs/ts-migration-plan.md new file mode 100644 index 00000000..19f77a9c --- /dev/null +++ b/docs/ts-migration-plan.md @@ -0,0 +1,586 @@ + + +# TypeScript Migration Plan + +> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/` +> +> **Status**: Complete (2026-08-01) + +## Implementation result + +The migration was completed on `feat/typescript-frontend-migration`. The +baseline and phased sections below are retained as the decision record; their +descriptions of JavaScript files and classic loading refer to the pre-migration +state. + +Completion evidence: + +- all first-party browser sources are strict `.ts` files, checked by separate + DOM and Web Worker TypeScript projects with no JavaScript compatibility mode + or suppression directives; +- `bootstrap.ts` is the single first-party HTML entry and esbuild represents + startup order, lazy feature imports, shared hashed chunks, and the worker in + its module graph; +- obsolete source and generated compatibility JavaScript was removed; +- Rust generates rig, status, capability, decoder, and flattened frontend + metadata contracts into `api/generated.ts`; runtime guards validate HTTP, + SSE, WebSocket, and worker boundaries; +- the generic embedded-asset handler serves a build-generated allowlist with + constrained MIME types, compression, ETags, immutable caching, and no file + system lookup; +- intentional browser host namespaces and transitional lazy-feature properties + are documented in `docs/frontend-architecture.md`; +- CI uses locked npm dependencies, caches npm downloads rather than + `node_modules`, runs strict type checking and linting, unit/DOM/worker tests, + Chromium startup coverage, generated-output drift checks, and REUSE after + generation; +- Cargo continues to consume committed generated assets without invoking Node + or requiring network access. + +The final local gate ran `npm ci`, type checking, linting, 30 frontend tests, +the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map +initialization, and navigation), generated-contract and bundle verification, +workspace formatting, Clippy with warnings denied, all-target builds, workspace +tests, and REUSE 3.3 validation. + +## 1. Decision + +Migrating the web frontend to TypeScript is worthwhile, provided it is used to +remove implicit contracts and global coupling. Renaming JavaScript files to +`.ts` without changing their boundaries would add tooling without delivering +the main safety benefits. + +The migration must be incremental. Every intermediate commit and pull request +must leave the frontend buildable and usable. + +## 2. Baseline State + +The frontend currently contains roughly 21,700 lines of first-party +JavaScript. Its largest components include: + +- `app.js`: approximately 8,900 lines; +- `map-core.js`: approximately 3,500 lines; +- `plugins/scheduler.js`: approximately 1,500 lines; +- `plugins/bookmarks.js`: approximately 800 lines; +- `plugins/vchan.js`: approximately 560 lines. + +Important characteristics of the current architecture are: + +- scripts are embedded individually into the Rust binary with `include_str!`; +- the Rust HTTP server exposes an explicit route for each asset; +- plugins are loaded dynamically as classic scripts; +- script order is significant; +- modules communicate through `window.*`, `window.trx`, callbacks, and shared + mutable state; +- the main HTML file contains the plugin loader; +- a Web Worker is loaded through a fixed URL; +- Cargo builds do not require Node.js; +- CI images contain Node.js, but CI currently runs only Rust and REUSE checks; +- Leaflet, Opus decoder, fonts, images, and other vendored assets are local. + +These constraints make a big-bang rewrite unnecessarily risky. + +## 3. Goals + +The migration should: + +1. Create checked contracts between Rust responses and browser code. +2. Replace global callbacks with explicit module interfaces. +3. Split `app.js` by responsibility. +4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit. +5. Preserve lazy loading for expensive features. +6. Add frontend type checking, linting, and automated tests to CI. +7. Keep ordinary Cargo builds independent of Node.js and network access. +8. Preserve existing browser behavior throughout the migration. + +## 4. Non-Goals + +The migration will not initially: + +- introduce a UI framework; +- redesign the user interface; +- convert vendored JavaScript to TypeScript; +- change REST, SSE, WebSocket, or worker protocols unless required to make an + existing contract unambiguous; +- make `build.rs` install packages or download frontend dependencies; +- convert all of `app.js` in one pull request. + +## 5. Target Layout + +```text +trx-frontend-http/ +├── frontend/ +│ ├── package.json +│ ├── package-lock.json +│ ├── tsconfig.json +│ ├── tsconfig.worker.json +│ ├── build.mjs +│ ├── src/ +│ │ ├── bootstrap.ts +│ │ ├── api/ +│ │ │ ├── client.ts +│ │ │ └── generated.ts +│ │ ├── core/ +│ │ │ ├── dom.ts +│ │ │ ├── events.ts +│ │ │ ├── settings.ts +│ │ │ └── state.ts +│ │ ├── features/ +│ │ │ ├── audio/ +│ │ │ ├── bookmarks/ +│ │ │ ├── map/ +│ │ │ ├── navigation/ +│ │ │ ├── radio/ +│ │ │ ├── recorder/ +│ │ │ ├── scheduler/ +│ │ │ └── spectrum/ +│ │ ├── decoders/ +│ │ ├── workers/ +│ │ └── legacy/ +│ │ └── global-bridge.ts +│ └── tests/ +└── assets/web/ + ├── generated/ + ├── vendor/ + ├── index.html + ├── style.css + └── themes.css +``` + +The exact feature directories may evolve, but dependencies should point from +features toward `core` and `api`, never from `core` back into features. + +## 6. Tooling + +Use TypeScript for type checking and esbuild for bundling. A framework-specific +development server is not needed. + +Recommended compiler baseline: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "useUnknownInCatchVariables": true, + "noEmit": true, + "lib": ["ES2022", "DOM"] + } +} +``` + +Worker code should use a separate configuration with `WebWorker` rather than +`DOM` globals. + +The package scripts should provide at least: + +```text +npm run typecheck +npm run lint +npm test +npm run build +npm run verify-generated +``` + +Pin dependencies in `package-lock.json`. New package and generated files must +carry or inherit valid REUSE licensing information. + +## 7. Cargo and Asset Integration + +Cargo must remain usable on machines without Node.js. Do not invoke `npm` +automatically from `build.rs`. + +The initial integration should work as follows: + +1. TypeScript and JavaScript sources live under `frontend/src`. +2. esbuild writes browser-ready output under `assets/web/generated`. +3. Generated browser output is committed to the repository. +4. Rust embeds generated output, not TypeScript source. +5. CI rebuilds the frontend and fails if committed output is stale. + +During early phases, retain the existing public URLs, including: + +```text +/app.js +/ui-core.js +/map-core.js +/scheduler.js +/decode-history-worker.js +``` + +Keeping these URLs stable avoids coupling the first migration phases to changes +in HTML loading, authentication rules, caching, or Rust routing. + +After modules and code splitting are established, replace the explicit list of +Rust constants and handlers with an embedded generated-asset directory. The +server must still enforce an allowlist, correct MIME types, cache headers, and +path traversal protection. + +## 8. Rust-to-TypeScript Contracts + +Generate TypeScript definitions from Rust wire types instead of maintaining +parallel handwritten representations. + +Initial candidates include: + +- `RigState`, `RigInfo`, and `RigCapabilities`; +- the `/rigs` response; +- decoder registry entries; +- scheduler configuration and status; +- filter and spectrum state; +- recorder responses; +- SSE update payloads; +- decoded-message variants; +- WebSocket control and status messages. + +A generator such as `ts-rs` can produce +`frontend/src/api/generated.ts`. Generated definitions should describe only +wire formats. UI state and view models should remain handwritten. + +CI must regenerate these definitions and fail when the working tree changes. +Serde renames, tagged enums, optional fields, flattened values, and numeric +ranges must be checked explicitly during the initial generator integration. + +TypeScript types do not validate data at runtime. Critical compatibility +boundaries should retain focused runtime checks for missing or malformed data, +particularly when clients and servers may run different versions. + +## 9. Target Module Contracts + +Plugins should eventually implement an explicit interface rather than assigning +callbacks to `window`: + +```ts +interface TrxPlugin { + readonly id: string; + initialize(context: PluginContext): void | Promise; + dispose?(): void; +} + +interface PluginContext { + api: TrxApi; + events: TrxEventBus; + navigation: NavigationService; + notifications: NotificationService; + state: ReadonlyRadioState; +} +``` + +During the transition, `legacy/global-bridge.ts` may expose the minimum globals +required by unmigrated scripts. The bridge must shrink as migration progresses; +new feature code must not add new global callbacks. + +State should be divided by responsibility rather than replaced by one large +typed global: + +```ts +interface RadioState { + activeRigId: string | null; + capabilities: RigCapabilities | null; + connection: ConnectionState; + frequencyHz: number | null; + mode: RigMode | null; + bandwidthHz: number | null; +} + +interface AudioState { + rx: AudioStreamState; + tx: AudioStreamState; + volume: VolumeState; +} + +interface DecoderState { + registry: DecoderDescriptor[]; + status: ReadonlyMap; +} +``` + +Commands should receive a rig ID explicitly. They must not infer their target +from mutable global selection state after an asynchronous operation begins. + +## 10. Migration Phases + +### Phase 0: Baseline and Measurements + +- Record current asset names, sizes, and load order. +- Add a browser startup smoke test. +- Record existing global symbols and plugin callbacks. +- Confirm that generated bundles do not introduce remote runtime dependencies. + +**Exit criterion:** current behavior and asset loading have an automated +baseline. + +### Phase 1: Tooling Scaffold + +- Add the frontend package, lockfile, TypeScript configuration, and esbuild. +- Allow existing JavaScript as build input without type checking it globally. +- Produce fixed-name outputs matching current URLs. +- Add frontend commands and CI checks. +- Document local frontend development commands. + +**Exit criterion:** existing JavaScript passes through the frontend build with +no runtime changes, and CI detects stale generated output. + +### Phase 2: Generated API Types + +- Add Rust-to-TypeScript type generation. +- Generate the first status, rig, capability, and decoder contracts. +- Introduce a typed fetch/post client and typed SSE decoding boundary. +- Keep narrow runtime guards at compatibility boundaries. + +**Exit criterion:** new API consumers cannot use untyped response objects. + +### Phase 3: Core Browser Services + +Convert or create: + +- DOM lookup helpers; +- notifications and confirmations; +- settings and per-rig preferences; +- navigation; +- the event bus; +- application state stores; +- plugin registry and loader. + +Convert `ui-core.js` as the first strict TypeScript entry. + +**Exit criterion:** shared UI behavior is TypeScript, tested, and does not add +new globals. + +### Phase 4: Independent Leaf Modules + +Convert lower-coupling code first: + +1. WebGL renderer; +2. decode-history worker; +3. screenshot support; +4. FT2 and FT4; +5. WSPR; +6. CW and other decoder views. + +Workers must be separate build entries. + +**Exit criterion:** each converted module has typed inputs, outputs, and tests. + +### Phase 5: Plugin Loading + +- Move the inline loader out of `index.html`. +- Replace classic-script injection with typed dynamic imports. +- Register plugins through `TrxPlugin`. +- Preserve lazy loading by tab and feature. +- Remove the corresponding global callbacks after each plugin migrates. + +**Exit criterion:** plugin dependencies and loading order are represented by the +module graph rather than implicit script order. + +### Phase 6: Split the Main Application + +Extract `app.js` by responsibility: + +1. authentication and API transport; +2. rig enumeration and switching; +3. radio commands and capabilities; +4. audio streaming; +5. spectrum state and rendering; +6. decode history; +7. recorder; +8. keyboard shortcuts; +9. application bootstrap. + +Do not split by arbitrary line ranges. Each extraction must establish an +explicit interface and remove the corresponding globals. + +**Exit criterion:** the bootstrap file composes services and features but does +not contain their implementations. + +### Phase 7: High-Coupling Features + +Convert the remaining large features after the core contracts are stable: + +- scheduler and satellite scheduler; +- bookmarks; +- virtual channels; +- map and map-backed statistics; +- remaining decoder plugins. + +**Exit criterion:** no first-party classic scripts or untyped plugin callbacks +remain. + +### Phase 8: Asset-Server Consolidation + +- Enable code splitting and hashed chunks. +- Embed the generated asset directory in Rust. +- Serve generated assets through a generic, allowlisted handler. +- Add appropriate immutable caching for hashed output. +- Retain stable handling for HTML and version metadata. +- Remove obsolete fixed-asset constants and handlers. + +**Exit criterion:** Rust no longer needs a source edit for every generated +frontend chunk. + +### Phase 9: Strictness and Cleanup + +- Remove `allowJs`. +- Remove the legacy global bridge. +- Enable all selected strict compiler and lint rules. +- Remove obsolete generated compatibility bundles. +- Update architecture and contributor documentation. + +**Exit criterion:** all first-party frontend source is strict TypeScript and the +browser runtime exposes only intentionally documented globals. + +## 11. Testing Strategy + +Frontend CI should run: + +```sh +npm ci +npm run typecheck +npm run lint +npm test +npm run build +git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts +``` + +Testing should cover four layers: + +1. **Pure unit tests** for bandwidth calculations, formatting, state changes, + capability decisions, and message routing. +2. **DOM component tests** for dialogs, tabs, workspace selection, rig + switching, and collapsible control sections. +3. **Worker tests** for decode-history pruning, batching, and message formats. +4. **Browser smoke tests** for startup, authentication gating, plugin loading, + navigation, rig switching, audio controls, and map initialization. + +The existing dependency-free `ui-core` test can remain during the scaffold +phase. It should be moved to the standard TypeScript test runner once that +runner is established. + +## 12. CI Changes + +Add a dedicated frontend job rather than hiding frontend work inside the Cargo +jobs. The job should: + +- use the Node.js version provided by the runner image; +- run with `npm ci`, never a mutable install; +- cache the npm download cache, not `node_modules`; +- type-check, lint, test, and build; +- verify generated artifacts and Rust-generated types are current; +- run REUSE validation after generated files are produced. + +Rust lint and test jobs should continue to consume committed generated assets. + +## 13. Pull Request Strategy + +Use small, independently reversible pull requests. A recommended sequence is: + +1. tooling and unchanged JavaScript build; +2. generated Rust wire types and typed API client; +3. `ui-core` conversion; +4. worker and WebGL conversion; +5. decoder plugin conversions in small groups; +6. plugin registry and dynamic imports; +7. one `app.js` responsibility per PR; +8. scheduler, bookmarks, and map conversions; +9. generic embedded asset serving; +10. removal of JavaScript compatibility mode. + +Every PR should include: + +- runtime behavior preserved or intentionally documented; +- frontend type checking and tests; +- strict Rust formatting and Clippy; +- generated-artifact drift verification; +- no newly introduced undocumented `window` globals; +- no remote runtime asset dependency. + +## 14. Risks and Mitigations + +### Superficial Conversion + +**Risk:** globals receive declarations but remain coupled and mutable. + +**Mitigation:** require every converted module to expose explicit inputs and +outputs and remove at least the globals it replaces. + +### Cargo Becomes Dependent on Node + +**Risk:** builds fail on systems without Node or network access. + +**Mitigation:** commit deterministic generated output and keep npm outside +`build.rs`. + +### Script-Order Regressions + +**Risk:** switching to modules changes execution timing and scope. + +**Mitigation:** retain current public entries initially, then replace script +loading only after the plugin registry is in place. + +### Unreviewable `app.js` Rewrite + +**Risk:** a large conversion is difficult to review, test, or bisect. + +**Mitigation:** extract one responsibility at a time and keep bootstrap working +after every extraction. + +### Rust and Browser Contracts Drift + +**Risk:** TypeScript compiles against stale wire definitions. + +**Mitigation:** generate types from Rust and enforce a clean working tree after +generation in CI. + +### Generated Asset Noise + +**Risk:** committed bundles make reviews noisy. + +**Mitigation:** separate source and generated commits when useful, include +source maps, and require deterministic output. + +### Bundle or Startup Regression + +**Risk:** bundling loads too much code eagerly. + +**Mitigation:** record the current baseline, preserve feature-level lazy loads, +and track entry/chunk sizes in CI. + +## 15. Initial Proof of Concept + +The first implementation PR should be deliberately limited to: + +1. adding the frontend package and locked toolchain; +2. building existing JavaScript to fixed-name generated output; +3. adding frontend CI and drift checks; +4. generating initial rig/status TypeScript definitions; +5. introducing the typed API client; +6. converting `ui-core.js` to strict TypeScript; +7. preserving every existing asset URL and observable behavior. + +This proof of concept is the decision gate for the remainder of the migration. +If it materially improves contract safety without making Cargo development +unwieldy, continue with the phased plan. If it does not, the repository can +retain the tooling and the converted core without committing to a full rewrite. + +## 16. Completion Criteria + +The migration is complete when: + +- all first-party browser source is strict TypeScript; +- vendor files remain isolated and declared through narrow type adapters; +- Rust wire types generate their browser contracts; +- no feature depends on accidental script ordering; +- no undocumented mutable `window` callbacks remain; +- `app.js` has been replaced by a small typed bootstrap and feature modules; +- frontend type checking, linting, unit tests, component tests, browser smoke + tests, and generated-output checks run in CI; +- Cargo builds remain possible without Node.js or network access; +- production assets remain local, embedded, cacheable, and reproducible. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml index 498adad3..18a130d8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml +++ b/src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml @@ -29,3 +29,4 @@ hex = "0.4" pickledb = "0.5" dirs = "6" uuid = { workspace = true } +ts-rs = "12.0.1" diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js new file mode 100644 index 00000000..3a85696f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -0,0 +1,331 @@ +// src/plugins/ais.ts +var aisWindow = window; +var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var aisStatus = document.getElementById("ais-status"); +var aisMessagesEl = document.getElementById("ais-messages"); +var aisFilterInput = document.getElementById("ais-filter"); +var aisBarOverlay = document.getElementById("ais-bar-overlay"); +var aisChannelSummaryEl = document.getElementById("ais-channel-summary"); +var aisVesselCountEl = document.getElementById("ais-vessel-count"); +var aisLatestSeenEl = document.getElementById("ais-latest-seen"); +var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3; +var AIS_DEFAULT_A_HZ = 161975e3; +var AIS_CHANNEL_SPACING_HZ = 5e4; +var aisFilterText = ""; +var aisMessageHistory = []; +function currentAisHistoryRetentionMs() { + return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneAisMessageHistory() { + const cutoffMs = Date.now() - currentAisHistoryRetentionMs(); + aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); +} +function scheduleAisUi(key, job) { + if (typeof aisWindow.trxScheduleUiFrameJob === "function") { + aisWindow.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleAisHistoryRender() { + scheduleAisUi("ais-history", () => { + renderAisHistory(); + }); +} +function scheduleAisBarUpdate() { + scheduleAisUi("ais-bar", () => { + updateAisBar(); + }); +} +function formatAisMhz(freqHz) { + return `${(freqHz / 1e6).toFixed(3)} MHz`; +} +function currentAisChannelPlan() { + const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); + const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; + const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; + return { + aHz: safeAHz, + bHz: safeAHz + AIS_CHANNEL_SPACING_HZ + }; +} +function aisChannelInfo(channel) { + const plan = currentAisChannelPlan(); + const ch = (channel ?? "").trim().toUpperCase(); + if (ch === "B") { + return { + label: "AIS-B", + badgeClass: "ais-badge ais-badge-channel-b", + freqText: formatAisMhz(plan.bHz) + }; + } + return { + label: "AIS-A", + badgeClass: "ais-badge ais-badge-channel-a", + freqText: formatAisMhz(plan.aHz) + }; +} +function aisDisplayName(msg) { + return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`; +} +function aisDisplayNameHtml(msg) { + const label = escapeAisHtml(aisDisplayName(msg)); + const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null; + if (!url) return label; + return `${label}`; +} +function aisTypeLabel(type) { + switch (Number(type)) { + case 1: + case 2: + case 3: + return "Class A Position"; + case 4: + return "Base Station"; + case 5: + return "Static/Voyage"; + case 18: + return "Class B Position"; + case 19: + return "Class B Extended"; + case 21: + return "Aid to Nav"; + case 24: + return "Class B Static"; + default: + return `Type ${type ?? "--"}`; + } +} +function aisAgeText(tsMs) { + if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function aisMotionText(msg) { + const parts = [ + msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null, + msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null, + msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null + ].filter(Boolean); + return parts.join(" · "); +} +function aisRouteText(msg) { + return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); +} +function aisDistanceText(msg) { + if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) { + return ""; + } + const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function aisLatestByVessel(messages) { + const byMmsi = /* @__PURE__ */ new Map(); + for (const msg of messages) { + const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`; + if (!byMmsi.has(key)) byMmsi.set(key, msg); + } + return Array.from(byMmsi.values()); +} +function updateAisSummary() { + const plan = currentAisChannelPlan(); + if (aisChannelSummaryEl) { + aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; + } + const vessels = aisLatestByVessel(aisMessageHistory); + if (aisVesselCountEl) { + const count = vessels.length; + aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; + } + if (aisLatestSeenEl) { + const latest = aisMessageHistory[0]; + if (!latest) { + aisLatestSeenEl.textContent = "No traffic yet"; + } else { + const channel = aisChannelInfo(latest.channel); + aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`; + } + } +} +function renderAisRow(msg) { + const row = document.createElement("div"); + row.className = "ais-message"; + const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + const name = aisDisplayName(msg); + const nameHtml = aisDisplayNameHtml(msg); + const channel = aisChannelInfo(msg.channel); + const motion = aisMotionText(msg); + const route = aisRouteText(msg); + const distance = aisDistanceText(msg); + const pos = msg.lat != null && msg.lon != null ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; + row.dataset.filterText = [ + name, + msg.mmsi, + msg.channel, + channel.label, + msg.vessel_name, + msg.callsign, + msg.destination, + aisTypeLabel(msg.message_type) + ].filter(Boolean).join(" ").toUpperCase(); + row.innerHTML = `
${ts}${nameHtml}${escapeAisHtml(channel.label)}${escapeAisHtml(aisTypeLabel(msg.message_type))}
MMSI ${escapeAisHtml(String(msg.mmsi))}` + (route ? `${escapeAisHtml(route)}` : "") + `${escapeAisHtml(channel.freqText)}
` + (motion ? `${escapeAisHtml(motion)}` : `No motion data`) + (distance ? `${escapeAisHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeAisHtml(aisAgeText(msg._tsMs))}
`; + applyAisFilterToRow(row); + return row; +} +function applyAisFilterToRow(row) { + if (!aisFilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.filterText || ""; + row.style.display = message.includes(aisFilterText) ? "" : "none"; +} +function updateAisBar() { + if (!aisBarOverlay) return; + updateAisSummary(); + const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; + const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; + const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); + const messages = aisLatestByVessel(recent).slice(0, 8); + if (!isAis || messages.length === 0) { + aisBarOverlay.style.display = "none"; + aisBarOverlay.innerHTML = ""; + return; + } + let html = `
AISLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + const pin = msg.lat != null && msg.lon != null ? `` : ""; + const name = `${aisDisplayNameHtml(msg)}`; + const channel = aisChannelInfo(msg.channel); + const distance = aisDistanceText(msg); + const details = [ + `MMSI ${escapeAisHtml(String(msg.mmsi))}`, + escapeAisHtml(channel.label), + msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null, + msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null, + distance ? escapeAisHtml(distance) : null, + escapeAisHtml(aisAgeText(msg._tsMs)) + ].filter(Boolean).join(" · "); + html += `
${ts}${pin}${name}: ${details}
`; + } + aisBarOverlay.innerHTML = html; + aisBarOverlay.style.display = "flex"; +} +aisWindow.updateAisBar = updateAisBar; +aisWindow.clearAisBar = function() { + resetAisHistoryView(); +}; +function resetAisHistoryView() { + if (aisMessagesEl) aisMessagesEl.innerHTML = ""; + aisMessageHistory = []; + updateAisBar(); + renderAisHistory(); + aisWindow.clearMapMarkersByType?.("ais"); +} +function renderAisHistory() { + pruneAisMessageHistory(); + if (!aisMessagesEl) { + updateAisSummary(); + return; + } + const fragment = document.createDocumentFragment(); + for (const message of aisMessageHistory) { + fragment.appendChild(renderAisRow(message)); + } + aisMessagesEl.replaceChildren(fragment); + updateAisSummary(); +} +function addAisMessage(msg) { + const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + aisMessageHistory.unshift(msg); + pruneAisMessageHistory(); + scheduleAisBarUpdate(); + scheduleAisHistoryRender(); + if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(msg); + } +} +function normalizeServerAisMessage(msg) { + return { + ...msg, + rig_id: msg.rig_id || null + }; +} +function onServerAisBatch(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (aisStatus) aisStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerAisMessage(msg); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(next); + } + normalized.push(next); + } + normalized.reverse(); + aisMessageHistory = normalized.concat(aisMessageHistory); + pruneAisMessageHistory(); + scheduleAisBarUpdate(); + scheduleAisHistoryRender(); +} +function pruneAisHistoryView() { + pruneAisMessageHistory(); + updateAisBar(); + renderAisHistory(); +} +document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { + void (async () => { + if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await aisWindow.postPath?.("/clear_ais_decode"); + resetAisHistoryView(); + } catch (e) { + console.error("AIS history clear failed", e); + } + })(); +}); +if (aisFilterInput) { + aisFilterInput.addEventListener("input", () => { + aisFilterText = aisFilterInput.value.trim().toUpperCase(); + renderAisHistory(); + }); +} +function onServerAis(msg) { + if (aisStatus) aisStatus.textContent = "Receiving"; + addAisMessage(normalizeServerAisMessage(msg)); +} +updateAisSummary(); +window.trxPluginRuntime.registerDecoder({ + id: "ais", + onMessage: onServerAis, + onBatch: onServerAisBatch, + restore: onServerAisBatch, + reset: resetAisHistoryView, + prune: pruneAisHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js new file mode 100644 index 00000000..03242457 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -0,0 +1,9311 @@ +// src/webgl-renderer.ts +(function initTrxWebGl(global) { + "use strict"; + const cssColorCache = /* @__PURE__ */ new Map(); + let cssColorProbe = null; + function clearCssColorCache() { + cssColorCache.clear(); + } + function ensureCssColorProbe() { + if (cssColorProbe) return cssColorProbe; + const el = document.createElement("span"); + el.style.position = "absolute"; + el.style.left = "-9999px"; + el.style.top = "-9999px"; + el.style.pointerEvents = "none"; + el.style.opacity = "0"; + document.body.appendChild(el); + cssColorProbe = el; + return cssColorProbe; + } + function parseRgbString(value) { + const m = /^rgba?\(([^)]+)\)$/.exec(value.trim()); + if (!m) return null; + const parts = m[1]?.split(",").map((p) => p.trim()) ?? []; + if (parts.length < 3) return null; + const r = Number(parts[0]); + const g = Number(parts[1]); + const b = Number(parts[2]); + const a = parts.length > 3 ? Number(parts[3]) : 1; + if (![r, g, b, a].every(Number.isFinite)) return null; + return [ + Math.max(0, Math.min(1, r / 255)), + Math.max(0, Math.min(1, g / 255)), + Math.max(0, Math.min(1, b / 255)), + Math.max(0, Math.min(1, a)) + ]; + } + function parseHexColor(value) { + const raw = value.trim(); + if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null; + let hex = raw.slice(1); + if (hex.length === 3 || hex.length === 4) { + hex = hex.split("").map((ch) => ch + ch).join(""); + } + if (!(hex.length === 6 || hex.length === 8)) return null; + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; + return [r, g, b, a]; + } + function parseCssColor(value) { + const key = value; + const cached = cssColorCache.get(key); + if (cached) return [...cached]; + let parsed = parseHexColor(key) || parseRgbString(key); + if (!parsed) { + const probe = ensureCssColorProbe(); + probe.style.color = ""; + probe.style.color = key; + const computed = getComputedStyle(probe).color; + parsed = parseRgbString(computed) || [0, 0, 0, 1]; + } + cssColorCache.set(key, [...parsed]); + return [...parsed]; + } + function hslToRgba(h, s, l, a = 1) { + const hue = ((h || 0) % 360 + 360) % 360 / 360; + const sat = Math.max(0, Math.min(1, (s || 0) / 100)); + const lig = Math.max(0, Math.min(1, (l || 0) / 100)); + const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat; + const p = 2 * lig - q; + const hueToRgb = (t) => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3); + const g = sat === 0 ? lig : hueToRgb(hue); + const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3); + return [r, g, b, Math.max(0, Math.min(1, a))]; + } + function normalizeColor(input, alphaMul = 1) { + let rgba; + if (Array.isArray(input)) { + const arr = input; + if (arr.length >= 4) { + rgba = [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 1]; + } else { + rgba = [0, 0, 0, 1]; + } + } else if (typeof input === "string") { + rgba = parseCssColor(input); + } else { + rgba = [ + input.r || 0, + input.g || 0, + input.b || 0, + input.a ?? 1 + ]; + } + const out = [ + Math.max(0, Math.min(1, rgba[0])), + Math.max(0, Math.min(1, rgba[1])), + Math.max(0, Math.min(1, rgba[2])), + Math.max(0, Math.min(1, rgba[3] * alphaMul)) + ]; + return out; + } + function compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (shader === null) throw new Error("Unable to create WebGL shader"); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader) || "shader compile error"; + gl.deleteShader(shader); + throw new Error(log); + } + return shader; + } + function createProgram(gl, vertexSrc, fragmentSrc) { + const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc); + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program) || "program link error"; + gl.deleteProgram(program); + throw new Error(log); + } + return program; + } + function pushColoredVertex(target, x, y, rgba) { + target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]); + } + function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) { + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (!(len > 1e-4)) return; + const nx = -dy / len * halfW; + const ny = dx / len * halfW; + const ax = x0 - nx, ay = y0 - ny; + const bx = x0 + nx, by = y0 + ny; + const cx = x1 + nx, cy = y1 + ny; + const dx2 = x1 - nx, dy2 = y1 - ny; + pushColoredVertex(out, ax, ay, rgba); + pushColoredVertex(out, bx, by, rgba); + pushColoredVertex(out, cx, cy, rgba); + pushColoredVertex(out, ax, ay, rgba); + pushColoredVertex(out, cx, cy, rgba); + pushColoredVertex(out, dx2, dy2, rgba); + } + class TrxWebGlRenderer { + canvas; + options; + gl; + ready; + textures = /* @__PURE__ */ new Map(); + _colorScratch = new Float32Array(4096 * 6); + _colorGpuSize = 0; + _texScratch = new Float32Array(6 * 4); + colorProgram; + colorBuffer; + colorLoc; + textureProgram; + textureBuffer; + textureLoc; + constructor(canvas, options = {}) { + this.canvas = canvas; + this.options = { alpha: true, premultipliedAlpha: false, ...options }; + this.gl = canvas.getContext("webgl", this.options) || canvas.getContext("experimental-webgl", this.options); + this.ready = !!this.gl; + if (!this.ready) return; + const gl = this.gl; + if (!gl) return; + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + const colorVertexSrc = "attribute vec2 a_pos;\nattribute vec4 a_color;\nuniform vec2 u_resolution;\nvarying vec4 v_color;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_color = a_color;\n}\n"; + const colorFragmentSrc = "precision mediump float;\nvarying vec4 v_color;\nvoid main() {\n gl_FragColor = v_color;\n}\n"; + const textureVertexSrc = "attribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec2 u_resolution;\nvarying vec2 v_uv;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_uv = a_uv;\n}\n"; + const textureFragmentSrc = "precision mediump float;\nvarying vec2 v_uv;\nuniform sampler2D u_tex;\nuniform float u_alpha;\nvoid main() {\n vec4 c = texture2D(u_tex, v_uv);\n gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n}\n"; + this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc); + this.colorBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW); + this._colorGpuSize = this._colorScratch.length; + this.colorLoc = { + pos: gl.getAttribLocation(this.colorProgram, "a_pos"), + color: gl.getAttribLocation(this.colorProgram, "a_color"), + resolution: gl.getUniformLocation(this.colorProgram, "u_resolution") + }; + this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc); + this.textureBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer); + gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW); + this.textureLoc = { + pos: gl.getAttribLocation(this.textureProgram, "a_pos"), + uv: gl.getAttribLocation(this.textureProgram, "a_uv"), + resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"), + alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"), + tex: gl.getUniformLocation(this.textureProgram, "u_tex") + }; + } + ensureSize(cssWidth, cssHeight, dpr = window.devicePixelRatio || 1) { + if (!this.gl) return false; + const nextW = Math.max(1, Math.round(cssWidth * dpr)); + const nextH = Math.max(1, Math.round(cssHeight * dpr)); + const changed = this.canvas.width !== nextW || this.canvas.height !== nextH; + if (changed) { + this.canvas.width = nextW; + this.canvas.height = nextH; + } + this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); + return changed; + } + clear(color) { + if (!this.gl) return; + const gl = this.gl; + const rgba = normalizeColor(color); + gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]); + gl.clear(gl.COLOR_BUFFER_BIT); + } + drawTriangles(vertices) { + if (!this.gl) return; + this._drawColorGeometry(vertices, this.gl.TRIANGLES); + } + drawTriangleStrip(vertices) { + if (!this.gl) return; + this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP); + } + _drawColorGeometry(vertices, mode) { + if (!this.gl || vertices.length === 0) return; + const gl = this.gl; + const count = vertices.length; + if (count > this._colorScratch.length) { + let newLen = this._colorScratch.length; + while (newLen < count) newLen *= 2; + this._colorScratch = new Float32Array(newLen); + } + this._colorScratch.set(vertices); + const view = this._colorScratch.subarray(0, count); + gl.useProgram(this.colorProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); + if (count > this._colorGpuSize) { + gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW); + this._colorGpuSize = this._colorScratch.length; + } else { + gl.bufferSubData(gl.ARRAY_BUFFER, 0, view); + } + gl.enableVertexAttribArray(this.colorLoc.pos); + gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0); + gl.enableVertexAttribArray(this.colorLoc.color); + gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8); + gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height); + gl.drawArrays(mode, 0, count / 6); + } + fillRect(x, y, w, h, color) { + if (w <= 0 || h <= 0) return; + const rgba = normalizeColor(color); + const v = []; + pushColoredVertex(v, x, y, rgba); + pushColoredVertex(v, x + w, y, rgba); + pushColoredVertex(v, x + w, y + h, rgba); + pushColoredVertex(v, x, y, rgba); + pushColoredVertex(v, x + w, y + h, rgba); + pushColoredVertex(v, x, y + h, rgba); + this.drawTriangles(v); + } + fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) { + if (w <= 0 || h <= 0) return; + const tl = normalizeColor(colorTL); + const tr = normalizeColor(colorTR); + const br = normalizeColor(colorBR); + const bl = normalizeColor(colorBL); + const v = []; + pushColoredVertex(v, x, y, tl); + pushColoredVertex(v, x + w, y, tr); + pushColoredVertex(v, x + w, y + h, br); + pushColoredVertex(v, x, y, tl); + pushColoredVertex(v, x + w, y + h, br); + pushColoredVertex(v, x, y + h, bl); + this.drawTriangles(v); + } + drawPolyline(points, color, width = 1) { + if (!Array.isArray(points) || points.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, width || 1) / 2; + const verts = []; + for (let i = 0; i < points.length - 2; i += 2) { + segmentToQuadVertices( + verts, + points[i] ?? 0, + points[i + 1] ?? 0, + points[i + 2] ?? 0, + points[i + 3] ?? 0, + halfW, + rgba + ); + } + this.drawTriangles(verts); + } + drawSegments(segments, color, width = 1) { + if (!Array.isArray(segments) || segments.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, width || 1) / 2; + const verts = []; + for (let i = 0; i < segments.length - 3; i += 4) { + segmentToQuadVertices( + verts, + segments[i] ?? 0, + segments[i + 1] ?? 0, + segments[i + 2] ?? 0, + segments[i + 3] ?? 0, + halfW, + rgba + ); + } + this.drawTriangles(verts); + } + drawFilledArea(points, baselineY, color) { + if (!Array.isArray(points) || points.length < 4) return; + const rgba = normalizeColor(color); + const verts = []; + for (let i = 0; i < points.length; i += 2) { + pushColoredVertex(verts, points[i] ?? 0, baselineY, rgba); + pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba); + } + this.drawTriangleStrip(verts); + } + drawPoints(points, size, color) { + if (!Array.isArray(points) || points.length < 2) return; + const radius = Math.max(1, size || 1); + const rgba = normalizeColor(color); + const verts = []; + for (let i = 0; i < points.length; i += 2) { + const x = (points[i] ?? 0) - radius; + const y = (points[i + 1] ?? 0) - radius; + const w = radius * 2; + const h = radius * 2; + pushColoredVertex(verts, x, y, rgba); + pushColoredVertex(verts, x + w, y, rgba); + pushColoredVertex(verts, x + w, y + h, rgba); + pushColoredVertex(verts, x, y, rgba); + pushColoredVertex(verts, x + w, y + h, rgba); + pushColoredVertex(verts, x, y + h, rgba); + } + this.drawTriangles(verts); + } + drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) { + const dash = Math.max(1, dashLen || 1); + const gap = Math.max(1, gapLen || 1); + const top = Math.min(y0, y1); + const bottom = Math.max(y0, y1); + const segments = []; + for (let y = top; y < bottom; y += dash + gap) { + const segEnd = Math.min(bottom, y + dash); + segments.push(x, y, x, segEnd); + } + this.drawSegments(segments, color, width); + } + uploadRgbaTexture(name, width, height, data, filter = "linear") { + if (!this.gl || !name) return null; + const gl = this.gl; + let entry = this.textures.get(name); + if (!entry) { + const texture = gl.createTexture(); + entry = { texture, width: 0, height: 0 }; + this.textures.set(name, entry); + } + gl.bindTexture(gl.TEXTURE_2D, entry.texture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + if (entry.width !== width || entry.height !== height) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + data + ); + entry.width = width; + entry.height = height; + } else { + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + width, + height, + gl.RGBA, + gl.UNSIGNED_BYTE, + data + ); + } + return entry.texture; + } + drawTexture(name, x, y, w, h, alpha = 1, flipY = true) { + if (!this.gl || !name || w <= 0 || h <= 0) return; + const entry = this.textures.get(name); + if (!entry) return; + const gl = this.gl; + const s = this._texScratch; + const x2 = x + w, y2 = y + h; + if (flipY) { + s[0] = x; + s[1] = y; + s[2] = 0; + s[3] = 1; + s[4] = x2; + s[5] = y; + s[6] = 1; + s[7] = 1; + s[8] = x2; + s[9] = y2; + s[10] = 1; + s[11] = 0; + s[12] = x; + s[13] = y; + s[14] = 0; + s[15] = 1; + s[16] = x2; + s[17] = y2; + s[18] = 1; + s[19] = 0; + s[20] = x; + s[21] = y2; + s[22] = 0; + s[23] = 0; + } else { + s[0] = x; + s[1] = y; + s[2] = 0; + s[3] = 0; + s[4] = x2; + s[5] = y; + s[6] = 1; + s[7] = 0; + s[8] = x2; + s[9] = y2; + s[10] = 1; + s[11] = 1; + s[12] = x; + s[13] = y; + s[14] = 0; + s[15] = 0; + s[16] = x2; + s[17] = y2; + s[18] = 1; + s[19] = 1; + s[20] = x; + s[21] = y2; + s[22] = 0; + s[23] = 1; + } + gl.useProgram(this.textureProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, s); + gl.enableVertexAttribArray(this.textureLoc.pos); + gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(this.textureLoc.uv); + gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8); + gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height); + gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, alpha || 0))); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, entry.texture); + gl.uniform1i(this.textureLoc.tex, 0); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + } + function createRenderer(canvas, options = {}) { + return new TrxWebGlRenderer(canvas, options); + } + global.trxParseCssColor = parseCssColor; + global.trxHslToRgba = hslToRgba; + global.createTrxWebGlRenderer = createRenderer; + global.trxClearCssColorCache = clearCssColorCache; +})(window); + +// src/ui-core.ts +var browserWindow = window; +var preparedTabLists = /* @__PURE__ */ new WeakSet(); +function elementById(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing required UI element #${id}`); + return element; +} +(function initUiCore() { + const api = browserWindow.trxUi ?? {}; + browserWindow.trxUi = api; + function ensureLiveRegions() { + if (!document.getElementById("toast-region")) { + const region = document.createElement("div"); + region.id = "toast-region"; + region.className = "toast-region"; + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "false"); + document.body.appendChild(region); + } + if (!document.getElementById("ui-confirm-dialog")) { + const dialog = document.createElement("dialog"); + dialog.id = "ui-confirm-dialog"; + dialog.className = "ui-dialog"; + dialog.innerHTML = ` +
+

Confirm action

+

+
+ + +
+
`; + document.body.appendChild(dialog); + } + } + api.notify = function notify(message, options = {}) { + ensureLiveRegions(); + const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options; + const toast = document.createElement("div"); + toast.className = `toast toast-${kind}`; + toast.setAttribute("role", kind === "error" ? "alert" : "status"); + const text = document.createElement("span"); + text.textContent = message; + toast.appendChild(text); + if (action && typeof action.run === "function") { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = action.label || "Retry"; + button.addEventListener("click", () => { + action.run(); + toast.remove(); + }); + toast.appendChild(button); + } + elementById("toast-region").appendChild(toast); + requestAnimationFrame(() => { + toast.classList.add("toast-visible"); + }); + if (duration > 0) setTimeout(() => { + toast.remove(); + }, duration); + return toast; + }; + api.confirm = function confirmAction(options = {}) { + ensureLiveRegions(); + const dialog = elementById("ui-confirm-dialog"); + elementById("ui-confirm-title").textContent = options.title || "Confirm action"; + elementById("ui-confirm-message").textContent = options.message || "Continue?"; + const confirmButton = dialog.querySelector('[value="confirm"]'); + if (!confirmButton) throw new Error("Confirmation dialog has no confirm button"); + confirmButton.textContent = options.confirmLabel || "Confirm"; + confirmButton.classList.toggle("danger", options.danger !== false); + return new Promise((resolve) => { + const finish = () => { + resolve(dialog.returnValue === "confirm"); + }; + dialog.addEventListener("close", finish, { once: true }); + dialog.showModal(); + }); + }; + api.setButtonState = function setButtonState(button, options = {}) { + if (!button) return; + const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options; + button.classList.toggle("is-active", active); + button.classList.toggle("is-busy", busy); + button.setAttribute("aria-pressed", String(active)); + button.setAttribute("aria-busy", String(busy)); + button.disabled = disabled || busy; + const label = active ? activeLabel : inactiveLabel; + if (label) button.textContent = label; + }; + api.prepareTabList = function prepareTabList(bar, kind = "primary") { + if (!bar) return; + if (preparedTabLists.has(bar)) return; + preparedTabLists.add(bar); + const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]"; + const buttons = Array.from(bar.querySelectorAll(selector)); + bar.setAttribute("role", "tablist"); + buttons.forEach((button, index) => { + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(button.classList.contains("active"))); + button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1; + const key = button.dataset.tab || button.dataset.subtab; + if (!key) return; + button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`); + const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`); + if (panel) { + if (!button.id) button.id = `${kind}-tab-${key}`; + panel.setAttribute("role", "tabpanel"); + panel.setAttribute("aria-labelledby", button.id); + } + }); + bar.addEventListener("keydown", (event) => { + if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return; + const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; + if (!direction) return; + event.preventDefault(); + const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length]; + if (!next) return; + next.focus(); + next.click(); + }); + }; + api.syncSelectedTab = function syncSelectedTab(bar, selected) { + if (!bar) return; + bar.querySelectorAll('[role="tab"]').forEach((tab) => { + const active = tab === selected; + tab.setAttribute("aria-selected", String(active)); + tab.tabIndex = active ? 0 : -1; + }); + }; + const layouts = { + compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, + broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, + digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" }, + full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" } + }; + const layoutCapabilities = { broadcast: false, digital: false }; + let activeRigId = null; + function layoutStorageKey() { + return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout"; + } + function savedLayoutName() { + return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact"; + } + function layoutAvailable(layout) { + return !layout.capability || layoutCapabilities[layout.capability]; + } + function unavailableLayoutMessage() { + const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable); + return unavailable.length ? `Unavailable: ${unavailable.map((layout) => layout.unavailable).join("; ")}.` : ""; + } + function refreshLayoutOptions() { + const select = document.getElementById("operator-layout-select"); + if (!select) return; + const previous = select.value || document.body.dataset.operatorLayout || "compact"; + select.replaceChildren(); + Object.entries(layouts).forEach(([value, layout]) => { + if (!layoutAvailable(layout)) return; + select.add(new Option(layout.label, value)); + }); + const available = Array.from(select.options).some((option) => option.value === previous); + select.value = available ? previous : "compact"; + if (!available && previous !== "compact") api.applyLayout("compact", { persist: false }); + select.title = unavailableLayoutMessage(); + } + api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) { + Object.keys(layoutCapabilities).forEach((name) => { + if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]); + }); + refreshLayoutOptions(); + const select = document.getElementById("operator-layout-select"); + const saved = savedLayoutName(); + if (select && Array.from(select.options).some((option) => option.value === saved)) { + select.value = saved; + api.applyLayout(saved, { persist: false }); + } + }; + api.setActiveRig = function setActiveRig(rigId) { + activeRigId = typeof rigId === "string" && rigId ? rigId : null; + const saved = savedLayoutName(); + const select = document.getElementById("operator-layout-select"); + if (select) select.value = Array.from(select.options).some((option) => option.value === saved) ? saved : "compact"; + api.applyLayout(select?.value || saved, { persist: false }); + }; + api.applyLayout = function applyLayout(name, options = {}) { + const requestedName = name in layouts ? name : "compact"; + const requestedLayout = layouts[requestedName]; + const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact"; + const layout = layouts[permittedName]; + document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact"; + if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName); + const details = document.getElementById("advanced-radio-controls"); + if (details) details.open = layout.advanced; + const audioDetails = document.getElementById("audio-controls"); + if (audioDetails) audioDetails.open = layout.audio; + const schedulerDetails = document.getElementById("scheduler-controls"); + if (schedulerDetails) schedulerDetails.open = layout.scheduler; + if (options.navigate && typeof browserWindow.navigateToTab === "function") { + browserWindow.navigateToTab(layout.preferredTab); + } + }; + function installLayoutControls() { + const actions = document.querySelector(".top-bar-actions"); + if (actions && !document.getElementById("operator-layout-select")) { + const label = document.createElement("label"); + label.className = "operator-layout-picker"; + label.innerHTML = 'Operator layout'; + const select = label.querySelector("select"); + if (!select) throw new Error("Operator layout picker has no select element"); + const savedLayout = savedLayoutName(); + actions.insertBefore(label, actions.firstChild); + select.value = savedLayout; + refreshLayoutOptions(); + if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout; + select.addEventListener("change", () => { + api.applyLayout(select.value, { navigate: true }); + }); + api.applyLayout(select.value); + } + const tray = document.querySelector(".controls-tray"); + if (tray && !document.getElementById("advanced-radio-controls")) { + const details = document.createElement("details"); + details.id = "advanced-radio-controls"; + details.className = "advanced-radio-controls"; + details.innerHTML = 'Advanced radio controls
'; + const body = details.querySelector(".advanced-radio-body"); + if (!body) throw new Error("Advanced controls have no body"); + ["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => { + const element = document.getElementById(id); + if (element) body.appendChild(element); + }); + tray.appendChild(details); + api.applyLayout(savedLayoutName(), { persist: false }); + } + } + function installMobileMore() { + const nav = document.querySelector(".tab-bar-nav"); + if (!nav || document.getElementById("mobile-more-btn")) return; + const more = document.createElement("button"); + more.id = "mobile-more-btn"; + more.className = "tab mobile-more-btn"; + more.type = "button"; + more.innerHTML = 'More'; + more.setAttribute("aria-haspopup", "menu"); + more.setAttribute("aria-expanded", "false"); + const menu = document.createElement("div"); + menu.id = "mobile-more-menu"; + menu.className = "mobile-more-menu"; + menu.setAttribute("role", "menu"); + more.setAttribute("aria-controls", menu.id); + const closeMore = (restoreFocus = false) => { + if (!menu.classList.contains("is-open")) return; + menu.classList.remove("is-open"); + more.setAttribute("aria-expanded", "false"); + if (restoreFocus) more.focus(); + }; + api.closeMobileOverlays = closeMore; + ["statistics", "recorder", "settings", "about"].forEach((tabName) => { + const source = nav.querySelector(`[data-tab="${tabName}"]`); + if (!source) return; + const item = document.createElement("button"); + item.type = "button"; + item.setAttribute("role", "menuitem"); + item.dataset.navigateTab = tabName; + item.textContent = source.textContent.trim(); + item.addEventListener("click", () => { + if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName); + closeMore(); + }); + menu.appendChild(item); + }); + more.addEventListener("click", () => { + const open = menu.classList.toggle("is-open"); + more.setAttribute("aria-expanded", String(open)); + if (open) menu.querySelector('[role="menuitem"]')?.focus(); + }); + document.addEventListener("click", (event) => { + if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMore(true); + }); + window.addEventListener("resize", () => { + closeMore(); + }); + window.addEventListener("popstate", () => { + closeMore(); + }); + nav.append(more, menu); + } + function installDecoderPicker() { + const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar"); + if (!bar || document.getElementById("decoder-tab-select")) return; + const select = document.createElement("select"); + select.id = "decoder-tab-select"; + select.className = "decoder-tab-select"; + select.setAttribute("aria-label", "Decoder view"); + const groups = [ + ["Overview", ["overview"]], + ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]], + ["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], + ["Broadcast & images", ["rds", "sat", "wefax"]] + ]; + groups.forEach(([label, ids]) => { + const group = document.createElement("optgroup"); + group.label = label; + ids.forEach((id) => { + const button = bar.querySelector(`[data-subtab="${id}"]`); + if (button) group.appendChild(new Option(button.textContent.trim(), id)); + }); + select.appendChild(group); + }); + select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click()); + bar.insertAdjacentElement("afterend", select); + } + function installDecoderBadges() { + const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar"); + if (!bar) return; + bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => { + const id = button.dataset.subtab; + if (!id) return; + if (id === "overview" || button.querySelector(".decoder-state-dot")) return; + const dot = document.createElement("span"); + dot.className = "decoder-state-dot"; + dot.setAttribute("aria-hidden", "true"); + button.appendChild(dot); + const status = document.getElementById(`${id}-status`); + if (!status) return; + const sync = () => { + const value = status.textContent.toLowerCase(); + const state = /receiv|decod|connected|listening/.test(value) ? "active" : /error|fail|disconnected/.test(value) ? "error" : "idle"; + dot.dataset.state = state; + button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`; + }; + new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true }); + sync(); + }); + } + api.init = function init() { + ensureLiveRegions(); + installLayoutControls(); + installMobileMore(); + installDecoderPicker(); + installDecoderBadges(); + api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary"); + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + api.prepareTabList(bar, "secondary"); + }); + window.addEventListener("unhandledrejection", (event) => { + const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly"; + api.notify(message, { kind: "error" }); + }); + }; + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => { + api.init(); + }, { once: true }); + else api.init(); +})(); + +// src/plugin-runtime.ts +var decoders = /* @__PURE__ */ new Map(); +var queued = /* @__PURE__ */ new Map(); +var MAX_QUEUED_ACTIONS_PER_DECODER = 512; +function enqueue(id, action) { + const actions = queued.get(id) ?? []; + actions.push(action); + if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER); + queued.set(id, actions); +} +function deliver(plugin, action) { + if (action.kind === "message" && plugin.onMessage) { + plugin.onMessage(action.payload); + return true; + } + if (action.kind === "batch" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + if (action.kind === "restore" && plugin.restore) { + plugin.restore(action.payload); + return true; + } + return false; +} +function dispatchOrQueue(id, action) { + const plugin = decoders.get(id); + if (!plugin) { + enqueue(id, action); + return false; + } + if (!deliver(plugin, action)) { + if (action.kind === "batch" && plugin.onMessage) { + for (const message of action.payload) plugin.onMessage(message); + return true; + } + if (action.kind === "restore" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; + } + return false; + } + return true; +} +var runtime = { + registerDecoder(plugin) { + if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`); + const erased = plugin; + decoders.set(plugin.id, erased); + const pending = queued.get(plugin.id) ?? []; + queued.delete(plugin.id); + for (const action of pending) deliver(erased, action); + return () => { + if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); + }; + }, + dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }), + dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }), + restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }), + reset(id) { + const plugin = decoders.get(id); + if (!plugin?.reset) return false; + plugin.reset(); + return true; + }, + resetAll() { + for (const plugin of decoders.values()) plugin.reset?.(); + }, + prune(id) { + const plugin = decoders.get(id); + if (!plugin?.prune) return false; + plugin.prune(); + return true; + }, + clearQueued() { + queued.clear(); + }, + hasDecoder: (id) => decoders.has(id) +}; +window.trxPluginRuntime = runtime; + +// src/leaflet-ais-tracksymbol.ts +(function() { + const leaflet = globalThis.L; + if (!leaflet) return; + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + function finiteAngle(value) { + if (value === null || !Number.isFinite(value)) return null; + const normalized = (value % 360 + 360) % 360; + return normalized; + } + function svgColor(value, fallback) { + const text = value || fallback || ""; + return text.replace(/"/g, """); + } + function buildSymbolHtml(options, zoom) { + const heading = finiteAngle(options.heading); + const course = finiteAngle(options.course); + const angle = heading != null ? heading : course; + const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0; + const sizeBase = Number.isFinite(options.size) ? options.size : 22; + const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; + const size = clamp(sizeBase + zoomBoost, 16, 32); + const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0; + const color = svgColor(options.color, "#ff7559"); + const outline = svgColor(options.outline, "#6b2118"); + const body = angle != null ? `` : ``; + const courseLine = course != null ? `` : ""; + return ``; + } + leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({ + options: { + heading: null, + course: null, + speed: null, + color: "#ff7559", + outline: "#6b2118", + size: 22, + interactive: true, + keyboard: true, + riseOnHover: true + }, + initialize: function(latlng, options) { + const merged = leaflet.Util.extend({}, this.options, options || {}); + merged.icon = leaflet.divIcon({ + className: "trx-ais-track-symbol-icon", + html: "", + iconSize: [merged.size, merged.size], + iconAnchor: [merged.size / 2, merged.size / 2] + }); + leaflet.Marker.prototype.initialize.call(this, latlng, merged); + }, + onAdd: function(map) { + leaflet.Marker.prototype.onAdd.call(this, map); + this._refreshIcon(); + this._boundZoomRefresh = this._refreshIcon.bind(this); + map.on("zoomend", this._boundZoomRefresh); + }, + onRemove: function(map) { + if (this._boundZoomRefresh) { + map.off("zoomend", this._boundZoomRefresh); + this._boundZoomRefresh = null; + } + leaflet.Marker.prototype.onRemove.call(this, map); + }, + setAisState: function(next) { + if ("heading" in next) this.options.heading = next.heading; + if ("course" in next) this.options.course = next.course; + if ("speed" in next) this.options.speed = next.speed; + if ("color" in next) this.options.color = next.color; + if ("outline" in next) this.options.outline = next.outline; + this._refreshIcon(); + return this; + }, + _refreshIcon: function() { + if (!this._icon) return; + const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0; + const html = buildSymbolHtml(this.options, zoom); + this._icon.innerHTML = html; + const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22; + const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; + const size = clamp(sizeBase + zoomBoost, 16, 32); + this._icon.style.width = `${size}px`; + this._icon.style.height = `${size}px`; + this._icon.style.marginLeft = `${-size / 2}px`; + this._icon.style.marginTop = `${-size / 2}px`; + } + }); + leaflet.trxAisTrackSymbol = function(latlng, options) { + const Constructor = leaflet.TrxAisTrackSymbol; + if (!Constructor) throw new Error("AIS track symbol constructor is unavailable"); + return new Constructor(latlng, options); + }; +})(); + +// src/core/geo.ts +function haversineKm(lat1, lon1, lat2, lon2) { + const radiusKm = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; + return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} +function locatorToLatLon(locator) { + const raw = typeof locator === "string" ? locator.trim().toUpperCase() : ""; + if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(raw)) return null; + let lon = -180 + (raw.charCodeAt(0) - 65) * 20 + Number(raw.slice(2, 3)) * 2; + let lat = -90 + (raw.charCodeAt(1) - 65) * 10 + Number(raw.slice(3, 4)); + if (raw.length >= 6) { + lon += (raw.charCodeAt(4) - 65) * (5 / 60) + 2.5 / 60; + lat += (raw.charCodeAt(5) - 65) * (2.5 / 60) + 1.25 / 60; + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; +} +function formatDistanceKm(distanceKm) { + if (!Number.isFinite(distanceKm)) return null; + return distanceKm < 1 ? `${Math.round(distanceKm * 1e3)} m` : `${distanceKm.toFixed(1)} km`; +} +function formatTimeAgo(timestampMs) { + if (!timestampMs) return null; + const seconds = Math.round((Date.now() - timestampMs) / 1e3); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}min ago` : `${hours}h ago`; +} +function latLonToMaidenhead(lat, lon) { + const adjustedLon = lon + 180; + const adjustedLat = lat + 90; + const upperA = "A".charCodeAt(0); + const lowerA = "a".charCodeAt(0); + const field1 = String.fromCharCode(upperA + Math.floor(adjustedLon / 20)); + const field2 = String.fromCharCode(upperA + Math.floor(adjustedLat / 10)); + const square1 = Math.floor(adjustedLon % 20 / 2); + const square2 = Math.floor(adjustedLat % 10); + const sub1 = String.fromCharCode(lowerA + Math.floor(adjustedLon % 2 * 12)); + const sub2 = String.fromCharCode(lowerA + Math.floor(adjustedLat % 1 * 24)); + return `${field1}${field2}${square1}${square2}${sub1}${sub2}`; +} + +// src/core/dom.ts +function escapeHtml(input) { + return String(input).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} + +// src/core/settings.ts +var STORAGE_PREFIX = "trx_"; +function saveSetting(key, value) { + try { + localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); + } catch { + } +} +function loadSetting(key, fallback) { + try { + const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`); + return value === null ? fallback : JSON.parse(value); + } catch { + return fallback; + } +} + +// src/core/decoder-registry.ts +var bridge = window; +var readyCallbacks = []; +var decoderRegistry = []; +function onDecoderRegistryReady(callback) { + if (decoderRegistry.length > 0) callback(); + else readyCallbacks.push(callback); +} +function applyDecoderRegistryVisibility() { + const knownIds = new Set(decoderRegistry.map(({ id }) => id)); + const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]); + document.querySelectorAll( + "#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]" + ).forEach((button) => { + const id = button.dataset.subtab; + if (!id || alwaysShow.has(id) || knownIds.has(id)) return; + button.style.display = "none"; + const panel = document.getElementById(`subtab-${id}`); + if (panel) panel.style.display = "none"; + }); + document.querySelectorAll('[id^="about-dec-"]').forEach((element) => { + const id = element.id.replace("about-dec-", ""); + if (alwaysShow.has(id) || knownIds.has(id)) return; + const row = element.closest("tr"); + if (row) row.style.display = "none"; + }); + document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach((element) => { + const match = /^settings-clear-(.+)-history$/.exec(element.id); + const id = match?.[1]; + if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none"; + }); + document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach((element) => { + const id = element.dataset.decoder; + if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none"; + }); +} +function isDecoderDescriptor(value) { + if (typeof value !== "object" || value === null) return false; + const item = value; + return typeof item.id === "string" && typeof item.label === "string" && typeof item.activation === "string" && Array.isArray(item.active_modes) && item.active_modes.every((mode) => typeof mode === "string") && typeof item.background_decode === "boolean" && typeof item.bookmark_selectable === "boolean"; +} +function decodeRegistry(value) { + if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) { + throw new TypeError("The decoder registry response is malformed"); + } + return value; +} +async function loadDecoderRegistry(onLoaded) { + try { + const response = await fetch("/decoders"); + if (!response.ok) return; + decoderRegistry = decodeRegistry(await response.json()); + bridge.decoderRegistry = decoderRegistry; + readyCallbacks.splice(0).forEach((callback) => { + callback(); + }); + applyDecoderRegistryVisibility(); + onLoaded(); + } catch (error) { + console.error("Failed to fetch decoder registry:", error); + } +} +bridge.decoderRegistry = decoderRegistry; +bridge.onDecoderRegistryReady = onDecoderRegistryReady; + +// src/api/auth.ts +function decodeAuthSession(value) { + if (typeof value !== "object" || value === null) { + throw new TypeError("The authentication response is malformed"); + } + const session = value; + if (typeof session.authenticated !== "boolean") { + throw new TypeError("The authentication response has no authenticated flag"); + } + if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") { + throw new TypeError("The authentication response has an invalid role"); + } + if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") { + throw new TypeError("The authentication response has an invalid auth_disabled flag"); + } + const decoded = { authenticated: session.authenticated }; + if (session.role !== void 0) decoded.role = session.role; + if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled; + return decoded; +} +var authDisabledSession = { + authenticated: true, + role: "control", + auth_disabled: true +}; +async function fetchAuthSession() { + try { + const response = await fetch("/auth/session"); + if (response.status === 404) return authDisabledSession; + if (!response.ok) return { authenticated: false }; + return decodeAuthSession(await response.json()); + } catch (error) { + console.error("Auth check failed:", error); + return { authenticated: false }; + } +} +async function login(passphrase) { + const response = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }) + }); + if (response.status === 404) return authDisabledSession; + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Login failed"); + } + return decodeAuthSession(await response.json()); +} +async function logout() { + const response = await fetch("/auth/logout", { method: "POST" }); + if (response.status !== 404 && !response.ok) throw new Error("Logout failed"); +} + +// src/core/format.ts +function formatDuration(milliseconds) { + const seconds = Math.floor(milliseconds / 1e3); + const days = Math.floor(seconds / 86400); + const hours = Math.floor(seconds % 86400 / 3600); + const minutes = Math.floor(seconds % 3600 / 60); + const remainder = seconds % 60; + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0 || days > 0) parts.push(`${hours}h`); + parts.push(`${minutes}m`, `${remainder}s`); + return parts.join(" "); +} +function formatFrequency(frequencyHz) { + if (!Number.isFinite(frequencyHz)) return "--"; + if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`; + if (frequencyHz >= 1e7) return `${(frequencyHz / 1e6).toFixed(3)} MHz`; + return `${(frequencyHz / 1e3).toFixed(1)} kHz`; +} +function formatFrequencyForStep(frequencyHz, stepHz) { + if (!Number.isFinite(frequencyHz)) return "--"; + if (stepHz >= 1e6) return (frequencyHz / 1e6).toFixed(6); + if (stepHz >= 1e3) return (frequencyHz / 1e3).toFixed(3); + if (stepHz >= 1) return String(Math.round(frequencyHz)); + return formatFrequency(frequencyHz); +} +function formatFrequencyForHumans(frequencyHz) { + if (!Number.isFinite(frequencyHz)) return "--"; + if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`; + if (frequencyHz >= 1e6) return `${(frequencyHz / 1e6).toFixed(3)} MHz`; + if (frequencyHz >= 1e3) return `${(frequencyHz / 1e3).toFixed(3)} kHz`; + return `${Math.round(frequencyHz)} Hz`; +} +function formatWavelength(frequencyHz) { + if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--"; + const meters = 299792458 / frequencyHz; + return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`; +} +function parseFrequencyInput(value, defaultStepHz, mode) { + if (!value) return null; + const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/.exec(value.trim().toLowerCase()); + if (!match?.[1]) return null; + const rawNumber = match[1]; + let frequency = Number.parseFloat(rawNumber.replace(",", ".")); + const unit = match[2] ?? ""; + if (Number.isNaN(frequency)) return null; + if (unit.startsWith("gh") || unit === "g") frequency *= 1e9; + else if (unit.startsWith("mh") || unit === "m") frequency *= 1e6; + else if (unit.startsWith("kh") || unit === "k") frequency *= 1e3; + else if (!unit) { + const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); + if (mode.toUpperCase() === "WFM") { + if (hasDecimalSeparator && frequency >= 50 && frequency < 200) { + return Math.round(frequency * 1e6); + } + if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) { + return Math.round(frequency / 10 * 1e6); + } + } + if (defaultStepHz >= 1e6) frequency *= 1e6; + else if (defaultStepHz >= 1e3) frequency *= 1e3; + else if (defaultStepHz < 1) { + if (frequency < 1e3) frequency *= 1e6; + else if (frequency < 1e6) frequency *= 1e3; + } + } + return Math.round(frequency); +} +function formatByteSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1048576).toFixed(1)} MB`; +} + +// src/core/cbor.ts +var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; +function decodeUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + const widths = { 24: 1, 25: 2, 26: 4, 27: 8 }; + const width = widths[additional]; + if (width === void 0) throw new Error("Unsupported CBOR additional info"); + if (offset + width > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += width; + if (additional === 24) return bytes[offset] ?? 0; + if (additional === 25) return view.getUint16(offset); + if (additional === 26) return view.getUint32(offset); + const numeric = Number(view.getBigUint64(offset)); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; +} +function decodeFloat16(bits) { + const sign = bits & 32768 ? -1 : 1; + const exponent = bits >> 10 & 31; + const fraction = bits & 1023; + if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024); + if (exponent === 31) return fraction === 0 ? sign * Infinity : Number.NaN; + return sign * 2 ** (exponent - 15) * (1 + fraction / 1024); +} +function decodeItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + if (initial === void 0) throw new Error("CBOR payload truncated"); + const major = initial >> 5; + const additional = initial & 31; + if (major === 0) return decodeUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeUint(view, bytes, state, additional); + if (major === 2 || major === 3) { + const length = decodeUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.subarray(state.offset, state.offset + length); + state.offset += length; + if (major === 2) return Array.from(chunk); + return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeUint(view, bytes, state, additional); + return Array.from({ length }, () => decodeItem(view, bytes, state)); + } + if (major === 5) { + const length = decodeUint(view, bytes, state, additional); + const value = {}; + for (let index = 0; index < length; index += 1) { + const key = decodeItem(view, bytes, state); + if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean" && key !== null) { + throw new Error("Unsupported composite CBOR map key"); + } + value[String(key)] = decodeItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeUint(view, bytes, state, additional); + return decodeItem(view, bytes, state); + } + if (major === 7) { + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + if (additional === 23) return void 0; + const widths = { 25: 2, 26: 4, 27: 8 }; + const width = widths[additional]; + if (width === void 0) throw new Error("Unsupported CBOR major type"); + if (state.offset + width > bytes.length) throw new Error("CBOR payload truncated"); + const offset = state.offset; + state.offset += width; + if (additional === 25) return decodeFloat16(view.getUint16(offset)); + if (additional === 26) return view.getFloat32(offset); + return view.getFloat64(offset); + } + throw new Error("Unsupported CBOR major type"); +} +function decodeCbor(buffer) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const state = { offset: 0 }; + const value = decodeItem(view, bytes, state); + if (state.offset !== bytes.length) throw new Error("Unexpected trailing bytes in CBOR payload"); + return value; +} + +// src/features/navigation/routes.ts +var TAB_ORDER = [ + "main", + "bookmarks", + "digital-modes", + "map", + "statistics", + "recorder", + "settings", + "about" +]; +var TAB_PATHS = { + main: "/", + bookmarks: "/bookmarks", + "digital-modes": "/digital-modes", + map: "/map", + statistics: "/statistics", + recorder: "/recorder", + settings: "/settings", + about: "/about" +}; +function normalizeTabPath(pathname) { + const raw = pathname.length > 0 ? pathname : "/"; + return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/"; +} +function tabFromPath(pathname) { + const normalized = normalizeTabPath(pathname); + const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized); + return match ? match[0] : "main"; +} +function updateTabHistory(name, replace = false) { + const targetPath = TAB_PATHS[name]; + if (normalizeTabPath(window.location.pathname) === targetPath) return; + const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; + if (replace) window.history.replaceState({}, "", nextUrl); + else window.history.pushState({}, "", nextUrl); +} + +// src/features/radio/auto-bandwidth.ts +function clampPercent(value) { + const numeric = Number(value) || 0; + return Math.max(0, Math.min(100, numeric)) / 100; +} +function estimateOccupiedBandwidth(data, centerHz, mode, limits, interference = {}) { + if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins) || !Number.isFinite(centerHz)) return null; + const bins = Array.from(data.bins); + if (bins.length < 3) return null; + const maxIdx = bins.length - 1; + const hzPerBin = data.sample_rate / maxIdx; + const fullLoHz = data.center_hz - data.sample_rate / 2; + const centerIdx = Math.max( + 1, + Math.min(maxIdx - 1, Math.round((centerHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const normalizedMode = mode.toUpperCase(); + const [defaultBw, minBw, maxBw, stepBw] = limits; + const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode) ? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0; + const isWfm = normalizedMode === "WFM"; + const smoothRadius = isWfm ? 3 : 1; + const smoothed = bins.map((_, index) => { + let sum = 0; + let count = 0; + for (let adjacent = Math.max(0, index - smoothRadius); adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) { + sum += bins[adjacent] ?? 0; + count += 1; + } + return sum / count; + }); + const sorted = [...bins].sort((left, right) => left - right); + const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity; + const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); + const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; + const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins)); + const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins)); + let peak = -Infinity; + for (let index = searchLo; index <= searchHi; index += 1) { + peak = Math.max(peak, smoothed[index] ?? -Infinity); + } + const snr = peak - noise; + if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw; + const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); + const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12e3 : stepBw) / hzPerBin)); + const occupiedExtent = (direction, limitBins) => { + let lastOccupied = centerIdx; + let gap = 0; + for (let offset = 0; offset <= limitBins; offset += 1) { + const index = centerIdx + direction * offset; + if (index <= 0 || index >= maxIdx) break; + if ((smoothed[index] ?? -Infinity) >= threshold) { + lastOccupied = index; + gap = 0; + } else if (++gap > allowedGap) break; + } + return Math.abs(lastOccupied - centerIdx) * hzPerBin; + }; + let rawBw = oneSided !== 0 ? occupiedExtent(oneSided, maxSpanBins) : 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins)); + rawBw *= isWfm ? 1.08 : 1.12; + if (isWfm) { + const aci = clampPercent(interference.aci); + const cci = clampPercent(interference.cci); + const aciCap = maxBw - (maxBw - minBw) * aci; + const cciFloor = minBw + (defaultBw - minBw) * 0.65; + const cciCap = maxBw - (maxBw - cciFloor) * cci; + rawBw = Math.min(rawBw, aciCap, cciCap); + } + const clamped = Math.max(minBw, Math.min(maxBw, rawBw)); + return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); +} + +// src/features/spectrum/math.ts +function isNumericBins(value) { + return Array.isArray(value) ? value.every((item) => typeof item === "number") : ArrayBuffer.isView(value) && !(value instanceof DataView); +} +var base64Lookup = new Uint8Array(128).fill(255); +var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +for (let index = 0; index < base64Alphabet.length; index += 1) { + base64Lookup[base64Alphabet.charCodeAt(index)] = index; +} +var spectrumBinBuffer = new Int8Array(0); +function decodeBase64Int8(value) { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1; + const outputLength = end * 3 >>> 2; + if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength); + let outputIndex = 0; + for (let index = 0; index < end; ) { + const sextets = [0, 0, 0, 0]; + for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) { + const code = value.charCodeAt(index); + const decoded = code < base64Lookup.length ? base64Lookup[code] : void 0; + if (decoded === void 0 || decoded === 255) throw new TypeError("Invalid base64 spectrum frame"); + sextets[offset] = decoded; + } + const packed = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0); + if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 255; + if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 255; + if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 255; + } + return spectrumBinBuffer; +} +var nthScratch = new Float64Array(0); +function nthElement(values, target) { + if (values.length === 0 || target < 0 || target >= values.length) return null; + if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length); + for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0; + let low = 0; + let high = values.length - 1; + while (low < high) { + const pivot = nthScratch[low + (high - low >> 1)] ?? 0; + let left = low; + let right = high; + while (left <= right) { + while ((nthScratch[left] ?? Infinity) < pivot) left += 1; + while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1; + if (left <= right) { + const temporary = nthScratch[left] ?? 0; + nthScratch[left] = nthScratch[right] ?? 0; + nthScratch[right] = temporary; + left += 1; + right -= 1; + } + } + if (right < target) low = left; + if (target < left) high = right; + } + return nthScratch[target] ?? null; +} +function estimateNoiseFloorDb(bins) { + if (!isNumericBins(bins) || bins.length === 0) return null; + return nthElement(bins, Math.floor(bins.length * 0.15)); +} + +// src/plugin-loader.ts +var pluginGroups = { + "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], + "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], + map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], + statistics: ["/map-core.js"], + bookmarks: ["/bookmarks.js"], + recorder: [], + settings: ["/vchan.js", "/scheduler.js"] +}; +var loaded = /* @__PURE__ */ new Set(); +var loading = /* @__PURE__ */ new Map(); +async function loadPlugin(path) { + if (loaded.has(path)) return; + const pending = loading.get(path); + if (pending) return pending; + const request = import(path).then(() => { + loaded.add(path); + loading.delete(path); + }).catch((error) => { + loading.delete(path); + throw new Error(`Failed to load plugin module: ${path}`, { cause: error }); + }); + loading.set(path, request); + return request; +} +async function loadPlugins(group) { + if (!(group in pluginGroups)) return; + for (const path of pluginGroups[group]) await loadPlugin(path); +} +async function loadEagerPlugins() { + await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins)); +} +async function loadPluginsForTab(tab) { + await loadPlugins(tab); +} + +// src/api/client.ts +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isRigSnapshot(value) { + if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) { + return false; + } + const { info, status } = value; + return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean"; +} +function isRigListResponse(value) { + return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every( + (rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean" + ); +} + +// src/app.ts +function requiredElement(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing required application element #${id}`); + return element; +} +function isFiniteNumber(value) { + return typeof value === "number" && Number.isFinite(value); +} +function primitiveString(value) { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : ""; +} +function isRecord2(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function parseJsonUnknown(source) { + return JSON.parse(source); +} +function messageEventData(event) { + const data = event.data; + if (typeof data !== "string") throw new TypeError("Expected a text event payload"); + return data; +} +async function responseJsonUnknown(response) { + return await response.json(); +} +function isAppUpdate(value) { + return isRecord2(value) && typeof value.clients === "number" && typeof value.audio_clients === "number" && typeof value.rigctl_clients === "number" && Array.isArray(value.remotes) && value.remotes.every((remote) => typeof remote === "string") && typeof value.show_sdr_gain_control === "boolean" && typeof value.initial_map_zoom === "number" && typeof value.spectrum_coverage_margin_hz === "number" && typeof value.spectrum_usable_span_ratio === "number" && typeof value.bandplan_enabled === "boolean" && typeof value.bandplan_region === "string" && typeof value.decode_history_retention_min === "number" && typeof value.server_connected === "boolean" && isRigSnapshot(value); +} +function isAudioStreamInfo(value) { + return isRecord2(value) && typeof value.sample_rate === "number" && typeof value.channels === "number"; +} +function isRecorderActiveList(value) { + return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.rig_id === "string" && typeof entry.path === "string" && typeof entry.started_at === "number"); +} +function isRecorderFileList(value) { + return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.name === "string" && typeof entry.size === "number"); +} +function isRdsData(value) { + return isRecord2(value); +} +function isVchanRdsEntry(value) { + return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number"); +} +void loadDecoderRegistry(refreshOperatorLayoutCapabilities); +var authRole = null; +var authEnabled = true; +async function checkAuthStatus() { + return fetchAuthSession(); +} +async function authLogin(passphrase) { + return login(passphrase); +} +async function authLogout() { + try { + await logout(); + authRole = null; + disconnect(); + setDecodeHistoryOverlayVisible(false); + requiredElement("content").style.display = "none"; + requiredElement("loading").style.display = "none"; + requiredElement("auth-passphrase").value = ""; + updateAuthUI(); + const authStatus = await checkAuthStatus(); + const allowGuest = authStatus.role === "rx"; + showAuthGate(allowGuest); + } catch (e) { + console.error("Logout failed:", e); + showAuthError("Logout failed"); + } +} +function showAuthGate(allowGuest = false) { + if (!authEnabled) return; + setDecodeHistoryOverlayVisible(false); + requiredElement("loading").style.display = "none"; + requiredElement("content").style.display = "none"; + const authGate = requiredElement("auth-gate"); + authGate.style.display = "flex"; + authGate.style.flexDirection = "column"; + authGate.style.justifyContent = "center"; + authGate.style.alignItems = "stretch"; + const signalVisualBlock = document.querySelector(".signal-visual-block"); + if (signalVisualBlock) { + signalVisualBlock.style.display = "none"; + } + document.querySelectorAll(".tab-panel").forEach((panel) => { + panel.style.display = "none"; + }); + const guestBtn2 = document.getElementById("auth-guest-btn"); + if (guestBtn2) { + guestBtn2.style.display = allowGuest ? "block" : "none"; + } + document.querySelectorAll(".tab-bar .tab").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.tab === "main"); + }); + syncTopBarAccess(); +} +function hideAuthGate() { + const authGate = requiredElement("auth-gate"); + authGate.style.display = "none"; + requiredElement("loading").style.display = "block"; + const signalVisualBlock = document.querySelector(".signal-visual-block"); + if (signalVisualBlock) { + signalVisualBlock.style.display = ""; + } + document.querySelectorAll(".tab-panel").forEach((panel) => { + panel.style.display = "none"; + }); + document.querySelectorAll(".tab-bar .tab").forEach((btn) => { + btn.classList.remove("active"); + }); + navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true }); + syncTopBarAccess(); +} +function showAuthError(msg) { + const el = requiredElement("auth-error"); + el.textContent = msg; + el.style.display = "block"; + setTimeout(() => { + el.style.display = "none"; + }, 5e3); +} +function updateAuthUI() { + const badge = document.getElementById("auth-badge"); + const badgeRole = document.getElementById("auth-role-badge"); + const headerAuthBtn2 = document.getElementById("header-auth-btn"); + if (!authEnabled) { + if (badge) badge.style.display = "none"; + if (headerAuthBtn2) headerAuthBtn2.style.display = "none"; + syncTopBarAccess(); + return; + } + if (authRole) { + if (badge) badge.style.display = "block"; + if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; + if (headerAuthBtn2) { + headerAuthBtn2.textContent = "Logout"; + headerAuthBtn2.style.display = "block"; + } + } else { + if (badge) badge.style.display = "none"; + if (headerAuthBtn2) { + headerAuthBtn2.textContent = "Login"; + headerAuthBtn2.style.display = "block"; + } + } + syncTopBarAccess(); +} +function applyAuthRestrictions() { + if (!authRole) return; + if (authRole === "rx") { + const pttBtn2 = document.getElementById("ptt-btn"); + const powerBtn2 = document.getElementById("power-btn"); + const lockBtn2 = document.getElementById("lock-btn"); + const freqInput = document.getElementById("freq"); + const centerFreqInput = document.getElementById("center-freq"); + const modeSelect = document.getElementById("mode"); + const txLimitInput2 = document.getElementById("tx-limit"); + const txLimitBtn2 = document.getElementById("tx-limit-btn"); + const txAudioBtn2 = document.getElementById("tx-audio-btn"); + const txLimitRow2 = document.getElementById("tx-limit-row"); + const jogUp = document.getElementById("jog-up"); + const jogDown = document.getElementById("jog-down"); + const jogButtons = document.querySelectorAll(".jog-step button"); + const vfoButtons = document.querySelectorAll("#vfo-picker button"); + if (pttBtn2) pttBtn2.disabled = true; + if (powerBtn2) powerBtn2.disabled = true; + if (lockBtn2) lockBtn2.disabled = true; + if (txAudioBtn2) txAudioBtn2.disabled = true; + if (txLimitBtn2) txLimitBtn2.disabled = true; + if (freqInput) freqInput.disabled = true; + if (centerFreqInput) centerFreqInput.disabled = true; + if (modeSelect) modeSelect.disabled = true; + if (txLimitInput2) txLimitInput2.disabled = true; + vfoButtons.forEach((btn) => btn.disabled = true); + const jogWheel2 = document.getElementById("jog-wheel"); + if (jogUp) jogUp.disabled = true; + if (jogDown) jogDown.disabled = true; + if (jogWheel2) jogWheel2.style.opacity = "0.5"; + jogButtons.forEach((btn) => btn.disabled = true); + const pluginToggleBtns = [ + "ft8-decode-toggle-btn", + "ft4-decode-toggle-btn", + "ft2-decode-toggle-btn", + "wspr-decode-toggle-btn", + "lrpt-decode-toggle-btn", + "hf-aprs-decode-toggle-btn", + "cw-auto", + "settings-clear-ais-history", + "settings-clear-vdes-history", + "settings-clear-aprs-history", + "settings-clear-hf-aprs-history", + "settings-clear-cw-history", + "settings-clear-ft8-history", + "settings-clear-ft4-history", + "settings-clear-ft2-history", + "settings-clear-wspr-history", + "settings-clear-sat-history", + "header-rec-btn", + "recorder-start-btn", + "recorder-stop-btn" + ]; + pluginToggleBtns.forEach((id) => { + const btn = document.getElementById(id); + if (btn instanceof HTMLButtonElement) { + btn.disabled = true; + } else if (btn instanceof HTMLInputElement && btn.type === "checkbox") { + btn.disabled = true; + } + }); + if (txLimitRow2) txLimitRow2.style.opacity = "0.5"; + } +} +function applyCapabilities(caps) { + if (!caps) return; + lastHasTx = !!caps.tx; + if (signalVisualBlockEl) signalVisualBlockEl.style.display = ""; + const pttBtn2 = document.getElementById("ptt-btn"); + const txPowerCol = document.getElementById("tx-power-col"); + const txMetersRow = document.getElementById("tx-meters"); + const txAudioBtn2 = document.getElementById("tx-audio-btn"); + const txVolSlider2 = document.getElementById("tx-vol"); + const txVolControl = txVolSlider2 ? txVolSlider2.closest(".vol-label") : null; + const hasPowerControl = !caps.filter_controls; + if (txPowerCol) { + txPowerCol.style.display = caps.tx || hasPowerControl || caps.lockable ? "" : "none"; + const label = txPowerCol.querySelector(".label span"); + if (label) { + label.textContent = caps.tx && hasPowerControl ? "Transmit / Power" : caps.tx ? "Transmit / Tuning" : hasPowerControl ? "Power / Tuning" : "Tuning"; + } + } + if (pttBtn2) pttBtn2.style.display = caps.tx ? "" : "none"; + if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none"; + if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none"; + if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none"; + if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none"; + if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; + if (!caps.tx && typeof stopTxAudio === "function" && txActive) { + void stopTxAudio(); + } + const txLimitRow2 = document.getElementById("tx-limit-row"); + if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none"; + const vfoRow = document.getElementById("vfo-row"); + if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none"; + document.querySelectorAll(".full-row.label-below-row").forEach((row) => { + const label = row.querySelector(".label span"); + if (label && label.textContent === "Signal") { + row.style.display = caps.signal_meter && !caps.filter_controls ? "" : "none"; + } + }); + const spectrumPanel = document.getElementById("spectrum-panel"); + const centerFreqField = document.getElementById("center-freq-field"); + if (spectrumPanel) { + if (caps.filter_controls) { + spectrumPanel.style.display = ""; + setSignalSplitControlVisible(true); + if (centerFreqField) centerFreqField.style.display = ""; + startSpectrumStreaming(); + } else { + spectrumPanel.style.display = "none"; + setSignalSplitControlVisible(false); + if (centerFreqField) centerFreqField.style.display = "none"; + stopSpectrumStreaming(); + resizeHeaderSignalCanvas(); + scheduleOverviewDraw(); + } + scheduleSpectrumLayout(); + } + if (!caps.filter_controls) { + sdrSquelchSupported = false; + } + updateSdrSquelchControlVisibility(); + window.trx?.modules?.vchan?.applyCapabilities(caps); +} +var freqEl = requiredElement("freq"); +var centerFreqEl = requiredElement("center-freq"); +var wavelengthEl = requiredElement("wavelength"); +var sigStrengthEl = requiredElement("sig-strength"); +var modeEl = requiredElement("mode"); +var bandLabel = document.getElementById("band-label"); +var powerBtn = requiredElement("power-btn"); +var powerHint = requiredElement("power-hint"); +var vfoPicker = requiredElement("vfo-picker"); +var signalBar = requiredElement("signal-bar"); +var signalValue = requiredElement("signal-value"); +var pttBtn = requiredElement("ptt-btn"); +var txLimitInput = requiredElement("tx-limit"); +var txLimitBtn = requiredElement("tx-limit-btn"); +var txLimitRow = requiredElement("tx-limit-row"); +var lockBtn = requiredElement("lock-btn"); +var txMeters = requiredElement("tx-meters"); +var pwrBar = requiredElement("pwr-bar"); +var pwrValue = requiredElement("pwr-value"); +var swrBar = requiredElement("swr-bar"); +var swrValue = requiredElement("swr-value"); +var loadingEl = requiredElement("loading"); +var contentEl = requiredElement("content"); +var serverSubtitle = requiredElement("server-subtitle"); +var rigSubtitle = requiredElement("rig-subtitle"); +var ownerSubtitle = document.getElementById("owner-subtitle"); +var locationSubtitle = requiredElement("location-subtitle"); +var loadingTitle = requiredElement("loading-title"); +var loadingSub = requiredElement("loading-sub"); +var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); +var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); +var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); +var connLostOverlayEl = document.getElementById("conn-lost-overlay"); +var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); +var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); +var overviewCanvas = requiredElement("overview-canvas"); +var signalOverlayCanvas = requiredElement("signal-overlay-canvas"); +var spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; +var overviewGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) : null; +var signalOverlayGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) : null; +var signalVisualBlockEl = document.querySelector(".signal-visual-block"); +var signalSplitControlEl = document.getElementById("signal-split-control"); +var signalSplitSliderEl = document.getElementById("signal-split-slider"); +var signalSplitValueEl = document.getElementById("signal-split-value"); +var overviewPeakHoldEl = document.getElementById("overview-peak-hold"); +var themeToggleBtn = document.getElementById("theme-toggle"); +var headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); +var headerRigSummary = document.getElementById("header-rig-summary"); +var headerStylePickSelect = document.getElementById("header-style-pick-select"); +var rdsPsOverlay = document.getElementById("rds-ps-overlay"); +var tabMainEl = document.getElementById("tab-main"); +var aboutServerVerEl = null; +var aboutServerBuildDateEl = null; +var aboutServerAddrEl = null; +var aboutServerCallEl = null; +var aboutServerLocationEl = null; +var aboutRigInfoEl = null; +var aboutRigAccessEl = null; +var aboutModesEl = null; +var aboutVfosEl = null; +var aboutActiveRigEl = null; +var aboutAudioCodecEl = null; +var aboutAudioSamplerateEl = null; +var aboutAudioChannelsEl = null; +var aboutAudioBitrateEl = null; +var aboutAudioFrameEl = null; +var aboutAudioRxEl = null; +var aboutAudioStreamsEl = null; +var aboutPskreporterEl = null; +var aboutAprsIsEl = null; +var aboutRigctlClientsEl = null; +var aboutRigctlEndpointEl = null; +var aboutClientsEl = null; +var _aboutElsResolved = false; +function _resolveAboutEls() { + if (_aboutElsResolved) return; + aboutServerVerEl = document.getElementById("about-server-ver"); + if (!aboutServerVerEl) return; + _aboutElsResolved = true; + aboutServerBuildDateEl = document.getElementById("about-server-build-date"); + aboutServerAddrEl = document.getElementById("about-server-addr"); + aboutServerCallEl = document.getElementById("about-server-call"); + aboutServerLocationEl = document.getElementById("about-server-location"); + aboutRigInfoEl = document.getElementById("about-rig-info"); + aboutRigAccessEl = document.getElementById("about-rig-access"); + aboutModesEl = document.getElementById("about-modes"); + aboutVfosEl = document.getElementById("about-vfos"); + aboutActiveRigEl = document.getElementById("about-active-rig"); + aboutAudioCodecEl = document.getElementById("about-audio-codec"); + aboutAudioSamplerateEl = document.getElementById("about-audio-samplerate"); + aboutAudioChannelsEl = document.getElementById("about-audio-channels"); + aboutAudioBitrateEl = document.getElementById("about-audio-bitrate"); + aboutAudioFrameEl = document.getElementById("about-audio-frame"); + aboutAudioRxEl = document.getElementById("about-audio-rx"); + aboutAudioStreamsEl = document.getElementById("about-audio-streams"); + aboutPskreporterEl = document.getElementById("about-pskreporter"); + aboutAprsIsEl = document.getElementById("about-aprs-is"); + aboutRigctlClientsEl = document.getElementById("about-rigctl-clients"); + aboutRigctlEndpointEl = document.getElementById("about-rigctl-endpoint"); + aboutClientsEl = document.getElementById("about-clients"); +} +var cwAutoEl = document.getElementById("cw-auto"); +var cwWpmEl = document.getElementById("cw-wpm"); +var cwToneEl = document.getElementById("cw-tone"); +var overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2e3)); +var decodeHistoryRetentionMin = 24 * 60; +var _decoderToggles = {}; +function _ensureDecoderToggles() { + if (decoderRegistry.length === 0) return; + for (const d of decoderRegistry) { + if (d.activation !== "toggle") continue; + const key = d.id.replace(/-/g, "_") + "_decode_enabled"; + if (_decoderToggles[key]) continue; + const el = document.getElementById(d.id + "-decode-toggle-btn"); + if (el) _decoderToggles[key] = { el, last: null, label: d.label }; + } +} +function syncDecoderToggle(entry, enabled, label) { + if (!entry.el || entry.last === enabled) return; + entry.last = enabled; + entry.el.dataset.enabled = enabled ? "true" : "false"; + entry.el.textContent = enabled ? `Disable ${label}` : `Enable ${label}`; + entry.el.style.borderColor = enabled ? "#00d17f" : ""; + entry.el.style.color = enabled ? "#00d17f" : ""; +} +var _aboutDecIds = [ + "about-dec-ft8", + "about-dec-ft4", + "about-dec-ft2", + "about-dec-wspr", + "about-dec-cw", + "about-dec-aprs", + "about-dec-lrpt" +]; +var _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); +function _resolveAboutDecEls() { + if (_aboutDecEls[0]?.el) return; + for (let i = 0; i < _aboutDecIds.length; i++) { + const entry = _aboutDecEls[i]; + const id = _aboutDecIds[i]; + if (entry && id) entry.el = document.getElementById(id); + } +} +function syncAboutDecoder(idx, enabled) { + const entry = _aboutDecEls[idx]; + if (!entry || !entry.el || entry.last === enabled) return; + entry.last = enabled; + entry.el.textContent = enabled ? "Active" : "Off"; + entry.el.className = enabled ? "about-status-on" : "about-status-off"; +} +var primaryRds = null; +var vchanRdsById = /* @__PURE__ */ new Map(); +var vchanSignalDbById = /* @__PURE__ */ new Map(); +var rdsOverlayEntries = []; +function currentDecodeHistoryRetentionMs() { + const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || 24 * 60)); + return minutes * 60 * 1e3; +} +window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; +window.applyDecodeHistoryRetention = function() { + for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) { + window.trxPluginRuntime.prune(decoder); + } +}; +function syncTopBarAccess() { + const loggedOut = authEnabled && !authRole; + const tabBar = document.getElementById("tab-bar"); + const rigSwitch = document.querySelector(".header-rig-switch"); + if (tabBar) tabBar.style.display = ""; + document.querySelectorAll(".tab-bar .tab").forEach((btn) => { + const isMain = btn.dataset.tab === "main"; + btn.style.display = !loggedOut || isMain ? "" : "none"; + btn.disabled = false; + }); + if (rigSwitch) { + rigSwitch.style.display = loggedOut ? "none" : ""; + } + if (headerRigSwitchSelect) { + headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; + } +} +var overviewDrawPending = false; +function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") { + if (!decodeHistoryOverlayEl) return; + if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title; + if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || ""; + decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible); +} +function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) { + if (!connLostOverlayEl) return; + if (connLostOverlayTitleEl) connLostOverlayTitleEl.textContent = title; + if (connLostOverlaySubEl) connLostOverlaySubEl.textContent = sub; + connLostOverlayEl.classList.toggle("conn-lost-fullscreen", fullscreen); + connLostOverlayEl.classList.toggle("is-hidden", !visible); +} +var decodeHistoryReplayActive = false; +var decodeMapSyncPending = false; +function markDecodeMapSyncPending() { + decodeMapSyncPending = true; +} +function flushDeferredDecodeMapSync() { + if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap) return; + decodeMapSyncPending = false; + scheduleUiFrameJob("decode-map-maintenance", () => { + window.trx.modules.map?.pruneMapHistory(); + }); +} +function setDecodeHistoryReplayActive(active) { + decodeHistoryReplayActive = !!active; + if (!decodeHistoryReplayActive) { + flushDeferredDecodeMapSync(); + } +} +function decodeHistoryMapRenderingDeferred() { + return decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap; +} +var lastSpectrumData = null; +window.lastSpectrumData = null; +var lastTxEn = null; +var lastHasTx = true; +var lastRendered = null; +var prevRenderData = {}; +var hintTimer = null; +var sigMeasuring = false; +var sigLastSUnits = null; +var sigLastDbm = null; +var SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; +var sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); +function sigUnit(u) { + return `${u}`; +} +function formatSigStrength(dbm) { + if (dbm === null || !isFiniteNumber(dbm)) return "--"; + const unit = SIG_STRENGTH_UNITS[sigStrengthUnitIdx] || "dBFS"; + if (unit === "S") return formatSignal(dbmToSUnits(dbm)); + if (unit === "dBm") return `${dbm.toFixed(1)} ${sigUnit("dBm")}`; + if (unit === "dBf") { + const dbf = dbm + 107; + return `${dbf.toFixed(1)} ${sigUnit("dBf")}`; + } + const dbfs = Math.max(-140, Math.min(0, dbm)); + return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`; +} +function refreshSigStrengthDisplay() { + if (!sigStrengthEl) return; + sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm); +} +if (sigStrengthEl) { + sigStrengthEl.addEventListener("click", () => { + sigStrengthUnitIdx = (sigStrengthUnitIdx + 1) % SIG_STRENGTH_UNITS.length; + saveSetting("sigStrengthUnit", sigStrengthUnitIdx); + refreshSigStrengthDisplay(); + }); +} +var sigMeasureTimer = null; +var sigMeasureLastTickMs = 0; +var sigMeasureAccumMs = 0; +var sigMeasureWeighted = 0; +var sigMeasurePeak = null; +var lastFreqHz = null; +window.lastFreqHz = null; +var centerFreqDirty = false; +var jogUnit = loadSetting("jogUnit", 1e3); +var jogMult = loadSetting("jogMult", 1); +var jogStep = Math.max(Math.round(jogUnit / jogMult), 1); +var minFreqStepHz = 1; +var lastModeName = ""; +var lastWfmCci = 0; +var lastWfmAci = 0; +var VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; +function vfoColor(idx) { + if (idx < VFO_COLORS.length) return VFO_COLORS[idx] ?? VFO_COLORS[0] ?? "var(--accent-green)"; + const hue = idx * 137 % 360; + return `hsl(${hue}, 70%, 55%)`; +} +var jogAngle = 0; +var lastClientCount = null; +var lastLocked = false; +var sdrSquelchSupported = false; +var previousTuneState = null; +function savePreviousTuneState() { + previousTuneState = { + freqHz: lastFreqHz, + bandwidthHz: currentBandwidthHz, + mode: modeEl ? modeEl.value : "", + centerHz: lastSpectrumData ? Number(lastSpectrumData.center_hz) : null + }; +} +async function restorePreviousTuneState() { + if (!previousTuneState) { + showHint("No previous state", 1500); + return; + } + const saved = previousTuneState; + savePreviousTuneState(); + if (saved.mode && modeEl && modeEl.value !== saved.mode) { + modeEl.value = saved.mode; + await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`); + updateWfmControls(); + } + if (isFiniteNumber(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { + currentBandwidthHz = saved.bandwidthHz; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(currentBandwidthHz); + await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`); + } + if (saved.freqHz !== null && isFiniteNumber(saved.freqHz)) { + setRigFrequency(saved.freqHz); + } + if (isFiniteNumber(saved.centerHz)) { + await postPath(`/set_center_freq?hz=${saved.centerHz}`); + } + showHint("Restored previous", 1500); +} +var lastRigIds = []; +var lastRigDisplayNames = {}; +var lastActiveRigId = null; +var rigSwitchInProgress = false; +var lastCityLabel = ""; +var sseSessionId = null; +var originalTitle = document.title; +var savedTheme = loadSetting("theme", null); +function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; +} +function updateDocumentTitle(rds = null) { + const freqHz = activeChannelFreqHz(); + if (!isFiniteNumber(freqHz)) { + document.title = originalTitle; + return; + } + const parts = [formatFrequency(freqHz)]; + const ps = rds?.program_service; + if (ps && ps.length > 0) { + parts.push(ps); + } + const rigName = lastActiveRigId && lastRigDisplayNames[lastActiveRigId] || lastActiveRigId || ""; + if (rigName) parts.push(rigName); + if (lastCityLabel) parts.push(lastCityLabel); + parts.push(originalTitle); + document.title = parts.join(" - "); +} +function setTheme(theme) { + const next = theme === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", next); + saveSetting("theme", next); + if (themeToggleBtn) { + themeToggleBtn.textContent = next === "dark" ? "☀️ Light" : "🌙 Dark"; + themeToggleBtn.title = next === "dark" ? "Switch to light mode" : "Switch to dark mode"; + } + if (typeof trxClearCssColorCache === "function") trxClearCssColorCache(); + invalidateBookmarkColors(); +} +function invalidateBookmarkColors() { + const bookmarks = window.trx?.modules.bookmarks; + if (!bookmarks) return; + bookmarks.invalidateColors(); + void getComputedStyle(document.documentElement).getPropertyValue("--bg"); + const colorMap = bmCategoryColorMap(); + const ref = bookmarks.overlayList; + document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { + const bm = ref.find((b) => b.id === chip.dataset.bmId); + if (!bm) return; + const col = colorMap[bm.category || ""] || "#66d9ef"; + chip.style.setProperty("--bm-cat-bg", col); + chip.style.setProperty("--bm-cat-fg", bmContrastFg(col)); + }); + for (const id of ["spectrum-bookmark-axis", "spectrum-bookmark-side-left", "spectrum-bookmark-side-right"]) { + const el = document.getElementById(id); + if (el) el.dataset.bmKey = ""; + } + try { + if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); + } catch (_) { + } +} +var CANVAS_PALETTE = { + original: { + dark: { + bg: "#0a0f18", + spectrumLine: "#00e676", + spectrumFill: "rgba(0,230,118,0.10)", + spectrumGrid: "rgba(255,255,255,0.06)", + spectrumLabel: "rgba(180,200,220,0.45)", + waveformLine: "rgba(94,234,212,0.92)", + waveformPeak: "rgba(251,191,36,0.88)", + waveformGrid: "rgba(148,163,184,0.12)", + waveformLabel: "rgba(203,213,225,0.72)", + waterfallHue: [225, 30], + waterfallSat: 88, + waterfallLight: [16, 68], + waterfallAlpha: [0.28, 0.86] + }, + light: { + bg: "#eef3fb", + spectrumLine: "#007a47", + spectrumFill: "rgba(0,110,70,0.12)", + spectrumGrid: "rgba(0,30,80,0.10)", + spectrumLabel: "rgba(30,50,90,0.55)", + waveformLine: "rgba(17,94,89,0.95)", + waveformPeak: "rgba(217,119,6,0.9)", + waveformGrid: "rgba(71,85,105,0.14)", + waveformLabel: "rgba(51,65,85,0.72)", + waterfallHue: [210, 35], + waterfallSat: 82, + waterfallLight: [92, 40], + waterfallAlpha: [0.42, 0.8] + } + }, + arctic: { + dark: { + bg: "#1e2530", + spectrumLine: "#88c0d0", + spectrumFill: "rgba(136,192,208,0.12)", + spectrumGrid: "rgba(216,222,233,0.08)", + spectrumLabel: "rgba(216,222,233,0.55)", + waveformLine: "rgba(136,192,208,0.92)", + waveformPeak: "rgba(235,203,139,0.88)", + waveformGrid: "rgba(216,222,233,0.10)", + waveformLabel: "rgba(216,222,233,0.65)", + waterfallHue: [212, 188], + waterfallSat: 70, + waterfallLight: [14, 58], + waterfallAlpha: [0.28, 0.82] + }, + light: { + bg: "#dde1e9", + spectrumLine: "#5e81ac", + spectrumFill: "rgba(94,129,172,0.14)", + spectrumGrid: "rgba(46,52,64,0.08)", + spectrumLabel: "rgba(46,52,64,0.55)", + waveformLine: "rgba(94,129,172,0.95)", + waveformPeak: "rgba(208,135,112,0.9)", + waveformGrid: "rgba(46,52,64,0.12)", + waveformLabel: "rgba(46,52,64,0.65)", + waterfallHue: [215, 195], + waterfallSat: 65, + waterfallLight: [88, 45], + waterfallAlpha: [0.35, 0.78] + } + }, + lime: { + dark: { + bg: "#181815", + spectrumLine: "#a6e22e", + spectrumFill: "rgba(166,226,46,0.10)", + spectrumGrid: "rgba(248,248,242,0.05)", + spectrumLabel: "rgba(248,248,242,0.45)", + waveformLine: "rgba(166,226,46,0.92)", + waveformPeak: "rgba(230,219,116,0.88)", + waveformGrid: "rgba(248,248,242,0.08)", + waveformLabel: "rgba(248,248,242,0.65)", + waterfallHue: [70, 38], + waterfallSat: 80, + waterfallLight: [12, 62], + waterfallAlpha: [0.25, 0.88] + }, + light: { + bg: "#ede8d8", + spectrumLine: "#5f8700", + spectrumFill: "rgba(95,135,0,0.12)", + spectrumGrid: "rgba(39,40,34,0.08)", + spectrumLabel: "rgba(39,40,34,0.50)", + waveformLine: "rgba(95,135,0,0.95)", + waveformPeak: "rgba(176,120,0,0.9)", + waveformGrid: "rgba(39,40,34,0.10)", + waveformLabel: "rgba(39,40,34,0.60)", + waterfallHue: [75, 42], + waterfallSat: 75, + waterfallLight: [90, 42], + waterfallAlpha: [0.35, 0.78] + } + }, + contrast: { + dark: { + bg: "#000000", + spectrumLine: "#00ff88", + spectrumFill: "rgba(0,255,136,0.12)", + spectrumGrid: "rgba(255,255,255,0.12)", + spectrumLabel: "rgba(255,255,255,0.70)", + waveformLine: "rgba(0,255,136,0.95)", + waveformPeak: "rgba(255,204,0,0.92)", + waveformGrid: "rgba(255,255,255,0.15)", + waveformLabel: "rgba(255,255,255,0.80)", + waterfallHue: [150, 60], + waterfallSat: 100, + waterfallLight: [8, 55], + waterfallAlpha: [0.3, 0.95] + }, + light: { + bg: "#f4f4f4", + spectrumLine: "#005cc5", + spectrumFill: "rgba(0,92,197,0.12)", + spectrumGrid: "rgba(0,0,0,0.12)", + spectrumLabel: "rgba(0,0,0,0.65)", + waveformLine: "rgba(0,92,197,0.95)", + waveformPeak: "rgba(180,60,0,0.9)", + waveformGrid: "rgba(0,0,0,0.14)", + waveformLabel: "rgba(0,0,0,0.70)", + waterfallHue: [220, 180], + waterfallSat: 100, + waterfallLight: [90, 42], + waterfallAlpha: [0.35, 0.82] + } + }, + "neon-disco": { + dark: { + bg: "#090010", + spectrumLine: "#ff10e0", + spectrumFill: "rgba(255,16,224,0.12)", + spectrumGrid: "rgba(255,16,224,0.10)", + spectrumLabel: "rgba(240,200,255,0.55)", + waveformLine: "rgba(57,255,20,0.92)", + waveformPeak: "rgba(255,16,224,0.88)", + waveformGrid: "rgba(255,16,224,0.10)", + waveformLabel: "rgba(240,200,255,0.65)", + waterfallHue: [300, 120], + waterfallSat: 100, + waterfallLight: [8, 55], + waterfallAlpha: [0.3, 0.92] + }, + light: { + bg: "#f0d8ff", + spectrumLine: "#cc00a8", + spectrumFill: "rgba(204,0,168,0.12)", + spectrumGrid: "rgba(100,0,150,0.10)", + spectrumLabel: "rgba(50,0,80,0.55)", + waveformLine: "rgba(31,136,0,0.95)", + waveformPeak: "rgba(180,0,120,0.9)", + waveformGrid: "rgba(50,0,80,0.10)", + waveformLabel: "rgba(50,0,80,0.65)", + waterfallHue: [300, 120], + waterfallSat: 90, + waterfallLight: [90, 45], + waterfallAlpha: [0.35, 0.8] + } + }, + "golden-rain": { + dark: { + bg: "#120d07", + spectrumLine: "#e4b24d", + spectrumFill: "rgba(228,178,77,0.11)", + spectrumGrid: "rgba(255,229,172,0.07)", + spectrumLabel: "rgba(230,205,152,0.54)", + waveformLine: "rgba(236,199,108,0.92)", + waveformPeak: "rgba(214,134,44,0.90)", + waveformGrid: "rgba(255,210,120,0.09)", + waveformLabel: "rgba(232,214,174,0.66)", + waterfallHue: [40, 18], + waterfallSat: 88, + waterfallLight: [8, 58], + waterfallAlpha: [0.26, 0.84] + }, + light: { + bg: "#f5ecd9", + spectrumLine: "#9e6700", + spectrumFill: "rgba(158,103,0,0.12)", + spectrumGrid: "rgba(82,55,14,0.09)", + spectrumLabel: "rgba(82,55,14,0.55)", + waveformLine: "rgba(140,92,0,0.94)", + waveformPeak: "rgba(191,86,0,0.90)", + waveformGrid: "rgba(82,55,14,0.11)", + waveformLabel: "rgba(82,55,14,0.66)", + waterfallHue: [45, 18], + waterfallSat: 86, + waterfallLight: [92, 42], + waterfallAlpha: [0.34, 0.82] + } + }, + amber: { + dark: { + bg: "#130706", + spectrumLine: "#ff7a1f", + spectrumFill: "rgba(255,122,31,0.14)", + spectrumGrid: "rgba(255,110,40,0.09)", + spectrumLabel: "rgba(255,202,164,0.54)", + waveformLine: "rgba(255,134,54,0.94)", + waveformPeak: "rgba(255,220,96,0.92)", + waveformGrid: "rgba(255,120,36,0.11)", + waveformLabel: "rgba(255,214,176,0.66)", + waterfallHue: [8, 42], + waterfallSat: 96, + waterfallLight: [8, 58], + waterfallAlpha: [0.26, 0.88] + }, + light: { + bg: "#fff2e7", + spectrumLine: "#c24500", + spectrumFill: "rgba(194,69,0,0.14)", + spectrumGrid: "rgba(125,52,0,0.09)", + spectrumLabel: "rgba(90,38,0,0.56)", + waveformLine: "rgba(176,62,0,0.95)", + waveformPeak: "rgba(224,132,0,0.90)", + waveformGrid: "rgba(125,52,0,0.10)", + waveformLabel: "rgba(90,38,0,0.68)", + waterfallHue: [18, 48], + waterfallSat: 90, + waterfallLight: [92, 42], + waterfallAlpha: [0.34, 0.84] + } + }, + fire: { + dark: { + bg: "#140406", + spectrumLine: "#cf1b22", + spectrumFill: "rgba(207,27,34,0.14)", + spectrumGrid: "rgba(255,84,60,0.08)", + spectrumLabel: "rgba(255,214,202,0.54)", + waveformLine: "rgba(222,46,34,0.94)", + waveformPeak: "rgba(255,112,48,0.90)", + waveformGrid: "rgba(255,84,60,0.10)", + waveformLabel: "rgba(255,226,214,0.66)", + waterfallHue: [2, 18], + waterfallSat: 96, + waterfallLight: [8, 52], + waterfallAlpha: [0.26, 0.88] + }, + light: { + bg: "#ffede5", + spectrumLine: "#a91511", + spectrumFill: "rgba(169,21,17,0.14)", + spectrumGrid: "rgba(125,36,12,0.09)", + spectrumLabel: "rgba(92,24,10,0.56)", + waveformLine: "rgba(164,28,16,0.95)", + waveformPeak: "rgba(214,88,20,0.90)", + waveformGrid: "rgba(125,36,12,0.10)", + waveformLabel: "rgba(92,24,10,0.68)", + waterfallHue: [4, 24], + waterfallSat: 82, + waterfallLight: [92, 40], + waterfallAlpha: [0.34, 0.84] + } + }, + phosphor: { + dark: { + bg: "#010501", + spectrumLine: "#39ff14", + spectrumFill: "rgba(57,255,20,0.13)", + spectrumGrid: "rgba(57,255,20,0.07)", + spectrumLabel: "rgba(168,230,168,0.55)", + waveformLine: "rgba(57,255,20,0.92)", + waveformPeak: "rgba(184,240,96,0.88)", + waveformGrid: "rgba(57,255,20,0.08)", + waveformLabel: "rgba(168,230,168,0.65)", + waterfallHue: [115, 90], + waterfallSat: 100, + waterfallLight: [5, 52], + waterfallAlpha: [0.28, 0.92] + }, + light: { + bg: "#e0f0e0", + spectrumLine: "#1a7a1a", + spectrumFill: "rgba(26,122,26,0.13)", + spectrumGrid: "rgba(10,42,10,0.08)", + spectrumLabel: "rgba(10,42,10,0.52)", + waveformLine: "rgba(20,110,20,0.95)", + waveformPeak: "rgba(74,138,0,0.90)", + waveformGrid: "rgba(10,42,10,0.10)", + waveformLabel: "rgba(10,42,10,0.65)", + waterfallHue: [115, 90], + waterfallSat: 90, + waterfallLight: [92, 40], + waterfallAlpha: [0.34, 0.82] + } + } +}; +function currentStyle() { + const style = document.documentElement.getAttribute("data-style"); + return style && style in CANVAS_PALETTE ? style : "original"; +} +function canvasPalette() { + const s = currentStyle(); + const t = currentTheme(); + return CANVAS_PALETTE[s][t]; +} +function setStyle(style) { + const remapped = style === "nord" ? "arctic" : style === "monokai" ? "lime" : style === "blood" ? "fire" : style; + const next = remapped in CANVAS_PALETTE ? remapped : "original"; + if (next === "original") { + document.documentElement.removeAttribute("data-style"); + } else { + document.documentElement.setAttribute("data-style", next); + } + saveSetting("style", next); + if (headerStylePickSelect) headerStylePickSelect.value = next; + if (typeof trxClearCssColorCache === "function") trxClearCssColorCache(); + invalidateBookmarkColors(); + scheduleOverviewDraw(); +} +if (overviewPeakHoldEl) { + if (!isFiniteNumber(overviewPeakHoldMs) || overviewPeakHoldMs < 0) { + overviewPeakHoldMs = 2e3; + } + overviewPeakHoldEl.value = String(overviewPeakHoldMs); + overviewPeakHoldEl.addEventListener("change", () => { + overviewPeakHoldMs = Math.max(0, Number(overviewPeakHoldEl.value) || 0); + saveSetting("overviewPeakHoldMs", overviewPeakHoldMs); + pruneSpectrumPeakHoldFrames(); + if (lastSpectrumData) scheduleSpectrumDraw(); + scheduleOverviewDraw(); + }); +} +if (savedTheme === "light" || savedTheme === "dark") { + setTheme(savedTheme); +} else { + const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; + setTheme(prefersLight ? "light" : "dark"); +} +var savedStyle = loadSetting("style", "original"); +setStyle(savedStyle); +if (themeToggleBtn) { + themeToggleBtn.addEventListener("click", () => { + setTheme(currentTheme() === "dark" ? "light" : "dark"); + window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme()); + window.trx.modules.map?.syncLocatorMarkerStyles(); + window.trx.modules.map?.refreshAisMarkerColors(); + scheduleOverviewDraw(); + if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw(); + }); +} +if (headerStylePickSelect) { + headerStylePickSelect.addEventListener("change", () => { + setStyle(headerStylePickSelect.value); + window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme()); + window.trx.modules.map?.syncLocatorMarkerStyles(); + window.trx.modules.map?.refreshAisMarkerColors(); + }); +} +function readyText() { + return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready"; +} +function rigBadgeColor(rigId) { + const text = (rigId || "rx").toString(); + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = hash * 33 + text.charCodeAt(i) >>> 0; + } + const hue = hash % 360; + return `hsl(${hue}, 62%, 52%)`; +} +window.getDecodeRigMeta = function() { + const rigId = lastActiveRigId || "local"; + return { + rigId, + label: lastRigDisplayNames[rigId] || rigId, + color: rigBadgeColor(rigId) + }; +}; +function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { + if (!selectEl) return; + const selectedBefore = selectEl.value; + selectEl.replaceChildren(); + rigIds.forEach((id) => { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = lastRigDisplayNames[id] || id; + selectEl.appendChild(opt); + }); + const preferred = typeof activeRigId === "string" && rigIds.includes(activeRigId) ? activeRigId : selectedBefore; + if (preferred && rigIds.includes(preferred)) { + selectEl.value = preferred; + } + selectEl.disabled = disabled; +} +function updateRigIdentitySummary(rigId, pending = false) { + if (!headerRigSummary) return; + const rig = serverRigs.find((entry) => entry?.remote === rigId); + if (!rig) { + headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available"; + return; + } + const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote; + const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []; + const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)]; + if (modes.length > 5) features.push(`+${modes.length - 5} modes`); + headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; +} +function updateRigSubtitle(activeRigId) { + if (!rigSubtitle) return; + const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--"; + rigSubtitle.textContent = `Rig: ${name}`; + updateDocumentTitle(activeChannelRds()); +} +function applyRigList(activeRigId, rigIds, displayNames = {}) { + if (!Array.isArray(rigIds)) return; + const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); + const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); + lastRigIds = nextIds; + if (displayNames && typeof displayNames === "object") { + lastRigDisplayNames = { ...displayNames }; + } + const aboutList = document.getElementById("about-rig-list"); + if (aboutList) { + aboutList.textContent = lastRigIds.length ? lastRigIds.join(", ") : "--"; + } + if (typeof activeRigId === "string" && activeRigId.length > 0) { + if (!lastActiveRigId) { + lastActiveRigId = activeRigId; + } + const aboutActive = document.getElementById("about-active-rig"); + if (aboutActive) aboutActive.textContent = lastActiveRigId; + } + const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); + const rigListChanged = prevKey !== nextKey; + const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; + populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); + updateRigSubtitle(lastActiveRigId); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); + if (rigListChanged) { + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + window.trx.modules.bookmarks?.populateScopePicker(); + void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); + } + window.trx.modules.map?.updateMapRigFilter(); +} +async function refreshRigList() { + try { + const resp = await fetch("/rigs", { cache: "no-store" }); + if (!resp.ok) return; + const data = await responseJsonUnknown(resp); + if (!isRigListResponse(data)) return; + const rigs = data.rigs; + const rigIds = rigs.map((r) => r.remote).filter(Boolean); + const displayNames = {}; + rigs.forEach((r) => { + if (!r || !r.remote) return; + if (typeof r.display_name === "string" && r.display_name.length > 0) { + displayNames[r.remote] = r.display_name; + } else { + const mfg = (r.manufacturer || "").trim(); + const mdl = (r.model || "").trim(); + const hw = [mfg, mdl].filter(Boolean).join(" "); + displayNames[r.remote] = hw || r.remote; + } + }); + serverRigs = rigs; + refreshOperatorLayoutCapabilities(); + serverActiveRigId = data.active_remote; + applyRigList(data.active_remote, rigIds, displayNames); + window.trx.modules.map?.syncAprsReceiverMarker(); + } catch (e) { + } +} +function refreshOperatorLayoutCapabilities() { + const rigModes = serverRigs.map( + (rig) => Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [] + ); + const decoderModes = new Set(decoderRegistry.flatMap( + (decoder) => Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : [] + )); + window.trxUi?.setLayoutCapabilities({ + broadcast: rigModes.some((modes) => modes.includes("WFM")), + digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))) + }); +} +function showHint(msg, duration) { + powerHint.textContent = msg; + if (hintTimer) clearTimeout(hintTimer); + if (duration) hintTimer = setTimeout(() => { + powerHint.textContent = readyText(); + }, duration); + if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) { + window.trxUi?.notify(msg, { kind: "error" }); + } +} +var supportedModes = []; +var supportedBands = []; +var lastUnsupportedFreqPopupAt = 0; +var freqDirty = false; +var initialized = false; +var lastEventAt = Date.now(); +var aboutUptimeStart = null; +var es = null; +var esHeartbeat = null; +setInterval(() => { + if (!aboutUptimeStart) return; + const el = document.getElementById("about-uptime"); + if (el) el.textContent = formatDuration(Date.now() - aboutUptimeStart); +}, 1e3); +var reconnectTimer = null; +var overviewSignalSamples = []; +var overviewSignalTimer = null; +var overviewWaterfallRows = []; +var overviewWaterfallPushCount = 0; +var HEADER_SIG_WINDOW_MS = 1e4; +var OVERVIEW_WF_TEX_MAX_W = 512; +var overviewWfTexData = null; +var overviewWfTexWidth = 0; +var overviewWfTexHeight = 0; +var overviewWfTexPushCount = 0; +var overviewWfTexPalKey = ""; +var overviewWfTexReady = false; +function cssColorToRgba(color, alphaMul = 1) { + const parsed = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor(color) : [0, 0, 0, 1]; + return [ + parsed[0] ?? 0, + parsed[1] ?? 0, + parsed[2] ?? 0, + Math.max(0, Math.min(1, (parsed[3] ?? 1) * alphaMul)) + ]; +} +function rgbaWithAlpha(color, alphaMul = 1) { + return cssColorToRgba(color, alphaMul); +} +var BW_OVERLAY_COLORS = { + soft: [240 / 255, 173 / 255, 78 / 255, 0.05], + mid: [240 / 255, 173 / 255, 78 / 255, 0.19], + edge: [240 / 255, 173 / 255, 78 / 255, 0.3], + stroke: [240 / 255, 173 / 255, 78 / 255, 0.7], + hard: [240 / 255, 173 / 255, 78 / 255, 0.38] +}; +var BOOKMARK_MARKER_FALLBACK = "#66d9ef"; +function overviewWfResetTextureCache() { + overviewWfTexData = null; + overviewWfTexWidth = 0; + overviewWfTexHeight = 0; + overviewWfTexPushCount = 0; + overviewWfTexPalKey = ""; + overviewWfTexReady = false; +} +function overviewWfPaletteKey(pal, viewKey = "") { + return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; +} +function resizeHeaderSignalCanvas() { + if (!ensureOverviewCanvasBackingStore()) return; + positionRdsPsOverlay(); + drawHeaderSignalGraph(); +} +function ensureOverviewCanvasBackingStore() { + if (!overviewCanvas || !overviewGl || !overviewGl.ready) return false; + const cssW = Math.floor(overviewCanvas.clientWidth); + const cssH = Math.floor(overviewCanvas.clientHeight); + if (cssW <= 0 || cssH <= 0) return false; + const dpr = window.devicePixelRatio || 1; + const resized = overviewGl.ensureSize(cssW, cssH, dpr); + if (resized) { + overviewWfResetTextureCache(); + trimOverviewWaterfallRows(); + } + return true; +} +function signalOverlayHeight() { + if (!overviewCanvas) return 0; + let height = overviewCanvas.clientHeight || 0; + if (bandplanStripEl && bandplanStripEl.classList.contains("bp-visible")) { + height += bandplanStripEl.clientHeight || 0; + } + const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + const spectrumVisible = spectrumCanvasEl && spectrumCanvasEl.clientHeight > 0 && spectrumPanelEl && getComputedStyle(spectrumPanelEl).display !== "none"; + if (spectrumVisible) { + height += spectrumCanvasEl.clientHeight || 0; + const wfCanvas = document.getElementById("spectrum-waterfall-canvas"); + if (wfCanvas && wfCanvas.clientHeight > 0) { + height += wfCanvas.clientHeight; + } + } + return Math.floor(height); +} +function drawSignalOverlay() { + if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return; + if (!lastSpectrumData) { + signalOverlayCanvas.style.height = "0"; + signalOverlayCanvas.width = 0; + signalOverlayCanvas.height = 0; + return; + } + const cssW = Math.floor(signalVisualBlockEl.clientWidth); + const cssH = signalOverlayHeight(); + signalOverlayCanvas.style.height = cssH > 0 ? `${cssH}px` : "0"; + if (cssW <= 0 || cssH <= 0) { + signalOverlayCanvas.width = 0; + signalOverlayCanvas.height = 0; + return; + } + const dpr = window.devicePixelRatio || 1; + signalOverlayGl.ensureSize(cssW, cssH, dpr); + const W = signalOverlayCanvas.width; + const H = signalOverlayCanvas.height; + if (W <= 0 || H <= 0) return; + signalOverlayGl.clear([0, 0, 0, 0]); + const range = spectrumVisibleRange(lastSpectrumData); + const hzToX = (hz) => (hz - range.visLoHz) / range.visSpanHz * W; + const bwSoft = BW_OVERLAY_COLORS.soft; + const bwMid = BW_OVERLAY_COLORS.mid; + const bwEdge = BW_OVERLAY_COLORS.edge; + const bwStroke = BW_OVERLAY_COLORS.stroke; + const bwHard = BW_OVERLAY_COLORS.hard; + const bmRef = window.trx.modules.bookmarks?.overlayList ?? null; + if (bmRef && bmRef.length > 0) { + const colorMap = bmCategoryColorMap(); + const grouped = /* @__PURE__ */ new Map(); + for (const bm of bmRef) { + const f = Number(bm?.freq_hz); + if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue; + if (lastFreqHz !== null && isFiniteNumber(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; + const x = hzToX(f); + if (!isFiniteNumber(x) || x < 0 || x > W) continue; + const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK; + const segments = grouped.get(color) ?? []; + segments.push(x, 0, x, H); + grouped.set(color, segments); + } + for (const [color, segments] of grouped.entries()) { + if (!Array.isArray(segments) || segments.length === 0) continue; + signalOverlayGl.drawSegments(segments, rgbaWithAlpha(color, 0.72), Math.max(1, dpr * 0.9)); + } + } + const _bwCenterHz = activeBandwidthCenterHz(); + if (_bwCenterHz != null && currentBandwidthHz > 0) { + for (const spec of visibleBandwidthSpecs(_bwCenterHz)) { + const span = displaySpanForBandwidthSpec(spec); + const xL = hzToX(span.loHz); + const xR = hzToX(span.hiHz); + const stripW = xR - xL; + if (stripW <= 1) continue; + if (span.side < 0) { + signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwSoft, bwMid, bwMid, bwSoft); + } else if (span.side > 0) { + signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwMid, bwSoft, bwSoft, bwMid); + } else { + const half = stripW / 2; + signalOverlayGl.fillGradientRect(xL, 0, half, H, bwSoft, bwMid, bwMid, bwSoft); + signalOverlayGl.fillGradientRect(xL + half, 0, half, H, bwMid, bwSoft, bwSoft, bwMid); + } + const edgeW = Math.max(1, Math.round(5 * dpr)); + if (span.side <= 0) { + signalOverlayGl.fillRect(xL, 0, edgeW, H, bwEdge); + } + if (span.side >= 0) { + signalOverlayGl.fillRect(xR - edgeW, 0, edgeW, H, bwEdge); + } + if (span.side <= 0) { + signalOverlayGl.drawSegments([xL, 0, xL, H], bwStroke, Math.max(1, dpr * 1.5)); + } + if (span.side >= 0) { + signalOverlayGl.drawSegments([xR, 0, xR, H], bwStroke, Math.max(1, dpr * 1.5)); + } + if (span.side !== 0) { + const hardX = span.side < 0 ? xR : xL; + signalOverlayGl.drawSegments([hardX, 0, hardX, H], bwHard, Math.max(1, dpr)); + } + } + } + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + virtualChannels.forEach((ch) => { + if (!isFiniteNumber(ch.freq_hz) || ch.freq_hz <= 0) return; + const xc = hzToX(ch.freq_hz); + if (xc < 0 || xc > W) return; + const isActive = ch.id === window.trx.modules.vchan?.activeId; + const color = cssColorToRgba("#38bdf8"); + if (isActive) { + signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5)); + } else { + signalOverlayGl.drawDashedVerticalLine( + xc, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(3, Math.round(6 * dpr)), + color, + Math.max(1, dpr) + ); + } + }); + } + if (lastFreqHz != null) { + const xf = hzToX(lastFreqHz); + if (xf >= 0 && xf <= W) { + signalOverlayGl.drawDashedVerticalLine( + xf, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(2, Math.round(4 * dpr)), + cssColorToRgba("#ff1744"), + Math.max(1, dpr) + ); + } + } +} +function scheduleOverviewDraw() { + if (!overviewCanvas || overviewDrawPending) return; + overviewDrawPending = true; + requestAnimationFrame(() => { + overviewDrawPending = false; + drawHeaderSignalGraph(); + }); +} +function pushHeaderSignalSample(sUnits) { + if (!overviewCanvas) return; + const now = Date.now(); + const sample = isFiniteNumber(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; + overviewSignalSamples.push({ t: now, v: sample }); + while (overviewSignalSamples[0] && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { + overviewSignalSamples.shift(); + } + scheduleOverviewDraw(); +} +function trimOverviewWaterfallRows() { + if (!overviewCanvas) return; + const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr)); + if (overviewWaterfallRows.length > maxRows) { + overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows); + } +} +function overviewVisibleBinWindow(data, binCount) { + if (!data || !isFiniteNumber(data.sample_rate) || binCount <= 1) { + return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; + } + const range = spectrumVisibleRange(data); + const fullLoHz = data.center_hz - data.sample_rate / 2; + const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate; + const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate; + const maxIdx = binCount - 1; + const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx))); + const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx))); + return { startIdx, endIdx }; +} +function pushOverviewWaterfallFrame(data) { + if (!overviewCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return; + overviewWaterfallRows.push(data.bins.slice()); + overviewWaterfallPushCount++; + trimOverviewWaterfallRows(); + scheduleOverviewDraw(); +} +function startHeaderSignalSampling() { + if (!overviewCanvas || overviewSignalTimer) return; + overviewSignalTimer = setInterval(() => { + pushHeaderSignalSample(isFiniteNumber(sigLastSUnits) ? sigLastSUnits : 0); + }, 120); +} +function drawHeaderSignalGraph() { + if (!ensureOverviewCanvasBackingStore()) return; + if (!overviewGl || !overviewGl.ready) return; + const pal = canvasPalette(); + const W = overviewCanvas.width; + const H = overviewCanvas.height; + if (W <= 0 || H <= 0) return; + overviewGl.clear(cssColorToRgba(pal.bg)); + if (lastSpectrumData && overviewWaterfallRows.length > 0) { + drawOverviewWaterfall(W, H, pal); + } else { + drawOverviewSignalHistory(W, H, pal); + } + positionRdsPsOverlay(); + drawSignalOverlay(); + updateBandplanStrip(bandplanComputeRange()); +} +function drawOverviewWaterfall(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; + const maxVisible = Math.max(1, Math.floor(H)); + const rows = overviewWaterfallRows.slice(-maxVisible); + if (rows.length === 0) return; + const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2))); + const iH = Math.max(1, rows.length); + const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); + const view = lastSpectrumData ? spectrumVisibleRange(lastSpectrumData) : null; + const viewKey = view ? `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}` : "na"; + const palKey = overviewWfPaletteKey(pal, viewKey); + const rowStride = iW * 4; + const expectedSize = iW * iH * 4; + const newPushes = overviewWaterfallPushCount - overviewWfTexPushCount; + const sizeChanged = overviewWfTexWidth !== iW || overviewWfTexHeight !== iH; + const palChanged = overviewWfTexPalKey !== palKey; + const needsFull = !overviewWfTexData || sizeChanged || palChanged || overviewWfTexPushCount === 0; + let texUpdated = false; + if (!overviewWfTexData || overviewWfTexData.length !== expectedSize) { + overviewWfTexData = new Uint8Array(expectedSize); + } + const textureData = overviewWfTexData; + overviewWfTexWidth = iW; + overviewWfTexHeight = iH; + ensureWaterfallLut(pal, minDb, maxDb); + function renderRow(dstY, srcBins) { + if (!isNumericBins(srcBins) || srcBins.length === 0) return; + const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); + const spanBins = Math.max(1, endIdx - startIdx); + const rowBase = dstY * rowStride; + const iwM1 = Math.max(1, iW - 1); + for (let x = 0; x < iW; x++) { + const binIdx = Math.min(endIdx, startIdx + (x * spanBins / iwM1 | 0)); + const db = srcBins[binIdx]; + if (db !== void 0) waterfallLutWrite(textureData, rowBase + x * 4, db); + } + } + if (needsFull) { + for (let y = 0; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + overviewWfTexPushCount = overviewWaterfallPushCount; + overviewWfTexPalKey = palKey; + texUpdated = true; + } else if (newPushes > 0) { + const newCount = Math.min(newPushes, iH); + if (newCount >= iH) { + for (let y = 0; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + } else { + const shiftBytes = newCount * rowStride; + textureData.copyWithin(0, shiftBytes); + const startRow = iH - newCount; + for (let y = startRow; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + } + overviewWfTexPushCount = overviewWaterfallPushCount; + overviewWfTexPalKey = palKey; + texUpdated = true; + } + if (texUpdated || !overviewWfTexReady) { + overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, textureData, "linear"); + overviewWfTexReady = true; + } + overviewGl.drawTexture("overview-waterfall", 0, 0, W, H, 1, true); +} +function drawOverviewSignalHistory(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; + const now = Date.now(); + const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS); + if (samples.length === 0) return; + const maxVal = 20; + const windowStart = now - HEADER_SIG_WINDOW_MS; + const toX = (t) => (t - windowStart) / HEADER_SIG_WINDOW_MS * W; + const toY = (v) => H - Math.max(0, Math.min(maxVal, v)) / maxVal * (H - 3) - 1.5; + const gridMarkers = [ + { val: 0 }, + { val: 9 }, + { val: 18 } + ]; + const gridSegments = []; + for (const marker of gridMarkers) { + const y = toY(marker.val); + gridSegments.push(0, y, W, y); + } + overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1); + const linePoints = []; + samples.forEach((sample, idx) => { + const x = toX(sample.t); + const y = toY(sample.v); + if (idx === 0 || x >= (linePoints[linePoints.length - 2] ?? -Infinity)) { + linePoints.push(x, y); + } + }); + overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); + const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs > 0) { + const holdPoints = []; + for (let i = 0; i < samples.length; i++) { + const sample = samples[i]; + if (!sample) continue; + let peak = sample.v; + for (let j = i; j >= 0; j--) { + const prior = samples[j]; + if (!prior || sample.t - prior.t > holdMs) break; + if (prior.v > peak) peak = prior.v; + } + const x = toX(sample.t); + const y = toY(peak); + if (i === 0 || x >= (holdPoints[holdPoints.length - 2] ?? -Infinity)) { + holdPoints.push(x, y); + } + } + overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1); + } +} +function waterfallColorRgba(db, pal, minDb, maxDb) { + const lo = isFiniteNumber(minDb) ? minDb : isFiniteNumber(spectrumFloor) ? spectrumFloor : -115; + const hi = isFiniteNumber(maxDb) ? maxDb : lo + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); + const safeDb = isFiniteNumber(db) ? db : lo; + const clamped = Math.max(lo, Math.min(hi, safeDb)); + const span = Math.max(1, hi - lo); + const tLinear = (clamped - lo) / span; + const t = waterfallGamma === 1 ? tLinear : Math.pow(tLinear, waterfallGamma); + const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]); + const light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]); + const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[0]); + if (typeof window.trxHslToRgba === "function") { + return window.trxHslToRgba(hue, pal.waterfallSat, light, alpha); + } + return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`); +} +var _wfLutKey = ""; +var _wfLut = new Uint8Array(256 * 4); +function ensureWaterfallLut(pal, minDb, maxDb) { + const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`; + if (key === _wfLutKey) return; + _wfLutKey = key; + for (let i = 0; i < 256; i++) { + const db = i < 128 ? i : i - 256; + const c = waterfallColorRgba(db, pal, minDb, maxDb); + const p = i * 4; + _wfLut[p + 0] = c[0] * 255 + 0.5 | 0; + _wfLut[p + 1] = c[1] * 255 + 0.5 | 0; + _wfLut[p + 2] = c[2] * 255 + 0.5 | 0; + _wfLut[p + 3] = c[3] * 255 + 0.5 | 0; + } +} +function waterfallLutWrite(texData, offset, db) { + const idx = (db | 0) + 256 & 255; + const p = idx * 4; + texData[offset] = _wfLut[p] ?? 0; + texData[offset + 1] = _wfLut[p + 1] ?? 0; + texData[offset + 2] = _wfLut[p + 2] ?? 0; + texData[offset + 3] = _wfLut[p + 3] ?? 0; +} +function refreshWavelengthDisplay(hz) { + if (!wavelengthEl) return; + wavelengthEl.textContent = formatWavelength(hz); +} +function refreshFreqDisplay() { + if (lastFreqHz == null || freqDirty) return; + freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit); + refreshWavelengthDisplay(lastFreqHz); +} +function activeRdsChannelId() { + const virtualChannelId = window.trx.modules.vchan?.activeId; + if (virtualChannelId) return virtualChannelId; + return null; +} +function activeChannelRds() { + if (!activeChannelIsWfm()) return null; + const activeId = activeRdsChannelId(); + if (activeId) { + const rds = vchanRdsById.get(activeId); + if (rds) return rds; + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + if (virtualChannels[0]?.id === activeId) return primaryRds; + } + } + return primaryRds; +} +function activeChannelIsWfm() { + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + const activeId = activeRdsChannelId(); + const active = virtualChannels.find((ch) => ch.id === activeId) || virtualChannels[0]; + return String(active?.mode || "").toUpperCase() === "WFM"; + } + return lastModeName === "WFM"; +} +function activeChannelFreqHz() { + if (window.trx.modules.vchan) { + const ch = window.trx.modules.vchan.activeChannel(); + if (ch && isFiniteNumber(ch.freq_hz)) return ch.freq_hz; + } + return lastFreqHz; +} +function activeBandwidthCenterHz() { + const freqHz = activeChannelFreqHz(); + return isFiniteNumber(freqHz) ? freqHz : lastFreqHz; +} +function buildRdsOverlayHtml(rds) { + const ps = rds?.program_service; + const hasPs = !!(ps && ps.length > 0); + const hasPi = rds?.pi != null; + if (!hasPs && !hasPi) return ""; + const mainText = hasPs ? formatOverlayPs(ps) : formatOverlayPi(rds?.pi); + const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback"; + const metaText = hasPs ? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}` : rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : ""); + const trafficFlags = `${overlayTrafficFlagHtml("TP", rds?.traffic_program)}${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}`; + return `${hasPs ? formatPsHtml(ps) : escapeHtml(mainText)}${escapeHtml(metaText)}${trafficFlags}`; +} +function collectRdsOverlayEntries() { + const entries = []; + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + for (const ch of virtualChannels) { + if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; + if (!isFiniteNumber(ch.freq_hz)) continue; + const rds = vchanRdsById.get(ch.id) || (virtualChannels[0]?.id === ch.id ? primaryRds : null); + if (!rds) continue; + entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds }); + } + } else if (lastModeName === "WFM" && primaryRds && lastFreqHz !== null && isFiniteNumber(lastFreqHz)) { + entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); + } + return entries; +} +function renderRdsOverlays() { + if (!rdsPsOverlay) return; + if (!lastSpectrumData || !overviewCanvas) { + rdsOverlayEntries = []; + rdsPsOverlay.style.display = "none"; + return; + } + const entries = collectRdsOverlayEntries(); + rdsOverlayEntries = []; + rdsPsOverlay.replaceChildren(); + if (entries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + entries.forEach((entry) => { + const html = buildRdsOverlayHtml(entry.rds); + if (!html) return; + const el = document.createElement("div"); + el.className = "rds-ps-overlay-item"; + el.dataset.freqHz = String(entry.freq_hz); + el.innerHTML = html; + el.addEventListener("click", (evt) => { + evt.stopPropagation(); + void copyRdsPsToClipboard(entry.rds, entry.freq_hz); + }); + el.addEventListener("mouseenter", () => { + el.style.zIndex = String(entries.length + 10); + }); + el.addEventListener("mouseleave", () => { + if (el.dataset.defaultZ) el.style.zIndex = el.dataset.defaultZ; + }); + rdsPsOverlay.appendChild(el); + rdsOverlayEntries.push({ ...entry, el }); + }); + if (rdsOverlayEntries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + rdsPsOverlay.style.display = "block"; + positionRdsOverlays(); +} +window.renderRdsOverlays = renderRdsOverlays; +function positionRdsOverlays() { + if (!rdsPsOverlay || !lastSpectrumData || !overviewCanvas || rdsOverlayEntries.length === 0) return; + const width = overviewCanvas.clientWidth || overviewCanvas.width || 0; + if (width <= 0) return; + const range = spectrumVisibleRange(lastSpectrumData); + if (!isFiniteNumber(range.visLoHz) || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) return; + const sortedByFreq = [...rdsOverlayEntries].sort((a, b) => a.freq_hz - b.freq_hz); + const freqZMap = new Map(sortedByFreq.map((e, i) => [e.id, i + 1])); + rdsOverlayEntries.forEach((entry, idx) => { + const el = entry.el; + if (!el) return; + if (!isFiniteNumber(entry.freq_hz)) { + el.style.display = "none"; + return; + } + el.style.display = ""; + const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; + const clamped = Math.max(0.06, Math.min(0.94, rel)); + el.style.left = `${clamped * width}px`; + el.style.top = "50%"; + const z = String(freqZMap.get(entry.id) ?? idx + 1); + el.style.zIndex = z; + el.dataset.defaultZ = z; + }); +} +function positionRdsPsOverlay() { + positionRdsOverlays(); +} +function resetRdsDisplay() { + updateRdsPsOverlay(primaryRds); +} +function resetDecoderStateOnRigSwitch() { + primaryRds = null; + vchanRdsById = /* @__PURE__ */ new Map(); + resetRdsDisplay(); + resetWfmStereoIndicator(); + resetIntfBars(); + lastSpectrumData = null; + window.lastSpectrumData = null; + lastSpectrumRenderData = null; + const decoderIds = ["ais-status", "vdes-status", "aprs-status", "cw-status", "ft8-status", "wspr-status"]; + decoderIds.forEach((id) => { + const el = document.getElementById(id); + if (el) el.textContent = "--"; + }); +} +function resetWfmStereoIndicator() { + if (!wfmStFlagEl) return; + wfmStFlagEl.textContent = "MO"; + wfmStFlagEl.classList.remove("wfm-st-flag-stereo"); + wfmStFlagEl.classList.add("wfm-st-flag-mono"); +} +function updateIntfBar(fillEl, valEl, level) { + if (!fillEl || !valEl) return; + const v = Math.round(Math.min(Math.max(level, 0), 100)); + valEl.textContent = String(v); + fillEl.style.width = v + "%"; + fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65); + fillEl.classList.toggle("wfm-intf-high", v >= 65); + if (v < 35) { + fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high"); + } +} +function resetIntfBars() { + updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); + updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); +} +var _fastFreqMarker = document.getElementById("fast-freq-marker"); +var _fastBwLeft = document.getElementById("fast-bw-left"); +var _fastBwRight = document.getElementById("fast-bw-right"); +function positionFastOverlay(freqHz, bwHz) { + if (!lastSpectrumData || !signalVisualBlockEl) { + if (_fastFreqMarker) _fastFreqMarker.style.display = "none"; + if (_fastBwLeft) _fastBwLeft.style.display = "none"; + if (_fastBwRight) _fastBwRight.style.display = "none"; + return; + } + const cssW = signalVisualBlockEl.clientWidth; + if (cssW <= 0) return; + const range = spectrumVisibleRange(lastSpectrumData); + const hzToFrac = (hz) => (hz - range.visLoHz) / range.visSpanHz; + if (_fastFreqMarker && isFiniteNumber(freqHz)) { + const frac = hzToFrac(freqHz); + if (frac >= 0 && frac <= 1) { + _fastFreqMarker.style.display = ""; + _fastFreqMarker.style.transform = `translateX(${frac * cssW}px)`; + } else { + _fastFreqMarker.style.display = "none"; + } + } + if (_fastBwLeft && _fastBwRight && isFiniteNumber(freqHz) && isFiniteNumber(bwHz) && bwHz > 0) { + const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); + let loHz, hiHz; + if (side < 0) { + loHz = freqHz - bwHz; + hiHz = freqHz; + } else if (side > 0) { + loHz = freqHz; + hiHz = freqHz + bwHz; + } else { + loHz = freqHz - bwHz / 2; + hiHz = freqHz + bwHz / 2; + } + const lFrac = hzToFrac(loHz); + const rFrac = hzToFrac(hiHz); + const cFrac = hzToFrac(freqHz); + if (lFrac < cFrac && cFrac >= 0 && lFrac <= 1) { + const x = Math.max(0, lFrac) * cssW; + const w = (Math.min(1, cFrac) - Math.max(0, lFrac)) * cssW; + _fastBwLeft.style.display = ""; + _fastBwLeft.style.transform = `translateX(${x}px)`; + _fastBwLeft.style.width = `${w}px`; + } else { + _fastBwLeft.style.display = "none"; + } + if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) { + const x = Math.max(0, cFrac) * cssW; + const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW; + _fastBwRight.style.display = ""; + _fastBwRight.style.transform = `translateX(${x}px)`; + _fastBwRight.style.width = `${w}px`; + } else { + _fastBwRight.style.display = "none"; + } + } +} +function applyLocalTunedFrequency(hz, forceDisplay = false) { + if (!isFiniteNumber(hz)) return; + const freqChanged = lastFreqHz !== hz; + if (!freqChanged && !forceDisplay) return; + if (freqChanged) { + if (lastFreqHz != null) savePreviousTuneState(); + primaryRds = null; + resetRdsDisplay(); + resetWfmStereoIndicator(); + resetIntfBars(); + } + lastFreqHz = hz; + window.lastFreqHz = lastFreqHz; + updateDocumentTitle(activeChannelRds()); + refreshWavelengthDisplay(lastFreqHz); + if (forceDisplay) { + freqDirty = false; + } + if (forceDisplay || !freqDirty) { + refreshFreqDisplay(); + } + window.ft8BaseHz = lastFreqHz; + if (window.updateFt8RfDisplay) { + window.updateFt8RfDisplay(); + } + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } + positionFastOverlay(lastFreqHz, currentBandwidthHz); + if (freqChanged && lastSpectrumData) { + scheduleSpectrumDraw(); + } + if (freqChanged && !lastSpectrumData) { + updateBandplanStrip(bandplanComputeRange()); + } + positionRdsPsOverlay(); +} +function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { + const [, , maxBw] = mwDefaultsForMode(mode); + return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz); +} +function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { + if (!isFiniteNumber(freqHz)) return []; + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "AIS") { + return [ + { centerHz: freqHz, widthHz: currentBandwidthHz }, + { centerHz: freqHz + 5e4, widthHz: currentBandwidthHz } + ]; + } + return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; +} +function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "LSB" || modeUpper === "CWR") return -1; + if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; + return 0; +} +function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { + const centerHz = Number(spec?.centerHz); + const widthHz = Math.max(0, isFiniteNumber(spec?.widthHz) ? Number(spec.widthHz) : 0); + const side = sidebandDirectionForMode(mode); + if (side < 0) { + return { loHz: centerHz - widthHz, hiHz: centerHz, side }; + } + if (side > 0) { + return { loHz: centerHz, hiHz: centerHz + widthHz, side }; + } + const halfBw = widthHz / 2; + return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; +} +function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { + if (!isFiniteNumber(freqHz)) return null; + const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { + const widthHz = Math.max( + 0, + isFiniteNumber(spec.widthHz) ? spec.widthHz : Math.max(0, isFiniteNumber(bandwidthHz) ? bandwidthHz : 0) + ); + return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); + }); + if (specs.length === 0) return null; + const first = specs[0]; + if (!first) return null; + let loHz = first.loHz; + let hiHz = first.hiHz; + for (const spec of specs.slice(1)) { + loHz = Math.min(loHz, spec.loHz); + hiHz = Math.max(hiHz, spec.hiHz); + } + return { loHz, hiHz }; +} +function effectiveSpectrumCoverageSpanHz(sampleRateHz) { + const sampleRate = Number(sampleRateHz); + if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0; + const ratio = isFiniteNumber(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; + return sampleRate * Math.max(0.01, Math.min(1, ratio)); +} +function sweetSpotMinimumOffsetHz(bandwidthHz) { + if (!isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) return 0; + return bandwidthHz / 2; +} +function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { + if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz)) return false; + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) return true; + return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; +} +function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { + if (!isFiniteNumber(freqHz) || !isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { + return null; + } + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) { + const fallbackCenterHz = isFiniteNumber(preferredCenterHz) ? preferredCenterHz : freqHz; + return alignFreqToRigStep(Math.round(Math.max(minCenterHz, Math.min(maxCenterHz, fallbackCenterHz)))); + } + const targetCentersHz = []; + const lowerTargetHz = alignFreqToRigStep(Math.round(freqHz - minOffsetHz)); + const upperTargetHz = alignFreqToRigStep(Math.round(freqHz + minOffsetHz)); + if (lowerTargetHz >= minCenterHz && lowerTargetHz <= maxCenterHz) targetCentersHz.push(lowerTargetHz); + if (upperTargetHz >= minCenterHz && upperTargetHz <= maxCenterHz && !targetCentersHz.some((value) => Math.abs(value - upperTargetHz) < 1)) { + targetCentersHz.push(upperTargetHz); + } + if (!targetCentersHz.length) return null; + if (isFiniteNumber(preferredCenterHz)) { + let bestCenterHz = targetCentersHz[0]; + if (bestCenterHz === void 0) return null; + let bestDistance = Math.abs(bestCenterHz - preferredCenterHz); + for (const targetCenterHz of targetCentersHz.slice(1)) { + const distance = Math.abs(targetCenterHz - preferredCenterHz); + if (distance < bestDistance) { + bestDistance = distance; + bestCenterHz = targetCenterHz; + } + } + return bestCenterHz; + } + return targetCentersHz[0]; +} +function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) { + if (!data || !isFiniteNumber(freqHz)) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); + const currentCenterHz = Number(data.center_hz); + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) { + return null; + } + const halfSpanHz = sampleRate / 2; + const span = coverageSpanForMode(freqHz, bandwidthHz); + if (!span) return null; + const requiredLoHz = span.loHz - spectrumCoverageMarginHz; + const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; + if (requiredHiHz - requiredLoHz >= sampleRate) { + return alignFreqToRigStep(Math.round(freqHz)); + } + const currentLoHz = currentCenterHz - halfSpanHz; + const currentHiHz = currentCenterHz + halfSpanHz; + if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) { + return null; + } + let nextCenterHz = currentCenterHz; + if (requiredLoHz < currentLoHz) { + nextCenterHz = requiredLoHz + halfSpanHz; + } + if (requiredHiHz > currentHiHz) { + nextCenterHz = requiredHiHz - halfSpanHz; + } + return alignFreqToRigStep(Math.round(nextCenterHz)); +} +function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { + return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz); +} +async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { + const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz); + if (!isFiniteNumber(nextCenterHz)) return; + if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return; + await postPath(`/set_center_freq?hz=${nextCenterHz}`); + if (centerFreqEl && !centerFreqDirty) { + centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit); + } +} +var _freqOptimisticHz = null; +var _freqOptimisticSeq = 0; +function setRigFrequency(freqHz) { + const targetHz = Math.round(freqHz); + if (!freqAllowed(targetHz)) { + showUnsupportedFreqPopup(targetHz); + throw new Error(`Unsupported frequency: ${targetHz}`); + } + const prevFreqHz = lastFreqHz; + const seq = ++_freqOptimisticSeq; + _freqOptimisticHz = targetHz; + applyLocalTunedFrequency(targetHz); + Promise.all([ + postPath(`/set_freq?hz=${targetHz}`), + ensureTunedBandwidthCoverage(targetHz) + ]).catch((err) => { + if (_freqOptimisticSeq === seq && prevFreqHz != null) { + _freqOptimisticHz = null; + applyLocalTunedFrequency(prevFreqHz, true); + } + console.warn("setRigFrequency failed:", err); + }).finally(() => { + if (_freqOptimisticSeq === seq) _freqOptimisticHz = null; + }); +} +function spectrumBinIndexForHz(data, hz) { + if (!data || !isNumericBins(data.bins) || data.bins.length < 2 || !isFiniteNumber(hz)) { + return null; + } + const maxIdx = data.bins.length - 1; + const fullLoHz = Number(data.center_hz) - Number(data.sample_rate) / 2; + const idx = Math.round((hz - fullLoHz) / Number(data.sample_rate) * maxIdx); + return Math.max(0, Math.min(maxIdx, idx)); +} +function spectrumPowerScore(db) { + const value = isFiniteNumber(db) ? db : -160; + const clamped = Math.max(-160, Math.min(40, value)); + return 10 ** (clamped / 10); +} +function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { + if (!data || !isNumericBins(data.bins) || data.bins.length < 16) { + return null; + } + if (!isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { + return null; + } + const bins = data.bins; + const sampleRate = Number(data.sample_rate); + const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); + const currentCenterHz = Number(data.center_hz); + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(usableSpanHz) || usableSpanHz <= 0 || !isFiniteNumber(currentCenterHz)) { + return null; + } + const halfUsableSpanHz = usableSpanHz / 2; + const fullHalfSpanHz = sampleRate / 2; + const span = coverageSpanForMode(freqHz, bandwidthHz); + if (!span) return null; + const requiredLoHz = span.loHz - spectrumCoverageMarginHz; + const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; + if (requiredHiHz - requiredLoHz >= usableSpanHz) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + currentCenterHz - halfUsableSpanHz, + currentCenterHz + halfUsableSpanHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) + ); + if (!isFiniteNumber(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; + } + const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2); + const evalMinCenterHz = currentCenterHz - evalHalfSpanHz; + const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz; + const fitMinCenterHz = requiredHiHz - halfUsableSpanHz; + const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz; + const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz); + const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz); + if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + evalMinCenterHz, + evalMaxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) + ); + if (!isFiniteNumber(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; + } + const maxIdx = bins.length - 1; + const usableBins = Math.max(4, Math.min(maxIdx, Math.round(usableSpanHz / sampleRate * maxIdx))); + const fullLoHz = currentCenterHz - fullHalfSpanHz; + const startMinIdx = Math.max( + 0, + Math.min(maxIdx - usableBins, Math.round((minCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + ); + const startMaxIdx = Math.max( + startMinIdx, + Math.min(maxIdx - usableBins, Math.round((maxCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + ); + let bestStartIdx = null; + let bestScore = Number.POSITIVE_INFINITY; + const signalLoHz = span.loHz; + const signalHiHz = span.hiHz; + for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) { + const endIdx = Math.min(maxIdx, startIdx + usableBins); + const windowLoHz = fullLoHz + startIdx / maxIdx * sampleRate; + const candidateCenterHz = windowLoHz + halfUsableSpanHz; + if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { + continue; + } + const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz ?? freqHz) ?? startIdx)); + const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz ?? freqHz) ?? endIdx)); + let score = 0; + for (let i = startIdx; i <= endIdx; i++) { + if (i >= signalLoIdx && i <= signalHiIdx) continue; + score += spectrumPowerScore(bins[i] ?? -128); + } + const spanMidHz = (span.loHz + span.hiHz) / 2; + const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz); + score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08; + if (score < bestScore) { + bestScore = score; + bestStartIdx = startIdx; + } + } + if (!isFiniteNumber(bestScore) || bestStartIdx == null) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + minCenterHz, + maxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) + ); + if (!isFiniteNumber(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; + } + const bestLoHz = fullLoHz + bestStartIdx / maxIdx * sampleRate; + const bestCenterHz = bestLoHz + halfUsableSpanHz; + return { + centerHz: alignFreqToRigStep(Math.round(bestCenterHz)), + score: bestScore + }; +} +function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) { + if (!isFiniteNumber(freqHz)) return null; + const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); + return candidate && isFiniteNumber(candidate.centerHz) ? candidate.centerHz : null; +} +function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { + if (!data || !isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { + return []; + } + const sampleRate = Number(data.sample_rate); + const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); + if (!isFiniteNumber(usableSpanHz) || usableSpanHz <= 0) return []; + const halfUsableSpanHz = usableSpanHz / 2; + const span = coverageSpanForMode(freqHz, bandwidthHz); + if (!span) return []; + const requiredLoHz = span.loHz - spectrumCoverageMarginHz; + const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; + if (requiredHiHz - requiredLoHz >= usableSpanHz) { + const probeCenters = []; + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + for (const centerHz of [freqHz - minOffsetHz, freqHz + minOffsetHz]) { + const alignedHz = alignFreqToRigStep(Math.round(centerHz)); + if (sweetSpotCenterHasRequiredOffset(alignedHz, freqHz, bandwidthHz) && !probeCenters.some((value) => Math.abs(value - alignedHz) < 1)) { + probeCenters.push(alignedHz); + } + } + return probeCenters; + } + const minCenterHz = requiredHiHz - halfUsableSpanHz; + const maxCenterHz = requiredLoHz + halfUsableSpanHz; + if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { + return []; + } + const points = 5; + const centers = []; + for (let i = 0; i < points; i++) { + const frac = i / (points - 1); + const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac)); + if (sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - centerHz) < 1)) { + centers.push(centerHz); + } + } + const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz))); + if (isFiniteNumber(currentCenterHz) && sweetSpotCenterHasRequiredOffset(currentCenterHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) { + centers.push(currentCenterHz); + centers.sort((a, b) => a - b); + } + return centers; +} +async function applySweetSpotCenter() { + if (sweetSpotScanInFlight) { + showHint("Sweet-spot already scanning", 900); + return; + } + if (!isFiniteNumber(lastFreqHz) || !lastSpectrumData) return; + const originalCenterHz = Number(lastSpectrumData.center_hz); + const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz); + let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz); + if (!probeCentersHz.length && (!bestCandidate || !isFiniteNumber(bestCandidate.centerHz))) { + showHint("Sweet-spot unavailable", 1100); + return; + } + sweetSpotScanInFlight = true; + try { + showHint("Scanning sweet spot...", 1400); + for (const probeCenterHz of probeCentersHz) { + if (!isFiniteNumber(probeCenterHz)) continue; + let probeFrame = lastSpectrumData; + if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) { + await postPath(`/set_center_freq?hz=${probeCenterHz}`); + try { + probeFrame = await waitForSpectrumFrame(probeCenterHz, 1400); + } catch (_) { + continue; + } + } + const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz); + if (!candidate || !isFiniteNumber(candidate.centerHz)) continue; + if (!bestCandidate || candidate.score < bestCandidate.score) { + bestCandidate = candidate; + } + } + const targetCenterHz = bestCandidate && isFiniteNumber(bestCandidate.centerHz) ? bestCandidate.centerHz : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); + if (!isFiniteNumber(targetCenterHz)) { + if (isFiniteNumber(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) { + await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`); + } + showHint("Sweet-spot unavailable", 1100); + return; + } + if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) { + await postPath(`/set_center_freq?hz=${targetCenterHz}`); + } + if (centerFreqEl && !centerFreqDirty) { + centerFreqEl.value = formatFrequencyForStep(targetCenterHz, jogUnit); + } + if (isFiniteNumber(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { + showHint("Already at sweet spot", 900); + } else { + showHint("Sweet-spot set", 1200); + } + } finally { + sweetSpotScanInFlight = false; + } +} +function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { + if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz) || !lastSpectrumData) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return null; + const span = coverageSpanForMode(freqHz, bandwidthHz); + if (!span) return null; + const halfSpanHz = sampleRate / 2; + const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz); + const requiredHiOffset = span.hiHz + spectrumCoverageMarginHz - freqHz; + if (requiredLoOffset + requiredHiOffset >= sampleRate) { + return alignFreqToRigStep(Math.round(centerHz)); + } + const minFreqHz = centerHz - halfSpanHz + requiredLoOffset; + const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset; + if (freqHz >= minFreqHz && freqHz <= maxFreqHz) { + return null; + } + const clampedHz = Math.max(minFreqHz, Math.min(maxFreqHz, freqHz)); + return alignFreqToRigStep(Math.round(clampedHz)); +} +var spectrumCenterPendingHz = null; +async function shiftSpectrumCenter(direction) { + if (!lastSpectrumData) return; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) return; + const stepHz = Math.max(5e4, Math.round(sampleRate * 0.35)); + const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz)); + spectrumCenterPendingHz = nextCenterHz; + showHint("Shifting spectrum…", 900); + await postPath(`/set_center_freq?hz=${nextCenterHz}`); + if (centerFreqEl && !centerFreqDirty) { + centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit); + } + const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); + if (isFiniteNumber(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { + await postPath(`/set_freq?hz=${nextFreqHz}`); + applyLocalTunedFrequency(nextFreqHz); + } +} +function refreshCenterFreqDisplay() { + if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; + centerFreqEl.value = formatFrequencyForStep(lastSpectrumData.center_hz, jogUnit); +} +function normalizeMinFreqStep(cap) { + const val = Number(cap && cap.min_freq_step_hz); + if (!isFiniteNumber(val) || val < 1) return 1; + return Math.round(val); +} +function alignFreqToRigStep(hz) { + if (!isFiniteNumber(hz)) return hz; + const step = Math.max(1, minFreqStepHz); + return Math.round(hz / step) * step; +} +function updateJogStepSupport(cap) { + const nextMinStep = normalizeMinFreqStep(cap); + minFreqStepHz = nextMinStep; + const stepRoot = document.getElementById("jog-step"); + if (!stepRoot) return; + const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); + if (buttons.length === 0) return; + buttons.forEach((btn) => { + const base = Number(btn.dataset.baseStep || btn.dataset.step); + if (isFiniteNumber(base) && base > 0) { + btn.dataset.baseStep = String(Math.round(base)); + btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz)); + } + }); + const steps = buttons.map((btn) => Number(btn.dataset.step)).filter((s) => isFiniteNumber(s) && s > 0); + if (steps.length === 0) return; + const current = Number(jogUnit); + const firstStep = steps[0]; + if (firstStep === void 0) return; + const desired = isFiniteNumber(current) && current >= minFreqStepHz ? current : Math.max(firstStep, minFreqStepHz); + jogUnit = steps.reduce((best, s) => Math.abs(s - desired) < Math.abs(best - desired) ? s : best, firstStep); + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); + saveSetting("jogUnit", jogUnit); + saveSetting("jogStep", jogStep); + buttons.forEach((btn) => { + btn.classList.toggle("active", Number(btn.dataset.step) === jogUnit); + }); + refreshFreqDisplay(); + refreshCenterFreqDisplay(); +} +function normalizeMode(modeVal) { + if (typeof modeVal === "string") return modeVal; + if (isRecord2(modeVal)) { + const entries = Object.entries(modeVal); + if (entries.length > 0) { + const firstEntry = entries[0]; + if (!firstEntry) return ""; + const [variant, value] = firstEntry; + if (variant === "Other" && typeof value === "string") return value; + return variant; + } + } + return ""; +} +function updateSupportedBands(cap) { + if (cap && Array.isArray(cap.supported_bands)) { + supportedBands = cap.supported_bands.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number").map((b) => ({ low: b.low_hz, high: b.high_hz })); + } else { + supportedBands = []; + } +} +function freqAllowed(hz) { + if (!isFiniteNumber(hz)) return false; + if (supportedBands.length === 0) return true; + return supportedBands.some((b) => hz >= b.low && hz <= b.high); +} +function unsupportedBandSummary() { + if (supportedBands.length === 0) return "No supported frequency ranges were reported by the rig."; + const ranges = supportedBands.slice().sort((a, b) => a.low - b.low).map((b) => `${formatFrequencyForHumans(b.low)} to ${formatFrequencyForHumans(b.high)}`); + return `Supported ranges: ${ranges.join(", ")}`; +} +function showUnsupportedFreqPopup(hz) { + const message = `Unsupported frequency: ${formatFrequencyForHumans(hz)}. + +${unsupportedBandSummary()}`; + showHint("Out of supported range", 1800); + const now = Date.now(); + if (now - lastUnsupportedFreqPopupAt < 1200) return; + lastUnsupportedFreqPopupAt = now; + window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7e3 }); +} +function dbmToSUnits(dbm) { + if (!isFiniteNumber(dbm)) return 0; + const clampedDbm = Math.max(-140, Math.min(20, dbm)); + if (clampedDbm <= -121) return 0; + if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10; + return (clampedDbm + 121) / 6; +} +function formatSignal(sUnits) { + if (!isFiniteNumber(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; + if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`; + const overDb = Math.min(60, Math.round((sUnits - 9) * 10 / 10) * 10); + return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; +} +function setDisabled(disabled) { + [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { + if (el) el.disabled = disabled; + }); +} +var serverVersion = null; +var serverBuildDate = null; +var serverCallsign = null; +var ownerCallsign = null; +var ownerWebsiteUrl = null; +var ownerWebsiteName = null; +var aisVesselUrlBase = null; +var serverRigs = []; +var serverActiveRigId = null; +var serverLat = null; +var serverLon = null; +var initialMapZoom = 10; +var spectrumCoverageMarginHz = 5e4; +var spectrumUsableSpanRatio = 0.92; +var DEFAULT_OVERVIEW_PLOT_HEIGHT_PX = 160; +var DEFAULT_SPECTRUM_PLOT_HEIGHT_PX = 160; +var MIN_OVERVIEW_PLOT_HEIGHT_PX = 90; +var MIN_SPECTRUM_PLOT_HEIGHT_PX = 130; +var DEFAULT_SIGNAL_SPLIT_PERCENT = 50; +var MIN_SIGNAL_SPLIT_PERCENT = 20; +var MAX_SIGNAL_SPLIT_PERCENT = 80; +var spectrumLayoutPending = false; +var spectrumManualTotalPlotHeightPx = null; +var spectrumResizeState = null; +var signalSplitPercent = clampSignalSplitPercent( + Number(loadSetting("signalSplitPercent", DEFAULT_SIGNAL_SPLIT_PERCENT)) +); +function scheduleSpectrumLayout() { + if (spectrumLayoutPending) return; + spectrumLayoutPending = true; + requestAnimationFrame(() => { + spectrumLayoutPending = false; + updateSpectrumAutoHeight(); + }); +} +function clampSignalSplitPercent(value) { + const numeric = isFiniteNumber(value) ? value : DEFAULT_SIGNAL_SPLIT_PERCENT; + return Math.max( + MIN_SIGNAL_SPLIT_PERCENT, + Math.min(MAX_SIGNAL_SPLIT_PERCENT, Math.round(numeric)) + ); +} +function updateSignalSplitControlText() { + if (!signalSplitValueEl) return; + signalSplitValueEl.textContent = `${signalSplitPercent}/${100 - signalSplitPercent}`; +} +function setSignalSplitControlVisible(visible) { + if (!signalSplitControlEl) return; + signalSplitControlEl.style.display = visible ? "flex" : "none"; +} +function currentOverviewHeightPx(overviewCanvasEl) { + return Math.max( + MIN_OVERVIEW_PLOT_HEIGHT_PX, + Math.round(overviewCanvasEl?.clientHeight || DEFAULT_OVERVIEW_PLOT_HEIGHT_PX) + ); +} +function currentSpectrumHeightPx(spectrumCanvasEl) { + return Math.max( + MIN_SPECTRUM_PLOT_HEIGHT_PX, + Math.round(spectrumCanvasEl?.clientHeight || DEFAULT_SPECTRUM_PLOT_HEIGHT_PX) + ); +} +function spectrumHeightBoundsPx(tabMain, content, overviewCanvasEl, spectrumCanvasEl) { + const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); + const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); + const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight; + const tabBottom = tabMain.getBoundingClientRect().bottom; + const contentBottom = content.getBoundingClientRect().bottom; + const slackPx = Math.floor(tabBottom - contentBottom); + const minTotalHeight = MIN_OVERVIEW_PLOT_HEIGHT_PX + MIN_SPECTRUM_PLOT_HEIGHT_PX; + const maxAutoTotalHeight = Math.max( + minTotalHeight, + currentTotalHeight + slackPx - 2 + ); + return { + minTotal: minTotalHeight, + autoMaxTotal: maxAutoTotalHeight + }; +} +function updateSpectrumAutoHeight() { + const root = document.documentElement; + const overviewCanvasEl = document.getElementById("overview-canvas"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + if (!root || !tabMainEl || !contentEl || !overviewCanvasEl || !spectrumPanelEl || !spectrumCanvasEl) return; + const mainVisible = getComputedStyle(tabMainEl).display !== "none"; + const contentVisible = getComputedStyle(contentEl).display !== "none"; + const spectrumVisible = getComputedStyle(spectrumPanelEl).display !== "none"; + const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); + const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); + if (!mainVisible || !contentVisible || !spectrumVisible) { + setSignalSplitControlVisible(false); + const dimensionsChanged = currentOverviewHeight !== DEFAULT_OVERVIEW_PLOT_HEIGHT_PX || currentSpectrumHeight !== DEFAULT_SPECTRUM_PLOT_HEIGHT_PX; + root.style.setProperty("--overview-plot-height", `${DEFAULT_OVERVIEW_PLOT_HEIGHT_PX}px`); + root.style.setProperty("--spectrum-plot-height", `${DEFAULT_SPECTRUM_PLOT_HEIGHT_PX}px`); + if (dimensionsChanged) { + resizeHeaderSignalCanvas(); + scheduleOverviewDraw(); + if (lastSpectrumData) scheduleSpectrumDraw(); + } + return; + } + setSignalSplitControlVisible(true); + const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); + const nextTotalHeight = spectrumManualTotalPlotHeightPx == null ? bounds.autoMaxTotal : Math.max(bounds.minTotal, Math.round(spectrumManualTotalPlotHeightPx)); + if (spectrumManualTotalPlotHeightPx != null) { + spectrumManualTotalPlotHeightPx = nextTotalHeight; + } + const requestedOverviewHeight = Math.round(nextTotalHeight * signalSplitPercent / 100); + const nextOverviewHeight = Math.max( + MIN_OVERVIEW_PLOT_HEIGHT_PX, + Math.min(nextTotalHeight - MIN_SPECTRUM_PLOT_HEIGHT_PX, requestedOverviewHeight) + ); + const nextSpectrumHeight = Math.max( + MIN_SPECTRUM_PLOT_HEIGHT_PX, + nextTotalHeight - nextOverviewHeight + ); + if (Math.abs(nextOverviewHeight - currentOverviewHeight) < 2 && Math.abs(nextSpectrumHeight - currentSpectrumHeight) < 2) return; + root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`); + root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`); + if (typeof _updateCachedCanvasSizes === "function") _updateCachedCanvasSizes(); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + scheduleSpectrumWaterfallDraw(); + } +} +function beginSpectrumResize(clientY) { + const overviewCanvasEl = document.getElementById("overview-canvas"); + const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + if (!tabMainEl || !contentEl || !overviewCanvasEl || !spectrumCanvasEl || !spectrumPanelEl) return false; + if (getComputedStyle(spectrumPanelEl).display === "none") return false; + const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); + const startTotalHeight = Math.max( + bounds.minTotal, + currentOverviewHeightPx(overviewCanvasEl) + currentSpectrumHeightPx(spectrumCanvasEl) + ); + spectrumResizeState = { + startY: clientY, + startTotalHeight, + minTotalHeight: bounds.minTotal + }; + document.body.classList.add("spectrum-resizing"); + return true; +} +function updateSpectrumResize(clientY) { + if (!spectrumResizeState) return; + const deltaY = clientY - spectrumResizeState.startY; + spectrumManualTotalPlotHeightPx = Math.max( + spectrumResizeState.minTotalHeight, + Math.round(spectrumResizeState.startTotalHeight + deltaY) + ); + updateSpectrumAutoHeight(); +} +function endSpectrumResize() { + spectrumResizeState = null; + document.body.classList.remove("spectrum-resizing"); +} +var spectrumSizeGrip = document.getElementById("spectrum-size-grip"); +if (spectrumSizeGrip) { + spectrumSizeGrip.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + if (!beginSpectrumResize(event.clientY)) return; + event.preventDefault(); + if (typeof spectrumSizeGrip.setPointerCapture === "function") { + spectrumSizeGrip.setPointerCapture(event.pointerId); + } + }); + spectrumSizeGrip.addEventListener("pointermove", (event) => { + if (!spectrumResizeState) return; + updateSpectrumResize(event.clientY); + }); + const finishResize = (event) => { + if (!spectrumResizeState) return; + if (typeof spectrumSizeGrip.releasePointerCapture === "function" && spectrumSizeGrip.hasPointerCapture(event.pointerId)) { + spectrumSizeGrip.releasePointerCapture(event.pointerId); + } + endSpectrumResize(); + }; + spectrumSizeGrip.addEventListener("pointerup", finishResize); + spectrumSizeGrip.addEventListener("pointercancel", finishResize); + spectrumSizeGrip.addEventListener("dblclick", () => { + spectrumManualTotalPlotHeightPx = null; + scheduleSpectrumLayout(); + }); +} +if (signalSplitSliderEl) { + signalSplitSliderEl.value = String(signalSplitPercent); + signalSplitSliderEl.addEventListener("input", () => { + signalSplitPercent = clampSignalSplitPercent(Number(signalSplitSliderEl.value)); + signalSplitSliderEl.value = String(signalSplitPercent); + updateSignalSplitControlText(); + saveSetting("signalSplitPercent", signalSplitPercent); + scheduleSpectrumLayout(); + }); + signalSplitSliderEl.addEventListener("dblclick", (event) => { + event.preventDefault(); + signalSplitPercent = DEFAULT_SIGNAL_SPLIT_PERCENT; + signalSplitSliderEl.value = String(signalSplitPercent); + updateSignalSplitControlText(); + saveSetting("signalSplitPercent", signalSplitPercent); + scheduleSpectrumLayout(); + }); +} +updateSignalSplitControlText(); +function updateTitle() { + const titleEl = document.getElementById("rig-title"); + if (titleEl) { + if (ownerWebsiteUrl) { + const label = ownerWebsiteName || displayLabelFromUrl(ownerWebsiteUrl); + titleEl.innerHTML = `${escapeHtml(label)}`; + } else { + titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; + } + } + updateDocumentTitle(activeChannelRds()); +} +function displayLabelFromUrl(url) { + try { + const host = new URL(url).hostname.replace(/^www\./i, ""); + return host || url; + } catch (_e) { + return url; + } +} +window.buildAisVesselUrl = function(mmsi) { + if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null; + return `${aisVesselUrlBase}${String(mmsi)}`; +}; +function render(update) { + if (!update) return; + if (update.server_version) serverVersion = update.server_version; + if (update.server_build_date) serverBuildDate = update.server_build_date; + if (update.server_callsign) serverCallsign = update.server_callsign; + if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) { + ownerCallsign = update.owner_callsign; + } + if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) { + ownerWebsiteUrl = update.owner_website_url; + } + if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) { + ownerWebsiteName = update.owner_website_name; + } + if (typeof update.ais_vessel_url_base === "string" && update.ais_vessel_url_base.length > 0) { + aisVesselUrlBase = update.ais_vessel_url_base; + } + const prevLat = serverLat, prevLon = serverLon; + if (update.server_latitude != null) serverLat = update.server_latitude; + if (update.server_longitude != null) serverLon = update.server_longitude; + if (locationSubtitle && isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && (serverLat !== prevLat || serverLon !== prevLon || !locationSubtitle.textContent)) { + const grid = latLonToMaidenhead(serverLat, serverLon); + locationSubtitle.textContent = `Location: ${grid}`; + locationSubtitle.style.display = ""; + window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid); + } + window.trx.modules.map?.syncAprsReceiverMarker(); + if (typeof update.initial_map_zoom === "number" && isFiniteNumber(update.initial_map_zoom)) { + initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); + } + if (typeof update.spectrum_coverage_margin_hz === "number" && isFiniteNumber(update.spectrum_coverage_margin_hz)) { + spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); + } + if (typeof update.spectrum_usable_span_ratio === "number" && isFiniteNumber(update.spectrum_usable_span_ratio)) { + spectrumUsableSpanRatio = Math.max(0.01, Math.min(1, Number(update.spectrum_usable_span_ratio))); + } + if (typeof update.decode_history_retention_min === "number" && isFiniteNumber(update.decode_history_retention_min) && update.decode_history_retention_min > 0) { + const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); + if (nextRetentionMin !== decodeHistoryRetentionMin) { + decodeHistoryRetentionMin = nextRetentionMin; + if (typeof window.applyDecodeHistoryRetention === "function") { + window.applyDecodeHistoryRetention(); + } + } + } + scheduleSpectrumLayout(); + updateTitle(); + initialized = !!update.initialized; + const hasUsableSnapshot = !!update.info && !!update.status && !!update.status.freq && typeof update.status.freq.hz === "number"; + if (!initialized) { + const fallbackRigName = originalTitle || "Rig"; + const manu = update.info && update.info.manufacturer || fallbackRigName; + const model = update.info && update.info.model || fallbackRigName; + const rev = update.info && update.info.revision || ""; + const parts = [manu, model, rev].filter(Boolean).join(" "); + if (!hasUsableSnapshot) { + loadingTitle.textContent = `Initializing ${parts}…`; + loadingSub.textContent = ""; + console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); + loadingEl.style.display = ""; + if (contentEl) contentEl.style.display = "none"; + powerHint.textContent = "Initializing rig…"; + setDisabled(true); + return; + } + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + powerHint.textContent = "Rig not fully initialized yet"; + } else { + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + } + if (serverSubtitle && update.server_callsign) { + const base = serverSubtitle.textContent.split(" hosted by")[0]; + const safeCallsign = escapeHtml(update.server_callsign); + const encodedCallsign = encodeURIComponent(update.server_callsign); + serverSubtitle.innerHTML = `${escapeHtml(base)} hosted by ${safeCallsign}`; + } + updateRigSubtitle(lastActiveRigId); + if (ownerSubtitle) { + if (ownerCallsign) { + const safeOwner = escapeHtml(ownerCallsign); + const encodedOwner = encodeURIComponent(ownerCallsign); + ownerSubtitle.innerHTML = `Owner: ${safeOwner}`; + } else { + ownerSubtitle.textContent = "Owner: --"; + } + } + setDisabled(false); + if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) { + const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean); + if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) { + supportedModes = modes; + modeEl.replaceChildren(); + supportedModes.forEach((m) => { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + modeEl.appendChild(opt); + }); + } + } + if (update.info && update.info.capabilities) { + updateJogStepSupport(update.info.capabilities); + updateSupportedBands(update.info.capabilities); + applyCapabilities(update.info.capabilities); + } + if (update.filter && typeof update.filter.bandwidth_hz === "number") { + currentBandwidthHz = update.filter.bandwidth_hz; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(currentBandwidthHz); + positionFastOverlay(lastFreqHz, currentBandwidthHz); + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } + if (sdrGainEl && typeof update.filter.sdr_gain_db === "number" && document.activeElement !== sdrGainEl) { + sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db)); + } + if (sdrLnaGainEl && typeof update.filter.sdr_lna_gain_db === "number" && document.activeElement !== sdrLnaGainEl) { + sdrLnaGainEl.value = String(Math.round(update.filter.sdr_lna_gain_db)); + if (sdrLnaGainControlsEl) sdrLnaGainControlsEl.style.display = ""; + } + if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") { + wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us); + } + if (wfmAudioModeEl && typeof update.filter.wfm_stereo === "boolean") { + const nextMode = update.filter.wfm_stereo ? "stereo" : "mono"; + if (wfmAudioModeEl.value !== nextMode) { + wfmAudioModeEl.value = nextMode; + saveSetting("wfmAudioMode", nextMode); + } + } + if (wfmDenoiseEl && (typeof update.filter.wfm_denoise === "string" || typeof update.filter.wfm_denoise === "boolean")) { + const nextDenoise = typeof update.filter.wfm_denoise === "string" ? normalizeWfmDenoiseLevel(update.filter.wfm_denoise) : update.filter.wfm_denoise ? "auto" : "off"; + if (wfmDenoiseEl.value !== nextDenoise) { + wfmDenoiseEl.value = nextDenoise; + saveSetting("wfmDenoise", nextDenoise); + } + } + if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") { + const detected = update.filter.wfm_stereo_detected; + wfmStFlagEl.textContent = detected ? "ST" : "MO"; + wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); + wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); + } + if (typeof update.filter.wfm_cci === "number") { + lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci)); + updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci); + } + if (typeof update.filter.wfm_aci === "number") { + lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci)); + updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci); + } + if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") { + samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100)); + } + if (samCarrierSyncEl && typeof update.filter.sam_carrier_sync === "boolean") { + const nextVal = update.filter.sam_carrier_sync ? "on" : "off"; + if (samCarrierSyncEl.value !== nextVal) samCarrierSyncEl.value = nextVal; + } + const hasSdrSquelchEnabled = typeof update.filter.sdr_squelch_enabled === "boolean"; + const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number"; + if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) { + sdrSquelchSupported = true; + syncSdrSquelchFromServer( + hasSdrSquelchEnabled ? update.filter.sdr_squelch_enabled === true : true, + hasSdrSquelchThreshold && typeof update.filter.sdr_squelch_threshold_db === "number" ? update.filter.sdr_squelch_threshold_db : -120 + ); + } + updateSdrSquelchControlVisibility(); + const hasSdrNbEnabled = typeof update.filter.sdr_nb_enabled === "boolean"; + const hasSdrNbThreshold = typeof update.filter.sdr_nb_threshold === "number"; + if (hasSdrNbEnabled || hasSdrNbThreshold) { + sdrNbSupported = true; + if (sdrNbWrapEl) sdrNbWrapEl.style.display = ""; + if (sdrNbThresholdControlsEl) sdrNbThresholdControlsEl.style.display = ""; + if (hasSdrNbEnabled && sdrNbEnabledEl) { + sdrNbEnabledEl.checked = update.filter.sdr_nb_enabled === true; + } + if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) { + if (typeof update.filter.sdr_nb_threshold === "number") { + sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold)); + } + } + } + } + if (typeof update.show_sdr_gain_control === "boolean") { + if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none"; + } + if (!_bandplanServerDefaultApplied && typeof update.bandplan_enabled === "boolean" && typeof update.bandplan_region === "string") { + _bandplanServerDefaultApplied = true; + const hasUserOverride = localStorage.getItem("trx_bandplanRegion") !== null; + if (!hasUserOverride) { + const region = update.bandplan_enabled ? update.bandplan_region : "off"; + bandplanRegion = region; + saveSetting("bandplanRegion", region); + if (bandplanRegionSelect) bandplanRegionSelect.value = region; + bandplanSegmentsCache = null; + bandplanCacheKey = ""; + if (lastSpectrumData) scheduleSpectrumDraw(); + } + } + if (update.filter && sdrAgcEl && typeof update.filter.sdr_agc_enabled === "boolean") { + sdrAgcEl.checked = update.filter.sdr_agc_enabled; + updateSdrGainInputState(); + } + if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { + if (update.status.freq.hz !== prevRenderData.freqHz) { + prevRenderData.freqHz = update.status.freq.hz; + const sseHz = update.status.freq.hz; + if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) > 1) { + } else { + if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) <= 1) { + _freqOptimisticHz = null; + } + applyLocalTunedFrequency(sseHz); + } + } + } + if (update.status && update.status.mode && update.status.mode !== prevRenderData.mode) { + prevRenderData.mode = update.status.mode; + const mode = normalizeMode(update.status.mode); + const modeUpper2 = mode ? mode.toUpperCase() : ""; + const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; + if (!onVirtual) { + modeEl.value = modeUpper2; + if (modeUpper2 === "WFM" && lastModeName !== "WFM") { + setJogDivisor(10); + resetRdsDisplay(); + } else if (modeUpper2 !== "WFM" && lastModeName === "WFM") { + resetRdsDisplay(); + } + lastModeName = modeUpper2; + if (lastSpectrumData && !update.filter) { + void applyBwDefaultForMode(mode, false); + } + } + updateWfmControls(); + updateSdrSquelchControlVisibility(); + } + const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; + for (const d of decoderRegistry) { + if (d.activation !== "mode_bound") continue; + const el = document.getElementById(d.id + "-status"); + if (!el) continue; + const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; + setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); + } + if (window.updateAisBar) window.updateAisBar(); + if (window.updateVdesBar) window.updateVdesBar(); + if (window.updateAprsBar) window.updateAprsBar(); + if (window.updateFt8Bar) window.updateFt8Bar(); + for (const d of decoderRegistry) { + if (d.activation !== "toggle") continue; + const key = d.id.replace(/-/g, "_") + "_decode_enabled"; + const enabled = !!update[key]; + const modeMatch = d.active_modes.includes(modeUpper); + const el = document.getElementById(d.id + "-status"); + if (el && (!enabled || !modeMatch) && el.textContent === "Receiving") { + el.textContent = "Connected, listening for packets"; + } + } + if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) { + prevRenderData.txEn = update.status.tx_en; + lastTxEn = update.status.tx_en; + window.trxUi?.setButtonState(pttBtn, { + active: update.status.tx_en, + activeLabel: "Stop TX", + inactiveLabel: "Start TX" + }); + if (update.status.tx_en) { + pttBtn.style.background = "var(--accent-red)"; + pttBtn.style.borderColor = "var(--accent-red)"; + pttBtn.style.color = "white"; + } else { + pttBtn.style.background = ""; + pttBtn.style.borderColor = ""; + pttBtn.style.color = ""; + } + } + _ensureDecoderToggles(); + for (const [key, entry] of Object.entries(_decoderToggles)) { + syncDecoderToggle(entry, !!update[key], entry.label); + } + if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) { + window.syncWefaxToggle(update.wefax_decode_enabled); + } + if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) { + window._syncRecorderState(update.recorder_enabled); + } + if (window.updateSatLiveState) window.updateSatLiveState(update); + if (cwWpmEl && typeof update.cw_wpm === "number") { + cwWpmEl.value = String(update.cw_wpm); + } + if (cwToneEl && typeof update.cw_tone_hz === "number") { + cwToneEl.value = String(update.cw_tone_hz); + } + if (typeof update.cw_auto === "boolean") { + if (typeof window.applyCwAutoUiFromServer === "function") { + window.applyCwAutoUiFromServer(update.cw_auto); + } else if (typeof window.applyCwAutoUi === "function") { + window.applyCwAutoUi(update.cw_auto); + } else { + if (cwAutoEl) cwAutoEl.checked = update.cw_auto; + if (cwWpmEl) { + cwWpmEl.disabled = update.cw_auto; + cwWpmEl.readOnly = update.cw_auto; + } + if (cwToneEl) { + cwToneEl.disabled = update.cw_auto; + cwToneEl.readOnly = update.cw_auto; + } + } + } + let activeFreqColor = "var(--accent-green)"; + if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { + const entries = update.status.vfo.entries; + const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null; + vfoPicker.replaceChildren(); + entries.forEach((entry, idx) => { + const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null; + if (hz === null) return; + const mode = entry.mode ? normalizeMode(entry.mode) : ""; + const modeText = mode ? ` [${mode}]` : ""; + const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFrequency(hz)}${modeText}`; + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + const color = vfoColor(idx); + if (activeIdx === idx) { + btn.classList.add("active"); + btn.style.color = color; + activeFreqColor = color; + } else btn.addEventListener("click", async () => { + btn.disabled = true; + showHint("Toggling VFO…"); + try { + await postPath("/toggle_vfo"); + showHint("VFO toggled", 1200); + } catch (err) { + showHint("VFO toggle failed", 2e3); + console.error(err); + } finally { + btn.disabled = false; + } + }); + vfoPicker.appendChild(btn); + }); + } else { + vfoPicker.innerHTML = ''; + } + if (freqEl) { + freqEl.style.color = activeFreqColor; + } + if (update.status && update.status.rx && typeof update.status.rx.sig === "number") { + if (update.status.rx.sig !== prevRenderData.sigDbm) { + prevRenderData.sigDbm = update.status.rx.sig; + const sUnits = dbmToSUnits(update.status.rx.sig); + sigLastSUnits = sUnits; + sigLastDbm = update.status.rx.sig; + const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; + signalBar.style.width = `${pct}%`; + signalValue.innerHTML = formatSignal(sUnits); + refreshSigStrengthDisplay(); + } + } else if (prevRenderData.sigDbm !== null) { + prevRenderData.sigDbm = null; + sigLastSUnits = null; + sigLastDbm = null; + signalBar.style.width = "0%"; + signalValue.textContent = "--"; + refreshSigStrengthDisplay(); + } + if (bandLabel) { + bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; + } + if (typeof update.enabled === "boolean") { + window.trxUi?.setButtonState(powerBtn, { + active: update.enabled, + activeLabel: "Power Off", + inactiveLabel: "Power On" + }); + } else { + powerBtn.disabled = true; + powerBtn.textContent = "Power unavailable"; + powerBtn.setAttribute("aria-pressed", "false"); + powerHint.textContent = "State unknown"; + } + if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { + txLimitInput.value = String(update.status.tx.limit); + txLimitRow.style.display = ""; + } else { + txLimitInput.value = ""; + txLimitRow.style.display = "none"; + } + if (typeof update.clients === "number") lastClientCount = update.clients; + if (_activeTab === "about") { + _resolveAboutEls(); + _resolveAboutDecEls(); + if (update.server_version && aboutServerVerEl) { + aboutServerVerEl.textContent = `trx-server v${update.server_version}`; + } + if (update.server_build_date && aboutServerBuildDateEl) { + aboutServerBuildDateEl.textContent = update.server_build_date; + } + if (aboutServerAddrEl) aboutServerAddrEl.textContent = location.host; + if (update.server_callsign && aboutServerCallEl) { + aboutServerCallEl.textContent = update.server_callsign; + } + if (isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && aboutServerLocationEl) { + const grid = latLonToMaidenhead(serverLat, serverLon); + aboutServerLocationEl.textContent = `${grid} (${serverLat.toFixed(4)}, ${serverLon.toFixed(4)})`; + } + if (update.info) { + const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" "); + if (parts && aboutRigInfoEl) aboutRigInfoEl.textContent = parts; + const access = update.info.access; + if (access) { + if ("Serial" in access) { + const serialPath = access.Serial.path || "?"; + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; + } else if ("Tcp" in access) { + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.addr || "?"})`; + } else { + const key = Object.keys(access)[0]; + if (key && aboutRigAccessEl) aboutRigAccessEl.textContent = key; + } + } + if (update.info.capabilities) { + const cap = update.info.capabilities; + if (Array.isArray(cap.supported_modes) && cap.supported_modes.length && aboutModesEl) { + aboutModesEl.textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", "); + } + if (typeof cap.num_vfos === "number" && aboutVfosEl) { + aboutVfosEl.textContent = String(cap.num_vfos); + } + } + } + if (lastActiveRigId && aboutActiveRigEl) { + aboutActiveRigEl.textContent = lastActiveRigId; + } + if (streamInfo) { + if (aboutAudioCodecEl) aboutAudioCodecEl.textContent = "Opus"; + if (aboutAudioSamplerateEl) aboutAudioSamplerateEl.textContent = `${(streamInfo.sample_rate || 48e3).toLocaleString()} Hz`; + if (aboutAudioChannelsEl) aboutAudioChannelsEl.textContent = (streamInfo.channels || 1) === 1 ? "Mono" : "Stereo"; + if (streamInfo.bitrate_bps && aboutAudioBitrateEl) { + const kbps = (streamInfo.bitrate_bps / 1e3).toFixed(0); + aboutAudioBitrateEl.textContent = `${kbps} kbps`; + } + if (streamInfo.frame_duration_ms && aboutAudioFrameEl) { + aboutAudioFrameEl.textContent = `${streamInfo.frame_duration_ms} ms`; + } + } + if (aboutAudioRxEl) aboutAudioRxEl.textContent = rxActive ? "Active" : "Off"; + if (typeof update.audio_clients === "number" && aboutAudioStreamsEl) { + aboutAudioStreamsEl.textContent = String(update.audio_clients); + } + syncAboutDecoder(0, !!update.ft8_decode_enabled); + syncAboutDecoder(1, !!update.ft4_decode_enabled); + syncAboutDecoder(2, !!update.ft2_decode_enabled); + syncAboutDecoder(3, !!update.wspr_decode_enabled); + syncAboutDecoder(4, !!update.cw_decode_enabled); + syncAboutDecoder(5, !!(update.aprs_decode_enabled || update.hf_aprs_decode_enabled)); + syncAboutDecoder(6, !!update.lrpt_decode_enabled); + if (update.pskreporter_status && aboutPskreporterEl) { + aboutPskreporterEl.textContent = update.pskreporter_status; + } + if (update.aprs_is_status && aboutAprsIsEl) { + aboutAprsIsEl.textContent = update.aprs_is_status; + } + if (typeof update.rigctl_clients === "number" && aboutRigctlClientsEl) { + aboutRigctlClientsEl.textContent = String(update.rigctl_clients); + } + if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0 && aboutRigctlEndpointEl) { + aboutRigctlEndpointEl.textContent = update.rigctl_addr; + } + if (typeof update.clients === "number" && aboutClientsEl) { + aboutClientsEl.textContent = String(update.clients); + } + } + if (Array.isArray(update.remotes)) { + applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes); + } + powerHint.textContent = readyText(); + lastLocked = update.status?.lock === true; + window.trxUi?.setButtonState(lockBtn, { + active: lastLocked, + activeLabel: "Unlock Tuning", + inactiveLabel: "Lock Tuning" + }); + const tx = update.status && update.status.tx ? update.status.tx : null; + txMeters.style.display = lastHasTx ? "" : "none"; + if (tx && typeof tx.power === "number") { + const pct = Math.max(0, Math.min(100, tx.power)); + pwrBar.style.width = `${pct}%`; + pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`; + } else { + pwrBar.style.width = "0%"; + pwrValue.textContent = "PWR --"; + } + if (tx && typeof tx.swr === "number") { + const swr = Math.max(1, tx.swr); + const pct = Math.max(0, Math.min(100, (swr - 1) / 2 * 100)); + swrBar.style.width = `${pct}%`; + swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`; + } else { + swrBar.style.width = "0%"; + swrValue.textContent = "SWR --"; + } +} +function scheduleReconnect(delayMs = 1e3) { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delayMs); +} +async function pollFreshSnapshot() { + try { + const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status"; + const resp = await fetch(statusUrl, { cache: "no-store" }); + if (!resp.ok) return; + const data = await responseJsonUnknown(resp); + if (!isAppUpdate(data)) return; + render(data); + void refreshRigList(); + lastEventAt = Date.now(); + } catch (e) { + } +} +function connect() { + if (es) { + es.close(); + sseSessionId = null; + } + if (esHeartbeat) { + clearInterval(esHeartbeat); + } + stopMeterStreaming(); + startMeterStreaming(); + void pollFreshSnapshot(); + const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events"; + es = new EventSource(eventsUrl); + const source = es; + lastEventAt = Date.now(); + es.onopen = () => { + setConnLostOverlay(false); + if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); + if (!aboutUptimeStart) aboutUptimeStart = Date.now(); + void pollFreshSnapshot(); + void refreshRigList(); + }; + source.onmessage = (evt) => { + try { + if (evt.data === lastRendered) return; + const data = parseJsonUnknown(evt.data); + if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape"); + lastRendered = evt.data; + render(data); + lastEventAt = Date.now(); + if (data.server_connected === false) { + powerHint.textContent = "trx-server connection lost"; + if (tabMainEl) tabMainEl.classList.add("server-disconnected"); + } else { + if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); + if (data.initialized) powerHint.textContent = readyText(); + } + } catch (e) { + console.error("Bad event data", e); + } + }; + source.addEventListener("ping", () => { + lastEventAt = Date.now(); + }); + source.addEventListener("session", (evt) => { + try { + const eventData = messageEventData(evt); + const d = parseJsonUnknown(eventData); + sseSessionId = isRecord2(d) && typeof d.session_id === "string" ? d.session_id : null; + } catch (_) { + } + window.trx.modules.vchan?.handleSession(messageEventData(evt)); + }); + source.addEventListener("channels", (evt) => { + window.trx.modules.vchan?.handleChannels(messageEventData(evt)); + }); + source.onerror = () => { + if (source.readyState === EventSource.CLOSED) { + powerHint.textContent = "trx-client connection lost, retrying…"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); + source.close(); + void pollFreshSnapshot(); + scheduleReconnect(1e3); + } + }; + esHeartbeat = setInterval(() => { + const now = Date.now(); + if (now - lastEventAt > 15e3) { + powerHint.textContent = "trx-client connection lost, retrying…"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); + source.close(); + void pollFreshSnapshot(); + scheduleReconnect(250); + } + }, 5e3); +} +function disconnect() { + if (es) { + es.close(); + es = null; + } + if (decodeSource) { + decodeSource.close(); + decodeSource = null; + } + stopSpectrumStreaming(); + stopMeterStreaming(); + if (esHeartbeat) { + clearInterval(esHeartbeat); + esHeartbeat = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + setDecodeHistoryOverlayVisible(false); + setConnLostOverlay(false); +} +var uiFrameJobs = /* @__PURE__ */ new Map(); +var uiFrameJobsHandle = null; +function flushUiFrameJobs() { + uiFrameJobsHandle = null; + const jobs = Array.from(uiFrameJobs.values()); + uiFrameJobs.clear(); + for (const job of jobs) { + try { + job(); + } catch (err) { + console.error("Deferred UI job failed:", err); + } + } +} +function scheduleUiFrameJob(key, job) { + if (typeof job !== "function") return; + uiFrameJobs.set(key, job); + if (uiFrameJobsHandle !== null) return; + if (typeof requestAnimationFrame === "function") { + uiFrameJobsHandle = requestAnimationFrame(flushUiFrameJobs); + } else { + uiFrameJobsHandle = setTimeout(flushUiFrameJobs, 16); + } +} +window.trxScheduleUiFrameJob = scheduleUiFrameJob; +async function postPath(path, options = {}) { + if (rigSwitchInProgress && !options.allowDuringRigSwitch) { + throw new Error("Wait for the rig switch to finish"); + } + const targetRigId = options.remote === void 0 ? lastActiveRigId : options.remote; + if (targetRigId && !path.includes("remote=")) { + const sep = path.includes("?") ? "&" : "?"; + path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; + } + const resp = await fetch(path, { method: "POST" }); + if (authEnabled && resp.status === 401) { + authRole = null; + if (es) es.close(); + showAuthGate(); + throw new Error("Authentication required"); + } + if (resp.status === 403) { + throw new Error("Insufficient permissions"); + } + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text || resp.statusText); + } + return resp; +} +async function takeSchedulerControlForDecoderDisable(buttonEl) { + const enabled = buttonEl?.dataset?.enabled === "true" || /^\s*Disable\b/i.test(buttonEl?.textContent || ""); + if (!enabled) return; + await window.trx.modules.vchan?.takeSchedulerControl(); +} +window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable; +async function switchRigFromSelect(selectEl) { + if (!selectEl || !selectEl.value) { + showHint("No rig selected", 1500); + return; + } + if (authRole === "rx") { + showHint("Control role required", 1500); + return; + } + if (!lastRigIds.includes(selectEl.value)) { + showHint("Unknown rig", 1500); + return; + } + const prevRig = lastActiveRigId; + const nextRig = selectEl.value; + if (nextRig === prevRig || rigSwitchInProgress) return; + rigSwitchInProgress = true; + setControlPending(selectEl, true); + selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); + updateRigIdentitySummary(nextRig, true); + showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`); + try { + const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; + await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); + lastActiveRigId = nextRig; + resetDecoderStateOnRigSwitch(); + updateRigSubtitle(lastActiveRigId); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); + window.trx.modules.map?.syncAprsReceiverMarker(); + connect(); + stopSpectrumStreaming(); + startSpectrumStreaming(); + stopMeterStreaming(); + startMeterStreaming(); + if (rxActive) { + stopRxAudio(); + startRxAudio(); + } + showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500); + } catch (err) { + console.error("select_rig failed:", err); + selectEl.value = prevRig || ""; + updateRigIdentitySummary(prevRig); + window.trxUi?.notify("Rig could not be switched", { kind: "error" }); + } finally { + rigSwitchInProgress = false; + setControlPending(selectEl, false); + selectEl.closest(".header-rig-switch")?.classList.remove("is-switching"); + } +} +if (headerRigSwitchSelect) { + headerRigSwitchSelect.addEventListener("change", () => { + void switchRigFromSelect(headerRigSwitchSelect); + }); +} +function setControlPending(control, pending) { + if (!control) return; + control.disabled = pending; + control.classList.toggle("is-busy", pending); + control.setAttribute("aria-busy", String(pending)); +} +powerBtn.addEventListener("click", async () => { + setControlPending(powerBtn, true); + showHint("Sending..."); + try { + await postPath("/toggle_power"); + showHint("Toggled, waiting for update…"); + } catch (err) { + showHint("Toggle failed", 2e3); + console.error(err); + } finally { + setControlPending(powerBtn, false); + } +}); +pttBtn.addEventListener("click", async () => { + setControlPending(pttBtn, true); + showHint("Toggling PTT…"); + try { + const desired = lastTxEn ? "false" : "true"; + await postPath(`/set_ptt?ptt=${desired}`); + showHint("PTT command sent", 1500); + } catch (err) { + showHint("PTT toggle failed", 2e3); + console.error(err); + } finally { + setControlPending(pttBtn, false); + } +}); +function applyFreqFromInput() { + const parsedRaw = parseFrequencyInput(freqEl.value, jogUnit, modeEl?.value || ""); + if (parsedRaw === null) { + showHint("Freq missing", 1500); + return; + } + const parsed = alignFreqToRigStep(parsedRaw); + if (!freqAllowed(parsed)) { + showUnsupportedFreqPopup(parsed); + return; + } + freqDirty = false; + setRigFrequency(parsed); +} +async function applyCenterFreqFromInput() { + if (!centerFreqEl) return; + const parsedRaw = parseFrequencyInput(centerFreqEl.value, jogUnit, modeEl?.value || ""); + if (parsedRaw === null) { + showHint("Central freq missing", 1500); + return; + } + const parsed = alignFreqToRigStep(parsedRaw); + if (!freqAllowed(parsed)) { + showUnsupportedFreqPopup(parsed); + return; + } + centerFreqDirty = false; + setControlPending(centerFreqEl, true); + showHint("Setting central frequency…"); + try { + await postPath(`/set_center_freq?hz=${parsed}`); + showHint("Central freq set", 1500); + } catch (err) { + showHint("Set central freq failed", 2e3); + console.error(err); + } finally { + setControlPending(centerFreqEl, false); + } +} +freqEl.addEventListener("keydown", (e) => { + freqDirty = true; + if (e.key === "Enter") { + e.preventDefault(); + applyFreqFromInput(); + } else if (e.key === "Escape") { + freqDirty = false; + refreshFreqDisplay(); + freqEl.blur(); + } +}); +freqEl.addEventListener("blur", () => { + if (freqDirty) { + freqDirty = false; + refreshFreqDisplay(); + } +}); +if (centerFreqEl) { + centerFreqEl.addEventListener("keydown", (e) => { + centerFreqDirty = true; + if (e.key === "Enter") { + e.preventDefault(); + void applyCenterFreqFromInput(); + } else if (e.key === "Escape") { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + centerFreqEl.blur(); + } + }); + centerFreqEl.addEventListener("blur", () => { + if (centerFreqDirty) { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + } + }); + centerFreqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); + }, { passive: false }); +} +freqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); +}, { passive: false }); +var jogWheel = requiredElement("jog-wheel"); +var jogIndicator = requiredElement("jog-indicator"); +var jogDownBtn = requiredElement("jog-down"); +var jogUpBtn = requiredElement("jog-up"); +var jogStepEl = requiredElement("jog-step"); +var jogMultEl = requiredElement("jog-mult"); +var VALID_JOG_DIVISORS = /* @__PURE__ */ new Set([1, 10]); +function applyJogStep() { + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); + saveSetting("jogUnit", jogUnit); + saveSetting("jogMult", jogMult); + saveSetting("jogStep", jogStep); + refreshFreqDisplay(); + refreshCenterFreqDisplay(); +} +function setJogDivisor(divisor) { + const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1; + jogMult = next; + if (jogMultEl) { + jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => { + b.classList.toggle("active", parseInt(b.dataset.mult ?? "", 10) === jogMult); + }); + } + applyJogStep(); +} +function jogFreq(direction) { + if (lastLocked) { + showHint("Locked", 1500); + return; + } + if (lastFreqHz === null) return; + const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep); + if (!freqAllowed(newHz)) { + showUnsupportedFreqPopup(newHz); + return; + } + jogAngle = (jogAngle + direction * 15) % 360; + jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`; + setRigFrequency(newHz); +} +jogDownBtn.addEventListener("click", () => jogFreq(-1)); +jogUpBtn.addEventListener("click", () => jogFreq(1)); +jogWheel.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); +}, { passive: false }); +var jogTouchY = null; +jogWheel.addEventListener("touchstart", (e) => { + e.preventDefault(); + jogTouchY = e.touches[0]?.clientY ?? null; +}, { passive: false }); +jogWheel.addEventListener("touchmove", (e) => { + e.preventDefault(); + if (jogTouchY === null) return; + const touch = e.touches[0]; + if (!touch) return; + const dy = jogTouchY - touch.clientY; + if (Math.abs(dy) > 12) { + jogFreq(dy > 0 ? 1 : -1); + jogTouchY = touch.clientY; + } +}, { passive: false }); +jogWheel.addEventListener("touchend", () => { + jogTouchY = null; +}); +var jogMouseY = null; +jogWheel.addEventListener("mousedown", (e) => { + e.preventDefault(); + jogMouseY = e.clientY; + jogWheel.style.cursor = "grabbing"; +}); +window.addEventListener("mousemove", (e) => { + if (jogMouseY === null) return; + const dy = jogMouseY - e.clientY; + if (Math.abs(dy) > 10) { + jogFreq(dy > 0 ? 1 : -1); + jogMouseY = e.clientY; + } +}); +window.addEventListener("mouseup", () => { + jogMouseY = null; + if (jogWheel) jogWheel.style.cursor = "grab"; +}); +jogStepEl.addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest("button[data-step]") : null; + if (!btn) return; + jogUnit = parseInt(btn.dataset.step ?? "", 10); + jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + applyJogStep(); +}); +if (jogMultEl) { + jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => { + const divisor = parseInt(btn.dataset.mult ?? "", 10); + if (!VALID_JOG_DIVISORS.has(divisor)) { + btn.remove(); + } + }); + jogMultEl.addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest("button[data-mult]") : null; + if (!btn) return; + setJogDivisor(parseInt(btn.dataset.mult ?? "", 10)); + }); +} +{ + const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); + const activeUnit = unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === jogUnit) || unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === 1e3) || unitBtns[0]; + if (activeUnit) { + jogUnit = parseInt(activeUnit.dataset.step ?? "", 10); + unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); + } + if (jogMultEl) { + const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); + const activeMult = multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === 1) || multBtns[0]; + if (activeMult) { + jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult ?? "", 10)) ? parseInt(activeMult.dataset.mult ?? "", 10) : 1; + multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); + } else { + jogMult = 1; + } + } + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); +} +async function applyModeFromPicker() { + const mode = modeEl.value || ""; + if (!mode) { + showHint("Mode missing", 1500); + return; + } + updateWfmControls(); + setControlPending(modeEl, true); + showHint("Setting mode…"); + try { + if (await window.trx.modules.vchan?.interceptMode(mode)) { + showHint("Channel mode set", 1500); + return; + } + await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); + showHint("Mode set", 1500); + if (mode.toUpperCase() === "WFM") { + setJogDivisor(10); + } + await applyBwDefaultForMode(mode, true); + } catch (err) { + showHint("Set mode failed", 2e3); + console.error(err); + } finally { + setControlPending(modeEl, false); + } +} +modeEl.addEventListener("change", applyModeFromPicker); +txLimitInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + txLimitBtn.click(); + } +}); +txLimitBtn.addEventListener("click", async () => { + const limit = txLimitInput.value; + if (limit === "" || limit === "--") { + showHint("Limit missing", 1500); + return; + } + setControlPending(txLimitBtn, true); + showHint("Setting TX limit…"); + try { + await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); + showHint("TX limit set", 1500); + } catch (err) { + showHint("TX limit failed", 2e3); + console.error(err); + } finally { + setControlPending(txLimitBtn, false); + } +}); +lockBtn.addEventListener("click", async () => { + setControlPending(lockBtn, true); + showHint("Toggling lock…"); + try { + const nextLock = !lastLocked; + await postPath(nextLock ? "/lock" : "/unlock"); + showHint("Lock toggled", 1500); + } catch (err) { + showHint("Lock toggle failed", 2e3); + console.error(err); + } finally { + setControlPending(lockBtn, false); + } +}); +var MODE_BW_DEFAULTS = { + CW: [500, 100, 9e3, 50], + CWR: [500, 100, 9e3, 50], + LSB: [2700, 300, 6e3, 100], + USB: [2700, 300, 6e3, 100], + AM: [9e3, 500, 2e4, 500], + SAM: [9e3, 500, 2e4, 500], + FM: [12500, 2500, 25e3, 500], + AIS: [25e3, 12500, 5e4, 500], + VDES: [1e5, 25e3, 2e5, 1e3], + WFM: [18e4, 6e4, 3e5, 5e3], + DIG: [3e3, 300, 6e3, 100], + PKT: [25e3, 300, 5e4, 500] +}; +var MODE_BW_FALLBACK = [3e3, 300, 5e5, 100]; +function mwDefaultsForMode(mode) { + return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK; +} +function formatBwLabel(hz) { + if (hz >= 1e3) return (hz / 1e3).toFixed(hz % 1e3 === 0 ? 0 : 1) + " kHz"; + return hz + " Hz"; +} +var currentBandwidthHz = 3e3; +window.currentBandwidthHz = currentBandwidthHz; +var spectrumBwInput = requiredElement("spectrum-bw-input"); +var spectrumBwSetBtn = requiredElement("spectrum-bw-set-btn"); +var spectrumBwAutoBtn = requiredElement("spectrum-bw-auto-btn"); +var spectrumBwSweetBtn = requiredElement("spectrum-bw-sweet-btn"); +function formatBandwidthInputKhz(hz) { + const khz = hz / 1e3; + if (Math.abs(Math.round(khz) - khz) < 1e-4) return String(Math.round(khz)); + if (Math.abs(Math.round(khz * 10) - khz * 10) < 1e-4) return khz.toFixed(1); + return khz.toFixed(2); +} +function syncBandwidthInput(hz) { + if (!spectrumBwInput || !isFiniteNumber(hz) || hz <= 0) return; + const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + spectrumBwInput.min = String(minBw / 1e3); + spectrumBwInput.max = String(maxBw / 1e3); + spectrumBwInput.step = String(stepBw / 1e3); + spectrumBwInput.value = formatBandwidthInputKhz(hz); +} +async function applyBwDefaultForMode(mode, sendToServer) { + const [def] = mwDefaultsForMode(mode); + currentBandwidthHz = def; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(def); + positionFastOverlay(lastFreqHz, def); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + } + if (sendToServer) { + try { + await postPath(`/set_bandwidth?hz=${def}`); + } catch (error) { + window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); + } + } +} +async function applyBandwidthFromInput() { + if (!spectrumBwInput) return; + const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + const nextKhz = Number(spectrumBwInput.value); + const next = Math.round(nextKhz * 1e3); + if (!isFiniteNumber(next) || next <= 0) { + syncBandwidthInput(currentBandwidthHz); + return; + } + const clamped = Math.max(minBw, Math.min(maxBw, next)); + currentBandwidthHz = clamped; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(clamped); + positionFastOverlay(lastFreqHz, clamped); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + } + try { + if (await window.trx.modules.vchan?.interceptBandwidth(clamped)) return; + await postPath(`/set_bandwidth?hz=${clamped}`); + if (isFiniteNumber(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz); + } + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); + } +} +async function applyAutoBandwidth() { + if (!lastSpectrumData || lastFreqHz == null) return; + const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; + const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; + const mode = (modeEl?.value || "").toUpperCase(); + const estimated = estimateOccupiedBandwidth( + lastSpectrumData, + lastFreqHz, + mode, + mwDefaultsForMode(mode), + interference + ); + if (!isFiniteNumber(estimated) || estimated <= 0) { + syncBandwidthInput(currentBandwidthHz); + return; + } + currentBandwidthHz = estimated; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(estimated); + positionFastOverlay(lastFreqHz, estimated); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + } + let reason = "measured occupied spectrum"; + if (mode === "WFM") { + if (estimated === 6e4 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; + else if (estimated === 6e4) reason = "weak-signal noise rejection"; + else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`; + else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`; + } + window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5e3 }); + try { + if (await window.trx.modules.vchan?.interceptBandwidth(estimated)) return; + await postPath(`/set_bandwidth?hz=${estimated}`); + if (isFiniteNumber(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz); + } + } catch (error) { + window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } }); + } +} +if (spectrumBwInput) { + spectrumBwInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + void applyBandwidthFromInput(); + } + }); +} +if (spectrumBwSetBtn) { + spectrumBwSetBtn.addEventListener("click", () => { + void applyBandwidthFromInput(); + }); +} +if (spectrumBwAutoBtn) { + spectrumBwAutoBtn.addEventListener("click", () => { + void applyAutoBandwidth(); + }); +} +if (spectrumBwSweetBtn) { + spectrumBwSweetBtn.addEventListener("click", () => { + applySweetSpotCenter().catch(() => { + }); + }); +} +var _activeTab = "main"; +function tabFromPath2(pathname = window.location.pathname) { + return tabFromPath(pathname); +} +var _mapInitTimer = null; +function _initMapWhenReady() { + const loadingEl2 = document.getElementById("map-loading"); + const map = window.trx.modules.map; + if (map && "L" in window) { + if (_mapInitTimer) { + clearInterval(_mapInitTimer); + _mapInitTimer = null; + } + if (loadingEl2) loadingEl2.classList.add("is-hidden"); + map.initAprsMap(); + map.sizeAprsMapToViewport(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + map.sizeAprsMapToViewport(); + map.aprsMap?.invalidateSize(); + }); + }); + return; + } + if (loadingEl2) loadingEl2.classList.remove("is-hidden"); + if (!_mapInitTimer) { + _mapInitTimer = setInterval(() => { + if (_activeTab !== "map") { + if (_mapInitTimer) clearInterval(_mapInitTimer); + _mapInitTimer = null; + return; + } + _initMapWhenReady(); + }, 100); + } +} +function navigateToTab(name, options = {}) { + window.trxUi?.closeMobileOverlays?.(); + const { updateHistory = true, replaceHistory = false } = options; + if (authEnabled && !authRole && name !== "main") { + showAuthGate(false); + return; + } + const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); + if (!btn) return; + _activeTab = name; + document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + btn.classList.add("active"); + window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const panel = document.getElementById(`tab-${name}`); + if (!panel) return; + panel.style.display = ""; + materializeTabPanel(panel); + if (name === "map" || name === "statistics") { + const peer = document.getElementById(name === "map" ? "tab-statistics" : "tab-map"); + if (peer) materializeTabPanel(peer); + } + if (updateHistory) { + updateTabHistory(name, replaceHistory); + } + scheduleSpectrumLayout(); + void loadPluginsForTab(name).catch((error) => { + console.error(error); + }); + if (name === "map") { + _initMapWhenReady(); + } + if (name === "statistics") { + window.trx.modules.map?.scheduleStatsRender(); + } + if (name === "recorder") { + void refreshRecorderStatus(); + } +} +function materializeTabPanel(panel) { + const tmpl = panel.querySelector("template"); + if (tmpl) { + panel.appendChild(tmpl.content.cloneNode(true)); + tmpl.remove(); + panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); + if (decoderRegistry.length) applyDecoderRegistryVisibility(); + } +} +window.navigateToTab = navigateToTab; +requiredElement("tab-bar").addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest(".tab[data-tab]") : null; + if (!btn) return; + const tab = btn.dataset.tab; + if (tab && TAB_ORDER.includes(tab)) navigateToTab(tab); +}); +window.addEventListener("popstate", () => { + navigateToTab(tabFromPath2(), { updateHistory: false }); +}); +(function() { + let tx = 0, ty = 0; + const THRESHOLD = 60; + const ANGLE_LIMIT = 1.6; + const NO_SWIPE_SELECTORS = [ + "#jog-wheel", + "#spectrum-canvas", + "#overview-canvas", + "#aprs-map", + ".controls-tray-scroll", + ".sub-tab-bar", + "input[type=range]", + "select", + "input[type=text]", + "input[type=number]", + "input[type=search]" + ]; + function isExcluded(el) { + return NO_SWIPE_SELECTORS.some((sel) => el.closest(sel)); + } + document.addEventListener("touchstart", (e) => { + if (e.touches.length !== 1) return; + if (!(e.target instanceof Element) || isExcluded(e.target)) return; + const touch = e.touches[0]; + if (!touch) return; + tx = touch.clientX; + ty = touch.clientY; + }, { passive: true }); + document.addEventListener("touchend", (e) => { + if (e.changedTouches.length !== 1 || tx === 0) return; + const touch = e.changedTouches[0]; + if (!touch) return; + const dx = touch.clientX - tx; + const dy = touch.clientY - ty; + tx = 0; + if (Math.abs(dx) < THRESHOLD) return; + if (Math.abs(dy) > 0 && Math.abs(dx) / Math.abs(dy) < ANGLE_LIMIT) return; + const activeBtn = document.querySelector(".tab-bar .tab.active"); + if (!activeBtn) return; + const cur = TAB_ORDER.indexOf(activeBtn.dataset.tab); + if (cur === -1) return; + const next = dx < 0 ? cur + 1 : cur - 1; + const nextTab = TAB_ORDER[next]; + if (next >= 0 && next < TAB_ORDER.length && nextTab) navigateToTab(nextTab); + }, { passive: true }); +})(); +window.addEventListener("resize", () => { + scheduleSpectrumLayout(); +}); +async function initializeApp() { + showAuthGate(false); + const authStatus = await checkAuthStatus(); + authEnabled = !authStatus.auth_disabled; + if (!authEnabled) { + authRole = "control"; + hideAuthGate(); + updateAuthUI(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + return; + } + if (authStatus.authenticated) { + authRole = authStatus.role ?? null; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + } else { + const allowGuest = authStatus.role === "rx"; + showAuthGate(allowGuest); + } +} +function initSettingsUI() { + window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); + window.trx.modules.scheduler?.wireEvents(); + if (window.trx.modules.backgroundDecode) { + window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); + window.trx.modules.backgroundDecode.wireEvents(); + } +} +requiredElement("auth-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const passphraseEl = requiredElement("auth-passphrase"); + const passphrase = passphraseEl.value; + const btn = requiredElement("auth-form").querySelector("button[type=submit]"); + if (!btn) return; + btn.disabled = true; + btn.textContent = "Logging in..."; + try { + const result = await authLogin(passphrase); + authRole = result.role ?? null; + passphraseEl.value = ""; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + } catch (err) { + showAuthError("Invalid passphrase"); + console.error("Login error:", err); + } finally { + btn.disabled = false; + btn.textContent = "Login"; + } +}); +var guestBtn = document.getElementById("auth-guest-btn"); +if (guestBtn) { + guestBtn.addEventListener("click", () => { + authRole = "rx"; + requiredElement("auth-passphrase").value = ""; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + }); +} +var headerAuthBtn = document.getElementById("header-auth-btn"); +if (headerAuthBtn) { + headerAuthBtn.addEventListener("click", async () => { + if (authRole) { + if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { + await authLogout(); + } + } else { + showAuthGate(false); + } + }); +} +var trxState = /* @__PURE__ */ Object.create(null); +var trxModules = {}; +Object.defineProperties(trxState, { + serverLat: { get() { + return serverLat; + }, set(v) { + serverLat = v; + } }, + serverLon: { get() { + return serverLon; + }, set(v) { + serverLon = v; + } }, + lastFreqHz: { get() { + return lastFreqHz; + } }, + lastActiveRigId: { get() { + return lastActiveRigId; + } }, + lastRigIds: { get() { + return lastRigIds; + } }, + lastRigDisplayNames: { get() { + return lastRigDisplayNames; + } }, + initialMapZoom: { get() { + return initialMapZoom; + } }, + decodeHistoryRetentionMin: { get() { + return decodeHistoryRetentionMin; + } }, + authRole: { get() { + return authRole; + } }, + decoderRegistry: { get() { + return decoderRegistry; + } }, + sseSessionId: { get() { + return sseSessionId; + } }, + primaryRds: { get() { + return primaryRds; + } }, + vchanRdsById: { get() { + return vchanRdsById; + } }, + vchanSignalDbById: { get() { + return vchanSignalDbById; + } }, + lastCityLabel: { get() { + return lastCityLabel; + }, set(v) { + lastCityLabel = v; + } }, + serverVersion: { get() { + return serverVersion; + } }, + serverBuildDate: { get() { + return serverBuildDate; + } }, + serverCallsign: { get() { + return serverCallsign; + } }, + ownerCallsign: { get() { + return ownerCallsign; + } }, + ownerWebsiteUrl: { get() { + return ownerWebsiteUrl; + } }, + ownerWebsiteName: { get() { + return ownerWebsiteName; + } }, + aisVesselUrlBase: { get() { + return aisVesselUrlBase; + } }, + serverRigs: { get() { + return serverRigs; + } }, + serverActiveRigId: { get() { + return serverActiveRigId; + } }, + lastModeName: { get() { + return lastModeName; + } }, + lastSpectrumData: { get() { + return lastSpectrumData; + } }, + lastSpectrumRenderData: { get() { + return lastSpectrumRenderData; + } }, + currentBandwidthHz: { get() { + return currentBandwidthHz; + }, set(v) { + currentBandwidthHz = v; + window.currentBandwidthHz = v; + } }, + spectrumFloor: { get() { + return spectrumFloor; + } }, + spectrumRange: { get() { + return spectrumRange; + } }, + spectrumCanvas: { get() { + return spectrumCanvas; + } }, + overviewCanvas: { get() { + return overviewCanvas; + } }, + overviewGl: { get() { + return overviewGl; + } }, + spectrumGl: { get() { + return spectrumGl; + } }, + signalOverlayGl: { get() { + return signalOverlayGl; + } } +}); +var trxCore = Object.freeze({ + saveSetting, + loadSetting, + showHint, + escapeMapHtml: escapeHtml, + formatFreq: formatFrequency, + formatFreqForHumans: formatFrequencyForHumans, + formatWavelength, + formatBwLabel, + formatUptime: formatDuration, + formatSigStrength, + formatSignal, + postPath, + scheduleUiFrameJob, + navigateToTab, + rigBadgeColor, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo, + bookmarkDistanceText, + buildBookmarkTooltipText, + nearestBookmarkForHz, + currentDecodeHistoryRetentionMs, + currentTheme, + canvasPalette, + currentStyle, + cssColorToRgba, + rgbaWithAlpha, + isBinsArray: isNumericBins, + estimateNoiseFloorDb, + spectrumVisibleRange, + drawSpectrum, + bandForHz: function(hz) { + return trxModules.map?.bandForHz?.(hz); + }, + markDecodeMapSyncPending, + decodeHistoryMapRenderingDeferred, + updateDocumentTitle, + activeChannelRds +}); +Object.defineProperties(trxState, { + decodeHistoryReplayActive: { get() { + return decodeHistoryReplayActive; + } }, + decodeMapSyncPending: { get() { + return decodeMapSyncPending; + } }, + _activeTab: { get() { + return _activeTab; + } }, + locationSubtitle: { get() { + return locationSubtitle; + } } +}); +window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules }); +void loadEagerPlugins().catch((error) => { + console.error(error); +}); +void initializeApp(); +window.addEventListener("resize", resizeHeaderSignalCanvas); +function bookmarkDistanceText(bm) { + if (!bm || serverLat == null || serverLon == null) return null; + const latLon = locatorToLatLon(bm.locator); + if (!latLon) return null; + return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon)); +} +function buildBookmarkTooltipText(bm) { + if (!bm) return null; + const parts = []; + if (bm.name) parts.push(String(bm.name)); + if (window.trx.modules.bookmarks) parts.push(window.trx.modules.bookmarks.formatFrequency(bm.freq_hz)); + if (bm.mode) parts.push(String(bm.mode)); + if (bm.locator) parts.push(String(bm.locator)); + const distance = bookmarkDistanceText(bm); + if (distance) parts.push(distance); + let text = parts.join(" · "); + if (bm.comment) { + text += (text ? "\n" : "") + String(bm.comment); + } + return text; +} +function nearestBookmarkForHz(hz, widthPx, range) { + const ref = window.trx.modules.bookmarks?.overlayList ?? null; + if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) { + return null; + } + const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10); + let best = null; + let bestDelta = Number.POSITIVE_INFINITY; + for (const bm of ref) { + const delta = Math.abs(Number(bm.freq_hz) - hz); + if (delta <= maxDeltaHz && delta < bestDelta) { + best = bm; + bestDelta = delta; + } + } + return best; +} +function _wireSubTabBar(bar) { + if (bar._subtabWired) return; + bar._subtabWired = true; + window.trxUi?.prepareTabList(bar, "secondary"); + bar.addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest(".sub-tab[data-subtab]") : null; + if (!btn) return; + bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); + btn.classList.add("active"); + window.trxUi?.syncSelectedTab(bar, btn); + const decoderPicker = document.getElementById("decoder-tab-select"); + if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab ?? ""; + const parent = bar.parentElement; + if (!parent) return; + parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); + const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); + if (nextPanel) nextPanel.style.display = ""; + if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) { + requestAnimationFrame(() => { + if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + }); + } + if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { + window.clearSatPredictionDom(); + } + }); +} +document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); +window.addEventListener("resize", () => { + const mapTab = document.getElementById("tab-map"); + if (!mapTab || mapTab.style.display === "none") return; + window.trx.modules.map?.sizeAprsMapToViewport(); +}); +var sigMeasureBtn = requiredElement("sig-measure-btn"); +var sigClearBtn = requiredElement("sig-clear-btn"); +var sigResult = requiredElement("sig-result"); +function resetSignalMeasurementState() { + sigMeasureLastTickMs = 0; + sigMeasureAccumMs = 0; + sigMeasureWeighted = 0; + sigMeasurePeak = null; +} +function updateSignalMeasurement(nowMs) { + if (!sigMeasuring) return; + if (sigMeasureLastTickMs === 0) { + sigMeasureLastTickMs = nowMs; + return; + } + const dt = Math.max(0, nowMs - sigMeasureLastTickMs); + sigMeasureLastTickMs = nowMs; + if (!isFiniteNumber(sigLastSUnits)) return; + sigMeasureAccumMs += dt; + sigMeasureWeighted += sigLastSUnits * dt; + if (sigMeasurePeak === null || sigLastSUnits > sigMeasurePeak) { + sigMeasurePeak = sigLastSUnits; + } +} +function stopSignalMeasurement() { + if (sigMeasureTimer) { + clearInterval(sigMeasureTimer); + sigMeasureTimer = null; + } + sigMeasuring = false; + sigMeasureBtn.textContent = "Measure"; + sigMeasureBtn.style.borderColor = ""; + sigMeasureBtn.style.color = ""; +} +sigMeasureBtn.addEventListener("click", () => { + if (!sigMeasuring) { + resetSignalMeasurementState(); + sigMeasuring = true; + sigMeasureBtn.textContent = "Stop (0.0s)"; + sigMeasureBtn.style.borderColor = "#00d17f"; + sigMeasureBtn.style.color = "#00d17f"; + sigMeasureTimer = setInterval(() => { + const now = Date.now(); + updateSignalMeasurement(now); + sigMeasureBtn.textContent = `Stop (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`; + }, 200); + } else { + updateSignalMeasurement(Date.now()); + stopSignalMeasurement(); + if (sigMeasureAccumMs > 0) { + const avg = sigMeasureWeighted / sigMeasureAccumMs; + const peak = sigMeasurePeak ?? avg; + sigResult.innerHTML = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`; + } + } +}); +sigClearBtn.addEventListener("click", () => { + stopSignalMeasurement(); + resetSignalMeasurementState(); + sigResult.textContent = ""; +}); +var rxAudioBtn = requiredElement("rx-audio-btn"); +var txAudioBtn = requiredElement("tx-audio-btn"); +var RX_AUDIO_LABEL = "Play Audio"; +var TX_AUDIO_LABEL = "Transmit Audio"; +var audioStatus = requiredElement("audio-status"); +var audioLevelFill = document.getElementById("audio-level-fill"); +var audioRow = requiredElement("audio-row"); +var wfmControlsCol = document.getElementById("wfm-controls-col"); +var wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); +var wfmAudioModeEl = document.getElementById("wfm-audio-mode"); +var wfmDenoiseEl = document.getElementById("wfm-denoise"); +var sdrSettingsRowEl = document.getElementById("sdr-settings-row"); +var sdrGainEl = document.getElementById("sdr-gain-db"); +var sdrGainSetBtn = document.getElementById("sdr-gain-set"); +var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls"); +var sdrLnaGainEl = document.getElementById("sdr-lna-gain-db"); +var sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set"); +var sdrAgcEl = document.getElementById("sdr-agc-enabled"); +var wfmStFlagEl = document.getElementById("wfm-st-flag"); +var wfmCciFillEl = document.getElementById("wfm-cci-fill"); +var wfmCciValEl = document.getElementById("wfm-cci-val"); +var wfmAciFillEl = document.getElementById("wfm-aci-fill"); +var wfmAciValEl = document.getElementById("wfm-aci-val"); +var samControlsCol = document.getElementById("sam-controls-col"); +var samStereoWidthEl = document.getElementById("sam-stereo-width"); +var samCarrierSyncEl = document.getElementById("sam-carrier-sync"); +var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap"); +var sdrSquelchEl = document.getElementById("sdr-squelch"); +var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct"); +var SDR_SQUELCH_MIN_DB = -120; +var SDR_SQUELCH_MAX_DB = -30; +var syncFromServerSdrSquelch = false; +var sdrNbWrapEl = document.getElementById("sdr-nb-wrap"); +var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled"); +var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls"); +var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold"); +var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set"); +var sdrNbSupported = false; +fetch("/audio", { method: "GET" }).then((r) => { + if (r.status === 404) audioRow.style.display = "none"; +}).catch(() => { +}); +var audioWs = null; +var audioCtx = null; +var rxActive = false; +var txActive = false; +var txStream = null; +var txProcessor = null; +var streamInfo = null; +var opusDecoder = null; +var wasmOpusDecoder = null; +var txEncoder = null; +var nextPlayTime = 0; +var lastLevelUpdate = 0; +var rxGainNode = null; +var txGainNode = null; +var rxVolSlider = requiredElement("rx-vol"); +var txVolSlider = requiredElement("tx-vol"); +var TX_TIMEOUT_SECS = 120; +var txTimeoutTimer = null; +var txTimeoutRemaining = 0; +var txTimeoutInterval = null; +var hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined"; +var opusDecoderGlobal = window["opus-decoder"]; +var hasWasmOpus = typeof opusDecoderGlobal?.OpusDecoder === "function"; +var MAX_RX_BUFFER_SECS = 0.25; +var TARGET_RX_BUFFER_SECS = 0.04; +var MIN_RX_JITTER_SAMPLES = 512; +if (rxAudioBtn) { + rxAudioBtn.textContent = RX_AUDIO_LABEL; + rxAudioBtn.setAttribute("aria-label", RX_AUDIO_LABEL); +} +if (txAudioBtn) { + txAudioBtn.textContent = TX_AUDIO_LABEL; + txAudioBtn.setAttribute("aria-label", TX_AUDIO_LABEL); +} +function setAudioLevel(levelPct) { + if (!audioLevelFill) return; + const clamped = Math.max(0, Math.min(100, isFiniteNumber(levelPct) ? levelPct : 0)); + audioLevelFill.style.width = `${clamped}%`; +} +function ensureRxAudioContext(preferredSampleRate) { + if (!audioCtx) { + try { + audioCtx = isFiniteNumber(preferredSampleRate) && preferredSampleRate > 0 ? new AudioContext({ sampleRate: preferredSampleRate }) : new AudioContext(); + } catch (e) { + audioCtx = new AudioContext(); + } + } + audioCtx.resume().catch(() => { + }); + if (!rxGainNode) { + rxGainNode = audioCtx.createGain(); + rxGainNode.connect(audioCtx.destination); + } +} +function levelFromChannels(channels, frameCount) { + if (!Array.isArray(channels) || channels.length === 0 || !isFiniteNumber(frameCount) || frameCount <= 0) { + return 0; + } + let sumSquares = 0; + let samples = 0; + for (const channel of channels) { + if (!channel) continue; + const limit = Math.min(frameCount, channel.length); + for (let i = 0; i < limit; i++) { + const sample = channel[i] ?? 0; + sumSquares += sample * sample; + } + samples += limit; + } + if (samples <= 0) return 0; + const rms = Math.sqrt(sumSquares / samples); + return Math.min(100, rms * 220); +} +function normalizeWfmDenoiseLevel(value) { + const next = primitiveString(value).toLowerCase(); + if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next; + return "auto"; +} +function clampSdrSquelchPercent(value) { + if (!isFiniteNumber(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} +function sdrSquelchPercentToServer(percent) { + const pct = clampSdrSquelchPercent(percent); + if (pct <= 0) { + return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB }; + } + const ratio = pct / 100; + const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB); + return { enabled: true, thresholdDb }; +} +function sdrSquelchServerToPercent(enabled, thresholdDb) { + if (!enabled) return 0; + if (!isFiniteNumber(thresholdDb)) return 0; + const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB); + return clampSdrSquelchPercent(ratio * 100); +} +function updateSdrSquelchPctLabel() { + if (!sdrSquelchEl || !sdrSquelchPctEl) return; + const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); + sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`; +} +function updateSdrSquelchControlVisibility() { + if (!sdrSquelchWrapEl) return; + const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); + sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none"; +} +function syncSdrSquelchFromServer(enabled, thresholdDb) { + if (!sdrSquelchEl) return; + if (document.activeElement === sdrSquelchEl) return; + const pct = sdrSquelchServerToPercent(enabled, thresholdDb); + syncFromServerSdrSquelch = true; + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + syncFromServerSdrSquelch = false; + saveSetting("sdrSquelchPct", pct); +} +function submitSdrSquelchPercent(percent) { + if (!sdrSquelchSupported) return; + const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent); + postPath( + `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}` + ).catch(() => { + }); +} +if (sdrSquelchEl) { + const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0))); + sdrSquelchEl.value = String(savedPct); + updateSdrSquelchPctLabel(); + sdrSquelchEl.addEventListener("input", () => { + const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", pct); + if (!syncFromServerSdrSquelch) { + submitSdrSquelchPercent(pct); + } + }); +} +var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto"); +if (sdrSquelchAutoBtn) { + sdrSquelchAutoBtn.addEventListener("click", () => { + if (!sdrSquelchSupported) return; + let pct = 0; + const data = lastSpectrumData || window.lastSpectrumData; + if (data && isNumericBins(data.bins) && data.bins.length > 0) { + const noiseDb = estimateNoiseFloorDb(data.bins); + if (noiseDb != null && isFiniteNumber(noiseDb)) { + const thresholdDb = noiseDb + 6; + const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb)); + pct = clampSdrSquelchPercent( + (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100 + ); + } + } + if (sdrSquelchEl) { + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", pct); + } + submitSdrSquelchPercent(pct); + }); +} +if (wfmAudioModeEl) { + wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo"); + wfmAudioModeEl.addEventListener("change", () => { + saveSetting("wfmAudioMode", wfmAudioModeEl.value); + const enabled = wfmAudioModeEl.value !== "mono"; + postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => { + }); + }); +} +if (wfmDenoiseEl) { + wfmDenoiseEl.value = normalizeWfmDenoiseLevel(loadSetting("wfmDenoise", "auto")); + wfmDenoiseEl.addEventListener("change", () => { + const level = normalizeWfmDenoiseLevel(wfmDenoiseEl.value); + wfmDenoiseEl.value = level; + saveSetting("wfmDenoise", level); + postPath(`/set_wfm_denoise?level=${encodeURIComponent(level)}`).catch(() => { + }); + }); +} +if (wfmDeemphasisEl) { + wfmDeemphasisEl.addEventListener("change", () => { + postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => { + }); + }); +} +if (samStereoWidthEl) { + samStereoWidthEl.addEventListener("input", () => { + const width = Number(samStereoWidthEl.value) / 100; + postPath(`/set_sam_stereo_width?width=${width}`).catch(() => { + }); + }); +} +if (samCarrierSyncEl) { + samCarrierSyncEl.addEventListener("change", () => { + const enabled = samCarrierSyncEl.value === "on"; + postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => { + }); + }); +} +function submitSdrGain() { + if (!sdrGainEl) return; + const parsed = Number.parseFloat(sdrGainEl.value); + if (!isFiniteNumber(parsed) || parsed < 0) return; + postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => { + }); +} +function updateSdrGainInputState() { + if (!sdrAgcEl) return; + const agcOn = sdrAgcEl.checked; + if (sdrGainEl) sdrGainEl.disabled = agcOn; + if (sdrGainSetBtn) sdrGainSetBtn.disabled = agcOn; + if (sdrLnaGainEl) sdrLnaGainEl.disabled = agcOn; + if (sdrLnaGainSetBtn) sdrLnaGainSetBtn.disabled = agcOn; +} +if (sdrAgcEl) { + sdrAgcEl.addEventListener("change", () => { + postPath(`/set_sdr_agc?enabled=${sdrAgcEl.checked ? "true" : "false"}`).catch(() => { + }); + updateSdrGainInputState(); + }); +} +if (sdrGainSetBtn) { + sdrGainSetBtn.addEventListener("click", submitSdrGain); +} +if (sdrGainEl) { + sdrGainEl.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + submitSdrGain(); + } + }); +} +function submitSdrLnaGain() { + if (!sdrLnaGainEl) return; + const parsed = Number.parseFloat(sdrLnaGainEl.value); + if (!isFiniteNumber(parsed) || parsed < 0) return; + postPath(`/set_sdr_lna_gain?db=${encodeURIComponent(parsed)}`).catch(() => { + }); +} +if (sdrLnaGainSetBtn) { + sdrLnaGainSetBtn.addEventListener("click", submitSdrLnaGain); +} +if (sdrLnaGainEl) { + sdrLnaGainEl.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + submitSdrLnaGain(); + } + }); +} +function submitSdrNbState() { + if (!sdrNbSupported) return; + const enabled = sdrNbEnabledEl ? sdrNbEnabledEl.checked : false; + const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10; + if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return; + postPath( + `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}` + ).catch(() => { + }); +} +if (sdrNbEnabledEl) { + sdrNbEnabledEl.addEventListener("change", () => { + submitSdrNbState(); + }); +} +function submitSdrNbThreshold() { + if (!sdrNbThresholdEl) return; + const parsed = Number.parseFloat(sdrNbThresholdEl.value); + if (!isFiniteNumber(parsed) || parsed < 1 || parsed > 100) return; + submitSdrNbState(); +} +if (sdrNbThresholdSetBtn) { + sdrNbThresholdSetBtn.addEventListener("click", submitSdrNbThreshold); +} +if (sdrNbThresholdEl) { + sdrNbThresholdEl.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + submitSdrNbThreshold(); + } + }); +} +function updateWfmControls() { + const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); + if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; + if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none"; +} +if (!hasWebCodecs) { + rxAudioBtn.disabled = true; + txAudioBtn.disabled = true; + audioStatus.textContent = "Audio requires Chrome/Edge"; +} +function resetTxTimeout() { + txTimeoutRemaining = TX_TIMEOUT_SECS; + if (txTimeoutTimer) clearTimeout(txTimeoutTimer); + txTimeoutTimer = setTimeout(() => { + console.warn("PTT safety timeout — stopping TX"); + void stopTxAudio(); + }, TX_TIMEOUT_SECS * 1e3); +} +function startTxTimeoutCountdown() { + txTimeoutRemaining = TX_TIMEOUT_SECS; + if (txTimeoutInterval) clearInterval(txTimeoutInterval); + txTimeoutInterval = setInterval(() => { + txTimeoutRemaining--; + if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) { + audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`; + } + }, 1e3); +} +function clearTxTimeout() { + if (txTimeoutTimer) { + clearTimeout(txTimeoutTimer); + txTimeoutTimer = null; + } + if (txTimeoutInterval) { + clearInterval(txTimeoutInterval); + txTimeoutInterval = null; + } + txTimeoutRemaining = 0; +} +function resetRxDecoder() { + if (opusDecoder) { + try { + opusDecoder.close(); + } catch (e) { + } + opusDecoder = null; + } + if (wasmOpusDecoder) { + try { + wasmOpusDecoder.free(); + } catch (e) { + } + wasmOpusDecoder = null; + } + nextPlayTime = 0; +} +function configureRxStream(nextInfo) { + const nextSampleRate = nextInfo && nextInfo.sample_rate || 48e3; + streamInfo = nextInfo; + updateWfmControls(); + resetRxDecoder(); + ensureRxAudioContext(nextSampleRate); + if (rxGainNode) rxGainNode.gain.value = Number(rxVolSlider.value) / 100; + rxActive = true; + window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); + setAudioLevel(0); + rxAudioBtn.style.borderColor = "#00d17f"; + rxAudioBtn.style.color = "#00d17f"; + audioStatus.textContent = "RX"; + syncHeaderAudioBtn(); +} +function extractAudioFrameChannels(frame) { + const channels = Math.max(1, frame.numberOfChannels || 1); + const frames = Math.max(0, frame.numberOfFrames || 0); + const format = String(frame.format || "").toLowerCase(); + const isPlanar = format.includes("planar"); + if (!isPlanar) { + const interleaved = new Float32Array(frames * channels); + frame.copyTo(interleaved, { planeIndex: 0 }); + const out2 = Array.from({ length: channels }, () => new Float32Array(frames)); + for (let i = 0; i < frames; i++) { + for (let ch = 0; ch < channels; ch++) { + const channel = out2[ch]; + if (channel) channel[i] = interleaved[i * channels + ch] ?? 0; + } + } + return out2; + } + const out = []; + for (let ch = 0; ch < channels; ch++) { + let len = frames; + try { + len = Math.max(frames, Math.floor(frame.allocationSize({ planeIndex: ch }) / 4)); + } catch (e) { + } + const plane = new Float32Array(len); + frame.copyTo(plane, { planeIndex: ch }); + out.push(plane.length === frames ? plane : plane.subarray(0, frames)); + } + return out; +} +var _audioChannelOverride = null; +function scheduleDecodedAudio(channelData, frameCount, sampleRate) { + if (!audioCtx || !rxGainNode) return; + const levelNow = Date.now(); + if (levelNow - lastLevelUpdate >= 50) { + setAudioLevel(levelFromChannels(channelData, frameCount)); + lastLevelUpdate = levelNow; + } + const forceMono = channelData.length >= 2 && wfmAudioModeEl && wfmAudioModeEl.value === "mono" && modeEl && (modeEl.value || "").toUpperCase() === "WFM"; + const outChannels = forceMono ? 1 : channelData.length; + const ab = audioCtx.createBuffer(outChannels, frameCount, sampleRate); + if (forceMono) { + const monoData = new Float32Array(frameCount); + for (let ch = 0; ch < channelData.length; ch++) { + const plane = channelData[ch]; + if (!plane) continue; + for (let i = 0; i < frameCount; i++) monoData[i] = (monoData[i] ?? 0) + (plane[i] ?? 0); + } + const inv = 1 / Math.max(1, channelData.length); + for (let i = 0; i < frameCount; i++) monoData[i] = (monoData[i] ?? 0) * inv; + ab.copyToChannel(monoData, 0); + } else { + for (let ch = 0; ch < channelData.length; ch++) { + const channel = channelData[ch]; + if (channel) ab.copyToChannel(new Float32Array(channel), ch); + } + } + const src = audioCtx.createBufferSource(); + src.buffer = ab; + src.connect(rxGainNode); + const now = audioCtx.currentTime; + const sr = streamInfo && streamInfo.sample_rate || sampleRate || 48e3; + const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sr)); + const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs); + if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) { + nextPlayTime = now + targetLeadSecs; + } + if (!nextPlayTime || nextPlayTime < now + minLeadSecs) { + nextPlayTime = now + targetLeadSecs; + } + const schedTime = nextPlayTime || now + targetLeadSecs; + src.start(schedTime); + nextPlayTime = schedTime + ab.duration; +} +function startRxAudio() { + if (rxActive) { + stopRxAudio(); + return; + } + if (!hasWebCodecs && !hasWasmOpus) { + audioStatus.textContent = "Audio not supported in this browser"; + return; + } + ensureRxAudioContext(streamInfo && streamInfo.sample_rate || 48e3); + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + let audioPath; + if (_audioChannelOverride) { + const remoteParam = lastActiveRigId ? `&remote=${encodeURIComponent(lastActiveRigId)}` : ""; + audioPath = `/audio?channel_id=${encodeURIComponent(_audioChannelOverride)}${remoteParam}`; + } else if (lastActiveRigId) { + audioPath = `/audio?remote=${encodeURIComponent(lastActiveRigId)}`; + } else { + audioPath = "/audio"; + } + audioWs = new WebSocket(`${proto}//${location.host}${audioPath}`); + audioWs.binaryType = "arraybuffer"; + audioStatus.textContent = "Connecting…"; + audioWs.onopen = () => { + audioStatus.textContent = "Connected"; + }; + audioWs.onmessage = (evt) => { + if (typeof evt.data === "string") { + try { + const info = parseJsonUnknown(evt.data); + if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata"); + configureRxStream(info); + } catch (e) { + console.error("Audio stream info parse error", e); + } + return; + } + if (!audioCtx) return; + const data = new Uint8Array(evt.data); + if (!opusDecoder && !wasmOpusDecoder) { + const channels = streamInfo && streamInfo.channels || 1; + const sampleRate = streamInfo && streamInfo.sample_rate || 48e3; + if (hasWebCodecs) { + try { + opusDecoder = new AudioDecoder({ + output: (frame) => { + const ch = extractAudioFrameChannels(frame); + scheduleDecodedAudio(ch, frame.numberOfFrames, frame.sampleRate); + frame.close(); + }, + error: (e) => { + console.error("AudioDecoder error", e); + } + }); + opusDecoder.configure({ codec: "opus", sampleRate, numberOfChannels: channels }); + } catch (e) { + console.warn("WebCodecs Opus not supported, trying WASM fallback", e); + opusDecoder = null; + } + } + if (!opusDecoder && hasWasmOpus) { + try { + const coupledStreamCount = channels >= 2 ? 1 : 0; + const mapping = channels >= 2 ? [0, 1] : [0]; + const Decoder = opusDecoderGlobal?.OpusDecoder; + if (!Decoder) throw new Error("Opus decoder unavailable"); + wasmOpusDecoder = new Decoder({ + sampleRate, + channels, + streamCount: 1, + coupledStreamCount, + channelMappingTable: mapping, + preSkip: 0 + }); + const decoder = wasmOpusDecoder; + decoder.ready.then(() => { + audioStatus.textContent = "RX"; + }).catch((e) => { + console.error("WASM Opus init failed", e); + wasmOpusDecoder = null; + }); + } catch (e) { + console.warn("WASM Opus decoder init failed", e); + wasmOpusDecoder = null; + } + } + } + if (opusDecoder) { + try { + opusDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: performance.now() * 1e3, + data + })); + } catch (e) { + } + } else if (wasmOpusDecoder) { + try { + const result = wasmOpusDecoder.decodeFrame(data); + if (result && result.samplesDecoded > 0) { + scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48e3); + } + } catch (e) { + } + } + }; + audioWs.onclose = () => { + if (txActive) { + void stopTxAudio(); + } + rxActive = false; + window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); + streamInfo = null; + updateWfmControls(); + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + setAudioLevel(0); + rxGainNode = null; + if (opusDecoder) { + try { + opusDecoder.close(); + } catch (e) { + } + opusDecoder = null; + } + if (wasmOpusDecoder) { + try { + wasmOpusDecoder.free(); + } catch (e) { + } + wasmOpusDecoder = null; + } + nextPlayTime = 0; + syncHeaderAudioBtn(); + }; + audioWs.onerror = () => { + audioStatus.textContent = "Error"; + }; +} +function stopRxAudio() { + rxActive = false; + window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" }); + streamInfo = null; + if (audioWs) { + audioWs.close(); + audioWs = null; + } + if (audioCtx) { + void audioCtx.close(); + audioCtx = null; + } + updateWfmControls(); + rxGainNode = null; + if (opusDecoder) { + try { + opusDecoder.close(); + } catch (e) { + } + opusDecoder = null; + } + if (wasmOpusDecoder) { + try { + wasmOpusDecoder.free(); + } catch (e) { + } + wasmOpusDecoder = null; + } + nextPlayTime = 0; + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + setAudioLevel(0); + syncHeaderAudioBtn(); +} +function startTxAudio() { + if (txActive) { + void stopTxAudio(); + return; + } + if (!hasWebCodecs) { + audioStatus.textContent = "Audio requires Chrome/Edge"; + return; + } + if (!audioWs || audioWs.readyState !== WebSocket.OPEN) { + audioStatus.textContent = "RX first"; + return; + } + if (!streamInfo) return; + const activeStreamInfo = streamInfo; + navigator.mediaDevices.getUserMedia({ + audio: { sampleRate: activeStreamInfo.sample_rate || 48e3, channelCount: activeStreamInfo.channels || 1 } + }).then(async (stream) => { + txStream = stream; + txActive = true; + window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" }); + txAudioBtn.style.borderColor = "#e55353"; + txAudioBtn.style.color = "#e55353"; + audioStatus.textContent = "RX+TX"; + resetTxTimeout(); + startTxTimeoutCountdown(); + try { + await postPath("/set_ptt?ptt=true"); + } catch (e) { + console.error("PTT on failed", e); + } + const sampleRate = activeStreamInfo.sample_rate || 48e3; + const channels = activeStreamInfo.channels || 1; + const encoder = new AudioEncoder({ + output: (chunk) => { + const buf = new ArrayBuffer(chunk.byteLength); + chunk.copyTo(buf); + if (audioWs && audioWs.readyState === WebSocket.OPEN) { + audioWs.send(buf); + } + }, + error: (e) => { + console.error("AudioEncoder error", e); + } + }); + encoder.configure({ + codec: "opus", + sampleRate, + numberOfChannels: channels, + bitrate: activeStreamInfo.bitrate_bps || 24e3 + }); + txEncoder = encoder; + if (!audioCtx) audioCtx = new AudioContext({ sampleRate }); + const source = audioCtx.createMediaStreamSource(stream); + const frameDuration = (activeStreamInfo.frame_duration_ms || 20) / 1e3; + const frameSize = Math.floor(sampleRate * frameDuration); + const processor = audioCtx.createScriptProcessor(frameSize, channels, channels); + let tsCounter = 0; + processor.onaudioprocess = (e) => { + if (!txActive || !txEncoder) return; + const input = e.inputBuffer; + resetTxTimeout(); + const monoData = input.getChannelData(0); + try { + const frame = new AudioData({ + format: "f32-planar", + sampleRate: input.sampleRate, + numberOfFrames: input.length, + numberOfChannels: 1, + timestamp: tsCounter, + data: monoData + }); + tsCounter += input.length / input.sampleRate * 1e6; + txEncoder.encode(frame); + frame.close(); + } catch (e2) { + } + }; + txGainNode = audioCtx.createGain(); + txGainNode.gain.value = Number(txVolSlider.value) / 100; + source.connect(txGainNode); + txGainNode.connect(processor); + processor.connect(audioCtx.destination); + txProcessor = { source, processor }; + }).catch((err) => { + console.error("getUserMedia failed:", err); + audioStatus.textContent = "Mic denied"; + }); +} +async function stopTxAudio() { + if (!txActive) return; + txActive = false; + window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" }); + clearTxTimeout(); + try { + await postPath("/set_ptt?ptt=false"); + } catch (e) { + console.error("PTT off failed", e); + } + if (txStream) { + txStream.getTracks().forEach((t) => t.stop()); + txStream = null; + } + if (txProcessor) { + txProcessor.source.disconnect(); + txProcessor.processor.disconnect(); + txProcessor = null; + } + if (txEncoder) { + try { + txEncoder.close(); + } catch (e) { + } + txEncoder = null; + } + txGainNode = null; + txAudioBtn.style.borderColor = ""; + txAudioBtn.style.color = ""; + audioStatus.textContent = rxActive ? "RX" : "Off"; +} +rxAudioBtn.addEventListener("click", startRxAudio); +txAudioBtn.addEventListener("click", startTxAudio); +var headerAudioToggle = document.getElementById("header-audio-toggle"); +var _audioIconPlay = ''; +var _audioIconPause = ''; +function syncHeaderAudioBtn() { + if (!headerAudioToggle) return; + headerAudioToggle.classList.toggle("audio-active", rxActive); + headerAudioToggle.title = rxActive ? "Stop audio" : "Play audio"; + headerAudioToggle.innerHTML = rxActive ? _audioIconPause : _audioIconPlay; +} +if (headerAudioToggle) { + headerAudioToggle.addEventListener("click", startRxAudio); +} +var recorderActive = false; +var recorderStartBtn = document.getElementById("recorder-start-btn"); +var recorderStopBtn = document.getElementById("recorder-stop-btn"); +var recorderStatusInd = document.getElementById("recorder-status-indicator"); +var headerRecBtn = document.getElementById("header-rec-btn"); +function syncRecorderUi() { + if (recorderStartBtn) recorderStartBtn.disabled = recorderActive; + if (recorderStopBtn) recorderStopBtn.disabled = !recorderActive; + if (recorderStatusInd) { + recorderStatusInd.textContent = recorderActive ? "Recording" : ""; + recorderStatusInd.classList.toggle("rec-active", recorderActive); + } + if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive); + const tabBtn = document.querySelector('.tab[data-tab="recorder"]'); + if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive); +} +if (recorderStartBtn) { + recorderStartBtn.addEventListener("click", async () => { + try { + await postPath("/api/recorder/start"); + } catch (e) { + console.error("Recorder start failed", e); + } + }); +} +if (recorderStopBtn) { + recorderStopBtn.addEventListener("click", async () => { + try { + await postPath("/api/recorder/stop"); + } catch (e) { + console.error("Recorder stop failed", e); + } + }); +} +if (headerRecBtn) { + headerRecBtn.addEventListener("click", async () => { + try { + if (recorderActive) { + await postPath("/api/recorder/stop"); + } else { + await postPath("/api/recorder/start"); + } + } catch (e) { + console.error("Recorder toggle failed", e); + } + }); +} +window._syncRecorderState = function(enabled) { + recorderActive = enabled; + syncRecorderUi(); +}; +var _recorderFiles = []; +var _recFilesPage = 0; +var REC_PAGE_SIZE = 15; +async function refreshRecorderStatus() { + try { + const [statusResp, filesResp] = await Promise.all([ + fetch("/api/recorder/status"), + fetch("/api/recorder/files") + ]); + if (statusResp.ok) { + const active = await responseJsonUnknown(statusResp); + if (isRecorderActiveList(active)) renderRecorderActive(active); + } + if (filesResp.ok) { + const files = await responseJsonUnknown(filesResp); + if (isRecorderFileList(files)) { + _recorderFiles = files; + renderRecorderFiles(); + } + } + } catch (e) { + console.error("Recorder status fetch failed", e); + } +} +function renderRecorderActive(list) { + const el = document.getElementById("recorder-active-list"); + if (!el) return; + if (!list.length) { + el.innerHTML = '

No active recordings.

'; + return; + } + let html = ''; + for (const r of list) { + const started = new Date(r.started_at * 1e3).toLocaleTimeString(); + const fname = r.path.split("/").pop(); + html += ``; + } + html += "
RigVChanFileStarted
${escapeHtml(r.rig_id)}${r.vchan_id ? escapeHtml(r.vchan_id) : "-"}${escapeHtml(fname)}${started}
"; + el.innerHTML = html; +} +function recFilterAndSort() { + const filterEl = document.getElementById("recorder-filter"); + const sortEl = document.getElementById("recorder-sort"); + const filter = (filterEl ? filterEl.value : "").toLowerCase(); + const sortMode = sortEl ? sortEl.value : "name-desc"; + let filtered = _recorderFiles; + if (filter) { + filtered = filtered.filter(function(f) { + return f.name.toLowerCase().includes(filter); + }); + } + const sorted = filtered.slice(); + switch (sortMode) { + case "name-asc": + sorted.sort(function(a, b) { + return a.name.localeCompare(b.name); + }); + break; + case "name-desc": + sorted.sort(function(a, b) { + return b.name.localeCompare(a.name); + }); + break; + case "size-asc": + sorted.sort(function(a, b) { + return a.size - b.size; + }); + break; + case "size-desc": + sorted.sort(function(a, b) { + return b.size - a.size; + }); + break; + } + return sorted; +} +function renderRecorderFiles() { + const el = document.getElementById("recorder-files-list"); + if (!el) return; + const sorted = recFilterAndSort(); + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE)); + if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1; + if (_recFilesPage < 0) _recFilesPage = 0; + const start = _recFilesPage * REC_PAGE_SIZE; + const page = sorted.slice(start, start + REC_PAGE_SIZE); + const summaryEl = document.getElementById("rec-page-summary"); + const indicatorEl = document.getElementById("rec-page-indicator"); + const prevBtn = document.getElementById("rec-page-prev"); + const nextBtn = document.getElementById("rec-page-next"); + if (summaryEl) { + summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0"; + } + if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages; + if (prevBtn) prevBtn.disabled = _recFilesPage <= 0; + if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1; + const filterEl = document.getElementById("recorder-filter"); + const filter = filterEl ? filterEl.value : ""; + if (!page.length) { + el.innerHTML = '

' + (filter ? "No files match filter." : "No recorded files.") + "

"; + return; + } + let html = ''; + for (const f of page) { + const safeName = escapeHtml(f.name); + const encodedName = encodeURIComponent(f.name); + html += ''; + } + html += "
FileSizeActions
' + safeName + "" + formatByteSize(f.size) + '
Download
"; + el.innerHTML = html; + el.querySelectorAll(".rec-play-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const row = btn.closest("tr"); + if (!row) return; + const next = row.nextElementSibling; + if (next && next.classList.contains("rec-player-row")) { + const audio2 = next.querySelector("audio"); + if (audio2) { + try { + audio2.pause(); + } catch (_) { + } + } + next.remove(); + btn.setAttribute("aria-expanded", "false"); + btn.textContent = "Play"; + return; + } + el.querySelectorAll(".rec-player-row").forEach(function(r) { + const a = r.querySelector("audio"); + if (a) { + try { + a.pause(); + } catch (_) { + } + } + r.remove(); + }); + el.querySelectorAll(".rec-play-btn").forEach(function(b) { + b.setAttribute("aria-expanded", "false"); + b.textContent = "Play"; + }); + const playerRow = document.createElement("tr"); + playerRow.className = "rec-player-row"; + const cell = document.createElement("td"); + cell.colSpan = 3; + const audio = document.createElement("audio"); + audio.controls = true; + audio.preload = "metadata"; + audio.src = btn.dataset.url ?? ""; + audio.className = "rec-player-audio"; + cell.appendChild(audio); + playerRow.appendChild(cell); + row.parentNode?.insertBefore(playerRow, row.nextSibling); + btn.setAttribute("aria-expanded", "true"); + btn.textContent = "Hide"; + void audio.play().catch(() => { + }); + }); + }); + el.querySelectorAll(".rec-delete-btn").forEach(function(btn) { + btn.addEventListener("click", async function() { + const name = btn.dataset.name ?? ""; + if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return; + try { + const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" }); + if (!resp.ok) throw new Error("HTTP " + resp.status); + _recorderFiles = _recorderFiles.filter(function(f) { + return f.name !== name; + }); + renderRecorderFiles(); + } catch (e) { + console.error("Delete failed", e); + window.trxUi?.notify("Recording could not be deleted", { kind: "error" }); + } + }); + }); +} +(function() { + const filterEl = document.getElementById("recorder-filter"); + const sortEl = document.getElementById("recorder-sort"); + if (filterEl) filterEl.addEventListener("input", function() { + _recFilesPage = 0; + renderRecorderFiles(); + }); + if (sortEl) sortEl.addEventListener("change", function() { + _recFilesPage = 0; + renderRecorderFiles(); + }); + const prevBtn = document.getElementById("rec-page-prev"); + const nextBtn = document.getElementById("rec-page-next"); + if (prevBtn) prevBtn.addEventListener("click", function() { + _recFilesPage--; + renderRecorderFiles(); + }); + if (nextBtn) nextBtn.addEventListener("click", function() { + _recFilesPage++; + renderRecorderFiles(); + }); +})(); +var rxVolPct = requiredElement("rx-vol-pct"); +var txVolPct = requiredElement("tx-vol-pct"); +rxVolSlider.value = String(loadSetting("rxVol", 80)); +txVolSlider.value = String(loadSetting("txVol", 80)); +rxVolPct.textContent = `${rxVolSlider.value}%`; +txVolPct.textContent = `${txVolSlider.value}%`; +function updateVolSlider(slider, pctEl, gainNode) { + pctEl.textContent = `${slider.value}%`; + if (gainNode) gainNode.gain.value = Number(slider.value) / 100; +} +rxVolSlider.addEventListener("input", () => { + updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); + saveSetting("rxVol", Number(rxVolSlider.value)); +}); +txVolSlider.addEventListener("input", () => { + updateVolSlider(txVolSlider, txVolPct, txGainNode); + saveSetting("txVol", Number(txVolSlider.value)); +}); +function volWheel(slider, pctEl, getGain, storageKey) { + slider.addEventListener("wheel", (e) => { + e.preventDefault(); + const step = e.deltaY < 0 ? 2 : -2; + slider.value = String(Math.max(0, Math.min(100, Number(slider.value) + step))); + updateVolSlider(slider, pctEl, getGain()); + saveSetting(storageKey, Number(slider.value)); + }, { passive: false }); +} +volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol"); +volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol"); +if (sdrSquelchEl) { + sdrSquelchEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const step = e.deltaY < 0 ? 2 : -2; + const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step); + sdrSquelchEl.value = String(next); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", next); + submitSdrSquelchPercent(next); + }, { passive: false }); +} +requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear()); +var decodeSource = null; +var decodeHistoryWorker = null; +function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) { + if (!el) return; + const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); + const isActiveMode = activeModes.includes(modeUpper); + if (el.textContent === "Receiving" && isActiveMode) return; + el.textContent = isActiveMode ? connectedText : inactiveText; +} +var _decodeConnectedText = { + vdes: "Connected, listening for bursts", + cw: "Connected, listening for CW" +}; +function updateDecodeStatus(text) { + for (const d of decoderRegistry) { + if (d.activation !== "mode_bound") continue; + const el = document.getElementById(d.id + "-status"); + if (!el) continue; + const connText = _decodeConnectedText[d.id] || text; + setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); + } + for (const d of decoderRegistry) { + if (d.activation !== "toggle") continue; + const el = document.getElementById(d.id + "-status"); + if (el && el.textContent !== "Receiving") el.textContent = text; + } +} +function dispatchDecodeMessage(msg, skipStats = false) { + if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); + if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") { + window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); + window.trx.modules.map?.scheduleStatsRender(); + } +} +var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; +var DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8; +function terminateDecodeHistoryWorker() { + if (!decodeHistoryWorker) return; + try { + decodeHistoryWorker.terminate(); + } catch (_) { + } + decodeHistoryWorker = null; +} +function scheduleDecodeHistoryDrainStep(callback) { + if (typeof callback !== "function") return; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => callback()); + } else { + setTimeout(callback, 16); + } +} +function decodeHistoryUrl() { + return "/decode/history"; +} +function loadDecodeHistoryOnMainThread(onReady, onError) { + fetch(decodeHistoryUrl()).then(async (resp) => { + if (!resp.ok) return null; + setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Receiving compressed history payload"); + const payload = await resp.arrayBuffer(); + if (!payload || payload.byteLength === 0) return {}; + setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history payload"); + return decodeCbor(payload); + }).then((groups) => { + if (groups && typeof groups === "object" && !Array.isArray(groups)) { + onReady(groups); + } else { + onReady({}); + } + }).catch((err) => { + if (typeof onError === "function") onError(err); + }); +} +function restoreDecodeHistoryGroup(kind, messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") { + for (const msg of messages) { + window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); + } + window.trx.modules.map?.scheduleStatsRender(); + } + window.trxPluginRuntime.restore(kind, messages); +} +function connectDecode() { + if (decodeSource) { + decodeSource.close(); + } + terminateDecodeHistoryWorker(); + decodeHistoryReplayActive = false; + decodeMapSyncPending = false; + window.trxPluginRuntime.clearQueued(); + window.trxPluginRuntime.resetAll(); + let historySettled = false; + let historyWorkerDone = false; + let historyFallbackStarted = false; + let historyBatchDrainScheduled = false; + let historyTotal = 0; + let historyProcessed = 0; + const historyGroupQueue = []; + const liveBuffer = []; + function flushLiveBuffer() { + historySettled = true; + terminateDecodeHistoryWorker(); + setDecodeHistoryReplayActive(false); + setDecodeHistoryOverlayVisible(false); + for (const msg of liveBuffer) { + try { + dispatchDecodeMessage(msg); + } catch (_) { + } + } + liveBuffer.length = 0; + } + function updateHistoryReplayOverlay() { + setDecodeHistoryOverlayVisible( + true, + "Loading decode history…", + `Replaying ${historyProcessed} / ${historyTotal} decoded messages` + ); + } + function maybeFinishHistoryReplay() { + if (historySettled) return; + if (historyWorkerDone && historyGroupQueue.length === 0) { + clearTimeout(historyTimeout); + flushLiveBuffer(); + } + } + function pumpDecodeHistoryGroupQueue() { + historyBatchDrainScheduled = false; + const startedAt = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0; + while (historyGroupQueue.length > 0) { + const next = historyGroupQueue.shift(); + if (!next) continue; + restoreDecodeHistoryGroup(next.kind, next.messages); + historyProcessed += Array.isArray(next.messages) ? next.messages.length : 0; + updateHistoryReplayOverlay(); + if (startedAt > 0 && performance.now() - startedAt >= DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS) { + break; + } + } + if (historyGroupQueue.length > 0) { + scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue); + historyBatchDrainScheduled = true; + return; + } + maybeFinishHistoryReplay(); + } + function enqueueDecodeHistoryGroup(kind, messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + historyGroupQueue.push({ kind, messages }); + if (historyBatchDrainScheduled) return; + historyBatchDrainScheduled = true; + scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue); + } + function totalDecodeHistoryMessages(groups) { + if (!groups || typeof groups !== "object") return 0; + return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"].reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0); + } + function enqueueDecodeHistoryGroups(groups) { + historyTotal = totalDecodeHistoryMessages(groups); + historyProcessed = 0; + if (historyTotal > 0) { + setDecodeHistoryReplayActive(true); + updateHistoryReplayOverlay(); + } + for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]) { + const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : []; + if (messages.length === 0) continue; + for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) { + enqueueDecodeHistoryGroup(kind, messages.slice(index, index + DECODE_HISTORY_WORKER_GROUP_LIMIT)); + } + } + historyWorkerDone = true; + maybeFinishHistoryReplay(); + } + function startDecodeHistoryFallback() { + if (historyFallbackStarted || historySettled) return; + historyFallbackStarted = true; + loadDecodeHistoryOnMainThread((groups) => { + clearTimeout(historyTimeout); + const total = totalDecodeHistoryMessages(groups); + if (total > 0) { + enqueueDecodeHistoryGroups(groups); + } else { + flushLiveBuffer(); + } + }, (err) => { + console.error("Decode history fallback failed", err); + clearTimeout(historyTimeout); + flushLiveBuffer(); + }); + } + function startDecodeHistoryWorkerReplay() { + if (typeof Worker !== "function") return false; + let worker; + try { + worker = new Worker("/decode-history-worker.js"); + } catch (err) { + console.error("Decode history worker startup failed", err); + return false; + } + decodeHistoryWorker = worker; + worker.onmessage = (evt) => { + if (historySettled || worker !== decodeHistoryWorker) return; + const data = evt.data; + if (!isRecord2(data) || typeof data.type !== "string") return; + if (data.type === "status") { + const phase = primitiveString(data.phase); + if (phase === "fetching") { + setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); + } else if (phase === "decoding") { + setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history in background"); + } + return; + } + if (data.type === "start") { + historyTotal = Math.max(0, Number(data.total) || 0); + historyProcessed = 0; + if (historyTotal > 0) { + setDecodeHistoryReplayActive(true); + updateHistoryReplayOverlay(); + } + return; + } + if (data.type === "group") { + const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : []; + enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages); + return; + } + if (data.type === "done") { + historyWorkerDone = true; + clearTimeout(historyTimeout); + terminateDecodeHistoryWorker(); + maybeFinishHistoryReplay(); + return; + } + if (data.type === "error") { + console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure"); + terminateDecodeHistoryWorker(); + startDecodeHistoryFallback(); + } + }; + worker.postMessage({ + type: "fetch-history", + url: decodeHistoryUrl(), + batchLimit: DECODE_HISTORY_WORKER_GROUP_LIMIT + }); + return true; + } + const historyTimeout = setTimeout(() => { + if (!historySettled) { + terminateDecodeHistoryWorker(); + flushLiveBuffer(); + } + }, 2e4); + setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); + decodeSource = new EventSource("/decode"); + const source = decodeSource; + source.onopen = () => { + updateDecodeStatus("Connected, listening for packets"); + }; + source.onmessage = (evt) => { + try { + const msg = parseJsonUnknown(evt.data); + if (!isRecord2(msg) || typeof msg.type !== "string") return; + const decoded = { ...msg, type: msg.type }; + if (historySettled) dispatchDecodeMessage(decoded); + else liveBuffer.push(decoded); + } catch (e) { + } + }; + source.onerror = () => { + const wasClosed = source.readyState === 2; + source.close(); + terminateDecodeHistoryWorker(); + if (!historySettled) flushLiveBuffer(); + if (wasClosed) { + updateDecodeStatus("Decode not available (check client audio config)"); + setTimeout(connectDecode, 1e4); + } else { + updateDecodeStatus("Decode disconnected, retrying…"); + setTimeout(connectDecode, 5e3); + } + }; + if (!startDecodeHistoryWorkerReplay()) { + startDecodeHistoryFallback(); + } +} +window.addEventListener("beforeunload", () => { + if (txActive) { + navigator.sendBeacon("/set_ptt?ptt=false", ""); + } +}); +var spectrumCanvas = document.getElementById("spectrum-canvas"); +var spectrumGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions) : null; +var spectrumDbAxis = document.getElementById("spectrum-db-axis"); +var spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); +var spectrumTooltip = document.getElementById("spectrum-tooltip"); +var spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn"); +var spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn"); +var spectrumSource = null; +var spectrumReconnectTimer = null; +var meterSource = null; +var meterReconnectTimer = null; +var spectrumDrawPending = false; +var spectrumAxisKey = ""; +var spectrumDbAxisKey = ""; +var lastSpectrumRenderData = null; +var spectrumPeakHoldFrames = []; +var pendingSpectrumFrameWaiters = []; +var sweetSpotScanInFlight = false; +var spectrumTmpGridSegments = []; +var spectrumTmpFillPoints = []; +var spectrumTmpPeakPoints = []; +var spectrumTmpMarkerPoints = []; +var spectrumZoom = 1; +var spectrumPanFrac = 0.5; +var spectrumFloor = -115; +var spectrumRange = 90; +var waterfallGamma = 1; +var SPECTRUM_HEADROOM_DB = 20; +var SPECTRUM_SMOOTH_ALPHA = 0.42; +var spectrumCrosshairX = null; +var spectrumCrosshairY = null; +var _bwDragEdge = null; +var _bwDragStartX = 0; +var _bwDragStartBwHz = 0; +var _bwDragCanvas = null; +function spectrumBgColor() { + return canvasPalette().bg; +} +function clearSpectrumPeakHoldFrames() { + spectrumPeakHoldFrames = []; +} +function settlePendingSpectrumFrameWaiters(frame) { + if (!pendingSpectrumFrameWaiters.length) return; + const remaining = []; + for (const waiter of pendingSpectrumFrameWaiters) { + if (!waiter) continue; + const targetCenterHz = Number(waiter.targetCenterHz); + if (isFiniteNumber(targetCenterHz) && (!frame || Math.abs(Number(frame.center_hz) - targetCenterHz) >= 2)) { + remaining.push(waiter); + continue; + } + if (waiter.timer) { + clearTimeout(waiter.timer); + waiter.timer = null; + } + if (typeof waiter.resolve === "function") { + waiter.resolve(frame); + } + } + pendingSpectrumFrameWaiters = remaining; +} +function rejectPendingSpectrumFrameWaiters(error) { + if (!pendingSpectrumFrameWaiters.length) return; + for (const waiter of pendingSpectrumFrameWaiters) { + if (!waiter) continue; + if (waiter.timer) { + clearTimeout(waiter.timer); + waiter.timer = null; + } + if (typeof waiter.reject === "function") { + waiter.reject(error || new Error("Spectrum unavailable")); + } + } + pendingSpectrumFrameWaiters = []; +} +function waitForSpectrumFrame(expectedCenterHz = null, timeoutMs = 1200) { + const targetCenterHz = expectedCenterHz; + if (lastSpectrumData && (!isFiniteNumber(targetCenterHz) || Math.abs(Number(lastSpectrumData.center_hz) - targetCenterHz) < 2)) { + return Promise.resolve(lastSpectrumData); + } + return new Promise((resolve, reject) => { + const waiter = { + targetCenterHz, + resolve, + reject, + timer: null + }; + waiter.timer = setTimeout(() => { + pendingSpectrumFrameWaiters = pendingSpectrumFrameWaiters.filter((entry) => entry !== waiter); + reject(new Error("Timed out waiting for spectrum frame")); + }, Math.max(200, timeoutMs)); + pendingSpectrumFrameWaiters.push(waiter); + }); +} +function pruneSpectrumPeakHoldFrames(now = Date.now()) { + const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs <= 0) { + clearSpectrumPeakHoldFrames(); + return; + } + let removeCount = 0; + for (let i = 0; i < spectrumPeakHoldFrames.length; i++) { + const f = spectrumPeakHoldFrames[i]; + if (f && isNumericBins(f.bins) && now - f.t <= holdMs) break; + removeCount++; + } + if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); +} +function pushSpectrumPeakHoldFrame(frame) { + if (!frame || !isNumericBins(frame.bins) || frame.bins.length === 0) { + clearSpectrumPeakHoldFrames(); + return; + } + const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs <= 0) { + clearSpectrumPeakHoldFrames(); + return; + } + const now = Date.now(); + pruneSpectrumPeakHoldFrames(now); + const lastFrame = spectrumPeakHoldFrames[spectrumPeakHoldFrames.length - 1]; + if (lastFrame && lastFrame.bins.length !== frame.bins.length) { + clearSpectrumPeakHoldFrames(); + } + spectrumPeakHoldFrames.push({ t: now, bins: frame.bins.slice() }); +} +function buildSpectrumPeakHoldBins(currentBins) { + const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs <= 0 || !isNumericBins(currentBins) || currentBins.length === 0) { + return null; + } + pruneSpectrumPeakHoldFrames(); + if (spectrumPeakHoldFrames.length === 0) return null; + const peakBins = currentBins.slice(); + for (const frame of spectrumPeakHoldFrames) { + if (!frame || !isNumericBins(frame.bins) || frame.bins.length !== peakBins.length) continue; + for (let i = 0; i < peakBins.length; i++) { + const frameValue = frame.bins[i]; + const peakValue = peakBins[i]; + if (frameValue !== void 0 && peakValue !== void 0 && frameValue > peakValue) peakBins[i] = frameValue; + } + } + return peakBins; +} +var _smoothBins = []; +function buildSpectrumRenderData(frame) { + if (!frame || !isNumericBins(frame.bins)) return frame; + const n = frame.bins.length; + const prev = lastSpectrumRenderData; + const canBlend = prev && isNumericBins(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz; + if (_smoothBins.length !== n) _smoothBins = new Array(n); + const src = frame.bins; + if (canBlend) { + const prevBins = prev.bins; + const alpha = SPECTRUM_SMOOTH_ALPHA; + for (let i = 0; i < n; i++) { + const previous = prevBins[i] ?? 0; + _smoothBins[i] = previous + ((src[i] ?? previous) - previous) * alpha; + } + } else { + for (let i = 0; i < n; i++) _smoothBins[i] = src[i] ?? 0; + } + return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds ?? null }; +} +function spectrumVisibleRange(data) { + const fullSpanHz = data.sample_rate; + const loHz = data.center_hz - fullSpanHz / 2; + const halfVis = 0.5 / spectrumZoom; + spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis); + const visCenterHz = loHz + spectrumPanFrac * fullSpanHz; + const visSpanHz = fullSpanHz / spectrumZoom; + return { + loHz, + hiHz: loHz + fullSpanHz, + visLoHz: visCenterHz - visSpanHz / 2, + visHiHz: visCenterHz + visSpanHz / 2, + fullSpanHz, + visSpanHz + }; +} +function canvasXToHz(cssX, cssW, range) { + return range.visLoHz + cssX / cssW * range.visSpanHz; +} +function nearestSpectrumPeak(cssX, cssW, data) { + if (!data || !isNumericBins(data.bins) || data.bins.length === 0 || cssW <= 0) { + return null; + } + const bins = data.bins; + const maxIdx = bins.length - 1; + const range = spectrumVisibleRange(data); + const fullLoHz = data.center_hz - data.sample_rate / 2; + const targetHz = canvasXToHz(cssX, cssW, range); + const targetIdx = Math.max( + 0, + Math.min(maxIdx, Math.round((targetHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const visStartIdx = Math.max( + 0, + Math.min(maxIdx, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const visEndIdx = Math.max( + visStartIdx, + Math.min(maxIdx, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const visSpanBins = Math.max(1, visEndIdx - visStartIdx); + const searchRadius = Math.max(3, Math.min(80, Math.round(24 / cssW * visSpanBins))); + const searchLo = Math.max(1, targetIdx - searchRadius); + const searchHi = Math.min(maxIdx - 1, targetIdx + searchRadius); + let windowMax = -Infinity; + const localPeaks = []; + for (let i = searchLo; i <= searchHi; i++) { + const val = bins[i] ?? -Infinity; + if (val > windowMax) windowMax = val; + if (val >= (bins[i - 1] ?? -Infinity) && val >= (bins[i + 1] ?? -Infinity)) { + localPeaks.push(i); + } + } + const candidates = localPeaks.filter((i) => (bins[i] ?? -Infinity) >= windowMax - 6); + const ranked = (candidates.length ? candidates : localPeaks).sort((a, b) => { + const dist = Math.abs(a - targetIdx) - Math.abs(b - targetIdx); + if (dist !== 0) return dist; + return (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity); + }); + let snappedIdx = ranked[0]; + if (snappedIdx == null) { + snappedIdx = targetIdx; + for (let i = searchLo; i <= searchHi; i++) { + if ((bins[i] ?? -Infinity) > (bins[snappedIdx] ?? -Infinity)) snappedIdx = i; + } + } + return { + index: snappedIdx, + hz: Math.round(fullLoHz + snappedIdx / maxIdx * data.sample_rate), + db: bins[snappedIdx] ?? -Infinity + }; +} +function nearestSpectrumPeakHz(cssX, cssW, data) { + return nearestSpectrumPeak(cssX, cssW, data)?.hz ?? null; +} +function spectrumTargetHzAt(cssX, cssW, data) { + if (!data) return null; + const range = spectrumVisibleRange(data); + return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range)); +} +function visibleSpectrumPeakIndices(data, limit = 24) { + if (!data || !isNumericBins(data.bins) || data.bins.length < 3) { + return []; + } + const bins = data.bins; + const maxIdx = bins.length - 1; + const range = spectrumVisibleRange(data); + const fullLoHz = data.center_hz - data.sample_rate / 2; + const visStartIdx = Math.max( + 1, + Math.min(maxIdx - 1, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const visEndIdx = Math.max( + visStartIdx, + Math.min(maxIdx - 1, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx)) + ); + const peaks = []; + for (let i = visStartIdx; i <= visEndIdx; i++) { + const v = bins[i] ?? -Infinity; + if (v >= (bins[i - 1] ?? -Infinity) && v >= (bins[i + 1] ?? -Infinity)) { + peaks.push(i); + } + } + if (peaks.length === 0) { + return []; + } + const peakValues = peaks.map((i) => bins[i] ?? -Infinity).sort((a, b) => a - b); + const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))] ?? -Infinity; + return peaks.filter((i) => (bins[i] ?? -Infinity) >= cutoff).sort((a, b) => (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity)).slice(0, limit).sort((a, b) => a - b); +} +function formatSpectrumFreq(hz) { + if (jogUnit >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (jogUnit >= 1e3) return (hz / 1e3).toFixed(3) + " kHz"; + return hz.toFixed(0) + " Hz"; +} +function scheduleSpectrumReconnect() { + if (spectrumReconnectTimer !== null) return; + spectrumReconnectTimer = setTimeout(() => { + spectrumReconnectTimer = null; + startSpectrumStreaming(); + }, 1e3); +} +function startSpectrumStreaming() { + if (spectrumSource !== null) return; + const spectrumUrl = lastActiveRigId ? `/spectrum?remote=${encodeURIComponent(lastActiveRigId)}` : "/spectrum"; + spectrumSource = new EventSource(spectrumUrl); + const source = spectrumSource; + source.onmessage = (evt) => { + if (evt.data === "null") { + rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset")); + lastSpectrumData = null; + lastSpectrumRenderData = null; + clearSpectrumPeakHoldFrames(); + overviewWaterfallRows = []; + overviewWaterfallPushCount = 0; + overviewWfResetTextureCache(); + spectrumWfRows = []; + spectrumWfPushCount = 0; + spectrumWfTexReady = false; + scheduleOverviewDraw(); + clearSpectrumCanvas(); + updateRdsPsOverlay(null); + } + }; + source.addEventListener("b", (evt) => { + try { + const eventData = messageEventData(evt); + const commaA = eventData.indexOf(","); + const commaB = eventData.indexOf(",", commaA + 1); + const centerHz = Number(eventData.slice(0, commaA)); + const sampleRate = Number(eventData.slice(commaA + 1, commaB)); + const b64 = eventData.slice(commaB + 1); + const hadSpectrum = !!lastSpectrumData; + const bins = decodeBase64Int8(b64); + const rds = lastSpectrumData?.rds; + const frame = { bins, center_hz: centerHz, sample_rate: sampleRate, rds: rds ?? null }; + lastSpectrumData = frame; + window.lastSpectrumData = lastSpectrumData; + const spectrumSummary = document.getElementById("spectrum-text-summary"); + if (spectrumSummary && bins.length) { + let peakIndex = 0; + for (let i = 1; i < bins.length; i += 1) if ((bins[i] ?? -Infinity) > (bins[peakIndex] ?? -Infinity)) peakIndex = i; + const peakHz = centerHz - sampleRate / 2 + peakIndex / Math.max(1, bins.length - 1) * sampleRate; + spectrumSummary.textContent = `Spectrum centered at ${formatFrequencyForHumans(centerHz)}, spanning ${formatFrequencyForHumans(sampleRate)}. Strongest visible bin near ${formatFrequencyForHumans(peakHz)} at ${bins[peakIndex]} dB.`; + } + if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1e3) { + spectrumCenterPendingHz = null; + } + const renderFrame = buildSpectrumRenderData(frame); + lastSpectrumRenderData = renderFrame; + settlePendingSpectrumFrameWaiters(frame); + pushSpectrumPeakHoldFrame(renderFrame); + pushOverviewWaterfallFrame(frame); + pushSpectrumWaterfallFrame(frame); + refreshCenterFreqDisplay(); + if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + scheduleSpectrumDraw(); + if (!hadSpectrum) { + updateRdsPsOverlay(frame.rds); + } else { + positionRdsPsOverlay(); + } + } catch (_) { + } + }); + source.addEventListener("rds", (evt) => { + try { + const eventData = messageEventData(evt); + const value = eventData === "null" ? void 0 : parseJsonUnknown(eventData); + const rds = value !== void 0 && isRdsData(value) ? value : null; + if (lastSpectrumData) lastSpectrumData.rds = rds; + updateRdsPsOverlay(rds ?? null); + } catch (_) { + } + }); + source.addEventListener("rds_vchan", (evt) => { + try { + const eventData = messageEventData(evt); + const value = eventData === "null" ? [] : parseJsonUnknown(eventData); + const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : []; + const next = /* @__PURE__ */ new Map(); + const nextSig = /* @__PURE__ */ new Map(); + payload.forEach((entry) => { + next.set(entry.id, entry.rds ?? null); + if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db); + }); + vchanRdsById = next; + vchanSignalDbById = nextSig; + const virtualChannelId = window.trx.modules.vchan?.activeId; + if (virtualChannelId && nextSig.has(virtualChannelId)) { + sigLastDbm = nextSig.get(virtualChannelId) ?? null; + refreshSigStrengthDisplay(); + } + updateRdsPsOverlay(primaryRds); + } catch (_) { + } + }); + source.onerror = () => { + rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected")); + if (spectrumSource) { + spectrumSource.close(); + spectrumSource = null; + } + scheduleSpectrumReconnect(); + }; +} +function stopSpectrumStreaming() { + if (spectrumSource !== null) { + spectrumSource.close(); + spectrumSource = null; + } + if (spectrumReconnectTimer !== null) { + clearTimeout(spectrumReconnectTimer); + spectrumReconnectTimer = null; + } + spectrumDrawPending = false; + lastSpectrumData = null; + lastSpectrumRenderData = null; + rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped")); + clearSpectrumPeakHoldFrames(); + overviewWaterfallRows = []; + overviewWaterfallPushCount = 0; + overviewWfResetTextureCache(); + spectrumWfRows = []; + spectrumWfPushCount = 0; + spectrumWfTexReady = false; + scheduleOverviewDraw(); + updateRdsPsOverlay(null); + clearSpectrumCanvas(); +} +var METER_ATTACK_ALPHA = 0.08; +var METER_DECAY_ALPHA = 0.03; +var meterSmoothedDbm = null; +var meterRafPending = false; +function scheduleMeterReconnect() { + if (meterReconnectTimer !== null) return; + meterReconnectTimer = setTimeout(() => { + meterReconnectTimer = null; + startMeterStreaming(); + }, 1e3); +} +function applyMeterSample(dbm) { + if (typeof dbm !== "number" || !isFiniteNumber(dbm)) return; + if (meterSmoothedDbm === null) { + meterSmoothedDbm = dbm; + } else { + const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA; + meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm); + } + if (!meterRafPending) { + meterRafPending = true; + requestAnimationFrame(flushMeterDom); + } +} +function flushMeterDom() { + meterRafPending = false; + const dbm = meterSmoothedDbm; + if (dbm === null) return; + prevRenderData.sigDbm = dbm; + const sUnits = dbmToSUnits(dbm); + sigLastSUnits = sUnits; + sigLastDbm = dbm; + const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; + if (signalBar) signalBar.style.width = `${pct}%`; + if (signalValue) signalValue.innerHTML = formatSignal(sUnits); + refreshSigStrengthDisplay(); +} +function startMeterStreaming() { + if (meterSource !== null) return; + const url = lastActiveRigId ? `/meter?remote=${encodeURIComponent(lastActiveRigId)}` : "/meter"; + meterSource = new EventSource(url); + meterSource.onmessage = (evt) => { + try { + const value = parseJsonUnknown(evt.data); + if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig); + } catch (_) { + } + }; + meterSource.onerror = () => { + if (meterSource) { + meterSource.close(); + meterSource = null; + } + scheduleMeterReconnect(); + }; +} +function stopMeterStreaming() { + if (meterSource !== null) { + meterSource.close(); + meterSource = null; + } + if (meterReconnectTimer !== null) { + clearTimeout(meterReconnectTimer); + meterReconnectTimer = null; + } + meterSmoothedDbm = null; +} +function clearSpectrumCanvas() { + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; + const cssW = spectrumCanvas.clientWidth || 1; + const cssH = spectrumCanvas.clientHeight || 1; + spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1); + spectrumGl.clear(cssColorToRgba(spectrumBgColor())); + if (spectrumDbAxis) { + spectrumDbAxis.replaceChildren(); + spectrumDbAxisKey = ""; + } +} +function formatOverlayPs(ps) { + return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_"); +} +function formatPsHtml(ps) { + const clipped = primitiveString(ps).slice(0, 8); + let html = ""; + for (let i = 0; i < 8; i += 1) { + const ch = clipped[i]; + if (ch == null || ch === " ") { + html += `_`; + } else { + html += escapeHtml(ch); + } + } + return html; +} +function formatOverlayPi(pi) { + return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --"; +} +function formatOverlayPty(pty, ptyName) { + const name = primitiveString(ptyName); + if (name) return name; + const code = primitiveString(pty); + return code || "--"; +} +function overlayTrafficFlagHtml(label, active) { + const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive"; + return `${label}`; +} +function formatRdsFlag(value, yes = "Yes", no = "No") { + if (value == null) return "--"; + return value ? yes : no; +} +function formatRdsAudio(value) { + if (value == null) return "--"; + return value ? "Music" : "Speech"; +} +function formatMinuteTimestamp(date = /* @__PURE__ */ new Date()) { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const min = String(date.getMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${min}`; +} +function buildRdsRawPayload(rds) { + const freqHz = activeChannelFreqHz(); + return { + time: formatMinuteTimestamp(), + freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null, + ...rds + }; +} +function formatRdsAfMHz(hz) { + return `${(hz / 1e6).toFixed(1)} MHz`; +} +function tuneRdsAlternativeFrequency(hz) { + if (!isFiniteNumber(hz) || hz <= 0) return; + const targetHz = Math.round(hz); + setRigFrequency(targetHz); + showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200); +} +function renderRdsAlternativeFrequencies(list) { + const afEl = document.getElementById("rds-af-list"); + if (!afEl) return; + const afs = Array.isArray(list) ? list.filter((hz) => isFiniteNumber(hz) && hz > 0).map((hz) => Math.round(hz)) : []; + const afKey = afs.join(","); + if (!afs.length) { + if (afEl.dataset.afKey === "") return; + afEl.dataset.afKey = ""; + afEl.textContent = "--"; + return; + } + if (afEl.dataset.afKey === afKey) return; + afEl.dataset.afKey = afKey; + afEl.replaceChildren(); + for (const hz of afs) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "rds-af-btn"; + btn.dataset.hz = String(hz); + btn.textContent = formatRdsAfMHz(hz); + afEl.appendChild(btn); + } + if (!afEl.childElementCount) afEl.textContent = "--"; +} +async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) { + const rds = rdsOverride || activeChannelRds(); + const ps = rds?.program_service; + if (!rds || !ps || ps.length === 0) { + showHint("No RDS PS", 1200); + return; + } + const freqHz = isFiniteNumber(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz(); + const freqMhz = isFiniteNumber(freqHz) ? (Math.round(freqHz / 1e5) / 10).toFixed(1) : "--.-"; + const piHex = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; + const clipPs = formatOverlayPs(ps); + const clipText = `${formatMinuteTimestamp()} - ${freqMhz} MHz - ${piHex} - ${clipPs}`; + try { + await navigator.clipboard.writeText(clipText); + showHint("RDS copied", 1200); + } catch (_) { + showHint("Clipboard failed", 1500); + } +} +async function copyRdsRawToClipboard() { + const rawEl = document.getElementById("rds-raw"); + const rawText = rawEl?.textContent ?? ""; + if (!rawText || rawText === "--") { + showHint("No RDS JSON", 1200); + return; + } + try { + await navigator.clipboard.writeText(rawText); + showHint("RDS JSON copied", 1200); + } catch (_) { + showHint("Clipboard failed", 1500); + } +} +var rdsPsValueEl = document.getElementById("rds-ps"); +if (rdsPsValueEl) { + rdsPsValueEl.addEventListener("click", () => { + void copyRdsPsToClipboard(); + }); +} +var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); +if (rdsRawCopyBtn) { + rdsRawCopyBtn.addEventListener("click", () => { + void copyRdsRawToClipboard(); + }); +} +var rdsAfListEl = document.getElementById("rds-af-list"); +if (rdsAfListEl) { + rdsAfListEl.addEventListener("click", (event) => { + const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null; + const hz = Number(btn?.dataset?.hz); + if (btn && isFiniteNumber(hz)) { + tuneRdsAlternativeFrequency(hz); + } + }); +} +function updateRdsPsOverlay(rds) { + primaryRds = rds || null; + const activeRds = activeChannelRds(); + updateDocumentTitle(activeRds); + renderRdsOverlays(); + const statusEl = document.getElementById("rds-status"); + const modeEl2 = document.getElementById("rds-mode"); + const piEl = document.getElementById("rds-pi"); + const psEl = document.getElementById("rds-ps"); + const ptyEl = document.getElementById("rds-pty"); + const ptyNameEl = document.getElementById("rds-pty-name"); + const ptynEl = document.getElementById("rds-ptyn"); + const tpEl = document.getElementById("rds-tp"); + const taEl = document.getElementById("rds-ta"); + const musicEl = document.getElementById("rds-music"); + const stereoEl = document.getElementById("rds-stereo"); + const compEl = document.getElementById("rds-compressed"); + const headEl = document.getElementById("rds-artificial-head"); + const dynPtyEl = document.getElementById("rds-dynamic-pty"); + const afEl = document.getElementById("rds-af-list"); + const rtEl = document.getElementById("rds-radio-text"); + const rawEl = document.getElementById("rds-raw"); + if (!statusEl || !piEl || !psEl || !ptyEl || !ptyNameEl || !rawEl) return; + if (modeEl2) modeEl2.textContent = document.getElementById("mode")?.value || "--"; + if (!activeRds) { + statusEl.textContent = "No signal"; + statusEl.className = "rds-value rds-no-signal"; + piEl.textContent = "--"; + psEl.textContent = "--"; + ptyEl.textContent = "--"; + ptyNameEl.textContent = "--"; + if (ptynEl) ptynEl.textContent = "--"; + if (tpEl) tpEl.textContent = "--"; + if (taEl) taEl.textContent = "--"; + if (musicEl) musicEl.textContent = "--"; + if (stereoEl) stereoEl.textContent = "--"; + if (compEl) compEl.textContent = "--"; + if (headEl) headEl.textContent = "--"; + if (dynPtyEl) dynPtyEl.textContent = "--"; + if (afEl) afEl.textContent = "--"; + if (rtEl) rtEl.textContent = "--"; + if (rawEl && lastSpectrumData) { + const { bins: _b, ...rest } = lastSpectrumData; + const freqHz = activeChannelFreqHz(); + rawEl.textContent = JSON.stringify({ + time: formatMinuteTimestamp(), + freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null, + ...rest + }, null, 2); + } + return; + } + statusEl.textContent = "Decoding"; + statusEl.className = "rds-value rds-decoding"; + piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; + if (psEl) { + if (activeRds.program_service) { + psEl.innerHTML = formatPsHtml(activeRds.program_service); + } else { + psEl.textContent = "--"; + } + } + ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--"); + ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--"; + if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--"; + if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program); + if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement); + if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music); + if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo); + if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed); + if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head); + if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty); + renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz); + if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--"; + rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2); +} +window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds); +function scheduleSpectrumDraw() { + if (spectrumDrawPending) return; + spectrumDrawPending = true; + requestAnimationFrame(() => { + spectrumDrawPending = false; + if (lastSpectrumRenderData) { + drawSpectrum(lastSpectrumRenderData); + if (overviewWaterfallRows.length > 0) scheduleOverviewDraw(); + if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw(); + } + }); +} +function drawSpectrum(data) { + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; + const dpr = _cachedDpr; + const cssW = _cachedSpectrumCssW; + const cssH = _cachedSpectrumCssH; + spectrumGl.ensureSize(cssW, cssH, dpr); + const W = spectrumCanvas.width; + const H = spectrumCanvas.height; + const pal = canvasPalette(); + const range = spectrumVisibleRange(data); + const bins = data.bins; + const peakHoldBins = buildSpectrumPeakHoldBins(bins); + const n = bins.length; + spectrumGl.clear(cssColorToRgba(pal.bg)); + if (!n) return; + const DB_MIN = spectrumFloor; + const DB_MAX = spectrumFloor + spectrumRange; + const dbRange = DB_MAX - DB_MIN; + const fullSpanHz = data.sample_rate; + const loHz = data.center_hz - fullSpanHz / 2; + const gridStep = spectrumRange > 100 ? 20 : 10; + spectrumTmpGridSegments.length = 0; + for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) { + const y = Math.round(H * (1 - (db - DB_MIN) / dbRange)); + spectrumTmpGridSegments.push(0, y, W, y); + } + spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1); + updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr); + function hzToX(hz) { + return (hz - range.visLoHz) / range.visSpanHz * W; + } + function binX(i) { + return hzToX(loHz + i / (n - 1) * fullSpanHz); + } + function binYFromBins(srcBins, i) { + const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i] ?? DB_MIN)); + return H * (1 - (db - DB_MIN) / dbRange); + } + spectrumTmpFillPoints.length = 0; + for (let i = 0; i < n; i++) { + spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i)); + } + spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill)); + if (isNumericBins(peakHoldBins) && peakHoldBins.length === n) { + spectrumTmpPeakPoints.length = 0; + for (let i = 0; i < n; i++) { + spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); + } + spectrumGl.drawPolyline(spectrumTmpPeakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9)); + } + spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr)); + const noiseDb = estimateNoiseFloorDb(bins); + if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) { + const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange)); + const nfSegments = []; + const dashLen = Math.max(4, Math.round(6 * dpr)); + const gapLen = Math.max(3, Math.round(5 * dpr)); + for (let x = 0; x < W; x += dashLen + gapLen) { + nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY); + } + spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8)); + } + const markerPeaks = visibleSpectrumPeakIndices(data); + if (markerPeaks.length > 0) { + spectrumTmpMarkerPoints.length = 0; + for (const idx of markerPeaks) { + spectrumTmpMarkerPoints.push(binX(idx), binYFromBins(bins, idx)); + } + spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak)); + } + if (spectrumCrosshairX != null && spectrumCrosshairY != null) { + const cx = spectrumCrosshairX * dpr; + const cy = spectrumCrosshairY * dpr; + const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5); + spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6)); + spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6)); + } + if (_spectrumZoomEl) { + if (spectrumZoom > 1.01) { + _spectrumZoomEl.textContent = spectrumZoom.toFixed(1) + "x"; + _spectrumZoomEl.style.display = "block"; + } else { + _spectrumZoomEl.style.display = "none"; + } + } + if (_spectrumMinimapEl) { + if (spectrumZoom > 1.01) { + _spectrumMinimapEl.style.display = "block"; + const viewFrac = 1 / spectrumZoom; + const halfVis = viewFrac / 2; + const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis); + const viewL = panClamped - halfVis; + const viewR = panClamped + halfVis; + if (_spectrumMinimapInner) { + _spectrumMinimapInner.style.left = viewL * 100 + "%"; + _spectrumMinimapInner.style.width = (viewR - viewL) * 100 + "%"; + } + } else { + _spectrumMinimapEl.style.display = "none"; + } + } + updateSpectrumFreqAxis(range); + updateBookmarkAxis(range); + updateBandplanStrip(range); + drawSignalOverlay(); +} +var spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas"); +var spectrumWaterfallGl = typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas ? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions) : null; +var spectrumWfRows = []; +var spectrumWfPushCount = 0; +var spectrumWfTexData = null; +var spectrumWfTexWidth = 0; +var spectrumWfTexHeight = 0; +var spectrumWfTexPushCount = 0; +var spectrumWfTexPalKey = ""; +var spectrumWfTexReady = false; +var spectrumWfDrawPending = false; +var SPECTRUM_WF_TEX_MAX_W = 1024; +var _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator"); +var _spectrumMinimapEl = document.getElementById("spectrum-minimap"); +var _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null; +var _cachedSpectrumCssW = 640; +var _cachedSpectrumCssH = 160; +var _cachedSpecWfCssW = 640; +var _cachedSpecWfCssH = 120; +var _cachedDpr = window.devicePixelRatio || 1; +function _updateCachedCanvasSizes() { + _cachedDpr = window.devicePixelRatio || 1; + if (spectrumCanvas) { + _cachedSpectrumCssW = spectrumCanvas.clientWidth || 640; + _cachedSpectrumCssH = spectrumCanvas.clientHeight || 160; + } + if (spectrumWaterfallCanvas) { + _cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640; + _cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120; + } +} +window.addEventListener("resize", _updateCachedCanvasSizes); +_updateCachedCanvasSizes(); +function pushSpectrumWaterfallFrame(data) { + if (!spectrumWaterfallCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return; + spectrumWfRows.push(data.bins.slice()); + spectrumWfPushCount++; + trimSpectrumWaterfallRows(); + scheduleSpectrumWaterfallDraw(); +} +function trimSpectrumWaterfallRows() { + if (!spectrumWaterfallCanvas) return; + const maxRows = Math.max(1, Math.floor(_cachedSpecWfCssH * _cachedDpr)); + if (spectrumWfRows.length > maxRows) { + spectrumWfRows.splice(0, spectrumWfRows.length - maxRows); + } +} +function scheduleSpectrumWaterfallDraw() { + if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return; + spectrumWfDrawPending = true; + requestAnimationFrame(() => { + spectrumWfDrawPending = false; + drawSpectrumWaterfall(); + }); +} +function drawSpectrumWaterfall() { + if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return; + if (!lastSpectrumData || spectrumWfRows.length === 0) return; + const dpr = _cachedDpr; + const cssW = _cachedSpecWfCssW; + const cssH = _cachedSpecWfCssH; + spectrumWaterfallGl.ensureSize(cssW, cssH, dpr); + const W = spectrumWaterfallCanvas.width; + const H = spectrumWaterfallCanvas.height; + if (W <= 0 || H <= 0) return; + const pal = canvasPalette(); + const maxVisible = Math.max(1, Math.floor(H)); + const rows = spectrumWfRows.slice(-maxVisible); + if (rows.length === 0) return; + const iW = Math.max(96, Math.min(SPECTRUM_WF_TEX_MAX_W, Math.ceil(W / 2))); + const iH = Math.max(1, rows.length); + const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); + const view = spectrumVisibleRange(lastSpectrumData); + const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`; + const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; + const rowStride = iW * 4; + const expectedSize = iW * iH * 4; + const newPushes = spectrumWfPushCount - spectrumWfTexPushCount; + const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH; + const palChanged = spectrumWfTexPalKey !== palKey; + const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0; + let texUpdated = false; + if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) { + spectrumWfTexData = new Uint8Array(expectedSize); + } + spectrumWfTexWidth = iW; + spectrumWfTexHeight = iH; + ensureWaterfallLut(pal, minDb, maxDb); + function renderRow(dstY, srcBins) { + if (!isNumericBins(srcBins) || srcBins.length === 0) return; + const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); + const spanBins = Math.max(1, endIdx - startIdx); + const rowBase = dstY * rowStride; + const iwM1 = Math.max(1, iW - 1); + for (let x = 0; x < iW; x++) { + const binIdx = Math.min(endIdx, startIdx + (x * spanBins / iwM1 | 0)); + const db = srcBins[binIdx]; + if (db !== void 0) waterfallLutWrite(textureData, rowBase + x * 4, db); + } + } + const textureData = spectrumWfTexData; + if (needsFull) { + for (let y = 0; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + spectrumWfTexPushCount = spectrumWfPushCount; + spectrumWfTexPalKey = palKey; + texUpdated = true; + } else if (newPushes > 0) { + const newCount = Math.min(newPushes, iH); + if (newCount >= iH) { + for (let y = 0; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + } else { + const shiftBytes = newCount * rowStride; + textureData.copyWithin(0, shiftBytes); + const startRow = iH - newCount; + for (let y = startRow; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } + } + spectrumWfTexPushCount = spectrumWfPushCount; + spectrumWfTexPalKey = palKey; + texUpdated = true; + } + if (texUpdated || !spectrumWfTexReady) { + spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, textureData, "linear"); + spectrumWfTexReady = true; + } + spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true); +} +function bmLuminance(hex) { + const lin = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + const r = lin(parseInt(hex.slice(1, 3), 16) / 255); + const g = lin(parseInt(hex.slice(3, 5), 16) / 255); + const b = lin(parseInt(hex.slice(5, 7), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +function bmContrastFg(bgHex) { + return bmLuminance(bgHex) >= 0.4 ? "#1a202c" : "#ffffff"; +} +function bmResolveThemeColor(name, fallbackHex) { + const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (/^#[0-9a-f]{6}$/i.test(val)) return val; + if (/^#[0-9a-f]{3}$/i.test(val)) + return "#" + [...val.slice(1)].map((c) => c + c).join(""); + const m = val.match(/\d+/g); + if (m && m.length >= 3) + return "#" + m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, "0")).join(""); + return fallbackHex; +} +function bmBlendHex(aHex, bHex, ratio = 0.5) { + const mix = Math.max(0, Math.min(1, isFiniteNumber(ratio) ? ratio : 0.5)); + const aR = parseInt(aHex.slice(1, 3), 16); + const aG = parseInt(aHex.slice(3, 5), 16); + const aB = parseInt(aHex.slice(5, 7), 16); + const bR = parseInt(bHex.slice(1, 3), 16); + const bG = parseInt(bHex.slice(3, 5), 16); + const bB = parseInt(bHex.slice(5, 7), 16); + const toHex = (value) => Math.round(value).toString(16).padStart(2, "0"); + return "#" + [ + aR + (bR - aR) * mix, + aG + (bG - aG) * mix, + aB + (bB - aB) * mix + ].map(toHex).join(""); +} +function bmThemePalette() { + const yellow = bmResolveThemeColor("--accent-yellow", "#f0ad4e"); + const green = bmResolveThemeColor("--accent-green", "#c24b1a"); + const red = bmResolveThemeColor("--accent-red", "#e55353"); + const heading = bmResolveThemeColor("--text-heading", "#c6d5ea"); + const border = bmResolveThemeColor("--border-light", "#304766"); + return [ + yellow, + bmBlendHex(yellow, heading, 0.28), + bmBlendHex(yellow, green, 0.45), + bmBlendHex(green, heading, 0.22), + bmBlendHex(yellow, red, 0.42), + bmBlendHex(red, heading, 0.18), + bmBlendHex(border, yellow, 0.58), + bmBlendHex(border, heading, 0.5) + ]; +} +function bmCategoryColorMap() { + const ref = window.trx.modules.bookmarks?.overlayList ?? []; + const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort(); + const palette = bmThemePalette(); + const fallback = palette[0] ?? BOOKMARK_MARKER_FALLBACK; + const map = { "": fallback }; + cats.forEach((cat, i) => { + if (cat) map[cat] = palette[(i + 1) % palette.length] ?? fallback; + }); + return map; +} +function createBookmarkChip(bm, colorMap, options = {}) { + const span = document.createElement("span"); + const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + " Hz"; + span.className = "spectrum-bookmark-chip"; + if (options.sideStack) { + span.classList.add("spectrum-bookmark-chip-side"); + } else { + span.style.top = "2px"; + } + span.title = buildBookmarkTooltipText(bm) || bm.name + " — " + freqStr + (bm.comment ? "\n" + bm.comment : ""); + span.dataset.bmId = bm.id; + const labelHtml = options.sideStack ? `${escapeHtml(freqStr)}${escapeHtml(bm.name)}` : " " + escapeHtml(bm.name) + ""; + span.innerHTML = labelHtml; + const col = colorMap[bm.category || ""] ?? BOOKMARK_MARKER_FALLBACK; + span.style.setProperty("--bm-cat-bg", col); + span.style.setProperty("--bm-cat-fg", bmContrastFg(col)); + span.addEventListener("click", () => { + void window.trx.modules.bookmarks?.apply(bm); + }); + return span; +} +function updateSideBookmarkStack(container, bookmarks, colorMap) { + if (!container) return; + const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0; + const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : ""; + if (!Array.isArray(bookmarks) || bookmarks.length === 0) { + if (container.dataset.bmKey) { + container.replaceChildren(); + container.dataset.bmKey = ""; + } + container.classList.remove("bm-side-visible"); + return; + } + if (container.dataset.bmKey !== nextKey) { + container.dataset.bmKey = nextKey; + container.replaceChildren(); + for (const bm of bookmarks) { + container.appendChild(createBookmarkChip(bm, colorMap, { sideStack: true })); + } + } + container.classList.add("bm-side-visible"); +} +function updateBookmarkAxis(range) { + const axisEl = document.getElementById("spectrum-bookmark-axis"); + const leftSideEl = document.getElementById("spectrum-bookmark-side-left"); + const rightSideEl = document.getElementById("spectrum-bookmark-side-right"); + if (!axisEl) return; + const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? []; + const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz); + const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3); + const rightBookmarks = allBookmarks.filter((bm) => bm.freq_hz > range.visHiHz).sort((a, b) => a.freq_hz - b.freq_hz).slice(0, 3); + const colorMap = bmCategoryColorMap(); + updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap); + updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap); + const hasVisible = visBookmarks.length > 0; + axisEl.classList.toggle("bm-axis-visible", hasVisible); + if (!hasVisible) { + if (axisEl.dataset.bmKey) { + axisEl.replaceChildren(); + axisEl.dataset.bmKey = ""; + } + return; + } + const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0; + const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`; + if (axisEl.dataset.bmKey !== newKey) { + axisEl.dataset.bmKey = newKey; + axisEl.replaceChildren(); + for (const bm of visBookmarks) { + axisEl.appendChild(createBookmarkChip(bm, colorMap)); + } + } + const axisWidth = axisEl.clientWidth || 0; + const edgePad = 8; + const spans = axisEl.querySelectorAll(":scope > span"); + const widths = []; + for (const span of spans) widths.push(span.offsetWidth || 0); + visBookmarks.forEach((bm, i) => { + const span = spans[i]; + if (!span) return; + const frac = (bm.freq_hz - range.visLoHz) / range.visSpanHz; + if (axisWidth > 0) { + const lw = widths[i] ?? 0; + const clamped = Math.max(edgePad + lw / 2, Math.min(axisWidth - edgePad - lw / 2, frac * axisWidth)); + span.style.transform = `translateX(${clamped - lw / 2}px)`; + } else { + span.style.left = (frac * 100).toFixed(2) + "%"; + } + }); +} +function updateSpectrumFreqAxis(range) { + if (!spectrumFreqAxis) return; + const spanHz = range.visSpanHz; + const targets = [ + 100, + 200, + 500, + 1e3, + 2e3, + 5e3, + 1e4, + 2e4, + 5e4, + 1e5, + 2e5, + 5e5, + 1e6, + 2e6, + 5e6, + 1e7 + ]; + const ideal = spanHz / 5; + const firstTarget = targets[0] ?? 100; + const stepHz = targets.reduce((best, s) => Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, firstTarget); + const axisKey = [ + Math.round(range.visLoHz), + Math.round(range.visHiHz), + Math.round(stepHz), + spectrumFreqAxis.clientWidth || 0 + ].join(":"); + if (axisKey === spectrumAxisKey) return; + spectrumAxisKey = axisKey; + const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz; + const leftShiftBtn = document.getElementById("spectrum-center-left-btn"); + const rightShiftBtn = document.getElementById("spectrum-center-right-btn"); + spectrumFreqAxis.replaceChildren(); + if (leftShiftBtn) spectrumFreqAxis.appendChild(leftShiftBtn); + if (rightShiftBtn) spectrumFreqAxis.appendChild(rightShiftBtn); + const axisWidth = spectrumFreqAxis.clientWidth || 0; + const buttonReserve = Math.max( + leftShiftBtn?.offsetWidth || 0, + rightShiftBtn?.offsetWidth || 0, + 0 + ); + const edgePad = Math.max(6, buttonReserve + 10); + for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) { + const frac = (hz - range.visLoHz) / range.visSpanHz; + if (frac < 0 || frac > 1) continue; + const label = hz >= 1e6 ? (hz / 1e6).toFixed(stepHz < 1e6 ? stepHz < 1e5 ? 3 : 1 : 0) + " M" : hz >= 1e3 ? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k" : hz.toFixed(0); + const span = document.createElement("span"); + span.textContent = label; + spectrumFreqAxis.appendChild(span); + const labelWidth = span.offsetWidth || 0; + if (axisWidth > 0 && labelWidth > 0) { + const minCenter = edgePad + labelWidth / 2; + const maxCenter = axisWidth - edgePad - labelWidth / 2; + const desiredCenter = frac * axisWidth; + const clampedCenter = Math.max(minCenter, Math.min(maxCenter, desiredCenter)); + span.style.left = `${clampedCenter}px`; + } else { + span.style.left = (frac * 100).toFixed(2) + "%"; + } + } +} +function updateSpectrumDbAxis(dbMin, dbMax, gridStep, heightPx, dpr) { + if (!spectrumDbAxis) return; + const key = [ + Math.round(dbMin), + Math.round(dbMax), + Math.round(gridStep), + Math.round(heightPx), + Math.round((dpr || 1) * 100), + currentTheme(), + currentStyle() + ].join(":"); + if (key === spectrumDbAxisKey) return; + spectrumDbAxisKey = key; + spectrumDbAxis.replaceChildren(); + const spanDb = Math.max(1, dbMax - dbMin); + const cssHeight = heightPx / Math.max(1, dpr || 1); + for (let db = Math.ceil(dbMin / gridStep) * gridStep; db <= dbMax; db += gridStep) { + const yPx = Math.round(heightPx * (1 - (db - dbMin) / spanDb)); + const yCss = yPx / Math.max(1, dpr || 1); + if (yCss <= 7 || yCss >= cssHeight - 4) continue; + const span = document.createElement("span"); + span.textContent = `${db}`; + span.style.top = `${yCss}px`; + spectrumDbAxis.appendChild(span); + } +} +function shouldIgnoreGlobalShortcut(target) { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (target.isContentEditable) return true; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return !!target.closest("[contenteditable='true']"); +} +function toggleShortcutOverlay() { + const el = document.getElementById("shortcut-overlay"); + if (!el) return; + el.classList.toggle("is-hidden"); +} +function hideShortcutOverlay() { + const el = document.getElementById("shortcut-overlay"); + if (el) el.classList.add("is-hidden"); +} +function isShortcutOverlayVisible() { + const el = document.getElementById("shortcut-overlay"); + return el && !el.classList.contains("is-hidden"); +} +document.addEventListener("DOMContentLoaded", () => { + const overlay = document.getElementById("shortcut-overlay"); + if (overlay) overlay.addEventListener("click", (e) => { + if (e.target === overlay) hideShortcutOverlay(); + }); +}); +window.addEventListener("keydown", (event) => { + if (event.defaultPrevented || event.repeat || event.isComposing) return; + const key = (event.key || "").toLowerCase(); + if (event.key === "F1") { + event.preventDefault(); + toggleShortcutOverlay(); + return; + } + if (event.key === "Escape" && isShortcutOverlayVisible()) { + event.preventDefault(); + hideShortcutOverlay(); + return; + } + if (key === "f" && !event.ctrlKey && !event.metaKey && !event.altKey && !shouldIgnoreGlobalShortcut(event.target)) { + event.preventDefault(); + const fi = document.getElementById("freq"); + if (fi) { + fi.focus(); + fi.select(); + } + return; + } + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (shouldIgnoreGlobalShortcut(event.target)) return; + if (key === "s") { + event.preventDefault(); + if (window.trx.modules.screenshot) { + void window.trx.modules.screenshot.captureSpectrumScreenshot(); + } else void import("./screenshot.js").then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot()).catch((error) => { + console.error("Screenshot module failed to load", error); + }); + return; + } + if (key === "r") { + event.preventDefault(); + if (lastLocked) { + showHint("Locked", 1500); + return; + } + if (lastFreqHz != null) { + const step = Math.max(1, jogStep); + const rounded = Math.round(lastFreqHz / step) * step; + if (rounded !== lastFreqHz) { + if (!freqAllowed(rounded)) { + showUnsupportedFreqPopup(rounded); + return; + } + setRigFrequency(rounded); + showHint(`Rounded → ${formatFrequency(rounded)}`, 1200); + } else { + showHint("Already on step", 1200); + } + } + return; + } + if (key === "b") { + event.preventDefault(); + void restorePreviousTuneState(); + return; + } + if (key === "[") { + event.preventDefault(); + const [, minBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + const next = Math.max(minBw, currentBandwidthHz - 1e4); + if (next !== currentBandwidthHz) { + currentBandwidthHz = next; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(next); + positionFastOverlay(lastFreqHz, next); + if (lastSpectrumData) scheduleSpectrumDraw(); + postPath(`/set_bandwidth?hz=${next}`).catch(() => { + }); + showHint(`BW ${formatBwLabel(next)}`, 1200); + } + return; + } + if (key === "]") { + event.preventDefault(); + const [, , maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + const next = Math.min(maxBw, currentBandwidthHz + 1e4); + if (next !== currentBandwidthHz) { + currentBandwidthHz = next; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(next); + positionFastOverlay(lastFreqHz, next); + if (lastSpectrumData) scheduleSpectrumDraw(); + postPath(`/set_bandwidth?hz=${next}`).catch(() => { + }); + showHint(`BW ${formatBwLabel(next)}`, 1200); + } + return; + } + if (key === "arrowleft" || key === "arrowright") { + event.preventDefault(); + jogFreq(key === "arrowright" ? 1 : -1); + return; + } + if (key === "arrowup" || key === "arrowdown") { + event.preventDefault(); + void shiftSpectrumCenter(key === "arrowup" ? 1 : -1); + return; + } + if (key === "m") { + event.preventDefault(); + if (modeEl && !modeEl.disabled) { + modeEl.focus(); + modeEl.click(); + if (typeof modeEl.showPicker === "function") { + try { + modeEl.showPicker(); + } catch (_) { + } + } + } + return; + } + if (key === "z") { + event.preventDefault(); + if (wfmAudioModeEl) { + const next = wfmAudioModeEl.value === "mono" ? "stereo" : "mono"; + wfmAudioModeEl.value = next; + saveSetting("wfmAudioMode", next); + const enabled = next !== "mono"; + postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => { + }); + showHint(next === "stereo" ? "Stereo" : "Mono", 1200); + } else { + showHint("Stereo N/A", 1200); + } + return; + } + if (key === "n") { + event.preventDefault(); + if (sdrNbSupported && sdrNbEnabledEl) { + sdrNbEnabledEl.checked = !sdrNbEnabledEl.checked; + submitSdrNbState(); + showHint(sdrNbEnabledEl.checked ? "NB On" : "NB Off", 1200); + } else { + showHint("NB N/A", 1200); + } + return; + } + if (key === "q") { + event.preventDefault(); + if (sdrSquelchSupported && sdrSquelchEl) { + const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); + let nextPct; + if (current > 0) { + nextPct = 0; + } else { + let auto = 30; + const data = lastSpectrumData || window.lastSpectrumData; + if (data && isNumericBins(data.bins) && data.bins.length > 0) { + const noiseDb = estimateNoiseFloorDb(data.bins); + if (noiseDb != null && isFiniteNumber(noiseDb)) { + const thresholdDb = noiseDb + 6; + const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb)); + auto = clampSdrSquelchPercent( + (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100 + ); + } + } + nextPct = auto; + } + sdrSquelchEl.value = String(nextPct); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", nextPct); + submitSdrSquelchPercent(nextPct); + showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200); + } else { + showHint("Squelch N/A", 1200); + } + return; + } + if (lastSpectrumData && spectrumCanvas) { + if (key === "+" || key === "=") { + event.preventDefault(); + const cssW = spectrumCanvas.clientWidth || 640; + spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25); + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + return; + } + if (key === "-") { + event.preventDefault(); + const cssW = spectrumCanvas.clientWidth || 640; + spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25); + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + return; + } + if (key === "0") { + event.preventDefault(); + spectrumZoom = 1; + spectrumPanFrac = 0.5; + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + return; + } + } +}, { capture: true }); +function spectrumZoomAt(cssX, cssW, data, factor) { + const range = spectrumVisibleRange(data); + const hzAtCursor = canvasXToHz(cssX, cssW, range); + const frac = cssX / cssW; + spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor)); + const newVisSpan = data.sample_rate / spectrumZoom; + const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan; + const loHz = data.center_hz - data.sample_rate / 2; + spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate; +} +function handleSpectrumWheel(e, canvasEl) { + e.preventDefault(); + if (!lastSpectrumData || !canvasEl) return; + if (e.ctrlKey) { + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); + return; + } + const rect = canvasEl.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25; + spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor); + scheduleSpectrumDraw(); + scheduleOverviewDraw(); +} +function handleSpectrumClick(e, canvasEl) { + if (_sDragMoved) { + _sDragMoved = false; + return; + } + if (!lastSpectrumData || !canvasEl) return; + const rect = canvasEl.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData); + if (!isFiniteNumber(targetHz)) return; + setRigFrequency(targetHz); +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("wheel", (e) => { + handleSpectrumWheel(e, spectrumCanvas); + }, { passive: false }); +} +if (overviewCanvas) { + overviewCanvas.addEventListener("wheel", (e) => { + handleSpectrumWheel(e, overviewCanvas); + }, { passive: false }); + overviewCanvas.addEventListener("click", (e) => { + handleSpectrumClick(e, overviewCanvas); + }); +} +if (spectrumWaterfallCanvas) { + spectrumWaterfallCanvas.addEventListener("wheel", (e) => { + handleSpectrumWheel(e, spectrumWaterfallCanvas); + }, { passive: false }); + spectrumWaterfallCanvas.addEventListener("click", (e) => { + handleSpectrumClick(e, spectrumWaterfallCanvas); + }); + spectrumWaterfallCanvas.addEventListener("mousedown", (e) => { + onSpectrumMouseDown(e, spectrumWaterfallCanvas); + }); +} +function getBwEdgeHit(cssX, cssW, range) { + const bwCenterHz = activeBandwidthCenterHz(); + if (!isFiniteNumber(bwCenterHz) || !currentBandwidthHz || !lastSpectrumData) return null; + const HIT = 8; + let bestEdge = null; + let bestDist = Number.POSITIVE_INFINITY; + for (const spec of visibleBandwidthSpecs(bwCenterHz)) { + const span = displaySpanForBandwidthSpec(spec); + const xL = (span.loHz - range.visLoHz) / range.visSpanHz * cssW; + const xR = (span.hiHz - range.visLoHz) / range.visSpanHz * cssW; + if (span.side < 0) { + const distL2 = Math.abs(cssX - xL); + if (distL2 < HIT && distL2 < bestDist) { + bestEdge = "left"; + bestDist = distL2; + } + continue; + } + if (span.side > 0) { + const distR2 = Math.abs(cssX - xR); + if (distR2 < HIT && distR2 < bestDist) { + bestEdge = "right"; + bestDist = distR2; + } + continue; + } + const distL = Math.abs(cssX - xL); + const distR = Math.abs(cssX - xR); + if (distL < HIT && distL < bestDist) { + bestEdge = "left"; + bestDist = distL; + } + if (distR < HIT && distR < bestDist) { + bestEdge = "right"; + bestDist = distR; + } + } + if (bestEdge) return bestEdge; + return null; +} +var _sDragStart = null; +var _sDragMoved = false; +var _sDragCanvas = null; +function onSpectrumMouseDown(e, canvasEl) { + if (!canvasEl || e.button !== 0) return; + if (lastSpectrumData) { + const rect = canvasEl.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const range = spectrumVisibleRange(lastSpectrumData); + const edge = getBwEdgeHit(cssX, rect.width, range); + if (edge) { + _bwDragEdge = edge; + _bwDragStartX = cssX; + _bwDragStartBwHz = currentBandwidthHz; + _bwDragCanvas = canvasEl; + _sDragStart = null; + _sDragCanvas = null; + _sDragMoved = true; + return; + } + } + _sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac }; + _sDragCanvas = canvasEl; + _sDragMoved = false; +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("mousedown", (e) => { + onSpectrumMouseDown(e, spectrumCanvas); + }); +} +if (overviewCanvas) { + overviewCanvas.addEventListener("mousedown", (e) => { + onSpectrumMouseDown(e, overviewCanvas); + }); +} +if (spectrumCanvas || overviewCanvas) { + window.addEventListener("mousemove", (e) => { + if (_bwDragEdge && lastSpectrumData) { + const dragCanvas2 = _bwDragCanvas || spectrumCanvas; + if (!dragCanvas2) return; + const rect2 = dragCanvas2.getBoundingClientRect(); + const cssX = e.clientX - rect2.left; + const range = spectrumVisibleRange(lastSpectrumData); + const dxHz = (cssX - _bwDragStartX) / rect2.width * range.visSpanHz; + const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); + let newBw; + if (side === 0) { + newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz * 2 : _bwDragStartBwHz - dxHz * 2; + } else { + newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz : _bwDragStartBwHz - dxHz; + } + const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw))); + currentBandwidthHz = newBw; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(newBw); + positionFastOverlay(lastFreqHz, newBw); + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + return; + } + if (!_sDragStart || !lastSpectrumData) return; + const dragCanvas = _sDragCanvas || spectrumCanvas || overviewCanvas; + if (!dragCanvas) return; + const rect = dragCanvas.getBoundingClientRect(); + const dx = e.clientX - _sDragStart.clientX; + if (Math.abs(dx) > 3) _sDragMoved = true; + spectrumPanFrac = _sDragStart.panFrac - dx / rect.width / spectrumZoom; + scheduleSpectrumDraw(); + }); + window.addEventListener("mouseup", async () => { + if (_bwDragEdge) { + try { + const bwHz = Math.round(currentBandwidthHz); + if (!await window.trx.modules.vchan?.interceptBandwidth(bwHz)) { + await postPath(`/set_bandwidth?hz=${bwHz}`); + if (isFiniteNumber(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz); + } + } + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { + kind: "error", + action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) } + }); + } + _bwDragEdge = null; + _bwDragCanvas = null; + return; + } + _sDragStart = null; + _sDragCanvas = null; + }); +} +var _sTouch = null; +if (spectrumCanvas) { + spectrumCanvas.addEventListener("touchstart", (e) => { + e.preventDefault(); + if (e.touches.length === 2) { + const t0 = e.touches[0], t1 = e.touches[1]; + if (!t0 || !t1) return; + _sTouch = { + type: "pinch", + dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY), + midX: (t0.clientX + t1.clientX) / 2, + zoom: spectrumZoom, + panFrac: spectrumPanFrac + }; + } else if (e.touches.length === 1) { + const touch = e.touches[0]; + if (touch) _sTouch = { type: "pan", clientX: touch.clientX, panFrac: spectrumPanFrac }; + } + }, { passive: false }); + spectrumCanvas.addEventListener("touchmove", (e) => { + e.preventDefault(); + if (!_sTouch || !lastSpectrumData) return; + const rect = spectrumCanvas.getBoundingClientRect(); + if (_sTouch.type === "pinch" && e.touches.length === 2) { + const t0 = e.touches[0], t1 = e.touches[1]; + if (!t0 || !t1) return; + const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); + const newMidX = (t0.clientX + t1.clientX) / 2; + const scale = newDist / _sTouch.dist; + const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale)); + const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2; + const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom; + const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2; + const midFrac = (_sTouch.midX - rect.left) / rect.width; + const midHz = oldVisLo + midFrac * oldVisSpan; + const newVisSpan = lastSpectrumData.sample_rate / newZoom; + const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan; + spectrumZoom = newZoom; + spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate; + const dxMid = newMidX - _sTouch.midX; + spectrumPanFrac -= dxMid / rect.width / spectrumZoom; + scheduleSpectrumDraw(); + } else if (_sTouch.type === "pan" && e.touches.length === 1) { + const touch = e.touches[0]; + if (!touch) return; + const dx = touch.clientX - _sTouch.clientX; + spectrumPanFrac = _sTouch.panFrac - dx / rect.width / spectrumZoom; + scheduleSpectrumDraw(); + } + }, { passive: false }); + spectrumCanvas.addEventListener("touchend", () => { + _sTouch = null; + }); +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("mousemove", (e) => { + if (!lastSpectrumData || !spectrumTooltip) return; + const rect = spectrumCanvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const range = spectrumVisibleRange(lastSpectrumData); + const edge = getBwEdgeHit(cssX, rect.width, range); + spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair"; + const hz = canvasXToHz(cssX, rect.width, range); + const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range); + const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData); + const peakHz = peak?.hz ?? null; + const peakDb = peak && isFiniteNumber(peak.db) ? `${peak.db.toFixed(1)} dB` : null; + if (bookmark) { + spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark); + } else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) { + spectrumTooltip.textContent = peakDb ? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}` : `Peak ${formatSpectrumFreq(peakHz)}`; + } else { + const baseText = formatSpectrumFreq(peakHz ?? hz); + spectrumTooltip.textContent = peakDb ? `${baseText} · ${peakDb}` : baseText; + } + spectrumTooltip.style.display = "block"; + const tw = spectrumTooltip.offsetWidth; + let tx = cssX + 10; + if (tx + tw > rect.width) tx = cssX - tw - 10; + spectrumTooltip.style.left = tx + "px"; + spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px"; + spectrumCrosshairX = cssX; + spectrumCrosshairY = e.clientY - rect.top; + scheduleSpectrumDraw(); + }); + spectrumCanvas.addEventListener("mouseleave", () => { + if (spectrumTooltip) spectrumTooltip.style.display = "none"; + spectrumCanvas.style.cursor = "crosshair"; + spectrumCrosshairX = null; + spectrumCrosshairY = null; + scheduleSpectrumDraw(); + }); +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("click", (e) => { + handleSpectrumClick(e, spectrumCanvas); + }); +} +if (spectrumCenterLeftBtn) { + spectrumCenterLeftBtn.addEventListener("click", () => { + shiftSpectrumCenter(-1).catch(() => { + }); + }); +} +if (spectrumCenterRightBtn) { + spectrumCenterRightBtn.addEventListener("click", () => { + shiftSpectrumCenter(1).catch(() => { + }); + }); +} +(function() { + const floorInput = document.getElementById("spectrum-floor-input"); + const autoBtn = document.getElementById("spectrum-auto-btn"); + if (floorInput) { + floorInput.addEventListener("change", () => { + const v = Number(floorInput.value); + if (!isNaN(v)) { + spectrumFloor = v; + if (lastSpectrumData) scheduleSpectrumDraw(); + } + }); + } + const rangeInput = document.getElementById("spectrum-range-input"); + if (rangeInput) { + rangeInput.value = String(spectrumRange); + rangeInput.addEventListener("change", () => { + const v = Number(rangeInput.value); + if (!isNaN(v) && v >= 10) { + spectrumRange = v; + if (lastSpectrumData) scheduleSpectrumDraw(); + } + }); + } + if (autoBtn) { + autoBtn.addEventListener("click", () => { + if (!lastSpectrumData) return; + const sorted = [...lastSpectrumData.bins].sort((a, b) => a - b); + const noise = sorted[Math.floor(sorted.length * 0.15)]; + const peak = sorted[sorted.length - 1]; + if (noise === void 0 || peak === void 0) return; + spectrumFloor = Math.floor(noise / 10) * 10 - 10; + spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB); + if (floorInput) floorInput.value = String(spectrumFloor); + if (rangeInput) rangeInput.value = String(spectrumRange); + scheduleSpectrumDraw(); + }); + } + const gammaInput = document.getElementById("spectrum-gamma-input"); + const gammaValue = document.getElementById("spectrum-gamma-value"); + if (gammaInput) { + gammaInput.addEventListener("input", () => { + const v = Number(gammaInput.value); + if (isFiniteNumber(v) && v > 0) { + waterfallGamma = v; + if (gammaValue) gammaValue.textContent = v.toFixed(1); + if (lastSpectrumData) scheduleSpectrumDraw(); + } + }); + gammaInput.addEventListener("dblclick", () => { + waterfallGamma = 1; + gammaInput.value = "1.0"; + if (gammaValue) gammaValue.textContent = "1.0"; + if (lastSpectrumData) scheduleSpectrumDraw(); + }); + } +})(); +var bandplanData = null; +var bandplanRegion = loadSetting("bandplanRegion", "off"); +var bandplanShowLabels = loadSetting("bandplanLabels", true); +var _bandplanServerDefaultApplied = false; +var bandplanSegmentsCache = null; +var bandplanCacheKey = ""; +var bandplanStripEl = document.getElementById("spectrum-bandplan-strip"); +var bandplanRegionSelect = document.getElementById("bandplan-region-select"); +var bandplanLabelsCheck = document.getElementById("bandplan-labels-check"); +(function loadBandplanJson() { + fetch("/bandplan.json").then(async (response) => { + if (!response.ok) throw new Error(String(response.status)); + return await responseJsonUnknown(response); + }).then((data) => { + if (!isRecord2(data)) return; + bandplanData = data; + bandplanSegmentsCache = null; + bandplanCacheKey = ""; + }).catch(() => { + }); +})(); +if (bandplanRegionSelect) { + bandplanRegionSelect.value = bandplanRegion; + bandplanRegionSelect.addEventListener("change", () => { + bandplanRegion = bandplanRegionSelect.value; + saveSetting("bandplanRegion", bandplanRegion); + bandplanSegmentsCache = null; + bandplanCacheKey = ""; + if (lastSpectrumData) scheduleSpectrumDraw(); + }); +} +if (bandplanLabelsCheck) { + bandplanLabelsCheck.checked = bandplanShowLabels; + bandplanLabelsCheck.addEventListener("change", () => { + bandplanShowLabels = bandplanLabelsCheck.checked; + saveSetting("bandplanLabels", bandplanShowLabels); + bandplanSegmentsCache = null; + bandplanCacheKey = ""; + if (lastSpectrumData) scheduleSpectrumDraw(); + }); +} +function bandplanComputeRange() { + if (lastSpectrumData) { + return spectrumVisibleRange(lastSpectrumData); + } + const freq = lastFreqHz; + if (!freq || !isFiniteNumber(freq)) return null; + if (bandplanData && bandplanData[bandplanRegion]) { + const bands = bandplanData[bandplanRegion]?.bands ?? []; + for (const band of bands) { + if (freq >= band.low_hz && freq <= band.high_hz) { + const margin = (band.high_hz - band.low_hz) * 0.05; + return { + visLoHz: band.low_hz - margin, + visHiHz: band.high_hz + margin, + visSpanHz: band.high_hz - band.low_hz + 2 * margin + }; + } + } + } + const span = 5e5; + return { visLoHz: freq - span / 2, visHiHz: freq + span / 2, visSpanHz: span }; +} +function bandplanVisibleSegments(region, loHz, hiHz) { + if (!bandplanData || !bandplanData[region]) return []; + const bands = bandplanData[region].bands; + const result = []; + for (const band of bands) { + if (band.high_hz < loHz || band.low_hz > hiHz) continue; + for (const seg of band.segments) { + if (seg.high_hz <= loHz || seg.low_hz >= hiHz) continue; + result.push({ + low_hz: seg.low_hz, + high_hz: seg.high_hz, + mode: seg.mode, + label: seg.label, + band: band.name + }); + } + } + return result; +} +function _hideBandplanStrip() { + if (!bandplanStripEl) return; + bandplanStripEl.classList.remove("bp-visible"); + bandplanStripEl.replaceChildren(); + bandplanCacheKey = ""; +} +function updateBandplanStrip(range) { + if (!bandplanStripEl) return; + if (!range || bandplanRegion === "off" || !bandplanData) { + if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + return; + } + const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz); + if (segments.length === 0) { + if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + return; + } + bandplanStripEl.classList.add("bp-visible"); + const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(","); + const stripW = bandplanStripEl.clientWidth || 1; + if (bandplanCacheKey !== newKey) { + bandplanCacheKey = newKey; + bandplanStripEl.replaceChildren(); + const seenBands = /* @__PURE__ */ new Set(); + for (const seg of segments) { + const el = document.createElement("div"); + el.className = "bp-segment"; + el.dataset.mode = seg.mode; + el.title = seg.band + " – " + seg.label + " (" + seg.mode + ")"; + if (bandplanShowLabels) { + const lbl = document.createElement("span"); + lbl.className = "bp-segment-label"; + lbl.textContent = seg.label; + el.appendChild(lbl); + } + bandplanStripEl.appendChild(el); + if (!seenBands.has(seg.band)) { + seenBands.add(seg.band); + const bandLbl = document.createElement("div"); + bandLbl.className = "bp-band-label"; + bandLbl.textContent = seg.band; + bandLbl.dataset.bandLow = String(seg.low_hz); + bandplanStripEl.appendChild(bandLbl); + } + } + bandplanSegmentsCache = segments; + } + const children = bandplanStripEl.querySelectorAll(".bp-segment"); + const bandLabels = bandplanStripEl.querySelectorAll(".bp-band-label"); + const segs = bandplanSegmentsCache || segments; + segs.forEach((seg, i) => { + const el = children[i]; + if (!el) return; + const l = Math.max(0, (seg.low_hz - range.visLoHz) / range.visSpanHz); + const r = Math.min(1, (seg.high_hz - range.visLoHz) / range.visSpanHz); + const leftPx = l * stripW; + const widthPx = Math.max(1, (r - l) * stripW); + el.style.left = leftPx + "px"; + el.style.width = widthPx + "px"; + const lbl = el.querySelector(".bp-segment-label"); + if (lbl) { + lbl.style.display = widthPx < 20 ? "none" : ""; + } + }); + bandLabels.forEach((lbl) => { + const bandLow = Number(lbl.dataset.bandLow); + const frac = (bandLow - range.visLoHz) / range.visSpanHz; + const px = Math.max(2, frac * stripW); + lbl.style.left = px + "px"; + lbl.style.display = frac < -0.1 || frac > 1.05 ? "none" : ""; + }); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js new file mode 100644 index 00000000..f56e02b4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -0,0 +1,314 @@ +import { + aprsAgeText, + aprsCategoryLabel, + aprsHexBytes, + aprsPacketCategory, + collapseAprsDuplicates, + normalizeAprsPacket, + renderAprsInfo, + renderLocalAprsSymbol +} from "./chunk-M2I6DH4X.js"; + +// src/plugins/aprs.ts +var aprsWindow = window; +var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var showAprsHint = (message, durationMs) => { + aprsWindow.showHint?.(message, durationMs); +}; +var aprsStatus = document.getElementById("aprs-status"); +var aprsPacketsEl = document.getElementById("aprs-packets"); +var aprsFilterInput = document.getElementById("aprs-filter"); +var aprsBarOverlay = document.getElementById("aprs-bar-overlay"); +var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); +var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); +var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); +var aprsTotalCountEl = document.getElementById("aprs-total-count"); +var aprsVisibleCountEl = document.getElementById("aprs-visible-count"); +var aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); +var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3; +var aprsFilterText = ""; +var aprsPacketHistory = []; +var aprsBarDismissedAtMs = 0; +var aprsOnlyPos = false; +var aprsHideCrc = false; +var aprsCollapseDup = false; +var aprsTypeFilter = "all"; +function currentAprsHistoryRetentionMs() { + return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneAprsPacketHistory() { + const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); + aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs); +} +function scheduleAprsUi(key, job) { + if (typeof aprsWindow.trxScheduleUiFrameJob === "function") { + aprsWindow.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleAprsHistoryRender() { + scheduleAprsUi("aprs-history", () => { + renderAprsHistory(); + }); +} +function scheduleAprsBarUpdate() { + scheduleAprsUi("aprs-bar", () => { + updateAprsBar(); + }); +} +function aprsDistanceText(pkt) { + if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return ""; + const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function aprsFilterMatch(pkt) { + if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; + if (aprsHideCrc && !pkt.crcOk) return false; + if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false; + if (!aprsFilterText) return true; + const haystack = [ + pkt.srcCall, + pkt.destCall, + pkt.path, + pkt.info, + pkt.type, + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "", + aprsPacketCategory(pkt) + ].filter(Boolean).join(" ").toUpperCase(); + return haystack.includes(aprsFilterText); +} +function aprsVisiblePackets() { + const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; + return packets.filter(aprsFilterMatch); +} +function updateAprsSummary() { + const visible = aprsVisiblePackets(); + if (aprsTotalCountEl) { + aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; + } + if (aprsVisibleCountEl) { + aprsVisibleCountEl.textContent = `${visible.length} shown`; + } + if (aprsLatestSeenEl) { + const latest = aprsPacketHistory[0]; + if (!latest) { + aprsLatestSeenEl.textContent = "No packets yet"; + } else { + aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`; + } + } +} +function updateAprsChipState() { + document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => { + btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`); + }); + aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos); + aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); + aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); +} +function renderAprsRow(pkt, isFresh) { + const row = document.createElement("div"); + row.className = "aprs-packet"; + if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); + if (isFresh) row.classList.add("aprs-packet-new"); + const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const age = aprsAgeText(pkt._tsMs); + const category = aprsPacketCategory(pkt); + const categoryLabel = aprsCategoryLabel(category); + const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; + const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = aprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + symbolHtml + `${escapeAprsHtml(pkt.srcCall ?? "")}>${escapeAprsHtml(pkt.destCall || "")}${escapeAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeAprsHtml(age)}` + (distance ? `${escapeAprsHtml(distance)}` : "") + `${escapeAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeAprsHtml(pkt.srcCall || "--")}Destination${escapeAprsHtml(pkt.destCall || "--")}Type${escapeAprsHtml(pkt.type || "--")}Path${escapeAprsHtml(pkt.path || "--")}Age${escapeAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeAprsHtml(pkt.info || "--")}Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; + row.querySelectorAll("[data-aprs-map]").forEach((el) => { + el.addEventListener("click", (evt) => { + evt.preventDefault(); + const raw = el.dataset.aprsMap ?? ""; + const [lat, lon] = raw.split(",").map(Number); + if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { + aprsWindow.navigateToAprsMap(lat, lon); + } + }); + }); + const copyBtn = row.querySelector("[data-aprs-copy]"); + if (copyBtn) { + copyBtn.addEventListener("click", () => { + void (async () => { + const raw = copyBtn.dataset.aprsCopy ?? ""; + try { + const clipboard = Reflect.get(navigator, "clipboard"); + if (clipboard) { + await clipboard.writeText(raw); + showAprsHint("Coordinates copied", 1200); + } + } catch { + showAprsHint("Copy failed", 1500); + } + })(); + }); + } + return row; +} +function renderAprsHistory() { + pruneAprsPacketHistory(); + if (!aprsPacketsEl) { + updateAprsSummary(); + updateAprsChipState(); + return; + } + const visible = aprsVisiblePackets(); + const fragment = document.createDocumentFragment(); + for (const [index, packet] of visible.entries()) { + fragment.appendChild(renderAprsRow(packet, index === 0)); + } + aprsPacketsEl.replaceChildren(fragment); + updateAprsSummary(); + updateAprsChipState(); +} +function updateAprsBar() { + if (!aprsBarOverlay) return; + const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; + const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; + const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs); + const frames = collapseAprsDuplicates(okFrames).slice(0, 8); + const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); + if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { + aprsBarOverlay.style.display = "none"; + aprsBarOverlay.innerHTML = ""; + return; + } + let html = `
APRSLiveLast 15 minutesClear
`; + for (const pkt of frames) { + const ts = pkt._ts ? `${pkt._ts}` : ""; + const call = `${escapeAprsHtml(pkt.srcCall ?? "")}`; + const dest = escapeAprsHtml(pkt.destCall || ""); + const info = escapeAprsHtml(pkt.info || ""); + const pin = pkt.lat != null && pkt.lon != null ? `` : ""; + html += `
${ts}${pin}${call}>${dest}: ${info}
`; + } + aprsBarOverlay.innerHTML = html; + aprsBarOverlay.style.display = "flex"; +} +aprsWindow.updateAprsBar = updateAprsBar; +aprsWindow.clearAprsBar = function() { + resetAprsHistoryView(); +}; +aprsWindow.closeAprsBar = function() { + aprsBarDismissedAtMs = Date.now(); + if (aprsBarOverlay) { + aprsBarOverlay.style.display = "none"; + aprsBarOverlay.innerHTML = ""; + } +}; +function resetAprsHistoryView() { + if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; + aprsPacketHistory = []; + updateAprsBar(); + renderAprsHistory(); + aprsWindow.clearMapMarkersByType?.("aprs"); +} +function pruneAprsHistoryView() { + pruneAprsPacketHistory(); + updateAprsBar(); + renderAprsHistory(); +} +function addAprsPacket(pkt) { + const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); + pkt._tsMs = tsMs; + pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + aprsPacketHistory.unshift(pkt); + pruneAprsPacketHistory(); + if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { + aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt); + } + if (pkt.crcOk) scheduleAprsBarUpdate(); + scheduleAprsHistoryRender(); +} +function normalizeServerAprsPacket(pkt) { + return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null); +} +function onServerAprsBatch(packets) { + if (!Array.isArray(packets) || packets.length === 0) return; + if (aprsStatus) aprsStatus.textContent = "Receiving"; + const normalized = []; + let hasCrcOk = false; + for (const pkt of packets) { + const next = normalizeServerAprsPacket(pkt); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { + aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next); + } + if (next.crcOk) hasCrcOk = true; + normalized.push(next); + } + normalized.reverse(); + aprsPacketHistory = normalized.concat(aprsPacketHistory); + pruneAprsPacketHistory(); + if (hasCrcOk) scheduleAprsBarUpdate(); + scheduleAprsHistoryRender(); +} +document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { + void (async () => { + if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await aprsWindow.postPath?.("/clear_aprs_decode"); + resetAprsHistoryView(); + } catch (e) { + console.error("APRS history clear failed", e); + } + })(); +}); +if (aprsOnlyPosBtn) { + aprsOnlyPosBtn.addEventListener("click", () => { + aprsOnlyPos = !aprsOnlyPos; + renderAprsHistory(); + }); +} +if (aprsHideCrcBtn) { + aprsHideCrcBtn.addEventListener("click", () => { + aprsHideCrc = !aprsHideCrc; + renderAprsHistory(); + }); +} +if (aprsCollapseDupBtn) { + aprsCollapseDupBtn.addEventListener("click", () => { + aprsCollapseDup = !aprsCollapseDup; + renderAprsHistory(); + }); +} +["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { + const btn = document.getElementById(`aprs-type-${type}`); + if (!btn) return; + btn.addEventListener("click", () => { + aprsTypeFilter = type; + renderAprsHistory(); + }); +}); +if (aprsFilterInput) { + aprsFilterInput.addEventListener("input", () => { + aprsFilterText = aprsFilterInput.value.trim().toUpperCase(); + renderAprsHistory(); + }); +} +function onServerAprs(pkt) { + if (aprsStatus) aprsStatus.textContent = "Receiving"; + addAprsPacket(normalizeServerAprsPacket(pkt)); +} +renderAprsHistory(); +window.trxPluginRuntime.registerDecoder({ + id: "aprs", + onMessage: onServerAprs, + onBatch: onServerAprsBatch, + restore: onServerAprsBatch, + reset: resetAprsHistoryView, + prune: pruneAprsHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js new file mode 100644 index 00000000..88d9afec --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js @@ -0,0 +1,366 @@ +// src/plugins/background-decode.ts +var bgdWindow = window; +(function() { + "use strict"; + function bgdSupportedIds() { + return (bgdWindow.decoderRegistry || []).filter(function(d) { + return d.background_decode; + }).map(function(d) { + return d.id; + }); + } + let backgroundDecodeRole = null; + let currentRigId = null; + let currentConfig = null; + let bookmarkList = []; + let statusInterval = null; + let bgdDirty = false; + function initBackgroundDecode(rigId, role) { + backgroundDecodeRole = role; + currentRigId = rigId || null; + if (currentRigId) loadBackgroundDecode(); + startStatusPolling(); + } + function setBackgroundDecodeRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + if (!currentRigId) return; + loadBackgroundDecode(); + } + function apiGetConfig(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + return r.json(); + }); + } + function apiPutConfig(rigId, config) { + return fetch("/background-decode/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + return r.json(); + }); + } + function apiResetConfig(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + return r.json(); + }); + } + function apiGetStatus(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) { + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + return r.json(); + }); + } + function apiGetBookmarks() { + return fetch("/bookmarks").then(function(r) { + if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); + return r.json(); + }); + } + function loadBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) { + currentConfig = config; + bookmarkList = Array.isArray(bookmarks) ? bookmarks : []; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + }).catch(function(err) { + console.error("background decode load failed", err); + }); + } + function supportedBookmarks() { + return bookmarkList.filter(function(bookmark) { + return bookmarkDecoderKinds(bookmark).length > 0; + }); + } + function bookmarkDecoderKinds(bookmark) { + const ids = bgdSupportedIds(); + const decoders = bookmark.decoders ?? []; + const explicit = decoders.map(function(item) { + return item.trim().toLowerCase(); + }).filter(function(item, index, arr) { + return ids.indexOf(item) >= 0 && arr.indexOf(item) === index; + }); + if (explicit.length > 0) return explicit; + const mode = bookmark.mode.trim().toUpperCase(); + return (bgdWindow.decoderRegistry || []).filter(function(d) { + return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0; + }).map(function(d) { + return d.id; + }); + } + function renderBackgroundDecode() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + setCheckbox("background-decode-enabled", currentConfig.enabled); + renderBookmarkChecklist(); + const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false; + const panel = document.getElementById("background-decode-panel"); + if (panel) { + panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) { + el.disabled = !isControl; + }); + } + const saveBtn = document.getElementById("background-decode-save-btn"); + const resetBtn = document.getElementById("background-decode-reset-btn"); + if (saveBtn) saveBtn.style.display = isControl ? "" : "none"; + if (resetBtn) resetBtn.style.display = isControl ? "" : "none"; + } + function renderBookmarkChecklist(filterText = "") { + const container = document.getElementById("bgd-bookmark-checklist"); + if (!container) return; + container.innerHTML = ""; + const selectedIds = new Set( + currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : [] + ); + const all = supportedBookmarks(); + const filter = (filterText || "").trim().toLowerCase(); + const filtered = filter ? all.filter(function(bm) { + const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase(); + return text.indexOf(filter) >= 0; + }) : all; + if (filtered.length === 0) { + container.innerHTML = '
' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "
"; + return; + } + filtered.forEach(function(bookmark) { + const row = document.createElement("label"); + row.className = "bgd-checklist-row"; + const decoders = bookmarkDecoderKinds(bookmark); + const checked = selectedIds.has(bookmark.id) ? " checked" : ""; + row.innerHTML = '' + escHtml(bookmark.name) + '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + ""; + row.querySelector("input")?.addEventListener("change", function(e) { + onChecklistToggle(bookmark.id, e.currentTarget.checked); + }); + container.appendChild(row); + }); + } + function onChecklistToggle(bookmarkId, checked) { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = []; + if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) { + currentConfig.bookmark_ids.push(bookmarkId); + } else if (!checked) { + currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) { + return id !== bookmarkId; + }); + } + markBgdDirty(); + } + function saveBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + const payload = { + remote: rigId, + enabled: document.getElementById("background-decode-enabled")?.checked ?? false, + bookmark_ids: currentConfig?.bookmark_ids.slice() ?? [] + }; + const btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.disabled = true; + apiPutConfig(rigId, payload).then(function(saved) { + currentConfig = saved; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + showToast("Background decode saved.", false); + }).catch(function(err) { + showToast(`Save failed: ${errorMessage(err)}`, true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + async function resetBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return; + apiResetConfig(rigId).then(function(saved) { + currentConfig = saved; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + showToast("Background decode reset.", false); + }).catch(function(err) { + showToast(`Reset failed: ${errorMessage(err)}`, true); + }); + } + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3); + } + function pollBackgroundDecodeStatus() { + const rigId = currentRigId; + if (!rigId) return; + apiGetStatus(rigId).then(renderStatus).catch(function() { + }); + } + function renderStatus(status) { + const card = document.getElementById("background-decode-status-card"); + if (!card) return; + const entries = status.entries ?? []; + if (!entries.length) { + card.textContent = "No background decode bookmarks configured."; + return; + } + const summary = []; + if (status.active_rig) { + if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz)); + if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2)); + } else { + summary.push("This rig is not currently selected for audio."); + } + let html = summary.length ? '
' + escHtml(summary.join(" · ")) + "
" : ""; + html += '
'; + entries.forEach(function(entry) { + const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark"; + const parts = []; + if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz)); + if (entry.mode) parts.push(entry.mode); + if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) { + parts.push(entry.decoder_kinds.join("/").toUpperCase()); + } + html += '
' + escHtml(name) + '
' + escHtml(parts.join(" · ")) + '
' + escHtml(prettyState(entry.state)) + "
"; + }); + html += "
"; + card.innerHTML = html; + } + function prettyState(state) { + switch (state) { + case "active": + return "✓ Active"; + case "out_of_span": + return "△ Out of span"; + case "waiting_for_spectrum": + return "△ Waiting"; + case "waiting_for_user": + return "△ No user"; + case "missing_bookmark": + return "✗ Missing"; + case "no_supported_decoders": + return "✗ Unsupported"; + case "disabled": + return "△ Disabled"; + case "handled_by_scheduler": + return "△ Scheduler"; + case "scheduler_has_control": + return "△ Scheduler"; + case "handled_by_virtual_channel": + return "△ VChan"; + default: + return "△ Inactive"; + } + } + function setCheckbox(id, value) { + const el = document.getElementById(id); + if (el) el.checked = value; + } + function formatFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; + return `${String(hz)} Hz`; + } + function escHtml(value) { + const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : ""; + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function errorMessage(error) { + return error instanceof Error ? error.message : String(error); + } + function markBgdDirty() { + if (bgdDirty) return; + bgdDirty = true; + const btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearBgdDirty() { + bgdDirty = false; + const btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showToast(msg, isError) { + const el = document.getElementById("background-decode-toast"); + if (!el) return; + el.textContent = msg; + el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; + el.style.display = "block"; + setTimeout(function() { + el.style.display = "none"; + }, 3e3); + } + function selectAllBookmarks() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + const ids = supportedBookmarks().map(function(bm) { + return bm.id; + }); + currentConfig.bookmark_ids = ids; + renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); + markBgdDirty(); + } + function deselectAllBookmarks() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + currentConfig.bookmark_ids = []; + renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); + markBgdDirty(); + } + function wireBackgroundDecodeEvents() { + const filterInput = document.getElementById("bgd-bookmark-filter"); + if (filterInput && !filterInput._wired) { + filterInput._wired = true; + filterInput.addEventListener("input", function() { + renderBookmarkChecklist(filterInput.value); + }); + } + const enabledCb = document.getElementById("background-decode-enabled"); + if (enabledCb && !enabledCb._wired) { + enabledCb._wired = true; + enabledCb.addEventListener("change", function() { + markBgdDirty(); + }); + } + const selectAllBtn = document.getElementById("bgd-select-all-btn"); + if (selectAllBtn && !selectAllBtn._wired) { + selectAllBtn._wired = true; + selectAllBtn.addEventListener("click", selectAllBookmarks); + } + const deselectAllBtn = document.getElementById("bgd-deselect-all-btn"); + if (deselectAllBtn && !deselectAllBtn._wired) { + deselectAllBtn._wired = true; + deselectAllBtn.addEventListener("click", deselectAllBookmarks); + } + const saveBtn = document.getElementById("background-decode-save-btn"); + if (saveBtn && !saveBtn._wired) { + saveBtn._wired = true; + saveBtn.addEventListener("click", saveBackgroundDecode); + } + const resetBtn = document.getElementById("background-decode-reset-btn"); + if (resetBtn && !resetBtn._wired) { + resetBtn._wired = true; + resetBtn.addEventListener("click", () => { + void resetBackgroundDecode(); + }); + } + } + bgdWindow.trx ??= {}; + bgdWindow.trx.modules ??= {}; + bgdWindow.trx.modules.backgroundDecode = { + initialize: initBackgroundDecode, + wireEvents: wireBackgroundDecodeEvents, + setRig: setBackgroundDecodeRig + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js new file mode 100644 index 00000000..b7bfd423 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -0,0 +1,677 @@ +// src/plugins/bookmarks.ts +var bridge = window; +function bmEl(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing bookmark element #${id}`); + return element; +} +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} +var bmScope = "general"; +function bmScopeParam(prefix, scope) { + const sep = prefix ? "&" : "?"; + return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); +} +var bmList = []; +var bmOverlayList = []; +var bmOverlayRevision = 0; +var bmFilteredList = []; +var bmEditScope = null; +var bmCurrentPage = 1; +var BM_PAGE_SIZE = 25; +var bmSelected = /* @__PURE__ */ new Set(); +function bmFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz"; + return `${hz} Hz`; +} +function bmEsc(str) { + const d = document.createElement("div"); + d.appendChild(document.createTextNode(String(str))); + return d.innerHTML; +} +function bmCanControl() { + return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; +} +function bmSyncAccess() { + const canCtrl = bmCanControl(); + const addBtn = bmEl("bm-add-btn"); + const selectAllBtn = bmEl("bm-select-all-btn"); + if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; + if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; +} +function bmListScope() { + const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; + return rig || "general"; +} +async function bmFetchOverlay() { + const overlayScope = bmListScope(); + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + bmOverlayList = await resp.json(); + } catch (e) { + console.error("Failed to fetch overlay bookmarks:", e); + bmOverlayList = []; + } + bmOverlayRevision++; + if (typeof bridge.syncBookmarkMapLocators === "function") { + bridge.syncBookmarkMapLocators(bmOverlayList); + } + if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); +} +async function bmFetch(categoryFilter) { + let url = "/bookmarks"; + let hasQuery = false; + if (categoryFilter && categoryFilter !== "") { + url += "?category=" + encodeURIComponent(categoryFilter); + hasQuery = true; + } + url += bmScopeParam(hasQuery); + const overlayPromise = bmFetchOverlay(); + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + bmList = await resp.json(); + } catch (e) { + console.error("Failed to fetch bookmarks:", e); + bmList = []; + } + bmSelected.clear(); + bmUpdateSelectionUi(); + bmSyncAccess(); + bmApplyFilters(); + void bmRefreshCategoryFilter(categoryFilter); + await overlayPromise; +} +function bmApplyFilters() { + const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase(); + const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase(); + let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList; + filtered = text ? filtered.filter( + (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text) + ) : filtered; + bmFilteredList = filtered; + bmCurrentPage = 1; + bmRender(filtered); +} +async function bmRefreshCategoryFilter(keepValue) { + const sel = bmEl("bm-category-filter"); + const modeSel = bmEl("bm-mode-filter"); + if (!sel && !modeSel) return; + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false)); + if (!resp.ok) return; + const all = await resp.json(); + if (sel) { + const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); + while (sel.options.length > 1) sel.remove(1); + cats.forEach((cat) => { + const opt = document.createElement("option"); + opt.value = cat; + opt.textContent = cat; + sel.add(opt); + }); + if (keepValue && cats.includes(keepValue)) sel.value = keepValue; + } + if (modeSel) { + const keepMode = modeSel.value; + const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); + while (modeSel.options.length > 1) modeSel.remove(1); + modes.forEach((mode) => { + const opt = document.createElement("option"); + opt.value = mode; + opt.textContent = mode; + modeSel.add(opt); + }); + if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; + } + } catch { + } +} +function bmRender(list) { + const tbody = bmEl("bm-tbody"); + const emptyEl = bmEl("bm-empty"); + const paginatorEl = bmEl("bm-paginator"); + const pageSummaryEl = bmEl("bm-page-summary"); + const pageIndicatorEl = bmEl("bm-page-indicator"); + const prevBtn = bmEl("bm-page-prev"); + const nextBtn = bmEl("bm-page-next"); + if (!tbody) return; + tbody.innerHTML = ""; + if (list.length === 0) { + if (emptyEl) emptyEl.style.display = ""; + if (paginatorEl) paginatorEl.style.display = "none"; + return; + } + if (emptyEl) emptyEl.style.display = "none"; + const canControl = bmCanControl(); + const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE)); + const page = Math.min(Math.max(bmCurrentPage, 1), totalPages); + bmCurrentPage = page; + const startIndex = (page - 1) * BM_PAGE_SIZE; + const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length); + const pageItems = list.slice(startIndex, endIndex); + const showScope = bmScope !== "general"; + pageItems.forEach((bm) => { + const tr = document.createElement("tr"); + tr.dataset.bmId = bm.id; + const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--"; + const locatorCell = bm.locator || "--"; + const catCell = bm.category || "Uncategorised"; + const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--"; + const commentCell = bm.comment || ""; + const checked = bmSelected.has(bm.id) ? " checked" : ""; + const scopeBadge = showScope && bm.scope === "general" ? ' G' : ""; + tr.innerHTML = `${bmEsc(bm.name)}${scopeBadge}${bmFmtFreq(bm.freq_hz)}${bmEsc(bm.mode)}${bwCell}${bmEsc(locatorCell)}${bmEsc(catCell)}${bmEsc(decoderCell)}${bmEsc(commentCell)}` + (canControl ? `` : "") + ``; + tbody.appendChild(tr); + }); + bmSyncSelectAllCheckbox(); + if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : ""; + if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`; + if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`; + if (prevBtn) prevBtn.disabled = page <= 1; + if (nextBtn) nextBtn.disabled = page >= totalPages; +} +function bmChangePage(delta) { + const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); + const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); + if (nextPage === bmCurrentPage) return; + bmCurrentPage = nextPage; + bmRender(bmFilteredList); +} +function bmReadDecoders() { + return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); +} +function bmWriteDecoders(decoders) { + const set = new Set(decoders || []); + (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const el = bmEl("bm-dec-" + d.id); + if (el) el.checked = set.has(d.id); + }); +} +function bmBuildDecoderCheckboxes() { + const container = bmEl("bm-decoder-checkboxes"); + if (!container) return; + container.innerHTML = ""; + (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const label = document.createElement("label"); + label.className = "bm-decoder-check"; + label.innerHTML = ' ' + d.label; + container.appendChild(label); + }); +} +function bmOpenForm(bm) { + const wrap = bmEl("bm-form-wrap"); + if (!wrap) return; + bmEditScope = bm ? bm.scope || bmScope : null; + bmBuildDecoderCheckboxes(); + bmEl("bm-id").value = bm ? bm.id : ""; + bmEl("bm-name").value = bm ? bm.name : ""; + bmEl("bm-freq").value = bm ? String(bm.freq_hz) : ""; + bmEl("bm-mode").value = bm ? bm.mode : ""; + bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : ""; + bmEl("bm-locator").value = bm ? bm.locator || "" : ""; + bmEl("bm-category-input").value = bm ? bm.category || "" : ""; + bmEl("bm-comment").value = bm ? bm.comment || "" : ""; + bmWriteDecoders(bm?.decoders ?? []); + bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; + wrap.style.display = "flex"; + bmEl("bm-name").focus(); +} +function bmCloseForm() { + const wrap = bmEl("bm-form-wrap"); + if (wrap) wrap.style.display = "none"; +} +function bmPrefillFromStatus() { + if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { + bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + } + if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { + bmEl("bm-mode").value = bridge.lastModeName; + } + if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + } + const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { + const btn = bmEl(d.id + "-decode-toggle-btn"); + return btn && btn.dataset.enabled === "true"; + }).map((d) => d.id); + bmWriteDecoders(activeDecoders); +} +async function bmSave(e) { + e.preventDefault(); + const id = bmEl("bm-id").value; + const name = bmEl("bm-name").value.trim(); + const freqStr = bmEl("bm-freq").value; + const freq_hz = parseInt(freqStr, 10); + const mode = bmEl("bm-mode").value.trim(); + const bwStr = bmEl("bm-bw").value; + const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; + const locator = bmEl("bm-locator").value.trim().toUpperCase(); + const category = bmEl("bm-category-input").value.trim(); + const comment = bmEl("bm-comment").value.trim(); + const decoders = bmReadDecoders(); + const formError = bmEl("bm-form-error"); + if (formError) formError.textContent = ""; + if (!name || !Number.isFinite(freq_hz) || !mode) { + if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; + const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode"); + invalid?.focus(); + return; + } + const body = { + name, + freq_hz, + mode, + bandwidth_hz, + locator: locator || null, + category, + comment, + decoders + }; + try { + let resp; + if (id) { + resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } else { + resp = await fetch("/bookmarks" + bmScopeParam(false), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } + if (!resp.ok) { + const text = await resp.text(); + if (resp.status === 409) { + throw new Error("A bookmark for that frequency already exists."); + } + throw new Error(text || `HTTP ${resp.status}`); + } + bmCloseForm(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to save bookmark:", err); + if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err); + bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" }); + } +} +async function bmDelete(id) { + if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; + const bm = bmList.find((b) => b.id === id); + const scope = bm ? bm.scope : void 0; + try { + const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { + method: "DELETE" + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmark:", err); + bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" }); + } +} +function bmApply(bm) { + try { + if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { + bridge.modeEl.value = (bm.mode || "").toUpperCase(); + } + if (bm.bandwidth_hz) { + if (typeof bridge.currentBandwidthHz !== "undefined") { + bridge.currentBandwidthHz = bm.bandwidth_hz; + } + bridge.currentBandwidthHz = bm.bandwidth_hz; + if (typeof bridge.syncBandwidthInput === "function") { + bridge.syncBandwidthInput(bm.bandwidth_hz); + } + } + if (typeof bridge.applyLocalTunedFrequency === "function") { + if (typeof bridge._freqOptimisticSeq !== "undefined") { + ++bridge._freqOptimisticSeq; + bridge._freqOptimisticHz = bm.freq_hz; + } + bridge.applyLocalTunedFrequency(bm.freq_hz, true); + } + if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { + bridge.scheduleSpectrumDraw(); + } + const tunePromise = (async () => { + await bridge.trx?.modules?.vchan?.takeSchedulerControl(); + const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false; + if (!onVirtual) { + await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + } + if (bm.bandwidth_hz) { + const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false; + if (!bwHandledByVchan) { + await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); + } + } + if (typeof bridge.setRigFrequency === "function") { + await bridge.setRigFrequency(bm.freq_hz); + } else { + await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); + } + })(); + const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; + const modeUp = (bm.mode || "").toUpperCase(); + const allToggleDecoders = (bridge.decoderRegistry || []).filter( + (d) => d.activation === "toggle" + ); + const decoderPromise = allToggleDecoders.length ? (async () => { + let statusUrl = "/status"; + if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { + statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + } + const statusResp = await fetch(statusUrl); + if (!statusResp.ok) return; + const st = await statusResp.json(); + const toggles = []; + for (const d of allToggleDecoders) { + const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; + const currentlyOn = !!st[statusKey]; + const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp); + let wanted; + if (!compatible) { + wanted = false; + } else if (hasDecoders) { + wanted = bm.decoders?.includes(d.id) ?? false; + } else { + wanted = currentlyOn; + } + if (wanted !== currentlyOn) { + toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + } + } + if (toggles.length) await Promise.all(toggles); + })() : Promise.resolve(); + void Promise.all([tunePromise, decoderPromise]).catch((error) => { + console.error("Bookmark apply background error:", error); + }); + } catch (err) { + console.error("Failed to apply bookmark:", err); + } +} +bridge.trx ??= {}; +bridge.trx.modules ??= {}; +bridge.trx.modules.bookmarks = { + get overlayList() { + return bmOverlayList; + }, + get overlayRevision() { + return bmOverlayRevision; + }, + refreshOverlay: bmFetchOverlay, + invalidateColors() { + bmOverlayRevision += 1; + }, + apply: bmApply, + formatFrequency: bmFmtFreq, + fetch: bmFetch, + populateScopePicker: bmPopulateScopePicker +}; +function bmUpdateSelectionUi() { + const count = bmSelected.size; + const canCtrl = bmCanControl(); + const visible = count > 0 && canCtrl; + const btn = bmEl("bm-del-selected-btn"); + const countEl = bmEl("bm-del-selected-count"); + if (btn) btn.style.display = visible ? "" : "none"; + if (countEl) countEl.textContent = String(count); + const moveWrap = bmEl("bm-move-selected-wrap"); + const moveCountEl = bmEl("bm-move-selected-count"); + if (moveWrap) moveWrap.style.display = visible ? "" : "none"; + if (moveCountEl) moveCountEl.textContent = String(count); + if (visible) bmPopulateMoveTarget(); + const selectAllBtn = bmEl("bm-select-all-btn"); + if (selectAllBtn && bmCanControl()) { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; + } +} +function bmPopulateMoveTarget() { + const sel = bmEl("bm-move-target"); + if (!sel) return; + const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; + const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const prev = sel.value; + sel.innerHTML = ""; + if (bmScope !== "general") { + const opt = document.createElement("option"); + opt.value = "general"; + opt.textContent = "General"; + sel.appendChild(opt); + } + rigIds.forEach((id) => { + if (id === bmScope) return; + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + sel.appendChild(opt); + }); + if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) { + sel.value = prev; + } +} +async function bmMoveSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + const target = bmEl("bm-move-target")?.value; + if (!target) return; + const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target; + if (!await bridge.trxUi.confirm({ + title: "Move selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`, + confirmLabel: "Move", + danger: false + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + if (scope === target) continue; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds, to: target }) + }).then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to move bookmarks:", err); + bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" }); + } +} +function bmSyncSelectAllCheckbox() { + const selectAll = bmEl("bm-select-all"); + if (!selectAll) return; + const checkboxes = document.querySelectorAll(".bm-row-sel"); + if (checkboxes.length === 0) { + selectAll.checked = false; + selectAll.indeterminate = false; + return; + } + const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length; + selectAll.checked = checkedCount === checkboxes.length; + selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; +} +async function bmDeleteSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + if (!await bridge.trxUi.confirm({ + title: "Delete selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`, + confirmLabel: "Delete" + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds }) + }).then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmarks:", err); + bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" }); + } +} +function bmPopulateScopePicker() { + const picker = bmEl("bm-scope-picker"); + if (!picker) return; + const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; + const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const prev = picker.value; + while (picker.options.length > 1) picker.remove(1); + rigIds.forEach((id) => { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + picker.appendChild(opt); + }); + if (prev && (prev === "general" || rigIds.includes(prev))) { + picker.value = prev; + } else { + picker.value = "general"; + } + bmScope = picker.value; +} +(function initBookmarks() { + bmSyncAccess(); + bmBuildDecoderCheckboxes(); + if (typeof bridge.onDecoderRegistryReady === "function") { + bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); + } + bmPopulateScopePicker(); + const scopePicker = bmEl("bm-scope-picker"); + if (scopePicker) { + scopePicker.addEventListener("change", (e) => { + bmScope = e.currentTarget.value; + void bmFetch(bmEl("bm-category-filter").value || ""); + }); + } + document.querySelector(".tab-bar")?.addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null; + if (!btn) return; + void bmFetch(bmEl("bm-category-filter").value); + }); + bmEl("bm-add-btn").addEventListener("click", () => { + bmOpenForm(null); + bmPrefillFromStatus(); + }); + bmEl("bm-category-filter").addEventListener("change", (e) => { + void bmFetch(e.currentTarget.value); + }); + bmEl("bm-mode-filter").addEventListener("change", () => { + bmApplyFilters(); + }); + bmEl("bm-text-filter").addEventListener("input", () => { + bmApplyFilters(); + }); + bmEl("bm-page-prev").addEventListener("click", () => { + bmChangePage(-1); + }); + bmEl("bm-page-next").addEventListener("click", () => { + bmChangePage(1); + }); + bmEl("bm-form").addEventListener("submit", (event) => { + void bmSave(event); + }); + bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); + const formWrap = bmEl("bm-form-wrap"); + if (formWrap) { + formWrap.addEventListener("click", (e) => { + if (e.target === formWrap) bmCloseForm(); + }); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") { + bmCloseForm(); + } + }); + bmEl("bm-select-all").addEventListener("change", (e) => { + const checked = e.currentTarget.checked; + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = checked; + const id = cb.dataset.bmId; + if (!id) return; + if (checked) bmSelected.add(id); + else bmSelected.delete(id); + }); + bmUpdateSelectionUi(); + }); + bmEl("bm-select-all-btn").addEventListener("click", () => { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + if (allSelected) { + bmSelected.clear(); + } else { + bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); + } + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId); + }); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + }); + bmEl("bm-del-selected-btn").addEventListener("click", () => { + void bmDeleteSelected(); + }); + bmEl("bm-move-selected-btn").addEventListener("click", () => { + void bmMoveSelected(); + }); + bmEl("bm-tbody").addEventListener("click", (e) => { + void (async () => { + if (!(e.target instanceof Element)) return; + const checkbox = e.target.closest(".bm-row-sel"); + if (checkbox) { + const id = checkbox.dataset.bmId; + if (!id) return; + if (checkbox.checked) bmSelected.add(id); + else bmSelected.delete(id); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + return; + } + const tuneBtn = e.target.closest(".bm-tune-btn"); + const editBtn = e.target.closest(".bm-edit-btn"); + const delBtn = e.target.closest(".bm-del-btn"); + if (tuneBtn) { + const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); + if (bm) bmApply(bm); + } else if (editBtn) { + const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); + if (bm) bmOpenForm(bm); + } else if (delBtn) { + const id = delBtn.dataset.bmId; + if (id) await bmDelete(id); + } + })(); + }); + void bmFetch(""); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js new file mode 100644 index 00000000..1d386a5f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js @@ -0,0 +1,112 @@ +// src/plugins/aprs-shared.ts +function aprsPacketCategory(packet) { + const type = (packet.type ?? "").toLowerCase(); + const info = (packet.info ?? "").toLowerCase(); + if (packet.lat != null && packet.lon != null || type.includes("position")) return "position"; + if (type.includes("message") || info.startsWith(":")) return "message"; + if (type.includes("weather") || info.startsWith("_")) return "weather"; + if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; + return "other"; +} +function aprsCategoryLabel(category) { + switch (category) { + case "position": + return "Position"; + case "message": + return "Message"; + case "weather": + return "Weather"; + case "telemetry": + return "Telemetry"; + default: + return "Other"; + } +} +function aprsAgeText(timestampMs) { + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now"; + const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${String(seconds)}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + return `${String(Math.round(minutes / 60))}h ago`; +} +function aprsPacketSignature(packet) { + return [ + packet.srcCall ?? "", + packet.destCall ?? "", + packet.path ?? "", + packet.info ?? "", + packet.type ?? "", + packet.lat?.toFixed(4) ?? "", + packet.lon?.toFixed(4) ?? "" + ].join("|"); +} +function collapseAprsDuplicates(packets) { + const seen = /* @__PURE__ */ new Set(); + return packets.filter((packet) => { + const signature = aprsPacketSignature(packet); + if (seen.has(signature)) return false; + seen.add(signature); + return true; + }); +} +function aprsHexBytes(bytes) { + if (!bytes?.length) return "--"; + return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" "); +} +function renderAprsInfo(packet) { + if (packet.info_bytes?.length) { + return packet.info_bytes.map((byte) => renderAprsByte(byte)).join(""); + } + return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join(""); +} +function renderAprsByte(byte) { + return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; +} +function renderAprsCharacter(character) { + const code = character.charCodeAt(0); + return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; +} +function escapeAprsCharacter(character) { + if (character === "<") return "<"; + if (character === ">") return ">"; + if (character === "&") return "&"; + if (character === '"') return """; + return character; +} +function renderLocalAprsSymbol(packet, escapeHtml) { + if (!packet.symbolTable || !packet.symbolCode) return ""; + const symbol = escapeHtml(packet.symbolCode); + const table = packet.symbolTable === "/" ? "Primary" : "Alternate"; + return `${symbol}`; +} +function normalizeAprsPacket(packet, receiver) { + return { + rig_id: packet.rig_id || null, + receiver, + srcCall: packet.src_call ?? "", + destCall: packet.dest_call ?? "", + path: packet.path ?? "", + info: packet.info ?? "", + info_bytes: packet.info_bytes ?? [], + type: packet.packet_type ?? "", + crcOk: packet.crc_ok ?? false, + ts_ms: packet.ts_ms ?? null, + lat: packet.lat ?? null, + lon: packet.lon ?? null, + symbolTable: packet.symbol_table ?? null, + symbolCode: packet.symbol_code ?? null + }; +} + +export { + aprsPacketCategory, + aprsCategoryLabel, + aprsAgeText, + collapseAprsDuplicates, + aprsHexBytes, + renderAprsInfo, + renderLocalAprsSymbol, + normalizeAprsPacket +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js new file mode 100644 index 00000000..1fa6534f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js @@ -0,0 +1,269 @@ +// src/plugins/ftx-family.ts +var bridge = window; +function finiteNumber(value) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} +function isAlphaNumeric(value) { + return value !== void 0 && /[A-Za-z0-9]/.test(value); +} +function isGrid(value) { + const normalized = value.trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR"; +} +function escapeFtxHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function extractFtxGrids(message) { + return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))]; +} +function tokenize(message) { + return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); +} +function isCallsign(token) { + return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); +} +function extractFtxLocatorDetails(message) { + const tokens = tokenize(message); + const grids = extractFtxGrids(message); + const gridIndex = tokens.findIndex(isGrid); + const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign); + const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? ""); + const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null; + const target = directed ? callsigns[0] ?? null : null; + return grids.map((grid) => ({ grid, station: source, source, target })); +} +function extractFtxCallsign(message) { + return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null; +} +function renderFtxMessage(message) { + let html = ""; + let index = 0; + while (index < message.length) { + if (!isAlphaNumeric(message[index])) { + html += escapeFtxHtml(message[index] ?? ""); + index += 1; + continue; + } + let end = index + 1; + while (end < message.length && isAlphaNumeric(message[end])) end += 1; + const token = message.slice(index, end); + const grid = token.toUpperCase(); + html += isGrid(grid) ? `${grid}` : escapeFtxHtml(token); + index = end; + } + return html; +} +function installFtxCompatibilityHelpers() { + bridge.renderFt8Message = renderFtxMessage; + bridge.ft8EscapeHtml = escapeFtxHtml; + bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails; + bridge.ft8ExtractAllGrids = extractFtxGrids; + bridge.ft8ExtractLikelyCallsign = extractFtxCallsign; +} +function initializeFt8FamilyBar() { + const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" }; + const builders = {}; + const dismissed = { ft8: 0, ft4: 0, ft2: 0 }; + const overlay = document.getElementById("ft8-bar-overlay"); + let active = "ft8"; + const update = () => { + if (!overlay) return; + const mode = (document.getElementById("mode")?.value ?? "").toUpperCase(); + const result = builders[active]?.(); + if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) { + overlay.style.display = "none"; + overlay.innerHTML = ""; + return; + } + const label = labels[active]; + overlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; + overlay.style.display = "flex"; + }; + bridge.registerFt8FamilyBarRenderer = (decoder, builder) => { + builders[decoder] = builder; + }; + bridge.setFt8FamilyBarDecoder = (decoder) => { + active = decoder; + update(); + }; + bridge.updateFt8Bar = update; + bridge.clearFt8Bar = () => { + bridge.trxPluginRuntime.reset(active); + }; + bridge.closeFt8Bar = () => { + dismissed[active] = Date.now(); + update(); + }; +} +function initializeFtxDecoder(config) { + const { id, label, periodMs, periodDigits = 1 } = config; + const status = document.getElementById(`${id}-status`); + const period = document.getElementById(`${id}-period`); + const messagesElement = document.getElementById(`${id}-messages`); + const filterInput = document.getElementById(`${id}-filter`); + let filterText = ""; + let history = []; + const renderMessage = (message) => { + return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message); + }; + const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5; + const prune = () => { + const cutoff = Date.now() - retentionMs(); + history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff); + }; + const schedule = (job) => { + if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job); + else job(); + }; + const displayFrequency = (value) => { + const raw = finiteNumber(value); + if (raw === null) return null; + const base = finiteNumber(bridge.ft8BaseHz); + return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw; + }; + const renderRow = (message) => { + const row = document.createElement("div"); + row.className = "ft8-row"; + const raw = message.message ?? ""; + row.dataset.message = raw.toUpperCase(); + row.dataset.decoder = id; + const storedFrequency = finiteNumber(message.freq_hz); + row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency); + const snr = finiteNumber(message.snr_db); + const delta = finiteNumber(message.dt_s); + const frequency = displayFrequency(message.freq_hz); + const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); + const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + row.innerHTML = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + return row; + }; + const render = () => { + prune(); + if (!messagesElement) return; + const fragment = document.createDocumentFragment(); + let count = 0; + for (const message of history) { + if (count >= 200) break; + if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; + fragment.appendChild(renderRow(message)); + count += 1; + } + messagesElement.replaceChildren(fragment); + }; + const normalize = (message) => { + const raw = message.message ?? ""; + const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); + const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); + const frequency = displayFrequency(message.freq_hz); + if (grids.length > 0) { + bridge.mapAddLocator?.(raw, grids, id, station, { + ...message, + freq_hz: frequency ?? message.freq_hz, + locator_details: locatorDetails + }); + } + return { + receiver: bridge.getDecodeRigMeta?.() ?? null, + ts_ms: message.ts_ms, + snr_db: message.snr_db, + dt_s: message.dt_s, + freq_hz: frequency ?? message.freq_hz, + message: message.message, + _tsMs: finiteNumber(message.ts_ms) ?? Date.now() + }; + }; + const receiveBatch = (messages) => { + if (messages.length === 0) return; + if (status) status.textContent = "Receiving"; + history = messages.map(normalize).reverse().concat(history); + prune(); + bridge.setFt8FamilyBarDecoder?.(id); + bridge.updateFt8Bar?.(); + schedule(render); + }; + const reset = () => { + history = []; + bridge.updateFt8Bar?.(); + render(); + bridge.clearMapMarkersByType?.(id); + }; + const barFrames = () => { + const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8); + let html = ""; + for (const message of recent) { + const timestamp = finiteNumber(message._tsMs ?? message.ts_ms); + const time = timestamp === null ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; + const snr = finiteNumber(message.snr_db); + const delta = finiteNumber(message.dt_s); + const frequency = displayFrequency(message.freq_hz); + const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · "); + html += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; + } + return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html }; + }; + bridge.trxPluginRuntime.registerDecoder({ + id, + onMessage: (message) => { + receiveBatch([message]); + }, + onBatch: receiveBatch, + restore: receiveBatch, + prune: () => { + prune(); + render(); + }, + reset + }); + bridge.registerFt8FamilyBarRenderer?.(id, barFrames); + const updatePeriod = () => { + if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`; + }; + updatePeriod(); + window.setInterval(updatePeriod, 250); + filterInput?.addEventListener("input", () => { + filterText = filterInput.value.trim().toUpperCase(); + render(); + }); + messagesElement?.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid; + if (grid) { + bridge.navigateToMapLocator?.(grid.toUpperCase(), id); + event.preventDefault(); + } + }); + const toggle = document.getElementById(`${id}-decode-toggle-btn`); + toggle?.addEventListener("click", () => { + void (async () => { + try { + await bridge.takeSchedulerControlForDecoderDisable?.(toggle); + await bridge.postPath?.(`/toggle_${id}_decode`); + } catch (error) { + console.error(`${label} toggle failed`, error); + } + })(); + }); + document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { + void (async () => { + if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return; + try { + await bridge.postPath?.(`/clear_${id}_decode`); + reset(); + } catch (error) { + console.error(`${label} history clear failed`, error); + } + })(); + }); +} + +export { + installFtxCompatibilityHelpers, + initializeFt8FamilyBar, + initializeFtxDecoder +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js new file mode 100644 index 00000000..514dd896 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -0,0 +1,400 @@ +// src/plugins/cw.ts +var cwWindow = window; +var cwStatusEl = document.getElementById("cw-status"); +var cwOutputEl = document.getElementById("cw-output"); +var cwAutoInput = document.getElementById("cw-auto"); +var cwWpmInput = document.getElementById("cw-wpm"); +var cwToneInput = document.getElementById("cw-tone"); +var cwSignalIndicator = document.getElementById("cw-signal-indicator"); +var cwToneCanvas = document.getElementById("cw-tone-waterfall"); +var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; +var cwTonePickerEl = document.querySelector(".cw-tone-picker"); +var cwToneRangeEl = document.getElementById("cw-tone-range"); +var cwBarOverlay = document.getElementById("cw-bar-overlay"); +var CW_MAX_LINES = 200; +var CW_TONE_MIN_HZ = 100; +var CW_TONE_MAX_HZ = 1e4; +var CW_WPM_MIN = 5; +var CW_WPM_MAX = 40; +var CW_BAR_WINDOW_MS = 15 * 60 * 1e3; +var CW_BAR_LINE_GAP_MS = 5e3; +var cwLastAppendTime = 0; +var cwTonePickerRaf = null; +var cwBarHistory = []; +var cwBarCurrentLine = null; +var cwBarDismissedAtMs = 0; +var cwAutoLocalOverride = null; +function escapeCwHtml(input) { + return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function applyCwAutoUi(enabled) { + if (cwAutoInput) cwAutoInput.checked = enabled; + if (cwWpmInput) { + cwWpmInput.disabled = enabled; + cwWpmInput.readOnly = enabled; + } + if (cwToneInput) { + cwToneInput.disabled = enabled; + cwToneInput.readOnly = enabled; + } + if (cwTonePickerEl) { + cwTonePickerEl.classList.toggle("is-auto", enabled); + } +} +cwWindow.applyCwAutoUi = applyCwAutoUi; +cwWindow.applyCwAutoUiFromServer = function(enabled) { + if (cwAutoLocalOverride !== null) return; + applyCwAutoUi(enabled); +}; +function cwBarFlushCurrentLine() { + if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { + cwBarHistory.unshift(cwBarCurrentLine); + if (cwBarHistory.length > 50) cwBarHistory.length = 50; + } + cwBarCurrentLine = null; +} +function updateCwBar() { + if (!cwBarOverlay) return; + const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + const isCw = mode === "CW" || mode === "CWR"; + const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); + const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0); + if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { + cwBarOverlay.style.display = "none"; + cwBarOverlay.innerHTML = ""; + return; + } + let html = `
CWLiveLast 15 minutesClear
`; + for (const line of liveLines.slice(0, 8)) { + const ts = line.ts ? `${line.ts}` : ""; + const meta = [ + line.wpm ? `${String(line.wpm)} WPM` : null, + line.tone_hz ? `${String(line.tone_hz)} Hz` : null + ].filter(Boolean).join(" · "); + html += `
${ts}${escapeCwHtml(line.text)}` + (meta ? ` ${escapeCwHtml(meta)}` : "") + `
`; + } + cwBarOverlay.innerHTML = html; + cwBarOverlay.style.display = "flex"; +} +cwWindow.updateCwBar = updateCwBar; +cwWindow.clearCwBar = function() { + resetCwHistoryView(); +}; +cwWindow.closeCwBar = function() { + cwBarDismissedAtMs = Date.now(); + if (cwBarOverlay) { + cwBarOverlay.style.display = "none"; + cwBarOverlay.innerHTML = ""; + } +}; +function clampCwWpm(wpm) { + const numeric = Number(wpm); + if (!Number.isFinite(numeric)) return 15; + return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric))); +} +function clampCwTone(tone) { + const numeric = Number(tone); + if (!Number.isFinite(numeric)) return 700; + return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); +} +function currentCwToneRange() { + const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN; + const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN; + if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return null; + } + const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + const lowerSideband = mode === "CWR"; + const upperSideband = mode === "CW"; + if (!lowerSideband && !upperSideband) return null; + const toneMinHz = CW_TONE_MIN_HZ; + const toneMaxHz = CW_TONE_MAX_HZ; + return { + tunedHz, + bandwidthHz, + toneMinHz, + toneMaxHz, + toneSpanHz: Math.max(1, toneMaxHz - toneMinHz), + lowerSideband, + mode + }; +} +function cwToneToRfHz(range, toneHz) { + if (!range) return NaN; + return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz; +} +function toneClampForRange(tone, range) { + const clamped = clampCwTone(tone); + if (!range) return clamped; + return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); +} +function ensureCwToneCanvasResolution() { + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; + const rect = cwToneCanvas.getBoundingClientRect(); + const cssWidth = Math.round(rect.width); + const cssHeight = Math.round(rect.height); + if (cssWidth < 8 || cssHeight < 8) { + return false; + } + const dpr = window.devicePixelRatio || 1; + return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); +} +function drawCwTonePicker() { + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; + ensureCwToneCanvasResolution(); + if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; + const width = cwToneCanvas.width; + const height = cwToneCanvas.height; + cwToneGl.clear([0, 0, 0, 0]); + const range = currentCwToneRange(); + if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) { + if (cwToneRangeEl) { + const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + if (mode !== "CW" && mode !== "CWR") { + cwToneRangeEl.textContent = "CW/CWR mode required"; + } else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) { + cwToneRangeEl.textContent = "Waiting for spectrum"; + } + } + cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]); + return; + } + if (cwToneRangeEl) { + const side = range.lowerSideband ? "Lower side" : "Upper side"; + cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`; + } + const bins = cwWindow.lastSpectrumData.bins; + const sampleRate = cwWindow.lastSpectrumData.sample_rate; + const centerHz = cwWindow.lastSpectrumData.center_hz; + const maxIdx = Math.max(1, bins.length - 1); + const fullLoHz = centerHz - sampleRate / 2; + const tones = new Array(width).fill(-140); + for (let x = 0; x < width; x += 1) { + const frac = width <= 1 ? 0 : x / (width - 1); + const toneHz = range.toneMinHz + frac * range.toneSpanHz; + const rfHz = cwToneToRfHz(range, toneHz); + const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx))); + const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140; + tones[x] = power; + } + const smoothed = new Array(width).fill(-140); + const smoothRadius = Math.max(1, Math.round(width / 180)); + for (let x = 0; x < width; x += 1) { + let sum = 0; + let count = 0; + for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { + if (i < 0 || i >= width) continue; + sum += tones[i] ?? -140; + count += 1; + } + smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140; + } + const sorted = smoothed.slice().sort((a, b) => a - b); + const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120; + const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70; + const floorDb = Math.min(q20 - 2, q95 - 10); + const ceilDb = Math.max(floorDb + 18, q95 + 2); + const dbSpan = Math.max(1, ceilDb - floorDb); + const yForDb = (db) => { + const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan)); + return Math.round((1 - n) * (height - 1)); + }; + const rootStyle = getComputedStyle(document.documentElement); + const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; + const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null; + const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; + const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; + cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); + const hGridCount = 4; + const gridSegments = []; + for (let i = 1; i <= hGridCount; i += 1) { + const y = Math.round(i / (hGridCount + 1) * (height - 1)); + gridSegments.push(0, y, width, y); + } + cwToneGl.drawSegments(gridSegments, axisColor, 1); + const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200; + const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; + const tickSegments = []; + for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { + const frac = (tone - range.toneMinHz) / range.toneSpanHz; + const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); + tickSegments.push(x, 0, x, height); + } + cwToneGl.drawSegments(tickSegments, axisColor, 1); + const linePoints = []; + for (let x = 0; x < width; x += 1) { + linePoints.push(x, yForDb(smoothed[x] ?? -140)); + } + cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); + cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); + const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); + const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; + const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); + const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140); + cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); + cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); + if (cwAutoInput?.checked) { + cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]); + } +} +async function setCwTone(tone, { syncInput = true } = {}) { + const range = currentCwToneRange(); + const clamped = toneClampForRange(tone, range); + if (cwToneInput && syncInput) { + cwToneInput.value = String(clamped); + } + try { + await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + } catch (e) { + console.error("CW tone set failed", e); + } + drawCwTonePicker(); +} +if (cwAutoInput) { + cwAutoInput.addEventListener("change", () => { + void (async () => { + const enabled = cwAutoInput.checked; + cwAutoLocalOverride = enabled; + applyCwAutoUi(enabled); + try { + await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + drawCwTonePicker(); + } catch (error) { + console.error("CW auto toggle failed", error); + } finally { + cwAutoLocalOverride = null; + } + })(); + }); +} +if (cwWpmInput) { + cwWpmInput.addEventListener("change", () => { + void (async () => { + if (cwAutoInput?.checked) return; + const wpm = clampCwWpm(cwWpmInput.value); + cwWpmInput.value = String(wpm); + try { + await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); + } catch (error) { + console.error("CW WPM set failed", error); + } + })(); + }); +} +if (cwToneInput) { + cwToneInput.addEventListener("change", () => { + if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); + }); +} +if (cwToneCanvas) { + cwToneCanvas.addEventListener("click", (event) => { + if (cwAutoInput?.checked) return; + const rect = cwToneCanvas.getBoundingClientRect(); + if (rect.width <= 0) return; + const range = currentCwToneRange(); + if (!range) return; + const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const tone = range.toneMinHz + frac * range.toneSpanHz; + void setCwTone(tone); + }); +} +function resetCwHistoryView() { + if (cwOutputEl) cwOutputEl.innerHTML = ""; + cwLastAppendTime = 0; + cwBarHistory = []; + cwBarCurrentLine = null; + updateCwBar(); + drawCwTonePicker(); +} +document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => { + void (async () => { + if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await cwWindow.postPath?.("/clear_cw_decode"); + resetCwHistoryView(); + } catch (error) { + console.error("CW history clear failed", error); + } + })(); +}); +function onServerCw(evt) { + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + if (evt.text && cwOutputEl) { + const now = Date.now(); + if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { + const line = document.createElement("div"); + line.className = "cw-line"; + cwOutputEl.appendChild(line); + } + cwLastAppendTime = now; + const lastLine = cwOutputEl.lastElementChild; + if (lastLine) { + lastLine.textContent += evt.text; + } + while (cwOutputEl.children.length > CW_MAX_LINES) { + const firstChild = cwOutputEl.firstChild; + if (!firstChild) break; + cwOutputEl.removeChild(firstChild); + } + cwOutputEl.scrollTop = cwOutputEl.scrollHeight; + } + if (evt.text) { + const now = Date.now(); + if (evt.text === "\n") { + cwBarFlushCurrentLine(); + } else { + if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(); + const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + } + cwBarCurrentLine.text += evt.text; + cwBarCurrentLine.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + } + updateCwBar(); + } + if (cwSignalIndicator) { + cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; + } + if (!cwAutoInput || cwAutoInput.checked) { + if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { + cwWpmInput.value = String(clampCwWpm(evt.wpm)); + } + if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { + cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange())); + } + } + if (cwTonePickerRaf != null) return; + cwTonePickerRaf = requestAnimationFrame(() => { + cwTonePickerRaf = null; + drawCwTonePicker(); + }); +} +function restoreCwHistory(events) { + if (!Array.isArray(events) || events.length === 0) return; + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + for (const evt of events) { + onServerCw(evt); + } +} +cwWindow.trxPluginRuntime.registerDecoder({ + id: "cw", + onMessage: onServerCw, + restore: restoreCwHistory, + reset: resetCwHistoryView +}); +cwWindow.refreshCwTonePicker = function refreshCwTonePicker() { + ensureCwToneCanvasResolution(); + drawCwTonePicker(); +}; +window.addEventListener("resize", () => { + if (ensureCwToneCanvasResolution()) drawCwTonePicker(); +}); +applyCwAutoUi(!!cwAutoInput?.checked); +updateCwBar(); +ensureCwToneCanvasResolution(); +drawCwTonePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js new file mode 100644 index 00000000..7a4fd220 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js @@ -0,0 +1,179 @@ +"use strict"; +(() => { + // src/decode-history-worker.ts + var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; + var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; + var workerScope = self; + function decodeCborUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + if (additional === 24) { + if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 1; + return bytes[offset] ?? 0; + } + if (additional === 25) { + if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 2; + return view.getUint16(offset); + } + if (additional === 26) { + if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 4; + return view.getUint32(offset); + } + if (additional === 27) { + if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getBigUint64(offset); + state.offset += 8; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; + } + throw new Error("Unsupported CBOR additional info"); + } + function decodeCborFloat16(bits) { + const sign = bits & 32768 ? -1 : 1; + const exponent = bits >> 10 & 31; + const fraction = bits & 1023; + if (exponent === 0) { + return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 31) { + return fraction === 0 ? sign * Infinity : Number.NaN; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); + } + function decodeCborItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + if (initial === void 0) throw new Error("CBOR payload truncated"); + const major = initial >> 5; + const additional = initial & 31; + if (major === 0) return decodeCborUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); + if (major === 2) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return Array.from(chunk); + } + if (major === 3) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.subarray(state.offset, state.offset + length); + state.offset += length; + return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeCborUint(view, bytes, state, additional); + const items = new Array(length); + for (let i = 0; i < length; i += 1) { + items[i] = decodeCborItem(view, bytes, state); + } + return items; + } + if (major === 5) { + const length = decodeCborUint(view, bytes, state, additional); + const value = {}; + for (let i = 0; i < length; i += 1) { + const key = decodeCborItem(view, bytes, state); + const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" ? String(key) : JSON.stringify(key); + value[property] = decodeCborItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeCborUint(view, bytes, state, additional); + return decodeCborItem(view, bytes, state); + } + if (major === 7) { + if (additional === 20) return false; + if (additional === 21) return true; + if (additional === 22) return null; + if (additional === 23) return void 0; + if (additional === 25) { + if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + const bits = view.getUint16(state.offset); + state.offset += 2; + return decodeCborFloat16(bits); + } + if (additional === 26) { + if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat32(state.offset); + state.offset += 4; + return value; + } + if (additional === 27) { + if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat64(state.offset); + state.offset += 8; + return value; + } + } + throw new Error("Unsupported CBOR major type"); + } + function decodeCborPayload(buffer) { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const state = { offset: 0 }; + const value = decodeCborItem(view, bytes, state); + if (state.offset !== bytes.length) { + throw new Error("Unexpected trailing bytes in decode history payload"); + } + return value; + } + function isHistory(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + async function fetchAndDecodeHistory(url, batchLimit) { + workerScope.postMessage({ type: "status", phase: "fetching" }); + const resp = await fetch(url, { credentials: "same-origin" }); + if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`); + const payload = await resp.arrayBuffer(); + if (payload.byteLength === 0) { + workerScope.postMessage({ type: "start", total: 0 }); + workerScope.postMessage({ type: "done", total: 0 }); + return; + } + workerScope.postMessage({ type: "status", phase: "decoding" }); + const history = decodeCborPayload(payload); + const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { + const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : []; + return sum + items.length; + }, 0); + workerScope.postMessage({ type: "start", total }); + let processed = 0; + const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); + for (const kind of HISTORY_GROUP_KEYS) { + const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : []; + if (items.length === 0) continue; + for (let index = 0; index < items.length; index += safeLimit) { + const messages = items.slice(index, index + safeLimit); + processed += messages.length; + workerScope.postMessage({ + type: "group", + kind, + messages, + processed, + total + }); + } + } + workerScope.postMessage({ type: "done", total }); + } + function isFetchHistoryRequest(value) { + return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history"; + } + workerScope.onmessage = (event) => { + const data = event.data; + if (!isFetchHistoryRequest(data)) return; + fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((error) => { + workerScope.postMessage({ + type: "error", + message: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown worker failure" + }); + }); + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js new file mode 100644 index 00000000..bb0f2236 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -0,0 +1,6 @@ +import { + initializeFtxDecoder +} from "./chunk-SGMG5LG2.js"; + +// src/plugins/ft2.ts +initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js new file mode 100644 index 00000000..bafde67f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -0,0 +1,6 @@ +import { + initializeFtxDecoder +} from "./chunk-SGMG5LG2.js"; + +// src/plugins/ft4.ts +initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js new file mode 100644 index 00000000..d9d961ce --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -0,0 +1,10 @@ +import { + initializeFt8FamilyBar, + initializeFtxDecoder, + installFtxCompatibilityHelpers +} from "./chunk-SGMG5LG2.js"; + +// src/plugins/ft8.ts +installFtxCompatibilityHelpers(); +initializeFt8FamilyBar(); +initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js new file mode 100644 index 00000000..aa270dfe --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -0,0 +1,265 @@ +import { + aprsAgeText, + aprsCategoryLabel, + aprsHexBytes, + aprsPacketCategory, + collapseAprsDuplicates, + normalizeAprsPacket, + renderAprsInfo, + renderLocalAprsSymbol +} from "./chunk-M2I6DH4X.js"; + +// src/plugins/hf-aprs.ts +var hfAprsWindow = window; +var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var hfAprsStatus = document.getElementById("hf-aprs-status"); +var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); +var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); +var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn"); +var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn"); +var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn"); +var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count"); +var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count"); +var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen"); +var hfAprsFilterText = ""; +var hfAprsPacketHistory = []; +var hfAprsOnlyPos = false; +var hfAprsHideCrc = false; +var hfAprsCollapseDup = false; +var hfAprsTypeFilter = "all"; +function currentHfAprsHistoryRetentionMs() { + return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneHfAprsPacketHistory() { + const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs(); + hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs); +} +function scheduleHfAprsHistoryRender() { + if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") { + hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => { + renderHfAprsHistory(); + }); + return; + } + renderHfAprsHistory(); +} +function hfAprsDistanceText(pkt) { + if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return ""; + const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function hfAprsFilterMatch(pkt) { + if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; + if (hfAprsHideCrc && !pkt.crcOk) return false; + if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false; + if (!hfAprsFilterText) return true; + const haystack = [ + pkt.srcCall, + pkt.destCall, + pkt.path, + pkt.info, + pkt.type, + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "", + aprsPacketCategory(pkt) + ].filter(Boolean).join(" ").toUpperCase(); + return haystack.includes(hfAprsFilterText); +} +function hfAprsVisiblePackets() { + const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; + return packets.filter(hfAprsFilterMatch); +} +var collapseHfAprsDuplicates = collapseAprsDuplicates; +function updateHfAprsSummary() { + const visible = hfAprsVisiblePackets(); + if (hfAprsTotalCountEl) { + hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; + } + if (hfAprsVisibleCountEl) { + hfAprsVisibleCountEl.textContent = `${visible.length} shown`; + } + if (hfAprsLatestSeenEl) { + const latest = hfAprsPacketHistory[0]; + if (!latest) { + hfAprsLatestSeenEl.textContent = "No packets yet"; + } else { + hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`; + } + } +} +function updateHfAprsChipState() { + document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => { + btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`); + }); + hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos); + hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc); + hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); +} +function renderHfAprsRow(pkt, isFresh) { + const row = document.createElement("div"); + row.className = "aprs-packet"; + if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); + if (isFresh) row.classList.add("aprs-packet-new"); + const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const age = aprsAgeText(pkt._tsMs); + const category = aprsPacketCategory(pkt); + const categoryLabel = aprsCategoryLabel(category); + const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; + const pathBadge = pkt.path ? `${escapeHfAprsHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + const hfBadge = 'HF'; + const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = hfAprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeHfAprsHtml(pkt.srcCall ?? "")}>${escapeHfAprsHtml(pkt.destCall || "")}${escapeHfAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeHfAprsHtml(age)}` + (distance ? `${escapeHfAprsHtml(distance)}` : "") + `${escapeHfAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeHfAprsHtml(pkt.srcCall || "--")}Destination${escapeHfAprsHtml(pkt.destCall || "--")}Type${escapeHfAprsHtml(pkt.type || "--")}Path${escapeHfAprsHtml(pkt.path || "--")}Age${escapeHfAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeHfAprsHtml(pkt.info || "--")}Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; + row.querySelectorAll("[data-aprs-map]").forEach((el) => { + el.addEventListener("click", (evt) => { + evt.preventDefault(); + const raw = el.dataset.aprsMap ?? ""; + const [lat, lon] = raw.split(",").map(Number); + if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { + hfAprsWindow.navigateToAprsMap(lat, lon); + } + }); + }); + const copyBtn = row.querySelector("[data-aprs-copy]"); + if (copyBtn) { + copyBtn.addEventListener("click", () => { + void (async () => { + const raw = copyBtn.dataset.aprsCopy ?? ""; + try { + const clipboard = Reflect.get(navigator, "clipboard"); + if (clipboard) { + await clipboard.writeText(raw); + hfAprsWindow.showHint?.("Coordinates copied", 1200); + } + } catch { + hfAprsWindow.showHint?.("Copy failed", 1500); + } + })(); + }); + } + return row; +} +function renderHfAprsHistory() { + pruneHfAprsPacketHistory(); + if (!hfAprsPacketsEl) { + updateHfAprsSummary(); + updateHfAprsChipState(); + return; + } + const visible = hfAprsVisiblePackets(); + const fragment = document.createDocumentFragment(); + for (const [index, packet] of visible.entries()) { + fragment.appendChild(renderHfAprsRow(packet, index === 0)); + } + hfAprsPacketsEl.replaceChildren(fragment); + updateHfAprsSummary(); + updateHfAprsChipState(); +} +function resetHfAprsHistoryView() { + if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; + hfAprsPacketHistory = []; + renderHfAprsHistory(); +} +function pruneHfAprsHistoryView() { + pruneHfAprsPacketHistory(); + renderHfAprsHistory(); +} +function addHfAprsPacket(pkt) { + const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); + pkt._tsMs = tsMs; + pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + hfAprsPacketHistory.unshift(pkt); + pruneHfAprsPacketHistory(); + scheduleHfAprsHistoryRender(); +} +function normalizeServerHfAprsPacket(pkt) { + return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null); +} +function onServerHfAprsBatch(packets) { + if (!Array.isArray(packets) || packets.length === 0) return; + if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; + const normalized = []; + for (const pkt of packets) { + const next = normalizeServerHfAprsPacket(pkt); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + normalized.push(next); + } + normalized.reverse(); + hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); + pruneHfAprsPacketHistory(); + scheduleHfAprsHistoryRender(); +} +var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); +hfAprsDecodeToggleBtn?.addEventListener("click", () => { + void (async () => { + try { + await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); + await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode"); + } catch (e) { + console.error("HF APRS toggle failed", e); + } + })(); +}); +document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => { + void (async () => { + if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await hfAprsWindow.postPath?.("/clear_hf_aprs_decode"); + resetHfAprsHistoryView(); + } catch (e) { + console.error("HF APRS history clear failed", e); + } + })(); +}); +if (hfAprsOnlyPosBtn) { + hfAprsOnlyPosBtn.addEventListener("click", () => { + hfAprsOnlyPos = !hfAprsOnlyPos; + renderHfAprsHistory(); + }); +} +if (hfAprsHideCrcBtn) { + hfAprsHideCrcBtn.addEventListener("click", () => { + hfAprsHideCrc = !hfAprsHideCrc; + renderHfAprsHistory(); + }); +} +if (hfAprsCollapseDupBtn) { + hfAprsCollapseDupBtn.addEventListener("click", () => { + hfAprsCollapseDup = !hfAprsCollapseDup; + renderHfAprsHistory(); + }); +} +["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { + const btn = document.getElementById(`hf-aprs-type-${type}`); + if (!btn) return; + btn.addEventListener("click", () => { + hfAprsTypeFilter = type; + renderHfAprsHistory(); + }); +}); +if (hfAprsFilterInput) { + hfAprsFilterInput.addEventListener("input", () => { + hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase(); + renderHfAprsHistory(); + }); +} +function onServerHfAprs(pkt) { + if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; + addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); +} +renderHfAprsHistory(); +window.trxPluginRuntime.registerDecoder({ + id: "hf_aprs", + onMessage: onServerHfAprs, + onBatch: onServerHfAprsBatch, + restore: onServerHfAprsBatch, + reset: resetHfAprsHistoryView, + prune: pruneHfAprsHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js similarity index 75% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/map-core.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index 2e69dd8b..5cff0e48 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,23 +1,27 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// Map, statistics, and geolocation module (lazy-loaded on map tab activation). -// Communicates with app.js through explicit state/core/module services. -(function () { +// src/map-core.ts +function mapEl(id) { + const element = document.querySelector(`#${CSS.escape(id)}`); + if (!element) throw new Error(`Missing map element #${id}`); + return element; +} +var mapWindow = window; +(function() { "use strict"; - const { state: T, core: C, modules } = window.trx; - - // Destructure shared utility functions for convenience - const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans, - postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, - formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm, - formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs, - formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText, - nearestBookmarkForHz } = C; - + const { state: T, core: C, modules } = mapWindow.trx; + const { + saveSetting, + loadSetting, + escapeMapHtml, + formatFreqForHumans, + scheduleUiFrameJob, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo + } = C; function updateMapRigFilter() { - const el = document.getElementById("map-rig-filter"); + const el = mapEl("map-rig-filter"); if (!el) return; const prev = el.value; while (el.options.length > 1) el.remove(1); @@ -35,12 +39,9 @@ } updateStatsRigFilter(); } - - // --- Leaflet Map (lazy-initialized) --- let aprsMap = null; let aprsMapBaseLayer = null; - let aprsMapReceiverMarker = null; - let aprsMapReceiverMarkers = {}; // keyed by rig remote id + const aprsMapReceiverMarkers = {}; let aprsRadioPaths = []; let selectedLocatorMarker = null; let selectedLocatorPulseRaf = null; @@ -50,24 +51,24 @@ let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; const MAP_QSO_SUMMARY_LIMIT = 5; - const stationMarkers = new Map(); - const locatorMarkers = new Map(); - const decodeContactPaths = new Map(); + const stationMarkers = /* @__PURE__ */ new Map(); + const locatorMarkers = /* @__PURE__ */ new Map(); + const decodeContactPaths = /* @__PURE__ */ new Map(); let selectedMapQsoKey = null; - const mapMarkers = new Set(); + const mapMarkers = /* @__PURE__ */ new Set(); const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; - const mapLocatorFilter = { phase: "band", bands: new Set() }; + const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; let mapSearchFilter = ""; - let mapRigFilter = ""; // "" = all rigs + let mapRigFilter = ""; let mapHistoryPruneTimer = null; let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( Number(loadSetting("mapHistoryLimitMinutes", 1440)) ); const APRS_TRACK_MAX_POINTS = 64; const AIS_TRACK_MAX_POINTS = 64; - const aisMarkers = new Map(); - const vdesMarkers = new Map(); + const aisMarkers = /* @__PURE__ */ new Map(); + const vdesMarkers = /* @__PURE__ */ new Map(); let selectedAprsTrackCall = null; let selectedAisTrackMmsi = null; const HAM_BANDS = [ @@ -93,31 +94,27 @@ { label: "13cm", meters: 0.13 }, { label: "9cm", meters: 0.09 }, { label: "6cm", meters: 0.06 }, - { label: "3cm", meters: 0.03 }, + { label: "3cm", meters: 0.03 } ].map((band) => ({ ...band, - nominalHz: 299_792_458 / band.meters, + nominalHz: 299792458 / band.meters })); - function normalizeLocatorFreqHz(hz) { if (!Number.isFinite(hz) || hz <= 0) return null; - if (hz >= 100_000) return hz; - const baseHz = Number(window.ft8BaseHz); + if (hz >= 1e5) return hz; + const baseHz = Number(mapWindow.ft8BaseHz); if (Number.isFinite(baseHz) && baseHz > 0) { return baseHz + hz; } return hz; } - function normalizeMapHistoryLimitMinutes(value) { const minutes = Math.round(Number(value)); return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; } - function mapHistoryCutoffMs() { - return Date.now() - (mapHistoryLimitMinutes * 60 * 1000); + return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; } - function trimTrackHistory(history, cutoffMs, maxPoints) { const list = Array.isArray(history) ? history : []; const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); @@ -126,7 +123,6 @@ } return trimmed; } - function refreshAprsTrack(call, entry) { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { @@ -146,13 +142,12 @@ opacity: 0.72, lineCap: "round", lineJoin: "round", - interactive: false, + interactive: false }); track.__trxType = "aprs"; track._aprsCall = call; entry.track = track; } - function refreshAisTrack(mmsi, entry) { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { @@ -173,26 +168,15 @@ lineCap: "round", lineJoin: "round", interactive: false, - dashArray: "5 4", + dashArray: "5 4" }); track.__trxType = "ais"; track._aisMmsi = mmsi; entry.track = track; } - - function removeMapMarker(marker) { - if (!marker) return; - if (marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); - mapMarkers.delete(marker); - } - function setRetainedMapMarkerVisible(marker, visible) { if (!marker) return; - marker.__trxHistoryVisible = visible !== false; + marker.__trxHistoryVisible = visible; if (!visible) { if (marker === selectedLocatorMarker) { setSelectedLocatorMarker(null); @@ -201,63 +185,53 @@ if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); } } - function ensureAprsMarker(call, entry) { if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; _aprsAddMarkerToMap(call, entry); } - function ensureAisMarker(key, entry) { if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; - const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg) - .addTo(aprsMap) - .bindPopup(buildAisPopupHtml(entry.msg)); + const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); marker.__trxType = "ais"; - marker.__trxRigIds = entry.rigIds || new Set(); + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker._aisMmsi = String(key); entry.marker = marker; mapMarkers.add(marker); } - function ensureVdesMarker(key, entry) { if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { radius: 5, color: "#5c394f", fillColor: "#c46392", - fillOpacity: 0.82, + fillOpacity: 0.82 }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); marker.__trxType = "vdes"; - marker.__trxRigIds = entry.rigIds || new Set(); + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker._vdesKey = String(key); entry.marker = marker; mapMarkers.add(marker); } - function ensureDecodeLocatorMarker(entry) { - if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return; + if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; const bounds = maidenheadToBounds(entry.grid); if (!bounds) return; const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); - const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)) - .addTo(aprsMap) - .bindPopup(tooltipHtml); + const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); marker.__trxType = entry.sourceType; - marker.__trxRigIds = entry.rigIds || new Set(); + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); sendLocatorOverlayToBack(marker); assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); entry.marker = marker; mapMarkers.add(marker); } - function pruneAprsEntry(call, entry, cutoffMs) { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; const pktTsMs = Number(entry?.pkt?._tsMs); const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS) - .map((point) => [point.lat, point.lon]); + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); if (canRenderMap) { refreshAprsTrack(call, entry); } else { @@ -277,20 +251,18 @@ if (!canRenderMap) return true; ensureAprsMarker(call, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.lat != null && entry.lon != null) { entry.marker.setLatLng([entry.lat, entry.lon]); entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); } return true; } - function pruneAisEntry(key, entry, cutoffMs) { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; const msgTsMs = Number(entry?.msg?._tsMs); const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS) - .map((point) => [point.lat, point.lon]); + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); if (canRenderMap) { refreshAisTrack(key, entry); } else { @@ -310,21 +282,18 @@ if (!canRenderMap) return true; ensureAisMarker(key, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.msg) { updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); } return true; } - function pruneLocatorEntry(key, entry, cutoffMs) { const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return true; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; if (!(entry.allStationDetails instanceof Map)) { - entry.allStationDetails = entry.stationDetails instanceof Map - ? new Map(entry.stationDetails) - : new Map(); + entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); } - const nextDetails = new Map(); + const nextDetails = /* @__PURE__ */ new Map(); for (const [detailKey, detail] of entry.allStationDetails.entries()) { const tsMs = Number(detail?.ts_ms); if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { @@ -333,14 +302,14 @@ } entry.visibleInHistoryWindow = nextDetails.size > 0; if (nextDetails.size === 0) { - entry.stationDetails = new Map(); - entry.stations = new Set(); - entry.bandMeta = new Map(); + entry.stationDetails = /* @__PURE__ */ new Map(); + entry.stations = /* @__PURE__ */ new Set(); + entry.bandMeta = /* @__PURE__ */ new Map(); if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); else C.markDecodeMapSyncPending(); return false; } - const nextStations = new Set(); + const nextStations = /* @__PURE__ */ new Set(); for (const detail of nextDetails.values()) { const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); if (source) nextStations.add(source); @@ -357,14 +326,13 @@ } ensureDecodeLocatorMarker(entry); setRetainedMapMarkerVisible(entry.marker, true); - if (entry.marker) { + if (entry.marker && entry.grid && entry.sourceType) { entry.marker.setStyle(locatorStyleForEntry(entry, count)); entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); } return true; } - function pruneMapHistory() { const cutoffMs = mapHistoryCutoffMs(); for (const [call, entry] of stationMarkers.entries()) { @@ -383,7 +351,7 @@ } ensureVdesMarker(key, entry); setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker) { + if (entry?.marker && entry.msg?.lat != null && entry.msg.lon != null) { entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); } @@ -399,7 +367,6 @@ rebuildMapLocatorFilters(); applyMapFilter(); } - function locatorSourceLabel(type) { if (type === "bookmark") return "Bookmarks"; if (type === "wspr") return "WSPR"; @@ -407,23 +374,18 @@ if (type === "ft2") return "FT2"; return "FT8"; } - function mapSourceLabel(type) { if (type === "bookmark") return "Bookmarks"; return String(type || "").toUpperCase(); } - function locatorFilterColor(type) { const hues = locatorThemeHues(); const lightTheme = C.currentTheme() === "light"; const sat = lightTheme ? 66 : 76; const light = lightTheme ? 42 : 56; - const hue = type === "bookmark" - ? hues.bookmark - : (type === "wspr" ? hues.wspr : (type === "ft4" ? hues.ft4 : (type === "ft2" ? hues.ft2 : hues.ft8))); + const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; } - function mapSourceColor(type) { if (type === "ais") return "#38bdf8"; if (type === "vdes") return "#a78bfa"; @@ -431,10 +393,9 @@ if (type === "aprs") return "#00d17f"; return locatorFilterColor(type); } - function bandForHz(hz) { const rfHz = normalizeLocatorFreqHz(hz); - if (!Number.isFinite(rfHz) || rfHz <= 0) return null; + if (rfHz == null || !Number.isFinite(rfHz) || rfHz <= 0) return null; let bestBand = null; let bestDistance = Infinity; for (const band of HAM_BANDS) { @@ -446,9 +407,8 @@ } return bestBand; } - function collectBandMeta(freqs) { - const out = new Map(); + const out = /* @__PURE__ */ new Map(); if (!Array.isArray(freqs)) return out; for (const hz of freqs) { const band = bandForHz(hz); @@ -456,36 +416,34 @@ } return out; } - function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { if (!marker) return; - const safeMeta = bandMeta instanceof Map ? bandMeta : new Map(); + const safeMeta = bandMeta ?? /* @__PURE__ */ new Map(); marker._locatorFilterMeta = { sourceType, bands: new Set(safeMeta.keys()), - bandMeta: new Map(safeMeta), + bandMeta: new Map(safeMeta) }; } - function parseMapColor(input) { const value = String(input || "").trim(); if (!value) return null; const hex = value.match(/^#([0-9a-f]{3,8})$/i); if (hex) { const raw = hex[1]; + if (!raw) return null; if (raw.length === 3 || raw.length === 4) { - const chars = raw.split(""); return { - r: parseInt(chars[0] + chars[0], 16), - g: parseInt(chars[1] + chars[1], 16), - b: parseInt(chars[2] + chars[2], 16), + r: parseInt(raw.charAt(0).repeat(2), 16), + g: parseInt(raw.charAt(1).repeat(2), 16), + b: parseInt(raw.charAt(2).repeat(2), 16) }; } if (raw.length === 6 || raw.length === 8) { return { r: parseInt(raw.slice(0, 2), 16), g: parseInt(raw.slice(2, 4), 16), - b: parseInt(raw.slice(4, 6), 16), + b: parseInt(raw.slice(4, 6), 16) }; } } @@ -494,12 +452,11 @@ return { r: Math.max(0, Math.min(255, Number(rgb[1]))), g: Math.max(0, Math.min(255, Number(rgb[2]))), - b: Math.max(0, Math.min(255, Number(rgb[3]))), + b: Math.max(0, Math.min(255, Number(rgb[3]))) }; } return null; } - function rgbToHsl(rgb) { if (!rgb) return null; const r = rgb.r / 255; @@ -516,28 +473,25 @@ let h; switch (max) { case r: - h = ((g - b) / d) + (g < b ? 6 : 0); + h = (g - b) / d + (g < b ? 6 : 0); break; case g: - h = ((b - r) / d) + 2; + h = (b - r) / d + 2; break; default: - h = ((r - g) / d) + 4; + h = (r - g) / d + 4; break; } - return { h: (h * 60) % 360, s: s * 100, l: l * 100 }; + return { h: h * 60 % 360, s: s * 100, l: l * 100 }; } - function wrapHue(hue) { const value = Number(hue) || 0; - return ((value % 360) + 360) % 360; + return (value % 360 + 360) % 360; } - function paletteHue(input, fallback) { const hsl = rgbToHsl(parseMapColor(input)); - return Number.isFinite(hsl?.h) ? hsl.h : fallback; + return hsl && Number.isFinite(hsl.h) ? hsl.h : fallback; } - function locatorThemeHues() { const pal = C.canvasPalette(); const baseHue = paletteHue(pal?.spectrumLine, 145); @@ -549,15 +503,13 @@ ft4: wrapHue(peakHue + 30), ft2: wrapHue(peakHue + 60), wspr: wrapHue((waveHue + baseHue) / 2), - bandBase: wrapHue((baseHue * 0.65) + (peakHue * 0.35)), + bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) }; } - function locatorBandIndex(label) { const idx = HAM_BANDS.findIndex((band) => band.label === label); return idx >= 0 ? idx : 0; } - function locatorBandChipColor(label) { const hues = locatorThemeHues(); const lightTheme = C.currentTheme() === "light"; @@ -566,9 +518,8 @@ const light = lightTheme ? 44 : 58; return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; } - function locatorBandLabelForEntry(entry) { - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map(); + const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); if (meta.size === 0) return null; if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { for (const label of mapLocatorFilter.bands) { @@ -586,7 +537,6 @@ } return bestLabel; } - function locatorHueForEntry(entry) { const hues = locatorThemeHues(); if (mapLocatorFilter.phase === "band") { @@ -601,7 +551,6 @@ if (entry?.sourceType === "ft2") return hues.ft2; return hues.ft8; } - function locatorStyleForEntry(entry, count) { const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); @@ -616,17 +565,15 @@ opacity: 0.42 + intensity * 0.5, weight: 1 + intensity * 1.2, fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, - fillOpacity: 0.16 + intensity * 0.34, + fillOpacity: 0.16 + intensity * 0.34 }; } - function locatorEntryCount(entry) { if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); return 1; } - function locatorEntryForMarker(marker) { if (!marker) return null; for (const entry of locatorMarkers.values()) { @@ -634,7 +581,6 @@ } return null; } - function syncLocatorMarkerStyles() { for (const entry of locatorMarkers.values()) { if (!entry?.marker) continue; @@ -646,14 +592,12 @@ entry.line.setStyle({ color, opacity: 0.78 }); } } - function stopSelectedLocatorPulse() { if (selectedLocatorPulseRaf != null) { cancelAnimationFrame(selectedLocatorPulseRaf); selectedLocatorPulseRaf = null; } } - function startSelectedLocatorPulse(marker) { stopSelectedLocatorPulse(); if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; @@ -662,24 +606,23 @@ return; } const entry = locatorEntryForMarker(marker); + if (!entry) return; const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); - const phase = (ts % 1600) / 1600; + const phase = ts % 1600 / 1600; const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; marker.setStyle({ ...base, opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), - weight: (base.weight || 1.8) + 1.8 * wave, + weight: (base.weight || 1.8) + 1.8 * wave }); selectedLocatorPulseRaf = requestAnimationFrame(tick); }; selectedLocatorPulseRaf = requestAnimationFrame(tick); } - function clearMapRadioPath() { for (const p of aprsRadioPaths) p.remove(); aprsRadioPaths = []; } - function clearDecodeContactPathRender(entry) { if (!entry) return; if (entry.line) { @@ -691,7 +634,6 @@ entry.labelMarker = null; } } - function clearDecodeContactPaths() { for (const entry of decodeContactPaths.values()) { clearDecodeContactPathRender(entry); @@ -699,19 +641,15 @@ decodeContactPaths.clear(); updateMapPathsAnimationClass(); } - const MAP_PATHS_STATIC_THRESHOLD = 20; function updateMapPathsAnimationClass() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; - mapEl.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); + const mapContainer = mapEl("aprs-map"); + mapContainer.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); } - function formatDecodeContactDistance(distanceKm) { const text = formatDistanceKm(distanceKm); return text || "--"; } - function decodeLocatorPathVisibility(grid) { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!normalizedGrid || !aprsMap) return false; @@ -722,7 +660,6 @@ } return false; } - function midpointLatLon(a, b) { if (!a || !b) return null; if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { @@ -730,26 +667,24 @@ } return { lat: (a.lat + b.lat) / 2, - lon: (a.lon + b.lon) / 2, + lon: (a.lon + b.lon) / 2 }; } - function decodeContactPathColor(entry) { if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); - const srcEntry = locatorMarkers.get(entry?.sourceGrid); + const srcEntry = entry.sourceGrid ? locatorMarkers.get(entry.sourceGrid) : void 0; if (srcEntry) { const label = locatorBandLabelForEntry(srcEntry); if (label) return locatorBandChipColor(label); - return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color; + return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color ?? locatorFilterColor("ft8"); } return locatorFilterColor("ft8"); } - function ensureDecodeContactPathRendered(entry) { - if (!entry || !aprsMap) return; + if (!aprsMap || !entry.from || !entry.to) return; const linePoints = [ [entry.from.lat, entry.from.lon], - [entry.to.lat, entry.to.lon], + [entry.to.lat, entry.to.lon] ]; const color = decodeContactPathColor(entry); if (!entry.line) { @@ -758,7 +693,7 @@ opacity: 0.78, className: "decode-contact-path", weight: 2.8, - interactive: false, + interactive: false }).addTo(aprsMap); } else { entry.line.setLatLngs(linePoints); @@ -770,33 +705,28 @@ const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; const icon = L.divIcon({ className: "decode-contact-distance-label", - html: `${escapeMapHtml(entry.distanceText)}`, + html: `${escapeMapHtml(entry.distanceText)}` }); if (!entry.labelMarker) { entry.labelMarker = L.marker([mid.lat, mid.lon], { icon, interactive: false, keyboard: false, - zIndexOffset: 900, + zIndexOffset: 900 }).addTo(aprsMap); } else { entry.labelMarker.setLatLng([mid.lat, mid.lon]); entry.labelMarker.setIcon(icon); if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); } - if (typeof entry.line.bringToBack === "function") entry.line.bringToBack(); + if (entry.line && typeof entry.line.bringToBack === "function") entry.line.bringToBack(); } - function decodeContactPathMatchesCurrentMap(entry) { - return decodeLocatorPathVisibility(entry.sourceGrid) - && decodeLocatorPathVisibility(entry.targetGrid); + return !!entry.sourceGrid && !!entry.targetGrid && decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); } - function decodeContactPathRenderVisible(entry) { - return mapDecodeContactPathsEnabled - && decodeContactPathMatchesCurrentMap(entry); + return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); } - function syncDecodeContactPathVisibility() { if (selectedMapQsoKey) { const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); @@ -805,8 +735,7 @@ } } for (const entry of decodeContactPaths.values()) { - const visible = decodeContactPathRenderVisible(entry) - && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); + const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); if (!visible) { clearDecodeContactPathRender(entry); continue; @@ -816,14 +745,12 @@ scheduleStatsRender(); updateMapPathsAnimationClass(); } - function _resolveReceiverLocations(rigIds) { - // Return all unique receiver locations for the given rig(s) - const seen = new Set(); + const seen = /* @__PURE__ */ new Set(); const locations = []; if (rigIds && rigIds.size) { for (const rid of rigIds) { - const rig = T.serverRigs.find(r => r.remote === rid); + const rig = T.serverRigs.find((r) => r.remote === rid); if (rig && rig.latitude != null && rig.longitude != null) { const key = _receiverLocationKey(rig.latitude, rig.longitude); if (!seen.has(key)) { @@ -833,13 +760,11 @@ } } } - // Fall back to active rig location if no specific locations found if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { locations.push([T.serverLat, T.serverLon]); } return locations; } - function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { clearMapRadioPath(); if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { @@ -855,7 +780,6 @@ ); } } - function locatorMarkerCenter(marker) { if (!marker) return null; if (typeof marker.getBounds === "function") { @@ -875,13 +799,11 @@ } return null; } - function setLocatorMarkerHighlight(marker, enabled) { const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; if (!element) return; element.classList.toggle("trx-locator-selected", !!enabled); } - function setSelectedLocatorMarker(marker) { if (selectedLocatorMarker && selectedLocatorMarker !== marker) { setLocatorMarkerHighlight(selectedLocatorMarker, false); @@ -897,17 +819,14 @@ startSelectedLocatorPulse(selectedLocatorMarker); } } - function isLocatorOverlay(marker) { const type = marker?.__trxType; return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; } - function sendLocatorOverlayToBack(marker) { if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; marker.bringToBack(); } - function renderMapLocatorChipRow(container, items, selectedSet, kind) { if (!container) return; container.replaceChildren(); @@ -920,7 +839,7 @@ const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); if (kind === "source") { if (noneSelected) { - helperText = "All sources visible \u2014 click to filter"; + helperText = "All sources visible — click to filter"; } } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; @@ -929,7 +848,7 @@ const btn = document.createElement("button"); btn.type = "button"; btn.className = "map-locator-chip"; - const isActive = kind === "source" ? !!mapFilter[item.key] : selectedSet.has(item.key); + const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key); if (kind === "source" && noneSelected) { btn.classList.add("is-default"); } else if (!isActive) { @@ -948,13 +867,12 @@ container.appendChild(hint); } } - function renderMapLocatorPhaseRow(container, phase) { if (!container) return; container.replaceChildren(); const phases = [ { key: "type", label: "Source" }, - { key: "band", label: "Band" }, + { key: "band", label: "Band" } ]; for (const item of phases) { const btn = document.createElement("button"); @@ -966,45 +884,37 @@ container.appendChild(btn); } } - function renderMapLocatorLegend(phase, sourceItems, bandItems) { - const legendEl = document.getElementById("map-band-legend"); + const legendEl = mapEl("map-band-legend"); if (!legendEl) return; const isSourcePhase = phase === "type"; - const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) - ? (isSourcePhase ? sourceItems : bandItems) - : []; + const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; if (items.length === 0) { legendEl.classList.add("is-empty"); legendEl.replaceChildren(); return; } legendEl.classList.remove("is-empty"); - const rows = items - .map((item) => { - const label = escapeMapHtml(item.label); - const color = escapeMapHtml(item.color); - return `${label}`; - }) - .join(""); + const rows = items.map((item) => { + const label = escapeMapHtml(item.label); + const color = escapeMapHtml(item.color); + return `${label}`; + }).join(""); const title = isSourcePhase ? "Source Colors" : "Band Colors"; legendEl.innerHTML = `
${title}
${rows}
`; } - - window.enableMapSourceFilter = function(key) { + mapWindow.enableMapSourceFilter = function(key) { if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { mapFilter[key] = true; rebuildMapLocatorFilters(); applyMapFilter(); } }; - function rebuildMapLocatorFilters() { - const phaseEl = document.getElementById("map-locator-phase"); - const choiceEl = document.getElementById("map-locator-choice-filter"); - const choiceLabelEl = document.getElementById("map-locator-choice-label"); - - const availableSources = new Set(); + const phaseEl = mapEl("map-locator-phase"); + const choiceEl = mapEl("map-locator-choice-filter"); + const choiceLabelEl = mapEl("map-locator-choice-label"); + const availableSources = /* @__PURE__ */ new Set(); for (const entry of aisMarkers.values()) { if (entry?.visibleInHistoryWindow) { availableSources.add("ais"); @@ -1023,13 +933,13 @@ break; } } - const bandMap = new Map(); + const bandMap = /* @__PURE__ */ new Map(); for (const entry of locatorMarkers.values()) { const sourceType = entry?.sourceType; if (!sourceType) continue; if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; availableSources.add(sourceType); - const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map(); + const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); for (const [label, hz] of meta.entries()) { if (!bandMap.has(label)) { bandMap.set(label, { @@ -1037,12 +947,12 @@ label, color: locatorBandChipColor(label), kind: "band", - sortHz: Number.isFinite(hz) ? hz : 0, + sortHz: Number.isFinite(hz) ? hz : 0 }); continue; } const existing = bandMap.get(label); - if (existing && Number.isFinite(hz) && (!Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { + if (existing && Number.isFinite(hz) && (existing.sortHz == null || !Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { existing.sortHz = hz; } if (existing && !existing.color) { @@ -1050,25 +960,18 @@ } } } - for (const key of Array.from(mapLocatorFilter.bands)) { if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); } - - const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] - .filter((key) => availableSources.has(key)) - .map((key) => ({ - key, - label: mapSourceLabel(key), - color: mapSourceColor(key), - kind: "source", - })); - const bandItems = Array.from(bandMap.values()) - .sort((a, b) => (b.sortHz - a.sortHz) || a.label.localeCompare(b.label)); - + const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + key, + label: mapSourceLabel(key), + color: mapSourceColor(key), + kind: "source" + })); + const bandItems = Array.from(bandMap.values()).sort((a, b) => (b.sortHz ?? 0) - (a.sortHz ?? 0) || a.label.localeCompare(b.label)); renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); if (!phaseEl || !choiceEl || !choiceLabelEl) return; - renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); if (mapLocatorFilter.phase === "band") { choiceLabelEl.textContent = "Visible Bands"; @@ -1080,7 +983,6 @@ syncLocatorMarkerStyles(); syncDecodeContactPathVisibility(); } - function markerPassesLocatorFilters(marker) { const meta = marker?._locatorFilterMeta; if (!meta) return true; @@ -1094,7 +996,6 @@ } return true; } - function markerSearchText(marker) { const type = marker?.__trxType; if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { @@ -1140,9 +1041,9 @@ msg?.name, msg?.callsign, msg?.destination, - Number.isFinite(msg?.mmsi) ? String(msg.mmsi) : "", - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "", + msg && Number.isFinite(msg.mmsi) ? String(msg.mmsi) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" ].join(" ").toLowerCase(); } if (type === "vdes") { @@ -1154,13 +1055,12 @@ msg?.mmsi, msg?.message, msg?.raw, - Number.isFinite(msg?.lat) ? String(msg.lat) : "", - Number.isFinite(msg?.lon) ? String(msg.lon) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" ].join(" ").toLowerCase(); } return ""; } - function markerPassesSearchFilter(marker) { const query = String(mapSearchFilter || "").trim().toLowerCase(); if (!query) return true; @@ -1170,33 +1070,28 @@ if (!haystack) return false; return terms.every((term) => haystack.includes(term)); } - function _receiverLocationKey(lat, lon) { return lat.toFixed(6) + "," + lon.toFixed(6); } - function syncAprsReceiverMarker() { if (!aprsMap) return; - // Build unique locations from all rigs - const locGroups = {}; // key -> { lat, lon, rigs: [...] } + const locGroups = {}; const activeId = T.lastActiveRigId || T.serverActiveRigId || null; for (const rig of T.serverRigs) { if (!rig || !rig.remote) continue; const lat = rig.latitude, lon = rig.longitude; if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; const key = _receiverLocationKey(lat, lon); - if (!locGroups[key]) locGroups[key] = { lat, lon, rigs: [], hasActive: false }; - locGroups[key].rigs.push(rig.remote); - if (rig.remote === activeId) locGroups[key].hasActive = true; + const group = locGroups[key] ??= { lat, lon, rigs: /* @__PURE__ */ new Set(), hasActive: false }; + group.rigs.add(rig.remote); + if (rig.remote === activeId) group.hasActive = true; } - // Fallback: if active rig has SSE location but isn't in T.serverRigs yet if (T.serverLat != null && T.serverLon != null) { const key = _receiverLocationKey(T.serverLat, T.serverLon); - if (!locGroups[key]) locGroups[key] = { lat: T.serverLat, lon: T.serverLon, rigs: [], hasActive: true }; - if (!locGroups[key].hasActive) locGroups[key].hasActive = true; + const group = locGroups[key] ??= { lat: T.serverLat, lon: T.serverLon, rigs: /* @__PURE__ */ new Set(), hasActive: true }; + group.hasActive = true; } - - const seen = new Set(); + const seen = /* @__PURE__ */ new Set(); let didInitialView = false; for (const [key, group] of Object.entries(locGroups)) { seen.add(key); @@ -1207,7 +1102,7 @@ m = L.circleMarker(latLng, { radius: isActive ? 8 : 6, className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), - fillOpacity: isActive ? 0.8 : 0.6, + fillOpacity: isActive ? 0.8 : 0.6 }).addTo(aprsMap).bindPopup(""); m._receiverLocKey = key; m._receiverRigs = group.rigs; @@ -1222,90 +1117,69 @@ m.setRadius(isActive ? 8 : 6); if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); } - // Keep legacy reference for the active-rig location marker - if (isActive) aprsMapReceiverMarker = m; } - // Remove markers for locations no longer present for (const key of Object.keys(aprsMapReceiverMarkers)) { if (!seen.has(key)) { const m = aprsMapReceiverMarkers[key]; if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); - delete aprsMapReceiverMarkers[key]; + Reflect.deleteProperty(aprsMapReceiverMarkers, key); } } - if (!seen.size) aprsMapReceiverMarker = null; } - - // --------------------------------------------------------------------------- - // Weather satellite image overlays on the map - // --------------------------------------------------------------------------- - - const satOverlays = new Map(); // key -> { overlay, track, msg } + const satOverlays = /* @__PURE__ */ new Map(); let satOverlaySeq = 0; - - window.addSatMapOverlay = function(msg) { - if (!msg || !msg.geo_bounds || !msg.path) return; + mapWindow.addSatMapOverlay = function(msg) { + if (!msg || !Array.isArray(msg.geo_bounds) || typeof msg.path !== "string") return; const bounds = msg.geo_bounds; - // bounds = [south, west, north, east] if (!Array.isArray(bounds) || bounds.length !== 4) return; const latLngBounds = L.latLngBounds( - [bounds[0], bounds[1]], // SW - [bounds[2], bounds[3]] // NE + [Number(bounds[0]), Number(bounds[1])], + // SW + [Number(bounds[2]), Number(bounds[3])] + // NE ); - const key = "sat-" + (++satOverlaySeq); + const key = `sat-${++satOverlaySeq}`; const overlay = L.imageOverlay(msg.path, latLngBounds, { opacity: 0.55, interactive: true, - zIndex: 300, + zIndex: 300 }); overlay.__trxType = "sat"; overlay.__trxSatKey = key; - overlay.__trxRigIds = msg.rig_id ? new Set([msg.rig_id]) : new Set(); + overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); overlay.__trxHistoryVisible = true; mapMarkers.add(overlay); - - // Build a popup for the overlay const decoder = "Meteor LRPT"; const satellite = msg.satellite || "Unknown"; const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; overlay.bindPopup( - `
` + - `${escapeMapHtml(decoder)}
` + - `${escapeMapHtml(satellite)}
` + - `${escapeMapHtml(ts)}
` + - (msg.path ? `Download PNG` : "") + - `
` + `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` ); - - // Add ground track polyline if available let track = null; if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { - const latlngs = msg.ground_track.map(function(pt) { return [pt[0], pt[1]]; }); + const latlngs = msg.ground_track.filter((pt) => Array.isArray(pt) && pt.length >= 2).map((pt) => [Number(pt[0]), Number(pt[1])]); track = L.polyline(latlngs, { color: mapSourceColor("sat"), weight: 2, opacity: 0.7, - dashArray: "6, 4", + dashArray: "6, 4" }); track.__trxType = "sat"; track.__trxSatKey = key; - track.__trxRigIds = overlay.__trxRigIds; + track.__trxRigIds = overlay.__trxRigIds ?? /* @__PURE__ */ new Set(); track.__trxHistoryVisible = true; mapMarkers.add(track); if (aprsMap) { track.addTo(aprsMap); } } - - satOverlays.set(key, { overlay: overlay, track: track, msg: msg }); - + satOverlays.set(key, { overlay, track, msg }); if (aprsMap) { overlay.addTo(aprsMap); } applyMapFilter(); }; - - window.removeSatMapOverlay = function(key) { + mapWindow.removeSatMapOverlay = function(key) { const entry = satOverlays.get(key); if (!entry) return; if (entry.overlay) { @@ -1318,14 +1192,12 @@ } satOverlays.delete(key); }; - - window.clearSatMapOverlays = function() { + mapWindow.clearSatMapOverlays = function() { for (const [key] of satOverlays) { - window.removeSatMapOverlay(key); + mapWindow.removeSatMapOverlay?.(key); } }; - - window.clearMapMarkersByType = function(type) { + mapWindow.clearMapMarkersByType = function(type) { if (type === "aprs") { selectedAprsTrackCall = null; stationMarkers.forEach((entry) => { @@ -1341,7 +1213,6 @@ stationMarkers.clear(); return; } - if (type === "ais") { aisMarkers.forEach((entry) => { if (entry && entry.marker) { @@ -1357,7 +1228,6 @@ aisMarkers.clear(); return; } - if (type === "vdes") { vdesMarkers.forEach((entry) => { if (entry && entry.marker) { @@ -1368,12 +1238,10 @@ vdesMarkers.clear(); return; } - if (type === "sat") { - window.clearSatMapOverlays(); + mapWindow.clearSatMapOverlays?.(); return; } - if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { const prefix = `${type}:`; for (const [key, entry] of locatorMarkers.entries()) { @@ -1391,7 +1259,6 @@ rebuildMapLocatorFilters(); rebuildDecodeContactPaths(); } - if (type === "bookmark") { for (const [key, entry] of locatorMarkers.entries()) { if (!key.startsWith("bookmark:")) continue; @@ -1408,7 +1275,6 @@ rebuildMapLocatorFilters(); } }; - function mapTileSpecForTheme(theme) { if (theme === "dark") { return { @@ -1416,19 +1282,18 @@ options: { maxZoom: 19, subdomains: "abcd", - attribution: '© OpenStreetMap © CARTO', - }, + attribution: '© OpenStreetMap © CARTO' + } }; } return { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", options: { maxZoom: 19, - attribution: '© OpenStreetMap', - }, + attribution: '© OpenStreetMap' + } }; } - function updateMapBaseLayerForTheme(theme) { if (!aprsMap) return; if (aprsMapBaseLayer) { @@ -1438,70 +1303,59 @@ const spec = mapTileSpecForTheme(theme); aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); } - function mapStageEl() { - return document.getElementById("map-stage"); + return mapEl("map-stage"); } - function mapIsFullscreen() { const stage = mapStageEl(); if (!stage) return false; - return document.fullscreenElement === stage - || document.webkitFullscreenElement === stage - || stage.classList.contains("map-fake-fullscreen"); + return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); } - function mapExitFakeFullscreen() { const stage = mapStageEl(); if (!stage) return; stage.classList.remove("map-fake-fullscreen"); document.body.classList.remove("map-fake-fullscreen-active"); } - function mapEnterFakeFullscreen() { const stage = mapStageEl(); if (!stage) return; stage.classList.add("map-fake-fullscreen"); document.body.classList.add("map-fake-fullscreen-active"); } - function updateMapFullscreenButton() { - const btn = document.getElementById("map-fullscreen-btn"); + const btn = mapEl("map-fullscreen-btn"); if (!btn) return; btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; } - function applyMapOverlayPanelVisibility() { const panel = document.querySelector("#map-stage .map-overlay-panel"); if (!panel) return; panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); } - function updateMapOverlayToggleButton() { - const btn = document.getElementById("map-overlay-toggle-btn"); + const btn = mapEl("map-overlay-toggle-btn"); if (!btn) return; btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; } - async function toggleMapFullscreen() { const stage = mapStageEl(); if (!stage) return; try { - const isNative = document.fullscreenElement === stage || document.webkitFullscreenElement === stage; - const isFake = stage.classList.contains("map-fake-fullscreen"); + const webkitDocument = document; + const isNative = document.fullscreenElement === stage || webkitDocument.webkitFullscreenElement === stage; + const isFake = stage.classList.contains("map-fake-fullscreen"); if (isNative) { if (document.exitFullscreen) await document.exitFullscreen(); - else if (document.webkitExitFullscreen) await document.webkitExitFullscreen(); + else if (webkitDocument.webkitExitFullscreen) await webkitDocument.webkitExitFullscreen(); } else if (isFake) { mapExitFakeFullscreen(); } else { - // Try native fullscreen; fall back to CSS fake fullscreen when the - // API is unavailable or blocked (e.g. mobile Safari). const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; if (nativeFn) { try { await nativeFn.call(stage); - } catch (_) { + } catch { mapEnterFakeFullscreen(); } } else { @@ -1512,22 +1366,23 @@ console.error("Map fullscreen toggle failed", err); } finally { updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + }); } } - - // Allow Escape to exit CSS fake fullscreen (native fullscreen handles its own Escape). document.addEventListener("keydown", (e) => { if (e.key === "Escape") { const stage = mapStageEl(); if (stage && stage.classList.contains("map-fake-fullscreen")) { mapExitFakeFullscreen(); updateMapFullscreenButton(); - requestAnimationFrame(() => sizeAprsMapToViewport()); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + }); } } }); - function materializeBufferedMapLayers() { if (!aprsMap) return; for (const [key, entry] of locatorMarkers) { @@ -1536,9 +1391,7 @@ if (!bounds) continue; entry.sourceType = "bookmark"; entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); - entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)) - .addTo(aprsMap) - .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); + entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); entry.marker.__trxType = "bookmark"; sendLocatorOverlayToBack(entry.marker); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); @@ -1546,39 +1399,29 @@ } pruneMapHistory(); } - let stageResizeObserver = null; function initAprsMap() { if (typeof L === "undefined") return; - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; + mapEl("aprs-map"); sizeAprsMapToViewport(); if (aprsMap) return; - const hasLocation = T.serverLat != null && T.serverLon != null; - const center = hasLocation ? [T.serverLat, T.serverLon] : [20, 0]; + const center = hasLocation && T.serverLat != null && T.serverLon != null ? [T.serverLat, T.serverLon] : [20, 0]; const zoom = hasLocation ? T.initialMapZoom : 2; - aprsMap = L.map("aprs-map").setView(center, zoom); - - // Observe the parent stage for size changes. Display:none → "" on the - // map tab, late-arriving content above the map, or any other layout - // shift would otherwise leave Leaflet's internal pane sized against - // stale dimensions until the user clicked the map. Observing the - // *parent* (not #aprs-map itself, which we resize) avoids feedback - // loops from sizeAprsMapToViewport's own height assignments. const stage = mapStageEl(); if (stage && typeof ResizeObserver !== "undefined") { if (stageResizeObserver) stageResizeObserver.disconnect(); - stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport()); + stageResizeObserver = new ResizeObserver(() => { + sizeAprsMapToViewport(); + }); stageResizeObserver.observe(stage); } updateMapBaseLayerForTheme(C.currentTheme()); syncAprsReceiverMarker(); - - // Rebuild popup content on open (keeps age/distance/rig list fresh) aprsMap.on("popupopen", function(e) { const marker = e.popup._source; + if (!marker) return; clearMapRadioPath(); setSelectedLocatorMarker(null); if (selectedAprsTrackCall) { @@ -1592,15 +1435,12 @@ selectedAisTrackMmsi = null; syncSelectedAisTrackVisibility(); } - if (marker._receiverLocKey) { e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); return; } - if (!marker) return; const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; - if (marker._aprsCall) { if (!ll) return; const entry = stationMarkers.get(marker._aprsCall); @@ -1614,7 +1454,6 @@ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); return; } - if (marker._aisMmsi) { if (!ll) return; const entry = aisMarkers.get(String(marker._aisMmsi)); @@ -1626,7 +1465,6 @@ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); return; } - if (marker._vdesKey) { if (!ll) return; const entry = vdesMarkers.get(String(marker._vdesKey)); @@ -1635,20 +1473,18 @@ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); return; } - if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { - const center = locatorMarkerCenter(marker); - if (center) { + const center2 = locatorMarkerCenter(marker); + if (center2) { setSelectedLocatorMarker(marker); const lEntry = locatorEntryForMarker(marker); - const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color : locatorFilterColor(marker.__trxType); - setMapRadioPathTo(center.lat, center.lon, lColor, "locator-radio-path", marker.__trxRigIds); + const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color ?? locatorFilterColor("ft8") : locatorFilterColor(marker.__trxType); + setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); } } else if (marker.__trxType === "bookmark") { setSelectedLocatorMarker(marker); } }); - aprsMap.on("popupclose", function() { clearMapRadioPath(); setSelectedLocatorMarker(null); @@ -1664,20 +1500,18 @@ syncSelectedAisTrackVisibility(); } }); - materializeBufferedMapLayers(); - - const locatorPhaseEl = document.getElementById("map-locator-phase"); - const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); - const mapSearchEl = document.getElementById("map-search-filter"); - const mapHistoryLimitEl = document.getElementById("map-history-limit"); - const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle"); - const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); - const fullscreenBtn = document.getElementById("map-fullscreen-btn"); - const overlayToggleBtn = document.getElementById("map-overlay-toggle-btn"); + const locatorPhaseEl = mapEl("map-locator-phase"); + const locatorChoiceEl = mapEl("map-locator-choice-filter"); + const mapSearchEl = mapEl("map-search-filter"); + const mapHistoryLimitEl = mapEl("map-history-limit"); + const mapP2pPathsToggleEl = mapEl("map-p2p-paths-toggle"); + const mapContactPathsToggleEl = mapEl("map-contact-paths-toggle"); + const fullscreenBtn = mapEl("map-fullscreen-btn"); + const overlayToggleBtn = mapEl("map-overlay-toggle-btn"); if (locatorPhaseEl) { locatorPhaseEl.addEventListener("click", (e) => { - const btn = e.target.closest(".map-locator-phase-btn[data-phase]"); + const btn = e.target?.closest(".map-locator-phase-btn[data-phase]"); if (!btn) return; const phase = String(btn.dataset.phase || ""); if (phase !== "type" && phase !== "band") return; @@ -1689,14 +1523,14 @@ } if (locatorChoiceEl) { locatorChoiceEl.addEventListener("click", (e) => { - const chip = e.target.closest(".map-locator-chip[data-filter-kind]"); + const chip = e.target?.closest(".map-locator-chip[data-filter-kind]"); if (!chip) return; const kind = String(chip.dataset.filterKind || ""); const key = String(chip.dataset.filterKey || ""); if (!key) return; if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { - // toggle the clicked source; when none are selected everything is shown - mapFilter[key] = !mapFilter[key]; + const sourceKey = key; + mapFilter[sourceKey] = !mapFilter[sourceKey]; const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); const anySelected = srcKeys.some((k) => mapFilter[k]); if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { @@ -1724,7 +1558,7 @@ applyMapFilter(); }); } - const mapRigFilterEl = document.getElementById("map-rig-filter"); + const mapRigFilterEl = mapEl("map-rig-filter"); if (mapRigFilterEl) { mapRigFilterEl.addEventListener("change", () => { mapRigFilter = mapRigFilterEl.value; @@ -1767,7 +1601,7 @@ } if (fullscreenBtn) { fullscreenBtn.addEventListener("click", () => { - toggleMapFullscreen(); + void toggleMapFullscreen(); }); updateMapFullscreenButton(); } @@ -1793,75 +1627,57 @@ if (!mapHistoryPruneTimer) { mapHistoryPruneTimer = setInterval(() => { pruneMapHistory(); - }, 60 * 1000); + }, 60 * 1e3); } rebuildMapLocatorFilters(); } - function sizeAprsMapToViewport() { - const mapEl = document.getElementById("aprs-map"); - if (!mapEl) return; + const mapContainer = mapEl("aprs-map"); const stage = mapStageEl(); if (mapIsFullscreen() && stage) { - // For CSS fake fullscreen use window.innerHeight directly — clientHeight - // may not yet reflect the fixed layout when called synchronously after - // adding the class. const isFake = stage.classList.contains("map-fake-fullscreen"); - const stageHeight = isFake - ? window.innerHeight - : (stage.clientHeight || stage.getBoundingClientRect().height); - const target = Math.max(260, Math.floor(stageHeight)); - mapEl.style.height = `${target}px`; + const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; + const target2 = Math.max(260, Math.floor(stageHeight)); + mapContainer.style.height = `${target2}px`; if (aprsMap) aprsMap.invalidateSize(); return; } - const mapRect = mapEl.getBoundingClientRect(); - const width = mapEl.clientWidth || mapRect.width; + const mapRect = mapContainer.getBoundingClientRect(); + const width = mapContainer.clientWidth || mapRect.width; const footer = document.querySelector(".footer"); - let bottom = mapIsFullscreen() && stage - ? stage.getBoundingClientRect().bottom - : window.innerHeight; + let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; if (!mapIsFullscreen() && footer) { const fr = footer.getBoundingClientRect(); if (fr.top > mapRect.top + 50) bottom = fr.top; } const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; - const viewportCap = mapIsFullscreen() - ? Math.floor(window.innerHeight * 0.9) - : Math.floor(window.innerHeight * 0.75); + const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); const minHeight = Math.min(260, available); const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); - mapEl.style.height = `${target}px`; + mapContainer.style.height = `${target}px`; if (aprsMap) aprsMap.invalidateSize(); } - function aprsSymbolIcon(symbolTable, symbolCode) { if (!symbolTable || !symbolCode) return null; - const sheet = symbolTable === "/" ? 0 : 1; - const code = symbolCode.charCodeAt(0) - 33; - const col = code % 16; - const row = Math.floor(code / 16); - const bgX = -(col * 24); - const bgY = -(row * 24); - const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`; + const table = symbolTable === "/" ? "primary" : "alternate"; return L.divIcon({ className: "", - html: `
`, + html: `
${escapeMapHtml(symbolCode)}
`, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -12] }); } - - window.navigateToAprsMap = function(lat, lon) { - // Activate the map tab + mapWindow.navigateToAprsMap = function(lat, lon) { T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = document.getElementById("tab-map"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); if (mapPanel) mapPanel.style.display = ""; initAprsMap(); sizeAprsMapToViewport(); @@ -1869,46 +1685,40 @@ requestAnimationFrame(() => { requestAnimationFrame(() => { sizeAprsMapToViewport(); - aprsMap.invalidateSize(); - aprsMap.setView([lat, lon], 13); + aprsMap?.invalidateSize(); + aprsMap?.setView([lat, lon], 13); }); }); } }; - - window.navigateToMapLocator = function(grid, preferredType = null) { + mapWindow.navigateToMapLocator = function(grid, preferredType = null) { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = document.getElementById("tab-map"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); sizeAprsMapToViewport(); if (!aprsMap) return false; - - const pref = preferredType === "wspr" ? "wspr" : (preferredType === "ft4" ? "ft4" : (preferredType === "ft2" ? "ft2" : (preferredType === "ft8" ? "ft8" : null))); - const keys = pref - ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] - : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; + const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; + const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; let entry = null; for (const key of keys) { entry = locatorMarkers.get(key); if (entry?.marker) break; } if (!entry?.marker) return false; - if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { mapFilter[pref] = true; rebuildMapLocatorFilters(); applyMapFilter(); } - const marker = entry.marker; if (!aprsMap.hasLayer(marker)) { marker.addTo(aprsMap); @@ -1924,7 +1734,8 @@ aprsMap.setView([center.lat, center.lon], targetZoom); if (marker.__trxType !== "bookmark") { const fEntry = locatorEntryForMarker(marker); - const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color : locatorFilterColor(marker?.__trxType); + const sourceType = marker.__trxType; + const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color ?? locatorFilterColor(sourceType) : locatorFilterColor(sourceType); setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); } } @@ -1937,14 +1748,6 @@ }); return true; }; - - - - - - - - function buildReceiverPopupHtml(rigIds) { const call = T.serverCallsign || T.ownerCallsign || "Receiver"; let meta = ""; @@ -1956,66 +1759,41 @@ if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; } - // Show location from first matching rig or active rig - const rigSet = rigIds && rigIds.length ? new Set(rigIds) : null; - const firstRig = rigSet ? T.serverRigs.find(r => rigSet.has(r.remote)) : null; + const rigList = rigIds ? Array.from(rigIds) : []; + const rigSet = rigList.length ? new Set(rigList) : null; + const firstRig = rigSet ? T.serverRigs.find((r) => !!r.remote && rigSet.has(r.remote)) : null; const popupLat = firstRig ? firstRig.latitude : T.serverLat; const popupLon = firstRig ? firstRig.longitude : T.serverLon; if (popupLat != null && popupLon != null) { const grid = latLonToMaidenhead(popupLat, popupLon); rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; } - // Show rigs at this location - const rigsToShow = rigSet - ? T.serverRigs.filter(r => rigSet.has(r.remote)) - : T.serverRigs; + const rigsToShow = rigSet ? T.serverRigs.filter((r) => !!r.remote && rigSet.has(r.remote)) : T.serverRigs; for (const rig of rigsToShow) { const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); - const active = rig.remote === T.serverActiveRigId - ? ` active` : ""; + const active = rig.remote === T.serverActiveRigId ? ` active` : ""; rows += `Rig${escapeMapHtml(name)}${active}`; } - return `
` + - `
${escapeMapHtml(call)}
` + - (meta ? `
${meta}
` : "") + - (rows ? `${rows}
` : "") + - `
`; + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; } - function buildAprsPopupHtml(call, lat, lon, info, pkt) { - const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : (pkt?._ts || null); - const distKm = (T.serverLat != null && T.serverLon != null) - ? haversineKm(T.serverLat, T.serverLon, lat, lon) - : null; - const distStr = distKm != null - ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`) - : null; + const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; + const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; const path = pkt?.path || null; const type = pkt?.type || null; - - let meta = [age, distStr].filter(Boolean).join(" · "); + const meta = [age, distStr].filter(Boolean).join(" · "); let rows = ""; if (type) rows += `Type${escapeMapHtml(type)}`; if (path) rows += `Path${escapeMapHtml(path)}`; if (lat != null && lon != null) rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; - - return `
` + - `
${escapeMapHtml(call)}
` + - (meta ? `
${meta}
` : "") + - (rows ? `${rows}
` : "") + - (info ? `
${escapeMapHtml(info)}
` : "") + - `
`; + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; } - function buildAisPopupHtml(msg) { const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; - const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null) - ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) - : null; - const distStr = distKm != null - ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`) - : null; + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); let rows = ""; rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; @@ -2028,31 +1806,19 @@ if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); - const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; - const vesselTitle = vesselUrl - ? `${vesselLabel}` - : vesselLabel; - return `
` + - `
${vesselTitle}
` + - (meta ? `
${meta}
` : "") + - (rows ? `${rows}
` : "") + - (info ? `
${info}
` : "") + - `
`; + const vesselUrl = mapWindow.buildAisVesselUrl ? mapWindow.buildAisVesselUrl(msg?.mmsi) : null; + const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; + return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; } - function buildVdesPopupHtml(msg) { - const age = formatTimeAgo(msg?.ts_ms); - const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null) - ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) - : null; - const distStr = distKm != null - ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`) - : null; + const age = msg.ts_ms == null ? "" : formatTimeAgo(msg.ts_ms); + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; const meta = [ age, distStr, msg?.message_label ? escapeMapHtml(msg.message_label) : null, - Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null, + Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null ].filter(Boolean).join(" · "); let rows = ""; if (distStr) rows += `Range${distStr} from TRX`; @@ -2066,35 +1832,27 @@ msg?.vessel_name, msg?.callsign, msg?.destination, - msg?.payload_preview, + msg?.payload_preview ].filter(Boolean).map(escapeMapHtml).join(" · "); const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); - return `
` + - `
${title}
` + - (meta ? `
${meta}
` : "") + - (rows ? `${rows}
` : "") + - (info ? `
${info}
` : "") + - `
`; + return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; } - function aprsPositionsEqual(a, b) { if (!a || !b) return false; const aLat = Array.isArray(a) ? a[0] : a.lat; const aLon = Array.isArray(a) ? a[1] : a.lon; const bLat = Array.isArray(b) ? b[0] : b.lat; const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; } - function aisPositionsEqual(a, b) { if (!a || !b) return false; const aLat = Array.isArray(a) ? a[0] : a.lat; const aLon = Array.isArray(a) ? a[1] : a.lon; const bLat = Array.isArray(b) ? b[0] : b.lat; const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; } - function vdesMarkerKey(msg) { if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; @@ -2103,24 +1861,24 @@ } return null; } - function _aprsAddMarkerToMap(call, entry) { + if (!aprsMap || entry.lat == null || entry.lon == null) return; refreshAprsTrack(call, entry); - const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode); + const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); - const marker = icon - ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) - : L.circleMarker([entry.lat, entry.lon], { - radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8 - }).addTo(aprsMap).bindPopup(popupContent); + const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { + radius: 6, + color: "#00d17f", + fillColor: "#00d17f", + fillOpacity: 0.8 + }).addTo(aprsMap).bindPopup(popupContent); marker.__trxType = "aprs"; - marker.__trxRigIds = entry.rigIds || new Set(); + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker._aprsCall = call; entry.marker = marker; mapMarkers.add(marker); } - - window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { const nextPoint = [lat, lon]; const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const msgRigId = pkt?.rig_id || T.lastActiveRigId; @@ -2133,7 +1891,7 @@ existing.symbolTable = symbolTable; existing.symbolCode = symbolCode; if (msgRigId) { - if (!existing.rigIds) existing.rigIds = new Set(); + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); existing.rigIds.add(msgRigId); } if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; @@ -2161,7 +1919,7 @@ info, symbolTable, symbolCode, - rigIds: new Set(msgRigId ? [msgRigId] : []), + rigIds: new Set(msgRigId ? [msgRigId] : []) }; stationMarkers.set(call, entry); pruneAprsEntry(call, entry, mapHistoryCutoffMs()); @@ -2169,28 +1927,26 @@ if (aprsMap) scheduleDecodeMapMaintenance(); } }; - function syncSelectedAisTrackVisibility() { if (!aprsMap) return; + const map = aprsMap; const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; aisMarkers.forEach((entry, key) => { const track = entry?.track; if (!track) return; const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; - const onMap = aprsMap.hasLayer(track); + const onMap = map.hasLayer(track); if (shouldShow && !onMap) { - track.addTo(aprsMap); + track.addTo(map); } if (!shouldShow && onMap) { - track.removeFrom(aprsMap); + track.removeFrom(map); } }); } - function getAisAccentColor() { return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; } - function aisMarkerOptionsFromMessage(msg) { const color = getAisAccentColor(); return { @@ -2199,10 +1955,9 @@ speed: msg?.sog_knots, color, outline: "#00000055", - size: 22, + size: 22 }; } - function createAisMarker(lat, lon, msg) { if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); @@ -2212,28 +1967,26 @@ radius: 6, color, fillColor: color, - fillOpacity: 0.82, + fillOpacity: 0.82 }); } - function updateAisMarker(marker, msg, popupHtml) { - if (!marker) return; + if (!marker || msg.lat == null || msg.lon == null) return; marker.setLatLng([msg.lat, msg.lon]); if (typeof marker.setAisState === "function") { marker.setAisState(aisMarkerOptionsFromMessage(msg)); } if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { const color = getAisAccentColor(); + marker.setRadius(6); marker.setStyle({ - radius: 6, color, fillColor: color, - fillOpacity: 0.84, + fillOpacity: 0.84 }); } marker.setPopupContent(popupHtml); } - function refreshAisMarkerColors() { const color = getAisAccentColor(); aisMarkers.forEach((entry) => { @@ -2249,8 +2002,7 @@ } }); } - - window.aisMapAddVessel = function(msg) { + mapWindow.aisMapAddVessel = function(msg) { if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; const key = String(msg.mmsi); const popupHtml = buildAisPopupHtml(msg); @@ -2261,7 +2013,7 @@ if (existing) { existing.msg = msg; if (msgRigId) { - if (!existing.rigIds) existing.rigIds = new Set(); + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); existing.rigIds.add(msgRigId); } if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; @@ -2277,33 +2029,32 @@ } return; } - aisMarkers.set(key, { + const entry = { marker: null, track: null, trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], trackPoints: [nextPoint], msg, - rigIds: new Set(msgRigId ? [msgRigId] : []), - }); - pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs()); - if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key)); + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + aisMarkers.set(key, entry); + pruneAisEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAisMarker(key, entry); scheduleDecodeMapMaintenance(); }; - - window.vdesMapAddPoint = function(msg) { + mapWindow.vdesMapAddPoint = function(msg) { if (msg == null || msg.lat == null || msg.lon == null) return; const key = vdesMarkerKey(msg); if (!key) return; const popupHtml = buildVdesPopupHtml(msg); - const visible = Number.isFinite(Number(msg?._tsMs)) - && Number(msg._tsMs) >= mapHistoryCutoffMs(); + const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); const msgRigId = msg?.rig_id || T.lastActiveRigId; const existing = vdesMarkers.get(key); if (existing) { existing.msg = msg; existing.visibleInHistoryWindow = visible; if (msgRigId) { - if (!existing.rigIds) existing.rigIds = new Set(); + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); existing.rigIds.add(msgRigId); } if (!visible) { @@ -2330,7 +2081,7 @@ marker: null, msg, visibleInHistoryWindow: visible, - rigIds: new Set(msgRigId ? [msgRigId] : []), + rigIds: new Set(msgRigId ? [msgRigId] : []) }; vdesMarkers.set(key, entry); if (!visible) return; @@ -2345,46 +2096,39 @@ } scheduleDecodeMapMaintenance(); }; - let reverseGeocodeLastKey = null; function reverseGeocodeLocation(lat, lon, grid) { const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; if (key === reverseGeocodeLastKey) return; reverseGeocodeLastKey = key; const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; - fetch(url, { headers: { "User-Agent": "trx-rs" } }) - .then((r) => r.ok ? r.json() : Promise.reject(r.status)) - .then((data) => { - const addr = data?.address; - if (!addr) return; - const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; - const country = addr.country || ""; - if (!city && !country) return; - const label = city && country ? `${city}, ${country}` : (city || country); - T.lastCityLabel = label; - if (T.locationSubtitle) { - T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; - } - C.updateDocumentTitle(C.activeChannelRds()); - }) - .catch(() => {}); + fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Reverse geocode failed: ${r.status}`))).then((data) => { + const addr = data?.address; + if (!addr) return; + const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; + const country = addr.country || ""; + if (!city && !country) return; + const label = city && country ? `${city}, ${country}` : city || country; + T.lastCityLabel = label; + if (T.locationSubtitle) { + T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; + } + C.updateDocumentTitle?.(); + }).catch(() => { + }); } - - function maidenheadToBounds(grid) { if (!grid || grid.length < 4) return null; const g = grid.toUpperCase(); const A = "A".charCodeAt(0); const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; - const squareLon = parseInt(g[2], 10) * 2; - const squareLat = parseInt(g[3], 10) * 1; - + const squareLon = parseInt(g.charAt(2), 10) * 2; + const squareLat = parseInt(g.charAt(3), 10); let lon = fieldLon + squareLon; let lat = fieldLat + squareLat; let lonSpan = 2; let latSpan = 1; - if (g.length >= 6) { const subLon = (g.charCodeAt(4) - A) * (5 / 60); const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); @@ -2393,55 +2137,43 @@ lonSpan = 5 / 60; latSpan = 2.5 / 60; } - return [ [lat, lon], - [lat + latSpan, lon + lonSpan], + [lat + latSpan, lon + lonSpan] ]; } - function applyMapFilter() { if (!aprsMap) return; + const map = aprsMap; const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); const noneSelected = sourceKeys.every((k) => !mapFilter[k]); mapMarkers.forEach((marker) => { const type = marker.__trxType; - const sourceVisible = noneSelected - ? DEFAULT_MAP_SOURCE_FILTER[type] !== undefined ? DEFAULT_MAP_SOURCE_FILTER[type] : true - : !!mapFilter[type]; - const rigVisible = !mapRigFilter - || marker.__trxType === "bookmark" - || (marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter)); - const visible = marker.__trxHistoryVisible !== false - && markerPassesSearchFilter(marker) - && markerPassesLocatorFilters(marker) - && sourceVisible - && rigVisible; - const onMap = aprsMap.hasLayer(marker); + const sourceVisible = noneSelected ? type == null || DEFAULT_MAP_SOURCE_FILTER[type] : type == null || !!mapFilter[type]; + const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); + const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; + const onMap = map.hasLayer(marker); if (visible && !onMap) { - marker.addTo(aprsMap); + marker.addTo(map); sendLocatorOverlayToBack(marker); } - if (!visible && onMap) marker.removeFrom(aprsMap); + if (!visible && onMap) marker.removeFrom(map); }); syncSelectedAisTrackVisibility(); syncDecodeContactPathVisibility(); } - function updateMapContactPathsToggle() { - const btn = document.getElementById("map-contact-paths-toggle"); + const btn = mapEl("map-contact-paths-toggle"); if (!btn) return; btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); } - function updateMapP2pPathsToggle() { - const btn = document.getElementById("map-p2p-paths-toggle"); + const btn = mapEl("map-p2p-paths-toggle"); if (!btn) return; btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); } - function scheduleDecodeMapMaintenance() { if (C.decodeHistoryMapRenderingDeferred()) { C.markDecodeMapSyncPending(); @@ -2453,83 +2185,56 @@ applyMapFilter(); }); } - function formatDecodeLocatorTime(tsMs) { if (!Number.isFinite(tsMs)) return "--:--:--"; return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", - second: "2-digit", + second: "2-digit" }); } - function formatMapPopupFreq(hz) { if (!Number.isFinite(hz)) return "--"; const value = Number(hz); - if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(6).replace(/\.?0+$/, "")} GHz`; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(6).replace(/\.?0+$/, "")} MHz`; - if (value >= 1_000) return `${(value / 1_000).toFixed(3).replace(/\.?0+$/, "")} kHz`; + if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; + if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; + if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; return `${Math.round(value)} Hz`; } - function buildDecodeLocatorTooltipHtml(grid, entry, type) { - const details = entry?.stationDetails instanceof Map - ? Array.from(entry.stationDetails.values()) - : []; + const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); const title = type === "wspr" ? "WSPR" : "FT8"; - const rows = details - .map((detail) => { - const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); - const freq = formatMapPopupFreq(Number(detail?.freq_hz)); - const meta = [ - detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, - Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, - Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, - escapeMapHtml(freq), - ].filter(Boolean).join(" · "); - const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 - ? Array.from(detail.remotes) - : (detail?.remote ? [detail.remote] : []); - const rxHtml = remoteIds - .map(rid => { - const label = _receiverLabel(rid); - return label ? `
${escapeMapHtml(label)}
` : ""; - }) - .filter(Boolean) - .join(""); - const message = detail?.message - ? `
${escapeMapHtml(String(detail.message))}
` - : ""; - return `
` + - `
` + - `${station}` + - `${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}` + - `
` + - (meta ? `
${meta}
` : "") + - rxHtml + - message + - `
`; - }) - .join(""); + const rows = details.map((detail) => { + const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); + const freq = formatMapPopupFreq(Number(detail?.freq_hz)); + const meta = [ + detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, + Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, + Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, + escapeMapHtml(freq) + ].filter(Boolean).join(" · "); + const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; + const rxHtml = remoteIds.map((rid) => { + const label = _receiverLabel(rid); + return label ? `
${escapeMapHtml(label)}
` : ""; + }).filter(Boolean).join(""); + const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; + return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; + }).join(""); const count = Math.max( 1, details.length, - entry?.stations instanceof Set ? entry.stations.size : 0, + entry?.stations instanceof Set ? entry.stations.size : 0 ); - return `
` + - `
${escapeMapHtml(grid)}
` + - `
${title} · ${count} station${count === 1 ? "" : "s"}
` + - rows + - `
`; + return `
${escapeMapHtml(grid)}
${title} · ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; } - function rebuildDecodeContactPaths() { clearDecodeContactPaths(); - const stationLocators = new Map(); + const stationLocators = /* @__PURE__ */ new Map(); const directedMessages = []; for (const entry of locatorMarkers.values()) { - if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; const grid = String(entry.grid || "").trim().toUpperCase(); if (!grid || !(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { @@ -2551,7 +2256,7 @@ sourceType: entry.sourceType, tsMs, bandLabel: band?.label || null, - remote: detail?.remote || null, + remote: detail?.remote || null }); } } @@ -2566,7 +2271,7 @@ const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); const key = [msg.source, msg.target].sort().join("::"); const prev = decodeContactPaths.get(key); - if (prev && prev.tsMs > msg.tsMs) continue; + if (prev?.tsMs != null && prev.tsMs > msg.tsMs) continue; decodeContactPaths.set(key, { pathKey: key, source: msg.source, @@ -2582,15 +2287,14 @@ distanceText: formatDecodeContactDistance(distanceKm), line: null, labelMarker: null, - remote: msg.remote, + remote: msg.remote }); } syncDecodeContactPathVisibility(); } - function _receiverLabel(rigId) { if (!rigId) return null; - const rig = T.serverRigs.find(r => r.remote === rigId); + const rig = T.serverRigs.find((r) => r.remote === rigId); const name = T.lastRigDisplayNames[rigId] || rigId; if (rig && rig.latitude != null && rig.longitude != null) { const grid = latLonToMaidenhead(rig.latitude, rig.longitude); @@ -2598,38 +2302,18 @@ } return name; } - - function _locatorEntryVisibleOnMap(entry) { - return entry?.marker && aprsMap && aprsMap.hasLayer(entry.marker); - } - - function _detailPassesRigFilter(detail) { - if (!mapRigFilter) return true; - if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter); - return detail?.remote === mapRigFilter; - } - function renderMapQsoSummary() { - const listEl = document.getElementById("map-qso-summary-list"); + const listEl = mapEl("map-qso-summary-list"); if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const entries = Array.from(decodeContactPaths.values()) - .filter((entry) => entry - && Number.isFinite(entry.distanceKm) - && _statsDetailPassesRigFilter(entry) - && (!entry.tsMs || entry.tsMs >= cutoff)) - .sort((a, b) => { - const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); - if (Math.abs(distanceDelta) > 0.001) return distanceDelta; - return Number(b.tsMs || 0) - Number(a.tsMs || 0); - }) - .slice(0, MAP_QSO_SUMMARY_LIMIT); - + const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { + const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); + if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; + return Number(b.tsMs || 0) - Number(a.tsMs || 0); + }).slice(0, MAP_QSO_SUMMARY_LIMIT); if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { selectedMapQsoKey = null; } - if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "map-qso-summary-empty"; @@ -2637,7 +2321,6 @@ listEl.replaceChildren(empty); return; } - const fragment = document.createDocumentFragment(); entries.forEach((entry, index) => { const card = document.createElement("button"); @@ -2646,42 +2329,34 @@ card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); card.addEventListener("click", () => { - selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey; + selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; syncDecodeContactPathVisibility(); if (selectedMapQsoKey && entry.sourceGrid) { - navigateToMapLocator(entry.sourceGrid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); } }); - const head = document.createElement("div"); head.className = "map-qso-card-head"; - const rank = document.createElement("span"); rank.className = "map-qso-card-rank"; rank.textContent = `#${index + 1}`; head.appendChild(rank); - const distance = document.createElement("span"); distance.className = "map-qso-card-distance"; distance.textContent = entry.distanceText || "--"; head.appendChild(distance); - const body = document.createElement("div"); body.className = "map-qso-card-body"; - const pair = document.createElement("div"); pair.className = "map-qso-card-pair"; pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; body.appendChild(pair); - const meta = document.createElement("div"); meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); sourceType.className = "map-qso-card-pill"; sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); meta.appendChild(sourceType); - if (entry.bandLabel) { const band = document.createElement("span"); band.className = "map-qso-card-pill map-qso-card-band"; @@ -2689,7 +2364,6 @@ band.textContent = entry.bandLabel; meta.appendChild(band); } - const ageText = formatTimeAgo(Number(entry.tsMs)); if (ageText) { const age = document.createElement("span"); @@ -2697,38 +2371,31 @@ age.textContent = ageText; meta.appendChild(age); } - - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; rx.textContent = rxLabel; meta.appendChild(rx); } - body.appendChild(meta); - const grids = document.createElement("div"); grids.className = "map-qso-card-grids"; grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; body.appendChild(grids); - card.appendChild(head); card.appendChild(body); fragment.appendChild(card); }); - listEl.replaceChildren(fragment); } - function renderMapSignalSummary() { - const listEl = document.getElementById("map-signal-summary-list"); + const listEl = mapEl("map-signal-summary-list"); if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const bestByStation = new Map(); + const bestByStation = /* @__PURE__ */ new Map(); for (const entry of locatorMarkers.values()) { - if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; if (!(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { if (!Number.isFinite(detail?.snr_db)) continue; @@ -2739,7 +2406,7 @@ const snrDb = Number(detail.snr_db); const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; const prev = bestByStation.get(station); - if (!prev || snrDb > prev.snrDb || (snrDb === prev.snrDb && tsMs > prev.tsMs)) { + if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { bestByStation.set(station, { station, snrDb, @@ -2747,20 +2414,16 @@ grid: entry.grid, sourceType: entry.sourceType, bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null, + remote: detail?.remote || null }); } } } - - const entries = Array.from(bestByStation.values()) - .sort((a, b) => { - const delta = b.snrDb - a.snrDb; - if (Math.abs(delta) > 0.001) return delta; - return b.tsMs - a.tsMs; - }) - .slice(0, MAP_QSO_SUMMARY_LIMIT); - + const entries = Array.from(bestByStation.values()).sort((a, b) => { + const delta = b.snrDb - a.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "map-qso-summary-empty"; @@ -2768,7 +2431,6 @@ listEl.replaceChildren(empty); return; } - const fragment = document.createDocumentFragment(); entries.forEach((entry, index) => { const card = document.createElement("button"); @@ -2776,39 +2438,31 @@ card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); }); } - const head = document.createElement("div"); head.className = "map-qso-card-head"; - const rank = document.createElement("span"); rank.className = "map-qso-card-rank"; rank.textContent = `#${index + 1}`; head.appendChild(rank); - const snr = document.createElement("span"); snr.className = "map-qso-card-distance"; snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; head.appendChild(snr); - const body = document.createElement("div"); body.className = "map-qso-card-body"; - const pair = document.createElement("div"); pair.className = "map-qso-card-pair"; pair.textContent = entry.station; body.appendChild(pair); - const meta = document.createElement("div"); meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); sourceType.className = "map-qso-card-pill"; sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); meta.appendChild(sourceType); - if (entry.bandLabel) { const band = document.createElement("span"); band.className = "map-qso-card-pill map-qso-card-band"; @@ -2816,7 +2470,6 @@ band.textContent = entry.bandLabel; meta.appendChild(band); } - const ageText = formatTimeAgo(Number(entry.tsMs)); if (ageText) { const age = document.createElement("span"); @@ -2824,37 +2477,31 @@ age.textContent = ageText; meta.appendChild(age); } - - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; rx.textContent = rxLabel; meta.appendChild(rx); } - body.appendChild(meta); - const grids = document.createElement("div"); grids.className = "map-qso-card-grids"; grids.textContent = entry.grid || "--"; body.appendChild(grids); - card.appendChild(head); card.appendChild(body); fragment.appendChild(card); }); listEl.replaceChildren(fragment); } - function renderMapWeakSignalSummary() { - const listEl = document.getElementById("map-weak-signal-summary-list"); + const listEl = mapEl("map-weak-signal-summary-list"); if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const worstByStation = new Map(); + const worstByStation = /* @__PURE__ */ new Map(); for (const entry of locatorMarkers.values()) { - if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; if (!(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { if (!Number.isFinite(detail?.snr_db)) continue; @@ -2865,7 +2512,7 @@ const snrDb = Number(detail.snr_db); const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; const prev = worstByStation.get(station); - if (!prev || snrDb < prev.snrDb || (snrDb === prev.snrDb && tsMs > prev.tsMs)) { + if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { worstByStation.set(station, { station, snrDb, @@ -2873,20 +2520,16 @@ grid: entry.grid, sourceType: entry.sourceType, bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null, + remote: detail?.remote || null }); } } } - - const entries = Array.from(worstByStation.values()) - .sort((a, b) => { - const delta = a.snrDb - b.snrDb; - if (Math.abs(delta) > 0.001) return delta; - return b.tsMs - a.tsMs; - }) - .slice(0, MAP_QSO_SUMMARY_LIMIT); - + const entries = Array.from(worstByStation.values()).sort((a, b) => { + const delta = a.snrDb - b.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); if (entries.length === 0) { const empty = document.createElement("div"); empty.className = "map-qso-summary-empty"; @@ -2894,7 +2537,6 @@ listEl.replaceChildren(empty); return; } - const fragment = document.createDocumentFragment(); entries.forEach((entry, index) => { const card = document.createElement("button"); @@ -2902,39 +2544,31 @@ card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - navigateToMapLocator(entry.grid, entry.sourceType); + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); }); } - const head = document.createElement("div"); head.className = "map-qso-card-head"; - const rank = document.createElement("span"); rank.className = "map-qso-card-rank"; rank.textContent = `#${index + 1}`; head.appendChild(rank); - const snr = document.createElement("span"); snr.className = "map-qso-card-distance"; snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; head.appendChild(snr); - const body = document.createElement("div"); body.className = "map-qso-card-body"; - const pair = document.createElement("div"); pair.className = "map-qso-card-pair"; pair.textContent = entry.station; body.appendChild(pair); - const meta = document.createElement("div"); meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); sourceType.className = "map-qso-card-pill"; sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); meta.appendChild(sourceType); - if (entry.bandLabel) { const band = document.createElement("span"); band.className = "map-qso-card-pill map-qso-card-band"; @@ -2942,7 +2576,6 @@ band.textContent = entry.bandLabel; meta.appendChild(band); } - const ageText = formatTimeAgo(Number(entry.tsMs)); if (ageText) { const age = document.createElement("span"); @@ -2950,60 +2583,57 @@ age.textContent = ageText; meta.appendChild(age); } - - const rxLabel = _receiverLabel(entry.remote); + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; if (rxLabel) { const rx = document.createElement("span"); rx.className = "map-qso-card-pill map-qso-card-rx"; rx.textContent = rxLabel; meta.appendChild(rx); } - body.appendChild(meta); - const grids = document.createElement("div"); grids.className = "map-qso-card-grids"; grids.textContent = entry.grid || "--"; body.appendChild(grids); - card.appendChild(head); card.appendChild(body); fragment.appendChild(card); }); listEl.replaceChildren(fragment); } - - // ── Statistics panel ───────────────────────────────────────────────── let statsRigFilter = ""; let statsHistoryLimitMinutes = 1440; - const statsDecodeLog = []; // {type, ts_ms, remote} - const STATS_LOG_MAX = 50000; + const statsDecodeLog = []; + const STATS_LOG_MAX = 5e4; const STATS_TYPE_COLORS = { - ft8: "#4fc3f7", ft4: "#81c784", ft2: "#aed581", wspr: "#ffb74d", - aprs: "#ce93d8", hf_aprs: "#ba68c8", ais: "#90a4ae", vdes: "#78909c", - cw: "#fff176", + ft8: "#4fc3f7", + ft4: "#81c784", + ft2: "#aed581", + wspr: "#ffb74d", + aprs: "#ce93d8", + hf_aprs: "#ba68c8", + ais: "#90a4ae", + vdes: "#78909c", + cw: "#fff176" }; const STATS_DX_BUCKETS = [ { label: "0–500 km", min: 0, max: 500 }, - { label: "500–1k", min: 500, max: 1000 }, - { label: "1k–2k", min: 1000, max: 2000 }, - { label: "2k–5k", min: 2000, max: 5000 }, - { label: "5k–10k", min: 5000, max: 10000 }, - { label: "10k+ km", min: 10000, max: Infinity }, + { label: "500–1k", min: 500, max: 1e3 }, + { label: "1k–2k", min: 1e3, max: 2e3 }, + { label: "2k–5k", min: 2e3, max: 5e3 }, + { label: "5k–10k", min: 5e3, max: 1e4 }, + { label: "10k+ km", min: 1e4, max: Infinity } ]; - function _statsHistoryCutoffMs() { - return Date.now() - (statsHistoryLimitMinutes * 60 * 1000); + return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; } - function _statsDetailPassesRigFilter(detail) { if (!statsRigFilter) return true; if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); return detail?.remote === statsRigFilter; } - function updateStatsRigFilter() { - const el = document.getElementById("stats-rig-filter"); + const el = mapEl("stats-rig-filter"); if (!el) return; const prev = el.value; while (el.options.length > 1) el.remove(1); @@ -3020,14 +2650,12 @@ statsRigFilter = ""; } } - function statsRecordDecode(type, remote, tsMs) { statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); if (statsDecodeLog.length > STATS_LOG_MAX) { statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); } } - function _statsFilteredLog() { const cutoff = _statsHistoryCutoffMs(); return statsDecodeLog.filter((e) => { @@ -3036,14 +2664,12 @@ return true; }); } - function renderStatsCounters() { const cutoff = _statsHistoryCutoffMs(); const log = _statsFilteredLog(); const totalDecodes = log.length; - - const uniqueStations = new Set(); - const uniqueGrids = new Set(); + const uniqueStations = /* @__PURE__ */ new Set(); + const uniqueGrids = /* @__PURE__ */ new Set(); for (const entry of locatorMarkers.values()) { if (!entry || !(entry.stationDetails instanceof Map)) continue; for (const detail of entry.stationDetails.values()) { @@ -3059,13 +2685,10 @@ if (hasVisible) uniqueGrids.add(entry.grid); } } - - // Decode rate: decodes in last 60 seconds → per minute - const rateWindow = Date.now() - 60000; + const rateWindow = Date.now() - 6e4; const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; - const setEl = (id, val) => { - const el = document.getElementById(id); + const el = mapEl(id); if (el) el.textContent = String(val); }; setEl("stats-total-decodes", totalDecodes.toLocaleString()); @@ -3073,9 +2696,8 @@ setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); setEl("stats-decode-rate", recentCount.toLocaleString()); } - function _renderBarChart(containerId, data, emptyMsg) { - const el = document.getElementById(containerId); + const el = mapEl(containerId); if (!el) return; if (!data || data.length === 0 || data.every((d) => d.count === 0)) { el.replaceChildren(); @@ -3090,47 +2712,39 @@ for (const item of data) { const row = document.createElement("div"); row.className = "stats-bar-row"; - const label = document.createElement("span"); label.className = "stats-bar-label"; label.textContent = item.label; row.appendChild(label); - const track = document.createElement("div"); track.className = "stats-bar-track"; const fill = document.createElement("div"); fill.className = "stats-bar-fill"; - fill.style.width = `${(item.count / maxVal) * 100}%`; + fill.style.width = `${item.count / maxVal * 100}%`; fill.style.background = item.color || "var(--accent-green)"; track.appendChild(fill); row.appendChild(track); - const count = document.createElement("span"); count.className = "stats-bar-count"; count.textContent = item.count.toLocaleString(); row.appendChild(count); - fragment.appendChild(row); } el.replaceChildren(fragment); } - function renderStatsDecodeTypes() { const log = _statsFilteredLog(); const counts = {}; for (const e of log) { counts[e.type] = (counts[e.type] || 0) + 1; } - const data = Object.entries(counts) - .map(([type, count]) => ({ - label: type.toUpperCase(), - count, - color: STATS_TYPE_COLORS[type] || "#aaa", - })) - .sort((a, b) => b.count - a.count); + const data = Object.entries(counts).map(([type, count]) => ({ + label: type.toUpperCase(), + count, + color: STATS_TYPE_COLORS[type] || "#aaa" + })).sort((a, b) => b.count - a.count); _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); } - function renderStatsBandActivity() { const cutoff = _statsHistoryCutoffMs(); const bandCounts = {}; @@ -3146,18 +2760,15 @@ } } } - const data = Object.entries(bandCounts) - .map(([label, count]) => ({ - label, - count, - color: locatorBandChipColor(label), - })) - .sort((a, b) => b.count - a.count); + const data = Object.entries(bandCounts).map(([label, count]) => ({ + label, + count, + color: locatorBandChipColor(label) + })).sort((a, b) => b.count - a.count); _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); } - function renderStatsRigCompare() { - const section = document.getElementById("stats-rig-compare-section"); + const section = mapEl("stats-rig-compare-section"); if (!section) return; if (T.lastRigIds.length < 2) { section.style.display = "none"; @@ -3171,16 +2782,13 @@ const rid = e.remote || "unknown"; rigCounts[rid] = (rigCounts[rid] || 0) + 1; } - const data = Object.entries(rigCounts) - .map(([rid, count]) => ({ - label: T.lastRigDisplayNames[rid] || rid, - count, - color: "var(--accent-green)", - })) - .sort((a, b) => b.count - a.count); + const data = Object.entries(rigCounts).map(([rid, count]) => ({ + label: T.lastRigDisplayNames[rid] || rid, + count, + color: "var(--accent-green)" + })).sort((a, b) => b.count - a.count); _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); } - function renderStatsDxHistogram() { const cutoff = _statsHistoryCutoffMs(); const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); @@ -3189,25 +2797,28 @@ if (entry.tsMs && entry.tsMs < cutoff) continue; if (!_statsDetailPassesRigFilter(entry)) continue; const km = entry.distanceKm; + if (km == null) continue; for (const b of buckets) { - if (km >= b.min && km < b.max) { b.count++; break; } + if (km >= b.min && km < b.max) { + b.count++; + break; + } } } const data = buckets.map((b) => ({ label: b.label, count: b.count, - color: "#4fc3f7", + color: "#4fc3f7" })); _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); } - let _statsRenderPending = false; let _statsControlsWired = false; function _wireStatsControls() { if (_statsControlsWired) return; - const rigEl = document.getElementById("stats-rig-filter"); - const histEl = document.getElementById("stats-history-limit"); - if (!rigEl && !histEl) return; // template not yet cloned + const rigEl = mapEl("stats-rig-filter"); + const histEl = mapEl("stats-history-limit"); + if (!rigEl && !histEl) return; _statsControlsWired = true; if (rigEl) { rigEl.addEventListener("change", () => { @@ -3239,25 +2850,19 @@ renderMapWeakSignalSummary(); }); } - function buildBookmarkLocatorPopupHtml(grid, bookmarks) { const list = Array.isArray(bookmarks) ? bookmarks : []; - const rows = list - .map((bm) => { - const title = escapeMapHtml(String(bm.name || "Bookmark")); - const freq = typeof bmFmtFreq === "function" - ? escapeMapHtml(bmFmtFreq(bm.freq_hz)) - : escapeMapHtml(String(bm.freq_hz || "--")); - const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; - return `${title} ${freq}${mode}`; - }) - .join("
"); + const rows = list.map((bm) => { + const title = escapeMapHtml(String(bm.name || "Bookmark")); + const freq = escapeMapHtml(formatFreqForHumans(Number(bm.freq_hz))); + const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; + return `${title} ${freq}${mode}`; + }).join("
"); return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); } - - window.syncBookmarkMapLocators = function(bookmarks) { + mapWindow.syncBookmarkMapLocators = function(bookmarks) { const list = Array.isArray(bookmarks) ? bookmarks : []; - const grouped = new Map(); + const grouped = /* @__PURE__ */ new Map(); for (const bm of list) { const grid = String(bm?.locator || "").trim().toUpperCase(); if (!grid) continue; @@ -3271,7 +2876,6 @@ grouped.set(key, { grid, bounds, bookmarks: [bm] }); } } - for (const [key, entry] of locatorMarkers.entries()) { if (!key.startsWith("bookmark:")) continue; if (!grouped.has(key)) { @@ -3286,7 +2890,6 @@ locatorMarkers.delete(key); } } - for (const [key, next] of grouped.entries()) { const existing = locatorMarkers.get(key); const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); @@ -3306,38 +2909,33 @@ } continue; } - const entry = { marker: null, grid: next.grid, bounds: next.bounds, bookmarks: next.bookmarks, sourceType: "bookmark", - bandMeta, + bandMeta }; locatorMarkers.set(key, entry); if (aprsMap) { - entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)) - .addTo(aprsMap) - .bindPopup(popupHtml); + entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); entry.marker.__trxType = "bookmark"; sendLocatorOverlayToBack(entry.marker); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + assignLocatorMarkerMeta(entry.marker, entry.sourceType ?? "bookmark", entry.bandMeta); mapMarkers.add(entry.marker); } } - rebuildMapLocatorFilters(); applyMapFilter(); }; - - window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { + mapWindow.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { if (!Array.isArray(grids) || grids.length === 0) return; - const markerType = type === "wspr" ? "wspr" : (type === "ft4" ? "ft4" : (type === "ft2" ? "ft2" : "ft8")); + const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; const msgRigId = details?.rig_id || T.lastActiveRigId; const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; - const locatorDetails = new Map(); + const locatorDetails = /* @__PURE__ */ new Map(); if (Array.isArray(details?.locator_details)) { for (const locatorDetail of details.locator_details) { const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); @@ -3349,24 +2947,20 @@ const bounds = maidenheadToBounds(grid); if (!bounds) continue; const locatorDetail = locatorDetails.get(grid); - const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() - ? String(locatorDetail.source).trim().toUpperCase() - : ""; - const targetId = locatorDetail?.target && String(locatorDetail.target).trim() - ? String(locatorDetail.target).trim().toUpperCase() - : ""; + const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; + const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; const detailStationId = sourceId || stationId; const detailEntry = { station: detailStationId || null, source: sourceId || null, target: targetId || null, - ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, - snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, - dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, - freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, + ts_ms: details && Number.isFinite(details.ts_ms) ? Number(details.ts_ms) : null, + snr_db: details && Number.isFinite(details.snr_db) ? Number(details.snr_db) : null, + dt_s: details && Number.isFinite(details.dt_s) ? Number(details.dt_s) : null, + freq_hz: details && Number.isFinite(details.freq_hz) ? Number(details.freq_hz) : null, message: String(details?.message || message || "").trim() || null, remote: msgRigId || null, - remotes: new Set(msgRigId ? [msgRigId] : []), + remotes: new Set(msgRigId ? [msgRigId] : []) }; const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; const key = `${markerType}:${grid}`; @@ -3374,17 +2968,15 @@ if (existing) { existing.grid = grid; if (!(existing.allStationDetails instanceof Map)) { - existing.allStationDetails = existing.stationDetails instanceof Map - ? new Map(existing.stationDetails) - : new Map(); + existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); } const prevDetail = existing.allStationDetails.get(detailKey); - const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set(); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); if (msgRigId) mergedRemotes.add(msgRigId); existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); existing.sourceType = markerType; if (msgRigId) { - if (!existing.rigIds) existing.rigIds = new Set(); + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); existing.rigIds.add(msgRigId); } pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); @@ -3392,18 +2984,17 @@ scheduleDecodeMapMaintenance(); continue; } - - const allStationDetails = new Map(); + const allStationDetails = /* @__PURE__ */ new Map(); allStationDetails.set(detailKey, { ...detailEntry }); const entry = { marker: null, grid, - stations: new Set(), - stationDetails: new Map(), + stations: /* @__PURE__ */ new Set(), + stationDetails: /* @__PURE__ */ new Map(), allStationDetails, sourceType: markerType, - bandMeta: new Map(), - rigIds: new Set(msgRigId ? [msgRigId] : []), + bandMeta: /* @__PURE__ */ new Map(), + rigIds: new Set(msgRigId ? [msgRigId] : []) }; locatorMarkers.set(key, entry); pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); @@ -3411,37 +3002,34 @@ } scheduleDecodeMapMaintenance(); }; - - // --- Sub-tab navigation --- document.querySelectorAll(".sub-tab-bar").forEach((bar) => { bar.addEventListener("click", (e) => { - const btn = e.target.closest(".sub-tab[data-subtab]"); + const btn = e.target?.closest(".sub-tab[data-subtab]"); if (!btn) return; - bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); + bar.querySelectorAll(".sub-tab").forEach((t) => { + t.classList.remove("active"); + }); btn.classList.add("active"); const parent = bar.parentElement; + if (!parent) return; parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); if (nextPanel) nextPanel.style.display = ""; - if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) { + if (btn.dataset.subtab === "cw" && mapWindow.refreshCwTonePicker) { requestAnimationFrame(() => { - if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + if (mapWindow.refreshCwTonePicker) mapWindow.refreshCwTonePicker(); }); } - // Clear SAT prediction DOM when leaving the SAT tab to reduce node count. - if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { - window.clearSatPredictionDom(); + if (btn.dataset.subtab !== "sat" && typeof mapWindow.clearSatPredictionDom === "function") { + mapWindow.clearSatPredictionDom(); } }); }); - window.addEventListener("resize", () => { - const mapTab = document.getElementById("tab-map"); + const mapTab = mapEl("tab-map"); if (!mapTab || mapTab.style.display === "none") return; sizeAprsMapToViewport(); }); - - function flushDeferredDecodeMapSync() { if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; T.decodeMapSyncPending = false; @@ -3449,18 +3037,16 @@ pruneMapHistory(); }); } - - - // Auto-init map if the map tab is already visible (e.g. direct /map navigation). - // Delegates to _initMapWhenReady() in app.js which handles the Leaflet load race. function autoInitIfVisible() { - const panel = document.getElementById("tab-map"); - if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") { - _initMapWhenReady(); + const panel = mapEl("tab-map"); + if (panel.style.display === "none") return; + if (typeof L === "undefined") { + window.setTimeout(autoInitIfVisible, 50); + return; } + initAprsMap(); + requestAnimationFrame(() => requestAnimationFrame(sizeAprsMapToViewport)); } - - // Register module API for core to call modules.map = { initAprsMap, sizeAprsMapToViewport, @@ -3469,12 +3055,24 @@ updateStatsRigFilter, statsRecordDecode, scheduleStatsRender, - get aprsMap() { return aprsMap; }, - get stationMarkers() { return stationMarkers; }, - get locatorMarkers() { return locatorMarkers; }, - get aisMarkers() { return aisMarkers; }, - get vdesMarkers() { return vdesMarkers; }, - get decodeContactPaths() { return decodeContactPaths; }, + get aprsMap() { + return aprsMap; + }, + get stationMarkers() { + return stationMarkers; + }, + get locatorMarkers() { + return locatorMarkers; + }, + get aisMarkers() { + return aisMarkers; + }, + get vdesMarkers() { + return vdesMarkers; + }, + get decodeContactPaths() { + return decodeContactPaths; + }, pruneMapHistory, aprsSymbolIcon, buildAprsPopupHtml, @@ -3492,6 +3090,8 @@ createAisMarker, getAisAccentColor, refreshAisMarkerColors, + updateMapBaseLayerForTheme, + syncLocatorMarkerStyles, setMapRadioPathTo, buildReceiverPopupHtml, rebuildDecodeContactPaths, @@ -3507,9 +3107,7 @@ materializeBufferedMapLayers, flushDeferredDecodeMapSync, bandForHz, - reverseGeocodeLocation, + reverseGeocodeLocation }; - - // If the map tab is already visible (direct /map URL), init immediately. autoInitIfVisible(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js new file mode 100644 index 00000000..1f8c88eb --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js @@ -0,0 +1,263 @@ +// src/plugins/sat-scheduler.ts +var satSchedulerWindow = window; +(function() { + "use strict"; + const dom = { + enabled: document.getElementById("scheduler-sat-enabled"), + pretune: document.getElementById("scheduler-sat-pretune"), + body: document.getElementById("scheduler-sat-body"), + tbody: document.getElementById("scheduler-sat-tbody"), + addBtn: document.getElementById("scheduler-sat-add-btn"), + passStatus: document.getElementById("scheduler-sat-pass-status"), + formWrap: document.getElementById("sch-sat-form-wrap"), + formTitle: document.getElementById("sch-sat-form-title"), + form: document.getElementById("sch-sat-form"), + formCancel: document.getElementById("sch-sat-form-cancel"), + preset: document.getElementById("scheduler-sat-preset"), + name: document.getElementById("scheduler-sat-name"), + norad: document.getElementById("scheduler-sat-norad"), + bookmark: document.getElementById("scheduler-sat-bookmark"), + minEl: document.getElementById("scheduler-sat-min-el"), + priority: document.getElementById("scheduler-sat-priority"), + centerHz: document.getElementById("scheduler-sat-center-hz") + }; + let editIdx = null; + let eventsWired = false; + function getBridge() { + return satSchedulerWindow.trx?.modules?.scheduler ?? null; + } + function getConfig() { + const b = getBridge(); + return b?.getConfig() ?? null; + } + function getStatus() { + const b = getBridge(); + return b?.getStatus() ?? null; + } + function getBookmarks() { + const b = getBridge(); + return b?.getBookmarks() ?? []; + } + function markDirty() { + getBridge()?.markDirty(); + } + function bmName(id) { + const bm = getBookmarks().find(function(b) { + return b.id === id; + }); + return bm ? bm.name : id; + } + function escHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return `${String(hz)} Hz`; + } + function getSatelliteEntries() { + const config = getConfig(); + return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : []; + } + function ensureSatelliteConfig() { + const config = getConfig(); + if (!config) return { enabled: false, pretune_secs: 60, entries: [] }; + if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] }; + return config.satellites; + } + function collectSatelliteConfig() { + const enabled = dom.enabled ? dom.enabled.checked : false; + const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60; + return { + enabled, + pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune, + entries: getSatelliteEntries() + }; + } + function renderSection() { + const config = getConfig(); + const satCfg = config?.satellites; + const enabled = satCfg?.enabled ?? false; + if (dom.enabled) dom.enabled.checked = enabled; + if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60); + if (dom.body) dom.body.style.display = enabled ? "" : "none"; + renderEntries(); + renderPassStatus(); + } + function renderEntries() { + if (!dom.tbody) return; + const entries = getSatelliteEntries(); + const frag = document.createDocumentFragment(); + entries.forEach(function(entry, idx) { + const tr = document.createElement("tr"); + const tdSat = document.createElement("td"); + tdSat.textContent = entry.satellite || ""; + tr.appendChild(tdSat); + const tdNorad = document.createElement("td"); + tdNorad.textContent = String(entry.norad_id || ""); + tr.appendChild(tdNorad); + const tdBm = document.createElement("td"); + tdBm.textContent = bmName(entry.bookmark_id); + tr.appendChild(tdBm); + const tdEl = document.createElement("td"); + tdEl.textContent = `${String(entry.min_elevation_deg)}°`; + tr.appendChild(tdEl); + const tdPrio = document.createElement("td"); + tdPrio.textContent = String(entry.priority || 0); + tr.appendChild(tdPrio); + const tdActions = document.createElement("td"); + const editBtn = document.createElement("button"); + editBtn.className = "sch-write"; + editBtn.type = "button"; + editBtn.textContent = "Edit"; + editBtn.addEventListener("click", function() { + openForm(entry, idx); + }); + tdActions.appendChild(editBtn); + const removeBtn = document.createElement("button"); + removeBtn.className = "sch-write"; + removeBtn.type = "button"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", function() { + removeEntry(idx); + }); + tdActions.appendChild(removeBtn); + tr.appendChild(tdActions); + frag.appendChild(tr); + }); + dom.tbody.replaceChildren(frag); + } + function renderPassStatus() { + if (!dom.passStatus) return; + const entries = getSatelliteEntries(); + if (entries.length === 0) { + dom.passStatus.innerHTML = ""; + return; + } + const status = getStatus(); + if (status && status.active_satellite) { + dom.passStatus.innerHTML = 'PASS ACTIVE: ' + escHtml(status.active_satellite) + ""; + } else { + dom.passStatus.innerHTML = 'No satellite pass active. Predictions available in the SAT tab.'; + } + } + function renderBookmarkSelect(selectedId) { + const bookmarkSelect = dom.bookmark; + if (!bookmarkSelect) return; + bookmarkSelect.innerHTML = ''; + getBookmarks().forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + bookmarkSelect.appendChild(opt); + }); + } + function removeEntry(idx) { + const sat = ensureSatelliteConfig(); + sat.entries.splice(idx, 1); + renderEntries(); + markDirty(); + } + function openForm(entry, idx) { + editIdx = idx != null ? idx : null; + if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite"; + if (dom.preset) dom.preset.value = ""; + if (dom.name) dom.name.value = entry ? entry.satellite || "" : ""; + if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : ""; + if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5); + if (dom.priority) dom.priority.value = String(entry?.priority ?? 0); + if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : ""; + renderBookmarkSelect(entry ? entry.bookmark_id : null); + if (dom.formWrap) { + dom.formWrap.style.display = "flex"; + if (dom.name) dom.name.focus(); + } + } + function closeForm() { + if (dom.formWrap) dom.formWrap.style.display = "none"; + editIdx = null; + } + function onFormSubmit(e) { + e.preventDefault(); + const satellite = dom.name ? dom.name.value.trim() : ""; + const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN; + const bmId = dom.bookmark ? dom.bookmark.value : ""; + if (!satellite) { + satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" }); + dom.name?.focus(); + return; + } + if (isNaN(noradId) || noradId <= 0) { + satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); + dom.norad?.focus(); + return; + } + if (!bmId) { + satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" }); + dom.bookmark?.focus(); + return; + } + const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5; + const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0; + const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN; + const sat = ensureSatelliteConfig(); + const existing = editIdx !== null ? sat.entries[editIdx] : void 0; + const entryData = { + id: existing?.id ?? `sat_${Date.now().toString(36)}`, + satellite, + norad_id: noradId, + bookmark_id: bmId, + min_elevation_deg: isNaN(minEl) ? 5 : minEl, + priority: isNaN(prio) ? 0 : prio, + center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null, + bookmark_ids: [] + }; + if (editIdx !== null) { + sat.entries[editIdx] = entryData; + } else { + sat.entries.push(entryData); + } + closeForm(); + renderEntries(); + markDirty(); + } + function onPresetChange() { + if (!dom.preset || !dom.preset.value) return; + const parts = dom.preset.value.split("|"); + if (dom.name) dom.name.value = parts[0] || ""; + if (dom.norad) dom.norad.value = parts[1] || ""; + } + function wireEvents() { + if (eventsWired) return; + eventsWired = true; + if (dom.enabled) { + const enabledInput = dom.enabled; + dom.enabled.addEventListener("change", function() { + if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none"; + markDirty(); + }); + } + if (dom.pretune) { + dom.pretune.addEventListener("input", function() { + markDirty(); + }); + } + if (dom.addBtn) dom.addBtn.addEventListener("click", function() { + openForm(null, null); + }); + if (dom.form) dom.form.addEventListener("submit", onFormSubmit); + if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm); + if (dom.preset) dom.preset.addEventListener("change", onPresetChange); + } + satSchedulerWindow.satScheduler = { + wireEvents, + renderSection, + renderPassStatus, + collectSatelliteConfig + }; + if (getBridge()) { + wireEvents(); + renderSection(); + } +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js new file mode 100644 index 00000000..6238a038 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js @@ -0,0 +1,479 @@ +// src/plugins/sat.ts +var satWindow = window; +var satDom = { + status: document.getElementById("sat-status"), + liveView: document.getElementById("sat-live-view"), + historyView: document.getElementById("sat-history-view"), + predictionsView: document.getElementById("sat-predictions-view"), + liveLatest: document.getElementById("sat-live-latest"), + historyList: document.getElementById("sat-history-list"), + historyCount: document.getElementById("sat-history-count"), + filterInput: document.getElementById("sat-filter"), + sortSelect: document.getElementById("sat-sort"), + typeFilter: document.getElementById("sat-type-filter"), + lrptState: document.getElementById("sat-lrpt-state"), + viewLiveBtn: document.getElementById("sat-view-live"), + viewHistoryBtn: document.getElementById("sat-view-history"), + viewPredBtn: document.getElementById("sat-view-predictions"), + predFilter: document.getElementById("sat-pred-filter"), + predMinEl: document.getElementById("sat-pred-min-el"), + predCategory: document.getElementById("sat-pred-category"), + predCurrentList: document.getElementById("sat-pred-current-list"), + predUpcomingList: document.getElementById("sat-pred-list"), + predCurrentSec: document.getElementById("sat-pred-current-section"), + predUpcomingSec: document.getElementById("sat-pred-upcoming-section"), + predStatus: document.getElementById("sat-pred-status") +}; +var satImageHistory = []; +var SAT_MAX_IMAGES = 100; +var SAT_PRED_PAGE_SIZE = 50; +var satPredShowAll = false; +var satFilterText = ""; +var satActiveView = "live"; +var satPredData = []; +var satPredFilterText = ""; +var satPredMinEl = 0; +var satPredCategory = "all"; +var satPredSatCount = 0; +var satPredCountdownTimer = null; +function scheduleSatUi(key, job) { + if (typeof satWindow.trxScheduleUiFrameJob === "function") { + satWindow.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function switchSatView(view) { + const leavingPredictions = satActiveView === "predictions" && view !== "predictions"; + satActiveView = view; + if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; + if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; + if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none"; + if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); + if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); + if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions"); + if (leavingPredictions) clearPredictionDom(); + if (view === "history") { + renderSatHistoryTable(); + } else if (view === "predictions") { + satPredShowAll = false; + void loadSatPredictions(); + } +} +function clearPredictionDom() { + stopCountdownTimer(); + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; +} +satWindow.clearSatPredictionDom = clearPredictionDom; +satDom.viewLiveBtn?.addEventListener("click", () => { + switchSatView("live"); +}); +satDom.viewHistoryBtn?.addEventListener("click", () => { + switchSatView("history"); +}); +satDom.viewPredBtn?.addEventListener("click", () => { + switchSatView("predictions"); +}); +var lastSatLrptOn = null; +satWindow.updateSatLiveState = function(update) { + if (!satDom.lrptState) return; + const lrptOn = !!update.lrpt_decode_enabled; + if (lrptOn !== lastSatLrptOn) { + lastSatLrptOn = lrptOn; + satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle"; + satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle"); + if (satDom.status) { + if (lrptOn) { + satDom.status.textContent = "Decoder active — waiting for signal"; + } else { + satDom.status.textContent = "Decoder idle"; + } + } + } +}; +function renderSatLatestCard() { + if (!satDom.liveLatest) return; + if (satImageHistory.length === 0) { + satDom.liveLatest.innerHTML = '
No images decoded yet. Enable a decoder and wait for a satellite pass.
'; + return; + } + const img = satImageHistory[0]; + if (!img) return; + const typeName = "Meteor LRPT"; + const satellite = img.satellite || ""; + const channels = img.channels || img.channel_a || ""; + const lines = img.mcu_count || img.line_count || 0; + const unit = "MCU rows"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; + const meta = [typeName]; + if (satellite) meta.push(satellite); + if (channels) meta.push(channels); + meta.push(`${lines} ${unit}`); + meta.push(`${date} ${ts}`); + let html = `
`; + html += `
Latest decoded image
`; + html += `
${meta.join(" · ")}
`; + if (img.path) { + html += `Download PNG`; + } + if (img.geo_bounds) { + html += ` `; + } + html += `
`; + satDom.liveLatest.innerHTML = html; +} +function getSatFilteredHistory() { + let items = satImageHistory; + const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all"; + if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt"); + if (satFilterText) { + items = items.filter((i) => { + const haystack = [ + "meteor lrpt", + i.satellite || "", + i.channels || "", + i.channel_a || "", + i.channel_b || "" + ].join(" ").toUpperCase(); + return haystack.includes(satFilterText); + }); + } + const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest"; + if (sortVal === "oldest") items = items.slice().reverse(); + return items; +} +function renderSatHistoryRow(img) { + const row = document.createElement("div"); + row.className = "sat-history-row"; + const typeName = "Meteor LRPT"; + const typeClass = "sat-type-lrpt"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; + const satellite = img.satellite || "--"; + const channels = img.channels || "--"; + const lines = img.mcu_count || img.line_count || 0; + const unit = "MCU"; + let link = img.path ? `PNG` : "--"; + if (img.geo_bounds) { + link += ` Map`; + } + row.innerHTML = [ + `${date} ${ts}`, + `${typeName}`, + `${satellite}`, + `${channels}`, + `${lines} ${unit}`, + `${link}` + ].join(""); + return row; +} +function renderSatHistoryTable() { + if (!satDom.historyList) return; + const items = getSatFilteredHistory(); + const fragment = document.createDocumentFragment(); + for (const image of items) { + fragment.appendChild(renderSatHistoryRow(image)); + } + satDom.historyList.replaceChildren(fragment); + if (satDom.historyCount) { + const total = satImageHistory.length; + const shown = items.length; + satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`; + } +} +function addSatImage(img, decoder) { + const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now(); + img._tsMs = tsMs; + img._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + img._decoder = decoder; + satImageHistory.unshift(img); + if (satImageHistory.length > SAT_MAX_IMAGES) { + satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES); + } + scheduleSatUi("sat-latest", () => { + renderSatLatestCard(); + }); + if (satActiveView === "history") { + scheduleSatUi("sat-history", () => { + renderSatHistoryTable(); + }); + } +} +function onServerLrptProgress(msg) { + if (satDom.status && (msg.mcu_count ?? 0) > 0) { + satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`; + } +} +function onServerLrptImage(msg) { + if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)"; + addSatImage(msg, "lrpt"); + if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) { + satWindow.addSatMapOverlay(msg); + } +} +function resetSatHistoryView() { + satImageHistory = []; + if (satDom.historyList) satDom.historyList.innerHTML = ""; + renderSatLatestCard(); + renderSatHistoryTable(); + satWindow.clearSatMapOverlays?.(); +} +function pruneSatHistoryView() { + renderSatHistoryTable(); + renderSatLatestCard(); +} +satWindow.trxPluginRuntime.registerDecoder({ + id: "lrpt_image", + onMessage: onServerLrptImage, + reset: resetSatHistoryView, + prune: pruneSatHistoryView +}); +satWindow.trxPluginRuntime.registerDecoder({ + id: "lrpt_progress", + onMessage: onServerLrptProgress +}); +var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); +lrptDecodeToggleBtn?.addEventListener("click", () => { + void (async () => { + try { + await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); + await satWindow.postPath?.("/toggle_lrpt_decode"); + } catch (e) { + console.error("LRPT toggle failed", e); + } + })(); +}); +var satFilterInput = satDom.filterInput; +satFilterInput?.addEventListener("input", () => { + satFilterText = satFilterInput.value.trim().toUpperCase(); + renderSatHistoryTable(); +}); +satDom.sortSelect?.addEventListener("change", () => { + renderSatHistoryTable(); +}); +satDom.typeFilter?.addEventListener("change", () => { + renderSatHistoryTable(); +}); +document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => { + void (async () => { + if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await satWindow.postPath?.("/clear_lrpt_decode"); + resetSatHistoryView(); + } catch (e) { + console.error("Weather satellite history clear failed", e); + } + })(); +}); +function azToCardinal(deg) { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(deg / 45) % 8] ?? "N"; +} +function formatPredTime(ms) { + const d = new Date(ms); + const now = /* @__PURE__ */ new Date(); + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : ""; + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${day}${hh}:${mm}`; +} +function formatPredDuration(s) { + if (s >= 60) return `${Math.round(s / 60)} min`; + return `${s}s`; +} +function formatCountdown(ms) { + const totalSec = Math.max(0, Math.floor(ms / 1e3)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} +function elevationClass(deg) { + if (deg >= 45) return "sat-pred-el-high"; + if (deg >= 10) return "sat-pred-el-mid"; + return "sat-pred-el-low"; +} +function stopCountdownTimer() { + if (satPredCountdownTimer) { + clearInterval(satPredCountdownTimer); + satPredCountdownTimer = null; + } +} +function startCountdownTimer(container) { + const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? []; + if (countdownEls.length === 0) return; + satPredCountdownTimer = setInterval(() => { + if (satActiveView !== "predictions") { + stopCountdownTimer(); + return; + } + const n = Date.now(); + let anyActive = false; + for (const el of countdownEls) { + const los = Number.parseInt(el.dataset.los ?? "0", 10); + const rem = los - n; + if (rem > 0) { + el.textContent = formatCountdown(rem); + anyActive = true; + } else { + el.textContent = "0:00"; + } + } + if (!anyActive) { + stopCountdownTimer(); + renderSatPredictions(getFilteredPredictions()); + } + }, 1e3); +} +function buildCurrentPassRow(pass, now) { + const row = document.createElement("div"); + row.className = "sat-pred-row-current"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`; + const remaining = Math.max(0, pass.los_ms - now); + row.innerHTML = [ + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}°`, + `${formatPredTime(pass.aos_ms)}`, + `${formatPredTime(pass.los_ms)}`, + `${formatCountdown(remaining)}`, + `${dir}` + ].join(""); + return row; +} +function buildUpcomingPassRow(pass) { + const row = document.createElement("div"); + row.className = "sat-pred-row"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`; + row.innerHTML = [ + `${formatPredTime(pass.aos_ms)}`, + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}°`, + `${formatPredDuration(pass.duration_s)}`, + `${dir}` + ].join(""); + return row; +} +function getFilteredPredictions() { + let items = satPredData; + if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory); + if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl); + if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText)); + return items; +} +function applyPredFilters() { + renderSatPredictions(getFilteredPredictions()); +} +var satPredictionFilter = satDom.predFilter; +satPredictionFilter?.addEventListener("input", () => { + satPredFilterText = satPredictionFilter.value.trim().toUpperCase(); + applyPredFilters(); +}); +var satPredictionMinElevation = satDom.predMinEl; +satPredictionMinElevation?.addEventListener("change", () => { + satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0; + applyPredFilters(); +}); +var satPredictionCategory = satDom.predCategory; +satPredictionCategory?.addEventListener("change", () => { + satPredCategory = satPredictionCategory.value; + applyPredFilters(); +}); +function renderSatPredictions(passes, error) { + stopCountdownTimer(); + if (error) { + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none"; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none"; + if (satDom.predStatus) satDom.predStatus.textContent = error; + return; + } + if (!Array.isArray(passes) || passes.length === 0) { + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none"; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none"; + if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours."; + return; + } + const now = Date.now(); + const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now); + const upcoming = passes.filter((p) => p.aos_ms > now); + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none"; + if (satDom.predCurrentList) { + if (current.length === 0) { + satDom.predCurrentList.innerHTML = ""; + } else { + const frag = document.createDocumentFragment(); + for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now)); + satDom.predCurrentList.replaceChildren(frag); + } + } + const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE; + const visibleUpcoming = upcoming.slice(0, upcomingLimit); + const hiddenCount = upcoming.length - visibleUpcoming.length; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none"; + if (satDom.predUpcomingList) { + const frag = document.createDocumentFragment(); + for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass)); + if (hiddenCount > 0) { + const moreRow = document.createElement("div"); + moreRow.className = "sat-pred-row"; + moreRow.style.cursor = "pointer"; + moreRow.style.textAlign = "center"; + moreRow.innerHTML = `Show ${hiddenCount} more passes…`; + moreRow.addEventListener("click", () => { + satPredShowAll = true; + renderSatPredictions(getFilteredPredictions()); + }); + frag.appendChild(moreRow); + } + satDom.predUpcomingList.replaceChildren(frag); + } + if (satDom.predStatus) { + let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`; + if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`; + satDom.predStatus.textContent = text; + } + if (current.length > 0 && satActiveView === "predictions") { + startCountdownTimer(satDom.predCurrentList); + } +} +async function loadSatPredictions() { + if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…"; + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + try { + const resp = await fetch("/sat_passes"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + satPredSatCount = data.satellite_count || 0; + if (data.error) { + satPredData = []; + renderSatPredictions([], data.error); + } else { + satPredData = data.passes || []; + renderSatPredictions(getFilteredPredictions()); + } + } catch (error) { + renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`); + } +} +satWindow.satShowOnMap = function(south, west, north, east) { + if (typeof satWindow.enableMapSourceFilter === "function") { + satWindow.enableMapSourceFilter("sat"); + } + const lat = (south + north) / 2; + const lon = (west + east) / 2; + if (satWindow.navigateToAprsMap) { + satWindow.navigateToAprsMap(lat, lon); + } +}; +renderSatLatestCard(); +renderSatHistoryTable(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js new file mode 100644 index 00000000..d1a7b1f4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -0,0 +1,1220 @@ +// src/plugins/scheduler.ts +var schedulerWindow = window; +var wiredElements = /* @__PURE__ */ new WeakSet(); +function schedulerEl(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing scheduler element #${id}`); + return element; +} +(function() { + "use strict"; + let schedulerRole = null; + let currentRigId = null; + let currentConfig = null; + let currentSchedulerStatus = null; + let bookmarkList = []; + let statusInterval = null; + let interleaveTicker = null; + let schedulerStepPending = false; + let schEntryEditIdx = null; + let schedulerDirty = false; + function initScheduler(rigId, role) { + schedulerRole = role; + currentRigId = rigId || null; + if (currentRigId) loadScheduler(); + startStatusPolling(); + startInterleaveTicker(); + } + function destroyScheduler() { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; + } + if (interleaveTicker) { + clearInterval(interleaveTicker); + interleaveTicker = null; + } + } + function setSchedulerRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + renderSchedulerInterleaveStatus(); + if (!currentRigId) return; + loadScheduler(); + pollStatus(); + } + function apiGetScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiPutScheduler(rigId, config) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiDeleteScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetStatus(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( + function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + } + ); + } + function apiActivateSchedulerEntry(rigId, entryId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_id: entryId }) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetBookmarks() { + const url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; + return fetch(url).then(function(r) { + return r.ok ? r.json() : []; + }); + } + function loadScheduler() { + const rig = currentRigId; + if (!rig) return; + Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { + currentConfig = config; + bookmarkList = Array.isArray(bms) ? bms : []; + populateTsBookmarkSelect(); + renderScheduler(); + clearSchedulerDirty(); + renderSchedulerInterleaveStatus(); + }).catch(function(error) { + console.error("scheduler load failed", error); + renderSchedulerInterleaveStatus(); + }); + } + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollStatus, 15e3); + pollStatus(); + } + function startInterleaveTicker() { + if (interleaveTicker) clearInterval(interleaveTicker); + interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); + renderSchedulerInterleaveStatus(); + } + function schedulerUtcSeconds() { + return Math.floor(Date.now() / 1e3); + } + function schedulerUtcMinuteInfo() { + const secs = schedulerUtcSeconds(); + const secsIntoDay = (secs % 86400 + 86400) % 86400; + return { + minuteOfDay: Math.floor(secsIntoDay / 60), + secondOfMinute: secsIntoDay % 60 + }; + } + function schedulerEntryIsActive(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + if (start === end) return true; + if (start < end) return nowMin >= start && nowMin < end; + return nowMin >= start || nowMin < end; + } + function schedulerEntryCurrentWindowStart(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; + if (start === end) return 0; + if (start < end) return start; + return nowMin >= start ? start : start - 1440; + } + function schedulerEntryDisplayName(entry) { + if (!entry) return "Scheduler entry"; + if (entry.label) return String(entry.label); + const bookmarkName = bmName(entry.bookmark_id); + return bookmarkName || "Scheduler entry"; + } + function schedulerInterleaveState(config) { + if (!config || config.mode !== "time_span") { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + const entries = Array.isArray(config.entries) ? config.entries : []; + const minuteInfo = schedulerUtcMinuteInfo(); + const nowMin = minuteInfo.minuteOfDay; + const active = entries.filter(function(entry) { + return schedulerEntryIsActive(entry, nowMin); + }); + if (active.length === 0) { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + if (active.length === 1) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const exclIdx = active.findIndex(function(e) { + return e.exclusive; + }); + if (exclIdx >= 0) { + const exclusive = active[exclIdx]; + return { activeEntries: exclusive ? [exclusive] : [], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const defaultInterleave = Number(config.interleave_min); + const durations = active.map(function(entry) { + const own = Number(entry && entry.interleave_min); + if (Number.isFinite(own) && own > 0) return Math.floor(own); + if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); + return 0; + }); + const cycleMin = durations.reduce(function(sum, value) { + return sum + value; + }, 0); + if (!(cycleMin > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; + const statusIndex = statusEntryId ? active.findIndex(function(entry) { + return String(entry && entry.id || "") === statusEntryId; + }) : -1; + const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; + if (statusIndex >= 0 && statusAppliedUtc != null) { + const manualDurationMin = durations[statusIndex] ?? 0; + const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); + const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; + if (remainingSec2 > 0) { + return { + activeEntries: active, + currentIndex: statusIndex, + remainingSec: remainingSec2, + cycleMin + }; + } + } + const overlapStart = active.reduce(function(maxStart, entry) { + return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); + }, Number.NEGATIVE_INFINITY); + if (!Number.isFinite(overlapStart)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; + const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; + let cumulative = 0; + let slotStart = 0; + let currentIndex = 0; + let currentDuration = 0; + for (let i = 0; i < durations.length; i += 1) { + const duration = durations[i] ?? 0; + const nextCumulative = cumulative + duration; + if (posMin < nextCumulative) { + slotStart = cumulative; + cumulative = nextCumulative; + currentIndex = i; + currentDuration = duration; + break; + } + cumulative = nextCumulative; + } + if (!(currentDuration > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); + const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); + return { + activeEntries: active, + currentIndex, + remainingSec, + cycleMin + }; + } + function renderSchedulerInterleaveStatus() { + const wrap = schedulerEl("scheduler-cycle-status"); + if (!wrap) return; + const state = schedulerInterleaveState(currentConfig); + const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; + wrap.style.display = isActive ? "" : "none"; + if (isActive) { + const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + const totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; + const elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; + const ringFill = schedulerEl("interleave-ring-fill"); + if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); + const nameEl = schedulerEl("interleave-active-name"); + if (nameEl) nameEl.textContent = activeName; + const countdownEl = schedulerEl("interleave-countdown"); + if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; + } + renderTimelineNeedle(); + renderSchedulerStepControls(); + } + function renderSchedulerStepControls() { + const prevBtn = schedulerEl("scheduler-prev-btn"); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (!prevBtn || !nextBtn) return; + const state = schedulerInterleaveState(currentConfig); + const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + prevBtn.disabled = !enabled; + nextBtn.disabled = !enabled; + const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; + prevBtn.title = hint; + nextBtn.title = hint; + } + function pollStatus() { + const rig = currentRigId; + if (!rig) return; + apiGetStatus(rig).then(function(st) { + currentSchedulerStatus = st || null; + renderStatus(st); + renderSchedulerInterleaveStatus(); + renderActivityLog(); + renderSatPassStatus(); + }).catch(function() { + }); + } + function renderStatus(st) { + const el = schedulerEl("scheduler-status-card"); + if (!el) return; + if (!st || !st.active && !st.last_bookmark_id) { + el.textContent = "No activity yet."; + return; + } + const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; + const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { + return String(item && item.id || "") === statusEntryId; + }) : null; + const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "—"; + let ts = ""; + if (st.last_applied_utc) { + const d = new Date(st.last_applied_utc * 1e3); + ts = " at " + d.toUTCString(); + } + const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; + let details = ""; + if (st.freq_hz) { + details += formatFreq(st.freq_hz); + if (st.mode) details += " · " + st.mode; + if (st.active_decoders && st.active_decoders.length > 0) { + details += " · " + st.active_decoders.join(", ") + " active"; + } + } + if (details) { + el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; + } else { + el.textContent = "Last applied: " + name + satLabel + ts; + } + } + function apiGetSchedulerLog(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { + return r.ok ? r.json() : []; + }); + } + function renderActivityLog() { + const wrap = schedulerEl("scheduler-activity-log-wrap"); + const container = schedulerEl("scheduler-activity-log"); + if (!wrap || !container || !currentRigId) return; + apiGetSchedulerLog(currentRigId).then(function(entries) { + if (!entries || entries.length === 0) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const html = entries.slice().reverse().map(function(e) { + const d = new Date(e.utc * 1e3); + const ts = d.toUTCString(); + const action = e.action || "unknown"; + const label = e.entry_label || ""; + const bm = e.bookmark_name || ""; + return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; + }).join(""); + container.innerHTML = html; + }).catch(function() { + }); + } + function renderScheduler() { + const panel = schedulerEl("scheduler-panel"); + if (!panel) return; + const mode = currentConfig && currentConfig.mode || "disabled"; + const isControl = schedulerRole === "control"; + setSelected("scheduler-mode-select", mode); + const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; + const controlRow = document.querySelector(".scheduler-control-row"); + if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; + const glSection = schedulerEl("scheduler-grayline-section"); + const tsSection = schedulerEl("scheduler-timespan-section"); + if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; + if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; + renderSatelliteSection(); + if (mode === "grayline" && currentConfig && currentConfig.grayline) { + const gl = currentConfig.grayline; + const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; + const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { + gridEl.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); + renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); + renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); + renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); + renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); + } else if (mode === "grayline") { + const lat = schedulerWindow.serverLat ?? ""; + const lon = schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + const gridEl2 = schedulerEl("scheduler-gl-grid"); + if (gridEl2 && lat !== "" && lon !== "") { + gridEl2.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", 20); + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } else { + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } + const ilEl = schedulerEl("scheduler-ts-interleave"); + if (ilEl) { + const il = currentConfig && currentConfig.interleave_min; + ilEl.value = il ? String(il) : ""; + } + renderTimespanEntries(); + const formEls = panel.querySelectorAll("input, select, button.sch-write"); + formEls.forEach(function(el) { + el.disabled = !isControl; + }); + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) { + saveBtn.style.display = isControl ? "" : "none"; + } + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) { + resetBtn.style.display = isControl ? "" : "none"; + } + } + function setSelected(id, value) { + const el = schedulerEl(id); + if (el) el.value = value; + } + function setInputValue(id, value) { + const el = schedulerEl(id); + if (el) el.value = String(value); + } + function renderBookmarkSelect(id, selectedId) { + const sel = schedulerEl(id); + if (!sel) return; + sel.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return hz + " Hz"; + } + function schOpenEntryForm(entry, idx) { + schEntryEditIdx = idx != null ? idx : null; + const titleEl = schedulerEl("sch-entry-form-title"); + if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; + if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; + if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; + if (labelEl) labelEl.value = entry ? entry.label || "" : ""; + if (ilEl) ilEl.value = entry?.interleave_min ? String(entry.interleave_min) : ""; + if (centerHzEl) centerHzEl.value = entry?.center_hz ? String(entry.center_hz) : ""; + const recordEl = schedulerEl("scheduler-ts-entry-record"); + if (recordEl) recordEl.checked = !!(entry && entry.record); + pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + renderExtraBmList(); + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) { + wrap.style.display = "block"; + if (startEl) startEl.focus(); + } + } + function schCloseEntryForm() { + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) wrap.style.display = "none"; + schEntryEditIdx = null; + pendingExtraBmIds = []; + } + function schEntryFormSubmit(e) { + e.preventDefault(); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (!startEl || !endEl || !bmEl) return; + const bmId = bmEl.value; + if (!bmId) { + schedulerWindow.trxUi.notify?.("Select a primary bookmark before saving.", { kind: "error" }); + return; + } + const startMin = hhmmToMin(startEl.value); + const endMin = hhmmToMin(endEl.value); + const label = labelEl ? labelEl.value.trim() : ""; + const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; + const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; + const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; + const extraBmIds = pendingExtraBmIds.slice(); + currentConfig ??= { remote: currentRigId, mode: "time_span", grayline: null, entries: [] }; + const config = currentConfig; + const recordCb = schedulerEl("scheduler-ts-entry-record"); + const entryRecord = recordCb ? recordCb.checked : false; + const entryData = { + id: "ts_" + Date.now().toString(36), + start_min: startMin, + end_min: endMin, + bookmark_id: bmId, + label: label || null, + interleave_min: entryInterleave, + center_hz: centerHz, + bookmark_ids: extraBmIds, + record: entryRecord, + exclusive: false + }; + if (schEntryEditIdx !== null) { + const existing = config.entries[schEntryEditIdx]; + if (existing?.id) entryData.id = existing.id; + config.entries[schEntryEditIdx] = entryData; + } else { + config.entries.push(entryData); + } + schCloseEntryForm(); + renderTimespanEntries(); + markSchedulerDirty(); + } + const TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; + function renderTimeline() { + const container = schedulerEl("scheduler-ts-timeline"); + if (!container) return; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + if (entries.length === 0) { + container.innerHTML = ""; + return; + } + const W = 1e3; + const H = 80; + const BAR_Y = 6; + const BAR_H = 30; + const TICK_Y = BAR_Y + BAR_H + 2; + let svg = ''; + svg += ''; + entries.forEach(function(entry, idx) { + const start = Number(entry.start_min); + const end = Number(entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + const color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; + if (start === end) { + svg += ''; + } else if (start < end) { + const x = start / 1440 * W; + const w = (end - start) / 1440 * W; + svg += ''; + } else { + const x1 = start / 1440 * W; + const w1 = W - x1; + svg += ''; + const w2 = end / 1440 * W; + svg += ''; + } + }); + const interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; + if (interleaveMin > 0 && entries.length > 1) { + for (let m = 0; m < 1440; m += interleaveMin) { + const overlapping = []; + entries.forEach(function(entry, idx) { + if (schedulerEntryIsActive(entry, m)) { + overlapping.push(idx); + } + }); + if (overlapping.length > 1) { + const stripeX = m / 1440 * W; + const stripeW = Math.max(1, interleaveMin / 1440 * W); + const cyclePos = m % (interleaveMin * overlapping.length); + const ownerSlot = Math.floor(cyclePos / interleaveMin); + const ownerIdx = overlapping[ownerSlot % overlapping.length] ?? 0; + const stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; + svg += ''; + } + } + } + for (let h = 0; h <= 24; h += 3) { + const tx = h / 24 * W; + svg += ''; + if (h < 24) { + svg += '' + String(h).padStart(2, "0") + ""; + } + } + const LOCAL_TICK_Y = TICK_Y + 18; + for (let h = 0; h < 24; h += 3) { + const localMin = h * 60; + const utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); + const utcMin = (localMin + utcOffset + 1440) % 1440; + const tx = utcMin / 1440 * W; + svg += '' + String(h).padStart(2, "0") + "L"; + } + svg += '' + timelineNeedleSvg() + ""; + svg += ""; + container.innerHTML = svg; + container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { + seg.addEventListener("click", function() { + const i = parseInt(seg.getAttribute("data-idx") ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + if (entry) schOpenEntryForm(entry, i); + }); + }); + const svgEl = container.querySelector("svg"); + if (svgEl) { + const timelineSvg = svgEl; + svgEl.addEventListener("click", function(e) { + if (e.target instanceof Element && e.target.classList.contains("sch-timeline-seg")) return; + const rect = timelineSvg.getBoundingClientRect(); + const xPct = (e.clientX - rect.left) / rect.width; + const clickMin = Math.floor(xPct * 1440); + const startHour = Math.floor(clickMin / 60); + const startMin = startHour * 60; + const endMin = (startHour + 1) % 24 * 60; + schOpenEntryForm(null); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + if (startEl) startEl.value = minToHHMM(startMin); + if (endEl) endEl.value = minToHHMM(endMin); + }); + svgEl.style.cursor = "crosshair"; + } + } + function timelineNeedleSvg() { + const info = schedulerUtcMinuteInfo(); + const nowMin = info.minuteOfDay + info.secondOfMinute / 60; + const x = nowMin / 1440 * 1e3; + return ''; + } + function renderTimelineNeedle() { + const g = schedulerEl("sch-timeline-needle-g"); + if (g) g.innerHTML = timelineNeedleSvg(); + } + function schInlineEdit(tr, entry, idx) { + const bmOptions = bookmarkList.map(function(bm) { + const sel = bm.id === entry.bookmark_id ? " selected" : ""; + return '"; + }).join(""); + const extraBmOptions = '' + bookmarkList.map(function(bm) { + return '"; + }).join(""); + const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + tr.innerHTML = '⠇' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '
'; + tr.classList.add("sch-inline-editing"); + const chipsContainer = tr.querySelector(".sch-inline-extra-chips"); + const extraPick = tr.querySelector(".sch-inline-extra-pick"); + const extraAddBtn = tr.querySelector(".sch-inline-extra-add"); + if (!chipsContainer || !extraPick || !extraAddBtn) return; + const chips = chipsContainer; + const picker = extraPick; + function renderInlineExtraChips() { + chips.innerHTML = ""; + inlineExtraIds.forEach(function(id, i) { + const chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + const rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "×"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + inlineExtraIds.splice(i, 1); + renderInlineExtraChips(); + }); + chip.appendChild(rmBtn); + chip.appendChild(document.createTextNode(" " + bmName(id))); + chips.appendChild(chip); + }); + Array.from(picker.options).forEach(function(opt) { + if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); + }); + } + renderInlineExtraChips(); + extraAddBtn.addEventListener("click", function() { + if (!picker.value) return; + if (!inlineExtraIds.includes(picker.value)) { + inlineExtraIds.push(picker.value); + renderInlineExtraChips(); + } + picker.value = ""; + }); + const exclEl = tr.querySelector('[data-field="exclusive"]'); + const ilInput = tr.querySelector('[data-field="interleave"]'); + if (exclEl && ilInput) { + const exclusiveInput = exclEl; + const interleaveInput = ilInput; + exclEl.addEventListener("change", function() { + interleaveInput.disabled = exclusiveInput.checked; + if (exclusiveInput.checked) interleaveInput.value = ""; + }); + } + tr.querySelector(".sch-inline-save")?.addEventListener("click", function() { + const startEl = tr.querySelector('[data-field="start"]'); + const endEl = tr.querySelector('[data-field="end"]'); + const bmEl = tr.querySelector('[data-field="bookmark"]'); + const labelEl = tr.querySelector('[data-field="label"]'); + const ilEl = tr.querySelector('[data-field="interleave"]'); + const recEl = tr.querySelector('[data-field="record"]'); + const exEl = tr.querySelector('[data-field="exclusive"]'); + if (!startEl || !endEl || !bmEl || !labelEl || !ilEl || !recEl) return; + if (bmEl && !bmEl.value) { + schedulerWindow.trxUi.notify?.("Select a bookmark before saving.", { kind: "error" }); + bmEl.focus(); + return; + } + entry.start_min = hhmmToMin(startEl.value); + entry.end_min = hhmmToMin(endEl.value); + entry.bookmark_id = bmEl.value; + entry.label = labelEl.value.trim() || null; + entry.exclusive = exEl ? exEl.checked : false; + const ilVal = parseInt(ilEl.value, 10); + entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + entry.bookmark_ids = inlineExtraIds.slice(); + entry.record = recEl.checked; + if (currentConfig) currentConfig.entries[idx] = entry; + renderTimespanEntries(); + markSchedulerDirty(); + }); + tr.querySelector(".sch-inline-cancel")?.addEventListener("click", function() { + renderTimespanEntries(); + }); + } + function renderTimespanEntries() { + const tbody = schedulerEl("scheduler-ts-tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + entries.forEach(function(entry, idx) { + const tr = document.createElement("tr"); + if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { + tr.classList.add("sch-active"); + } + const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; + const allDay = entry.start_min === entry.end_min; + const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—"; + const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + const extraCell = extraIds.length ? extraIds.map(function(id) { + return escHtml(bmName(id)); + }).join(", ") : "—"; + tr.innerHTML = '⠇' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "—" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const i = parseInt(btn.dataset.idx ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + const row = btn.closest("tr"); + if (entry && row) schInlineEdit(row, entry, i); + }); + }); + tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + removeEntry(parseInt(btn.dataset.idx ?? "", 10)); + }); + }); + (function() { + const handles = tbody.querySelectorAll(".sch-drag-handle"); + let dragIdx = null; + handles.forEach(function(handle, idx) { + const row = handle.parentElement; + if (!row) return; + const dragRow = row; + handle.addEventListener("dragstart", function(event) { + const e = event; + dragIdx = idx; + dragRow.classList.add("sch-dragging"); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + } + }); + dragRow.addEventListener("dragover", function(event) { + const e = event; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragRow.classList.add("sch-drag-over"); + }); + dragRow.addEventListener("dragleave", function() { + dragRow.classList.remove("sch-drag-over"); + }); + dragRow.addEventListener("drop", function(event) { + const e = event; + e.preventDefault(); + dragRow.classList.remove("sch-drag-over"); + if (dragIdx === null || dragIdx === idx) return; + if (!currentConfig) return; + const entries2 = currentConfig.entries; + const moved = entries2.splice(dragIdx, 1)[0]; + if (moved) entries2.splice(idx, 0, moved); + renderTimespanEntries(); + markSchedulerDirty(); + }); + handle.addEventListener("dragend", function() { + dragRow.classList.remove("sch-dragging"); + dragIdx = null; + }); + }); + })(); + renderTimeline(); + } + function bmName(id) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + return bm ? bm.name : String(id || ""); + } + function minToLocal(min) { + const now = /* @__PURE__ */ new Date(); + const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const utcMs = utcMidnight.getTime() + min * 6e4; + const local = new Date(utcMs); + return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); + } + function minToHHMM(min) { + const h = Math.floor(min / 60) % 24; + const m = min % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + } + function hhmmToMin(str) { + const parts = str.split(":"); + return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); + } + function gridToLatLon(grid) { + grid = String(grid).toUpperCase().trim(); + if (grid.length < 4) return null; + const lonField = grid.charCodeAt(0) - 65; + const latField = grid.charCodeAt(1) - 65; + const lonSquare = parseInt(grid.charAt(2), 10); + const latSquare = parseInt(grid.charAt(3), 10); + if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; + let lon = lonField * 20 + lonSquare * 2 - 180; + let lat = latField * 10 + latSquare * 1 - 90; + if (grid.length >= 6) { + const lonSub = grid.charCodeAt(4) - 65; + const latSub = grid.charCodeAt(5) - 65; + if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { + lon += lonSub * (2 / 24) + 1 / 24; + lat += latSub * (1 / 24) + 0.5 / 24; + } + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; + } + function latLonToGrid(lat, lon) { + lon += 180; + lat += 90; + if (isNaN(lon) || isNaN(lat)) return ""; + const lonField = String.fromCharCode(65 + Math.floor(lon / 20)); + const latField = String.fromCharCode(65 + Math.floor(lat / 10)); + const lonSquare = Math.floor(lon % 20 / 2); + const latSquare = Math.floor(lat % 10); + const lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); + const latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); + return lonField + latField + lonSquare + latSquare + lonSub + latSub; + } + function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function schedulerSelectRelativeEntry(delta) { + const state = schedulerInterleaveState(currentConfig); + const rigId = currentRigId; + if (!rigId || schedulerStepPending || state.activeEntries.length <= 1) return; + const count = state.activeEntries.length; + const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; + const targetIndex = (currentIndex + delta + count) % count; + const target = state.activeEntries[targetIndex]; + if (!target || !target.id) return; + const targetId = target.id; + schedulerStepPending = true; + renderSchedulerStepControls(); + Promise.resolve(schedulerWindow.trx.modules.vchan?.takeSchedulerControl() ?? null).then(function() { + return apiActivateSchedulerEntry(rigId, targetId); + }).then(function(status) { + currentSchedulerStatus = status || null; + return Promise.resolve( + schedulerWindow.trx.modules.vchan?.releaseToScheduler() ?? null + ).then(function() { + renderStatus(status); + renderSchedulerInterleaveStatus(); + showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); + pollStatus(); + }); + }).catch(function(error) { + console.error("scheduler entry selection failed", error); + showSchedulerToast("Scheduler entry selection failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + schedulerStepPending = false; + renderSchedulerStepControls(); + }); + } + function removeEntry(idx) { + if (!currentConfig || !currentConfig.entries) return; + currentConfig.entries.splice(idx, 1); + renderTimespanEntries(); + markSchedulerDirty(); + } + function bookmarkExists(id) { + if (!id) return true; + return bookmarkList.some(function(bm) { + return bm.id === id; + }); + } + function saveScheduler() { + const rig = currentRigId; + if (!rig) return; + const modeEl = schedulerEl("scheduler-mode-select"); + const rawMode = modeEl ? modeEl.value : "disabled"; + const mode = rawMode === "grayline" || rawMode === "time_span" ? rawMode : "disabled"; + const config = { + remote: rig, + mode, + grayline: null, + entries: [] + }; + if (mode === "grayline") { + const lat = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lon = parseFloat(schedulerEl("scheduler-gl-lon").value); + const win = parseInt(schedulerEl("scheduler-gl-window").value, 10); + config.grayline = { + lat: isNaN(lat) ? 0 : lat, + lon: isNaN(lon) ? 0 : lon, + transition_window_min: isNaN(win) ? 20 : win, + dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, + day_bookmark_id: selectVal("scheduler-gl-day") || null, + dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, + night_bookmark_id: selectVal("scheduler-gl-night") || null + }; + } else if (mode === "time_span") { + config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; + const ilVal = parseInt(schedulerEl("scheduler-ts-interleave").value, 10); + config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; + } + config.satellites = collectSatelliteConfig(); + const missingBmErrors = []; + if (mode === "grayline" && config.grayline) { + const gl = config.grayline; + const glFields = [ + ["dawn_bookmark_id", "Grayline dawn"], + ["day_bookmark_id", "Grayline day"], + ["dusk_bookmark_id", "Grayline dusk"], + ["night_bookmark_id", "Grayline night"] + ]; + glFields.forEach(function(pair) { + const bookmarkId = gl[pair[0]]; + if (typeof bookmarkId === "string" && !bookmarkExists(bookmarkId)) missingBmErrors.push(pair[1] + " (bookmark " + bookmarkId + ")"); + }); + } + if (mode === "time_span" && Array.isArray(config.entries)) { + config.entries.forEach(function(entry, idx) { + const label = entry.label || "Entry #" + (idx + 1); + if (!bookmarkExists(entry.bookmark_id)) { + missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); + } + const extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + extras.forEach(function(id) { + if (!bookmarkExists(id)) { + missingBmErrors.push(label + " extra channel (" + id + ")"); + } + }); + }); + } + if (config.satellites && Array.isArray(config.satellites.entries)) { + config.satellites.entries.forEach(function(sat, idx) { + const satLabel = sat.satellite || "Satellite #" + (idx + 1); + if (!bookmarkExists(sat.bookmark_id)) { + missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); + } + }); + } + if (missingBmErrors.length > 0) { + showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); + return; + } + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.disabled = true; + apiPutScheduler(rig, config).then(function(saved) { + currentConfig = saved; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler saved."); + }).catch(function(error) { + showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + function selectVal(id) { + const el = schedulerEl(id); + return el ? el.value : ""; + } + async function resetScheduler() { + const rig = currentRigId; + if (!rig) return; + if (!await schedulerWindow.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; + apiDeleteScheduler(rig).then(function() { + currentConfig = { + remote: rig, + mode: "disabled", + grayline: null, + entries: [] + }; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler reset."); + }).catch(function(error) { + showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true); + }); + } + function markSchedulerDirty() { + if (schedulerDirty) return; + schedulerDirty = true; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearSchedulerDirty() { + schedulerDirty = false; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showSchedulerToast(msg, isError = false) { + const el = schedulerEl("scheduler-toast"); + if (!el) return; + el.textContent = msg; + el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; + el.style.display = "block"; + setTimeout(function() { + el.style.display = "none"; + }, 3e3); + } + function wireSchedulerEvents() { + const modeEl = schedulerEl("scheduler-mode-select"); + if (modeEl) { + modeEl.addEventListener("change", function() { + const mode = modeEl.value === "grayline" || modeEl.value === "time_span" ? modeEl.value : "disabled"; + currentConfig ??= { remote: currentRigId, mode, grayline: null, entries: [] }; + currentConfig.mode = mode; + renderScheduler(); + }); + } + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveScheduler); + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) resetBtn.addEventListener("click", () => { + void resetScheduler(); + }); + const addBtn = schedulerEl("scheduler-ts-add-btn"); + if (addBtn) addBtn.addEventListener("click", function() { + schOpenEntryForm(null); + }); + const entryForm = schedulerEl("sch-entry-form"); + if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); + const cancelBtn = schedulerEl("sch-entry-form-cancel"); + if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); + const prevBtn = schedulerEl("scheduler-prev-btn"); + if (prevBtn) prevBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(-1); + }); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (nextBtn) nextBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(1); + }); + const schPanel = schedulerEl("scheduler-panel"); + if (schPanel && !wiredElements.has(schPanel)) { + wiredElements.add(schPanel); + schPanel.addEventListener("input", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + schPanel.addEventListener("change", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + } + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { + gridEl.addEventListener("input", function() { + const ll = gridToLatLon(gridEl.value); + if (ll) { + setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); + setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); + markSchedulerDirty(); + } + }); + } + const latEl = schedulerEl("scheduler-gl-lat"); + const lonEl = schedulerEl("scheduler-gl-lon"); + [latEl, lonEl].forEach(function(el) { + if (el) { + el.addEventListener("input", function() { + const la = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lo = parseFloat(schedulerEl("scheduler-gl-lon").value); + const gEl = schedulerEl("scheduler-gl-grid"); + if (gEl && !isNaN(la) && !isNaN(lo)) { + gEl.value = latLonToGrid(la, lo); + } + }); + } + }); + wireExtraBmAdd(); + wireSatelliteEvents(); + } + function populateTsBookmarkSelect() { + const sel = schedulerEl("scheduler-ts-bookmark"); + const extraSel = schedulerEl("scheduler-ts-extra-bm-pick"); + [sel, extraSel].forEach(function(el) { + if (!el) return; + const prev = el.value; + el.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + el.appendChild(opt); + }); + if (prev) el.value = prev; + }); + } + let pendingExtraBmIds = []; + function renderExtraBmList() { + const container = schedulerEl("scheduler-ts-extra-bm-list"); + if (!container) return; + container.innerHTML = ""; + pendingExtraBmIds.forEach(function(id, idx) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + const chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + const rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "×"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + pendingExtraBmIds.splice(idx, 1); + renderExtraBmList(); + }); + chip.appendChild(rmBtn); + const label = document.createTextNode(" " + (bm ? bm.name : id)); + chip.appendChild(label); + container.appendChild(chip); + }); + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (pick) { + Array.from(pick.options).forEach(function(opt) { + if (opt.value) { + opt.disabled = pendingExtraBmIds.includes(opt.value); + } + }); + } + } + function wireExtraBmAdd() { + const addBtn = schedulerEl("scheduler-ts-extra-bm-add"); + if (!addBtn || wiredElements.has(addBtn)) return; + wiredElements.add(addBtn); + addBtn.addEventListener("click", function() { + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (!pick || !pick.value) return; + if (!pendingExtraBmIds.includes(pick.value)) { + pendingExtraBmIds.push(pick.value); + renderExtraBmList(); + } + pick.value = ""; + }); + } + function renderSatelliteSection() { + schedulerWindow.satScheduler?.renderSection(); + } + function renderSatPassStatus() { + schedulerWindow.satScheduler?.renderPassStatus(); + } + function collectSatelliteConfig() { + return schedulerWindow.satScheduler ? schedulerWindow.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; + } + function wireSatelliteEvents() { + schedulerWindow.satScheduler?.wireEvents(); + } + function isInputFocused() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el instanceof HTMLElement && el.isContentEditable; + } + document.addEventListener("keydown", function(e) { + if (isInputFocused()) return; + if (e.shiftKey && e.key === "R") { + e.preventDefault(); + const releaseBtn = schedulerEl("scheduler-release-btn"); + if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); + } else if (e.shiftKey && e.key === "N") { + e.preventDefault(); + schedulerSelectRelativeEntry(1); + } else if (e.shiftKey && e.key === "P") { + e.preventDefault(); + schedulerSelectRelativeEntry(-1); + } + }); + (function() { + const details = document.querySelector(".sch-ts-details"); + if (!details) return; + const schedulerDetails = details; + const key = "sch-details-open"; + const saved = localStorage.getItem(key); + if (saved !== null) schedulerDetails.open = saved === "1"; + details.addEventListener("toggle", function() { + localStorage.setItem(key, schedulerDetails.open ? "1" : "0"); + }); + })(); + const schedulerService = { + initialize: initScheduler, + destroy: destroyScheduler, + wireEvents: wireSchedulerEvents, + setRig: setSchedulerRig, + getConfig: () => currentConfig, + getStatus: () => currentSchedulerStatus, + getBookmarks: () => bookmarkList, + markDirty: markSchedulerDirty + }; + schedulerWindow.trx.modules.scheduler = schedulerService; + if (schedulerWindow.authRole != null) { + initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); + wireSchedulerEvents(); + } +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/screenshot.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js similarity index 84% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/screenshot.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js index 7f57cc5a..e577b4bb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/screenshot.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js @@ -1,13 +1,8 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// Spectrum screenshot module (loaded on demand when user triggers screenshot). -// Communicates with app.js core via window.trx namespace. -(function () { +// src/screenshot.ts +var screenshotWindow = window; +(function() { "use strict"; - const T = window.trx; - + const T = screenshotWindow.trx; function isVisibleForSnapshot(el) { if (!el) return false; const style = getComputedStyle(el); @@ -17,7 +12,6 @@ const rect = el.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } - function drawRoundedRectPath(ctx, x, y, w, h, r) { const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)); ctx.beginPath(); @@ -32,7 +26,6 @@ ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } - function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) { if (!isVisibleForSnapshot(el)) return null; const rect = el.getBoundingClientRect(); @@ -45,30 +38,29 @@ const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)"); const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0); const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)"); - const bgAlpha = Math.min(bg[3], maxAlpha); if (bgAlpha > 0.01) { drawRoundedRectPath(ctx, x, y, w, h, radius); - ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`; + ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`; ctx.fill(); } const borderAlpha = Math.min(border[3], maxAlpha); if (borderWidth > 0 && borderAlpha > 0.01) { drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5)); ctx.lineWidth = borderWidth; - ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`; + ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`; ctx.stroke(); } return { x, y, w, h, style }; } - function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { - const words = String(text || "").split(/\s+/).filter(Boolean); + const words = (text || "").split(/\s+/).filter(Boolean); if (!words.length) return; let line = ""; let lineIdx = 0; for (let i = 0; i < words.length; i += 1) { - const candidate = line ? `${line} ${words[i]}` : words[i]; + const word = words[i] ?? ""; + const candidate = line ? `${line} ${word}` : word; if (ctx.measureText(candidate).width <= maxWidth || !line) { line = candidate; continue; @@ -76,13 +68,12 @@ ctx.fillText(line, x, y + lineIdx * lineHeight); lineIdx += 1; if (lineIdx >= maxLines) return; - line = words[i]; + line = word; } if (line && lineIdx < maxLines) { ctx.fillText(line, x, y + lineIdx * lineHeight); } } - function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) { const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha); if (!chrome) return; @@ -91,7 +82,7 @@ if (!clean) return; const style = chrome.style; const fontSize = parseFloat(style.fontSize) || 12; - const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25); + const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25; const padX = 6; const padY = 4; const maxWidth = Math.max(20, chrome.w - padX * 2); @@ -110,12 +101,11 @@ chrome.y + padY + lineCursor * lineHeight, maxWidth, lineHeight, - maxLines - lineCursor, + maxLines - lineCursor ); lineCursor += 1; } } - function drawAxisLabels(ctx, axisEl, rootRect) { if (!isVisibleForSnapshot(axisEl)) return; for (const node of axisEl.children) { @@ -132,7 +122,6 @@ ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2); } } - function buildSpectrumSnapshotCanvas() { const rootEl = document.querySelector(".signal-visual-block"); const spectrumPanelEl = document.getElementById("spectrum-panel"); @@ -145,8 +134,7 @@ try { if (typeof gl.flush === "function") gl.flush(); if (typeof gl.finish === "function") gl.finish(); - } catch (_) { - // Ignore transient WebGL state errors and capture the last good frame. + } catch { } } const rootRect = rootEl.getBoundingClientRect(); @@ -157,11 +145,9 @@ const ctx = out.getContext("2d"); if (!ctx) return null; ctx.scale(dpr, dpr); - const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000"; ctx.fillStyle = bg; ctx.fillRect(0, 0, rootRect.width, rootRect.height); - const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas]; for (const canvas of canvases) { @@ -172,28 +158,21 @@ rect.left - rootRect.left, rect.top - rootRect.top, rect.width, - rect.height, + rect.height ); } - - // Decoder overlays over the signal view. - // Cap background alpha to avoid opaque blocks (backdrop-filter can't be - // replicated on canvas, so frosted-glass overlays would otherwise obscure - // the spectrum). const decoderOverlayIds = [ "ais-bar-overlay", "vdes-bar-overlay", "ft8-bar-overlay", "aprs-bar-overlay", - "rds-ps-overlay", + "rds-ps-overlay" ]; for (const id of decoderOverlayIds) { const overlayEl = document.getElementById(id); if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue; drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35); } - - // Spectrum axis labels and bookmark chips (includes freq bar). const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); const spectrumDbAxis = document.getElementById("spectrum-db-axis"); drawAxisLabels(ctx, spectrumFreqAxis, rootRect); @@ -201,10 +180,8 @@ drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect); drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect); drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect); - return out; } - function clickCanvasDownload(href, fileName) { const a = document.createElement("a"); a.href = href; @@ -213,9 +190,10 @@ a.style.display = "none"; document.body.appendChild(a); a.click(); - requestAnimationFrame(() => a.remove()); + requestAnimationFrame(() => { + a.remove(); + }); } - function saveCanvasAsPng(canvas, fileName) { if (!canvas) return Promise.resolve(false); if (typeof canvas.toBlob === "function") { @@ -228,10 +206,12 @@ } const url = URL.createObjectURL(blob); clickCanvasDownload(url, fileName); - setTimeout(() => URL.revokeObjectURL(url), 1000); + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1e3); resolve(true); }, "image/png"); - } catch (_) { + } catch { resolve(false); } }); @@ -239,27 +219,24 @@ try { clickCanvasDownload(canvas.toDataURL("image/png"), fileName); return Promise.resolve(true); - } catch (_) { + } catch { return Promise.resolve(false); } } - async function captureSpectrumScreenshot() { const snapshotCanvas = buildSpectrumSnapshotCanvas(); if (!snapshotCanvas) { T.showHint("Spectrum view not ready", 1300); return false; } - const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`); T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800); return saved; } - - // Register module API - window.trx.modules.screenshot = { + screenshotWindow.trx.modules.screenshot = { captureSpectrumScreenshot, buildSpectrumSnapshotCanvas, - saveCanvasAsPng, + saveCanvasAsPng }; })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js new file mode 100644 index 00000000..a3d42565 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -0,0 +1,470 @@ +// src/plugins/vchan.ts +var vchanWindow = window; +var vchanSessionId = null; +var vchanRigId = null; +var vchanChannels = []; +var vchanActiveId = null; +var schedulerReleaseState = null; +var schedulerReleasePollTimer = null; +function vchanFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; + return `${String(hz)} Hz`; +} +function schedulerReleaseSummaryText(state) { + if (!state) return "Scheduler is controlling the rig."; + const connected = Number(state.connected_sessions) || 0; + const released = Number(state.released_sessions) || 0; + if (connected === 0) return "Scheduler can control the rig."; + if (state.all_released) { + return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; + } + if (!state.current_session_released) { + const othersReleased = Math.max(released, 0); + return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; + } + const blocking = Math.max(connected - released, 0); + return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; +} +function vchanRenderSchedulerRelease() { + const btn = document.getElementById("scheduler-release-btn"); + const status = document.getElementById("scheduler-release-status"); + if (!btn || !status) return; + const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); + btn.disabled = !vchanSessionId || currentReleased; + btn.classList.toggle("active", !currentReleased); + btn.textContent = "Release to Scheduler"; + status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); +} +async function vchanPollSchedulerRelease() { + if (!vchanSessionId) { + schedulerReleaseState = null; + vchanRenderSchedulerRelease(); + return; + } + try { + const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release status failed", e); + } +} +function vchanStartSchedulerReleasePolling() { + if (schedulerReleasePollTimer) { + clearInterval(schedulerReleasePollTimer); + } + schedulerReleasePollTimer = setInterval(() => { + void vchanPollSchedulerRelease(); + }, 1e4); +} +async function vchanToggleSchedulerRelease() { + if (!vchanSessionId) return; + const rigId = vchanRigId || vchanWindow.lastActiveRigId || null; + try { + const resp = await fetch("/scheduler-control", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }) + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release toggle failed", e); + } +} +async function vchanTakeSchedulerControl() { + if (!vchanSessionId) return; + if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return; + try { + const resp = await fetch("/scheduler-control", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, released: false }) + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler control takeover failed", e); + } +} +function vchanHandleSession(data) { + try { + const d = JSON.parse(data); + vchanSessionId = d.session_id || null; + void vchanPollSchedulerRelease(); + } catch (e) { + console.warn("vchan: bad session event", e); + } +} +function vchanHandleChannels(data) { + try { + const d = JSON.parse(data); + vchanRigId = d.remote || null; + vchanChannels = d.channels || []; + const ids = new Set(vchanChannels.map((c) => c.id)); + const primaryChannel = vchanChannels[0]; + if (!vchanActiveId && primaryChannel && vchanSessionId) { + void vchanAutoJoinPrimary(primaryChannel.id); + } else if (vchanActiveId && !ids.has(vchanActiveId)) { + vchanActiveId = vchanChannels[0]?.id ?? null; + vchanReconnectAudio(); + } + vchanRender(); + vchanRenderSchedulerRelease(); + vchanWindow.renderRdsOverlays?.(); + } catch (e) { + console.warn("vchan: bad channels event", e); + } +} +function vchanRender() { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.innerHTML = ""; + vchanChannels.forEach((ch) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`; + if (ch.id === vchanActiveId) btn.classList.add("active"); + const label = document.createElement("span"); + label.className = "vchan-label"; + label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`; + btn.appendChild(label); + if (!ch.permanent) { + const del = document.createElement("span"); + del.className = "vchan-del"; + del.textContent = "×"; + del.title = "Delete channel"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + void vchanDelete(ch.id); + }); + btn.appendChild(del); + } + btn.addEventListener("click", () => { + if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id); + }); + picker.appendChild(btn); + }); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "vchan-add"; + addBtn.textContent = "+"; + addBtn.title = "Allocate new virtual channel at current frequency"; + addBtn.addEventListener("click", () => { + void vchanAllocate(); + }); + picker.appendChild(addBtn); + vchanSyncAccentUI(); + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + } + vchanRenderSchedulerRelease(); +} +async function vchanAllocate() { + if (!vchanSessionId || !vchanRigId) return; + const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0; + const modeEl = document.getElementById("mode"); + const mode = modeEl ? modeEl.value || "USB" : "USB"; + try { + const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) + }); + if (!resp.ok) { + const msg = await resp.text().catch(() => String(resp.status)); + console.warn("vchan: allocate failed —", msg); + return; + } + const ch = await resp.json(); + vchanActiveId = ch.id; + vchanRender(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: allocate error", e); + } +} +async function vchanDelete(channelId) { + if (!vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, + { method: "DELETE" } + ); + if (!resp.ok) { + console.warn("vchan: delete failed", resp.status); + } + } catch (e) { + console.error("vchan: delete error", e); + } +} +async function vchanAutoJoinPrimary(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) + } + ); + if (!resp.ok) { + console.warn("vchan: auto-join primary failed", resp.status); + return; + } + vchanActiveId = channelId; + vchanRender(); + } catch (e) { + console.error("vchan: auto-join error", e); + } +} +async function vchanSubscribe(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) + } + ); + if (!resp.ok) { + console.warn("vchan: subscribe failed", resp.status); + return; + } + vchanActiveId = channelId; + vchanRender(); + vchanSyncModeDisplay(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: subscribe error", e); + } +} +function vchanReconnectAudio() { + const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; + vchanWindow._audioChannelOverride = ch?.id ?? null; + if (!vchanWindow.rxActive) return; + vchanWindow.stopRxAudio?.(); + setTimeout(() => { + vchanWindow.startRxAudio?.(); + }, 300); +} +function vchanApplyCapabilities(caps) { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.style.display = caps && caps.filter_controls ? "" : "none"; + vchanRenderSchedulerRelease(); +} +function vchanIsOnVirtual() { + if (!vchanActiveId || vchanChannels.length === 0) return false; + return vchanActiveId !== vchanChannels[0]?.id; +} +function vchanActiveChannel() { + return vchanChannels.find((c) => c.id === vchanActiveId) || null; +} +function vchanUpdateFreqDisplay() { + const ch = vchanActiveChannel(); + if (!ch) return; + const el = document.getElementById("freq"); + if (!el) return; + if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") { + el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit); + } else { + el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); + } +} +function vchanSyncModeDisplay() { + const modeEl = document.getElementById("mode"); + if (!modeEl) return; + if (vchanIsOnVirtual()) { + const ch = vchanActiveChannel(); + if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); + } + const modeUpper = (modeEl.value || "").toUpperCase(); + if (typeof vchanWindow.lastModeName === "string") { + if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { + vchanWindow.setJogDivisor?.(10); + vchanWindow.resetRdsDisplay?.(); + } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { + vchanWindow.resetRdsDisplay?.(); + } + vchanWindow.lastModeName = modeUpper; + } + vchanWindow.updateWfmControls?.(); + vchanWindow.updateSdrSquelchControlVisibility?.(); + if (vchanWindow.refreshRdsUi) { + vchanWindow.refreshRdsUi(); + } else { + vchanWindow.positionRdsPsOverlay?.(); + } +} +function vchanSyncBwDisplay() { + if (!vchanIsOnVirtual()) return; + const ch = vchanActiveChannel(); + if (!ch) return; + const bwEl = document.getElementById("spectrum-bw-input"); + if (!bwEl) return; + let bwHz = ch.bandwidth_hz || 0; + if (bwHz === 0 && vchanWindow.mwDefaultsForMode) { + bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0; + } + if (bwHz > 0) { + bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); + vchanWindow.currentBandwidthHz = bwHz; + } +} +function vchanSyncAccentUI() { + const onVirtual = vchanIsOnVirtual(); + const freqEl = document.getElementById("freq"); + const bwEl = document.getElementById("spectrum-bw-input"); + if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual); + if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual); + if (onVirtual) { + vchanUpdateFreqDisplay(); + vchanSyncModeDisplay(); + vchanSyncBwDisplay(); + } else { + origRefreshFreqDisplay?.(); + } + if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) { + vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds()); + } +} +var origRefreshFreqDisplay = null; +function vchanSetChannelFreq(freqHz) { + if (!vchanRigId || !vchanActiveId) return; + if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) { + const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2; + const center = vchanWindow.lastSpectrumData.center_hz; + if (Math.abs(freqHz - center) > halfSpan) { + if (vchanWindow.showHint) { + vchanWindow.showHint( + `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`, + 3e3 + ); + } + return; + } + } + void vchanTakeSchedulerControl(); + void fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ freq_hz: Math.round(freqHz) }) + } + ).catch((error) => { + console.error("vchan: set freq error", error); + }); +} +async function vchanSetChannelBandwidth(bwHz) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) + } + ); + if (!resp.ok) console.warn("vchan: set bw failed", resp.status); + } catch (e) { + console.error("vchan: set bw error", e); + } +} +async function vchanSetChannelMode(mode) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }) + } + ); + if (!resp.ok) console.warn("vchan: set mode failed", resp.status); + } catch (e) { + console.error("vchan: set mode error", e); + } +} +async function vchanInterceptMode(mode) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelMode(mode); + return true; +} +async function vchanInterceptBandwidth(bwHz) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelBandwidth(bwHz); + return true; +} +vchanWindow.trx ??= {}; +vchanWindow.trx.modules ??= {}; +vchanWindow.trx.modules.vchan = { + get channels() { + return vchanChannels; + }, + get activeId() { + return vchanActiveId; + }, + activeChannel: vchanActiveChannel, + applyCapabilities: vchanApplyCapabilities, + handleSession: vchanHandleSession, + handleChannels: vchanHandleChannels, + isOnVirtual: vchanIsOnVirtual, + interceptMode: vchanInterceptMode, + interceptBandwidth: vchanInterceptBandwidth, + takeSchedulerControl: vchanTakeSchedulerControl, + releaseToScheduler: vchanToggleSchedulerRelease +}; +(function() { + const original = vchanWindow.setRigFrequency; + vchanWindow.setRigFrequency = function(freqHz) { + if (vchanIsOnVirtual()) { + if (vchanWindow.applyLocalTunedFrequency) { + if (typeof vchanWindow._freqOptimisticSeq === "number") { + vchanWindow._freqOptimisticSeq += 1; + vchanWindow._freqOptimisticHz = Math.round(freqHz); + } + vchanWindow.applyLocalTunedFrequency(Math.round(freqHz)); + } + vchanSetChannelFreq(freqHz); + return; + } + void vchanTakeSchedulerControl(); + original?.(freqHz); + }; +})(); +(function initSchedulerReleaseControl() { + const btn = document.getElementById("scheduler-release-btn"); + if (btn) { + btn.addEventListener("click", () => { + void vchanToggleSchedulerRelease(); + }); + } + vchanStartSchedulerReleasePolling(); + vchanRenderSchedulerRelease(); +})(); +(function() { + origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; + vchanWindow.refreshFreqDisplay = function() { + if (vchanIsOnVirtual()) { + vchanUpdateFreqDisplay(); + return; + } + origRefreshFreqDisplay?.(); + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js new file mode 100644 index 00000000..eb756fcf --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -0,0 +1,268 @@ +// src/plugins/vdes.ts +var vdesWindow = window; +var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var vdesStatus = document.getElementById("vdes-status"); +var vdesMessagesEl = document.getElementById("vdes-messages"); +var vdesFilterInput = document.getElementById("vdes-filter"); +var vdesBarOverlay = document.getElementById("vdes-bar-overlay"); +var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); +var vdesFrameCountEl = document.getElementById("vdes-frame-count"); +var vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); +var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3; +var vdesFilterText = ""; +var vdesMessageHistory = []; +function currentVdesHistoryRetentionMs() { + return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneVdesMessageHistory() { + const cutoffMs = Date.now() - currentVdesHistoryRetentionMs(); + vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs); +} +function scheduleVdesUi(key, job) { + if (typeof vdesWindow.trxScheduleUiFrameJob === "function") { + vdesWindow.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleVdesHistoryRender() { + scheduleVdesUi("vdes-history", () => { + renderVdesHistory(); + }); +} +function scheduleVdesBarUpdate() { + scheduleVdesUi("vdes-bar", () => { + updateVdesBar(); + }); +} +function currentVdesCenterText() { + const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); + const hz = raw ? Number(raw) : 0; + if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency"; + return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`; +} +function vdesAgeText(tsMs) { + if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function vdesHexPreview(rawBytes) { + if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; + return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase(); +} +function updateVdesSummary() { + pruneVdesMessageHistory(); + if (vdesChannelSummaryEl) { + vdesChannelSummaryEl.textContent = currentVdesCenterText(); + } + if (vdesFrameCountEl) { + const count = vdesMessageHistory.length; + vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; + } + if (vdesLatestSeenEl) { + const latest = vdesMessageHistory[0]; + vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; + } +} +function applyVdesFilterToRow(row) { + if (!vdesFilterText) { + row.style.display = ""; + return; + } + const text = row.dataset.filterText || ""; + row.style.display = text.includes(vdesFilterText) ? "" : "none"; +} +function renderVdesRow(msg) { + const row = document.createElement("div"); + row.className = "vdes-message"; + const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + const title = msg.vessel_name || "VDES Burst"; + const label = msg.callsign || "VDES"; + const info = msg.destination || ""; + const labelText = msg.message_label || ""; + const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : ""; + const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : ""; + const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : ""; + const fecText = msg.fec_state || ""; + const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : ""; + const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : ""; + const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : ""; + const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : ""; + const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : ""; + const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : ""; + const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : ""; + const previewText = msg.payload_preview || ""; + const rawHex = vdesHexPreview(msg.raw_bytes); + row.dataset.filterText = [ + title, + label, + labelText, + info, + srcText, + dstText, + sessionText, + asmText, + countText, + ackText, + cqiText, + previewText, + linkText, + syncText, + phaseText, + fecText, + rawHex, + msg.message_type, + msg.bit_len + ].filter(Boolean).join(" ").toUpperCase(); + row.innerHTML = `
${ts}${escapeVdesHtml(title)}${escapeVdesHtml(label)}` + (labelText ? `${escapeVdesHtml(labelText)}` : "") + (linkText ? `${escapeVdesHtml(linkText)}` : "") + (srcText ? `${escapeVdesHtml(srcText)}` : "") + (dstText ? `${escapeVdesHtml(dstText)}` : "") + (syncText ? `${escapeVdesHtml(syncText)}` : "") + (phaseText ? `${escapeVdesHtml(phaseText)}` : "") + `T${escapeVdesHtml(String(msg.message_type ?? "--"))}
${escapeVdesHtml(currentVdesCenterText())}${escapeVdesHtml(`${msg.bit_len || 0} bits`)}` + (sessionText ? `${escapeVdesHtml(sessionText)}` : "") + (asmText ? `${escapeVdesHtml(asmText)}` : "") + (countText ? `${escapeVdesHtml(countText)}` : "") + (ackText ? `${escapeVdesHtml(ackText)}` : "") + (cqiText ? `${escapeVdesHtml(cqiText)}` : "") + (info ? `${escapeVdesHtml(info)}` : "") + (fecText ? `${escapeVdesHtml(fecText)}` : "") + `${escapeVdesHtml(vdesAgeText(msg._tsMs))}
` + (previewText ? `${escapeVdesHtml(previewText)}` : "") + (previewText ? `·` : "") + `${escapeVdesHtml(rawHex)}
`; + applyVdesFilterToRow(row); + return row; +} +function updateVdesBar() { + if (!vdesBarOverlay) return; + updateVdesSummary(); + const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; + const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; + const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6); + if (!isVdes || messages.length === 0) { + vdesBarOverlay.style.display = "none"; + vdesBarOverlay.innerHTML = ""; + return; + } + let html = `
VDESLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + const label = escapeVdesHtml(msg.callsign || "VDES"); + const title = escapeVdesHtml(msg.vessel_name || "Burst"); + const detail = [ + `${msg.bit_len || 0} bits`, + msg.message_label ? escapeVdesHtml(msg.message_label) : null, + Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null, + Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null, + Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null, + Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null, + Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null, + Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null, + msg.destination ? escapeVdesHtml(msg.destination) : null, + escapeVdesHtml(vdesAgeText(msg._tsMs)) + ].filter(Boolean).join(" · "); + html += `
${ts}${title} ${label}: ${detail}
`; + } + vdesBarOverlay.innerHTML = html; + vdesBarOverlay.style.display = "flex"; +} +vdesWindow.updateVdesBar = updateVdesBar; +vdesWindow.clearVdesBar = function() { + resetVdesHistoryView(); +}; +function resetVdesHistoryView() { + if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; + vdesMessageHistory = []; + updateVdesBar(); + renderVdesHistory(); +} +function renderVdesHistory() { + pruneVdesMessageHistory(); + if (!vdesMessagesEl) { + updateVdesSummary(); + return; + } + const fragment = document.createDocumentFragment(); + for (const message of vdesMessageHistory) { + fragment.appendChild(renderVdesRow(message)); + } + vdesMessagesEl.replaceChildren(fragment); + updateVdesSummary(); +} +function addVdesMessage(msg) { + const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + vdesMessageHistory.unshift(msg); + pruneVdesMessageHistory(); + scheduleVdesBarUpdate(); + scheduleVdesHistoryRender(); +} +function normalizeServerVdesMessage(msg) { + return { + ...msg, + rig_id: msg.rig_id || null + }; +} +function onServerVdesBatch(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (vdesStatus) vdesStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerVdesMessage(msg); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { + vdesWindow.vdesMapAddPoint(next); + } + normalized.push(next); + } + normalized.reverse(); + vdesMessageHistory = normalized.concat(vdesMessageHistory); + pruneVdesMessageHistory(); + scheduleVdesBarUpdate(); + scheduleVdesHistoryRender(); +} +document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { + void (async () => { + if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await vdesWindow.postPath?.("/clear_vdes_decode"); + resetVdesHistoryView(); + } catch (e) { + console.error("VDES history clear failed", e); + } + })(); +}); +if (vdesFilterInput) { + vdesFilterInput.addEventListener("input", () => { + vdesFilterText = vdesFilterInput.value.trim().toUpperCase(); + renderVdesHistory(); + }); +} +function onServerVdes(msg) { + if (vdesStatus) vdesStatus.textContent = "Receiving"; + const next = normalizeServerVdesMessage(msg); + addVdesMessage(next); + if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { + vdesWindow.vdesMapAddPoint(next); + } +} +function pruneVdesHistoryView() { + pruneVdesMessageHistory(); + updateVdesBar(); + renderVdesHistory(); +} +updateVdesSummary(); +window.trxPluginRuntime.registerDecoder({ + id: "vdes", + onMessage: onServerVdes, + onBatch: onServerVdesBatch, + restore: onServerVdesBatch, + reset: resetVdesHistoryView, + prune: pruneVdesHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js new file mode 100644 index 00000000..09bae50f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js @@ -0,0 +1,332 @@ +// src/plugins/wefax.ts +var wefaxWindow = window; +var wefaxDom = { + status: document.getElementById("wefax-status"), + liveView: document.getElementById("wefax-live-view"), + historyView: document.getElementById("wefax-history-view"), + liveContainer: document.getElementById("wefax-live-container"), + liveInfo: document.getElementById("wefax-live-info"), + liveCanvas: document.getElementById("wefax-live-canvas"), + liveLatest: document.getElementById("wefax-live-latest"), + historyList: document.getElementById("wefax-history-list"), + historyCount: document.getElementById("wefax-history-count"), + filterInput: document.getElementById("wefax-filter"), + sortSelect: document.getElementById("wefax-sort"), + toggleBtn: document.getElementById("wefax-decode-toggle-btn"), + clearBtn: document.getElementById("wefax-clear-btn"), + viewLiveBtn: document.getElementById("wefax-view-live"), + viewHistoryBtn: document.getElementById("wefax-view-history") +}; +var wefaxImageHistory = []; +var WEFAX_MAX_IMAGES = 100; +var wefaxLiveCtx = null; +var wefaxLiveLineCount = 0; +var wefaxLivePixelsPerLine = 1809; +var wefaxActiveView = "live"; +var wefaxFilterText = ""; +function currentWefaxHistoryRetentionMs() { + return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3; +} +function pruneWefaxHistory() { + const cutoff = Date.now() - currentWefaxHistoryRetentionMs(); + wefaxImageHistory = wefaxImageHistory.filter(function(m) { + return (m._tsMs || 0) > cutoff; + }); +} +function escapeHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function scheduleWefaxUi(key, job) { + if (typeof wefaxWindow.trxScheduleUiFrameJob === "function") { + wefaxWindow.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function switchWefaxView(view) { + wefaxActiveView = view; + if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none"; + if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none"; + [wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) { + if (btn) btn.classList.remove("sat-view-active"); + }); + if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active"); + if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active"); + if (view === "history") renderWefaxHistoryTable(); +} +if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() { + switchWefaxView("live"); +}); +if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() { + switchWefaxView("history"); +}); +function resetLiveCanvas(pixelsPerLine) { + const canvas = wefaxDom.liveCanvas; + if (!canvas) return; + wefaxLivePixelsPerLine = pixelsPerLine; + wefaxLiveLineCount = 0; + canvas.width = pixelsPerLine; + canvas.height = 800; + wefaxLiveCtx = canvas.getContext("2d"); + if (!wefaxLiveCtx) return; + wefaxLiveCtx.fillStyle = "#000"; + wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height); + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = ""; +} +function paintLine(lineBytes) { + const canvas = wefaxDom.liveCanvas; + if (!wefaxLiveCtx || !canvas) return; + const y = wefaxLiveLineCount; + if (y >= canvas.height) { + const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height); + canvas.height *= 2; + wefaxLiveCtx = canvas.getContext("2d"); + if (!wefaxLiveCtx) return; + wefaxLiveCtx.putImageData(old, 0, 0); + } + const w = wefaxLivePixelsPerLine; + const imgData = wefaxLiveCtx.createImageData(w, 1); + const d = imgData.data; + for (let x = 0; x < w; x++) { + const v = lineBytes[x] ?? 0; + const i = x * 4; + d[i] = v; + d[i + 1] = v; + d[i + 2] = v; + d[i + 3] = 255; + } + wefaxLiveCtx.putImageData(imgData, 0, y); + wefaxLiveLineCount++; +} +function renderWefaxLatestCard() { + if (!wefaxDom.liveLatest) return; + if (wefaxImageHistory.length === 0) { + wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; + return; + } + const img = wefaxImageHistory[0]; + if (!img) return; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; + const meta = [ + `${String(img.ioc ?? "--")} IOC`, + `${String(img.lpm ?? "--")} LPM`, + `${String(img.line_count ?? 0)} lines`, + `${date} ${ts}` + ].join(" · "); + const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; + let html = '
'; + html += '
Latest decoded image
'; + html += '
' + escapeHtml(meta) + "
"; + if (imgSrc) { + html += 'View full image'; + } + html += "
"; + wefaxDom.liveLatest.innerHTML = html; +} +function getWefaxFilteredHistory() { + let items = wefaxImageHistory; + if (wefaxFilterText) { + items = items.filter(function(i) { + const haystack = [ + String(i.ioc || ""), + String(i.lpm || ""), + String(i.line_count || "") + ].join(" ").toUpperCase(); + return haystack.indexOf(wefaxFilterText) >= 0; + }); + } + const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest"; + if (sortVal === "oldest") items = items.slice().reverse(); + return items; +} +function renderWefaxHistoryRow(img) { + const row = document.createElement("div"); + row.className = "sat-history-row"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; + const ioc = img.ioc || "--"; + const lpm = img.lpm || "--"; + const lines = img.line_count || 0; + const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; + const link = imgSrc ? 'View' : "--"; + row.innerHTML = [ + "" + escapeHtml(date + " " + ts) + "", + "" + escapeHtml(String(ioc)) + "", + "" + escapeHtml(String(lpm)) + "", + `${String(lines)}`, + "" + link + "" + ].join(""); + return row; +} +function renderWefaxHistoryTable() { + if (!wefaxDom.historyList) return; + pruneWefaxHistory(); + const items = getWefaxFilteredHistory(); + const fragment = document.createDocumentFragment(); + for (const item of items) { + fragment.appendChild(renderWefaxHistoryRow(item)); + } + wefaxDom.historyList.replaceChildren(fragment); + if (wefaxDom.historyCount) { + const total = wefaxImageHistory.length; + const shown = items.length; + wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`; + } +} +function addWefaxImage(msg) { + const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + const canvas = wefaxDom.liveCanvas; + if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) { + const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount); + canvas.height = wefaxLiveLineCount; + wefaxLiveCtx = canvas.getContext("2d"); + if (!wefaxLiveCtx) return; + wefaxLiveCtx.putImageData(trimmed, 0, 0); + try { + msg._dataUrl = canvas.toDataURL("image/png"); + } catch { + } + } + wefaxImageHistory.unshift(msg); + if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) { + wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES); + } + scheduleWefaxUi("wefax-latest", renderWefaxLatestCard); + if (wefaxActiveView === "history") { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + } +} +function onServerWefaxProgress(msg) { + if (msg.state && !msg.line_data) { + if (wefaxDom.status) { + wefaxDom.status.textContent = msg.state; + wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)"; + } + return; + } + if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) { + resetLiveCanvas(msg.pixels_per_line || 1809); + } + if (msg.line_data) { + const binary = atob(msg.line_data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + paintLine(bytes); + } + if (wefaxDom.liveInfo) { + wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`; + } + if (wefaxDom.status) { + wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`; + wefaxDom.status.style.color = "var(--text-accent)"; + } +} +function onServerWefax(msg) { + addWefaxImage(msg); + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; + if (wefaxDom.status) { + wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`; + wefaxDom.status.style.color = ""; + } +} +function restoreWefaxHistory(messages) { + if (!messages.length) return; + for (const message of messages) { + const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now(); + message._tsMs = tsMs; + message._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + wefaxImageHistory = messages.concat(wefaxImageHistory); + pruneWefaxHistory(); + scheduleWefaxUi("wefax-latest", renderWefaxLatestCard); + if (wefaxActiveView === "history") { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + } +} +function pruneWefaxHistoryView() { + pruneWefaxHistory(); + renderWefaxHistoryTable(); + renderWefaxLatestCard(); +} +function resetWefaxHistoryView() { + wefaxImageHistory = []; + if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = ""; + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; + wefaxLiveCtx = null; + wefaxLiveLineCount = 0; + renderWefaxLatestCard(); + renderWefaxHistoryTable(); + if (wefaxDom.status) { + wefaxDom.status.textContent = "Idle"; + wefaxDom.status.style.color = ""; + } +} +if (wefaxDom.filterInput) { + const filterInput = wefaxDom.filterInput; + wefaxDom.filterInput.addEventListener("input", function() { + wefaxFilterText = filterInput.value.trim().toUpperCase(); + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + }); +} +if (wefaxDom.sortSelect) { + wefaxDom.sortSelect.addEventListener("change", function() { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + }); +} +wefaxWindow.syncWefaxToggle = function(enabled) { + if (!wefaxDom.toggleBtn) return; + wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false"; + wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX"; + wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : ""; + wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : ""; +}; +if (wefaxDom.toggleBtn) { + const toggleButton = wefaxDom.toggleBtn; + wefaxDom.toggleBtn.addEventListener("click", () => { + void (async () => { + try { + if (wefaxWindow.takeSchedulerControlForDecoderDisable) { + await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton); + } + await wefaxWindow.postPath?.("/toggle_wefax_decode"); + } catch (e) { + console.error("WEFAX toggle failed", e); + } + })(); + }); +} +if (wefaxDom.clearBtn) { + wefaxDom.clearBtn.addEventListener("click", () => { + void (async () => { + try { + await wefaxWindow.postPath?.("/clear_wefax_decode"); + resetWefaxHistoryView(); + } catch (e) { + console.error("WEFAX clear failed", e); + } + })(); + }); +} +renderWefaxLatestCard(); +wefaxWindow.trxPluginRuntime.registerDecoder({ + id: "wefax", + onMessage: onServerWefax, + restore: restoreWefaxHistory, + prune: pruneWefaxHistoryView, + reset: resetWefaxHistoryView +}); +wefaxWindow.trxPluginRuntime.registerDecoder({ + id: "wefax_progress", + onMessage: onServerWefaxProgress +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js similarity index 51% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index a5392325..60f26a9f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -1,147 +1,127 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// --- WSPR Decoder Plugin (server-side decode) --- -const wsprStatus = document.getElementById("wspr-status"); -const wsprPeriodEl = document.getElementById("wspr-period"); -const wsprMessagesEl = document.getElementById("wspr-messages"); -const wsprFilterInput = document.getElementById("wspr-filter"); -const WSPR_PERIOD_SECONDS = 120; -let wsprFilterText = ""; -let wsprMessageHistory = []; - +// src/plugins/wspr.ts +var wsprWindow = window; +var wsprStatus = document.getElementById("wspr-status"); +var wsprPeriodEl = document.getElementById("wspr-period"); +var wsprMessagesEl = document.getElementById("wspr-messages"); +var wsprFilterInput = document.getElementById("wspr-filter"); +var WSPR_PERIOD_SECONDS = 120; +var wsprFilterText = ""; +var wsprMessageHistory = []; +function finiteNumber(value) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} function currentWsprHistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; + return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; } - function pruneWsprMessageHistory() { const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); - wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs); } - function scheduleWsprHistoryRender() { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); + if (typeof wsprWindow.trxScheduleUiFrameJob === "function") { + wsprWindow.trxScheduleUiFrameJob("wspr-history", () => { + renderWsprHistory(); + }); return; } renderWsprHistory(); } - function fmtWsprTime(tsMs) { if (!tsMs) return "--:--:--"; return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } - function updateWsprPeriodTimer() { if (!wsprPeriodEl) return; - const nowSec = Math.floor(Date.now() / 1000); - const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS); + const nowSec = Math.floor(Date.now() / 1e3); + const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS; const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); const ss = String(remaining % 60).padStart(2, "0"); wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; } - updateWsprPeriodTimer(); setInterval(updateWsprPeriodTimer, 500); - function renderWsprRow(msg) { const row = document.createElement("div"); row.className = "ft8-row"; row.dataset.decoder = "wspr"; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null; - const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--"; - const message = (msg.message || "").toString(); + const snr = finiteNumber(msg.snr_db); + const delta = finiteNumber(msg.dt_s); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null; + const freq = rfHz?.toFixed(0) ?? "--"; + const message = msg.message ?? ""; row.dataset.message = message.toUpperCase(); - row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr}${dt}${freq}${renderWsprMessage(message)}`; + row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${freq}${renderWsprMessage(message)}`; applyWsprFilterToRow(row); return row; } - function renderWsprHistory() { pruneWsprMessageHistory(); if (!wsprMessagesEl) return; const fragment = document.createDocumentFragment(); for (let i = 0; i < wsprMessageHistory.length; i += 1) { - fragment.appendChild(renderWsprRow(wsprMessageHistory[i])); + const message = wsprMessageHistory[i]; + if (message) fragment.appendChild(renderWsprRow(message)); } wsprMessagesEl.replaceChildren(fragment); } - function addWsprMessage(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); wsprMessageHistory.unshift(msg); pruneWsprMessageHistory(); scheduleWsprHistoryRender(); } - function normalizeServerWsprMessage(msg) { - const raw = (msg.message || "").toString(); + const raw = msg.message ?? ""; const grids = extractAllGrids(raw); const station = extractLikelyCallsign(raw); - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) - ? (baseHz + Number(msg.freq_hz)) - : (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null); + const baseHz = finiteNumber(wsprWindow.ft8BaseHz); + const offsetHz = finiteNumber(msg.freq_hz); + const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz; return { raw, grids, station, rfHz, history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s, freq_hz: msg.freq_hz, - message: raw, - }, + message: raw + } }; } - -window.onServerWsprBatch = function(messages) { +function onServerWsprBatch(messages) { if (!Array.isArray(messages) || messages.length === 0) return; - wsprStatus.textContent = "Receiving"; + if (wsprStatus) wsprStatus.textContent = "Receiving"; const normalized = []; for (const msg of messages) { const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { ...msg, - freq_hz: next.rfHz, + ...next.rfHz === null ? {} : { freq_hz: next.rfHz } }); } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); normalized.push(next.history); } normalized.reverse(); wsprMessageHistory = normalized.concat(wsprMessageHistory); pruneWsprMessageHistory(); scheduleWsprHistoryRender(); -}; - -window.restoreWsprHistory = function(messages) { - window.onServerWsprBatch(messages); -}; - -window.pruneWsprHistoryView = function() { +} +function pruneWsprHistoryView() { pruneWsprMessageHistory(); renderWsprHistory(); -}; - -function escapeWsprHtml(input) { - return input - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); } - +function escapeWsprHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} function renderWsprMessage(message) { let out = ""; let i = 0; @@ -159,16 +139,15 @@ function renderWsprMessage(message) { } i = j; } else { - out += escapeWsprHtml(ch); + out += escapeWsprHtml(ch ?? ""); i += 1; } } return out; } - function extractAllGrids(message) { const out = []; - const seen = new Set(); + const seen = /* @__PURE__ */ new Set(); const parts = message.toUpperCase().split(/[^A-Z0-9]+/); for (const token of parts) { if (!token) continue; @@ -179,9 +158,8 @@ function extractAllGrids(message) { } return out; } - function extractLikelyCallsign(message) { - const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/); + const parts = message.toUpperCase().split(/[^A-Z0-9/]+/); for (const token of parts) { if (!token) continue; if (token.length < 3 || token.length > 12) continue; @@ -191,32 +169,28 @@ function extractLikelyCallsign(message) { } return null; } - function isFtxFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); + const normalized = token.trim().toUpperCase(); return normalized === "RR73" || normalized === "73" || normalized === "RR"; } - function isMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); + const normalized = token.trim().toUpperCase(); return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); } - function isAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); + return ch !== void 0 && /[A-Za-z0-9]/.test(ch); } - -function activateWsprHistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); +function activateWsprHistoryLocator(target) { + if (!(target instanceof Element)) return false; + const locatorEl = target.closest(".ft8-locator[data-locator-grid]"); if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); + const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase(); if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "wspr"); + if (typeof wsprWindow.navigateToMapLocator === "function") { + wsprWindow.navigateToMapLocator(grid, "wspr"); } return true; } - function applyWsprFilterToRow(row) { if (!wsprFilterText) { row.style.display = ""; @@ -225,26 +199,18 @@ function applyWsprFilterToRow(row) { const message = row.dataset.message || ""; row.style.display = message.includes(wsprFilterText) ? "" : "none"; } - -function applyWsprFilterToAll() { - const rows = wsprMessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyWsprFilterToRow(row)); -} - -window.resetWsprHistoryView = function() { - wsprMessagesEl.innerHTML = ""; +function resetWsprHistoryView() { + if (wsprMessagesEl) wsprMessagesEl.innerHTML = ""; wsprMessageHistory = []; renderWsprHistory(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr"); -}; - + if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr"); +} if (wsprFilterInput) { wsprFilterInput.addEventListener("input", () => { wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); renderWsprHistory(); }); } - if (wsprMessagesEl) { wsprMessagesEl.addEventListener("click", (event) => { if (!activateWsprHistoryLocator(event.target)) return; @@ -258,35 +224,44 @@ if (wsprMessagesEl) { event.stopPropagation(); }); } - -const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); -wsprDecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); - await postPath("/toggle_wspr_decode"); - } catch (e) { - console.error("WSPR toggle failed", e); - } +var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); +wsprDecodeToggleBtn?.addEventListener("click", () => { + void (async () => { + try { + await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); + await wsprWindow.postPath?.("/toggle_wspr_decode"); + } catch (error) { + console.error("WSPR toggle failed", error); + } + })(); }); - -document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_wspr_decode"); - window.resetWsprHistoryView(); - } catch (e) { - console.error("WSPR history clear failed", e); - } +document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => { + void (async () => { + if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await wsprWindow.postPath?.("/clear_wspr_decode"); + resetWsprHistoryView(); + } catch (error) { + console.error("WSPR history clear failed", error); + } + })(); }); - -window.onServerWspr = function(msg) { - wsprStatus.textContent = "Receiving"; +function onServerWspr(msg) { + if (wsprStatus) wsprStatus.textContent = "Receiving"; const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + if (next.grids.length > 0 && wsprWindow.mapAddLocator) { + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { ...msg, - freq_hz: next.rfHz, + ...next.rfHz === null ? {} : { freq_hz: next.rfHz } }); } addWsprMessage(next.history); -}; +} +wsprWindow.trxPluginRuntime.registerDecoder({ + id: "wspr", + onMessage: onServerWspr, + onBatch: onServerWsprBatch, + restore: onServerWsprBatch, + prune: pruneWsprHistoryView, + reset: resetWsprHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 1ab75cc4..3f45290c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -1637,77 +1637,7 @@ SPDX-License-Identifier: GPL-2.0-or-later - - - - - - + + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js deleted file mode 100644 index ad0d13b3..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js +++ /dev/null @@ -1,120 +0,0 @@ -(function() { - if (typeof L === "undefined") return; - - function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); - } - - function finiteAngle(value) { - if (!Number.isFinite(value)) return null; - const normalized = ((Number(value) % 360) + 360) % 360; - return normalized; - } - - function svgColor(value, fallback) { - const text = String(value || fallback || ""); - return text.replace(/"/g, """); - } - - function buildSymbolHtml(options, zoom) { - const heading = finiteAngle(options.heading); - const course = finiteAngle(options.course); - const angle = heading != null ? heading : course; - const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0; - const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22; - const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; - const size = clamp(sizeBase + zoomBoost, 16, 32); - const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0; - const color = svgColor(options.color, "#ff7559"); - const outline = svgColor(options.outline, "#6b2118"); - - const body = angle != null - ? `` + - `` + - `` - : ``; - - const courseLine = course != null - ? `` + - `` + - `` - : ""; - - return ( - `` - ); - } - - L.TrxAisTrackSymbol = L.Marker.extend({ - options: { - heading: null, - course: null, - speed: null, - color: "#ff7559", - outline: "#6b2118", - size: 22, - interactive: true, - keyboard: true, - riseOnHover: true, - }, - - initialize: function(latlng, options) { - const merged = L.Util.extend({}, this.options, options || {}); - merged.icon = L.divIcon({ - className: "trx-ais-track-symbol-icon", - html: "", - iconSize: [merged.size, merged.size], - iconAnchor: [merged.size / 2, merged.size / 2], - }); - L.Marker.prototype.initialize.call(this, latlng, merged); - }, - - onAdd: function(map) { - L.Marker.prototype.onAdd.call(this, map); - this._refreshIcon(); - this._boundZoomRefresh = this._refreshIcon.bind(this); - map.on("zoomend", this._boundZoomRefresh); - }, - - onRemove: function(map) { - if (this._boundZoomRefresh) { - map.off("zoomend", this._boundZoomRefresh); - this._boundZoomRefresh = null; - } - L.Marker.prototype.onRemove.call(this, map); - }, - - setAisState: function(next) { - if (next && typeof next === "object") { - if ("heading" in next) this.options.heading = next.heading; - if ("course" in next) this.options.course = next.course; - if ("speed" in next) this.options.speed = next.speed; - if ("color" in next) this.options.color = next.color; - if ("outline" in next) this.options.outline = next.outline; - } - this._refreshIcon(); - return this; - }, - - _refreshIcon: function() { - if (!this._icon) return; - const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0; - const html = buildSymbolHtml(this.options, zoom); - this._icon.innerHTML = html; - const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22; - const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; - const size = clamp(sizeBase + zoomBoost, 16, 32); - this._icon.style.width = `${size}px`; - this._icon.style.height = `${size}px`; - this._icon.style.marginLeft = `${-size / 2}px`; - this._icon.style.marginTop = `${-size / 2}px`; - }, - }); - - L.trxAisTrackSymbol = function(latlng, options) { - return new L.TrxAisTrackSymbol(latlng, options); - }; -})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft2.js deleted file mode 100644 index cc27230a..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft2.js +++ /dev/null @@ -1,207 +0,0 @@ -// --- FT2 Decoder Plugin (server-side decode) --- -// SPDX-FileCopyrightText: 2026 Stan Grams -// SPDX-License-Identifier: GPL-2.0-or-later - -function ft8RenderMessageFt2(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} - -const ft2Status = document.getElementById("ft2-status"); -const ft2PeriodEl = document.getElementById("ft2-period"); -const ft2MessagesEl = document.getElementById("ft2-messages"); -const ft2FilterInput = document.getElementById("ft2-filter"); -const FT2_PERIOD_MS = 3750; -const FT2_MAX_DOM_ROWS = 200; -let ft2FilterText = ""; -let ft2MessageHistory = []; - -function currentFt2HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt2MessageHistory() { - const cutoffMs = Date.now() - currentFt2HistoryRetentionMs(); - ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt2Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); } - -function normalizeFt2DisplayFreqHz(freqHz) { - const rawHz = Number(freqHz); - if (!Number.isFinite(rawHz)) return null; - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) { - return baseHz + rawHz; - } - return rawHz; -} - -function updateFt2PeriodTimer() { - if (!ft2PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000; - ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} - -updateFt2PeriodTimer(); -setInterval(updateFt2PeriodTimer, 250); - -function renderFt2Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft2"; - row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessageFt2(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} - -function renderFt2History() { - pruneFt2MessageHistory(); - if (!ft2MessagesEl) return; - const filter = ft2FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) { - const msg = ft2MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt2Row(msg)); - rendered++; - } - ft2MessagesEl.replaceChildren(fragment); -} - -function addFt2Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft2MessageHistory.unshift(msg); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -} - -function normalizeServerFt2Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 - ? locatorDetails.map((d) => d.grid) - : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []); - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz); - return { - raw, grids, station, rfHz, locatorDetails, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s, - freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, - message: msg.message, - }, - }; -} - -window.onServerFt2Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft2Status) ft2Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - ft2MessageHistory = normalized.concat(ft2MessageHistory); - pruneFt2MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft2"); - window.updateFt8Bar?.(); - scheduleFt2HistoryRender(); -}; - -window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); }; -window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); }; - -window.resetFt2HistoryView = function() { - if (ft2MessagesEl) ft2MessagesEl.innerHTML = ""; - ft2MessageHistory = []; - window.updateFt8Bar?.(); - renderFt2History(); -}; - -function buildFt2BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1000; - const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; - const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; - const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessageFt2((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames); - -if (ft2FilterInput) { - ft2FilterInput.addEventListener("input", () => { - ft2FilterText = ft2FilterInput.value.trim().toUpperCase(); - renderFt2History(); - }); -} - -const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn"); -ft2DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn); - await postPath("/toggle_ft2_decode"); - } catch (e) { - console.error("FT2 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft2_decode"); - window.resetFt2HistoryView(); - } catch (e) { console.error("FT2 history clear failed", e); } -}); - -window.onServerFt2 = function(msg) { - if (ft2Status) ft2Status.textContent = "Receiving"; - const next = normalizeServerFt2Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt2Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft4.js deleted file mode 100644 index 58a07985..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft4.js +++ /dev/null @@ -1,207 +0,0 @@ -// --- FT4 Decoder Plugin (server-side decode) --- -// SPDX-FileCopyrightText: 2026 Stan Grams -// SPDX-License-Identifier: GPL-2.0-or-later - -function ft8RenderMessage(message) { - if (typeof renderFt8Message === "function") return renderFt8Message(message); - if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); - return message; -} - -const ft4Status = document.getElementById("ft4-status"); -const ft4PeriodEl = document.getElementById("ft4-period"); -const ft4MessagesEl = document.getElementById("ft4-messages"); -const ft4FilterInput = document.getElementById("ft4-filter"); -const FT4_PERIOD_MS = 7500; -const FT4_MAX_DOM_ROWS = 200; -let ft4FilterText = ""; -let ft4MessageHistory = []; - -function currentFt4HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt4MessageHistory() { - const cutoffMs = Date.now() - currentFt4HistoryRetentionMs(); - ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt4Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); } - -function normalizeFt4DisplayFreqHz(freqHz) { - const rawHz = Number(freqHz); - if (!Number.isFinite(rawHz)) return null; - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) { - return baseHz + rawHz; - } - return rawHz; -} - -function updateFt4PeriodTimer() { - if (!ft4PeriodEl) return; - const nowMs = Date.now(); - const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000; - ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; -} - -updateFt4PeriodTimer(); -setInterval(updateFt4PeriodTimer, 250); - -function renderFt4Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft4"; - row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = ft8RenderMessage(rawMessage); - const tsMs = msg._tsMs ?? msg.ts_ms; - const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; - row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; - return row; -} - -function renderFt4History() { - pruneFt4MessageHistory(); - if (!ft4MessagesEl) return; - const filter = ft4FilterText; - const fragment = document.createDocumentFragment(); - let rendered = 0; - for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) { - const msg = ft4MessageHistory[i]; - if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; - fragment.appendChild(renderFt4Row(msg)); - rendered++; - } - ft4MessagesEl.replaceChildren(fragment); -} - -function addFt4Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft4MessageHistory.unshift(msg); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -} - -function normalizeServerFt4Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; - const grids = locatorDetails.length > 0 - ? locatorDetails.map((d) => d.grid) - : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []); - const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; - const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz); - return { - raw, grids, station, rfHz, locatorDetails, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s, - freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, - message: msg.message, - }, - }; -} - -window.onServerFt4Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - if (ft4Status) ft4Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - ft4MessageHistory = normalized.concat(ft4MessageHistory); - pruneFt4MessageHistory(); - window.setFt8FamilyBarDecoder?.("ft4"); - window.updateFt8Bar?.(); - scheduleFt4HistoryRender(); -}; - -window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); }; -window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); }; - -window.resetFt4HistoryView = function() { - if (ft4MessagesEl) ft4MessagesEl.innerHTML = ""; - ft4MessageHistory = []; - window.updateFt8Bar?.(); - renderFt4History(); -}; - -function buildFt4BarFrames() { - const cutoffMs = Date.now() - 15 * 60 * 1000; - const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const tsMs = msg._tsMs ?? msg.ts_ms; - const ts = tsMs ? `${fmtTime(tsMs)}` : ""; - const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; - const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; - const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); - const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8RenderMessage((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} -window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames); - -if (ft4FilterInput) { - ft4FilterInput.addEventListener("input", () => { - ft4FilterText = ft4FilterInput.value.trim().toUpperCase(); - renderFt4History(); - }); -} - -const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn"); -ft4DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn); - await postPath("/toggle_ft4_decode"); - } catch (e) { - console.error("FT4 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft4_decode"); - window.resetFt4HistoryView(); - } catch (e) { console.error("FT4 history clear failed", e); } -}); - -window.onServerFt4 = function(msg) { - if (ft4Status) ft4Status.textContent = "Receiving"; - const next = normalizeServerFt4Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); - } - addFt4Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js deleted file mode 100644 index c9c4edff..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js +++ /dev/null @@ -1,486 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// --- FT8 Decoder Plugin (server-side decode) --- -const ft8Status = document.getElementById("ft8-status"); -const ft8PeriodEl = document.getElementById("ft8-period"); -const ft8MessagesEl = document.getElementById("ft8-messages"); -const ft8FilterInput = document.getElementById("ft8-filter"); -const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); -const FT8_BAR_WINDOW_MS = 15 * 60 * 1000; -const FT8_PERIOD_SECONDS = 15; -const FT8_MAX_DOM_ROWS = 200; -const FT8_BAR_DECODER_LABELS = { - ft8: "FT8", - ft4: "FT4", - ft2: "FT2", -}; -let ft8FilterText = ""; -let ft8MessageHistory = []; -let ft8BarActiveDecoder = "ft8"; -const ft8BarBuilders = {}; -const ft8BarDismissedAtMsByDecoder = { - ft8: 0, - ft4: 0, - ft2: 0, -}; - -function currentFt8HistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() - : 24 * 60 * 60 * 1000; -} - -function pruneFt8MessageHistory() { - const cutoffMs = Date.now() - currentFt8HistoryRetentionMs(); - ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); -} - -function scheduleFt8Ui(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; - } - job(); -} - -function scheduleFt8HistoryRender() { - scheduleFt8Ui("ft8-history", () => renderFt8History()); -} - -function scheduleFt8BarUpdate() { - scheduleFt8Ui("ft8-bar", () => updateFt8Bar()); -} - -window.registerFt8FamilyBarRenderer = function(decoder, builder) { - if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return; - ft8BarBuilders[decoder] = builder; -}; - -window.setFt8FamilyBarDecoder = function(decoder) { - if (!FT8_BAR_DECODER_LABELS[decoder]) return; - ft8BarActiveDecoder = decoder; - scheduleFt8BarUpdate(); -}; - -function normalizeFt8DisplayFreqHz(freqHz) { - const rawHz = Number(freqHz); - if (!Number.isFinite(rawHz)) return null; - const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; - if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) { - return baseHz + rawHz; - } - return rawHz; -} - -function fmtTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -function updateFt8PeriodTimer() { - if (!ft8PeriodEl) return; - const nowSec = Math.floor(Date.now() / 1000); - const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS); - ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`; -} - -updateFt8PeriodTimer(); -setInterval(updateFt8PeriodTimer, 500); - -function renderFt8Row(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - const rawMessage = (msg.message || "").toString(); - row.dataset.message = rawMessage.toUpperCase(); - row.dataset.decoder = "ft8"; - row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; - const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; - const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; - const renderedMessage = renderFt8Message(rawMessage); - row.innerHTML = `${fmtTime(msg.ts_ms)}${snr}${dt}${freq}${renderedMessage}`; - applyFt8FilterToRow(row); - return row; -} - -function renderFt8History() { - pruneFt8MessageHistory(); - if (!ft8MessagesEl) return; - const fragment = document.createDocumentFragment(); - const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS); - for (let i = 0; i < limit; i += 1) { - fragment.appendChild(renderFt8Row(ft8MessageHistory[i])); - } - ft8MessagesEl.replaceChildren(fragment); -} - -function addFt8Message(msg) { - msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); - ft8MessageHistory.unshift(msg); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -} - -function normalizeServerFt8Message(msg) { - const raw = (msg.message || "").toString(); - const locatorDetails = ft8ExtractLocatorDetails(raw); - const grids = locatorDetails.length > 0 - ? locatorDetails.map((detail) => detail.grid) - : ft8ExtractAllGrids(raw); - const station = ft8ExtractLikelyCallsign(raw); - const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - return { - raw, - grids, - station, - rfHz, - locatorDetails, - history: { - receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, - ts_ms: msg.ts_ms, - snr_db: msg.snr_db, - dt_s: msg.dt_s, - freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, - message: msg.message, - }, - }; -} - -window.onServerFt8Batch = function(messages) { - if (!Array.isArray(messages) || messages.length === 0) return; - ft8Status.textContent = "Receiving"; - const normalized = []; - for (const msg of messages) { - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails, - }); - } - next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); - normalized.push(next.history); - } - normalized.reverse(); - ft8MessageHistory = normalized.concat(ft8MessageHistory); - pruneFt8MessageHistory(); - ft8BarActiveDecoder = "ft8"; - scheduleFt8BarUpdate(); - scheduleFt8HistoryRender(); -}; - -window.restoreFt8History = function(messages) { - window.onServerFt8Batch(messages); -}; - -window.pruneFt8HistoryView = function() { - pruneFt8MessageHistory(); - updateFt8Bar(); - renderFt8History(); -}; - -function ft8BarRfText(msg) { - const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); - if (!Number.isFinite(displayFreqHz)) return null; - return `${displayFreqHz.toFixed(0)} Hz`; -} - -function buildFt8BarFrames() { - const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS; - const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8); - const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0); - if (messages.length === 0) { - return { count: 0, newestTsMs: 0, html: "" }; - } - let html = ""; - for (const msg of messages) { - const ts = msg.ts_ms ? `${fmtTime(msg.ts_ms)}` : ""; - const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; - const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; - const rf = ft8BarRfText(msg); - const detail = [snr, dt, rf].filter(Boolean).join(" · "); - const text = ft8EscapeHtml((msg.message || "").toString()); - html += `
${ts}${text}${detail ? ` · ${detail}` : ""}
`; - } - return { count: messages.length, newestTsMs, html }; -} - -function updateFt8Bar() { - if (!ft8BarOverlay) return; - const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); - const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB"; - const decoder = ft8BarActiveDecoder; - const builder = ft8BarBuilders[decoder]; - const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8"; - const result = typeof builder === "function" ? builder() : null; - const newestTsMs = Number(result?.newestTsMs) || 0; - if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - return; - } - - ft8BarOverlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; - ft8BarOverlay.style.display = "flex"; -} -window.updateFt8Bar = updateFt8Bar; -window.clearFt8Bar = function() { - const decoder = ft8BarActiveDecoder; - if (decoder === "ft4") { - window.resetFt4HistoryView?.(); - return; - } - if (decoder === "ft2") { - window.resetFt2HistoryView?.(); - return; - } - window.resetFt8HistoryView?.(); -}; -window.closeFt8Bar = function() { - ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now(); - if (ft8BarOverlay) { - ft8BarOverlay.style.display = "none"; - ft8BarOverlay.innerHTML = ""; - } -}; -window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames); - -function renderFt8Message(message) { - let out = ""; - let i = 0; - while (i < message.length) { - const ch = message[i]; - if (ft8IsAlphaNum(ch)) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid)) { - out += `${grid}`; - } else { - out += ft8EscapeHtml(token); - } - i = j; - } else { - out += ft8EscapeHtml(ch); - i += 1; - } - } - return out; -} - -function ft8TokenizeMessage(message) { - return String(message || "") - .toUpperCase() - .split(/[^A-Z0-9/]+/) - .filter(Boolean); -} - -function ft8ExtractAllGrids(message) { - const out = []; - const seen = new Set(); - let i = 0; - while (i < message.length) { - if (ft8IsAlphaNum(message[i])) { - let j = i + 1; - while (j < message.length && ft8IsAlphaNum(message[j])) j++; - const token = message.slice(i, j); - const grid = token.toUpperCase(); - if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) { - seen.add(grid); - out.push(grid); - } - i = j; - } else { - i += 1; - } - } - return out; -} - -function ft8ExtractLocatorDetails(message) { - const tokens = ft8TokenizeMessage(message); - const grids = ft8ExtractAllGrids(String(message || "")); - if (tokens.length === 0 || grids.length === 0) return []; - const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token)); - const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length; - const callsigns = []; - for (let i = 0; i < limit; i += 1) { - if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]); - } - - let source = null; - let target = null; - const head = tokens[0]; - if (callsigns.length > 0) { - if (head === "CQ" || head === "DE" || head === "QRZ") { - source = callsigns[0]; - } else if (callsigns.length >= 2) { - target = callsigns[0]; - source = callsigns[1]; - } else { - source = callsigns[0]; - } - } - - return grids.map((grid) => ({ - grid, - station: source || null, - source: source || null, - target: target || null, - })); -} - -function ft8ExtractLikelyCallsign(message) { - const locatorDetails = ft8ExtractLocatorDetails(message); - if (locatorDetails.length > 0 && locatorDetails[0].station) { - return locatorDetails[0].station; - } - const tokens = ft8TokenizeMessage(message); - for (const token of tokens) { - if (ft8IsLikelyCallsignToken(token)) return token; - } - return null; -} - -function ft8IsLikelyCallsignToken(token) { - if (!token) return false; - if (token.length < 3 || token.length > 12) return false; - if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false; - if (ft8IsMaidenheadGridToken(token)) return false; - return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); -} - -function ft8IsFarewellToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return normalized === "RR73" || normalized === "73" || normalized === "RR"; -} - -function ft8IsMaidenheadGridToken(token) { - const normalized = String(token || "").trim().toUpperCase(); - return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized); -} - -function ft8EscapeHtml(input) { - return input - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); -} - -function ft8IsAlphaNum(ch) { - return /[A-Za-z0-9]/.test(ch); -} - -function activateFt8HistoryLocator(targetEl) { - const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); - if (!locatorEl) return false; - const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); - if (!grid) return false; - if (typeof window.navigateToMapLocator === "function") { - window.navigateToMapLocator(grid, "ft8"); - } - return true; -} - -function applyFt8FilterToRow(row) { - if (!ft8FilterText) { - row.style.display = ""; - return; - } - const message = row.dataset.message || ""; - row.style.display = message.includes(ft8FilterText) ? "" : "none"; -} - -function applyFt8FilterToAll() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => applyFt8FilterToRow(row)); -} - -function updateFt8RowRf(row) { - const freqEl = row.querySelector(".ft8-freq"); - if (!freqEl) return; - const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN; - const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz); - if (Number.isFinite(displayFreqHz)) { - freqEl.textContent = displayFreqHz.toFixed(0); - } else { - freqEl.textContent = "--"; - } -} - -window.updateFt8RfDisplay = function() { - const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); - rows.forEach((row) => updateFt8RowRf(row)); - updateFt8Bar(); -}; - -window.resetFt8HistoryView = function() { - ft8MessagesEl.innerHTML = ""; - ft8MessageHistory = []; - updateFt8Bar(); - renderFt8History(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8"); -}; - -if (ft8FilterInput) { - ft8FilterInput.addEventListener("input", () => { - ft8FilterText = ft8FilterInput.value.trim().toUpperCase(); - renderFt8History(); - }); -} - -if (ft8MessagesEl) { - ft8MessagesEl.addEventListener("click", (event) => { - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); - ft8MessagesEl.addEventListener("keydown", (event) => { - if (event.key !== "Enter" && event.key !== " ") return; - if (!activateFt8HistoryLocator(event.target)) return; - event.preventDefault(); - event.stopPropagation(); - }); -} - -const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn"); -ft8DecodeToggleBtn?.addEventListener("click", async () => { - try { - await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn); - await postPath("/toggle_ft8_decode"); - } catch (e) { - console.error("FT8 toggle failed", e); - } -}); - -document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ft8_decode"); - window.resetFt8HistoryView(); - } catch (e) { - console.error("FT8 history clear failed", e); - } -}); - -// --- Server-side FT8 decode handler --- -window.onServerFt8 = function(msg) { - ft8Status.textContent = "Receiving"; - const next = normalizeServerFt8Message(msg); - if (next.grids.length > 0 && window.mapAddLocator) { - window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { - ...msg, - freq_hz: next.rfHz, - locator_details: next.locatorDetails, - }); - } - addFt8Message(next.history); -}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index f5e18207..480ba033 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -2521,6 +2521,7 @@ body.map-fake-fullscreen-active { word-break: break-all; } .aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } +.aprs-symbol-local { border: 1px solid var(--border); border-radius: 4px; background: var(--surface-raised); color: var(--text); font: 700 0.8rem/22px ui-monospace, monospace; text-align: center; } .aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; } .aprs-pos:hover { text-decoration: underline; } .leaflet-popup-content-wrapper, .leaflet-popup-tip { background: color-mix(in srgb, var(--card-bg) 84%, transparent) !important; color: var(--text) !important; box-shadow: 0 3px 14px rgba(0,0,0,0.45) !important; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/build.rs b/src/trx-client/trx-frontend/trx-frontend-http/build.rs index debef5d7..8158eaf5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/build.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/build.rs @@ -2,8 +2,53 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +fn generate_asset_manifest() { + let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); + let generated_dir = manifest_dir.join("assets/web/generated"); + println!("cargo:rerun-if-changed={}", generated_dir.display()); + + let mut filenames = fs::read_dir(&generated_dir) + .expect("read generated frontend assets") + .map(|entry| entry.expect("read generated asset entry")) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .map(|entry| { + entry + .file_name() + .into_string() + .expect("UTF-8 asset filename") + }) + .collect::>(); + filenames.sort(); + + let mut output = String::from( + "// Generated by build.rs from committed frontend output.\n\ + pub const GENERATED_ASSETS: &[(&str, &[u8])] = &[\n", + ); + for filename in filenames { + assert!( + filename + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)), + "unsupported generated asset filename: {filename}" + ); + writeln!( + output, + " ({filename:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/web/generated/\", {filename:?})))," + ) + .expect("write asset manifest"); + } + output.push_str("];\n"); + + let output_path = + PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("generated_assets.rs"); + fs::write(output_path, output).expect("write generated asset manifest"); +} + fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) { let days = secs.div_euclid(86_400); let z = days + 719_468; @@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) { } fn main() { + generate_asset_manifest(); let secs = match SystemTime::now().duration_since(UNIX_EPOCH) { Ok(d) => d.as_secs() as i64, Err(_) => 0, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs new file mode 100644 index 00000000..e7dc2a48 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/examples/generate_typescript.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use trx_core::radio::freq::{Band, Freq}; +use trx_core::rig::state::{SpectrumData, VchanRdsEntry}; +use trx_core::rig::{ + RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo, + RigVfoEntry, +}; +use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel}; +use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse}; +use trx_frontend_http::server::api::FrontendMeta; +use trx_protocol::{DecoderActivation, DecoderDescriptor}; +use ts_rs::{Config, TS}; + +fn main() -> Result<(), Box> { + let mut output = format!( + "// SPDX-FileCopyrightText: 2026 Stan Grams \n\ + //\n\ + // SPDX-License-{}: GPL-2.0-or-later\n\n\ + // Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\ + // Do not edit manually.\n\n", + "Identifier", + ); + let config = Config::default(); + + macro_rules! export { + ($type:ty) => { + writeln!(output, "export {}\n", <$type as TS>::decl(&config))?; + }; + } + + export!(Band); + export!(Freq); + export!(RigMode); + export!(RigAccessMethod); + export!(RigCapabilities); + export!(RigInfo); + export!(RigVfoEntry); + export!(RigVfo); + export!(RigTxStatus); + export!(RigRxStatus); + export!(RigStatus); + export!(DecoderConfig); + export!(WfmDenoiseLevel); + export!(RigFilterState); + export!(RdsData); + export!(SpectrumData); + export!(VchanRdsEntry); + export!(RigSnapshot); + export!(RigListItem); + export!(RigListResponse); + export!(FrontendMeta); + export!(DecoderActivation); + export!(DecoderDescriptor); + + let output_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frontend/src/api/generated.ts"); + fs::create_dir_all(output_path.parent().expect("generated file has a parent"))?; + fs::write(output_path, output)?; + Ok(()) +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs new file mode 100644 index 00000000..52015816 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { build } from "esbuild"; +import { rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const frontendDir = path.dirname(fileURLToPath(import.meta.url)); +const sourceDir = path.join(frontendDir, "src"); +const outputDir = path.join(frontendDir, "..", "assets", "web", "generated"); + +await rm(outputDir, { recursive: true, force: true }); + +await build({ + entryPoints: { + app: path.join(sourceDir, "bootstrap.ts"), + ft2: path.join(sourceDir, "plugins", "ft2.ts"), + ft4: path.join(sourceDir, "plugins", "ft4.ts"), + wspr: path.join(sourceDir, "plugins", "wspr.ts"), + cw: path.join(sourceDir, "plugins", "cw.ts"), + ft8: path.join(sourceDir, "plugins", "ft8.ts"), + vdes: path.join(sourceDir, "plugins", "vdes.ts"), + wefax: path.join(sourceDir, "plugins", "wefax.ts"), + "background-decode": path.join(sourceDir, "plugins", "background-decode.ts"), + ais: path.join(sourceDir, "plugins", "ais.ts"), + aprs: path.join(sourceDir, "plugins", "aprs.ts"), + "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"), + sat: path.join(sourceDir, "plugins", "sat.ts"), + "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), + vchan: path.join(sourceDir, "plugins", "vchan.ts"), + bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), + scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), + "map-core": path.join(sourceDir, "map-core.ts"), + screenshot: path.join(sourceDir, "screenshot.ts"), + }, + outdir: outputDir, + bundle: true, + format: "esm", + splitting: true, + entryNames: "[name]", + chunkNames: "chunk-[hash]", + platform: "browser", + target: "es2022", + sourcemap: false, + legalComments: "inline", + charset: "utf8", + logLevel: "info", +}); + +await build({ + entryPoints: [path.join(sourceDir, "decode-history-worker.ts")], + outfile: path.join(outputDir, "decode-history-worker.js"), + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + sourcemap: false, + legalComments: "inline", + charset: "utf8", + logLevel: "info", +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js new file mode 100644 index 00000000..caf39e54 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import eslint from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "../assets/web/generated/**", + ], + }, + { + files: ["**/*.mjs"], + ...eslint.configs.recommended, + languageOptions: { + globals: globals.node, + }, + }, + ...tseslint.configs.recommendedTypeChecked.map((config) => ({ + ...config, + files: ["src/**/*.ts", "tests/**/*.ts"], + })), + { + files: ["src/**/*.ts", "tests/**/*.ts"], + ignores: ["src/decode-history-worker.ts"], + languageOptions: { + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { + argsIgnorePattern: "^_", + caughtErrors: "none", + varsIgnorePattern: "^_", + }], + // DOM event targets intentionally ignore listener return values. Async + // listeners handle their own failures; keep the rule for all other + // promise misuse sites. + "@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }], + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], + }, + }, + { + files: ["src/decode-history-worker.ts"], + languageOptions: { + globals: globals.worker, + parserOptions: { + project: "./tsconfig.worker.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], + }, + }, +); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json new file mode 100644 index 00000000..8b062dd4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json @@ -0,0 +1,2003 @@ +{ + "name": "trx-frontend-http-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trx-frontend-http-web", + "version": "0.1.0", + "devDependencies": { + "@eslint/js": "9.39.2", + "@types/leaflet": "1.9.22", + "esbuild": "0.25.12", + "eslint": "9.39.2", + "globals": "16.5.0", + "playwright-core": "1.62.1", + "typescript": "5.9.3", + "typescript-eslint": "8.51.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.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/typescript-eslint": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", + "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.51.0", + "@typescript-eslint/parser": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json new file mode 100644 index 00000000..ccb5e9f0 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "trx-frontend-http-web", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "node build.mjs", + "generate-types": "cargo run -p trx-frontend-http --example generate_typescript", + "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", + "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", + "test": "node --test tests/*.test.mjs", + "test:browser": "node tests/browser-smoke.mjs", + "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "@types/leaflet": "1.9.22", + "esbuild": "0.25.12", + "eslint": "9.39.2", + "globals": "16.5.0", + "playwright-core": "1.62.1", + "typescript": "5.9.3", + "typescript-eslint": "8.51.0" + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md new file mode 100644 index 00000000..3b997837 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md @@ -0,0 +1,50 @@ + + +# Frontend source + +This directory contains the strict TypeScript source for the browser assets +embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point; +esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry +points loaded on demand by `plugin-loader.ts`, and the decode-history worker is +built with its own Web Worker TypeScript configuration. + +Run all frontend commands from the parent `frontend` directory: + +```sh +npm ci +npm run typecheck +npm run lint +npm test +npm run test:browser +npm run build +npm run verify-generated +``` + +`npm run verify-generated` regenerates Rust wire contracts and browser bundles, +then rejects drift from the committed files. The browser smoke test needs a +local Chromium-family executable; set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` +when it is not installed at `/usr/bin/chromium`. + +Cargo consumes committed output under `../assets/web/generated`. It never +invokes Node.js, installs packages, or accesses the network. After changing a +source file, commit the deterministic generated asset changes together with the +source change. + +## Boundaries + +- `api/generated.ts` is generated from Rust and must not be edited manually. +- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE + boundaries. +- `core/` contains dependency-light shared helpers. +- `features/` contains application behavior grouped by responsibility. +- `plugins/` contains lazy decoder and high-coupling feature entries. +- Files under `assets/web/vendor/` are vendored JavaScript and are not part of + the TypeScript migration. + +First-party features communicate through imports, the typed plugin runtime, or +the documented `window.trx` host interface. New standalone `window` callbacks +are not permitted. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts new file mode 100644 index 00000000..d7d9ece5 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/auth.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +export type AuthRole = "rx" | "control"; + +export interface AuthSession { + authenticated: boolean; + role?: AuthRole; + auth_disabled?: boolean; +} + +function decodeAuthSession(value: unknown): AuthSession { + if (typeof value !== "object" || value === null) { + throw new TypeError("The authentication response is malformed"); + } + const session = value as Record; + if (typeof session.authenticated !== "boolean") { + throw new TypeError("The authentication response has no authenticated flag"); + } + if (session.role !== undefined && session.role !== "rx" && session.role !== "control") { + throw new TypeError("The authentication response has an invalid role"); + } + if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") { + throw new TypeError("The authentication response has an invalid auth_disabled flag"); + } + const decoded: AuthSession = { authenticated: session.authenticated }; + if (session.role !== undefined) decoded.role = session.role; + if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled; + return decoded; +} + +const authDisabledSession: AuthSession = { + authenticated: true, + role: "control", + auth_disabled: true, +}; + +export async function fetchAuthSession(): Promise { + try { + const response = await fetch("/auth/session"); + if (response.status === 404) return authDisabledSession; + if (!response.ok) return { authenticated: false }; + return decodeAuthSession(await response.json()); + } catch (error: unknown) { + console.error("Auth check failed:", error); + return { authenticated: false }; + } +} + +export async function login(passphrase: string): Promise { + const response = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }); + if (response.status === 404) return authDisabledSession; + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Login failed"); + } + return decodeAuthSession(await response.json()); +} + +export async function logout(): Promise { + const response = await fetch("/auth/logout", { method: "POST" }); + if (response.status !== 404 && !response.ok) throw new Error("Logout failed"); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts new file mode 100644 index 00000000..587fdc18 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/client.ts @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { + DecoderDescriptor, + RigListResponse, + RigSnapshot, +} from "./generated"; + +export class ApiError extends Error { + public constructor( + public readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +type Validator = (value: unknown) => value is T; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isRigSnapshot(value: unknown): value is RigSnapshot { + if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) { + return false; + } + const { info, status } = value; + return ( + typeof value.initialized === "boolean" && + typeof info.manufacturer === "string" && + typeof info.model === "string" && + isRecord(status.freq) && + typeof status.freq.hz === "number" && + (typeof status.mode === "string" || isRecord(status.mode)) && + typeof status.tx_en === "boolean" + ); +} + +export function isRigListResponse(value: unknown): value is RigListResponse { + return ( + isRecord(value) && + (value.active_remote === null || typeof value.active_remote === "string") && + Array.isArray(value.rigs) && + value.rigs.every( + (rig) => + isRecord(rig) && + typeof rig.remote === "string" && + typeof rig.manufacturer === "string" && + typeof rig.model === "string" && + Array.isArray(rig.supported_modes) && + typeof rig.tx === "boolean" && + typeof rig.filter_controls === "boolean" && + typeof rig.initialized === "boolean", + ) + ); +} + +export function isDecoderRegistry(value: unknown): value is DecoderDescriptor[] { + return ( + Array.isArray(value) && + value.every( + (decoder) => + isRecord(decoder) && + typeof decoder.id === "string" && + typeof decoder.label === "string" && + (decoder.activation === "mode_bound" || decoder.activation === "toggle") && + Array.isArray(decoder.active_modes) && + decoder.active_modes.every((mode) => typeof mode === "string") && + typeof decoder.background_decode === "boolean" && + typeof decoder.bookmark_selectable === "boolean", + ) + ); +} + +export class TrxApi { + public constructor(private readonly baseUrl = "") {} + + public async get(path: string, validate: Validator): Promise { + return this.request(path, { cache: "no-store" }, validate); + } + + public async post( + path: string, + body: unknown, + validate: Validator, + ): Promise { + return this.request( + path, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + validate, + ); + } + + private async request( + path: string, + init: RequestInit, + validate: Validator, + ): Promise { + const response = await fetch(`${this.baseUrl}${path}`, init); + if (!response.ok) { + const detail = await response.text(); + throw new ApiError(response.status, detail || response.statusText); + } + const value: unknown = await response.json(); + if (!validate(value)) { + throw new ApiError(response.status, `Malformed response from ${path}`); + } + return value; + } +} + +export function decodeServerEvent( + event: MessageEvent, + validate: Validator, +): T { + let value: unknown; + try { + value = JSON.parse(event.data) as unknown; + } catch (error: unknown) { + throw new TypeError("Server event is not valid JSON", { cause: error }); + } + if (!validate(value)) { + throw new TypeError("Server event has an unexpected shape"); + } + return value; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts new file mode 100644 index 00000000..0a772a85 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/api/generated.ts @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Generated by `cargo run -p trx-frontend-http --example generate_typescript`. +// Do not edit manually. + +export type Band = { low_hz: number, high_hz: number, tx_allowed: boolean, }; + +export type Freq = { hz: number, }; + +export type RigMode = "LSB" | "USB" | "CW" | "CWR" | "AM" | "SAM" | "WFM" | "FM" | "AIS" | "VDES" | "DIG" | "PKT" | { "Other": string }; + +export type RigAccessMethod = { "Serial": { path: string, baud: number, } } | { "Tcp": { addr: string, } }; + +export type RigCapabilities = { min_freq_step_hz: number, supported_bands: Array, supported_modes: Array, num_vfos: number, lock: boolean, lockable: boolean, attenuator: boolean, preamp: boolean, rit: boolean, rpt: boolean, split: boolean, +/** + * Backend supports transmit: PTT, power on/off, TX meters, TX audio. + */ +tx: boolean, +/** + * Backend supports get_tx_limit / set_tx_limit. + */ +tx_limit: boolean, +/** + * Backend supports toggle_vfo. + */ +vfo_switch: boolean, +/** + * Backend supports runtime filter adjustment (bandwidth). + */ +filter_controls: boolean, +/** + * Backend returns a meaningful RX signal strength value. + */ +signal_meter: boolean, }; + +export type RigInfo = { manufacturer: string, model: string, revision: string, capabilities: RigCapabilities, access: RigAccessMethod, }; + +export type RigVfoEntry = { name: string, freq: Freq, mode: RigMode | null, }; + +export type RigVfo = { entries: Array, +/** + * Index into `entries` for the active VFO, if known. + */ +active: number | null, }; + +export type RigTxStatus = { power: number | null, limit: number | null, swr: number | null, alc: number | null, }; + +export type RigRxStatus = { sig: number | null, }; + +export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, }; + +export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, }; + +export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high"; + +export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel, +/** + * Co-Channel Interference level (0–100 scale). + */ +wfm_cci: number, +/** + * Adjacent Channel Interference level (0–100 scale). + */ +wfm_aci: number, +/** + * SAM stereo width (0.0 = mono, 1.0 = full stereo). + */ +sam_stereo_width: number, +/** + * SAM carrier synchronization enabled. + */ +sam_carrier_sync: boolean, }; + +export type RdsData = { pi?: number | null, program_service?: string | null, radio_text?: string | null, program_type_name_long?: string | null, pty?: number | null, pty_name?: string | null, traffic_program?: boolean | null, traffic_announcement?: boolean | null, music?: boolean | null, stereo?: boolean | null, artificial_head?: boolean | null, compressed?: boolean | null, dynamic_pty?: boolean | null, alternative_frequencies_hz?: Array | null, }; + +export type SpectrumData = { +/** + * FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2. + */ +bins: Array, +/** + * Centre frequency of the SDR capture in Hz. + */ +center_hz: number, +/** + * SDR capture sample rate in Hz; the displayed span is ±sample_rate/2. + */ +sample_rate: number, +/** + * Decoded Radio Data System state, when available for WFM. + */ +rds?: RdsData | null, }; + +export type VchanRdsEntry = { +/** + * Virtual channel UUID. + */ +id: string, +/** + * Latest RDS data, if decoded. + */ +rds?: RdsData | null, +/** + * Channel signal level in dBFS. + */ +signal_db?: number | null, }; + +export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | null, enabled: boolean | null, initialized: boolean, server_callsign?: string | null, server_version?: string | null, server_build_date?: string | null, server_latitude?: number | null, server_longitude?: number | null, pskreporter_status?: string | null, aprs_is_status?: string | null, cw_auto: boolean, cw_wpm: number, cw_tone_hz: number, filter?: RigFilterState | null, spectrum?: SpectrumData | null, +/** + * Per-virtual-channel RDS snapshots, when available. + */ +vchan_rds?: Array | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, }; + +export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, }; + +export type RigListResponse = { active_remote: string | null, rigs: Array, }; + +export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, }; + +export type DecoderActivation = "mode_bound" | "toggle"; + +export type DecoderDescriptor = { +/** + * Machine identifier, e.g. `"ft8"`, `"aprs"`. + */ +id: string, +/** + * Human-readable label, e.g. `"FT8"`, `"APRS"`. + */ +label: string, +/** + * How the decoder is activated. + */ +activation: DecoderActivation, +/** + * Rig modes where this decoder operates (upper-case). + */ +active_modes: Array, +/** + * Whether the decoder can run on SDR virtual channels + * (background-decode / scheduler). + */ +background_decode: boolean, +/** + * Whether this decoder should appear in bookmark forms. + */ +bookmark_selectable: boolean, }; + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts similarity index 76% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index b41fdb46..fb0f8b9e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -2,142 +2,412 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -// --- Decoder registry (fetched from /decoders on load) --- -/** @type {Array<{id:string,label:string,activation:string,active_modes:string[],background_decode:boolean,bookmark_selectable:boolean}>} */ -let decoderRegistry = []; -window.decoderRegistry = decoderRegistry; +import { + formatDistanceKm, + formatTimeAgo, + haversineKm, + latLonToMaidenhead, + locatorToLatLon, +} from "./core/geo.js"; +import { escapeHtml as escapeMapHtml } from "./core/dom.js"; +import { loadSetting, saveSetting } from "./core/settings.js"; +import { + applyDecoderRegistryVisibility, + decoderRegistry, + loadDecoderRegistry, +} from "./core/decoder-registry.js"; +import { + fetchAuthSession, + login, + logout, +} from "./api/auth.js"; +import { + formatByteSize as recorderFormatSize, + formatDuration as formatUptime, + formatFrequency as formatFreq, + formatFrequencyForHumans as formatFreqForHumans, + formatFrequencyForStep as formatFreqForStep, + formatWavelength, + parseFrequencyInput, +} from "./core/format.js"; +import { decodeCbor as decodeCborPayload } from "./core/cbor.js"; +import { + TAB_ORDER, + tabFromPath as tabFromPathname, + updateTabHistory, +} from "./features/navigation/routes.js"; +import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js"; +import { + decodeBase64Int8 as decodeBase64ToInt8, + estimateNoiseFloorDb, + isNumericBins as isBinsArray, +} from "./features/spectrum/math.js"; +import type { NumericBins } from "./features/spectrum/math.js"; +import type { AuthRole } from "./api/auth.js"; +import type { FrontendMeta, RdsData, RigCapabilities, RigListItem, RigSnapshot, VchanRdsEntry } from "./api/generated.js"; +import type { TrxPluginRuntime } from "./plugins/runtime-contract.js"; +import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js"; +import { isRigListResponse, isRigSnapshot } from "./api/client.js"; -/** Callbacks invoked once the decoder registry is fetched. */ -const _decoderRegistryReadyCallbacks = []; -window.onDecoderRegistryReady = function (fn) { - if (decoderRegistry.length > 0) fn(); - else _decoderRegistryReadyCallbacks.push(fn); +interface SpectrumFrame { + bins: NumericBins; + center_hz: number; + sample_rate: number; + rds?: RdsData | null; +} +interface PreviousTuneState { + freqHz: number | null; + bandwidthHz: number; + mode: string; + centerHz: number | null; +} +interface SignalSample { t: number; v: number } +interface SpectrumResizeState { startY: number; startTotalHeight: number; minTotalHeight: number } +interface SpectrumRange { + loHz?: number; + hiHz?: number; + visLoHz: number; + visHiHz: number; + fullSpanHz?: number; + visSpanHz: number; +} +interface PostOptions extends RequestInit { remote?: string | null; allowDuringRigSwitch?: boolean } +interface BandwidthSpec { centerHz: number; widthHz: number } +interface CanvasPalette { + bg: string; + spectrumLine: string; + spectrumFill: string; + spectrumGrid: string; + spectrumLabel: string; + waveformLine: string; + waveformPeak: string; + waveformGrid: string; + waveformLabel: string; + waterfallHue: readonly [number, number]; + waterfallSat: number; + waterfallLight: readonly [number, number]; + waterfallAlpha: readonly [number, number]; +} +type ThemeName = "dark" | "light"; +type StyleName = "original" | "arctic" | "lime" | "contrast" | "neon-disco" | "golden-rain" | "amber" | "fire" | "phosphor"; +type TabName = typeof TAB_ORDER[number]; +interface WiredElement extends HTMLElement { _subtabWired?: boolean } +interface DecoderToggleEntry { el: HTMLElement; last: boolean | null; label: string } +interface RdsOverlayEntry { id: string; freq_hz: number; rds: RdsData; el: HTMLElement } +interface SupportedBand { low: number; high: number } +interface SpectrumPeakHoldFrame { t: number; bins: NumericBins } +interface SpectrumFrameWaiter { + targetCenterHz: number | null; + resolve(frame: SpectrumFrame): void; + reject(error: Error): void; + timer: ReturnType | null; +} +interface AudioStreamInfo { + sample_rate: number; + channels: number; + bitrate_bps?: number; + frame_duration_ms?: number; +} +interface WasmOpusDecoder { + ready: Promise; + decodeFrame(data: Uint8Array): { channelData: Float32Array[]; samplesDecoded: number; sampleRate?: number }; + free(): void; +} +interface TxProcessor { source: MediaStreamAudioSourceNode; processor: ScriptProcessorNode } +interface RecorderFile { name: string; size: number } +interface RecorderActive { rig_id: string; vchan_id?: string | null; path: string; started_at: number } +interface Bookmark { + id: string; + name: string; + freq_hz: number; + mode?: string; + bandwidth_hz?: number | null; + category?: string | null; + locator?: string | null; + comment?: string | null; +} +type AppUpdate = RigSnapshot & FrontendMeta & { + [key: string]: unknown; }; +interface BandplanSegment { low_hz: number; high_hz: number; mode: string; label: string } +interface BandplanBand { name: string; low_hz: number; high_hz: number; segments: BandplanSegment[] } +type BandplanData = Record; +interface VisibleBandplanSegment extends BandplanSegment { band: string } +interface SpectrumDragStart { clientX: number; panFrac: number } +type SpectrumTouchState = + | { type: "pan"; clientX: number; panFrac: number } + | { type: "pinch"; dist: number; zoom: number; panFrac: number; midX: number }; +interface DecodeMessage { + type: string; + ts_ms?: number; + rig_id?: string; + remote?: string; + [key: string]: unknown; +} +interface DecodeHistoryGroup { kind: string; messages: DecodeMessage[] } +type DecodeHistoryGroups = Record; +type Rgba = [number, number, number, number]; +interface TrxWebGlRenderer { + readonly ready: boolean; + ensureSize(width?: number, height?: number, devicePixelRatio?: number): boolean; + clear(color: Rgba): void; + drawPolyline(points: readonly number[], color: Rgba, width: number): void; + drawSegments(points: readonly number[], color: Rgba, width: number): void; + drawFilledArea(points: readonly number[], baseline: number, color: Rgba): void; + drawPoints(points: readonly number[], size: number, color: Rgba): void; + fillRect(x: number, y: number, width: number, height: number, color: Rgba): void; + fillGradientRect(x: number, y: number, width: number, height: number, topLeft: Rgba, topRight: Rgba, bottomRight: Rgba, bottomLeft: Rgba): void; + drawDashedVerticalLine(x: number, y0: number, y1: number, dashLength: number, gapLength: number, color: Rgba, width?: number): void; + uploadRgbaTexture(name: string, width: number, height: number, data: Uint8Array, filter: string): void; + drawTexture(name: string, x: number, y: number, width: number, height: number, alpha?: number, flipY?: boolean): void; +} +interface VirtualChannel { + id: string; + freq_hz: number; + mode: string; + bandwidth_hz?: number; +} +interface TrxModules { + map?: { + aprsMap?: { invalidateSize(): void } | null; + initAprsMap(): void; + sizeAprsMapToViewport(): void; + pruneMapHistory(): void; + updateMapBaseLayerForTheme(theme: string): void; + syncLocatorMarkerStyles(): void; + refreshAisMarkerColors(): void; + updateMapRigFilter(): void; + syncAprsReceiverMarker(): void; + scheduleStatsRender(): void; + statsRecordDecode(type: string, rigId: string | null, timestampMs?: number): void; + reverseGeocodeLocation(lat: number, lon: number, grid: string): void; + bandForHz(frequencyHz: number): unknown; + }; + scheduler?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; + backgroundDecode?: { initialize(rigId: string | null, role: AuthRole | null): void; setRig(rigId: string | null): void; wireEvents(): void }; + bookmarks?: { + readonly overlayList: readonly Bookmark[]; + readonly overlayRevision: number; + formatFrequency(frequencyHz: number): string; + populateScopePicker(): void; + fetch(category: string): Promise; + apply(bookmark: Bookmark): void; + invalidateColors(): void; + }; + vchan?: { + readonly channels: readonly VirtualChannel[]; + readonly activeId: string | null; + activeChannel(): VirtualChannel | null; + handleChannels(data: string): void; + handleSession(data: string): void; + interceptBandwidth(bandwidthHz: number): Promise; + interceptMode(mode: string): Promise; + isOnVirtual(): boolean; + takeSchedulerControl(): Promise; + applyCapabilities(capabilities: RigCapabilities | null): void; + }; + screenshot?: { captureSpectrumScreenshot(): Promise }; +} +interface TrxState { + serverLat: number | null; + serverLon: number | null; + readonly lastFreqHz: number | null; + readonly lastActiveRigId: string | null; + readonly lastRigIds: string[]; + readonly lastRigDisplayNames: Record; + readonly initialMapZoom: number; + readonly decodeHistoryRetentionMin: number; + readonly authRole: AuthRole | null; + readonly decoderRegistry: typeof decoderRegistry; + readonly sseSessionId: string | null; + readonly primaryRds: RdsData | null; + readonly vchanRdsById: Map; + readonly vchanSignalDbById: Map; + lastCityLabel: string; + readonly serverVersion: string | null; + readonly serverBuildDate: string | null; + readonly serverCallsign: string | null; + readonly ownerCallsign: string | null; + readonly ownerWebsiteUrl: string | null; + readonly ownerWebsiteName: string | null; + readonly aisVesselUrlBase: string | null; + readonly serverRigs: RigListItem[]; + readonly serverActiveRigId: string | null; + readonly lastModeName: string; + readonly lastSpectrumData: SpectrumFrame | null; + readonly lastSpectrumRenderData: SpectrumFrame | null; + currentBandwidthHz: number; + readonly spectrumFloor: number; + readonly spectrumRange: number; + readonly spectrumCanvas: HTMLCanvasElement; + readonly overviewCanvas: HTMLCanvasElement; + readonly overviewGl: TrxWebGlRenderer; + readonly spectrumGl: TrxWebGlRenderer; + readonly signalOverlayGl: TrxWebGlRenderer; + readonly decodeHistoryReplayActive: boolean; + readonly decodeMapSyncPending: boolean; + readonly _activeTab: TabName; + readonly locationSubtitle: HTMLElement | null; +} +interface TrxUi { + confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise; + notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement; + setActiveRig(rigId: string | null): void; + setLayoutCapabilities(capabilities?: Partial>): void; + setButtonState(element: HTMLButtonElement | null, options?: { active?: boolean; activeLabel?: string; inactiveLabel?: string; busy?: boolean; disabled?: boolean }): void; + closeMobileOverlays?(restoreFocus?: boolean): void; + syncSelectedTab(container: HTMLElement | null, selected: Element): void; + prepareTabList(container: HTMLElement | null, kind?: "primary" | "secondary"): void; +} +declare function createTrxWebGlRenderer( + canvas: HTMLCanvasElement | null, + options?: WebGLContextAttributes, +): TrxWebGlRenderer; +declare function trxClearCssColorCache(): void; -(async function fetchDecoderRegistry() { - try { - const resp = await fetch("/decoders"); - if (resp.ok) { - decoderRegistry = await resp.json(); - window.decoderRegistry = decoderRegistry; - for (const fn of _decoderRegistryReadyCallbacks) fn(); - _decoderRegistryReadyCallbacks.length = 0; - hideUnsupportedDecoderTabs(); - refreshOperatorLayoutCapabilities(); - } - } catch (e) { - console.error("Failed to fetch decoder registry:", e); +function requiredElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing required application element #${id}`); + return element as T; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function primitiveString(value: unknown): string { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? String(value) + : ""; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonUnknown(source: string): unknown { + return JSON.parse(source) as unknown; +} + +function messageEventData(event: Event): string { + const data: unknown = (event as MessageEvent).data; + if (typeof data !== "string") throw new TypeError("Expected a text event payload"); + return data; +} + +async function responseJsonUnknown(response: Response): Promise { + return await response.json() as unknown; +} + +function isAppUpdate(value: unknown): value is AppUpdate { + return isRecord(value) + && typeof value.clients === "number" + && typeof value.audio_clients === "number" + && typeof value.rigctl_clients === "number" + && Array.isArray(value.remotes) + && value.remotes.every((remote) => typeof remote === "string") + && typeof value.show_sdr_gain_control === "boolean" + && typeof value.initial_map_zoom === "number" + && typeof value.spectrum_coverage_margin_hz === "number" + && typeof value.spectrum_usable_span_ratio === "number" + && typeof value.bandplan_enabled === "boolean" + && typeof value.bandplan_region === "string" + && typeof value.decode_history_retention_min === "number" + && typeof value.server_connected === "boolean" + && isRigSnapshot(value); +} + +function isAudioStreamInfo(value: unknown): value is AudioStreamInfo { + return isRecord(value) + && typeof value.sample_rate === "number" + && typeof value.channels === "number"; +} + +function isRecorderActiveList(value: unknown): value is RecorderActive[] { + return Array.isArray(value) && value.every((entry) => isRecord(entry) + && typeof entry.rig_id === "string" + && typeof entry.path === "string" + && typeof entry.started_at === "number"); +} + +function isRecorderFileList(value: unknown): value is RecorderFile[] { + return Array.isArray(value) && value.every((entry) => isRecord(entry) + && typeof entry.name === "string" + && typeof entry.size === "number"); +} + +function isRdsData(value: unknown): value is RdsData { + return isRecord(value); +} + +function isVchanRdsEntry(value: unknown): value is VchanRdsEntry { + return isRecord(value) + && typeof value.id === "string" + && (value.rds === undefined || value.rds === null || isRdsData(value.rds)) + && (value.signal_db === undefined || value.signal_db === null || typeof value.signal_db === "number"); +} + +declare global { + interface Window { + trx: { state: TrxState; core: Readonly>; modules: TrxModules }; + trxUi: TrxUi; + trxPluginRuntime: TrxPluginRuntime; + lastSpectrumData: SpectrumFrame | null; + lastFreqHz: number | null; + currentBandwidthHz: number; + ft8BaseHz: number; + getDecodeHistoryRetentionMs(): number; + applyDecodeHistoryRetention(): void; + getDecodeRigMeta(): { rigId: string; label: string; color: string }; + renderRdsOverlays(): void; + buildAisVesselUrl(mmsi: unknown): string | null; + trxScheduleUiFrameJob(key: string, job: () => void): void; + takeSchedulerControlForDecoderDisable(element: HTMLElement | null): Promise; + navigateToTab(name: TabName, options?: { updateHistory?: boolean; replaceHistory?: boolean }): void; + _syncRecorderState(enabled: boolean): void; + refreshRdsUi(): void; + refreshCwTonePicker?(): void; + updateFt8RfDisplay?(): void; + clearSatPredictionDom?(): void; + syncWefaxToggle?(enabled: boolean): void; + updateAisBar?(value?: number): void; + updateVdesBar?(value?: number): void; + updateAprsBar?(value?: number): void; + updateFt8Bar?(value?: number): void; + updateSatLiveState?(value: unknown): void; + applyCwAutoUi?(enabled: boolean): void; + applyCwAutoUiFromServer?(enabled: boolean): void; + trxParseCssColor?(color: string): Rgba; + trxHslToRgba?(hue: number, saturation: number, lightness: number, alpha?: number): Rgba; } -})(); - -/** Hide decoder sub-tabs, panels, about rows, and settings buttons - * for decoders not present in the server's registry. */ -function hideUnsupportedDecoderTabs() { - const knownIds = new Set(decoderRegistry.map(function (d) { return d.id; })); - // Sub-tabs that are not decoders and should always be visible. - const alwaysShow = new Set(["overview", "rds", "sat"]); - document.querySelectorAll("#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]").forEach(function (btn) { - const id = btn.dataset.subtab; - if (alwaysShow.has(id) || knownIds.has(id)) return; - btn.style.display = "none"; - var panel = document.getElementById("subtab-" + id); - if (panel) panel.style.display = "none"; - }); - // About-tab decoder status rows: LABEL - document.querySelectorAll('[id^="about-dec-"]').forEach(function (el) { - var id = el.id.replace("about-dec-", ""); - if (!alwaysShow.has(id) && !knownIds.has(id)) { - var row = el.closest("tr"); - if (row) row.style.display = "none"; - } - }); - // Settings clear-history buttons - document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach(function (el) { - var m = el.id.match(/^settings-clear-(.+)-history$/); - if (m && !alwaysShow.has(m[1]) && !knownIds.has(m[1])) { - el.style.display = "none"; - } - }); - // Overview decoder descriptions - document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach(function (el) { - var id = el.dataset.decoder; - if (!alwaysShow.has(id) && !knownIds.has(id)) { - el.style.display = "none"; - } - }); } -// --- Persistent settings (localStorage) --- -const STORAGE_PREFIX = "trx_"; -function saveSetting(key, value) { - try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {} -} -function loadSetting(key, fallback) { - try { - const v = localStorage.getItem(STORAGE_PREFIX + key); - return v !== null ? JSON.parse(v) : fallback; - } catch(e) { return fallback; } -} -function escapeMapHtml(input) { - return String(input) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); -} + +// --- Decoder registry (fetched from /decoders on load) --- +void loadDecoderRegistry(refreshOperatorLayoutCapabilities); // --- Authentication --- -let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access) +let authRole: AuthRole | null = null; let authEnabled = true; async function checkAuthStatus() { - try { - const resp = await fetch("/auth/session"); - if (resp.status === 404) { - // Auth API not exposed -> treat as auth-disabled mode. - return { authenticated: true, role: "control", auth_disabled: true }; - } - if (!resp.ok) return { authenticated: false }; - const data = await resp.json(); - return data; - } catch (e) { - console.error("Auth check failed:", e); - return { authenticated: false }; - } + return fetchAuthSession(); } -async function authLogin(passphrase) { - try { - const resp = await fetch("/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ passphrase }), - }); - if (resp.status === 404) { - return { authenticated: true, role: "control", auth_disabled: true }; - } - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || "Login failed"); - } - const data = await resp.json(); - return data; - } catch (e) { - throw e; - } +async function authLogin(passphrase: string) { + return login(passphrase); } async function authLogout() { try { - const resp = await fetch("/auth/logout", { method: "POST" }); - if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed"); + await logout(); authRole = null; // Disconnect and show auth gate without page reload disconnect(); setDecodeHistoryOverlayVisible(false); - document.getElementById("content").style.display = "none"; - document.getElementById("loading").style.display = "none"; - document.getElementById("auth-passphrase").value = ""; + requiredElement("content").style.display = "none"; + requiredElement("loading").style.display = "none"; + requiredElement("auth-passphrase").value = ""; updateAuthUI(); // Check if guest mode is available after logout @@ -153,20 +423,20 @@ async function authLogout() { function showAuthGate(allowGuest = false) { if (!authEnabled) return; setDecodeHistoryOverlayVisible(false); - document.getElementById("loading").style.display = "none"; - document.getElementById("content").style.display = "none"; - const authGate = document.getElementById("auth-gate"); + requiredElement("loading").style.display = "none"; + requiredElement("content").style.display = "none"; + const authGate = requiredElement("auth-gate"); authGate.style.display = "flex"; authGate.style.flexDirection = "column"; authGate.style.justifyContent = "center"; authGate.style.alignItems = "stretch"; - const signalVisualBlock = document.querySelector(".signal-visual-block"); + const signalVisualBlock = document.querySelector(".signal-visual-block"); if (signalVisualBlock) { signalVisualBlock.style.display = "none"; } // Hide all tab panels - document.querySelectorAll(".tab-panel").forEach(panel => { + document.querySelectorAll(".tab-panel").forEach(panel => { panel.style.display = "none"; }); @@ -176,23 +446,23 @@ function showAuthGate(allowGuest = false) { guestBtn.style.display = allowGuest ? "block" : "none"; } - document.querySelectorAll(".tab-bar .tab").forEach((btn) => { + document.querySelectorAll(".tab-bar .tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === "main"); }); syncTopBarAccess(); } function hideAuthGate() { - const authGate = document.getElementById("auth-gate"); + const authGate = requiredElement("auth-gate"); authGate.style.display = "none"; - document.getElementById("loading").style.display = "block"; - const signalVisualBlock = document.querySelector(".signal-visual-block"); + requiredElement("loading").style.display = "block"; + const signalVisualBlock = document.querySelector(".signal-visual-block"); if (signalVisualBlock) { signalVisualBlock.style.display = ""; } // Show the tab that matches the current route. - document.querySelectorAll(".tab-panel").forEach(panel => { + document.querySelectorAll(".tab-panel").forEach(panel => { panel.style.display = "none"; }); document.querySelectorAll(".tab-bar .tab").forEach(btn => { @@ -202,8 +472,8 @@ function hideAuthGate() { syncTopBarAccess(); } -function showAuthError(msg) { - const el = document.getElementById("auth-error"); +function showAuthError(msg: string) { + const el = requiredElement("auth-error"); el.textContent = msg; el.style.display = "block"; setTimeout(() => { @@ -245,21 +515,20 @@ function applyAuthRestrictions() { // Disable TX/PTT/frequency/mode/VFO controls for rx role if (authRole === "rx") { - const pttBtn = document.getElementById("ptt-btn"); - const powerBtn = document.getElementById("power-btn"); - const lockBtn = document.getElementById("lock-btn"); - const freqInput = document.getElementById("freq"); - const centerFreqInput = document.getElementById("center-freq"); - const modeSelect = document.getElementById("mode"); - const txLimitInput = document.getElementById("tx-limit"); - const txLimitBtn = document.getElementById("tx-limit-btn"); - const txAudioBtn = document.getElementById("tx-audio-btn"); + const pttBtn = document.getElementById("ptt-btn") as HTMLButtonElement | null; + const powerBtn = document.getElementById("power-btn") as HTMLButtonElement | null; + const lockBtn = document.getElementById("lock-btn") as HTMLButtonElement | null; + const freqInput = document.getElementById("freq") as HTMLInputElement | null; + const centerFreqInput = document.getElementById("center-freq") as HTMLInputElement | null; + const modeSelect = document.getElementById("mode") as HTMLSelectElement | null; + const txLimitInput = document.getElementById("tx-limit") as HTMLInputElement | null; + const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null; + const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null; const txLimitRow = document.getElementById("tx-limit-row"); - const vfoPicker = document.getElementById("vfo-picker"); - const jogUp = document.getElementById("jog-up"); - const jogDown = document.getElementById("jog-down"); - const jogButtons = document.querySelectorAll(".jog-step button"); - const vfoButtons = document.querySelectorAll("#vfo-picker button"); + const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null; + const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null; + const jogButtons = document.querySelectorAll(".jog-step button"); + const vfoButtons = document.querySelectorAll("#vfo-picker button"); // Disable TX buttons if (pttBtn) pttBtn.disabled = true; @@ -310,9 +579,9 @@ function applyAuthRestrictions() { ]; pluginToggleBtns.forEach(id => { const btn = document.getElementById(id); - if (btn && btn.tagName === "BUTTON") { + if (btn instanceof HTMLButtonElement) { btn.disabled = true; - } else if (btn && btn.type === "checkbox") { + } else if (btn instanceof HTMLInputElement && btn.type === "checkbox") { btn.disabled = true; } }); @@ -322,7 +591,7 @@ function applyAuthRestrictions() { } } -function applyCapabilities(caps) { +function applyCapabilities(caps: RigCapabilities | null) { if (!caps) return; lastHasTx = !!caps.tx; if (signalVisualBlockEl) signalVisualBlockEl.style.display = ""; @@ -333,11 +602,11 @@ function applyCapabilities(caps) { const txMetersRow = document.getElementById("tx-meters"); const txAudioBtn = document.getElementById("tx-audio-btn"); const txVolSlider = document.getElementById("tx-vol"); - const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; + const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; const hasPowerControl = !caps.filter_controls; if (txPowerCol) { txPowerCol.style.display = (caps.tx || hasPowerControl || caps.lockable) ? "" : "none"; - const label = txPowerCol.querySelector(".label span"); + const label = txPowerCol.querySelector(".label span"); if (label) { label.textContent = caps.tx && hasPowerControl ? "Transmit / Power" : caps.tx ? "Transmit / Tuning" @@ -351,7 +620,7 @@ function applyCapabilities(caps) { if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (!caps.tx && typeof stopTxAudio === "function" && txActive) { - stopTxAudio(); + void stopTxAudio(); } // TX limit row @@ -363,7 +632,7 @@ function applyCapabilities(caps) { if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none"; // Signal meter row - document.querySelectorAll(".full-row.label-below-row").forEach(row => { + document.querySelectorAll(".full-row.label-below-row").forEach(row => { const label = row.querySelector(".label span"); if (label && label.textContent === "Signal") { row.style.display = (caps.signal_meter && !caps.filter_controls) ? "" : "none"; @@ -393,46 +662,46 @@ function applyCapabilities(caps) { sdrSquelchSupported = false; } updateSdrSquelchControlVisibility(); - if (typeof vchanApplyCapabilities === "function") vchanApplyCapabilities(caps); + window.trx?.modules?.vchan?.applyCapabilities(caps); } -const freqEl = document.getElementById("freq"); -const centerFreqEl = document.getElementById("center-freq"); -const wavelengthEl = document.getElementById("wavelength"); -const sigStrengthEl = document.getElementById("sig-strength"); -const modeEl = document.getElementById("mode"); +const freqEl = requiredElement("freq"); +const centerFreqEl = requiredElement("center-freq"); +const wavelengthEl = requiredElement("wavelength"); +const sigStrengthEl = requiredElement("sig-strength"); +const modeEl = requiredElement("mode"); const bandLabel = document.getElementById("band-label"); -const powerBtn = document.getElementById("power-btn"); -const powerHint = document.getElementById("power-hint"); -const vfoPicker = document.getElementById("vfo-picker"); -const signalBar = document.getElementById("signal-bar"); -const signalValue = document.getElementById("signal-value"); -const pttBtn = document.getElementById("ptt-btn"); -const txLimitInput = document.getElementById("tx-limit"); -const txLimitBtn = document.getElementById("tx-limit-btn"); -const txLimitRow = document.getElementById("tx-limit-row"); -const lockBtn = document.getElementById("lock-btn"); -const txMeters = document.getElementById("tx-meters"); -const pwrBar = document.getElementById("pwr-bar"); -const pwrValue = document.getElementById("pwr-value"); -const swrBar = document.getElementById("swr-bar"); -const swrValue = document.getElementById("swr-value"); -const loadingEl = document.getElementById("loading"); -const contentEl = document.getElementById("content"); -const serverSubtitle = document.getElementById("server-subtitle"); -const rigSubtitle = document.getElementById("rig-subtitle"); +const powerBtn = requiredElement("power-btn"); +const powerHint = requiredElement("power-hint"); +const vfoPicker = requiredElement("vfo-picker"); +const signalBar = requiredElement("signal-bar"); +const signalValue = requiredElement("signal-value"); +const pttBtn = requiredElement("ptt-btn"); +const txLimitInput = requiredElement("tx-limit"); +const txLimitBtn = requiredElement("tx-limit-btn"); +const txLimitRow = requiredElement("tx-limit-row"); +const lockBtn = requiredElement("lock-btn"); +const txMeters = requiredElement("tx-meters"); +const pwrBar = requiredElement("pwr-bar"); +const pwrValue = requiredElement("pwr-value"); +const swrBar = requiredElement("swr-bar"); +const swrValue = requiredElement("swr-value"); +const loadingEl = requiredElement("loading"); +const contentEl = requiredElement("content"); +const serverSubtitle = requiredElement("server-subtitle"); +const rigSubtitle = requiredElement("rig-subtitle"); const ownerSubtitle = document.getElementById("owner-subtitle"); -const locationSubtitle = document.getElementById("location-subtitle"); -const loadingTitle = document.getElementById("loading-title"); -const loadingSub = document.getElementById("loading-sub"); +const locationSubtitle = requiredElement("location-subtitle"); +const loadingTitle = requiredElement("loading-title"); +const loadingSub = requiredElement("loading-sub"); const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); const connLostOverlayEl = document.getElementById("conn-lost-overlay"); const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); -const overviewCanvas = document.getElementById("overview-canvas"); -const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); +const overviewCanvas = requiredElement("overview-canvas"); +const signalOverlayCanvas = requiredElement("signal-overlay-canvas"); // Screenshots composite these live WebGL canvases into a PNG. const spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; const overviewGl = typeof createTrxWebGlRenderer === "function" @@ -441,40 +710,40 @@ const overviewGl = typeof createTrxWebGlRenderer === "function" const signalOverlayGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) : null; -const signalVisualBlockEl = document.querySelector(".signal-visual-block"); +const signalVisualBlockEl = document.querySelector(".signal-visual-block"); const signalSplitControlEl = document.getElementById("signal-split-control"); -const signalSplitSliderEl = document.getElementById("signal-split-slider"); +const signalSplitSliderEl = document.getElementById("signal-split-slider") as HTMLInputElement | null; const signalSplitValueEl = document.getElementById("signal-split-value"); -const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); -const themeToggleBtn = document.getElementById("theme-toggle"); -const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); +const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null; +const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | null; +const headerRigSwitchSelect = document.getElementById("header-rig-switch-select") as HTMLSelectElement | null; const headerRigSummary = document.getElementById("header-rig-summary"); -const headerStylePickSelect = document.getElementById("header-style-pick-select"); +const headerStylePickSelect = document.getElementById("header-style-pick-select") as HTMLSelectElement | null; const rdsPsOverlay = document.getElementById("rds-ps-overlay"); const tabMainEl = document.getElementById("tab-main"); // About-tab elements — resolved lazily after the about template is cloned. -let aboutServerVerEl = null; -let aboutServerBuildDateEl = null; -let aboutServerAddrEl = null; -let aboutServerCallEl = null; -let aboutServerLocationEl = null; -let aboutRigInfoEl = null; -let aboutRigAccessEl = null; -let aboutModesEl = null; -let aboutVfosEl = null; -let aboutActiveRigEl = null; -let aboutAudioCodecEl = null; -let aboutAudioSamplerateEl = null; -let aboutAudioChannelsEl = null; -let aboutAudioBitrateEl = null; -let aboutAudioFrameEl = null; -let aboutAudioRxEl = null; -let aboutAudioStreamsEl = null; -let aboutPskreporterEl = null; -let aboutAprsIsEl = null; -let aboutRigctlClientsEl = null; -let aboutRigctlEndpointEl = null; -let aboutClientsEl = null; +let aboutServerVerEl: HTMLElement | null = null; +let aboutServerBuildDateEl: HTMLElement | null = null; +let aboutServerAddrEl: HTMLElement | null = null; +let aboutServerCallEl: HTMLElement | null = null; +let aboutServerLocationEl: HTMLElement | null = null; +let aboutRigInfoEl: HTMLElement | null = null; +let aboutRigAccessEl: HTMLElement | null = null; +let aboutModesEl: HTMLElement | null = null; +let aboutVfosEl: HTMLElement | null = null; +let aboutActiveRigEl: HTMLElement | null = null; +let aboutAudioCodecEl: HTMLElement | null = null; +let aboutAudioSamplerateEl: HTMLElement | null = null; +let aboutAudioChannelsEl: HTMLElement | null = null; +let aboutAudioBitrateEl: HTMLElement | null = null; +let aboutAudioFrameEl: HTMLElement | null = null; +let aboutAudioRxEl: HTMLElement | null = null; +let aboutAudioStreamsEl: HTMLElement | null = null; +let aboutPskreporterEl: HTMLElement | null = null; +let aboutAprsIsEl: HTMLElement | null = null; +let aboutRigctlClientsEl: HTMLElement | null = null; +let aboutRigctlEndpointEl: HTMLElement | null = null; +let aboutClientsEl: HTMLElement | null = null; let _aboutElsResolved = false; function _resolveAboutEls() { if (_aboutElsResolved) return; @@ -504,15 +773,15 @@ function _resolveAboutEls() { aboutClientsEl = document.getElementById("about-clients"); } // Cached CW elements (avoid getElementById on every SSE render) -const cwAutoEl = document.getElementById("cw-auto"); -const cwWpmEl = document.getElementById("cw-wpm"); -const cwToneEl = document.getElementById("cw-tone"); +const cwAutoEl = document.getElementById("cw-auto") as HTMLInputElement | null; +const cwWpmEl = document.getElementById("cw-wpm") as HTMLInputElement | null; +const cwToneEl = document.getElementById("cw-tone") as HTMLInputElement | null; let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); let decodeHistoryRetentionMin = 24 * 60; // Cached decoder toggle buttons — built from the registry, keyed by status // field name (e.g. "ft8_decode_enabled"). Lazily populated on first SSE. -const _decoderToggles = {}; +const _decoderToggles: Record = {}; function _ensureDecoderToggles() { if (decoderRegistry.length === 0) return; for (const d of decoderRegistry) { @@ -524,7 +793,7 @@ function _ensureDecoderToggles() { } } -function syncDecoderToggle(entry, enabled, label) { +function syncDecoderToggle(entry: DecoderToggleEntry, enabled: boolean, label: string) { if (!entry.el || entry.last === enabled) return; entry.last = enabled; entry.el.dataset.enabled = enabled ? "true" : "false"; @@ -538,15 +807,18 @@ const _aboutDecIds = [ "about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr", "about-dec-cw", "about-dec-aprs", "about-dec-lrpt", ]; -let _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); +const _aboutDecEls: Array<{ el: HTMLElement | null; last: boolean | null }> = + _aboutDecIds.map(() => ({ el: null, last: null })); function _resolveAboutDecEls() { - if (_aboutDecEls[0].el) return; + if (_aboutDecEls[0]?.el) return; for (let i = 0; i < _aboutDecIds.length; i++) { - _aboutDecEls[i].el = document.getElementById(_aboutDecIds[i]); + const entry = _aboutDecEls[i]; + const id = _aboutDecIds[i]; + if (entry && id) entry.el = document.getElementById(id); } } -function syncAboutDecoder(idx, enabled) { +function syncAboutDecoder(idx: number, enabled: boolean) { const entry = _aboutDecEls[idx]; if (!entry || !entry.el || entry.last === enabled) return; entry.last = enabled; @@ -554,10 +826,10 @@ function syncAboutDecoder(idx, enabled) { entry.el.className = enabled ? "about-status-on" : "about-status-off"; } -let primaryRds = null; -let vchanRdsById = new Map(); -let vchanSignalDbById = new Map(); -let rdsOverlayEntries = []; +let primaryRds: RdsData | null = null; +let vchanRdsById = new Map(); +let vchanSignalDbById = new Map(); +let rdsOverlayEntries: RdsOverlayEntry[] = []; function currentDecodeHistoryRetentionMs() { const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || (24 * 60))); @@ -567,21 +839,18 @@ function currentDecodeHistoryRetentionMs() { window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.applyDecodeHistoryRetention = function() { - if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); - if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); - if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); - if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); - if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); - if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); + for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) { + window.trxPluginRuntime.prune(decoder); + } }; function syncTopBarAccess() { const loggedOut = authEnabled && !authRole; const tabBar = document.getElementById("tab-bar"); - const rigSwitch = document.querySelector(".header-rig-switch"); + const rigSwitch = document.querySelector(".header-rig-switch"); if (tabBar) tabBar.style.display = ""; - document.querySelectorAll(".tab-bar .tab").forEach((btn) => { + document.querySelectorAll(".tab-bar .tab").forEach((btn) => { const isMain = btn.dataset.tab === "main"; btn.style.display = !loggedOut || isMain ? "" : "none"; btn.disabled = false; @@ -597,72 +866,36 @@ function syncTopBarAccess() { } let overviewDrawPending = false; -function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") { +function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") { if (!decodeHistoryOverlayEl) return; if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title; if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || ""; decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible); } -function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying\u2026", fullscreen = false) { +function setConnLostOverlay(visible: boolean, title = "Connection lost", sub = "Retrying\u2026", fullscreen = false) { if (!connLostOverlayEl) return; if (connLostOverlayTitleEl) connLostOverlayTitleEl.textContent = title; if (connLostOverlaySubEl) connLostOverlaySubEl.textContent = sub; connLostOverlayEl.classList.toggle("conn-lost-fullscreen", fullscreen); connLostOverlayEl.classList.toggle("is-hidden", !visible); } -const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; let decodeHistoryReplayActive = false; let decodeMapSyncPending = false; -// --- Pending decode data buffers --- -// Map-data plugins (ais.js, aprs.js, vdes.js, hf-aprs.js) are loaded eagerly -// but dynamically-inserted scripts have no guaranteed execution order. If -// decode history or live SSE messages arrive before the plugin handlers are -// registered, buffer them here and let each plugin drain on init. -const _pendingDecodeHistory = {}; -const _pendingDecodeLive = {}; - -window._trxDrainPendingDecode = function(kind) { - const historyKey = { - ais: "restoreAisHistory", - vdes: "restoreVdesHistory", - aprs: "restoreAprsHistory", - hf_aprs: "restoreHfAprsHistory", - }[kind]; - if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { - const msgs = _pendingDecodeHistory[kind]; - delete _pendingDecodeHistory[kind]; - window[historyKey](msgs); - } - const liveKey = { - ais: "onServerAis", - vdes: "onServerVdes", - aprs: "onServerAprs", - hf_aprs: "onServerHfAprs", - }[kind]; - if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { - const msgs = _pendingDecodeLive[kind]; - delete _pendingDecodeLive[kind]; - for (const msg of msgs) { - try { window[liveKey](msg); } catch (_) {} - } - } -}; - function markDecodeMapSyncPending() { decodeMapSyncPending = true; } function flushDeferredDecodeMapSync() { - if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return; + if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap) return; decodeMapSyncPending = false; scheduleUiFrameJob("decode-map-maintenance", () => { window.trx.modules.map?.pruneMapHistory(); }); } -function setDecodeHistoryReplayActive(active) { +function setDecodeHistoryReplayActive(active: boolean) { decodeHistoryReplayActive = !!active; if (!decodeHistoryReplayActive) { flushDeferredDecodeMapSync(); @@ -670,149 +903,26 @@ function setDecodeHistoryReplayActive(active) { } function decodeHistoryMapRenderingDeferred() { - return decodeHistoryReplayActive || !window.trx?.map?.aprsMap; + return decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap; } -function decodeCborUint(view, bytes, state, additional) { - const offset = state.offset; - if (additional < 24) return additional; - if (additional === 24) { - if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 1; - return bytes[offset]; - } - if (additional === 25) { - if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 2; - return view.getUint16(offset); - } - if (additional === 26) { - if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 4; - return view.getUint32(offset); - } - if (additional === 27) { - if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getBigUint64(offset); - state.offset += 8; - const numeric = Number(value); - if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); - return numeric; - } - throw new Error("Unsupported CBOR additional info"); -} - -function decodeCborFloat16(bits) { - const sign = (bits & 0x8000) ? -1 : 1; - const exponent = (bits >> 10) & 0x1f; - const fraction = bits & 0x03ff; - if (exponent === 0) { - return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); - } - if (exponent === 0x1f) { - return fraction === 0 ? sign * Infinity : Number.NaN; - } - return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024)); -} - -function decodeCborItem(view, bytes, state) { - if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); - const initial = bytes[state.offset++]; - const major = initial >> 5; - const additional = initial & 0x1f; - if (major === 0) return decodeCborUint(view, bytes, state, additional); - if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); - if (major === 2) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.slice(state.offset, state.offset + length); - state.offset += length; - return Array.from(chunk); - } - if (major === 3) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.subarray(state.offset, state.offset + length); - state.offset += length; - return decodeHistoryTextDecoder ? decodeHistoryTextDecoder.decode(chunk) : String.fromCharCode(...chunk); - } - if (major === 4) { - const length = decodeCborUint(view, bytes, state, additional); - const items = new Array(length); - for (let i = 0; i < length; i += 1) { - items[i] = decodeCborItem(view, bytes, state); - } - return items; - } - if (major === 5) { - const length = decodeCborUint(view, bytes, state, additional); - const value = {}; - for (let i = 0; i < length; i += 1) { - const key = decodeCborItem(view, bytes, state); - value[String(key)] = decodeCborItem(view, bytes, state); - } - return value; - } - if (major === 6) { - decodeCborUint(view, bytes, state, additional); - return decodeCborItem(view, bytes, state); - } - if (major === 7) { - if (additional === 20) return false; - if (additional === 21) return true; - if (additional === 22) return null; - if (additional === 23) return undefined; - if (additional === 25) { - if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - const bits = view.getUint16(state.offset); - state.offset += 2; - return decodeCborFloat16(bits); - } - if (additional === 26) { - if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat32(state.offset); - state.offset += 4; - return value; - } - if (additional === 27) { - if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat64(state.offset); - state.offset += 8; - return value; - } - } - throw new Error("Unsupported CBOR major type"); -} - -function decodeCborPayload(buffer) { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const state = { offset: 0 }; - const value = decodeCborItem(view, bytes, state); - if (state.offset !== bytes.length) { - throw new Error("Unexpected trailing bytes in CBOR payload"); - } - return value; -} - -let lastSpectrumData = null; +let lastSpectrumData: SpectrumFrame | null = null; window.lastSpectrumData = null; -let lastControl; -let lastTxEn = null; +let lastTxEn: boolean | null = null; let lastHasTx = true; -let lastRendered = null; -let prevRenderData = {}; -let hintTimer = null; +let lastRendered: string | null = null; +const prevRenderData: Record = {}; +let hintTimer: ReturnType | null = null; let sigMeasuring = false; -let sigLastSUnits = null; -let sigLastDbm = null; +let sigLastSUnits: number | null = null; +let sigLastDbm: number | null = null; const SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; let sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); -function sigUnit(u) { return `${u}`; } +function sigUnit(u: string) { return `${u}`; } -function formatSigStrength(dbm) { - if (!Number.isFinite(dbm)) return "--"; +function formatSigStrength(dbm: number | null) { + if (dbm === null || !isFiniteNumber(dbm)) return "--"; const unit = SIG_STRENGTH_UNITS[sigStrengthUnitIdx] || "dBFS"; if (unit === "S") return formatSignal(dbmToSUnits(dbm)); if (unit === "dBm") return `${dbm.toFixed(1)} ${sigUnit("dBm")}`; @@ -840,12 +950,12 @@ if (sigStrengthEl) { }); } -let sigMeasureTimer = null; +let sigMeasureTimer: ReturnType | null = null; let sigMeasureLastTickMs = 0; let sigMeasureAccumMs = 0; let sigMeasureWeighted = 0; -let sigMeasurePeak = null; -let lastFreqHz = null; +let sigMeasurePeak: number | null = null; +let lastFreqHz: number | null = null; window.lastFreqHz = null; let centerFreqDirty = false; let jogUnit = loadSetting("jogUnit", 1000); // base unit: 1, 1000, 1000000 @@ -856,18 +966,18 @@ let lastModeName = ""; let lastWfmCci = 0; let lastWfmAci = 0; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; -function vfoColor(idx) { - if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; +function vfoColor(idx: number) { + if (idx < VFO_COLORS.length) return VFO_COLORS[idx] ?? VFO_COLORS[0] ?? "var(--accent-green)"; // Deterministic pseudo-random hue for extra VFOs const hue = ((idx * 137) % 360); return `hsl(${hue}, 70%, 55%)`; } let jogAngle = 0; -let lastClientCount = null; +let lastClientCount: number | null = null; let lastLocked = false; let sdrSquelchSupported = false; // ── Previous-state tracking for "B" hotkey ──────────────────────────────────── -let previousTuneState = null; // { freqHz, bandwidthHz, mode, centerHz } +let previousTuneState: PreviousTuneState | null = null; function savePreviousTuneState() { previousTuneState = { @@ -890,37 +1000,37 @@ async function restorePreviousTuneState() { await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`); updateWfmControls(); } - if (Number.isFinite(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { + if (isFiniteNumber(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { currentBandwidthHz = saved.bandwidthHz; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(currentBandwidthHz); await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`); } - if (Number.isFinite(saved.freqHz)) { + if (saved.freqHz !== null && isFiniteNumber(saved.freqHz)) { setRigFrequency(saved.freqHz); } - if (Number.isFinite(saved.centerHz)) { + if (isFiniteNumber(saved.centerHz)) { await postPath(`/set_center_freq?hz=${saved.centerHz}`); } showHint("Restored previous", 1500); } -let lastRigIds = []; -let lastRigDisplayNames = {}; -let lastActiveRigId = null; +let lastRigIds: string[] = []; +let lastRigDisplayNames: Record = {}; +let lastActiveRigId: string | null = null; let rigSwitchInProgress = false; let lastCityLabel = ""; -let sseSessionId = null; +let sseSessionId: string | null = null; const originalTitle = document.title; const savedTheme = loadSetting("theme", null); -function currentTheme() { +function currentTheme(): ThemeName { return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; } -function updateDocumentTitle(rds = null) { +function updateDocumentTitle(rds: RdsData | null = null) { const freqHz = activeChannelFreqHz(); - if (!Number.isFinite(freqHz)) { + if (!isFiniteNumber(freqHz)) { document.title = originalTitle; return; } @@ -936,7 +1046,7 @@ function updateDocumentTitle(rds = null) { document.title = parts.join(" - "); } -function setTheme(theme) { +function setTheme(theme: string) { const next = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); saveSetting("theme", next); @@ -950,14 +1060,15 @@ function setTheme(theme) { // Recolour bookmark chips after any palette/theme change (setTheme or setStyle). function invalidateBookmarkColors() { - if (typeof bmOverlayRevision === "undefined") return; - bmOverlayRevision++; + const bookmarks = window.trx?.modules.bookmarks; + if (!bookmarks) return; + bookmarks.invalidateColors(); // Force the browser to recalculate styles so getComputedStyle reads new values. void getComputedStyle(document.documentElement).getPropertyValue("--bg"); const colorMap = bmCategoryColorMap(); - const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; - document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { - const bm = ref.find((b) => b.id === chip.dataset.bmId); + const ref = bookmarks.overlayList; + document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { + const bm = ref.find((b: Bookmark) => b.id === chip.dataset.bmId); if (!bm) return; const col = colorMap[bm.category || ""] || "#66d9ef"; chip.style.setProperty("--bm-cat-bg", col); @@ -972,7 +1083,7 @@ function invalidateBookmarkColors() { } // ── Style / palette system ──────────────────────────────────────────────────── -const CANVAS_PALETTE = { +const CANVAS_PALETTE: Record> = { original: { dark: { bg: "#0a0f18", @@ -1137,24 +1248,24 @@ const CANVAS_PALETTE = { }, }; -function currentStyle() { - return document.documentElement.getAttribute("data-style") || "original"; +function currentStyle(): StyleName { + const style = document.documentElement.getAttribute("data-style"); + return style && style in CANVAS_PALETTE ? style as StyleName : "original"; } -function canvasPalette() { +function canvasPalette(): CanvasPalette { const s = currentStyle(); const t = currentTheme(); - return (CANVAS_PALETTE[s] ?? CANVAS_PALETTE.original)[t]; + return CANVAS_PALETTE[s][t]; } -function setStyle(style) { +function setStyle(style: string) { const remapped = style === "nord" ? "arctic" : style === "monokai" ? "lime" : style === "blood" ? "fire" : style; - const valid = ["original", "arctic", "lime", "contrast", "neon-disco", "golden-rain", "amber", "fire", "phosphor"]; - const next = valid.includes(remapped) ? remapped : "original"; + const next: StyleName = remapped in CANVAS_PALETTE ? remapped as StyleName : "original"; if (next === "original") { document.documentElement.removeAttribute("data-style"); } else { @@ -1168,7 +1279,7 @@ function setStyle(style) { } if (overviewPeakHoldEl) { - if (!Number.isFinite(overviewPeakHoldMs) || overviewPeakHoldMs < 0) { + if (!isFiniteNumber(overviewPeakHoldMs) || overviewPeakHoldMs < 0) { overviewPeakHoldMs = 2000; } overviewPeakHoldEl.value = String(overviewPeakHoldMs); @@ -1194,9 +1305,9 @@ setStyle(savedStyle); if (themeToggleBtn) { themeToggleBtn.addEventListener("click", () => { setTheme(currentTheme() === "dark" ? "light" : "dark"); - updateMapBaseLayerForTheme(currentTheme()); - syncLocatorMarkerStyles(); - refreshAisMarkerColors(); + window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme()); + window.trx.modules.map?.syncLocatorMarkerStyles(); + window.trx.modules.map?.refreshAisMarkerColors(); scheduleOverviewDraw(); if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw(); }); @@ -1205,9 +1316,9 @@ if (themeToggleBtn) { if (headerStylePickSelect) { headerStylePickSelect.addEventListener("change", () => { setStyle(headerStylePickSelect.value); - updateMapBaseLayerForTheme(currentTheme()); - syncLocatorMarkerStyles(); - refreshAisMarkerColors(); + window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme()); + window.trx.modules.map?.syncLocatorMarkerStyles(); + window.trx.modules.map?.refreshAisMarkerColors(); }); } @@ -1215,7 +1326,7 @@ function readyText() { return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready"; } -function rigBadgeColor(rigId) { +function rigBadgeColor(rigId: string) { const text = (rigId || "rx").toString(); let hash = 0; for (let i = 0; i < text.length; i++) { @@ -1234,7 +1345,7 @@ window.getDecodeRigMeta = function() { }; }; -function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { +function populateRigPicker(selectEl: HTMLSelectElement | null, rigIds: string[], activeRigId: string | null, disabled: boolean) { if (!selectEl) return; const selectedBefore = selectEl.value; selectEl.replaceChildren(); @@ -1253,7 +1364,7 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { selectEl.disabled = disabled; } -function updateRigIdentitySummary(rigId, pending = false) { +function updateRigIdentitySummary(rigId: string | null, pending = false) { if (!headerRigSummary) return; const rig = serverRigs.find((entry) => entry?.remote === rigId); if (!rig) { @@ -1267,14 +1378,14 @@ function updateRigIdentitySummary(rigId, pending = false) { headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; } -function updateRigSubtitle(activeRigId) { +function updateRigSubtitle(activeRigId: string | null) { if (!rigSubtitle) return; const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; rigSubtitle.textContent = `Rig: ${name}`; updateDocumentTitle(activeChannelRds()); } -function applyRigList(activeRigId, rigIds, displayNames) { +function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record = {}) { if (!Array.isArray(rigIds)) return; const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); // Detect whether the rig list or active rig actually changed so we can @@ -1306,10 +1417,10 @@ function applyRigList(activeRigId, rigIds, displayNames) { updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); if (rigListChanged) { - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); - if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); - if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); - if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + window.trx.modules.bookmarks?.populateScopePicker(); + void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || ""); } window.trx.modules.map?.updateMapRigFilter(); } @@ -1319,11 +1430,12 @@ async function refreshRigList() { try { const resp = await fetch("/rigs", { cache: "no-store" }); if (!resp.ok) return; - const data = await resp.json(); - const rigs = Array.isArray(data.rigs) ? data.rigs : []; - const rigIds = rigs.map((r) => r && r.remote).filter(Boolean); - const displayNames = {}; - rigs.forEach((r) => { + const data = await responseJsonUnknown(resp); + if (!isRigListResponse(data)) return; + const rigs = data.rigs; + const rigIds = rigs.map((r: RigListItem) => r.remote).filter(Boolean); + const displayNames: Record = {}; + rigs.forEach((r: RigListItem) => { if (!r || !r.remote) return; if (typeof r.display_name === "string" && r.display_name.length > 0) { displayNames[r.remote] = r.display_name; @@ -1336,7 +1448,7 @@ async function refreshRigList() { }); serverRigs = rigs; refreshOperatorLayoutCapabilities(); - serverActiveRigId = data.active_remote || null; + serverActiveRigId = data.active_remote; applyRigList(data.active_remote, rigIds, displayNames); window.trx.modules.map?.syncAprsReceiverMarker(); } catch (e) { @@ -1357,7 +1469,7 @@ function refreshOperatorLayoutCapabilities() { }); } -function showHint(msg, duration) { +function showHint(msg: string, duration?: number) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); @@ -1365,64 +1477,52 @@ function showHint(msg, duration) { window.trxUi?.notify(msg, { kind: "error" }); } } -let supportedModes = []; -let supportedBands = []; +let supportedModes: string[] = []; +let supportedBands: SupportedBand[] = []; let lastUnsupportedFreqPopupAt = 0; let freqDirty = false; let initialized = false; let lastEventAt = Date.now(); -let aboutUptimeStart = null; -let es; -let esHeartbeat; +let aboutUptimeStart: number | null = null; +let es: EventSource | null = null; +let esHeartbeat: ReturnType | null = null; -function formatUptime(ms) { - const s = Math.floor(ms / 1000); - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - const parts = []; - if (d > 0) parts.push(`${d}d`); - if (h > 0 || d > 0) parts.push(`${h}h`); - parts.push(`${m}m`); - parts.push(`${sec}s`); - return parts.join(" "); -} setInterval(() => { if (!aboutUptimeStart) return; const el = document.getElementById("about-uptime"); if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart); }, 1000); -let reconnectTimer = null; -let overviewSignalSamples = []; -let overviewSignalTimer = null; -let overviewWaterfallRows = []; +let reconnectTimer: ReturnType | null = null; +const overviewSignalSamples: SignalSample[] = []; +let overviewSignalTimer: ReturnType | null = null; +let overviewWaterfallRows: NumericBins[] = []; let overviewWaterfallPushCount = 0; // monotonically increments on every push const HEADER_SIG_WINDOW_MS = 10_000; const OVERVIEW_WF_TEX_MAX_W = 512; -let overviewWfTexData = null; +let overviewWfTexData: Uint8Array | null = null; let overviewWfTexWidth = 0; let overviewWfTexHeight = 0; let overviewWfTexPushCount = 0; let overviewWfTexPalKey = ""; let overviewWfTexReady = false; -function cssColorToRgba(color, alphaMul = 1) { - const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; - const parsed = parser ? parser(color) : [0, 0, 0, 1]; +function cssColorToRgba(color: string, alphaMul = 1): Rgba { + const parsed = typeof window.trxParseCssColor === "function" + ? window.trxParseCssColor(color) + : [0, 0, 0, 1]; return [ - parsed[0], - parsed[1], - parsed[2], - Math.max(0, Math.min(1, parsed[3] * alphaMul)), + parsed[0] ?? 0, + parsed[1] ?? 0, + parsed[2] ?? 0, + Math.max(0, Math.min(1, (parsed[3] ?? 1) * alphaMul)), ]; } -function rgbaWithAlpha(color, alphaMul = 1) { +function rgbaWithAlpha(color: string, alphaMul = 1): Rgba { return cssColorToRgba(color, alphaMul); } -const BW_OVERLAY_COLORS = { +const BW_OVERLAY_COLORS: Record<"soft" | "mid" | "edge" | "stroke" | "hard", Rgba> = { soft: [240 / 255, 173 / 255, 78 / 255, 0.05], mid: [240 / 255, 173 / 255, 78 / 255, 0.19], edge: [240 / 255, 173 / 255, 78 / 255, 0.30], @@ -1441,8 +1541,8 @@ function overviewWfResetTextureCache() { overviewWfTexReady = false; } -function overviewWfPaletteKey(pal, viewKey = "") { - return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; +function overviewWfPaletteKey(pal: CanvasPalette, viewKey = "") { + return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; } function resizeHeaderSignalCanvas() { @@ -1514,25 +1614,26 @@ function drawSignalOverlay() { signalOverlayGl.clear([0, 0, 0, 0]); const range = spectrumVisibleRange(lastSpectrumData); - const hzToX = (hz) => ((hz - range.visLoHz) / range.visSpanHz) * W; + const hzToX = (hz: number) => ((hz - range.visLoHz) / range.visSpanHz) * W; const bwSoft = BW_OVERLAY_COLORS.soft; const bwMid = BW_OVERLAY_COLORS.mid; const bwEdge = BW_OVERLAY_COLORS.edge; const bwStroke = BW_OVERLAY_COLORS.stroke; const bwHard = BW_OVERLAY_COLORS.hard; - const bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; - if (Array.isArray(bmRef) && bmRef.length > 0) { + const bmRef = window.trx.modules.bookmarks?.overlayList ?? null; + if (bmRef && bmRef.length > 0) { const colorMap = bmCategoryColorMap(); - const grouped = new Map(); + const grouped = new Map(); for (const bm of bmRef) { const f = Number(bm?.freq_hz); - if (!Number.isFinite(f) || f < range.visLoHz || f > range.visHiHz) continue; - if (Number.isFinite(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; + if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue; + if (lastFreqHz !== null && isFiniteNumber(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; const x = hzToX(f); - if (!Number.isFinite(x) || x < 0 || x > W) continue; + if (!isFiniteNumber(x) || x < 0 || x > W) continue; const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK; - if (!grouped.has(color)) grouped.set(color, []); - grouped.get(color).push(x, 0, x, H); + const segments = grouped.get(color) ?? []; + segments.push(x, 0, x, H); + grouped.set(color, segments); } for (const [color, segments] of grouped.entries()) { if (!Array.isArray(segments) || segments.length === 0) continue; @@ -1580,12 +1681,13 @@ function drawSignalOverlay() { } // Virtual channel markers (sky-blue dashed lines, active one is solid). - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels)) { - vchanChannels.forEach(ch => { - if (!Number.isFinite(ch.freq_hz) || ch.freq_hz <= 0) return; + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + virtualChannels.forEach((ch: { id: string; freq_hz: number }) => { + if (!isFiniteNumber(ch.freq_hz) || ch.freq_hz <= 0) return; const xc = hzToX(ch.freq_hz); if (xc < 0 || xc > W) return; - const isActive = ch.id === vchanActiveId; + const isActive = ch.id === window.trx.modules.vchan?.activeId; const color = cssColorToRgba("#38bdf8"); if (isActive) { signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5)); @@ -1626,12 +1728,12 @@ function scheduleOverviewDraw() { }); } -function pushHeaderSignalSample(sUnits) { +function pushHeaderSignalSample(sUnits: number) { if (!overviewCanvas) return; const now = Date.now(); - const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; + const sample = isFiniteNumber(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; overviewSignalSamples.push({ t: now, v: sample }); - while (overviewSignalSamples.length && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { + while (overviewSignalSamples[0] && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { overviewSignalSamples.shift(); } scheduleOverviewDraw(); @@ -1645,8 +1747,8 @@ function trimOverviewWaterfallRows() { } } -function overviewVisibleBinWindow(data, binCount) { - if (!data || !Number.isFinite(data.sample_rate) || binCount <= 1) { +function overviewVisibleBinWindow(data: SpectrumFrame | null, binCount: number) { + if (!data || !isFiniteNumber(data.sample_rate) || binCount <= 1) { return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; } const range = spectrumVisibleRange(data); @@ -1659,7 +1761,7 @@ function overviewVisibleBinWindow(data, binCount) { return { startIdx, endIdx }; } -function pushOverviewWaterfallFrame(data) { +function pushOverviewWaterfallFrame(data: SpectrumFrame) { if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; overviewWaterfallRows.push(data.bins.slice()); overviewWaterfallPushCount++; @@ -1670,7 +1772,7 @@ function pushOverviewWaterfallFrame(data) { function startHeaderSignalSampling() { if (!overviewCanvas || overviewSignalTimer) return; overviewSignalTimer = setInterval(() => { - pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0); + pushHeaderSignalSample(isFiniteNumber(sigLastSUnits) ? sigLastSUnits : 0); }, 120); } @@ -1693,7 +1795,7 @@ function drawHeaderSignalGraph() { updateBandplanStrip(bandplanComputeRange()); } -function drawOverviewWaterfall(W, H, pal) { +function drawOverviewWaterfall(W: number, H: number, pal: CanvasPalette) { if (!overviewGl || !overviewGl.ready) return; const maxVisible = Math.max(1, Math.floor(H)); const rows = overviewWaterfallRows.slice(-maxVisible); @@ -1701,8 +1803,8 @@ function drawOverviewWaterfall(W, H, pal) { const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2))); const iH = Math.max(1, rows.length); - const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; - const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90); + const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); const view = lastSpectrumData ? spectrumVisibleRange(lastSpectrumData) : null; const viewKey = view ? `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}` : "na"; const palKey = overviewWfPaletteKey(pal, viewKey); @@ -1717,12 +1819,13 @@ function drawOverviewWaterfall(W, H, pal) { if (!overviewWfTexData || overviewWfTexData.length !== expectedSize) { overviewWfTexData = new Uint8Array(expectedSize); } + const textureData = overviewWfTexData; overviewWfTexWidth = iW; overviewWfTexHeight = iH; ensureWaterfallLut(pal, minDb, maxDb); - function renderRow(dstY, srcBins) { + function renderRow(dstY: number, srcBins: NumericBins) { if (!isBinsArray(srcBins) || srcBins.length === 0) return; const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const spanBins = Math.max(1, endIdx - startIdx); @@ -1730,13 +1833,15 @@ function drawOverviewWaterfall(W, H, pal) { const iwM1 = Math.max(1, iW - 1); for (let x = 0; x < iW; x++) { const binIdx = Math.min(endIdx, startIdx + ((x * spanBins / iwM1) | 0)); - waterfallLutWrite(overviewWfTexData, rowBase + x * 4, srcBins[binIdx]); + const db = srcBins[binIdx]; + if (db !== undefined) waterfallLutWrite(textureData, rowBase + x * 4, db); } } if (needsFull) { for (let y = 0; y < iH; y++) { - renderRow(y, rows[y]); + const row = rows[y]; + if (row) renderRow(y, row); } overviewWfTexPushCount = overviewWaterfallPushCount; overviewWfTexPalKey = palKey; @@ -1744,13 +1849,17 @@ function drawOverviewWaterfall(W, H, pal) { } else if (newPushes > 0) { const newCount = Math.min(newPushes, iH); if (newCount >= iH) { - for (let y = 0; y < iH; y++) renderRow(y, rows[y]); + for (let y = 0; y < iH; y++) { + const row = rows[y]; + if (row) renderRow(y, row); + } } else { const shiftBytes = newCount * rowStride; - overviewWfTexData.copyWithin(0, shiftBytes); + textureData.copyWithin(0, shiftBytes); const startRow = iH - newCount; for (let y = startRow; y < iH; y++) { - renderRow(y, rows[y]); + const row = rows[y]; + if (row) renderRow(y, row); } } overviewWfTexPushCount = overviewWaterfallPushCount; @@ -1759,13 +1868,13 @@ function drawOverviewWaterfall(W, H, pal) { } if (texUpdated || !overviewWfTexReady) { - overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, overviewWfTexData, "linear"); + overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, textureData, "linear"); overviewWfTexReady = true; } overviewGl.drawTexture("overview-waterfall", 0, 0, W, H, 1, true); } -function drawOverviewSignalHistory(W, H, pal) { +function drawOverviewSignalHistory(W: number, H: number, pal: CanvasPalette) { if (!overviewGl || !overviewGl.ready) return; const now = Date.now(); const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS); @@ -1773,8 +1882,8 @@ function drawOverviewSignalHistory(W, H, pal) { const maxVal = 20; const windowStart = now - HEADER_SIG_WINDOW_MS; - const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * W; - const toY = (v) => H - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (H - 3) - 1.5; + const toX = (t: number) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * W; + const toY = (v: number) => H - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (H - 3) - 1.5; const gridMarkers = [ { val: 0 }, @@ -1788,28 +1897,31 @@ function drawOverviewSignalHistory(W, H, pal) { } overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1); - const linePoints = []; + const linePoints: number[] = []; samples.forEach((sample, idx) => { const x = toX(sample.t); const y = toY(sample.v); - if (idx === 0 || x >= linePoints[linePoints.length - 2]) { + if (idx === 0 || x >= (linePoints[linePoints.length - 2] ?? -Infinity)) { linePoints.push(x, y); } }); overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); - const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); if (holdMs > 0) { const holdPoints = []; for (let i = 0; i < samples.length; i++) { - let peak = samples[i].v; + const sample = samples[i]; + if (!sample) continue; + let peak = sample.v; for (let j = i; j >= 0; j--) { - if (samples[i].t - samples[j].t > holdMs) break; - if (samples[j].v > peak) peak = samples[j].v; + const prior = samples[j]; + if (!prior || sample.t - prior.t > holdMs) break; + if (prior.v > peak) peak = prior.v; } - const x = toX(samples[i].t); + const x = toX(sample.t); const y = toY(peak); - if (i === 0 || x >= holdPoints[holdPoints.length - 2]) { + if (i === 0 || x >= (holdPoints[holdPoints.length - 2] ?? -Infinity)) { holdPoints.push(x, y); } } @@ -1817,10 +1929,10 @@ function drawOverviewSignalHistory(W, H, pal) { } } -function waterfallColorRgba(db, pal, minDb, maxDb) { - const lo = Number.isFinite(minDb) ? minDb : (Number.isFinite(spectrumFloor) ? spectrumFloor : -115); - const hi = Number.isFinite(maxDb) ? maxDb : (lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90)); - const safeDb = Number.isFinite(db) ? db : lo; +function waterfallColorRgba(db: number, pal: CanvasPalette, minDb: number, maxDb: number): Rgba { + const lo = isFiniteNumber(minDb) ? minDb : (isFiniteNumber(spectrumFloor) ? spectrumFloor : -115); + const hi = isFiniteNumber(maxDb) ? maxDb : (lo + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90)); + const safeDb = isFiniteNumber(db) ? db : lo; const clamped = Math.max(lo, Math.min(hi, safeDb)); const span = Math.max(1, hi - lo); const tLinear = (clamped - lo) / span; @@ -1839,8 +1951,8 @@ function waterfallColorRgba(db, pal, minDb, maxDb) { let _wfLutKey = ""; const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range -function ensureWaterfallLut(pal, minDb, maxDb) { - const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`; +function ensureWaterfallLut(pal: CanvasPalette, minDb: number, maxDb: number) { + const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`; if (key === _wfLutKey) return; _wfLutKey = key; for (let i = 0; i < 256; i++) { @@ -1856,43 +1968,17 @@ function ensureWaterfallLut(pal, minDb, maxDb) { } // Fast waterfall pixel write using LUT. `db` is the raw i8 bin value. -function waterfallLutWrite(texData, offset, db) { +function waterfallLutWrite(texData: Uint8Array, offset: number, db: number) { // Convert signed i8 to 0-255 LUT index const idx = ((db | 0) + 256) & 0xFF; const p = idx * 4; - texData[offset] = _wfLut[p]; - texData[offset + 1] = _wfLut[p + 1]; - texData[offset + 2] = _wfLut[p + 2]; - texData[offset + 3] = _wfLut[p + 3]; + texData[offset] = _wfLut[p] ?? 0; + texData[offset + 1] = _wfLut[p + 1] ?? 0; + texData[offset + 2] = _wfLut[p + 2] ?? 0; + texData[offset + 3] = _wfLut[p + 3] ?? 0; } -function formatFreq(hz) { - if (!Number.isFinite(hz)) return "--"; - if (hz >= 1_000_000_000) { - return `${(hz / 1_000_000_000).toFixed(3)} GHz`; - } - if (hz >= 10_000_000) { - return `${(hz / 1_000_000).toFixed(3)} MHz`; - } - return `${(hz / 1_000).toFixed(1)} kHz`; -} - -function formatFreqForStep(hz, step) { - if (!Number.isFinite(hz)) return "--"; - if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6); - if (step >= 1_000) return (hz / 1_000).toFixed(3); - if (step >= 1) return String(Math.round(hz)); - return formatFreq(hz); -} - -function formatWavelength(hz) { - if (!Number.isFinite(hz) || hz <= 0) return "--"; - const meters = 299_792_458 / hz; - if (meters >= 1) return `${Math.round(meters)} m`; - return `${Math.round(meters * 100)} cm`; -} - -function refreshWavelengthDisplay(hz) { +function refreshWavelengthDisplay(hz: number) { if (!wavelengthEl) return; wavelengthEl.textContent = formatWavelength(hz); } @@ -1904,7 +1990,8 @@ function refreshFreqDisplay() { } function activeRdsChannelId() { - if (typeof vchanActiveId !== "undefined" && vchanActiveId) return vchanActiveId; + const virtualChannelId = window.trx.modules.vchan?.activeId; + if (virtualChannelId) return virtualChannelId; return null; } @@ -1914,36 +2001,38 @@ function activeChannelRds() { if (activeId) { const rds = vchanRdsById.get(activeId); if (rds) return rds; - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { - if (vchanChannels[0].id === activeId) return primaryRds; + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + if (virtualChannels[0]?.id === activeId) return primaryRds; } } return primaryRds; } function activeChannelIsWfm() { - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { const activeId = activeRdsChannelId(); - const active = vchanChannels.find((ch) => ch.id === activeId) || vchanChannels[0]; + const active = virtualChannels.find((ch: { id: string; mode: string }) => ch.id === activeId) || virtualChannels[0]; return String(active?.mode || "").toUpperCase() === "WFM"; } return lastModeName === "WFM"; } function activeChannelFreqHz() { - if (typeof vchanActiveChannel === "function") { - const ch = vchanActiveChannel(); - if (Number.isFinite(ch?.freq_hz)) return ch.freq_hz; + if (window.trx.modules.vchan) { + const ch = window.trx.modules.vchan.activeChannel(); + if (ch && isFiniteNumber(ch.freq_hz)) return ch.freq_hz; } return lastFreqHz; } function activeBandwidthCenterHz() { const freqHz = activeChannelFreqHz(); - return Number.isFinite(freqHz) ? freqHz : lastFreqHz; + return isFiniteNumber(freqHz) ? freqHz : lastFreqHz; } -function buildRdsOverlayHtml(rds) { +function buildRdsOverlayHtml(rds: RdsData | null) { const ps = rds?.program_service; const hasPs = !!(ps && ps.length > 0); const hasPi = rds?.pi != null; @@ -1967,17 +2056,18 @@ function buildRdsOverlayHtml(rds) { ); } -function collectRdsOverlayEntries() { - const entries = []; - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { - for (const ch of vchanChannels) { +function collectRdsOverlayEntries(): Array> { + const entries: Array> = []; + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { + for (const ch of virtualChannels) { if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; - if (!Number.isFinite(ch?.freq_hz)) continue; - const rds = vchanRdsById.get(ch.id) || (vchanChannels[0].id === ch.id ? primaryRds : null); + if (!isFiniteNumber(ch.freq_hz)) continue; + const rds = vchanRdsById.get(ch.id) || (virtualChannels[0]?.id === ch.id ? primaryRds : null); if (!rds) continue; entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds }); } - } else if (lastModeName === "WFM" && primaryRds && Number.isFinite(lastFreqHz)) { + } else if (lastModeName === "WFM" && primaryRds && lastFreqHz !== null && isFiniteNumber(lastFreqHz)) { entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); } return entries; @@ -2006,7 +2096,7 @@ function renderRdsOverlays() { el.innerHTML = html; el.addEventListener("click", (evt) => { evt.stopPropagation(); - copyRdsPsToClipboard(entry.rds, entry.freq_hz); + void copyRdsPsToClipboard(entry.rds, entry.freq_hz); }); el.addEventListener("mouseenter", () => { el.style.zIndex = String(entries.length + 10); @@ -2032,7 +2122,7 @@ function positionRdsOverlays() { const width = overviewCanvas.clientWidth || overviewCanvas.width || 0; if (width <= 0) return; const range = spectrumVisibleRange(lastSpectrumData); - if (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) return; + if (!isFiniteNumber(range.visLoHz) || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) return; // Assign z-indices: sort by frequency ascending so higher-frequency layers // sit on top of lower-frequency ones in the default (non-hover) state. const sortedByFreq = [...rdsOverlayEntries].sort((a, b) => a.freq_hz - b.freq_hz); @@ -2040,7 +2130,7 @@ function positionRdsOverlays() { rdsOverlayEntries.forEach((entry, idx) => { const el = entry.el; if (!el) return; - if (!Number.isFinite(entry.freq_hz)) { + if (!isFiniteNumber(entry.freq_hz)) { el.style.display = "none"; return; } @@ -2091,7 +2181,7 @@ function resetWfmStereoIndicator() { wfmStFlagEl.classList.add("wfm-st-flag-mono"); } -function updateIntfBar(fillEl, valEl, level) { +function updateIntfBar(fillEl: HTMLElement | null, valEl: HTMLElement | null, level: number) { if (!fillEl || !valEl) return; const v = Math.round(Math.min(Math.max(level, 0), 100)); valEl.textContent = String(v); @@ -2116,7 +2206,7 @@ const _fastFreqMarker = document.getElementById("fast-freq-marker"); const _fastBwLeft = document.getElementById("fast-bw-left"); const _fastBwRight = document.getElementById("fast-bw-right"); -function positionFastOverlay(freqHz, bwHz) { +function positionFastOverlay(freqHz: number | null, bwHz: number) { if (!lastSpectrumData || !signalVisualBlockEl) { if (_fastFreqMarker) _fastFreqMarker.style.display = "none"; if (_fastBwLeft) _fastBwLeft.style.display = "none"; @@ -2126,9 +2216,9 @@ function positionFastOverlay(freqHz, bwHz) { const cssW = signalVisualBlockEl.clientWidth; if (cssW <= 0) return; const range = spectrumVisibleRange(lastSpectrumData); - const hzToFrac = (hz) => (hz - range.visLoHz) / range.visSpanHz; + const hzToFrac = (hz: number) => (hz - range.visLoHz) / range.visSpanHz; - if (_fastFreqMarker && Number.isFinite(freqHz)) { + if (_fastFreqMarker && isFiniteNumber(freqHz)) { const frac = hzToFrac(freqHz); if (frac >= 0 && frac <= 1) { _fastFreqMarker.style.display = ""; @@ -2137,7 +2227,7 @@ function positionFastOverlay(freqHz, bwHz) { _fastFreqMarker.style.display = "none"; } } - if (_fastBwLeft && _fastBwRight && Number.isFinite(freqHz) && Number.isFinite(bwHz) && bwHz > 0) { + if (_fastBwLeft && _fastBwRight && isFiniteNumber(freqHz) && isFiniteNumber(bwHz) && bwHz > 0) { const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); let loHz, hiHz; if (side < 0) { @@ -2173,8 +2263,8 @@ function positionFastOverlay(freqHz, bwHz) { } } -function applyLocalTunedFrequency(hz, forceDisplay = false) { - if (!Number.isFinite(hz)) return; +function applyLocalTunedFrequency(hz: number, forceDisplay = false) { + if (!isFiniteNumber(hz)) return; const freqChanged = lastFreqHz !== hz; if (!freqChanged && !forceDisplay) return; if (freqChanged) { @@ -2214,19 +2304,11 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) { function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { const [, , maxBw] = mwDefaultsForMode(mode); - return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz); + return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz); } -function isAisMode(mode = modeEl ? modeEl.value : "") { - return String(mode || "").toUpperCase() === "AIS"; -} - -function isVdesMode(mode = modeEl ? modeEl.value : "") { - return String(mode || "").toUpperCase() === "VDES"; -} - -function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { - if (!Number.isFinite(freqHz)) return []; +function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : ""): BandwidthSpec[] { + if (!isFiniteNumber(freqHz)) return []; const modeUpper = String(mode || "").toUpperCase(); if (modeUpper === "AIS") { return [ @@ -2244,9 +2326,9 @@ function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { return 0; } -function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { +function displaySpanForBandwidthSpec(spec: BandwidthSpec, mode = modeEl ? modeEl.value : "") { const centerHz = Number(spec?.centerHz); - const widthHz = Math.max(0, Number.isFinite(spec?.widthHz) ? Number(spec.widthHz) : 0); + const widthHz = Math.max(0, isFiniteNumber(spec?.widthHz) ? Number(spec.widthHz) : 0); const side = sidebandDirectionForMode(mode); if (side < 0) { return { loHz: centerHz - widthHz, hiHz: centerHz, side }; @@ -2258,18 +2340,20 @@ function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; } -function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { - if (!Number.isFinite(freqHz)) return null; +function coverageSpanForMode(freqHz: number | null, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { + if (!isFiniteNumber(freqHz)) return null; const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { const widthHz = Math.max( 0, - Number.isFinite(spec.widthHz) ? spec.widthHz : Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0), + isFiniteNumber(spec.widthHz) ? spec.widthHz : Math.max(0, isFiniteNumber(bandwidthHz) ? bandwidthHz : 0), ); return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); }); if (specs.length === 0) return null; - let loHz = specs[0].loHz; - let hiHz = specs[0].hiHz; + const first = specs[0]; + if (!first) return null; + let loHz = first.loHz; + let hiHz = first.hiHz; for (const spec of specs.slice(1)) { loHz = Math.min(loHz, spec.loHz); hiHz = Math.max(hiHz, spec.hiHz); @@ -2277,42 +2361,44 @@ function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), m return { loHz, hiHz }; } -function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { - return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz); -} - -function effectiveSpectrumCoverageSpanHz(sampleRateHz) { +function effectiveSpectrumCoverageSpanHz(sampleRateHz: number) { const sampleRate = Number(sampleRateHz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0; + if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0; // Keep a guard band at the spectrum edges; practical usable span is slightly smaller. - const ratio = Number.isFinite(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; + const ratio = isFiniteNumber(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; return sampleRate * Math.max(0.01, Math.min(1.0, ratio)); } -function sweetSpotMinimumOffsetHz(bandwidthHz) { - if (!Number.isFinite(bandwidthHz) || bandwidthHz <= 0) return 0; +function sweetSpotMinimumOffsetHz(bandwidthHz: number) { + if (!isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) return 0; return bandwidthHz / 2; } -function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { - if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz)) return false; +function sweetSpotCenterHasRequiredOffset(centerHz: number, freqHz: number, bandwidthHz: number) { + if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz)) return false; const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); - if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) return true; + if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) return true; return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; } -function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { - if (!Number.isFinite(freqHz) || !Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { +function chooseSweetSpotCenterOutsideOffsetRange( + freqHz: number, + bandwidthHz: number, + minCenterHz: number, + maxCenterHz: number, + preferredCenterHz: number | null = null, +) { + if (!isFiniteNumber(freqHz) || !isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { return null; } const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); - if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) { - const fallbackCenterHz = Number.isFinite(preferredCenterHz) ? preferredCenterHz : freqHz; + if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) { + const fallbackCenterHz = isFiniteNumber(preferredCenterHz) ? preferredCenterHz : freqHz; return alignFreqToRigStep(Math.round(Math.max(minCenterHz, Math.min(maxCenterHz, fallbackCenterHz)))); } - const targetCentersHz = []; + const targetCentersHz: number[] = []; const lowerTargetHz = alignFreqToRigStep(Math.round(freqHz - minOffsetHz)); const upperTargetHz = alignFreqToRigStep(Math.round(freqHz + minOffsetHz)); if (lowerTargetHz >= minCenterHz && lowerTargetHz <= maxCenterHz) targetCentersHz.push(lowerTargetHz); @@ -2321,8 +2407,9 @@ function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterH } if (!targetCentersHz.length) return null; - if (Number.isFinite(preferredCenterHz)) { + if (isFiniteNumber(preferredCenterHz)) { let bestCenterHz = targetCentersHz[0]; + if (bestCenterHz === undefined) return null; let bestDistance = Math.abs(bestCenterHz - preferredCenterHz); for (const targetCenterHz of targetCentersHz.slice(1)) { const distance = Math.abs(targetCenterHz - preferredCenterHz); @@ -2337,11 +2424,11 @@ function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterH return targetCentersHz[0]; } -function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) { - if (!data || !Number.isFinite(freqHz)) return null; +function requiredCenterFreqForCoverageInFrame(data: SpectrumFrame | null, freqHz: number, bandwidthHz = coverageGuardBandwidthHz()) { + if (!data || !isFiniteNumber(freqHz)) return null; const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); const currentCenterHz = Number(data.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) { + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) { return null; } @@ -2370,13 +2457,13 @@ function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = covera return alignFreqToRigStep(Math.round(nextCenterHz)); } -function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { +function requiredCenterFreqForCoverage(freqHz: number, bandwidthHz = coverageGuardBandwidthHz()) { return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz); } -async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { +async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = coverageGuardBandwidthHz()) { const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz); - if (!Number.isFinite(nextCenterHz)) return; + if (!isFiniteNumber(nextCenterHz)) return; if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return; await postPath(`/set_center_freq?hz=${nextCenterHz}`); if (centerFreqEl && !centerFreqDirty) { @@ -2386,10 +2473,10 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB // Guard: while a set_freq is in flight, SSE state updates must not overwrite // the optimistic local frequency with the stale server value. -let _freqOptimisticHz = null; +let _freqOptimisticHz: number | null = null; let _freqOptimisticSeq = 0; -function setRigFrequency(freqHz) { +function setRigFrequency(freqHz: number) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); @@ -2417,8 +2504,8 @@ function setRigFrequency(freqHz) { }); } -function spectrumBinIndexForHz(data, hz) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) { +function spectrumBinIndexForHz(data: SpectrumFrame | null, hz: number) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 2 || !isFiniteNumber(hz)) { return null; } const maxIdx = data.bins.length - 1; @@ -2427,17 +2514,17 @@ function spectrumBinIndexForHz(data, hz) { return Math.max(0, Math.min(maxIdx, idx)); } -function spectrumPowerScore(db) { - const value = Number.isFinite(db) ? db : -160; +function spectrumPowerScore(db: number) { + const value = isFiniteNumber(db) ? db : -160; const clamped = Math.max(-160, Math.min(40, value)); return 10 ** (clamped / 10); } -function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { +function sweetSpotCandidateForFrame(data: SpectrumFrame | null, freqHz: number, bandwidthHz: number) { if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { return null; } - if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + if (!isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { return null; } @@ -2445,7 +2532,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { const sampleRate = Number(data.sample_rate); const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); const currentCenterHz = Number(data.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(usableSpanHz) || usableSpanHz <= 0 || !Number.isFinite(currentCenterHz)) { + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(usableSpanHz) || usableSpanHz <= 0 || !isFiniteNumber(currentCenterHz)) { return null; } @@ -2463,7 +2550,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { currentCenterHz + halfUsableSpanHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), ); - if (!Number.isFinite(fallbackCenterHz)) return null; + if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } @@ -2474,7 +2561,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz; const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz); const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz); - if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( freqHz, bandwidthHz, @@ -2482,7 +2569,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { evalMaxCenterHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), ); - if (!Number.isFinite(fallbackCenterHz)) return null; + if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } @@ -2498,7 +2585,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { Math.min(maxIdx - usableBins, Math.round((((maxCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)), ); - let bestStartIdx = null; + let bestStartIdx: number | null = null; let bestScore = Number.POSITIVE_INFINITY; const signalLoHz = span.loHz; const signalHiHz = span.hiHz; @@ -2510,13 +2597,13 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { continue; } - const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz))); - const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz))); + const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz ?? freqHz) ?? startIdx)); + const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz ?? freqHz) ?? endIdx)); let score = 0; for (let i = startIdx; i <= endIdx; i++) { if (i >= signalLoIdx && i <= signalHiIdx) continue; - score += spectrumPowerScore(bins[i]); + score += spectrumPowerScore(bins[i] ?? -128); } // Keep a very small bias toward a reasonably centered passband when scores are close. @@ -2529,7 +2616,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { } } - if (!Number.isFinite(bestScore) || bestStartIdx == null) { + if (!isFiniteNumber(bestScore) || bestStartIdx == null) { const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( freqHz, bandwidthHz, @@ -2537,7 +2624,7 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { maxCenterHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), ); - if (!Number.isFinite(fallbackCenterHz)) return null; + if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } @@ -2549,19 +2636,20 @@ function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { }; } -function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) { +function sweetSpotCenterFreq(freqHz: number | null = lastFreqHz, bandwidthHz = currentBandwidthHz) { + if (!isFiniteNumber(freqHz)) return null; const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); - return candidate && Number.isFinite(candidate.centerHz) ? candidate.centerHz : null; + return candidate && isFiniteNumber(candidate.centerHz) ? candidate.centerHz : null; } -function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { - if (!data || !Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { +function sweetSpotProbeCenters(data: SpectrumFrame | null, freqHz: number, bandwidthHz: number) { + if (!data || !isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { return []; } const sampleRate = Number(data.sample_rate); const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); - if (!Number.isFinite(usableSpanHz) || usableSpanHz <= 0) return []; + if (!isFiniteNumber(usableSpanHz) || usableSpanHz <= 0) return []; const halfUsableSpanHz = usableSpanHz / 2; const span = coverageSpanForMode(freqHz, bandwidthHz); @@ -2569,7 +2657,7 @@ function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { const requiredLoHz = span.loHz - spectrumCoverageMarginHz; const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; if (requiredHiHz - requiredLoHz >= usableSpanHz) { - const probeCenters = []; + const probeCenters: number[] = []; const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); for (const centerHz of [freqHz - minOffsetHz, freqHz + minOffsetHz]) { const alignedHz = alignFreqToRigStep(Math.round(centerHz)); @@ -2583,14 +2671,14 @@ function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { const minCenterHz = requiredHiHz - halfUsableSpanHz; const maxCenterHz = requiredLoHz + halfUsableSpanHz; - if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { return []; } const points = 5; - const centers = []; + const centers: number[] = []; for (let i = 0; i < points; i++) { - const frac = points === 1 ? 0.5 : i / (points - 1); + const frac = i / (points - 1); const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac)); if (sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - centerHz) < 1)) { @@ -2599,7 +2687,7 @@ function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { } const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz))); - if (Number.isFinite(currentCenterHz) + if (isFiniteNumber(currentCenterHz) && sweetSpotCenterHasRequiredOffset(currentCenterHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) { centers.push(currentCenterHz); @@ -2613,12 +2701,12 @@ async function applySweetSpotCenter() { showHint("Sweet-spot already scanning", 900); return; } - if (!Number.isFinite(lastFreqHz) || !lastSpectrumData) return; + if (!isFiniteNumber(lastFreqHz) || !lastSpectrumData) return; const originalCenterHz = Number(lastSpectrumData.center_hz); const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz); let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz); - if (!probeCentersHz.length && (!bestCandidate || !Number.isFinite(bestCandidate.centerHz))) { + if (!probeCentersHz.length && (!bestCandidate || !isFiniteNumber(bestCandidate.centerHz))) { showHint("Sweet-spot unavailable", 1100); return; } @@ -2628,7 +2716,7 @@ async function applySweetSpotCenter() { showHint("Scanning sweet spot...", 1400); for (const probeCenterHz of probeCentersHz) { - if (!Number.isFinite(probeCenterHz)) continue; + if (!isFiniteNumber(probeCenterHz)) continue; let probeFrame = lastSpectrumData; if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) { await postPath(`/set_center_freq?hz=${probeCenterHz}`); @@ -2640,17 +2728,17 @@ async function applySweetSpotCenter() { } const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz); - if (!candidate || !Number.isFinite(candidate.centerHz)) continue; + if (!candidate || !isFiniteNumber(candidate.centerHz)) continue; if (!bestCandidate || candidate.score < bestCandidate.score) { bestCandidate = candidate; } } - const targetCenterHz = bestCandidate && Number.isFinite(bestCandidate.centerHz) + const targetCenterHz = bestCandidate && isFiniteNumber(bestCandidate.centerHz) ? bestCandidate.centerHz : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); - if (!Number.isFinite(targetCenterHz)) { - if (Number.isFinite(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) { + if (!isFiniteNumber(targetCenterHz)) { + if (isFiniteNumber(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) { await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`); } showHint("Sweet-spot unavailable", 1100); @@ -2662,7 +2750,7 @@ async function applySweetSpotCenter() { if (centerFreqEl && !centerFreqDirty) { centerFreqEl.value = formatFreqForStep(targetCenterHz, jogUnit); } - if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { + if (isFiniteNumber(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { showHint("Already at sweet spot", 900); } else { showHint("Sweet-spot set", 1200); @@ -2672,10 +2760,10 @@ async function applySweetSpotCenter() { } } -function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { - if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null; +function tunedFrequencyForCenterCoverage(centerHz: number, freqHz: number | null = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { + if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz) || !lastSpectrumData) return null; const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); - if (!Number.isFinite(sampleRate) || sampleRate <= 0) return null; + if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return null; const span = coverageSpanForMode(freqHz, bandwidthHz); if (!span) return null; @@ -2697,13 +2785,13 @@ function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidt // Optimistic center freq: updated immediately on each arrow click so that // rapid clicks accumulate rather than all starting from the same stale frame. -let spectrumCenterPendingHz = null; +let spectrumCenterPendingHz: number | null = null; -async function shiftSpectrumCenter(direction) { - if (!lastSpectrumData || !Number.isFinite(direction) || direction === 0) return; +async function shiftSpectrumCenter(direction: -1 | 1) { + if (!lastSpectrumData) return; const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) return; + if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) return; const stepHz = Math.max(50_000, Math.round(sampleRate * 0.35)); const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz)); @@ -2715,7 +2803,7 @@ async function shiftSpectrumCenter(direction) { } const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); - if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { + if (isFiniteNumber(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { await postPath(`/set_freq?hz=${nextFreqHz}`); applyLocalTunedFrequency(nextFreqHz); } @@ -2726,79 +2814,30 @@ function refreshCenterFreqDisplay() { centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit); } -function parseFreqInput(val, defaultStep) { - if (!val) return null; - const trimmed = val.trim().toLowerCase(); - const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); - if (!match) return null; - const rawNumber = match[1]; - let num = parseFloat(rawNumber.replace(",", ".")); - const unit = match[2] || ""; - if (Number.isNaN(num)) return null; - if (unit.startsWith("gh") || unit === "g") { - num *= 1_000_000_000; - } else if (unit.startsWith("mh") || unit === "m") { - num *= 1_000_000; - } else if (unit.startsWith("kh") || unit === "k") { - num *= 1_000; - } else if (!unit) { - const mode = (modeEl?.value || "").toUpperCase(); - const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); - if (mode === "WFM") { - if (hasDecimalSeparator && num >= 50 && num < 200) { - num *= 1_000_000; - return Math.round(num); - } - if (!hasDecimalSeparator && num >= 875 && num <= 1080) { - num = (num / 10) * 1_000_000; - return Math.round(num); - } - } - // Use currently selected input unit when user omits suffix. - if (defaultStep >= 1_000_000) { - num *= 1_000_000; - } else if (defaultStep >= 1_000) { - num *= 1_000; - } else if (defaultStep >= 1) { - // already Hz - } else { - // Fallback heuristic. - if (num >= 1_000_000) { - // Assume already Hz. - } else if (num >= 1_000) { - num *= 1_000; - } else { - num *= 1_000_000; - } - } - } - return Math.round(num); -} - -function normalizeMinFreqStep(cap) { +function normalizeMinFreqStep(cap: RigCapabilities | null) { const val = Number(cap && cap.min_freq_step_hz); - if (!Number.isFinite(val) || val < 1) return 1; + if (!isFiniteNumber(val) || val < 1) return 1; return Math.round(val); } -function alignFreqToRigStep(hz) { - if (!Number.isFinite(hz)) return hz; +function alignFreqToRigStep(hz: number) { + if (!isFiniteNumber(hz)) return hz; const step = Math.max(1, minFreqStepHz); return Math.round(hz / step) * step; } -function updateJogStepSupport(cap) { +function updateJogStepSupport(cap: RigCapabilities | null) { const nextMinStep = normalizeMinFreqStep(cap); minFreqStepHz = nextMinStep; const stepRoot = document.getElementById("jog-step"); if (!stepRoot) return; - const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); + const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); if (buttons.length === 0) return; buttons.forEach((btn) => { const base = Number(btn.dataset.baseStep || btn.dataset.step); - if (Number.isFinite(base) && base > 0) { + if (isFiniteNumber(base) && base > 0) { btn.dataset.baseStep = String(Math.round(base)); btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz)); } @@ -2806,14 +2845,16 @@ function updateJogStepSupport(cap) { const steps = buttons .map((btn) => Number(btn.dataset.step)) - .filter((s) => Number.isFinite(s) && s > 0); + .filter((s) => isFiniteNumber(s) && s > 0); if (steps.length === 0) return; const current = Number(jogUnit); + const firstStep = steps[0]; + if (firstStep === undefined) return; const desired = - Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz); + isFiniteNumber(current) && current >= minFreqStepHz ? current : Math.max(firstStep, minFreqStepHz); - jogUnit = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), steps[0]); + jogUnit = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), firstStep); jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); saveSetting("jogUnit", jogUnit); saveSetting("jogStep", jogStep); @@ -2826,12 +2867,14 @@ function updateJogStepSupport(cap) { refreshCenterFreqDisplay(); } -function normalizeMode(modeVal) { +function normalizeMode(modeVal: unknown): string { if (typeof modeVal === "string") return modeVal; - if (modeVal && typeof modeVal === "object") { + if (isRecord(modeVal)) { const entries = Object.entries(modeVal); if (entries.length > 0) { - const [variant, value] = entries[0]; + const firstEntry = entries[0]; + if (!firstEntry) return ""; + const [variant, value] = firstEntry; if (variant === "Other" && typeof value === "string") return value; return variant; } @@ -2839,7 +2882,7 @@ function normalizeMode(modeVal) { return ""; } -function updateSupportedBands(cap) { +function updateSupportedBands(cap: RigCapabilities | null) { if (cap && Array.isArray(cap.supported_bands)) { supportedBands = cap.supported_bands .filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number") @@ -2849,8 +2892,8 @@ function updateSupportedBands(cap) { } } -function freqAllowed(hz) { - if (!Number.isFinite(hz)) return false; +function freqAllowed(hz: number) { + if (!isFiniteNumber(hz)) return false; if (supportedBands.length === 0) return true; // if unknown, don't block return supportedBands.some((b) => hz >= b.low && hz <= b.high); } @@ -2864,15 +2907,7 @@ function unsupportedBandSummary() { return `Supported ranges: ${ranges.join(", ")}`; } -function formatFreqForHumans(hz) { - if (!Number.isFinite(hz)) return "--"; - if (hz >= 1_000_000_000) return `${(hz / 1_000_000_000).toFixed(3)} GHz`; - if (hz >= 1_000_000) return `${(hz / 1_000_000).toFixed(3)} MHz`; - if (hz >= 1_000) return `${(hz / 1_000).toFixed(3)} kHz`; - return `${Math.round(hz)} Hz`; -} - -function showUnsupportedFreqPopup(hz) { +function showUnsupportedFreqPopup(hz: number) { const message = `Unsupported frequency: ${formatFreqForHumans(hz)}.\n\n${unsupportedBandSummary()}`; showHint("Out of supported range", 1800); const now = Date.now(); @@ -2883,8 +2918,8 @@ function showUnsupportedFreqPopup(hz) { // Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). // Above S9, returns 9 + (overshoot in S-unit-equivalent, i.e. dB/10). -function dbmToSUnits(dbm) { - if (!Number.isFinite(dbm)) return 0; +function dbmToSUnits(dbm: number) { + if (!isFiniteNumber(dbm)) return 0; // Guard against bogus backend values to keep display in a realistic range. const clampedDbm = Math.max(-140, Math.min(20, dbm)); if (clampedDbm <= -121) return 0; @@ -2892,31 +2927,31 @@ function dbmToSUnits(dbm) { return (clampedDbm + 121) / 6; } -function formatSignal(sUnits) { - if (!Number.isFinite(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; +function formatSignal(sUnits: number) { + if (!isFiniteNumber(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`; // S9+xdB: round to nearest 10 dB step, cap at +60. const overDb = Math.min(60, Math.round((sUnits - 9) * 10 / 10) * 10); return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; } -function setDisabled(disabled) { +function setDisabled(disabled: boolean) { [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { if (el) el.disabled = disabled; }); } -let serverVersion = null; -let serverBuildDate = null; -let serverCallsign = null; -let ownerCallsign = null; -let ownerWebsiteUrl = null; -let ownerWebsiteName = null; -let aisVesselUrlBase = null; -let serverRigs = []; -let serverActiveRigId = null; -let serverLat = null; -let serverLon = null; +let serverVersion: string | null = null; +let serverBuildDate: string | null = null; +let serverCallsign: string | null = null; +let ownerCallsign: string | null = null; +let ownerWebsiteUrl: string | null = null; +let ownerWebsiteName: string | null = null; +let aisVesselUrlBase: string | null = null; +let serverRigs: RigListItem[] = []; +let serverActiveRigId: string | null = null; +let serverLat: number | null = null; +let serverLon: number | null = null; let initialMapZoom = 10; let spectrumCoverageMarginHz = 50_000; let spectrumUsableSpanRatio = 0.92; @@ -2928,8 +2963,8 @@ const DEFAULT_SIGNAL_SPLIT_PERCENT = 50; const MIN_SIGNAL_SPLIT_PERCENT = 20; const MAX_SIGNAL_SPLIT_PERCENT = 80; let spectrumLayoutPending = false; -let spectrumManualTotalPlotHeightPx = null; -let spectrumResizeState = null; +let spectrumManualTotalPlotHeightPx: number | null = null; +let spectrumResizeState: SpectrumResizeState | null = null; let signalSplitPercent = clampSignalSplitPercent( Number(loadSetting("signalSplitPercent", DEFAULT_SIGNAL_SPLIT_PERCENT)), ); @@ -2944,8 +2979,8 @@ function scheduleSpectrumLayout() { }); } -function clampSignalSplitPercent(value) { - const numeric = Number.isFinite(value) ? value : DEFAULT_SIGNAL_SPLIT_PERCENT; +function clampSignalSplitPercent(value: number) { + const numeric = isFiniteNumber(value) ? value : DEFAULT_SIGNAL_SPLIT_PERCENT; return Math.max( MIN_SIGNAL_SPLIT_PERCENT, Math.min(MAX_SIGNAL_SPLIT_PERCENT, Math.round(numeric)), @@ -2957,31 +2992,36 @@ function updateSignalSplitControlText() { signalSplitValueEl.textContent = `${signalSplitPercent}/${100 - signalSplitPercent}`; } -function setSignalSplitControlVisible(visible) { +function setSignalSplitControlVisible(visible: boolean) { if (!signalSplitControlEl) return; signalSplitControlEl.style.display = visible ? "flex" : "none"; } -function currentOverviewHeightPx(overviewCanvasEl) { +function currentOverviewHeightPx(overviewCanvasEl: HTMLCanvasElement) { return Math.max( MIN_OVERVIEW_PLOT_HEIGHT_PX, Math.round(overviewCanvasEl?.clientHeight || DEFAULT_OVERVIEW_PLOT_HEIGHT_PX), ); } -function currentSpectrumHeightPx(spectrumCanvasEl) { +function currentSpectrumHeightPx(spectrumCanvasEl: HTMLCanvasElement) { return Math.max( MIN_SPECTRUM_PLOT_HEIGHT_PX, Math.round(spectrumCanvasEl?.clientHeight || DEFAULT_SPECTRUM_PLOT_HEIGHT_PX), ); } -function spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl) { +function spectrumHeightBoundsPx( + tabMain: HTMLElement, + content: HTMLElement, + overviewCanvasEl: HTMLCanvasElement, + spectrumCanvasEl: HTMLCanvasElement, +) { const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight; - const tabBottom = tabMainEl.getBoundingClientRect().bottom; - const contentBottom = contentEl.getBoundingClientRect().bottom; + const tabBottom = tabMain.getBoundingClientRect().bottom; + const contentBottom = content.getBoundingClientRect().bottom; const slackPx = Math.floor(tabBottom - contentBottom); const minTotalHeight = MIN_OVERVIEW_PLOT_HEIGHT_PX + MIN_SPECTRUM_PLOT_HEIGHT_PX; const maxAutoTotalHeight = Math.max( @@ -2996,9 +3036,9 @@ function spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrum function updateSpectrumAutoHeight() { const root = document.documentElement; - const overviewCanvasEl = document.getElementById("overview-canvas"); + const overviewCanvasEl = document.getElementById("overview-canvas") as HTMLCanvasElement | null; const spectrumPanelEl = document.getElementById("spectrum-panel"); - const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + const spectrumCanvasEl = document.getElementById("spectrum-canvas") as HTMLCanvasElement | null; if (!root || !tabMainEl || !contentEl || !overviewCanvasEl || !spectrumPanelEl || !spectrumCanvasEl) return; const mainVisible = getComputedStyle(tabMainEl).display !== "none"; @@ -3055,9 +3095,9 @@ function updateSpectrumAutoHeight() { } } -function beginSpectrumResize(clientY) { - const overviewCanvasEl = document.getElementById("overview-canvas"); - const spectrumCanvasEl = document.getElementById("spectrum-canvas"); +function beginSpectrumResize(clientY: number) { + const overviewCanvasEl = document.getElementById("overview-canvas") as HTMLCanvasElement | null; + const spectrumCanvasEl = document.getElementById("spectrum-canvas") as HTMLCanvasElement | null; const spectrumPanelEl = document.getElementById("spectrum-panel"); if (!tabMainEl || !contentEl || !overviewCanvasEl || !spectrumCanvasEl || !spectrumPanelEl) return false; if (getComputedStyle(spectrumPanelEl).display === "none") return false; @@ -3075,7 +3115,7 @@ function beginSpectrumResize(clientY) { return true; } -function updateSpectrumResize(clientY) { +function updateSpectrumResize(clientY: number) { if (!spectrumResizeState) return; const deltaY = clientY - spectrumResizeState.startY; spectrumManualTotalPlotHeightPx = Math.max( @@ -3104,7 +3144,7 @@ if (spectrumSizeGrip) { if (!spectrumResizeState) return; updateSpectrumResize(event.clientY); }); - const finishResize = (event) => { + const finishResize = (event: PointerEvent) => { if (!spectrumResizeState) return; if (typeof spectrumSizeGrip.releasePointerCapture === "function" && spectrumSizeGrip.hasPointerCapture(event.pointerId)) { spectrumSizeGrip.releasePointerCapture(event.pointerId); @@ -3153,7 +3193,7 @@ function updateTitle() { updateDocumentTitle(activeChannelRds()); } -function displayLabelFromUrl(url) { +function displayLabelFromUrl(url: string) { try { const host = new URL(url).hostname.replace(/^www\./i, ""); return host || url; @@ -3162,12 +3202,12 @@ function displayLabelFromUrl(url) { } } -window.buildAisVesselUrl = function(mmsi) { - if (!aisVesselUrlBase || !Number.isFinite(Number(mmsi))) return null; +window.buildAisVesselUrl = function(mmsi: unknown) { + if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null; return `${aisVesselUrlBase}${String(mmsi)}`; }; -function render(update) { +function render(update: AppUpdate) { if (!update) return; if (update.server_version) serverVersion = update.server_version; if (update.server_build_date) serverBuildDate = update.server_build_date; @@ -3187,7 +3227,7 @@ function render(update) { const prevLat = serverLat, prevLon = serverLon; if (update.server_latitude != null) serverLat = update.server_latitude; if (update.server_longitude != null) serverLon = update.server_longitude; - if (locationSubtitle && Number.isFinite(serverLat) && Number.isFinite(serverLon) + if (locationSubtitle && isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && (serverLat !== prevLat || serverLon !== prevLon || !locationSubtitle.textContent)) { const grid = latLonToMaidenhead(serverLat, serverLon); locationSubtitle.textContent = `Location: ${grid}`; @@ -3195,24 +3235,24 @@ function render(update) { window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid); } window.trx.modules.map?.syncAprsReceiverMarker(); - if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) { + if (typeof update.initial_map_zoom === "number" && isFiniteNumber(update.initial_map_zoom)) { initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); } if ( typeof update.spectrum_coverage_margin_hz === "number" && - Number.isFinite(update.spectrum_coverage_margin_hz) + isFiniteNumber(update.spectrum_coverage_margin_hz) ) { spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); } if ( typeof update.spectrum_usable_span_ratio === "number" && - Number.isFinite(update.spectrum_usable_span_ratio) + isFiniteNumber(update.spectrum_usable_span_ratio) ) { spectrumUsableSpanRatio = Math.max(0.01, Math.min(1.0, Number(update.spectrum_usable_span_ratio))); } if ( typeof update.decode_history_retention_min === "number" && - Number.isFinite(update.decode_history_retention_min) && + isFiniteNumber(update.decode_history_retention_min) && update.decode_history_retention_min > 0 ) { const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); @@ -3362,8 +3402,9 @@ function render(update) { if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) { sdrSquelchSupported = true; syncSdrSquelchFromServer( - hasSdrSquelchEnabled ? update.filter.sdr_squelch_enabled : true, - hasSdrSquelchThreshold ? update.filter.sdr_squelch_threshold_db : -120, + hasSdrSquelchEnabled ? update.filter.sdr_squelch_enabled === true : true, + hasSdrSquelchThreshold && typeof update.filter.sdr_squelch_threshold_db === "number" + ? update.filter.sdr_squelch_threshold_db : -120, ); } updateSdrSquelchControlVisibility(); @@ -3374,10 +3415,12 @@ function render(update) { if (sdrNbWrapEl) sdrNbWrapEl.style.display = ""; if (sdrNbThresholdControlsEl) sdrNbThresholdControlsEl.style.display = ""; if (hasSdrNbEnabled && sdrNbEnabledEl) { - sdrNbEnabledEl.checked = update.filter.sdr_nb_enabled; + sdrNbEnabledEl.checked = update.filter.sdr_nb_enabled === true; } if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) { - sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold)); + if (typeof update.filter.sdr_nb_threshold === "number") { + sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold)); + } } } } @@ -3389,7 +3432,7 @@ function render(update) { if (!_bandplanServerDefaultApplied && typeof update.bandplan_enabled === "boolean" && typeof update.bandplan_region === "string") { _bandplanServerDefaultApplied = true; - const hasUserOverride = localStorage.getItem(STORAGE_PREFIX + "bandplanRegion") !== null; + const hasUserOverride = localStorage.getItem("trx_bandplanRegion") !== null; if (!hasUserOverride) { const region = update.bandplan_enabled ? update.bandplan_region : "off"; bandplanRegion = region; @@ -3424,7 +3467,7 @@ function render(update) { prevRenderData.mode = update.status.mode; const mode = normalizeMode(update.status.mode); const modeUpper = mode ? mode.toUpperCase() : ""; - const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; // When subscribed to a virtual channel the mode picker must reflect // that channel's mode, not the primary rig mode. Skip the update here; // vchan.js will apply the correct mode via vchanSyncModeDisplay(). @@ -3443,7 +3486,7 @@ function render(update) { // When SDR backend is active (spectrum visible), apply BW default for new // mode — but only if the server hasn't already pushed a filter_state. if (lastSpectrumData && !update.filter) { - applyBwDefaultForMode(mode, false); + void applyBwDefaultForMode(mode, false); } } updateWfmControls(); @@ -3507,10 +3550,10 @@ function render(update) { if (window.updateSatLiveState) window.updateSatLiveState(update); // cwAutoEl, cwWpmEl, cwToneEl are cached at module level if (cwWpmEl && typeof update.cw_wpm === "number") { - cwWpmEl.value = update.cw_wpm; + cwWpmEl.value = String(update.cw_wpm); } if (cwToneEl && typeof update.cw_tone_hz === "number") { - cwToneEl.value = update.cw_tone_hz; + cwToneEl.value = String(update.cw_tone_hz); } if (typeof update.cw_auto === "boolean") { if (typeof window.applyCwAutoUiFromServer === "function") { @@ -3600,10 +3643,9 @@ function render(update) { powerBtn.setAttribute("aria-pressed", "false"); powerHint.textContent = "State unknown"; } - lastControl = update.enabled; if (update.status && update.status.tx && typeof update.status.tx.limit === "number") { - txLimitInput.value = update.status.tx.limit; + txLimitInput.value = String(update.status.tx.limit); txLimitRow.style.display = ""; } else { txLimitInput.value = ""; @@ -3626,7 +3668,7 @@ function render(update) { if (update.server_callsign && aboutServerCallEl) { aboutServerCallEl.textContent = update.server_callsign; } - if (Number.isFinite(serverLat) && Number.isFinite(serverLon) && aboutServerLocationEl) { + if (isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && aboutServerLocationEl) { const grid = latLonToMaidenhead(serverLat, serverLon); aboutServerLocationEl.textContent = `${grid} (${serverLat.toFixed(4)}, ${serverLon.toFixed(4)})`; } @@ -3637,11 +3679,11 @@ function render(update) { if (parts && aboutRigInfoEl) aboutRigInfoEl.textContent = parts; const access = update.info.access; if (access) { - if (access.Serial) { - const serialPath = access.Serial.path || access.Serial.port || "?"; + if ("Serial" in access) { + const serialPath = access.Serial.path || "?"; if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; - } else if (access.Tcp) { - if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; + } else if ("Tcp" in access) { + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.addr || "?"})`; } else { const key = Object.keys(access)[0]; if (key && aboutRigAccessEl) aboutRigAccessEl.textContent = key; @@ -3653,7 +3695,7 @@ function render(update) { aboutModesEl.textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", "); } if (typeof cap.num_vfos === "number" && aboutVfosEl) { - aboutVfosEl.textContent = cap.num_vfos; + aboutVfosEl.textContent = String(cap.num_vfos); } } } @@ -3676,7 +3718,7 @@ function render(update) { } if (aboutAudioRxEl) aboutAudioRxEl.textContent = rxActive ? "Active" : "Off"; if (typeof update.audio_clients === "number" && aboutAudioStreamsEl) { - aboutAudioStreamsEl.textContent = update.audio_clients; + aboutAudioStreamsEl.textContent = String(update.audio_clients); } // About — Decoders card (only update when values change) @@ -3696,7 +3738,7 @@ function render(update) { aboutAprsIsEl.textContent = update.aprs_is_status; } if (typeof update.rigctl_clients === "number" && aboutRigctlClientsEl) { - aboutRigctlClientsEl.textContent = update.rigctl_clients; + aboutRigctlClientsEl.textContent = String(update.rigctl_clients); } if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0 && aboutRigctlEndpointEl) { aboutRigctlEndpointEl.textContent = update.rigctl_addr; @@ -3704,14 +3746,14 @@ function render(update) { // About — Clients card if (typeof update.clients === "number" && aboutClientsEl) { - aboutClientsEl.textContent = update.clients; + aboutClientsEl.textContent = String(update.clients); } } // end _activeTab === "about" if (Array.isArray(update.remotes)) { - applyRigList(update.active_remote, update.remotes); + applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes); } powerHint.textContent = readyText(); - lastLocked = update.status && update.status.lock === true; + lastLocked = update.status?.lock === true; window.trxUi?.setButtonState(lockBtn, { active: lastLocked, activeLabel: "Unlock Tuning", @@ -3754,9 +3796,10 @@ async function pollFreshSnapshot() { : "/status"; const resp = await fetch(statusUrl, { cache: "no-store" }); if (!resp.ok) return; - const data = await resp.json(); + const data = await responseJsonUnknown(resp); + if (!isAppUpdate(data)) return; render(data); - refreshRigList(); + void refreshRigList(); lastEventAt = Date.now(); } catch (e) { // Ignore network errors; connect() retry loop handles reconnection. @@ -3773,23 +3816,25 @@ function connect() { } stopMeterStreaming(); startMeterStreaming(); - pollFreshSnapshot(); + void pollFreshSnapshot(); const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events"; es = new EventSource(eventsUrl); + const source = es; lastEventAt = Date.now(); es.onopen = () => { setConnLostOverlay(false); if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); if (!aboutUptimeStart) aboutUptimeStart = Date.now(); - pollFreshSnapshot(); - refreshRigList(); + void pollFreshSnapshot(); + void refreshRigList(); }; - es.onmessage = (evt) => { + source.onmessage = (evt: MessageEvent) => { try { if (evt.data === lastRendered) return; - const data = JSON.parse(evt.data); + const data = parseJsonUnknown(evt.data); + if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape"); lastRendered = evt.data; render(data); lastEventAt = Date.now(); @@ -3804,26 +3849,27 @@ function connect() { console.error("Bad event data", e); } }; - es.addEventListener("ping", () => { + source.addEventListener("ping", () => { lastEventAt = Date.now(); }); - es.addEventListener("session", evt => { + source.addEventListener("session", evt => { try { - const d = JSON.parse(evt.data); - sseSessionId = d.session_id || null; + const eventData = messageEventData(evt); + const d = parseJsonUnknown(eventData); + sseSessionId = isRecord(d) && typeof d.session_id === "string" ? d.session_id : null; } catch (_) {} - if (typeof vchanHandleSession === "function") vchanHandleSession(evt.data); + window.trx.modules.vchan?.handleSession(messageEventData(evt)); }); - es.addEventListener("channels", evt => { - if (typeof vchanHandleChannels === "function") vchanHandleChannels(evt.data); + source.addEventListener("channels", evt => { + window.trx.modules.vchan?.handleChannels(messageEventData(evt)); }); - es.onerror = () => { + source.onerror = () => { // Check if this is an auth error by looking at readyState - if (es.readyState === EventSource.CLOSED) { + if (source.readyState === EventSource.CLOSED) { powerHint.textContent = "trx-client connection lost, retrying\u2026"; setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); - es.close(); - pollFreshSnapshot(); + source.close(); + void pollFreshSnapshot(); scheduleReconnect(1000); } }; @@ -3833,8 +3879,8 @@ function connect() { if (now - lastEventAt > 15000) { powerHint.textContent = "trx-client connection lost, retrying\u2026"; setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); - es.close(); - pollFreshSnapshot(); + source.close(); + void pollFreshSnapshot(); scheduleReconnect(250); } }, 5000); @@ -3867,15 +3913,8 @@ function disconnect() { // Yield the main thread so the browser can paint before heavy async work. // Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback. -function yieldToMain() { - if (typeof scheduler !== "undefined" && typeof scheduler.yield === "function") { - return scheduler.yield(); - } - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -const uiFrameJobs = new Map(); -let uiFrameJobsHandle = null; +const uiFrameJobs = new Map void>(); +let uiFrameJobsHandle: ReturnType | null = null; function flushUiFrameJobs() { uiFrameJobsHandle = null; @@ -3890,7 +3929,7 @@ function flushUiFrameJobs() { } } -function scheduleUiFrameJob(key, job) { +function scheduleUiFrameJob(key: string, job: () => void) { if (typeof job !== "function") return; uiFrameJobs.set(key, job); if (uiFrameJobsHandle !== null) return; @@ -3903,7 +3942,7 @@ function scheduleUiFrameJob(key, job) { window.trxScheduleUiFrameJob = scheduleUiFrameJob; -async function postPath(path, options = {}) { +async function postPath(path: string, options: PostOptions = {}) { if (rigSwitchInProgress && !options.allowDuringRigSwitch) { throw new Error("Wait for the rig switch to finish"); } @@ -3933,17 +3972,15 @@ async function postPath(path, options = {}) { return resp; } -async function takeSchedulerControlForDecoderDisable(buttonEl) { +async function takeSchedulerControlForDecoderDisable(buttonEl: HTMLElement | null) { const enabled = buttonEl?.dataset?.enabled === "true" || /^\s*Disable\b/i.test(buttonEl?.textContent || ""); if (!enabled) return; - if (typeof window.vchanTakeSchedulerControl === "function") { - await window.vchanTakeSchedulerControl(); - } + await window.trx.modules.vchan?.takeSchedulerControl(); } window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable; -async function switchRigFromSelect(selectEl) { +async function switchRigFromSelect(selectEl: HTMLSelectElement) { if (!selectEl || !selectEl.value) { showHint("No rig selected", 1500); return; @@ -3972,9 +4009,9 @@ async function switchRigFromSelect(selectEl) { updateRigSubtitle(lastActiveRigId); updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); - if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); - if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || ""); window.trx.modules.map?.syncAprsReceiverMarker(); connect(); stopSpectrumStreaming(); @@ -3999,10 +4036,10 @@ async function switchRigFromSelect(selectEl) { } if (headerRigSwitchSelect) { - headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); }); + headerRigSwitchSelect.addEventListener("change", () => { void switchRigFromSelect(headerRigSwitchSelect); }); } -function setControlPending(control, pending) { +function setControlPending(control: HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null, pending: boolean) { if (!control) return; control.disabled = pending; control.classList.toggle("is-busy", pending); @@ -4039,12 +4076,12 @@ pttBtn.addEventListener("click", async () => { }); function applyFreqFromInput() { - const parsedRaw = parseFreqInput(freqEl.value, jogUnit); - const parsed = alignFreqToRigStep(parsedRaw); - if (parsed === null) { + const parsedRaw = parseFrequencyInput(freqEl.value, jogUnit, modeEl?.value || ""); + if (parsedRaw === null) { showHint("Freq missing", 1500); return; } + const parsed = alignFreqToRigStep(parsedRaw); if (!freqAllowed(parsed)) { showUnsupportedFreqPopup(parsed); return; @@ -4056,12 +4093,12 @@ function applyFreqFromInput() { async function applyCenterFreqFromInput() { if (!centerFreqEl) return; - const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit); - const parsed = alignFreqToRigStep(parsedRaw); - if (parsed === null) { + const parsedRaw = parseFrequencyInput(centerFreqEl.value, jogUnit, modeEl?.value || ""); + if (parsedRaw === null) { showHint("Central freq missing", 1500); return; } + const parsed = alignFreqToRigStep(parsedRaw); if (!freqAllowed(parsed)) { showUnsupportedFreqPopup(parsed); return; @@ -4102,7 +4139,7 @@ if (centerFreqEl) { centerFreqDirty = true; if (e.key === "Enter") { e.preventDefault(); - applyCenterFreqFromInput(); + void applyCenterFreqFromInput(); } else if (e.key === "Escape") { centerFreqDirty = false; refreshCenterFreqDisplay(); @@ -4128,12 +4165,12 @@ freqEl.addEventListener("wheel", (e) => { }, { passive: false }); // --- Jog wheel --- -const jogWheel = document.getElementById("jog-wheel"); -const jogIndicator = document.getElementById("jog-indicator"); -const jogDownBtn = document.getElementById("jog-down"); -const jogUpBtn = document.getElementById("jog-up"); -const jogStepEl = document.getElementById("jog-step"); -const jogMultEl = document.getElementById("jog-mult"); +const jogWheel = requiredElement("jog-wheel"); +const jogIndicator = requiredElement("jog-indicator"); +const jogDownBtn = requiredElement("jog-down"); +const jogUpBtn = requiredElement("jog-up"); +const jogStepEl = requiredElement("jog-step"); +const jogMultEl = requiredElement("jog-mult"); const VALID_JOG_DIVISORS = new Set([1, 10]); function applyJogStep() { @@ -4145,18 +4182,18 @@ function applyJogStep() { refreshCenterFreqDisplay(); } -function setJogDivisor(divisor) { +function setJogDivisor(divisor: number) { const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1; jogMult = next; if (jogMultEl) { - jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => { - b.classList.toggle("active", parseInt(b.dataset.mult, 10) === jogMult); + jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => { + b.classList.toggle("active", parseInt(b.dataset.mult ?? "", 10) === jogMult); }); } applyJogStep(); } -function jogFreq(direction) { +function jogFreq(direction: -1 | 1) { if (lastLocked) { showHint("Locked", 1500); return; } if (lastFreqHz === null) return; const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep); @@ -4180,24 +4217,26 @@ jogWheel.addEventListener("wheel", (e) => { }, { passive: false }); // Touch drag on jog wheel -let jogTouchY = null; +let jogTouchY: number | null = null; jogWheel.addEventListener("touchstart", (e) => { e.preventDefault(); - jogTouchY = e.touches[0].clientY; + jogTouchY = e.touches[0]?.clientY ?? null; }, { passive: false }); jogWheel.addEventListener("touchmove", (e) => { e.preventDefault(); if (jogTouchY === null) return; - const dy = jogTouchY - e.touches[0].clientY; + const touch = e.touches[0]; + if (!touch) return; + const dy = jogTouchY - touch.clientY; if (Math.abs(dy) > 12) { jogFreq(dy > 0 ? 1 : -1); - jogTouchY = e.touches[0].clientY; + jogTouchY = touch.clientY; } }, { passive: false }); jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); // Mouse drag on jog wheel -let jogMouseY = null; +let jogMouseY: number | null = null; jogWheel.addEventListener("mousedown", (e) => { e.preventDefault(); jogMouseY = e.clientY; @@ -4218,49 +4257,49 @@ window.addEventListener("mouseup", () => { // Step unit selector jogStepEl.addEventListener("click", (e) => { - const btn = e.target.closest("button[data-step]"); + const btn = e.target instanceof Element ? e.target.closest("button[data-step]") : null; if (!btn) return; - jogUnit = parseInt(btn.dataset.step, 10); - jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + jogUnit = parseInt(btn.dataset.step ?? "", 10); + jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); applyJogStep(); }); // Step multiplier selector if (jogMultEl) { - jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => { - const divisor = parseInt(btn.dataset.mult, 10); + jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => { + const divisor = parseInt(btn.dataset.mult ?? "", 10); if (!VALID_JOG_DIVISORS.has(divisor)) { btn.remove(); } }); jogMultEl.addEventListener("click", (e) => { - const btn = e.target.closest("button[data-mult]"); + const btn = e.target instanceof Element ? e.target.closest("button[data-mult]") : null; if (!btn) return; - setJogDivisor(parseInt(btn.dataset.mult, 10)); + setJogDivisor(parseInt(btn.dataset.mult ?? "", 10)); }); } // Restore active jog step buttons from saved settings { - const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); + const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); const activeUnit = - unitBtns.find((b) => parseInt(b.dataset.step, 10) === jogUnit) || - unitBtns.find((b) => parseInt(b.dataset.step, 10) === 1000) || + unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === jogUnit) || + unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === 1000) || unitBtns[0]; if (activeUnit) { - jogUnit = parseInt(activeUnit.dataset.step, 10); + jogUnit = parseInt(activeUnit.dataset.step ?? "", 10); unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); } if (jogMultEl) { - const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); + const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); const activeMult = - multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || - multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) || + multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || + multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === 1) || multBtns[0]; if (activeMult) { - jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult, 10)) - ? parseInt(activeMult.dataset.mult, 10) + jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult ?? "", 10)) + ? parseInt(activeMult.dataset.mult ?? "", 10) : 1; multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); } else { @@ -4280,7 +4319,7 @@ async function applyModeFromPicker() { setControlPending(modeEl, true); showHint("Setting mode…"); try { - if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { + if (await window.trx.modules.vchan?.interceptMode(mode)) { showHint("Channel mode set", 1500); return; } @@ -4345,7 +4384,7 @@ lockBtn.addEventListener("click", async () => { // --- Filter controls --- // Per-mode defaults: [default bandwidth Hz, min Hz, max Hz, step Hz] -const MODE_BW_DEFAULTS = { +const MODE_BW_DEFAULTS: Record = { CW: [500, 100, 9_000, 50], CWR: [500, 100, 9_000, 50], LSB: [2_700, 300, 6_000, 100], @@ -4359,13 +4398,13 @@ const MODE_BW_DEFAULTS = { DIG: [3_000, 300, 6_000, 100], PKT: [25_000, 300, 50_000, 500], }; -const MODE_BW_FALLBACK = [3_000, 300, 500_000, 100]; +const MODE_BW_FALLBACK: [number, number, number, number] = [3_000, 300, 500_000, 100]; -function mwDefaultsForMode(mode) { +function mwDefaultsForMode(mode: string): [number, number, number, number] { return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK; } -function formatBwLabel(hz) { +function formatBwLabel(hz: number) { if (hz >= 1000) return (hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1) + " kHz"; return hz + " Hz"; } @@ -4373,20 +4412,20 @@ function formatBwLabel(hz) { // Current receive bandwidth (Hz) — updated by server sync and BW drag. let currentBandwidthHz = 3_000; window.currentBandwidthHz = currentBandwidthHz; -const spectrumBwInput = document.getElementById("spectrum-bw-input"); -const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn"); -const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn"); -const spectrumBwSweetBtn = document.getElementById("spectrum-bw-sweet-btn"); +const spectrumBwInput = requiredElement("spectrum-bw-input"); +const spectrumBwSetBtn = requiredElement("spectrum-bw-set-btn"); +const spectrumBwAutoBtn = requiredElement("spectrum-bw-auto-btn"); +const spectrumBwSweetBtn = requiredElement("spectrum-bw-sweet-btn"); -function formatBandwidthInputKhz(hz) { +function formatBandwidthInputKhz(hz: number) { const khz = hz / 1000; if (Math.abs(Math.round(khz) - khz) < 0.0001) return String(Math.round(khz)); if (Math.abs(Math.round(khz * 10) - khz * 10) < 0.0001) return khz.toFixed(1); return khz.toFixed(2); } -function syncBandwidthInput(hz) { - if (!spectrumBwInput || !Number.isFinite(hz) || hz <= 0) return; +function syncBandwidthInput(hz: number) { + if (!spectrumBwInput || !isFiniteNumber(hz) || hz <= 0) return; const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); spectrumBwInput.min = String(minBw / 1000); spectrumBwInput.max = String(maxBw / 1000); @@ -4395,7 +4434,7 @@ function syncBandwidthInput(hz) { } // Apply mode-specific BW default and optionally push to server. -async function applyBwDefaultForMode(mode, sendToServer) { +async function applyBwDefaultForMode(mode: string, sendToServer: boolean) { const [def] = mwDefaultsForMode(mode); currentBandwidthHz = def; window.currentBandwidthHz = currentBandwidthHz; @@ -4415,7 +4454,7 @@ async function applyBandwidthFromInput() { const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); const nextKhz = Number(spectrumBwInput.value); const next = Math.round(nextKhz * 1000); - if (!Number.isFinite(next) || next <= 0) { + if (!isFiniteNumber(next) || next <= 0) { syncBandwidthInput(currentBandwidthHz); return; } @@ -4428,9 +4467,9 @@ async function applyBandwidthFromInput() { scheduleSpectrumDraw(); } try { - if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(clamped)) return; + if (await window.trx.modules.vchan?.interceptBandwidth(clamped)) return; await postPath(`/set_bandwidth?hz=${clamped}`); - if (Number.isFinite(lastFreqHz)) { + if (isFiniteNumber(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz); } } catch (error) { @@ -4438,111 +4477,21 @@ async function applyBandwidthFromInput() { } } -function estimateOccupiedBandwidth(data, centerHz, interference = {}) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { - return null; - } - - const bins = data.bins; - const maxIdx = bins.length - 1; - const hzPerBin = data.sample_rate / maxIdx; - const fullLoHz = data.center_hz - data.sample_rate / 2; - const centerIdx = Math.max( - 1, - Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)), - ); - const mode = (modeEl ? modeEl.value : "USB").toUpperCase(); - const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); - const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" - ? 1 - : mode === "LSB" || mode === "CWR" ? -1 : 0; - const isWfm = mode === "WFM"; - - // Reduce single-bin peaks and holes before finding occupied-channel edges. - // WFM needs a wider smoothing window because its energy is noise-like and - // spread across the entire channel rather than concentrated at a carrier. - const smoothRadius = isWfm ? 3 : 1; - const smoothed = bins.map((_, i) => { - let sum = 0; - let count = 0; - for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) { - sum += bins[j]; - count += 1; - } - return sum / count; - }); - const sorted = [...bins].sort((a, b) => a - b); - const noise = sorted[Math.floor(sorted.length * 0.2)]; - const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); - const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; - const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins)); - const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins)); - let peak = -Infinity; - for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]); - const snr = peak - noise; - if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw; - - // A threshold relative to the noise floor finds occupied bandwidth much - // more reliably than one relative to the peak. The latter fails for WFM, - // whose multiplex spectrum has peaks, notches, and no narrow centre carrier. - const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); - const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin)); - - function occupiedExtent(direction, limitBins) { - let lastOccupied = centerIdx; - let gap = 0; - for (let n = 0; n <= limitBins; n++) { - const i = centerIdx + direction * n; - if (i <= 0 || i >= maxIdx) break; - if (smoothed[i] >= threshold) { - lastOccupied = i; - gap = 0; - } else if (++gap > allowedGap) { - break; - } - } - return Math.abs(lastOccupied - centerIdx) * hzPerBin; - } - - let rawBw; - if (oneSided !== 0) { - rawBw = occupiedExtent(oneSided, maxSpanBins); - } else { - const leftHz = occupiedExtent(-1, searchHalfBins); - const rightHz = occupiedExtent(1, searchHalfBins); - // A symmetric RF filter must contain the larger of the two sidebands. - rawBw = 2 * Math.max(leftHz, rightHz); - } - - // Add a transition-band margin. Weak WFM deliberately falls back to the - // 60 kHz mode floor above: a narrower filter trades stereo/RDS content for - // a useful improvement in intelligibility when the signal is very poor. - rawBw *= isWfm ? 1.08 : 1.12; - if (isWfm) { - const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; - const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; - // Adjacent-channel energy is outside the wanted modulation, so ACI can - // safely drive the cap all the way from the 300 kHz ceiling to 60 kHz. - const aciCap = maxBw - (maxBw - minBw) * aci; - // CCI overlaps the wanted station and cannot be removed by an RF filter. - // Only distrust the widest edge estimates, retaining at least 65% of the - // useful range between the weak-signal floor and nominal WFM bandwidth. - const cciFloor = minBw + (defaultBw - minBw) * 0.65; - const cciCap = maxBw - (maxBw - cciFloor) * cci; - rawBw = Math.min(rawBw, aciCap, cciCap); - } - const clamped = Math.max(minBw, Math.min(maxBw, rawBw)); - return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); -} - async function applyAutoBandwidth() { if (!lastSpectrumData || lastFreqHz == null) return; // WFM interference telemetry belongs to the primary DSP channel. Do not // apply it to a virtual channel, where it would describe the wrong signal. - const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; - const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); - if (!Number.isFinite(estimated) || estimated <= 0) { + const mode = (modeEl?.value || "").toUpperCase(); + const estimated = estimateOccupiedBandwidth( + lastSpectrumData, + lastFreqHz, + mode, + mwDefaultsForMode(mode), + interference, + ); + if (!isFiniteNumber(estimated) || estimated <= 0) { syncBandwidthInput(currentBandwidthHz); return; } @@ -4553,7 +4502,6 @@ async function applyAutoBandwidth() { if (lastSpectrumData) { scheduleSpectrumDraw(); } - const mode = (modeEl?.value || "").toUpperCase(); let reason = "measured occupied spectrum"; if (mode === "WFM") { if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; @@ -4563,9 +4511,9 @@ async function applyAutoBandwidth() { } window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5000 }); try { - if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; + if (await window.trx.modules.vchan?.interceptBandwidth(estimated)) return; await postPath(`/set_bandwidth?hz=${estimated}`); - if (Number.isFinite(lastFreqHz)) { + if (isFiniteNumber(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz); } } catch (error) { @@ -4577,15 +4525,15 @@ if (spectrumBwInput) { spectrumBwInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); - applyBandwidthFromInput(); + void applyBandwidthFromInput(); } }); } if (spectrumBwSetBtn) { - spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); }); + spectrumBwSetBtn.addEventListener("click", () => { void applyBandwidthFromInput(); }); } if (spectrumBwAutoBtn) { - spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); }); + spectrumBwAutoBtn.addEventListener("click", () => { void applyAutoBandwidth(); }); } if (spectrumBwSweetBtn) { spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); }); @@ -4593,57 +4541,29 @@ if (spectrumBwSweetBtn) { // --- Tab navigation --- let _activeTab = "main"; // tracked for render-path tab awareness -const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"]; -const TAB_PATHS = { - main: "/", - bookmarks: "/bookmarks", - "digital-modes": "/digital-modes", - map: "/map", - recorder: "/recorder", - settings: "/settings", - about: "/about", -}; - -function normalizeTabPath(pathname) { - const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/"; - if (raw === "/") return "/"; - return raw.replace(/\/+$/, "") || "/"; -} - function tabFromPath(pathname = window.location.pathname) { - const normalized = normalizeTabPath(pathname); - for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) { - if (normalized === tabPath) return tabName; - } - return "main"; -} - -function updateTabHistory(name, replaceHistory = false) { - const targetPath = TAB_PATHS[name] || "/"; - if (normalizeTabPath(window.location.pathname) === targetPath) return; - const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; - const method = replaceHistory ? "replaceState" : "pushState"; - window.history[method]({}, "", nextUrl); + return tabFromPathname(pathname); } // Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js // (window.trx.modules.map) if they haven't loaded yet. -let _mapInitTimer = null; +let _mapInitTimer: ReturnType | null = null; function _initMapWhenReady() { const loadingEl = document.getElementById("map-loading"); - if (window.trx.modules.map && typeof L !== "undefined") { + const map = window.trx.modules.map; + if (map && "L" in window) { if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; } if (loadingEl) loadingEl.classList.add("is-hidden"); - window.trx.modules.map.initAprsMap(); - window.trx.modules.map.sizeAprsMapToViewport(); + map.initAprsMap(); + map.sizeAprsMapToViewport(); // The map panel was just made visible (display:none → ""); the browser // may not have laid it out yet, so getBoundingClientRect() can return // stale/zero dimensions. Double-rAF ensures a full layout pass has // completed before we re-measure and tell Leaflet about its real size. requestAnimationFrame(() => { requestAnimationFrame(() => { - window.trx.modules.map.sizeAprsMapToViewport(); - if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize(); + map.sizeAprsMapToViewport(); + map.aprsMap?.invalidateSize(); }); }); return; @@ -4652,43 +4572,46 @@ function _initMapWhenReady() { if (loadingEl) loadingEl.classList.remove("is-hidden"); if (!_mapInitTimer) { _mapInitTimer = setInterval(() => { - if (_activeTab !== "map") { clearInterval(_mapInitTimer); _mapInitTimer = null; return; } + if (_activeTab !== "map") { + if (_mapInitTimer) clearInterval(_mapInitTimer); + _mapInitTimer = null; + return; + } _initMapWhenReady(); }, 100); } } -function navigateToTab(name, options = {}) { +function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) { window.trxUi?.closeMobileOverlays?.(); const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { showAuthGate(false); return; } - const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); + const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; _activeTab = name; document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); btn.classList.add("active"); window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); const panel = document.getElementById(`tab-${name}`); + if (!panel) return; panel.style.display = ""; - // Clone deferred