Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3c426a93 |
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "trx-rs SDK",
|
|
||||||
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
|
|
||||||
"workspaceFolder": "/work",
|
|
||||||
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
|
|
||||||
"mounts": [
|
|
||||||
"source=trx-rs-sccache,target=/sccache,type=volume"
|
|
||||||
],
|
|
||||||
"containerEnv": {
|
|
||||||
"RUSTC_WRAPPER": "sccache",
|
|
||||||
"CARGO_INCREMENTAL": "0",
|
|
||||||
"SCCACHE_DIR": "/sccache"
|
|
||||||
},
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"tamasfe.even-better-toml"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-51
@@ -2,11 +2,10 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
|
# CI for the self-hosted, host-executor Podman runners (see container/).
|
||||||
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
|
# The runner image bakes in the Rust toolchain and all build dependencies,
|
||||||
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
|
# so jobs go straight to cargo — no apt/rustup setup steps (which also
|
||||||
# reuse job uses the upstream Docker action, which the Docker executor launches
|
# collided on the dpkg lock when jobs ran concurrently in the same runner).
|
||||||
# as a sibling container.
|
|
||||||
|
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
@@ -17,73 +16,32 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
# sccache: shared compilation cache persisted on the runner host (see the
|
|
||||||
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
|
|
||||||
# sccache cannot cache incremental artifacts.
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
SCCACHE_DIR: /sccache
|
|
||||||
SCCACHE_CACHE_SIZE: "20G"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: rustfmt
|
- name: rustfmt
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
- name: clippy
|
- name: clippy
|
||||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
- name: sccache stats
|
|
||||||
if: always()
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk:latest
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --workspace --all-targets --locked
|
run: cargo build --workspace --all-targets --locked
|
||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
- name: sccache stats
|
|
||||||
if: always()
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container: git.haxx.space/sjg/trx-rs/sdk: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
|
|
||||||
# Chromium comes from the SDK image at the path the smoke test defaults
|
|
||||||
# to, so there is nothing to install here.
|
|
||||||
- name: Browser smoke test
|
|
||||||
run: npm run test:browser
|
|
||||||
- name: Verify generated assets
|
|
||||||
run: npm run verify-generated
|
|
||||||
|
|
||||||
reuse:
|
reuse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: fsfe/reuse-action@v5
|
- name: REUSE compliance
|
||||||
|
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
|
||||||
|
# action, which the host-executor runners cannot run. `reuse` is baked
|
||||||
|
# into the runner image (see container/Containerfile).
|
||||||
|
run: reuse lint
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ Cargo.lock
|
|||||||
coverage/
|
coverage/
|
||||||
benchmarks/
|
benchmarks/
|
||||||
|
|
||||||
# Frontend dependencies
|
|
||||||
**/node_modules/
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
Generated
-35
@@ -2645,15 +2645,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -3146,7 +3137,6 @@ dependencies = [
|
|||||||
"sgp4",
|
"sgp4",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"ts-rs",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3205,7 +3195,6 @@ dependencies = [
|
|||||||
"trx-core",
|
"trx-core",
|
||||||
"trx-frontend",
|
"trx-frontend",
|
||||||
"trx-protocol",
|
"trx-protocol",
|
||||||
"ts-rs",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3249,7 +3238,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
"ts-rs",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3345,29 +3333,6 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.0"
|
version = "1.20.0"
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) <year> <copyright holders>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -142,6 +142,6 @@ a unified set of frontends.
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
|
||||||
bundled third-party license files. Bundled third-party components retain their
|
bundled third-party license files. Bundled third-party components (Leaflet and
|
||||||
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
|
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their
|
||||||
is MIT.
|
original BSD-2-Clause license.
|
||||||
|
|||||||
+1
-15
@@ -12,19 +12,13 @@ path = [
|
|||||||
"trx-rs.toml.example",
|
"trx-rs.toml.example",
|
||||||
"docs/**",
|
"docs/**",
|
||||||
"aidocs/**",
|
"aidocs/**",
|
||||||
"container/**",
|
|
||||||
".devcontainer/**",
|
|
||||||
"src/decoders/trx-ftx/README.md",
|
"src/decoders/trx-ftx/README.md",
|
||||||
"src/decoders/trx-wxsat/README.md",
|
"src/decoders/trx-wxsat/README.md",
|
||||||
"assets/trx-logo.png",
|
"assets/trx-logo.png",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
|
"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/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/bandplan.json",
|
||||||
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
|
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js",
|
||||||
"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 <sjg@haxx.space>"
|
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
@@ -48,11 +42,3 @@ SPDX-License-Identifier = "BSD-2-Clause"
|
|||||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
|
||||||
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
|
||||||
SPDX-License-Identifier = "OFL-1.1"
|
SPDX-License-Identifier = "OFL-1.1"
|
||||||
|
|
||||||
# Vendored opus-decoder 0.7.11 browser build
|
|
||||||
# (https://github.com/eshaz/wasm-audio-decoders), MIT.
|
|
||||||
# SHA-256: fd73ee0a9c8a5e233c0b88234df14f46003ad89bb4ba27435bc8714db2a6dc62
|
|
||||||
[[annotations]]
|
|
||||||
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
|
|
||||||
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
|
|
||||||
SPDX-License-Identifier = "MIT"
|
|
||||||
|
|||||||
+39
-46
@@ -2,67 +2,60 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
# trx-rs SDK / build image.
|
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
|
||||||
#
|
#
|
||||||
# Single source of truth for the build environment. Used two ways:
|
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
|
||||||
# * CI — as the job container for the lint/test jobs (Docker executor).
|
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
|
||||||
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
|
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
|
||||||
#
|
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
|
||||||
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
|
# just become fast no-ops because everything is already installed.
|
||||||
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
|
|
||||||
# cannot happen.
|
|
||||||
FROM docker.io/library/debian:bookworm-slim
|
FROM docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
# Keep in sync with rust-toolchain.toml.
|
ARG ACT_RUNNER_VERSION=0.2.11
|
||||||
ARG RUST_VERSION=1.97.1
|
|
||||||
ARG NODE_MAJOR=20
|
ARG NODE_MAJOR=20
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
RUSTUP_HOME=/usr/local/rustup \
|
RUSTUP_HOME=/opt/rustup \
|
||||||
CARGO_HOME=/usr/local/cargo \
|
CARGO_HOME=/opt/cargo \
|
||||||
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
# Build dependencies (mirror README's manual instructions).
|
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl git \
|
ca-certificates curl xz-utils git sudo pipx \
|
||||||
build-essential pkg-config cmake clang libclang-dev \
|
build-essential pkg-config cmake clang libclang-dev \
|
||||||
libopus-dev libasound2-dev libsoapysdr-dev chromium \
|
libopus-dev libasound2-dev libsoapysdr-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Node.js — JS-based actions (actions/checkout, actions/cache) run *inside*
|
# Node.js (JS-based actions need node in PATH under the host executor).
|
||||||
# the job container under the Docker executor, so node must be present.
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
|
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
|
||||||
# devcontainer uses can invoke cargo.
|
# The [charset-normalizer] extra provides an encoding-detection backend;
|
||||||
|
# without it (and without libmagic) reuse fails to import at runtime.
|
||||||
|
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
|
||||||
|
|
||||||
|
# Rust stable with rustfmt + clippy, installed system-wide.
|
||||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
| sh -s -- -y --no-modify-path \
|
| sh -s -- -y --no-modify-path --profile minimal \
|
||||||
--default-toolchain "${RUST_VERSION}" --profile minimal \
|
--component rustfmt --component clippy \
|
||||||
--component rustfmt --component clippy \
|
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
|
||||||
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
|
|
||||||
|
|
||||||
# sccache — shared compilation cache. Enabled at build time via
|
# act_runner binary.
|
||||||
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
|
RUN arch="$(dpkg --print-architecture)"; \
|
||||||
# non-SDK builds are unaffected. musl build is static and runs anywhere.
|
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
|
||||||
#
|
curl -fsSL -o /usr/local/bin/act_runner \
|
||||||
# The release asset is per-architecture, so resolve it from `uname -m` rather
|
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
|
||||||
# than hardcoding one triple: everything else in this image is arch-agnostic,
|
&& chmod +x /usr/local/bin/act_runner
|
||||||
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
|
|
||||||
# on an arm64 host. `uname -m` reflects the build platform under plain
|
|
||||||
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
|
|
||||||
ARG SCCACHE_VERSION=0.8.2
|
|
||||||
RUN set -eux; \
|
|
||||||
case "$(uname -m)" in \
|
|
||||||
x86_64) sccache_arch=x86_64 ;; \
|
|
||||||
aarch64|arm64) sccache_arch=aarch64 ;; \
|
|
||||||
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
|
|
||||||
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
|
|
||||||
| tar -xz -C /tmp; \
|
|
||||||
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
|
|
||||||
rm -rf /tmp/sccache-*
|
|
||||||
|
|
||||||
WORKDIR /work
|
# Default config template (seeded into the /data volume on first boot).
|
||||||
|
COPY config.yaml /etc/act_runner/config.yaml
|
||||||
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# /data holds the .runner registration, cache and workflow workspaces.
|
||||||
|
VOLUME /data
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|||||||
+81
-156
@@ -3,191 +3,116 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
-->
|
-->
|
||||||
|
|
||||||
# trx-rs SDK image
|
# Podman-based Gitea Actions runners
|
||||||
|
|
||||||
A single container image that is the canonical build environment for trx-rs,
|
Run two independent Gitea Actions runners on one host as rootless Podman
|
||||||
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
|
containers managed by systemd (Quadlet) — one per project — instead of two
|
||||||
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
|
VMs. Uses the **host executor**: workflow steps run directly inside a
|
||||||
and `clippy` are identical everywhere — no "works on my machine".
|
purpose-built runner image that already has the Rust toolchain and all build
|
||||||
|
dependencies baked in, so CI runs skip the per-run install cost and no
|
||||||
|
Docker/Podman socket is needed.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
|
||||||
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
|
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
|
||||||
|
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
|
||||||
|
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
|
||||||
|
| `project2-runner.container` | Quadlet unit for the second project's runner. |
|
||||||
|
|
||||||
## Build and publish
|
## Prerequisites (once per host)
|
||||||
|
|
||||||
Nothing in the image is architecture-specific: the base image, the Debian build
|
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
|
||||||
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
|
user that will own the runners:
|
||||||
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
|
|
||||||
|
|
||||||
Single architecture — the tag then only works on the architecture you built it
|
|
||||||
on:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# from the repo root
|
# Survive logout / start on boot without an interactive session.
|
||||||
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
|
loginctl enable-linger "$USER"
|
||||||
podman login git.haxx.space
|
|
||||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Both architectures without emulation.** The CI runner is x86_64 and Apple
|
No `podman.socket` is required for the host executor.
|
||||||
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
|
|
||||||
a single-architecture tag makes the other side fall back to Rosetta or qemu.
|
## 1. Build the image
|
||||||
Build each half natively on a host of that architecture, then join them:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# on an x86_64 host
|
cd container
|
||||||
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
|
podman build -t trx-rs-ci:latest .
|
||||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
|
|
||||||
|
|
||||||
# on an arm64 host
|
|
||||||
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
|
|
||||||
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
|
||||||
|
|
||||||
# from either, once both are pushed
|
|
||||||
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
|
|
||||||
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
|
|
||||||
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
|
|
||||||
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Building both from one machine is a single command
|
## 2. Get a registration token
|
||||||
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
|
|
||||||
foreign half runs under emulation and is slow — the two-host flow above is
|
|
||||||
what keeps every build native.
|
|
||||||
|
|
||||||
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
|
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
|
||||||
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
|
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
|
||||||
runner and developers can pull it without credentials. If you keep it private,
|
|
||||||
add `credentials:` under the workflow's `container:` and log the runner into the
|
|
||||||
registry.
|
|
||||||
|
|
||||||
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
|
## 3. Install and start the runners
|
||||||
act_runner reuses whatever it cached the first time unless `force_pull: true` is
|
|
||||||
set (see `runner-config.example.yaml`). Without it the job log says
|
|
||||||
`Image exists? true` and the run behaves as though the image were never
|
|
||||||
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
|
|
||||||
Either set `force_pull`, or refresh the VM's copy by hand:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
|
mkdir -p ~/.config/containers/systemd
|
||||||
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
|
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
|
||||||
|
|
||||||
|
# Paste each repo's token for the FIRST boot only:
|
||||||
|
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
|
||||||
|
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
|
||||||
|
$EDITOR ~/.config/containers/systemd/project2-runner.container
|
||||||
|
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user start trx-rs-runner
|
||||||
|
systemctl --user start project2-runner
|
||||||
|
|
||||||
|
systemctl --user status trx-rs-runner
|
||||||
|
podman logs -f gitea-runner-trx-rs
|
||||||
```
|
```
|
||||||
|
|
||||||
### macOS note
|
Once each runner shows **online** in the repo's runner list, blank out the
|
||||||
|
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
|
||||||
|
the `…-data` volume) and `systemctl --user daemon-reload`.
|
||||||
|
|
||||||
Apple's `container` CLI builds through a BuildKit helper VM that is configured
|
## Required workflow change: the `reuse` job
|
||||||
with Rosetta whether or not the target is x86_64, so `container build` fails
|
|
||||||
with *"Rosetta is not installed"* on a clean machine. That is a property of the
|
|
||||||
builder, not of this image — `container run` works natively without it. Either
|
|
||||||
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
|
|
||||||
build still produces a native arm64 image), or build with Podman, whose arm64
|
|
||||||
BuildKit needs no emulation.
|
|
||||||
|
|
||||||
## Developer use
|
The host executor runs steps directly in the container and therefore **cannot
|
||||||
|
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
|
||||||
|
which is a Docker action. `reuse` is baked into the image, so replace that job
|
||||||
|
with a plain command:
|
||||||
|
|
||||||
Reproducible one-off build, no local toolchain needed:
|
```yaml
|
||||||
|
reuse:
|
||||||
```bash
|
runs-on: ubuntu-latest
|
||||||
podman run --rm -it -v "$PWD":/work -w /work \
|
steps:
|
||||||
git.haxx.space/sjg/trx-rs/sdk:latest \
|
- uses: actions/checkout@v4
|
||||||
cargo build --release
|
- name: REUSE compliance
|
||||||
|
run: reuse lint
|
||||||
```
|
```
|
||||||
|
|
||||||
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
|
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
|
||||||
(`.devcontainer/devcontainer.json` points at the same image).
|
steps still run, but become fast no-ops because the image already has those
|
||||||
|
packages and the toolchain. (`sudo` is included in the image for exactly this
|
||||||
|
reason.)
|
||||||
|
|
||||||
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
|
> If you would rather keep Docker-based actions and per-run images, use the
|
||||||
`rustup` installs the matching toolchain automatically.
|
> **Docker executor** instead: drop the `:host` suffix from the label in
|
||||||
|
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
|
||||||
|
> into the container, and set `container.docker_host` to the socket path. That
|
||||||
|
> trades the baked-in speed for stronger per-job isolation.
|
||||||
|
|
||||||
## CI use
|
## Tuning
|
||||||
|
|
||||||
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
|
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
|
||||||
this image via the `container:` key, so they skip all setup and go straight to
|
are heavy; 1–2 is sensible when two runners share a host.
|
||||||
`cargo` and `npm`. The frontend job needs three things from the image beyond
|
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
|
||||||
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
|
so one project cannot starve the other.
|
||||||
browser smoke test, and `cargo` — `npm run verify-generated` regenerates the
|
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
|
||||||
Rust wire contracts before checking for drift.
|
enforcing.
|
||||||
|
|
||||||
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
|
## Committing these files
|
||||||
Docker executor launches as a sibling container) — nothing REUSE-related is
|
|
||||||
baked into the SDK, and it lints the whole repository, so no job runs its own
|
|
||||||
licence check.
|
|
||||||
|
|
||||||
## Compilation cache (sccache)
|
If you add this directory to a REUSE-checked repo, register the markdown in
|
||||||
|
`REUSE.toml` (the other files carry inline SPDX headers):
|
||||||
|
|
||||||
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
|
```toml
|
||||||
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
|
[[annotations]]
|
||||||
so plain `cargo` builds outside the SDK are unaffected).
|
path = ["container/**"]
|
||||||
|
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
|
||||||
- **CI** persists the cache on the runner host — create the dir once:
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
|
|
||||||
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
|
|
||||||
and is shared between the lint/test jobs and both projects.
|
|
||||||
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
|
|
||||||
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
|
|
||||||
|
|
||||||
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
|
|
||||||
incremental artifacts.
|
|
||||||
|
|
||||||
## CI runner (Alpine / OpenRC)
|
|
||||||
|
|
||||||
The runner uses the **Docker executor** (not the host executor): per-job
|
|
||||||
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
|
|
||||||
as an OpenRC service. Files provided:
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
|
|
||||||
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
|
|
||||||
|
|
||||||
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
|
|
||||||
(libvirt/KVM):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<vcpu placement='static'>2</vcpu>
|
|
||||||
<cputune>
|
|
||||||
<vcpupin vcpu='0' cpuset='4'/>
|
|
||||||
<vcpupin vcpu='1' cpuset='5'/>
|
|
||||||
</cputune>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
|
|
||||||
`runner-config.example.yaml` already bound each runner.
|
|
||||||
|
|
||||||
**Set it up:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Docker + a dedicated user with socket access
|
|
||||||
apk add docker docker-cli
|
|
||||||
rc-update add docker default && rc-service docker start
|
|
||||||
adduser -S -D -H -h /var/lib/act_runner act
|
|
||||||
addgroup act docker
|
|
||||||
|
|
||||||
# 2. act_runner binary (static Go build, works on musl)
|
|
||||||
# Upstream publishes per-architecture builds; pick the host's.
|
|
||||||
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
|
|
||||||
curl -fsSL -o /usr/local/bin/act_runner \
|
|
||||||
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
|
|
||||||
chmod +x /usr/local/bin/act_runner
|
|
||||||
|
|
||||||
# 3. Config + register one runner per project (scope keeps their jobs apart)
|
|
||||||
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
|
|
||||||
install -d -o act /var/lib/act_runner/trx-rs
|
|
||||||
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
|
|
||||||
act_runner register --no-interactive \
|
|
||||||
--instance https://git.haxx.space --token <TOKEN> \
|
|
||||||
--name trx-rs-ci \
|
|
||||||
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
|
|
||||||
|
|
||||||
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
|
|
||||||
install -m755 container/act_runner.openrc /etc/init.d/act_runner
|
|
||||||
ln -s act_runner /etc/init.d/act_runner.trx-rs
|
|
||||||
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
|
||||||
rc-update add act_runner.trx-rs default
|
|
||||||
rc-service act_runner.trx-rs start
|
|
||||||
```
|
|
||||||
|
|
||||||
Check it with `rc-service act_runner.trx-rs status` and
|
|
||||||
`tail -f /var/log/act_runner.trx-rs.log`.
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Per-instance settings for an act_runner OpenRC service.
|
|
||||||
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
|
|
||||||
# (the name must match the /etc/init.d/ symlink).
|
|
||||||
|
|
||||||
# User that runs the daemon. Must be a member of the `docker` group.
|
|
||||||
runner_user="act"
|
|
||||||
|
|
||||||
# Per-instance state dir (holds the .runner registration) and config file,
|
|
||||||
# so two runners on one host stay independent.
|
|
||||||
runner_dir="/var/lib/act_runner/trx-rs"
|
|
||||||
runner_config="/etc/act_runner/trx-rs.yaml"
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/sbin/openrc-run
|
|
||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
|
|
||||||
#
|
|
||||||
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
|
|
||||||
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
|
|
||||||
# and add a matching conf.d file:
|
|
||||||
#
|
|
||||||
# ln -s act_runner /etc/init.d/act_runner.trx-rs
|
|
||||||
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
|
|
||||||
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
|
|
||||||
# rc-update add act_runner.trx-rs default
|
|
||||||
# rc-service act_runner.trx-rs start
|
|
||||||
|
|
||||||
description="Gitea Actions runner"
|
|
||||||
|
|
||||||
: "${runner_user:=act}"
|
|
||||||
: "${runner_dir:=/var/lib/act_runner}"
|
|
||||||
: "${runner_config:=/etc/act_runner/config.yaml}"
|
|
||||||
|
|
||||||
command="/usr/local/bin/act_runner"
|
|
||||||
command_args="daemon --config ${runner_config}"
|
|
||||||
# No group given, so supplementary groups (incl. docker) are initialised.
|
|
||||||
command_user="${runner_user}"
|
|
||||||
directory="${runner_dir}"
|
|
||||||
|
|
||||||
supervisor="supervise-daemon"
|
|
||||||
respawn_delay=5
|
|
||||||
respawn_max=0
|
|
||||||
pidfile="/run/${RC_SVCNAME}.pid"
|
|
||||||
output_log="/var/log/${RC_SVCNAME}.log"
|
|
||||||
error_log="/var/log/${RC_SVCNAME}.log"
|
|
||||||
|
|
||||||
depend() {
|
|
||||||
need docker
|
|
||||||
use net dns
|
|
||||||
}
|
|
||||||
|
|
||||||
start_pre() {
|
|
||||||
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
|
|
||||||
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# act_runner configuration template. Seeded into /data/config.yaml on first
|
||||||
|
# boot; edit the copy inside the volume to change settings per runner.
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
# Registration state. Relative to the daemon's working directory (/data).
|
||||||
|
file: .runner
|
||||||
|
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
|
||||||
|
# modest, especially if two runners share one host. The trx-rs workflow has
|
||||||
|
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
|
||||||
|
capacity: 2
|
||||||
|
timeout: 3h
|
||||||
|
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
|
||||||
|
# steps directly inside THIS container (which already has all the toolchain).
|
||||||
|
# No Docker/Podman socket is required in this mode.
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:host"
|
||||||
|
|
||||||
|
cache:
|
||||||
|
# Built-in actions cache server (used by actions/cache). Stored in the volume.
|
||||||
|
enabled: true
|
||||||
|
dir: "/data/cache"
|
||||||
|
|
||||||
|
host:
|
||||||
|
# Where per-job workspaces are created.
|
||||||
|
workdir_parent: /data/workflows
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Registers the runner on first boot (if no .runner state exists in /data),
|
||||||
|
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
|
||||||
|
# the stored registration and ignores the token.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
|
||||||
|
|
||||||
|
cd /data
|
||||||
|
|
||||||
|
# Seed the config from the image's template on first boot so it lives in the
|
||||||
|
# persistent volume and can be edited there.
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
|
||||||
|
if [ ! -f /data/.runner ]; then
|
||||||
|
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
|
||||||
|
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
|
||||||
|
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
|
||||||
|
echo " in the Quadlet unit for the first boot only." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
|
||||||
|
act_runner register --no-interactive \
|
||||||
|
--config "$CONFIG_FILE" \
|
||||||
|
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
|
||||||
|
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
|
||||||
|
--name "${GITEA_RUNNER_NAME:-podman}" \
|
||||||
|
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec act_runner daemon --config "$CONFIG_FILE"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
|
||||||
|
# co-located on the same host as the trx-rs runner.
|
||||||
|
#
|
||||||
|
# It has its own name, its own data volume and its own registration token, so
|
||||||
|
# the two runners are fully independent. They share the `ubuntu-latest` label,
|
||||||
|
# but registration SCOPE (which repo each token came from) keeps their jobs
|
||||||
|
# separate — neither will pick up the other's work.
|
||||||
|
#
|
||||||
|
# If project 2 needs different build dependencies, build it its own image from
|
||||||
|
# an adjusted Containerfile and point Image= at that instead of reusing the
|
||||||
|
# trx-rs image below.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner — project 2
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=localhost/gitea-act-runner:latest
|
||||||
|
ContainerName=gitea-runner-project2
|
||||||
|
Volume=gitea-runner-project2-data:/data:Z
|
||||||
|
|
||||||
|
Environment=CONFIG_FILE=/data/config.yaml
|
||||||
|
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||||
|
Environment=GITEA_RUNNER_NAME=project2-podman
|
||||||
|
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||||
|
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||||
|
|
||||||
|
PodmanArgs=--cpus=4.0 --memory=6g
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Example act_runner config for the Docker-executor runner that lives in the
|
|
||||||
# CI VM. This is NOT the SDK image — it configures the runner that launches
|
|
||||||
# per-job containers (including the trx-rs SDK image referenced by the
|
|
||||||
# workflow's `container:` key). Copy to the VM and pass with
|
|
||||||
# `act_runner daemon --config`.
|
|
||||||
|
|
||||||
log:
|
|
||||||
level: info
|
|
||||||
|
|
||||||
runner:
|
|
||||||
file: .runner
|
|
||||||
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
|
|
||||||
# total CI usage at ~2 threads.
|
|
||||||
capacity: 1
|
|
||||||
timeout: 3h
|
|
||||||
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
|
|
||||||
# (the workflow overrides these per job via `container:`).
|
|
||||||
labels:
|
|
||||||
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
|
|
||||||
|
|
||||||
cache:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
container:
|
|
||||||
# Cap every job container's CPU so CI stays within the 2-thread budget even
|
|
||||||
# if capacity is raised later. The -v mount persists the sccache cache on the
|
|
||||||
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
|
|
||||||
# in the workflow.
|
|
||||||
options: "--cpus=2 -v /var/cache/sccache:/sccache"
|
|
||||||
# act_runner rejects every bind mount unless it is listed here — the default
|
|
||||||
# is an empty allowlist, so the -v above is dropped with only a
|
|
||||||
# "[...] is not a valid volume, will be ignored" line in the job log, and
|
|
||||||
# SCCACHE_DIR then points at a directory that does not outlive the job.
|
|
||||||
valid_volumes:
|
|
||||||
- /var/cache/sccache
|
|
||||||
# Reuse the host VM's Docker network for the built-in cache/artifact server.
|
|
||||||
network: "host"
|
|
||||||
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
|
|
||||||
# the runner logs "Image exists? true" and reuses whatever it cached the
|
|
||||||
# first time, so pushing a rebuilt image has no effect until someone pulls
|
|
||||||
# on the VM by hand — which looks like the image is missing a tool it in
|
|
||||||
# fact has. The extra registry round-trip per job is nothing next to a build.
|
|
||||||
force_pull: true
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
|
||||||
|
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user start trx-rs-runner
|
||||||
|
#
|
||||||
|
# First boot only: paste a registration token (repo Settings -> Actions ->
|
||||||
|
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
|
||||||
|
# online you can blank it again — the registration is persisted in the volume.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner — trx-rs
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
Image=localhost/trx-rs-ci:latest
|
||||||
|
ContainerName=gitea-runner-trx-rs
|
||||||
|
# Persistent state: .runner registration, cache, workspaces.
|
||||||
|
Volume=gitea-runner-trx-rs-data:/data:Z
|
||||||
|
|
||||||
|
Environment=CONFIG_FILE=/data/config.yaml
|
||||||
|
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
|
||||||
|
Environment=GITEA_RUNNER_NAME=trx-rs-podman
|
||||||
|
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
|
||||||
|
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
|
||||||
|
|
||||||
|
# Resource caps so a heavy Rust build here cannot starve the other project's
|
||||||
|
# runner on the same host. Tune to your box.
|
||||||
|
PodmanArgs=--cpus=4.0 --memory=6g
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
# A cold Rust build can be slow; don't let systemd consider startup failed.
|
||||||
|
TimeoutStartSec=0
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
|
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
-->
|
|
||||||
|
|
||||||
# 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. |
|
|
||||||
|
|
||||||
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
|
|
||||||
and `window.trx.core` that feature bundles consume. Feature entries import it
|
|
||||||
instead of re-deriving the contract, so a service that moves out of the
|
|
||||||
application entry is added in one place. Application state that a feature needs
|
|
||||||
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
|
|
||||||
that has to intercept application behavior registers a `modules.*` method the
|
|
||||||
application calls, as the virtual-channel entry does for tuning, mode,
|
|
||||||
bandwidth, and frequency display.
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -7,11 +7,7 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
|
|||||||
application served as embedded static assets (gzip-compressed with ETag
|
application served as embedded static assets (gzip-compressed with ETag
|
||||||
caching) from the Actix-Web server.
|
caching) from the Actix-Web server.
|
||||||
|
|
||||||
## Historical asset inventory
|
## Current 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 |
|
| File | Lines | Size |
|
||||||
|------|------:|-----:|
|
|------|------:|-----:|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
|
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
-->
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
|
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
-->
|
|
||||||
|
|
||||||
# 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<void>;
|
|
||||||
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<DecoderId, DecoderStatus>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
#
|
|
||||||
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
|
|
||||||
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
|
|
||||||
# automatically for local builds outside the SDK container.
|
|
||||||
|
|
||||||
[toolchain]
|
|
||||||
channel = "1.97.1"
|
|
||||||
components = ["rustfmt", "clippy"]
|
|
||||||
@@ -274,8 +274,7 @@ mod tests {
|
|||||||
|
|
||||||
// Pseudo-random noise vs gradient — correlation should be low.
|
// Pseudo-random noise vs gradient — correlation should be low.
|
||||||
let noise: Vec<u8> = (0..256)
|
let noise: Vec<u8> = (0..256)
|
||||||
.map(|i| (i as u32).wrapping_mul(1_103_515_245).wrapping_add(12_345))
|
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
|
||||||
.map(|value| ((value >> 8) & 0xff) as u8)
|
|
||||||
.collect();
|
.collect();
|
||||||
let r = asm.correlation_with_last(&noise).expect("r");
|
let r = asm.correlation_with_last(&noise).expect("r");
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -29,4 +29,3 @@ hex = "0.4"
|
|||||||
pickledb = "0.5"
|
pickledb = "0.5"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
ts-rs = "12.0.1"
|
|
||||||
|
|||||||
+1793
-1732
File diff suppressed because it is too large
Load Diff
+27
-51
@@ -2,27 +2,16 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||||
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
|
|
||||||
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
|
|
||||||
interface DecodeState { offset: number }
|
|
||||||
interface FetchHistoryRequest { type: "fetch-history"; url?: string; batchLimit?: number }
|
|
||||||
interface WorkerScope {
|
|
||||||
postMessage(message: unknown): void;
|
|
||||||
onmessage: ((event: MessageEvent<unknown>) => void) | null;
|
|
||||||
}
|
|
||||||
const workerScope = self as unknown as WorkerScope;
|
|
||||||
|
|
||||||
function decodeCborUint(view: DataView, bytes: Uint8Array, state: DecodeState, additional: number): number {
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
const offset = state.offset;
|
const offset = state.offset;
|
||||||
if (additional < 24) return additional;
|
if (additional < 24) return additional;
|
||||||
if (additional === 24) {
|
if (additional === 24) {
|
||||||
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
state.offset += 1;
|
state.offset += 1;
|
||||||
return bytes[offset] ?? 0;
|
return bytes[offset];
|
||||||
}
|
}
|
||||||
if (additional === 25) {
|
if (additional === 25) {
|
||||||
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
@@ -45,7 +34,7 @@ function decodeCborUint(view: DataView, bytes: Uint8Array, state: DecodeState, a
|
|||||||
throw new Error("Unsupported CBOR additional info");
|
throw new Error("Unsupported CBOR additional info");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborFloat16(bits: number): number {
|
function decodeCborFloat16(bits) {
|
||||||
const sign = (bits & 0x8000) ? -1 : 1;
|
const sign = (bits & 0x8000) ? -1 : 1;
|
||||||
const exponent = (bits >> 10) & 0x1f;
|
const exponent = (bits >> 10) & 0x1f;
|
||||||
const fraction = bits & 0x03ff;
|
const fraction = bits & 0x03ff;
|
||||||
@@ -58,10 +47,9 @@ function decodeCborFloat16(bits: number): number {
|
|||||||
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
|
function decodeCborItem(view, bytes, state) {
|
||||||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
const initial = bytes[state.offset++];
|
const initial = bytes[state.offset++];
|
||||||
if (initial === undefined) throw new Error("CBOR payload truncated");
|
|
||||||
const major = initial >> 5;
|
const major = initial >> 5;
|
||||||
const additional = initial & 0x1f;
|
const additional = initial & 0x1f;
|
||||||
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
@@ -82,7 +70,7 @@ function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState):
|
|||||||
}
|
}
|
||||||
if (major === 4) {
|
if (major === 4) {
|
||||||
const length = decodeCborUint(view, bytes, state, additional);
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
const items: CborValue[] = new Array<CborValue>(length);
|
const items = new Array(length);
|
||||||
for (let i = 0; i < length; i += 1) {
|
for (let i = 0; i < length; i += 1) {
|
||||||
items[i] = decodeCborItem(view, bytes, state);
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
}
|
}
|
||||||
@@ -90,13 +78,10 @@ function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState):
|
|||||||
}
|
}
|
||||||
if (major === 5) {
|
if (major === 5) {
|
||||||
const length = decodeCborUint(view, bytes, state, additional);
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
const value: Record<string, CborValue> = {};
|
const value = {};
|
||||||
for (let i = 0; i < length; i += 1) {
|
for (let i = 0; i < length; i += 1) {
|
||||||
const key = decodeCborItem(view, bytes, state);
|
const key = decodeCborItem(view, bytes, state);
|
||||||
const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean"
|
value[String(key)] = decodeCborItem(view, bytes, state);
|
||||||
? String(key)
|
|
||||||
: JSON.stringify(key);
|
|
||||||
value[property] = decodeCborItem(view, bytes, state);
|
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -131,7 +116,7 @@ function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState):
|
|||||||
throw new Error("Unsupported CBOR major type");
|
throw new Error("Unsupported CBOR major type");
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeCborPayload(buffer: ArrayBuffer | Uint8Array): CborValue {
|
function decodeCborPayload(buffer) {
|
||||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const state = { offset: 0 };
|
const state = { offset: 0 };
|
||||||
@@ -142,39 +127,35 @@ function decodeCborPayload(buffer: ArrayBuffer | Uint8Array): CborValue {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHistory(value: CborValue): value is Partial<Record<HistoryGroup, CborValue[]>> {
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
self.postMessage({ type: "status", phase: "fetching" });
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAndDecodeHistory(url: string, batchLimit?: number) {
|
|
||||||
workerScope.postMessage({ type: "status", phase: "fetching" });
|
|
||||||
const resp = await fetch(url, { credentials: "same-origin" });
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
|
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
||||||
const payload = await resp.arrayBuffer();
|
const payload = await resp.arrayBuffer();
|
||||||
if (payload.byteLength === 0) {
|
if (!payload || payload.byteLength === 0) {
|
||||||
workerScope.postMessage({ type: "start", total: 0 });
|
self.postMessage({ type: "start", total: 0 });
|
||||||
workerScope.postMessage({ type: "done", total: 0 });
|
self.postMessage({ type: "done", total: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
workerScope.postMessage({ type: "status", phase: "decoding" });
|
self.postMessage({ type: "status", phase: "decoding" });
|
||||||
const history = decodeCborPayload(payload);
|
const history = decodeCborPayload(payload);
|
||||||
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
|
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
||||||
return sum + items.length;
|
return sum + items.length;
|
||||||
}, 0);
|
}, 0);
|
||||||
workerScope.postMessage({ type: "start", total });
|
self.postMessage({ type: "start", total });
|
||||||
|
|
||||||
let processed = 0;
|
let processed = 0;
|
||||||
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
|
||||||
for (const kind of HISTORY_GROUP_KEYS) {
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
|
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
if (items.length === 0) continue;
|
if (items.length === 0) continue;
|
||||||
for (let index = 0; index < items.length; index += safeLimit) {
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
const messages = items.slice(index, index + safeLimit);
|
const messages = items.slice(index, index + safeLimit);
|
||||||
processed += messages.length;
|
processed += messages.length;
|
||||||
workerScope.postMessage({
|
self.postMessage({
|
||||||
type: "group",
|
type: "group",
|
||||||
kind,
|
kind,
|
||||||
messages,
|
messages,
|
||||||
@@ -183,22 +164,17 @@ async function fetchAndDecodeHistory(url: string, batchLimit?: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
workerScope.postMessage({ type: "done", total });
|
self.postMessage({ type: "done", total });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFetchHistoryRequest(value: unknown): value is FetchHistoryRequest {
|
self.onmessage = (event) => {
|
||||||
return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
|
const data = event?.data || {};
|
||||||
}
|
if (data?.type !== "fetch-history") return;
|
||||||
|
|
||||||
workerScope.onmessage = (event) => {
|
|
||||||
const data = event.data;
|
|
||||||
if (!isFetchHistoryRequest(data)) return;
|
|
||||||
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
||||||
.catch((error: unknown) => {
|
.catch((err) => {
|
||||||
workerScope.postMessage({
|
self.postMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: error instanceof Error ? error.message
|
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
||||||
: typeof error === "string" ? error : "unknown worker failure",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore,
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/ais.ts
|
|
||||||
var aisWindow = window;
|
|
||||||
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
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 `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
|
||||||
}
|
|
||||||
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 (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.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 ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
|
|
||||||
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 = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
|
||||||
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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
|
||||||
for (const msg of messages) {
|
|
||||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
|
||||||
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
|
|
||||||
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
|
||||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
|
||||||
}
|
|
||||||
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 hostCore.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
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,318 +0,0 @@
|
|||||||
import {
|
|
||||||
aprsAgeText,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
normalizeAprsPacket,
|
|
||||||
renderAprsInfo,
|
|
||||||
renderLocalAprsSymbol
|
|
||||||
} from "./chunk-M2I6DH4X.js";
|
|
||||||
import {
|
|
||||||
hostCore,
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/aprs.ts
|
|
||||||
var aprsWindow = window;
|
|
||||||
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
var showAprsHint = (message, durationMs) => {
|
|
||||||
hostCore.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 (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.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 ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
|
||||||
const distance = aprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>`;
|
|
||||||
for (const pkt of frames) {
|
|
||||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
|
||||||
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
|
||||||
const dest = escapeAprsHtml(pkt.destCall || "");
|
|
||||||
const info = escapeAprsHtml(pkt.info || "");
|
|
||||||
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
|
|
||||||
}
|
|
||||||
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 hostCore.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
|
|
||||||
});
|
|
||||||
-370
@@ -1,370 +0,0 @@
|
|||||||
import {
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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" || hostState.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 = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
|
||||||
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 = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
|
|
||||||
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 ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
|
||||||
html += '<div class="bgd-status-list">';
|
|
||||||
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 += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
|
||||||
});
|
|
||||||
html += "</div>";
|
|
||||||
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, ">").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
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore,
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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 bmOptionalEl(id) {
|
|
||||||
return document.getElementById(id);
|
|
||||||
}
|
|
||||||
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 !hostState.authEnabled || hostState.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() {
|
|
||||||
return hostState.lastActiveRigId || "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);
|
|
||||||
}
|
|
||||||
hostCore.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" ? ' <span class="bm-scope-badge">G</span>' : "";
|
|
||||||
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
|
|
||||||
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 hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
|
||||||
}
|
|
||||||
function bmWriteDecoders(decoders) {
|
|
||||||
const set = new Set(decoders || []);
|
|
||||||
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
|
||||||
const el = bmOptionalEl("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 = "";
|
|
||||||
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
|
|
||||||
const label = document.createElement("label");
|
|
||||||
label.className = "bm-decoder-check";
|
|
||||||
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + 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() {
|
|
||||||
const freqHz = hostState.lastFreqHz;
|
|
||||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
|
||||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
|
||||||
}
|
|
||||||
if (hostState.lastModeName) {
|
|
||||||
bmEl("bm-mode").value = hostState.lastModeName;
|
|
||||||
}
|
|
||||||
if (hostState.currentBandwidthHz > 0) {
|
|
||||||
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
|
||||||
}
|
|
||||||
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-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 {
|
|
||||||
const modeEl = document.getElementById("mode");
|
|
||||||
if (modeEl) {
|
|
||||||
modeEl.value = (bm.mode || "").toUpperCase();
|
|
||||||
}
|
|
||||||
if (bm.bandwidth_hz) {
|
|
||||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
|
||||||
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
|
||||||
}
|
|
||||||
hostCore.armOptimisticFrequency(bm.freq_hz);
|
|
||||||
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
|
||||||
if (hostState.lastSpectrumData) {
|
|
||||||
hostCore.scheduleSpectrumDraw();
|
|
||||||
}
|
|
||||||
const tunePromise = (async () => {
|
|
||||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
|
||||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
|
||||||
if (!onVirtual) {
|
|
||||||
await hostCore.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 hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hostCore.setRigFrequency(bm.freq_hz);
|
|
||||||
})();
|
|
||||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
|
||||||
const modeUp = (bm.mode || "").toUpperCase();
|
|
||||||
const allToggleDecoders = hostState.decoderRegistry.filter(
|
|
||||||
(d) => d.activation === "toggle"
|
|
||||||
);
|
|
||||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
|
||||||
let statusUrl = "/status";
|
|
||||||
const rigId = hostState.lastActiveRigId;
|
|
||||||
if (rigId) {
|
|
||||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
|
||||||
}
|
|
||||||
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(hostCore.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.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 = hostState.lastRigIds;
|
|
||||||
const displayNames = hostState.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 = hostState.lastRigIds;
|
|
||||||
const displayNames = hostState.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();
|
|
||||||
hostCore.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("");
|
|
||||||
})();
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// src/plugins/host.ts
|
|
||||||
var host = window;
|
|
||||||
var hostState = host.trx.state;
|
|
||||||
var hostCore = host.trx.core;
|
|
||||||
|
|
||||||
export {
|
|
||||||
hostState,
|
|
||||||
hostCore
|
|
||||||
};
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// 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)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function renderAprsCharacter(character) {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
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 `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
|
||||||
}
|
|
||||||
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
|
|
||||||
};
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/ftx-family.ts
|
|
||||||
var bridge = window;
|
|
||||||
function formatBarTime(timestampMs) {
|
|
||||||
if (!timestampMs) return "--:--:--";
|
|
||||||
return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
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) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : 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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${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 = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
|
||||||
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 ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
|
||||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
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 hostCore.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 hostCore.postPath(`/clear_${id}_decode`);
|
|
||||||
reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} history clear failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
installFtxCompatibilityHelpers,
|
|
||||||
initializeFt8FamilyBar,
|
|
||||||
initializeFtxDecoder
|
|
||||||
};
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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 hostCore.escapeMapHtml(input);
|
|
||||||
}
|
|
||||||
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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
|
||||||
for (const line of liveLines.slice(0, 8)) {
|
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
|
||||||
const meta = [
|
|
||||||
line.wpm ? `${String(line.wpm)} WPM` : null,
|
|
||||||
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
|
||||||
].filter(Boolean).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
|
||||||
}
|
|
||||||
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 hostCore.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 hostCore.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 hostCore.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 hostCore.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();
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
"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"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import {
|
|
||||||
initializeFtxDecoder
|
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
|
||||||
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import {
|
|
||||||
initializeFtxDecoder
|
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
|
||||||
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import {
|
|
||||||
initializeFt8FamilyBar,
|
|
||||||
initializeFtxDecoder,
|
|
||||||
installFtxCompatibilityHelpers
|
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
|
||||||
import "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
|
||||||
installFtxCompatibilityHelpers();
|
|
||||||
initializeFt8FamilyBar();
|
|
||||||
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import {
|
|
||||||
aprsAgeText,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
normalizeAprsPacket,
|
|
||||||
renderAprsInfo,
|
|
||||||
renderLocalAprsSymbol
|
|
||||||
} from "./chunk-M2I6DH4X.js";
|
|
||||||
import {
|
|
||||||
hostCore,
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/hf-aprs.ts
|
|
||||||
var hfAprsWindow = window;
|
|
||||||
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
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 (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.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 ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
|
||||||
const distance = hfAprsDistanceText(pkt);
|
|
||||||
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
|
||||||
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
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);
|
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hostCore.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 hostCore.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 hostCore.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
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,263 +0,0 @@
|
|||||||
// 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, ">").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 = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
|
|
||||||
} else {
|
|
||||||
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function renderBookmarkSelect(selectedId) {
|
|
||||||
const bookmarkSelect = dom.bookmark;
|
|
||||||
if (!bookmarkSelect) return;
|
|
||||||
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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 = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
|
||||||
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 = `<div class="sat-latest-card">`;
|
|
||||||
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
|
||||||
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
|
||||||
if (img.path) {
|
|
||||||
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
|
||||||
}
|
|
||||||
if (img.geo_bounds) {
|
|
||||||
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
|
||||||
}
|
|
||||||
html += `</div>`;
|
|
||||||
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 ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
|
|
||||||
if (img.geo_bounds) {
|
|
||||||
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
|
||||||
}
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span>${date} ${ts}</span>`,
|
|
||||||
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
|
||||||
`<span>${satellite}</span>`,
|
|
||||||
`<span>${channels}</span>`,
|
|
||||||
`<span>${lines} ${unit}</span>`,
|
|
||||||
`<span>${link}</span>`
|
|
||||||
].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 hostCore.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 hostCore.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 = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].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 = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].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 = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
||||||
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();
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,451 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore,
|
|
||||||
hostState
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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 || hostState.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();
|
|
||||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
}
|
|
||||||
async function vchanAllocate() {
|
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
|
||||||
const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.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;
|
|
||||||
hostState.audioChannelOverride = ch?.id ?? null;
|
|
||||||
if (!hostState.rxActive) return;
|
|
||||||
hostCore.stopRxAudio();
|
|
||||||
setTimeout(() => {
|
|
||||||
hostCore.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;
|
|
||||||
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
|
||||||
}
|
|
||||||
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 hostState.lastModeName === "string") {
|
|
||||||
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
|
||||||
hostCore.setJogDivisor(10);
|
|
||||||
hostCore.resetRdsDisplay();
|
|
||||||
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
|
||||||
hostCore.resetRdsDisplay();
|
|
||||||
}
|
|
||||||
hostState.lastModeName = modeUpper;
|
|
||||||
}
|
|
||||||
hostCore.updateWfmControls();
|
|
||||||
hostCore.updateSdrSquelchControlVisibility();
|
|
||||||
if (vchanWindow.refreshRdsUi) {
|
|
||||||
vchanWindow.refreshRdsUi();
|
|
||||||
} else {
|
|
||||||
hostCore.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) {
|
|
||||||
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
|
||||||
}
|
|
||||||
if (bwHz > 0) {
|
|
||||||
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
|
||||||
hostState.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 {
|
|
||||||
hostCore.refreshFreqDisplay();
|
|
||||||
}
|
|
||||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
|
||||||
}
|
|
||||||
function vchanSetChannelFreq(freqHz) {
|
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
|
||||||
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
|
||||||
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
|
||||||
const center = hostState.lastSpectrumData.center_hz;
|
|
||||||
if (Math.abs(freqHz - center) > halfSpan) {
|
|
||||||
hostCore.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;
|
|
||||||
}
|
|
||||||
function vchanInterceptFrequency(freqHz) {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
const targetHz = Math.round(freqHz);
|
|
||||||
hostCore.armOptimisticFrequency(targetHz);
|
|
||||||
hostCore.applyLocalTunedFrequency(targetHz);
|
|
||||||
vchanSetChannelFreq(freqHz);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function vchanInterceptFreqDisplay() {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
vchanUpdateFreqDisplay();
|
|
||||||
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,
|
|
||||||
interceptFrequency: vchanInterceptFrequency,
|
|
||||||
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
|
||||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
|
||||||
releaseToScheduler: vchanToggleSchedulerRelease
|
|
||||||
};
|
|
||||||
(function initSchedulerReleaseControl() {
|
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
void vchanToggleSchedulerRelease();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
vchanStartSchedulerReleasePolling();
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
})();
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// src/plugins/vdes.ts
|
|
||||||
var vdesWindow = window;
|
|
||||||
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
|
|
||||||
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 = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span></div>`;
|
|
||||||
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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
|
||||||
for (const msg of messages) {
|
|
||||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
|
||||||
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
|
||||||
}
|
|
||||||
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 hostCore.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
|
|
||||||
});
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import {
|
|
||||||
hostCore
|
|
||||||
} from "./chunk-KL66PICH.js";
|
|
||||||
|
|
||||||
// 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, ">").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 = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
|
||||||
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 = '<div class="sat-latest-card">';
|
|
||||||
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
|
||||||
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
|
|
||||||
if (imgSrc) {
|
|
||||||
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
|
||||||
}
|
|
||||||
html += "</div>";
|
|
||||||
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 ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
|
|
||||||
row.innerHTML = [
|
|
||||||
"<span>" + escapeHtml(date + " " + ts) + "</span>",
|
|
||||||
"<span>" + escapeHtml(String(ioc)) + "</span>",
|
|
||||||
"<span>" + escapeHtml(String(lpm)) + "</span>",
|
|
||||||
`<span>${String(lines)}</span>`,
|
|
||||||
"<span>" + link + "</span>"
|
|
||||||
].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 hostCore.postPath("/toggle_wefax_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WEFAX toggle failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (wefaxDom.clearBtn) {
|
|
||||||
wefaxDom.clearBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await hostCore.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
|
|
||||||
});
|
|
||||||
@@ -43,10 +43,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
<div class="subtitle" id="location-subtitle" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-bar-nav" aria-label="Primary navigation">
|
<div class="tab-bar-nav">
|
||||||
<button class="tab active" data-tab="main">
|
<button class="tab active" data-tab="main">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
|
||||||
<span class="tab-label">Radio</span>
|
<span class="tab-label">Main</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="bookmarks">
|
<button class="tab" data-tab="bookmarks">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
|
||||||
@@ -140,14 +140,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="spectrum-wrap">
|
<div class="spectrum-wrap">
|
||||||
<div id="spectrum-bookmark-axis"></div>
|
<div id="spectrum-bookmark-axis"></div>
|
||||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display"></canvas>
|
||||||
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
|
||||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||||
<div id="spectrum-tooltip"></div>
|
<div id="spectrum-tooltip"></div>
|
||||||
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
|
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas>
|
||||||
<div id="spectrum-freq-axis">
|
<div id="spectrum-freq-axis">
|
||||||
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||||
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||||
@@ -205,12 +204,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="label"><span>Signal strength</span></div>
|
<div class="label"><span>Signal strength</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col">
|
<div class="freq-field frequency-col">
|
||||||
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
|
<input class="status-input" id="freq" type="text" value="--" />
|
||||||
<div class="label" id="freq-label"><span>Frequency</span></div>
|
<div class="label"><span>Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
|
||||||
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
|
<input class="status-input" id="center-freq" type="text" value="--" />
|
||||||
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
|
<div class="label"><span>Center Frequency</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="freq-field unit-col">
|
<div class="freq-field unit-col">
|
||||||
<div class="jog-step" id="jog-step">
|
<div class="jog-step" id="jog-step">
|
||||||
@@ -236,7 +235,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="controls-col label-below-col">
|
<div class="controls-col label-below-col">
|
||||||
<div class="label"><span>Mode</span></div>
|
<div class="label"><span>Mode</span></div>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
|
<select class="status-input" id="mode"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-col controls-col-center">
|
<div class="controls-col controls-col-center">
|
||||||
@@ -308,9 +307,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
|
||||||
<div class="label"><span>Transmit / Power</span></div>
|
<div class="label"><span>Transmit / Power</span></div>
|
||||||
<div class="btn-grid">
|
<div class="btn-grid">
|
||||||
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
|
<button id="ptt-btn" type="button">Toggle PTT</button>
|
||||||
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
|
<button id="power-btn" type="button">Toggle Power</button>
|
||||||
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
|
<button id="lock-btn" type="button">Lock</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,14 +356,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="full-row label-below-row" id="vchan-row">
|
<div class="full-row label-below-row" id="vchan-row">
|
||||||
<div class="label"><span>Channels</span></div>
|
<div class="label"><span>Channels / Scheduler</span></div>
|
||||||
<div class="channel-scheduler-controls">
|
<div class="channel-scheduler-controls">
|
||||||
<div class="vchan-picker" id="vchan-picker"></div>
|
<div class="vchan-picker" id="vchan-picker"></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
|
|
||||||
<summary>Scheduler controls</summary>
|
|
||||||
<div class="advanced-radio-body">
|
|
||||||
<div class="scheduler-control-row" style="display:none">
|
<div class="scheduler-control-row" style="display:none">
|
||||||
<div class="scheduler-release-wrap">
|
<div class="scheduler-release-wrap">
|
||||||
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
|
||||||
@@ -387,7 +381,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
<div class="full-row label-below-row">
|
<div class="full-row label-below-row">
|
||||||
<div class="label"><span>Signal</span></div>
|
<div class="label"><span>Signal</span></div>
|
||||||
<div class="signal" style="gap: 1rem;">
|
<div class="signal" style="gap: 1rem;">
|
||||||
@@ -418,24 +412,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<button id="tx-limit-btn" type="button">Set</button>
|
<button id="tx-limit-btn" type="button">Set</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<details id="audio-controls" class="advanced-radio-controls audio-controls-section">
|
<div class="full-row label-below-row" id="audio-row">
|
||||||
<summary>Audio controls</summary>
|
<div class="label"><span>Audio</span></div>
|
||||||
<div class="advanced-radio-body">
|
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
||||||
<div class="full-row" id="audio-row">
|
<button id="rx-audio-btn" type="button">Play Audio</button>
|
||||||
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
|
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||||
<button id="rx-audio-btn" type="button">Play Audio</button>
|
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
||||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
<div id="audio-level">
|
||||||
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
<div id="audio-level-fill"></div>
|
||||||
<div id="audio-level">
|
|
||||||
<div id="audio-level-fill"></div>
|
|
||||||
</div>
|
|
||||||
<small id="audio-status" style="min-width: 60px;">Off</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<small id="audio-status" style="min-width: 60px;">Off</small>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,7 +495,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="bm-form-actions">
|
<div class="bm-form-actions">
|
||||||
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
|
|
||||||
<button type="submit" class="bm-save-btn">Save</button>
|
<button type="submit" class="bm-save-btn">Save</button>
|
||||||
<button type="button" id="bm-form-cancel">Cancel</button>
|
<button type="button" id="bm-form-cancel">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,7 +530,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
<div id="tab-digital-modes" class="tab-panel" style="display:none;">
|
||||||
<div class="sub-tab-bar" aria-label="Decoder views">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
||||||
<button class="sub-tab" data-subtab="ais">AIS</button>
|
<button class="sub-tab" data-subtab="ais">AIS</button>
|
||||||
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
<button class="sub-tab" data-subtab="vdes">VDES</button>
|
||||||
@@ -1051,7 +1040,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
<div class="stats-controls">
|
<div class="stats-controls">
|
||||||
<div class="stats-control-group">
|
<div class="stats-control-group">
|
||||||
@@ -1192,7 +1180,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-settings" class="tab-panel" style="display:none;">
|
<div id="tab-settings" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Settings</h2>
|
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
|
||||||
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
|
||||||
@@ -1488,7 +1475,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-about" class="tab-panel" style="display:none;">
|
<div id="tab-about" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">About</h2>
|
|
||||||
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
|
||||||
<template id="tmpl-about">
|
<template id="tmpl-about">
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
@@ -1637,9 +1623,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.11/dist/opus-decoder.min.js" charset="UTF-8"></script>
|
||||||
<script defer src="/vendor/leaflet.js"></script>
|
<script defer src="/vendor/leaflet.js"></script>
|
||||||
<script type="module" src="/app.js"></script>
|
<script defer src="/leaflet-ais-tracksymbol.js"></script>
|
||||||
<!-- Template cloning is handled by the typed application bundle. -->
|
<script defer src="/webgl-renderer.js"></script>
|
||||||
|
<script defer src="/app.js"></script>
|
||||||
|
<script>
|
||||||
|
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
|
||||||
|
(function() {
|
||||||
|
var pluginScripts = {
|
||||||
|
'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 = new Set();
|
||||||
|
function loadPlugins(tab) {
|
||||||
|
var scripts = pluginScripts[tab];
|
||||||
|
if (!scripts) return;
|
||||||
|
scripts.forEach(function(src) {
|
||||||
|
if (loaded.has(src)) return;
|
||||||
|
loaded.add(src);
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.defer = true;
|
||||||
|
document.body.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Eager plugin loading is triggered by app.js (after window.trx is set up)
|
||||||
|
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
|
||||||
|
// loading them before app.js would cause map-core.js to crash when
|
||||||
|
// window.trx is not yet defined.
|
||||||
|
window.loadEagerPlugins = function() {
|
||||||
|
['digital-modes', 'map-data', 'bookmarks', 'settings'].forEach(loadPlugins);
|
||||||
|
};
|
||||||
|
// Load others on tab switch
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var tab = e.target.closest('[data-tab]');
|
||||||
|
if (tab) loadPlugins(tab.dataset.tab);
|
||||||
|
});
|
||||||
|
window.loadPluginsForTab = loadPlugins;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- Template cloning is handled by navigateToTab() in app.js -->
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
(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
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
||||||
|
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
||||||
|
`</g>`
|
||||||
|
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||||
|
|
||||||
|
const courseLine = course != null
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
||||||
|
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
||||||
|
`</g>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
||||||
|
courseLine +
|
||||||
|
body +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
})();
|
||||||
+402
-564
File diff suppressed because it is too large
Load Diff
+101
-124
@@ -2,47 +2,10 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
|
||||||
|
|
||||||
interface AisMessage {
|
|
||||||
rig_id?: string | null;
|
|
||||||
channel?: string | null;
|
|
||||||
message_type?: number | null;
|
|
||||||
mmsi?: number | null;
|
|
||||||
lat?: number | null;
|
|
||||||
lon?: number | null;
|
|
||||||
sog_knots?: number | null;
|
|
||||||
cog_deg?: number | null;
|
|
||||||
heading_deg?: number | null;
|
|
||||||
vessel_name?: string | null;
|
|
||||||
callsign?: string | null;
|
|
||||||
destination?: string | null;
|
|
||||||
ts_ms?: number | null;
|
|
||||||
_tsMs?: number;
|
|
||||||
_ts?: string;
|
|
||||||
}
|
|
||||||
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
|
||||||
interface AisBridge {
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
|
||||||
aisMapAddVessel?: (message: AisMessage) => void;
|
|
||||||
clearMapMarkersByType?: (type: string) => void;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
updateAisBar?: () => void;
|
|
||||||
clearAisBar?: () => void;
|
|
||||||
}
|
|
||||||
const aisWindow = window as unknown as AisBridge;
|
|
||||||
const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
|
|
||||||
// --- AIS Decoder Plugin (server-side decode) ---
|
// --- AIS Decoder Plugin (server-side decode) ---
|
||||||
const aisStatus = document.getElementById("ais-status");
|
const aisStatus = document.getElementById("ais-status");
|
||||||
const aisMessagesEl = document.getElementById("ais-messages");
|
const aisMessagesEl = document.getElementById("ais-messages");
|
||||||
const aisFilterInput = document.getElementById("ais-filter") as HTMLInputElement | null;
|
const aisFilterInput = document.getElementById("ais-filter");
|
||||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
@@ -51,41 +14,41 @@ const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|||||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||||
let aisFilterText = "";
|
let aisFilterText = "";
|
||||||
let aisMessageHistory: AisMessage[] = [];
|
let aisMessageHistory = [];
|
||||||
|
|
||||||
function currentAisHistoryRetentionMs(): number {
|
function currentAisHistoryRetentionMs() {
|
||||||
return typeof aisWindow.getDecodeHistoryRetentionMs === "function"
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
? aisWindow.getDecodeHistoryRetentionMs()
|
? window.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisMessageHistory() {
|
function pruneAisMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisUi(key: string, job: () => void): void {
|
function scheduleAisUi(key, job) {
|
||||||
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
aisWindow.trxScheduleUiFrameJob(key, job);
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisHistoryRender() {
|
function scheduleAisHistoryRender() {
|
||||||
scheduleAisUi("ais-history", () => { renderAisHistory(); });
|
scheduleAisUi("ais-history", () => renderAisHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisBarUpdate() {
|
function scheduleAisBarUpdate() {
|
||||||
scheduleAisUi("ais-bar", () => { updateAisBar(); });
|
scheduleAisUi("ais-bar", () => updateAisBar());
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAisMhz(freqHz: number): string {
|
function formatAisMhz(freqHz) {
|
||||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentAisChannelPlan(): { aHz: number; bHz: number } {
|
function currentAisChannelPlan() {
|
||||||
const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
return {
|
return {
|
||||||
@@ -94,9 +57,9 @@ function currentAisChannelPlan(): { aHz: number; bHz: number } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisChannelInfo(channel: string | null | undefined): AisChannelInfo {
|
function aisChannelInfo(channel) {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
const ch = (channel ?? "").trim().toUpperCase();
|
const ch = String(channel || "").trim().toUpperCase();
|
||||||
if (ch === "B") {
|
if (ch === "B") {
|
||||||
return {
|
return {
|
||||||
label: "AIS-B",
|
label: "AIS-B",
|
||||||
@@ -111,18 +74,18 @@ function aisChannelInfo(channel: string | null | undefined): AisChannelInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayName(msg: AisMessage): string {
|
function aisDisplayName(msg) {
|
||||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayNameHtml(msg: AisMessage): string {
|
function aisDisplayNameHtml(msg) {
|
||||||
const label = escapeAisHtml(aisDisplayName(msg));
|
const label = escapeMapHtml(aisDisplayName(msg));
|
||||||
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||||
if (!url) return label;
|
if (!url) return label;
|
||||||
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisTypeLabel(type: number | null | undefined): string {
|
function aisTypeLabel(type) {
|
||||||
switch (Number(type)) {
|
switch (Number(type)) {
|
||||||
case 1:
|
case 1:
|
||||||
case 2:
|
case 2:
|
||||||
@@ -145,8 +108,8 @@ function aisTypeLabel(type: number | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisAgeText(tsMs: number | undefined): string {
|
function aisAgeText(tsMs) {
|
||||||
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return "just now";
|
||||||
@@ -157,31 +120,31 @@ function aisAgeText(tsMs: number | undefined): string {
|
|||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisMotionText(msg: AisMessage): string {
|
function aisMotionText(msg) {
|
||||||
const parts = [
|
const parts = [
|
||||||
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
||||||
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null,
|
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisRouteText(msg: AisMessage): string {
|
function aisRouteText(msg) {
|
||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDistanceText(msg: AisMessage): string {
|
function aisDistanceText(msg) {
|
||||||
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
|
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
function aisLatestByVessel(messages) {
|
||||||
const byMmsi = new Map<string, AisMessage>();
|
const byMmsi = new Map();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
@@ -212,7 +175,7 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAisRow(msg: AisMessage): HTMLElement {
|
function renderAisRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
@@ -246,25 +209,25 @@ function renderAisRow(msg: AisMessage): HTMLElement {
|
|||||||
`<div class="ais-row-head">` +
|
`<div class="ais-row-head">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<span class="ais-time">${ts}</span>` +
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||||
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-meta">` +
|
`<div class="ais-row-meta">` +
|
||||||
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||||
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
|
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
||||||
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
|
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-detail">` +
|
`<div class="ais-row-detail">` +
|
||||||
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||||
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
|
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAisFilterToRow(row: HTMLElement): void {
|
function applyAisFilterToRow(row) {
|
||||||
if (!aisFilterText) {
|
if (!aisFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
return;
|
return;
|
||||||
@@ -273,13 +236,19 @@ function applyAisFilterToRow(row: HTMLElement): void {
|
|||||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyAisFilterToAll() {
|
||||||
|
if (!aisMessagesEl) return;
|
||||||
|
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||||
|
rows.forEach((row) => applyAisFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
function updateAisBar() {
|
function updateAisBar() {
|
||||||
if (!aisBarOverlay) return;
|
if (!aisBarOverlay) return;
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
|
||||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -297,12 +266,12 @@ function updateAisBar() {
|
|||||||
const channel = aisChannelInfo(msg.channel);
|
const channel = aisChannelInfo(msg.channel);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const details = [
|
const details = [
|
||||||
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||||
escapeAisHtml(channel.label),
|
escapeMapHtml(channel.label),
|
||||||
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||||
distance ? escapeAisHtml(distance) : null,
|
distance ? escapeMapHtml(distance) : null,
|
||||||
escapeAisHtml(aisAgeText(msg._tsMs)),
|
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -313,18 +282,18 @@ function updateAisBar() {
|
|||||||
aisBarOverlay.innerHTML = html;
|
aisBarOverlay.innerHTML = html;
|
||||||
aisBarOverlay.style.display = "flex";
|
aisBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
aisWindow.updateAisBar = updateAisBar;
|
window.updateAisBar = updateAisBar;
|
||||||
aisWindow.clearAisBar = function() {
|
window.clearAisBar = function() {
|
||||||
resetAisHistoryView();
|
window.resetAisHistoryView();
|
||||||
};
|
};
|
||||||
|
|
||||||
function resetAisHistoryView(): void {
|
window.resetAisHistoryView = function() {
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
aisMessageHistory = [];
|
aisMessageHistory = [];
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
aisWindow.clearMapMarkersByType?.("ais");
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||||
}
|
};
|
||||||
|
|
||||||
function renderAisHistory() {
|
function renderAisHistory() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
@@ -333,14 +302,14 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAisMessage(msg: AisMessage): void {
|
function addAisMessage(msg) {
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
msg._tsMs = tsMs;
|
msg._tsMs = tsMs;
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -354,22 +323,33 @@ function addAisMessage(msg: AisMessage): void {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||||
aisWindow.aisMapAddVessel(msg);
|
window.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
function normalizeServerAisMessage(msg) {
|
||||||
return {
|
return {
|
||||||
...msg,
|
|
||||||
rig_id: msg.rig_id || null,
|
rig_id: msg.rig_id || null,
|
||||||
|
channel: msg.channel,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
mmsi: msg.mmsi,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
sog_knots: msg.sog_knots,
|
||||||
|
cog_deg: msg.cog_deg,
|
||||||
|
heading_deg: msg.heading_deg,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerAisBatch(messages: AisMessage[]): void {
|
window.onServerAisBatch = function(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized: AisMessage[] = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
@@ -379,8 +359,8 @@ function onServerAisBatch(messages: AisMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
||||||
aisWindow.aisMapAddVessel(next);
|
window.aisMapAddVessel(next);
|
||||||
}
|
}
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -389,23 +369,27 @@ function onServerAisBatch(messages: AisMessage[]): void {
|
|||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneAisHistoryView(): void {
|
window.restoreAisHistory = function(messages) {
|
||||||
|
window.onServerAisBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneAisHistoryView = function() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||||
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_ais_decode");
|
await postPath("/clear_ais_decode");
|
||||||
resetAisHistoryView();
|
window.resetAisHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AIS history clear failed", e);
|
console.error("AIS history clear failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
if (aisFilterInput) {
|
if (aisFilterInput) {
|
||||||
aisFilterInput.addEventListener("input", () => {
|
aisFilterInput.addEventListener("input", () => {
|
||||||
@@ -414,17 +398,10 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerAis(msg: AisMessage): void {
|
window.onServerAis = function(msg) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
}
|
};
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AisMessage>({
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
||||||
id: "ais",
|
|
||||||
onMessage: onServerAis,
|
|
||||||
onBatch: onServerAisBatch,
|
|
||||||
restore: onServerAisBatch,
|
|
||||||
reset: resetAisHistoryView,
|
|
||||||
prune: pruneAisHistoryView,
|
|
||||||
});
|
|
||||||
+212
-123
@@ -2,41 +2,10 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
|
||||||
|
|
||||||
import {
|
|
||||||
aprsAgeText,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
normalizeAprsPacket,
|
|
||||||
renderAprsInfo,
|
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
|
||||||
type AprsTypeFilter,
|
|
||||||
} from "./aprs-shared";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
|
||||||
interface AprsBridge {
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
|
||||||
clearMapMarkersByType?: (type: string) => void;
|
|
||||||
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
|
|
||||||
getDecodeRigMeta?: () => unknown;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
updateAprsBar?: () => void;
|
|
||||||
clearAprsBar?: () => void;
|
|
||||||
closeAprsBar?: () => void;
|
|
||||||
}
|
|
||||||
const aprsWindow = window as unknown as AprsBridge;
|
|
||||||
const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); };
|
|
||||||
|
|
||||||
// --- APRS Decoder Plugin (server-side decode) ---
|
// --- APRS Decoder Plugin (server-side decode) ---
|
||||||
const aprsStatus = document.getElementById("aprs-status");
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
const aprsPacketsEl = document.getElementById("aprs-packets");
|
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
const aprsFilterInput = document.getElementById("aprs-filter") as HTMLInputElement | null;
|
const aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
@@ -46,49 +15,137 @@ const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
|||||||
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
let aprsFilterText = "";
|
let aprsFilterText = "";
|
||||||
let aprsPacketHistory: AprsPacket[] = [];
|
let aprsPacketHistory = [];
|
||||||
let aprsBarDismissedAtMs = 0;
|
let aprsBarDismissedAtMs = 0;
|
||||||
let aprsOnlyPos = false;
|
let aprsOnlyPos = false;
|
||||||
let aprsHideCrc = false;
|
let aprsHideCrc = false;
|
||||||
let aprsCollapseDup = false;
|
let aprsCollapseDup = false;
|
||||||
let aprsTypeFilter: AprsTypeFilter = "all";
|
let aprsTypeFilter = "all";
|
||||||
|
|
||||||
function currentAprsHistoryRetentionMs(): number {
|
function currentAprsHistoryRetentionMs() {
|
||||||
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function"
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
? aprsWindow.getDecodeHistoryRetentionMs()
|
? window.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAprsPacketHistory() {
|
function pruneAprsPacketHistory() {
|
||||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsUi(key: string, job: () => void): void {
|
function scheduleAprsUi(key, job) {
|
||||||
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
aprsWindow.trxScheduleUiFrameJob(key, job);
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsHistoryRender() {
|
function scheduleAprsHistoryRender() {
|
||||||
scheduleAprsUi("aprs-history", () => { renderAprsHistory(); });
|
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAprsBarUpdate() {
|
function scheduleAprsBarUpdate() {
|
||||||
scheduleAprsUi("aprs-bar", () => { updateAprsBar(); });
|
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsDistanceText(pkt: AprsPacket): string {
|
function renderAprsInfo(pkt) {
|
||||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 0x20 && b <= 0x7e) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 0x20 && code <= 0x7e) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsPacketCategory(pkt) {
|
||||||
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.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(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
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 aprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsFilterMatch(pkt: AprsPacket): boolean {
|
function aprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsFilterMatch(pkt) {
|
||||||
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
@@ -109,11 +166,23 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function aprsVisiblePackets(): AprsPacket[] {
|
function aprsVisiblePackets() {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = aprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
@@ -141,7 +210,7 @@ function updateAprsChipState() {
|
|||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
function renderAprsRow(pkt, isFresh) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "aprs-packet";
|
row.className = "aprs-packet";
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
@@ -152,9 +221,18 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
const category = aprsPacketCategory(pkt);
|
const category = aprsPacketCategory(pkt);
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
const posLink = pkt.lat != null && pkt.lon != null
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||||
: "";
|
: "";
|
||||||
@@ -165,19 +243,19 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
`<div class="aprs-row-head">` +
|
`<div class="aprs-row-head">` +
|
||||||
`<span class="aprs-time">${ts}</span>` +
|
`<span class="aprs-time">${ts}</span>` +
|
||||||
symbolHtml +
|
symbolHtml +
|
||||||
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
|
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||||
`<span>>${escapeAprsHtml(pkt.destCall || "")}</span>` +
|
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||||
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
|
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||||
pathBadge +
|
pathBadge +
|
||||||
crcBadge +
|
crcBadge +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-meta">` +
|
`<div class="aprs-row-meta">` +
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
|
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
|
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||||
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-detail">` +
|
`<div class="aprs-row-detail">` +
|
||||||
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
(posLink ? `<span>${posLink}</span>` : "") +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-actions">` +
|
`<div class="aprs-row-actions">` +
|
||||||
@@ -188,43 +266,42 @@ function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
`<details class="aprs-details">` +
|
`<details class="aprs-details">` +
|
||||||
`<summary>Details</summary>` +
|
`<summary>Details</summary>` +
|
||||||
`<div class="aprs-details-grid">` +
|
`<div class="aprs-details-grid">` +
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
|
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
|
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
|
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
|
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
|
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</details>`;
|
`</details>`;
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
el.addEventListener("click", (evt) => {
|
el.addEventListener("click", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
window.navigateToAprsMap(lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
copyBtn.addEventListener("click", async () => {
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
try {
|
try {
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
if (navigator.clipboard?.writeText) {
|
||||||
if (clipboard) {
|
await navigator.clipboard.writeText(raw);
|
||||||
await clipboard.writeText(raw);
|
showHint("Coordinates copied", 1200);
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (_e) {
|
||||||
showAprsHint("Copy failed", 1500);
|
showHint("Copy failed", 1500);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -239,8 +316,8 @@ function renderAprsHistory() {
|
|||||||
}
|
}
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const [index, packet] of visible.entries()) {
|
for (let i = 0; i < visible.length; i++) {
|
||||||
fragment.appendChild(renderAprsRow(packet, index === 0));
|
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
||||||
}
|
}
|
||||||
aprsPacketsEl.replaceChildren(fragment);
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
updateAprsSummary();
|
updateAprsSummary();
|
||||||
@@ -249,9 +326,9 @@ function renderAprsHistory() {
|
|||||||
|
|
||||||
function updateAprsBar() {
|
function updateAprsBar() {
|
||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -262,9 +339,9 @@ function updateAprsBar() {
|
|||||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
||||||
for (const pkt of frames) {
|
for (const pkt of frames) {
|
||||||
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
||||||
const dest = escapeAprsHtml(pkt.destCall || "");
|
const dest = escapeMapHtml(pkt.destCall || "");
|
||||||
const info = escapeAprsHtml(pkt.info || "");
|
const info = escapeMapHtml(pkt.info || "");
|
||||||
const pin = pkt.lat != null && pkt.lon != null
|
const pin = pkt.lat != null && pkt.lon != null
|
||||||
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
||||||
: "";
|
: "";
|
||||||
@@ -275,11 +352,11 @@ function updateAprsBar() {
|
|||||||
aprsBarOverlay.innerHTML = html;
|
aprsBarOverlay.innerHTML = html;
|
||||||
aprsBarOverlay.style.display = "flex";
|
aprsBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
aprsWindow.updateAprsBar = updateAprsBar;
|
window.updateAprsBar = updateAprsBar;
|
||||||
aprsWindow.clearAprsBar = function() {
|
window.clearAprsBar = function() {
|
||||||
resetAprsHistoryView();
|
window.resetAprsHistoryView();
|
||||||
};
|
};
|
||||||
aprsWindow.closeAprsBar = function() {
|
window.closeAprsBar = function() {
|
||||||
aprsBarDismissedAtMs = Date.now();
|
aprsBarDismissedAtMs = Date.now();
|
||||||
if (aprsBarOverlay) {
|
if (aprsBarOverlay) {
|
||||||
aprsBarOverlay.style.display = "none";
|
aprsBarOverlay.style.display = "none";
|
||||||
@@ -287,21 +364,21 @@ aprsWindow.closeAprsBar = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function resetAprsHistoryView(): void {
|
window.resetAprsHistoryView = function() {
|
||||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
aprsPacketHistory = [];
|
aprsPacketHistory = [];
|
||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
aprsWindow.clearMapMarkersByType?.("aprs");
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneAprsHistoryView(): void {
|
window.pruneAprsHistoryView = function() {
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
updateAprsBar();
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
function addAprsPacket(pkt: AprsPacket): void {
|
function addAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -309,8 +386,8 @@ function addAprsPacket(pkt: AprsPacket): void {
|
|||||||
aprsPacketHistory.unshift(pkt);
|
aprsPacketHistory.unshift(pkt);
|
||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
|
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
@@ -318,22 +395,37 @@ function addAprsPacket(pkt: AprsPacket): void {
|
|||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
function normalizeServerAprsPacket(pkt) {
|
||||||
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
window.onServerAprsBatch = function(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
aprsStatus.textContent = "Receiving";
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
|
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
||||||
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
|
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
||||||
}
|
}
|
||||||
if (next.crcOk) hasCrcOk = true;
|
if (next.crcOk) hasCrcOk = true;
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
@@ -343,17 +435,21 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
|
|||||||
pruneAprsPacketHistory();
|
pruneAprsPacketHistory();
|
||||||
if (hasCrcOk) scheduleAprsBarUpdate();
|
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||||
scheduleAprsHistoryRender();
|
scheduleAprsHistoryRender();
|
||||||
}
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
|
window.restoreAprsHistory = function(packets) {
|
||||||
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
window.onServerAprsBatch(packets);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_aprs_decode");
|
await postPath("/clear_aprs_decode");
|
||||||
resetAprsHistoryView();
|
window.resetAprsHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("APRS history clear failed", e);
|
console.error("APRS history clear failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
if (aprsOnlyPosBtn) {
|
if (aprsOnlyPosBtn) {
|
||||||
aprsOnlyPosBtn.addEventListener("click", () => {
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
@@ -376,7 +472,7 @@ if (aprsCollapseDupBtn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
(["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
const btn = document.getElementById(`aprs-type-${type}`);
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
@@ -393,17 +489,10 @@ if (aprsFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
function onServerAprs(pkt: AprsPacket): void {
|
window.onServerAprs = function(pkt) {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
aprsStatus.textContent = "Receiving";
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
}
|
};
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
||||||
id: "aprs",
|
|
||||||
onMessage: onServerAprs,
|
|
||||||
onBatch: onServerAprsBatch,
|
|
||||||
restore: onServerAprsBatch,
|
|
||||||
reset: resetAprsHistoryView,
|
|
||||||
prune: pruneAprsHistoryView,
|
|
||||||
});
|
|
||||||
+86
-146
@@ -2,79 +2,30 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostState } from "./host.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
interface DecoderDescriptor {
|
|
||||||
id: string;
|
|
||||||
background_decode?: boolean;
|
|
||||||
activation?: string;
|
|
||||||
active_modes: string[];
|
|
||||||
}
|
|
||||||
interface Bookmark {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
freq_hz: number;
|
|
||||||
mode: string;
|
|
||||||
decoders?: string[];
|
|
||||||
}
|
|
||||||
interface BackgroundDecodeConfig {
|
|
||||||
remote: string | null;
|
|
||||||
enabled: boolean;
|
|
||||||
bookmark_ids: string[];
|
|
||||||
}
|
|
||||||
interface BackgroundStatusEntry {
|
|
||||||
bookmark_name?: string;
|
|
||||||
bookmark_id?: string;
|
|
||||||
freq_hz?: number;
|
|
||||||
mode?: string;
|
|
||||||
decoder_kinds?: string[];
|
|
||||||
state?: string;
|
|
||||||
}
|
|
||||||
interface BackgroundDecodeStatus {
|
|
||||||
entries?: BackgroundStatusEntry[];
|
|
||||||
active_rig?: boolean;
|
|
||||||
center_hz?: number;
|
|
||||||
sample_rate?: number;
|
|
||||||
}
|
|
||||||
interface BackgroundBridge {
|
|
||||||
decoderRegistry?: DecoderDescriptor[];
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
|
|
||||||
}
|
|
||||||
interface BackgroundDecodeService {
|
|
||||||
initialize(rigId: string | null, role: string | null): void;
|
|
||||||
wireEvents(): void;
|
|
||||||
setRig(rigId: string | null): void;
|
|
||||||
}
|
|
||||||
interface WiredElement extends HTMLElement { _wired?: boolean }
|
|
||||||
const bgdWindow = window as unknown as BackgroundBridge;
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function bgdSupportedIds(): string[] {
|
function bgdSupportedIds() {
|
||||||
return (bgdWindow.decoderRegistry || [])
|
return (window.decoderRegistry || [])
|
||||||
.filter(function (d) { return d.background_decode; })
|
.filter(function (d) { return d.background_decode; })
|
||||||
.map(function (d) { return d.id; });
|
.map(function (d) { return d.id; });
|
||||||
}
|
}
|
||||||
|
|
||||||
let backgroundDecodeRole: string | null = null;
|
let backgroundDecodeRole = null;
|
||||||
let currentRigId: string | null = null;
|
let currentRigId = null;
|
||||||
let currentConfig: BackgroundDecodeConfig | null = null;
|
let currentConfig = null;
|
||||||
let bookmarkList: Bookmark[] = [];
|
let bookmarkList = [];
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
let statusInterval = null;
|
||||||
let bgdDirty = false;
|
let bgdDirty = false;
|
||||||
|
|
||||||
function initBackgroundDecode(rigId: string | null, role: string | null): void {
|
function initBackgroundDecode(rigId, role) {
|
||||||
backgroundDecodeRole = role;
|
backgroundDecodeRole = role;
|
||||||
currentRigId = rigId || null;
|
currentRigId = rigId || null;
|
||||||
if (currentRigId) loadBackgroundDecode();
|
if (currentRigId) loadBackgroundDecode();
|
||||||
startStatusPolling();
|
startStatusPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBackgroundDecodeRig(rigId: string | null): void {
|
function setBackgroundDecodeRig(rigId) {
|
||||||
const nextRigId = rigId || null;
|
const nextRigId = rigId || null;
|
||||||
if (nextRigId === currentRigId) return;
|
if (nextRigId === currentRigId) return;
|
||||||
currentRigId = nextRigId;
|
currentRigId = nextRigId;
|
||||||
@@ -82,44 +33,44 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
loadBackgroundDecode();
|
loadBackgroundDecode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
|
function apiGetConfig(rigId) {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
return r.json() as Promise<BackgroundDecodeConfig>;
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiPutConfig(rigId: string, config: BackgroundDecodeConfig): Promise<BackgroundDecodeConfig> {
|
function apiPutConfig(rigId, config) {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
return r.json() as Promise<BackgroundDecodeConfig>;
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiResetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
|
function apiResetConfig(rigId) {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
return r.json() as Promise<BackgroundDecodeConfig>;
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetStatus(rigId: string): Promise<BackgroundDecodeStatus> {
|
function apiGetStatus(rigId) {
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
return r.json() as Promise<BackgroundDecodeStatus>;
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiGetBookmarks(): Promise<Bookmark[]> {
|
function apiGetBookmarks() {
|
||||||
return fetch("/bookmarks").then(function (r) {
|
return fetch("/bookmarks").then(function (r) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
return r.json() as Promise<Bookmark[]>;
|
return r.json();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,35 +79,35 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
if (!rigId) return;
|
if (!rigId) return;
|
||||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
||||||
.then(function ([config, bookmarks]) {
|
.then(function ([config, bookmarks]) {
|
||||||
currentConfig = config;
|
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
||||||
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
})
|
})
|
||||||
.catch(function (err: unknown) {
|
.catch(function (err) {
|
||||||
console.error("background decode load failed", err);
|
console.error("background decode load failed", err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportedBookmarks(): Bookmark[] {
|
function supportedBookmarks() {
|
||||||
return bookmarkList.filter(function (bookmark) {
|
return bookmarkList.filter(function (bookmark) {
|
||||||
return bookmarkDecoderKinds(bookmark).length > 0;
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bookmarkDecoderKinds(bookmark: Bookmark): string[] {
|
function bookmarkDecoderKinds(bookmark) {
|
||||||
const ids = bgdSupportedIds();
|
var ids = bgdSupportedIds();
|
||||||
const decoders = bookmark.decoders ?? [];
|
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
||||||
const explicit = decoders
|
var explicit = decoders
|
||||||
.map(function (item) { return item.trim().toLowerCase(); })
|
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
||||||
.filter(function (item, index, arr) {
|
.filter(function (item, index, arr) {
|
||||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
});
|
});
|
||||||
if (explicit.length > 0) return explicit;
|
if (explicit.length > 0) return explicit;
|
||||||
// Fall back: infer from mode via mode-bound entries in the registry.
|
// Fall back: infer from mode via mode-bound entries in the registry.
|
||||||
const mode = bookmark.mode.trim().toUpperCase();
|
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||||
return (bgdWindow.decoderRegistry || [])
|
return (window.decoderRegistry || [])
|
||||||
.filter(function (d) {
|
.filter(function (d) {
|
||||||
return d.activation === "mode_bound" && d.background_decode
|
return d.activation === "mode_bound" && d.background_decode
|
||||||
&& d.active_modes.indexOf(mode) >= 0;
|
&& d.active_modes.indexOf(mode) >= 0;
|
||||||
@@ -168,13 +119,13 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
setCheckbox("background-decode-enabled", currentConfig.enabled);
|
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
||||||
renderBookmarkChecklist();
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
|
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
||||||
const panel = document.getElementById("background-decode-panel");
|
const panel = document.getElementById("background-decode-panel");
|
||||||
if (panel) {
|
if (panel) {
|
||||||
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
||||||
el.disabled = !isControl;
|
el.disabled = !isControl;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -184,7 +135,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBookmarkChecklist(filterText = ""): void {
|
function renderBookmarkChecklist(filterText) {
|
||||||
const container = document.getElementById("bgd-bookmark-checklist");
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
@@ -197,7 +148,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
|
|
||||||
const filtered = filter
|
const filtered = filter
|
||||||
? all.filter(function (bm) {
|
? all.filter(function (bm) {
|
||||||
const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
return text.indexOf(filter) >= 0;
|
return text.indexOf(filter) >= 0;
|
||||||
})
|
})
|
||||||
: all;
|
: all;
|
||||||
@@ -210,22 +161,22 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(function (bookmark) {
|
filtered.forEach(function (bookmark) {
|
||||||
const row = document.createElement("label");
|
var row = document.createElement("label");
|
||||||
row.className = "bgd-checklist-row";
|
row.className = "bgd-checklist-row";
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
var decoders = bookmarkDecoderKinds(bookmark);
|
||||||
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||||
row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
|
row.querySelector("input").addEventListener("change", function (e) {
|
||||||
onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
|
onChecklistToggle(bookmark.id, e.target.checked);
|
||||||
});
|
});
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChecklistToggle(bookmarkId: string, checked: boolean): void {
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
@@ -241,12 +192,12 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
function saveBackgroundDecode() {
|
function saveBackgroundDecode() {
|
||||||
const rigId = currentRigId;
|
const rigId = currentRigId;
|
||||||
if (!rigId) return;
|
if (!rigId) return;
|
||||||
const payload: BackgroundDecodeConfig = {
|
const payload = {
|
||||||
remote: rigId,
|
remote: rigId,
|
||||||
enabled: (document.getElementById("background-decode-enabled") as HTMLInputElement | null)?.checked ?? false,
|
enabled: !!document.getElementById("background-decode-enabled").checked,
|
||||||
bookmark_ids: currentConfig?.bookmark_ids.slice() ?? [],
|
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
||||||
};
|
};
|
||||||
const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
apiPutConfig(rigId, payload)
|
apiPutConfig(rigId, payload)
|
||||||
.then(function (saved) {
|
.then(function (saved) {
|
||||||
@@ -254,30 +205,30 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
showToast("Background decode saved.", false);
|
showToast("Background decode saved.");
|
||||||
})
|
})
|
||||||
.catch(function (err: unknown) {
|
.catch(function (err) {
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
showToast("Save failed: " + err.message, true);
|
||||||
})
|
})
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetBackgroundDecode() {
|
function resetBackgroundDecode() {
|
||||||
const rigId = currentRigId;
|
const rigId = currentRigId;
|
||||||
if (!rigId) return;
|
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;
|
if (!confirm("Reset background decode configuration? This cannot be undone.")) return;
|
||||||
apiResetConfig(rigId)
|
apiResetConfig(rigId)
|
||||||
.then(function (saved) {
|
.then(function (saved) {
|
||||||
currentConfig = saved;
|
currentConfig = saved;
|
||||||
renderBackgroundDecode();
|
renderBackgroundDecode();
|
||||||
clearBgdDirty();
|
clearBgdDirty();
|
||||||
pollBackgroundDecodeStatus();
|
pollBackgroundDecodeStatus();
|
||||||
showToast("Background decode reset.", false);
|
showToast("Background decode reset.");
|
||||||
})
|
})
|
||||||
.catch(function (err: unknown) {
|
.catch(function (err) {
|
||||||
showToast(`Reset failed: ${errorMessage(err)}`, true);
|
showToast("Reset failed: " + err.message, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,18 +245,18 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
.catch(function () {});
|
.catch(function () {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(status: BackgroundDecodeStatus): void {
|
function renderStatus(status) {
|
||||||
const card = document.getElementById("background-decode-status-card");
|
const card = document.getElementById("background-decode-status-card");
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const entries = status.entries ?? [];
|
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const summary = [];
|
const summary = [];
|
||||||
if (status.active_rig) {
|
if (status.active_rig) {
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
if (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));
|
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
} else {
|
} else {
|
||||||
summary.push("This rig is not currently selected for audio.");
|
summary.push("This rig is not currently selected for audio.");
|
||||||
}
|
}
|
||||||
@@ -314,7 +265,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
if (entry.mode) parts.push(entry.mode);
|
if (entry.mode) parts.push(entry.mode);
|
||||||
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
@@ -334,7 +285,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
card.innerHTML = html;
|
card.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prettyState(state: string | undefined): string {
|
function prettyState(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "active": return "\u2713 Active";
|
case "active": return "\u2713 Active";
|
||||||
case "out_of_span": return "\u25B3 Out of span";
|
case "out_of_span": return "\u25B3 Out of span";
|
||||||
@@ -350,47 +301,40 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCheckbox(id: string, value: boolean): void {
|
function setCheckbox(id, value) {
|
||||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
const el = document.getElementById(id);
|
||||||
if (el) el.checked = value;
|
if (el) el.checked = !!value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFreq(hz: number): string {
|
function formatFreq(hz) {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
return `${String(hz)} Hz`;
|
return hz + " Hz";
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(value: unknown): string {
|
function escHtml(value) {
|
||||||
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
return String(value == null ? "" : value)
|
||||||
? String(value)
|
|
||||||
: "";
|
|
||||||
return text
|
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markBgdDirty() {
|
function markBgdDirty() {
|
||||||
if (bgdDirty) return;
|
if (bgdDirty) return;
|
||||||
bgdDirty = true;
|
bgdDirty = true;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearBgdDirty() {
|
function clearBgdDirty() {
|
||||||
bgdDirty = false;
|
bgdDirty = false;
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
if (btn) btn.classList.remove("sch-dirty");
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(msg: string, isError: boolean): void {
|
function showToast(msg, isError) {
|
||||||
const el = document.getElementById("background-decode-toast");
|
const el = document.getElementById("background-decode-toast");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
@@ -405,9 +349,9 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
if (!currentConfig) {
|
if (!currentConfig) {
|
||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
const ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
currentConfig.bookmark_ids = ids;
|
currentConfig.bookmark_ids = ids;
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,12 +360,12 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
currentConfig.bookmark_ids = [];
|
currentConfig.bookmark_ids = [];
|
||||||
renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
markBgdDirty();
|
markBgdDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireBackgroundDecodeEvents() {
|
function wireBackgroundDecodeEvents() {
|
||||||
const filterInput = document.getElementById("bgd-bookmark-filter") as (HTMLInputElement & WiredElement) | null;
|
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||||
if (filterInput && !filterInput._wired) {
|
if (filterInput && !filterInput._wired) {
|
||||||
filterInput._wired = true;
|
filterInput._wired = true;
|
||||||
filterInput.addEventListener("input", function () {
|
filterInput.addEventListener("input", function () {
|
||||||
@@ -429,42 +373,38 @@ const bgdWindow = window as unknown as BackgroundBridge;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
|
const enabledCb = document.getElementById("background-decode-enabled");
|
||||||
if (enabledCb && !enabledCb._wired) {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
enabledCb._wired = true;
|
enabledCb._wired = true;
|
||||||
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
if (selectAllBtn && !selectAllBtn._wired) {
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
selectAllBtn._wired = true;
|
selectAllBtn._wired = true;
|
||||||
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn") as WiredElement | null;
|
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||||
if (deselectAllBtn && !deselectAllBtn._wired) {
|
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||||
deselectAllBtn._wired = true;
|
deselectAllBtn._wired = true;
|
||||||
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveBtn = document.getElementById("background-decode-save-btn") as WiredElement | null;
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
if (saveBtn && !saveBtn._wired) {
|
if (saveBtn && !saveBtn._wired) {
|
||||||
saveBtn._wired = true;
|
saveBtn._wired = true;
|
||||||
saveBtn.addEventListener("click", saveBackgroundDecode);
|
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetBtn = document.getElementById("background-decode-reset-btn") as WiredElement | null;
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
if (resetBtn && !resetBtn._wired) {
|
if (resetBtn && !resetBtn._wired) {
|
||||||
resetBtn._wired = true;
|
resetBtn._wired = true;
|
||||||
resetBtn.addEventListener("click", () => { void resetBackgroundDecode(); });
|
resetBtn.addEventListener("click", resetBackgroundDecode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bgdWindow.trx ??= {};
|
window.initBackgroundDecode = initBackgroundDecode;
|
||||||
bgdWindow.trx.modules ??= {};
|
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||||
bgdWindow.trx.modules.backgroundDecode = {
|
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||||
initialize: initBackgroundDecode,
|
|
||||||
wireEvents: wireBackgroundDecodeEvents,
|
|
||||||
setRig: setBackgroundDecodeRig,
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
+221
-286
@@ -2,139 +2,83 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
/* DOM IDs in the server-owned page are required by this feature; bmEl throws
|
|
||||||
* during initialization if that contract is broken. */
|
|
||||||
interface Bookmark {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
freq_hz: number;
|
|
||||||
mode: string;
|
|
||||||
bandwidth_hz?: number | null;
|
|
||||||
locator?: string | null;
|
|
||||||
category?: string | null;
|
|
||||||
comment?: string | null;
|
|
||||||
decoders?: string[];
|
|
||||||
scope?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookmarkService {
|
|
||||||
readonly overlayList: readonly Bookmark[];
|
|
||||||
readonly overlayRevision: number;
|
|
||||||
refreshOverlay(): Promise<void>;
|
|
||||||
invalidateColors(): void;
|
|
||||||
apply(bookmark: Bookmark): void;
|
|
||||||
formatFrequency(frequencyHz: number): string;
|
|
||||||
fetch(categoryFilter: string): Promise<void>;
|
|
||||||
populateScopePicker(): void;
|
|
||||||
}
|
|
||||||
interface VirtualChannelService {
|
|
||||||
interceptMode(mode: string): Promise<boolean>;
|
|
||||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
|
||||||
takeSchedulerControl(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookmarkBridge {
|
|
||||||
trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
|
|
||||||
trxUi: {
|
|
||||||
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
|
|
||||||
notify?(message: string, options: { kind: "error" }): void;
|
|
||||||
};
|
|
||||||
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
|
|
||||||
const bridge = window as unknown as BookmarkBridge;
|
|
||||||
function bmEl(id: string): BookmarkElement {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (!element) throw new Error(`Missing bookmark element #${id}`);
|
|
||||||
return element as BookmarkElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
|
|
||||||
* registry, so their elements are legitimately absent for unbuilt decoders. */
|
|
||||||
function bmOptionalEl(id: string): BookmarkElement | null {
|
|
||||||
return document.getElementById(id) as BookmarkElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bookmarks Tab ---
|
// --- Bookmarks Tab ---
|
||||||
|
|
||||||
/** Current bookmark scope: "general" or a rig remote name. */
|
/** Current bookmark scope: "general" or a rig remote name. */
|
||||||
let bmScope = "general";
|
let bmScope = "general";
|
||||||
|
|
||||||
/** Build the ?scope= query string for a given or current bookmark scope. */
|
/** Build the ?scope= query string for a given or current bookmark scope. */
|
||||||
function bmScopeParam(prefix: boolean, scope?: string | null): string {
|
function bmScopeParam(prefix, scope) {
|
||||||
const sep = prefix ? "&" : "?";
|
const sep = prefix ? "&" : "?";
|
||||||
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bmList: Bookmark[] = [];
|
var bmList = [];
|
||||||
|
var bmRevision = 0;
|
||||||
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
||||||
let bmOverlayList: Bookmark[] = [];
|
var bmOverlayList = [];
|
||||||
let bmOverlayRevision = 0;
|
var bmOverlayRevision = 0;
|
||||||
let bmFilteredList: Bookmark[] = [];
|
let bmFilteredList = [];
|
||||||
let bmEditScope: string | null = null;
|
let bmEditId = null;
|
||||||
|
let bmEditScope = null;
|
||||||
let bmCurrentPage = 1;
|
let bmCurrentPage = 1;
|
||||||
const BM_PAGE_SIZE = 25;
|
const BM_PAGE_SIZE = 25;
|
||||||
const bmSelected = new Set<string>();
|
const bmSelected = new Set();
|
||||||
|
|
||||||
function bmFmtFreq(hz: number): string {
|
function bmFmtFreq(hz) {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||||
return `${hz}\u202fHz`;
|
return hz + "\u202fHz";
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmEsc(str: unknown): string {
|
function bmEsc(str) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.appendChild(document.createTextNode(String(str)));
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmCanControl() {
|
function bmCanControl() {
|
||||||
return !hostState.authEnabled || hostState.authRole === "control";
|
return (
|
||||||
|
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
||||||
|
(typeof authRole !== "undefined" && authRole === "control")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||||
function bmSyncAccess() {
|
function bmSyncAccess() {
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
const addBtn = bmEl("bm-add-btn");
|
const addBtn = document.getElementById("bm-add-btn");
|
||||||
const selectAllBtn = bmEl("bm-select-all-btn");
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||||
function bmListScope() {
|
function bmListScope() {
|
||||||
return hostState.lastActiveRigId || "general";
|
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
|
||||||
|
return rig || "general";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetchOverlay() {
|
async function bmFetchOverlay() {
|
||||||
const overlayScope = bmListScope();
|
const overlayScope = bmListScope();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
bmOverlayList = await resp.json() as Bookmark[];
|
bmOverlayList = await resp.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch overlay bookmarks:", e);
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
bmOverlayList = [];
|
bmOverlayList = [];
|
||||||
}
|
}
|
||||||
bmOverlayRevision++;
|
bmOverlayRevision++;
|
||||||
if (typeof bridge.syncBookmarkMapLocators === "function") {
|
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||||
bridge.syncBookmarkMapLocators(bmOverlayList);
|
window.syncBookmarkMapLocators(bmOverlayList);
|
||||||
}
|
}
|
||||||
hostCore.scheduleSpectrumDraw();
|
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmFetch(categoryFilter: string): Promise<void> {
|
async function bmFetch(categoryFilter) {
|
||||||
let url = "/bookmarks";
|
let url = "/bookmarks";
|
||||||
let hasQuery = false;
|
let hasQuery = false;
|
||||||
if (categoryFilter && categoryFilter !== "") {
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
@@ -145,25 +89,26 @@ async function bmFetch(categoryFilter: string): Promise<void> {
|
|||||||
const overlayPromise = bmFetchOverlay();
|
const overlayPromise = bmFetchOverlay();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
bmList = await resp.json() as Bookmark[];
|
bmList = await resp.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to fetch bookmarks:", e);
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
bmList = [];
|
bmList = [];
|
||||||
}
|
}
|
||||||
|
bmRevision++;
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
bmSyncAccess();
|
bmSyncAccess();
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
void bmRefreshCategoryFilter(categoryFilter);
|
bmRefreshCategoryFilter(categoryFilter);
|
||||||
await overlayPromise;
|
await overlayPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmApplyFilters() {
|
function bmApplyFilters() {
|
||||||
const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
|
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
|
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
let filtered = modeFilter
|
let filtered = modeFilter
|
||||||
? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
|
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
|
||||||
: bmList;
|
: bmList;
|
||||||
filtered = text
|
filtered = text
|
||||||
? filtered.filter((bm) =>
|
? filtered.filter((bm) =>
|
||||||
@@ -178,14 +123,14 @@ function bmApplyFilters() {
|
|||||||
bmRender(filtered);
|
bmRender(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
|
async function bmRefreshCategoryFilter(keepValue) {
|
||||||
const sel = bmEl("bm-category-filter");
|
const sel = document.getElementById("bm-category-filter");
|
||||||
const modeSel = bmEl("bm-mode-filter");
|
const modeSel = document.getElementById("bm-mode-filter");
|
||||||
if (!sel && !modeSel) return;
|
if (!sel && !modeSel) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const all = await resp.json() as Bookmark[];
|
const all = await resp.json();
|
||||||
if (sel) {
|
if (sel) {
|
||||||
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
while (sel.options.length > 1) sel.remove(1);
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
@@ -199,7 +144,7 @@ async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
if (modeSel) {
|
if (modeSel) {
|
||||||
const keepMode = modeSel.value;
|
const keepMode = modeSel.value;
|
||||||
const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
while (modeSel.options.length > 1) modeSel.remove(1);
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
modes.forEach((mode) => {
|
modes.forEach((mode) => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
@@ -209,17 +154,17 @@ async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmRender(list: Bookmark[]): void {
|
function bmRender(list) {
|
||||||
const tbody = bmEl("bm-tbody");
|
const tbody = document.getElementById("bm-tbody");
|
||||||
const emptyEl = bmEl("bm-empty");
|
const emptyEl = document.getElementById("bm-empty");
|
||||||
const paginatorEl = bmEl("bm-paginator");
|
const paginatorEl = document.getElementById("bm-paginator");
|
||||||
const pageSummaryEl = bmEl("bm-page-summary");
|
const pageSummaryEl = document.getElementById("bm-page-summary");
|
||||||
const pageIndicatorEl = bmEl("bm-page-indicator");
|
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
||||||
const prevBtn = bmEl("bm-page-prev");
|
const prevBtn = document.getElementById("bm-page-prev");
|
||||||
const nextBtn = bmEl("bm-page-next");
|
const nextBtn = document.getElementById("bm-page-next");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
@@ -277,7 +222,7 @@ function bmRender(list: Bookmark[]): void {
|
|||||||
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmChangePage(delta: number): void {
|
function bmChangePage(delta) {
|
||||||
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
if (nextPage === bmCurrentPage) return;
|
if (nextPage === bmCurrentPage) return;
|
||||||
@@ -286,30 +231,30 @@ function bmChangePage(delta: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read decoder checkboxes and return an array of selected decoder names.
|
// Read decoder checkboxes and return an array of selected decoder names.
|
||||||
function bmReadDecoders(): string[] {
|
function bmReadDecoders() {
|
||||||
return hostState.decoderRegistry
|
return (window.decoderRegistry || [])
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
|
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set decoder checkboxes to match the given array.
|
// Set decoder checkboxes to match the given array.
|
||||||
function bmWriteDecoders(decoders: readonly string[]): void {
|
function bmWriteDecoders(decoders) {
|
||||||
const set = new Set(decoders || []);
|
const set = new Set(decoders || []);
|
||||||
hostState.decoderRegistry
|
(window.decoderRegistry || [])
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const el = bmOptionalEl("bm-dec-" + d.id);
|
const el = document.getElementById("bm-dec-" + d.id);
|
||||||
if (el) el.checked = set.has(d.id);
|
if (el) el.checked = set.has(d.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build decoder checkboxes dynamically from the registry.
|
// Build decoder checkboxes dynamically from the registry.
|
||||||
function bmBuildDecoderCheckboxes() {
|
function bmBuildDecoderCheckboxes() {
|
||||||
const container = bmEl("bm-decoder-checkboxes");
|
const container = document.getElementById("bm-decoder-checkboxes");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
hostState.decoderRegistry
|
(window.decoderRegistry || [])
|
||||||
.filter(d => d.bookmark_selectable)
|
.filter(d => d.bookmark_selectable)
|
||||||
.forEach(d => {
|
.forEach(d => {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
@@ -319,76 +264,75 @@ function bmBuildDecoderCheckboxes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmOpenForm(bm: Bookmark | null): void {
|
function bmOpenForm(bm) {
|
||||||
const wrap = bmEl("bm-form-wrap");
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
bmEditId = bm ? bm.id : null;
|
||||||
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
||||||
|
|
||||||
// Rebuild decoder checkboxes from registry (handles race where registry
|
// Rebuild decoder checkboxes from registry (handles race where registry
|
||||||
// loaded after initial build).
|
// loaded after initial build).
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
|
|
||||||
bmEl("bm-id").value = bm ? bm.id : "";
|
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||||
bmEl("bm-name").value = bm ? bm.name : "";
|
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||||
bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
|
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||||
bmEl("bm-mode").value = bm ? bm.mode : "";
|
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||||
bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
|
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||||
bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
|
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||||
bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
|
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||||
bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
|
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||||
bmWriteDecoders(bm?.decoders ?? []);
|
bmWriteDecoders(bm ? bm.decoders : []);
|
||||||
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
|
||||||
wrap.style.display = "flex";
|
wrap.style.display = "flex";
|
||||||
bmEl("bm-name").focus();
|
document.getElementById("bm-name").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmCloseForm() {
|
function bmCloseForm() {
|
||||||
const wrap = bmEl("bm-form-wrap");
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
if (wrap) wrap.style.display = "none";
|
if (wrap) wrap.style.display = "none";
|
||||||
|
bmEditId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmPrefillFromStatus() {
|
function bmPrefillFromStatus() {
|
||||||
// Read live rig state from the host contract (updated by the SSE stream).
|
// Use globals maintained by app.js (updated by SSE stream)
|
||||||
const freqHz = hostState.lastFreqHz;
|
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||||
if (freqHz != null && Number.isFinite(freqHz)) {
|
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||||
bmEl("bm-freq").value = String(Math.round(freqHz));
|
|
||||||
}
|
}
|
||||||
if (hostState.lastModeName) {
|
if (typeof lastModeName === "string" && lastModeName) {
|
||||||
bmEl("bm-mode").value = hostState.lastModeName;
|
document.getElementById("bm-mode").value = lastModeName;
|
||||||
}
|
}
|
||||||
if (hostState.currentBandwidthHz > 0) {
|
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||||
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
|
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||||
}
|
}
|
||||||
// Prefill decoder checkboxes from current toggle button state.
|
// Prefill decoder checkboxes from current toggle button state.
|
||||||
const activeDecoders = hostState.decoderRegistry
|
const activeDecoders = (window.decoderRegistry || [])
|
||||||
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||||
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
|
.filter(d => {
|
||||||
|
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
||||||
|
return btn && btn.dataset.enabled === "true";
|
||||||
|
})
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
bmWriteDecoders(activeDecoders);
|
bmWriteDecoders(activeDecoders);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmSave(e: Event): Promise<void> {
|
async function bmSave(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = bmEl("bm-id").value;
|
const id = document.getElementById("bm-id").value;
|
||||||
const name = bmEl("bm-name").value.trim();
|
const name = document.getElementById("bm-name").value.trim();
|
||||||
const freqStr = bmEl("bm-freq").value;
|
const freqStr = document.getElementById("bm-freq").value;
|
||||||
const freq_hz = parseInt(freqStr, 10);
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
const mode = bmEl("bm-mode").value.trim();
|
const mode = document.getElementById("bm-mode").value.trim();
|
||||||
const bwStr = bmEl("bm-bw").value;
|
const bwStr = document.getElementById("bm-bw").value;
|
||||||
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
const locator = bmEl("bm-locator").value.trim().toUpperCase();
|
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
||||||
const category = bmEl("bm-category-input").value.trim();
|
const category = document.getElementById("bm-category-input").value.trim();
|
||||||
const comment = bmEl("bm-comment").value.trim();
|
const comment = document.getElementById("bm-comment").value.trim();
|
||||||
const decoders = bmReadDecoders();
|
const decoders = bmReadDecoders();
|
||||||
|
|
||||||
const formError = bmEl("bm-form-error");
|
|
||||||
if (formError) formError.textContent = "";
|
|
||||||
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
alert("Name, Frequency, and Mode are required.");
|
||||||
const invalid = !name ? bmEl("bm-name")
|
|
||||||
: !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
|
|
||||||
invalid?.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,74 +367,90 @@ async function bmSave(e: Event): Promise<void> {
|
|||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
throw new Error("A bookmark for that frequency already exists.");
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
}
|
}
|
||||||
throw new Error(text || `HTTP ${resp.status}`);
|
throw new Error(text || "HTTP " + resp.status);
|
||||||
}
|
}
|
||||||
bmCloseForm();
|
bmCloseForm();
|
||||||
await bmFetch(bmEl("bm-category-filter").value);
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save bookmark:", err);
|
console.error("Failed to save bookmark:", err);
|
||||||
if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
|
alert("Failed to save bookmark: " + err.message);
|
||||||
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bmDelete(id: string): Promise<void> {
|
async function bmDelete(id) {
|
||||||
if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
if (!confirm("Delete this bookmark?")) return;
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm ? bm.scope : undefined;
|
const scope = bm ? bm.scope : undefined;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
await bmFetch(bmEl("bm-category-filter").value);
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete bookmark:", err);
|
console.error("Failed to delete bookmark:", err);
|
||||||
bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
|
alert("Failed to delete bookmark: " + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmApply(bm: Bookmark): void {
|
async function bmApply(bm) {
|
||||||
try {
|
try {
|
||||||
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
if (typeof modeEl !== "undefined" && modeEl) {
|
||||||
if (modeEl) {
|
modeEl.value = String(bm.mode || "").toUpperCase();
|
||||||
modeEl.value = (bm.mode || "").toUpperCase();
|
|
||||||
}
|
}
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
hostState.currentBandwidthHz = bm.bandwidth_hz;
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
hostCore.syncBandwidthInput(bm.bandwidth_hz);
|
currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
}
|
||||||
|
window.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
if (typeof syncBandwidthInput === "function") {
|
||||||
|
syncBandwidthInput(bm.bandwidth_hz);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Set optimistic guard before applying so SSE cannot snap back.
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
hostCore.armOptimisticFrequency(bm.freq_hz);
|
// Set optimistic guard before applying so SSE cannot snap back.
|
||||||
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
|
++_freqOptimisticSeq;
|
||||||
if (hostState.lastSpectrumData) {
|
_freqOptimisticHz = bm.freq_hz;
|
||||||
hostCore.scheduleSpectrumDraw();
|
}
|
||||||
|
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||||
|
applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
|
}
|
||||||
|
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
|
||||||
|
scheduleSpectrumDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Take scheduler control up front, then apply mode before bandwidth so a
|
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||||
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||||
const tunePromise = (async () => {
|
const tunePromise = (async () => {
|
||||||
await bridge.trx.modules.vchan?.takeSchedulerControl();
|
if (typeof vchanTakeSchedulerControl === "function") {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
}
|
||||||
|
|
||||||
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
|
const onVirtual = typeof vchanInterceptMode === "function"
|
||||||
|
&& await vchanInterceptMode(bm.mode);
|
||||||
if (!onVirtual) {
|
if (!onVirtual) {
|
||||||
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bm.bandwidth_hz) {
|
if (bm.bandwidth_hz) {
|
||||||
const bwHandledByVchan =
|
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
|
||||||
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
|
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
|
||||||
if (!bwHandledByVchan) {
|
if (!bwHandledByVchan) {
|
||||||
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
|
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setRigFrequency redirects to the channel API when a virtual channel is
|
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||||
// active. It repeats the optimistic update applied above, which is a
|
// when on a virtual channel, so this call works correctly in both cases.
|
||||||
// no-op because the value is unchanged.
|
// It also does its own optimistic update (applyLocalTunedFrequency) but
|
||||||
hostCore.setRigFrequency(bm.freq_hz);
|
// that's a no-op since we already set the same value above.
|
||||||
|
if (typeof setRigFrequency === "function") {
|
||||||
|
await setRigFrequency(bm.freq_hz);
|
||||||
|
} else {
|
||||||
|
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
// Decoder toggles — fire-and-forget.
|
// Decoder toggles — fire-and-forget.
|
||||||
// - Decoders incompatible with the new mode are always turned off
|
// - Decoders incompatible with the new mode are always turned off
|
||||||
@@ -500,19 +460,18 @@ function bmApply(bm: Bookmark): void {
|
|||||||
// alone.
|
// alone.
|
||||||
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
const modeUp = (bm.mode || "").toUpperCase();
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
const allToggleDecoders = hostState.decoderRegistry.filter(d =>
|
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
|
||||||
d.activation === "toggle"
|
d.activation === "toggle"
|
||||||
);
|
);
|
||||||
const decoderPromise = allToggleDecoders.length ? (async () => {
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
let statusUrl = "/status";
|
let statusUrl = "/status";
|
||||||
const rigId = hostState.lastActiveRigId;
|
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
||||||
if (rigId) {
|
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
||||||
statusUrl += "?remote=" + encodeURIComponent(rigId);
|
|
||||||
}
|
}
|
||||||
const statusResp = await fetch(statusUrl);
|
const statusResp = await fetch(statusUrl);
|
||||||
if (!statusResp.ok) return;
|
if (!statusResp.ok) return;
|
||||||
const st = await statusResp.json() as Record<string, unknown>;
|
const st = await statusResp.json();
|
||||||
const toggles: Promise<unknown>[] = [];
|
const toggles = [];
|
||||||
for (const d of allToggleDecoders) {
|
for (const d of allToggleDecoders) {
|
||||||
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
const currentlyOn = !!st[statusKey];
|
const currentlyOn = !!st[statusKey];
|
||||||
@@ -523,52 +482,41 @@ function bmApply(bm: Bookmark): void {
|
|||||||
// Always disable decoders that don't apply to the new mode.
|
// Always disable decoders that don't apply to the new mode.
|
||||||
wanted = false;
|
wanted = false;
|
||||||
} else if (hasDecoders) {
|
} else if (hasDecoders) {
|
||||||
wanted = bm.decoders?.includes(d.id) ?? false;
|
wanted = bm.decoders.includes(d.id);
|
||||||
} else {
|
} else {
|
||||||
// Mode-compatible and no bookmark selection: leave as-is.
|
// Mode-compatible and no bookmark selection: leave as-is.
|
||||||
wanted = currentlyOn;
|
wanted = currentlyOn;
|
||||||
}
|
}
|
||||||
if (wanted !== currentlyOn) {
|
if (wanted !== currentlyOn) {
|
||||||
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (toggles.length) await Promise.all(toggles);
|
if (toggles.length) await Promise.all(toggles);
|
||||||
})() : Promise.resolve();
|
})() : Promise.resolve();
|
||||||
// Don't await — let the network calls settle in the background.
|
// Don't await — let the network calls settle in the background.
|
||||||
// Errors are logged but don't block the UI.
|
// Errors are logged but don't block the UI.
|
||||||
void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
|
Promise.all([tunePromise, decoderPromise]).catch(
|
||||||
console.error("Bookmark apply background error:", error);
|
(err) => console.error("Bookmark apply background error:", err)
|
||||||
});
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to apply bookmark:", err);
|
console.error("Failed to apply bookmark:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function bmUpdateSelectionUi() {
|
||||||
const count = bmSelected.size;
|
const count = bmSelected.size;
|
||||||
const canCtrl = bmCanControl();
|
const canCtrl = bmCanControl();
|
||||||
const visible = count > 0 && canCtrl;
|
const visible = count > 0 && canCtrl;
|
||||||
const btn = bmEl("bm-del-selected-btn");
|
const btn = document.getElementById("bm-del-selected-btn");
|
||||||
const countEl = bmEl("bm-del-selected-count");
|
const countEl = document.getElementById("bm-del-selected-count");
|
||||||
if (btn) btn.style.display = visible ? "" : "none";
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
if (countEl) countEl.textContent = String(count);
|
if (countEl) countEl.textContent = count;
|
||||||
const moveWrap = bmEl("bm-move-selected-wrap");
|
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||||
const moveCountEl = bmEl("bm-move-selected-count");
|
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||||
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
if (moveCountEl) moveCountEl.textContent = String(count);
|
if (moveCountEl) moveCountEl.textContent = count;
|
||||||
if (visible) bmPopulateMoveTarget();
|
if (visible) bmPopulateMoveTarget();
|
||||||
const selectAllBtn = bmEl("bm-select-all-btn");
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
if (selectAllBtn && bmCanControl()) {
|
if (selectAllBtn && bmCanControl()) {
|
||||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
@@ -577,10 +525,10 @@ function bmUpdateSelectionUi() {
|
|||||||
|
|
||||||
/** Populate the move-target dropdown with all scopes except the current one. */
|
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||||
function bmPopulateMoveTarget() {
|
function bmPopulateMoveTarget() {
|
||||||
const sel = bmEl("bm-move-target");
|
const sel = document.getElementById("bm-move-target");
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const rigIds = hostState.lastRigIds;
|
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||||
const displayNames = hostState.lastRigDisplayNames;
|
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||||
const prev = sel.value;
|
const prev = sel.value;
|
||||||
sel.innerHTML = "";
|
sel.innerHTML = "";
|
||||||
if (bmScope !== "general") {
|
if (bmScope !== "general") {
|
||||||
@@ -604,18 +552,13 @@ function bmPopulateMoveTarget() {
|
|||||||
async function bmMoveSelected() {
|
async function bmMoveSelected() {
|
||||||
const ids = Array.from(bmSelected);
|
const ids = Array.from(bmSelected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const target = bmEl("bm-move-target")?.value;
|
const target = document.getElementById("bm-move-target")?.value;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
if (!await bridge.trxUi.confirm({
|
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return;
|
||||||
title: "Move selected bookmarks?",
|
|
||||||
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
|
||||||
confirmLabel: "Move",
|
|
||||||
danger: false,
|
|
||||||
})) return;
|
|
||||||
try {
|
try {
|
||||||
// Group selected IDs by their owning scope (skip if already in target).
|
// Group selected IDs by their owning scope (skip if already in target).
|
||||||
const byScope: Record<string, string[]> = {};
|
const byScope = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm?.scope || bmScope;
|
const scope = bm?.scope || bmScope;
|
||||||
@@ -627,21 +570,21 @@ async function bmMoveSelected() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: scopeIds, to: target }),
|
body: JSON.stringify({ ids: scopeIds, to: target }),
|
||||||
}).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||||
));
|
));
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
await bmFetch(bmEl("bm-category-filter").value);
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to move bookmarks:", err);
|
console.error("Failed to move bookmarks:", err);
|
||||||
bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
|
alert("Failed to move bookmarks: " + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmSyncSelectAllCheckbox() {
|
function bmSyncSelectAllCheckbox() {
|
||||||
const selectAll = bmEl("bm-select-all");
|
const selectAll = document.getElementById("bm-select-all");
|
||||||
if (!selectAll) return;
|
if (!selectAll) return;
|
||||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(".bm-row-sel");
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
if (checkboxes.length === 0) {
|
if (checkboxes.length === 0) {
|
||||||
selectAll.checked = false;
|
selectAll.checked = false;
|
||||||
selectAll.indeterminate = false;
|
selectAll.indeterminate = false;
|
||||||
@@ -655,14 +598,10 @@ function bmSyncSelectAllCheckbox() {
|
|||||||
async function bmDeleteSelected() {
|
async function bmDeleteSelected() {
|
||||||
const ids = Array.from(bmSelected);
|
const ids = Array.from(bmSelected);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
if (!await bridge.trxUi.confirm({
|
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return;
|
||||||
title: "Delete selected bookmarks?",
|
|
||||||
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
|
||||||
confirmLabel: "Delete",
|
|
||||||
})) return;
|
|
||||||
try {
|
try {
|
||||||
// Group selected IDs by their owning scope.
|
// Group selected IDs by their owning scope.
|
||||||
const byScope: Record<string, string[]> = {};
|
const byScope = {};
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const bm = bmList.find((b) => b.id === id);
|
const bm = bmList.find((b) => b.id === id);
|
||||||
const scope = bm?.scope || bmScope;
|
const scope = bm?.scope || bmScope;
|
||||||
@@ -673,23 +612,23 @@ async function bmDeleteSelected() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ids: scopeIds }),
|
body: JSON.stringify({ ids: scopeIds }),
|
||||||
}).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
|
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||||
));
|
));
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
await bmFetch(bmEl("bm-category-filter").value);
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete bookmarks:", err);
|
console.error("Failed to delete bookmarks:", err);
|
||||||
bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
|
alert("Failed to delete bookmarks: " + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Populate the scope picker with "General" + one option per rig. */
|
/** Populate the scope picker with "General" + one option per rig. */
|
||||||
function bmPopulateScopePicker() {
|
function bmPopulateScopePicker() {
|
||||||
const picker = bmEl("bm-scope-picker");
|
const picker = document.getElementById("bm-scope-picker");
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const rigIds = hostState.lastRigIds;
|
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||||
const displayNames = hostState.lastRigDisplayNames;
|
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||||
// Preserve current selection if still valid.
|
// Preserve current selection if still valid.
|
||||||
const prev = picker.value;
|
const prev = picker.value;
|
||||||
while (picker.options.length > 1) picker.remove(1);
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
@@ -716,61 +655,63 @@ function bmPopulateScopePicker() {
|
|||||||
// Build decoder checkboxes from registry. The registry is fetched async
|
// Build decoder checkboxes from registry. The registry is fetched async
|
||||||
// so we rebuild once it arrives to ensure checkboxes are present.
|
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||||
bmBuildDecoderCheckboxes();
|
bmBuildDecoderCheckboxes();
|
||||||
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
if (typeof window.onDecoderRegistryReady === "function") {
|
||||||
|
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
|
}
|
||||||
|
|
||||||
// Scope picker
|
// Scope picker
|
||||||
bmPopulateScopePicker();
|
bmPopulateScopePicker();
|
||||||
const scopePicker = bmEl("bm-scope-picker");
|
const scopePicker = document.getElementById("bm-scope-picker");
|
||||||
if (scopePicker) {
|
if (scopePicker) {
|
||||||
scopePicker.addEventListener("change", (e) => {
|
scopePicker.addEventListener("change", (e) => {
|
||||||
bmScope = (e.currentTarget as HTMLSelectElement).value;
|
bmScope = e.target.value;
|
||||||
void bmFetch(bmEl("bm-category-filter").value || "");
|
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh list and sync access when the Bookmarks tab is activated
|
// Refresh list and sync access when the Bookmarks tab is activated
|
||||||
document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||||
const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
|
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
void bmFetch(bmEl("bm-category-filter").value);
|
bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Bookmark button — open form and prefill from current rig state
|
// Add Bookmark button — open form and prefill from current rig state
|
||||||
bmEl("bm-add-btn").addEventListener("click", () => {
|
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||||
bmOpenForm(null);
|
bmOpenForm(null);
|
||||||
bmPrefillFromStatus();
|
bmPrefillFromStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Category filter dropdown
|
// Category filter dropdown
|
||||||
bmEl("bm-category-filter").addEventListener("change", (e) => {
|
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||||
void bmFetch((e.currentTarget as HTMLSelectElement).value);
|
bmFetch(e.target.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mode filter dropdown (client-side, no re-fetch)
|
// Mode filter dropdown (client-side, no re-fetch)
|
||||||
bmEl("bm-mode-filter").addEventListener("change", () => {
|
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text search filter (client-side, no re-fetch)
|
// Text search filter (client-side, no re-fetch)
|
||||||
bmEl("bm-text-filter").addEventListener("input", () => {
|
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
||||||
bmApplyFilters();
|
bmApplyFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
bmEl("bm-page-prev").addEventListener("click", () => {
|
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
||||||
bmChangePage(-1);
|
bmChangePage(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
bmEl("bm-page-next").addEventListener("click", () => {
|
document.getElementById("bm-page-next").addEventListener("click", () => {
|
||||||
bmChangePage(1);
|
bmChangePage(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submit
|
// Form submit
|
||||||
bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
|
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||||
|
|
||||||
// Form cancel
|
// Form cancel
|
||||||
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
|
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
|
||||||
const formWrap = bmEl("bm-form-wrap");
|
const formWrap = document.getElementById("bm-form-wrap");
|
||||||
if (formWrap) {
|
if (formWrap) {
|
||||||
formWrap.addEventListener("click", (e) => {
|
formWrap.addEventListener("click", (e) => {
|
||||||
if (e.target === formWrap) bmCloseForm();
|
if (e.target === formWrap) bmCloseForm();
|
||||||
@@ -778,26 +719,24 @@ function bmPopulateScopePicker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
|
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
||||||
bmCloseForm();
|
bmCloseForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select-all checkbox
|
// Select-all checkbox
|
||||||
bmEl("bm-select-all").addEventListener("change", (e) => {
|
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||||
const checked = (e.currentTarget as HTMLInputElement).checked;
|
const checked = e.target.checked;
|
||||||
document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
cb.checked = checked;
|
cb.checked = checked;
|
||||||
const id = cb.dataset.bmId;
|
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||||
if (!id) return;
|
else bmSelected.delete(cb.dataset.bmId);
|
||||||
if (checked) bmSelected.add(id);
|
|
||||||
else bmSelected.delete(id);
|
|
||||||
});
|
});
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Select All (across all pages) button
|
// Select All (across all pages) button
|
||||||
bmEl("bm-select-all-btn").addEventListener("click", () => {
|
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
||||||
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
bmSelected.clear();
|
bmSelected.clear();
|
||||||
@@ -805,53 +744,49 @@ function bmPopulateScopePicker() {
|
|||||||
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
}
|
}
|
||||||
// Sync visible page checkboxes
|
// Sync visible page checkboxes
|
||||||
document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
|
cb.checked = bmSelected.has(cb.dataset.bmId);
|
||||||
});
|
});
|
||||||
bmSyncSelectAllCheckbox();
|
bmSyncSelectAllCheckbox();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete Selected button
|
// Delete Selected button
|
||||||
bmEl("bm-del-selected-btn").addEventListener("click", () => {
|
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
void bmDeleteSelected();
|
bmDeleteSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move Selected button
|
// Move Selected button
|
||||||
bmEl("bm-move-selected-btn").addEventListener("click", () => {
|
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
void bmMoveSelected();
|
bmMoveSelected();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Table action buttons and row checkboxes (event delegation)
|
// Table action buttons and row checkboxes (event delegation)
|
||||||
bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
|
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||||
if (!(e.target instanceof Element)) return;
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
const checkbox = e.target.closest<HTMLInputElement>(".bm-row-sel");
|
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
const id = checkbox.dataset.bmId;
|
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||||
if (!id) return;
|
else bmSelected.delete(checkbox.dataset.bmId);
|
||||||
if (checkbox.checked) bmSelected.add(id);
|
|
||||||
else bmSelected.delete(id);
|
|
||||||
bmSyncSelectAllCheckbox();
|
bmSyncSelectAllCheckbox();
|
||||||
bmUpdateSelectionUi();
|
bmUpdateSelectionUi();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tuneBtn = e.target.closest<HTMLElement>(".bm-tune-btn");
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
const editBtn = e.target.closest<HTMLElement>(".bm-edit-btn");
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
const delBtn = e.target.closest<HTMLElement>(".bm-del-btn");
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
|
||||||
if (tuneBtn) {
|
if (tuneBtn) {
|
||||||
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
if (bm) bmApply(bm);
|
if (bm) await bmApply(bm);
|
||||||
} else if (editBtn) {
|
} else if (editBtn) {
|
||||||
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
if (bm) bmOpenForm(bm);
|
if (bm) bmOpenForm(bm);
|
||||||
} else if (delBtn) {
|
} else if (delBtn) {
|
||||||
const id = delBtn.dataset.bmId;
|
await bmDelete(delBtn.dataset.bmId);
|
||||||
if (id) await bmDelete(id);
|
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
// Pre-load bookmarks so spectrum markers are visible immediately.
|
// Pre-load bookmarks so spectrum markers are visible immediately.
|
||||||
void bmFetch("");
|
bmFetch("");
|
||||||
})();
|
})();
|
||||||
+104
-159
@@ -2,56 +2,16 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore } from "./host.js";
|
|
||||||
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
type Rgba = [number, number, number, number];
|
|
||||||
interface CwRenderer {
|
|
||||||
ready: boolean;
|
|
||||||
ensureSize(width: number, height: number, dpr: number): boolean;
|
|
||||||
clear(color: Rgba): void;
|
|
||||||
fillRect(x: number, y: number, width: number, height: number, color: Rgba): void;
|
|
||||||
drawSegments(points: number[], color: Rgba, width: number): void;
|
|
||||||
drawFilledArea(points: number[], baseline: number, color: Rgba): void;
|
|
||||||
drawPolyline(points: number[], color: Rgba, width: number): void;
|
|
||||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
|
||||||
}
|
|
||||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
|
||||||
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
|
||||||
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
|
||||||
interface CwToneRange {
|
|
||||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
|
||||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
|
||||||
}
|
|
||||||
interface CwBridge {
|
|
||||||
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer;
|
|
||||||
trxParseCssColor?: (color: string) => Rgba;
|
|
||||||
lastFreqHz?: number;
|
|
||||||
currentBandwidthHz?: number;
|
|
||||||
lastSpectrumData?: CwSpectrum;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
applyCwAutoUi?: (enabled: boolean) => void;
|
|
||||||
applyCwAutoUiFromServer?: (enabled: boolean) => void;
|
|
||||||
updateCwBar?: () => void;
|
|
||||||
clearCwBar?: () => void;
|
|
||||||
closeCwBar?: () => void;
|
|
||||||
refreshCwTonePicker?: () => void;
|
|
||||||
}
|
|
||||||
const cwWindow = window as unknown as CwBridge & PluginRuntimeWindow;
|
|
||||||
|
|
||||||
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||||
const cwStatusEl = document.getElementById("cw-status");
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
const cwOutputEl = document.getElementById("cw-output");
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
|
const cwAutoInput = document.getElementById("cw-auto");
|
||||||
const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
|
const cwWpmInput = document.getElementById("cw-wpm");
|
||||||
const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
|
const cwToneInput = document.getElementById("cw-tone");
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
|
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
||||||
? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||||
: null;
|
: null;
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
@@ -64,21 +24,17 @@ const CW_WPM_MAX = 40;
|
|||||||
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
const CW_BAR_LINE_GAP_MS = 5000;
|
const CW_BAR_LINE_GAP_MS = 5000;
|
||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf: number | null = null;
|
let cwTonePickerRaf = null;
|
||||||
let cwBarHistory: CwLine[] = [];
|
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
||||||
let cwBarCurrentLine: CwLine | null = null;
|
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||||
// does not immediately undo the user's choice.
|
// does not immediately undo the user's choice.
|
||||||
let cwAutoLocalOverride: boolean | null = null;
|
let cwAutoLocalOverride = null;
|
||||||
|
|
||||||
function escapeCwHtml(input: string): string {
|
function applyCwAutoUi(enabled) {
|
||||||
return hostCore.escapeMapHtml(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCwAutoUi(enabled: boolean): void {
|
|
||||||
if (cwAutoInput) cwAutoInput.checked = enabled;
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.disabled = enabled;
|
cwWpmInput.disabled = enabled;
|
||||||
@@ -92,16 +48,16 @@ function applyCwAutoUi(enabled: boolean): void {
|
|||||||
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
window.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
|
||||||
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||||
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||||
cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
window.applyCwAutoUiFromServer = function(enabled) {
|
||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine(): void {
|
function cwBarFlushCurrentLine() {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
@@ -109,15 +65,15 @@ function cwBarFlushCurrentLine(): void {
|
|||||||
cwBarCurrentLine = null;
|
cwBarCurrentLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar(): void {
|
function updateCwBar() {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress line so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
cwBarOverlay.innerHTML = "";
|
cwBarOverlay.innerHTML = "";
|
||||||
@@ -138,22 +94,22 @@ function updateCwBar(): void {
|
|||||||
for (const line of liveLines.slice(0, 8)) {
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
const meta = [
|
const meta = [
|
||||||
line.wpm ? `${String(line.wpm)} WPM` : null,
|
line.wpm ? `${line.wpm} WPM` : null,
|
||||||
line.tone_hz ? `${String(line.tone_hz)} Hz` : null,
|
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
html += `<div class="aprs-bar-frame">` +
|
html += `<div class="aprs-bar-frame">` +
|
||||||
`<div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
|
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
||||||
(meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
|
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
||||||
`</div></div>`;
|
`</div></div>`;
|
||||||
}
|
}
|
||||||
cwBarOverlay.innerHTML = html;
|
cwBarOverlay.innerHTML = html;
|
||||||
cwBarOverlay.style.display = "flex";
|
cwBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
cwWindow.updateCwBar = updateCwBar;
|
window.updateCwBar = updateCwBar;
|
||||||
cwWindow.clearCwBar = function() {
|
window.clearCwBar = function() {
|
||||||
resetCwHistoryView();
|
window.resetCwHistoryView();
|
||||||
};
|
};
|
||||||
cwWindow.closeCwBar = function() {
|
window.closeCwBar = function() {
|
||||||
cwBarDismissedAtMs = Date.now();
|
cwBarDismissedAtMs = Date.now();
|
||||||
if (cwBarOverlay) {
|
if (cwBarOverlay) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -161,31 +117,34 @@ cwWindow.closeCwBar = function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampCwWpm(wpm: unknown): number {
|
function clampCwWpm(wpm) {
|
||||||
const numeric = Number(wpm);
|
const numeric = Number(wpm);
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCwTone(tone: unknown): number {
|
function clampCwTone(tone) {
|
||||||
const numeric = Number(tone);
|
const numeric = Number(tone);
|
||||||
if (!Number.isFinite(numeric)) return 700;
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentCwToneRange(): CwToneRange | null {
|
function currentCwToneRange() {
|
||||||
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||||
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const lowerSideband = mode === "CWR";
|
const lowerSideband = mode === "CWR";
|
||||||
const upperSideband = mode === "CW";
|
const upperSideband = mode === "CW";
|
||||||
if (!lowerSideband && !upperSideband) return null;
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
|
||||||
const toneMinHz = CW_TONE_MIN_HZ;
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
const toneMaxHz = CW_TONE_MAX_HZ;
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
|
if (toneMaxHz < toneMinHz) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tunedHz,
|
tunedHz,
|
||||||
bandwidthHz,
|
bandwidthHz,
|
||||||
@@ -197,20 +156,20 @@ function currentCwToneRange(): CwToneRange | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
|
function cwToneToRfHz(range, toneHz) {
|
||||||
if (!range) return NaN;
|
if (!range) return NaN;
|
||||||
return range.lowerSideband
|
return range.lowerSideband
|
||||||
? range.tunedHz - toneHz
|
? range.tunedHz - toneHz
|
||||||
: range.tunedHz + toneHz;
|
: range.tunedHz + toneHz;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
|
function toneClampForRange(tone, range) {
|
||||||
const clamped = clampCwTone(tone);
|
const clamped = clampCwTone(tone);
|
||||||
if (!range) return clamped;
|
if (!range) return clamped;
|
||||||
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCwToneCanvasResolution(): boolean {
|
function ensureCwToneCanvasResolution() {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
const cssWidth = Math.round(rect.width);
|
const cssWidth = Math.round(rect.width);
|
||||||
@@ -222,7 +181,7 @@ function ensureCwToneCanvasResolution(): boolean {
|
|||||||
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCwTonePicker(): void {
|
function drawCwTonePicker() {
|
||||||
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
@@ -231,12 +190,12 @@ function drawCwTonePicker(): void {
|
|||||||
cwToneGl.clear([0, 0, 0, 0]);
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
|
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,15 +205,15 @@ function drawCwTonePicker(): void {
|
|||||||
|
|
||||||
if (cwToneRangeEl) {
|
if (cwToneRangeEl) {
|
||||||
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bins = cwWindow.lastSpectrumData.bins;
|
const bins = window.lastSpectrumData.bins;
|
||||||
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||||
const centerHz = cwWindow.lastSpectrumData.center_hz;
|
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||||
const maxIdx = Math.max(1, bins.length - 1);
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
const fullLoHz = centerHz - sampleRate / 2;
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
const tones: number[] = new Array<number>(width).fill(-140);
|
const tones = new Array(width).fill(-140);
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
const frac = width <= 1 ? 0 : x / (width - 1);
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
@@ -264,17 +223,17 @@ function drawCwTonePicker(): void {
|
|||||||
tones[x] = power;
|
tones[x] = power;
|
||||||
}
|
}
|
||||||
|
|
||||||
const smoothed: number[] = new Array<number>(width).fill(-140);
|
const smoothed = new Array(width).fill(-140);
|
||||||
const smoothRadius = Math.max(1, Math.round(width / 180));
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
if (i < 0 || i >= width) continue;
|
if (i < 0 || i >= width) continue;
|
||||||
sum += tones[i] ?? -140;
|
sum += tones[i];
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
smoothed[x] = count > 0 ? sum / count : tones[x];
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = smoothed.slice().sort((a, b) => a - b);
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
@@ -283,23 +242,23 @@ function drawCwTonePicker(): void {
|
|||||||
const floorDb = Math.min(q20 - 2, q95 - 10);
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
const dbSpan = Math.max(1, ceilDb - floorDb);
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
const yForDb = (db: number): number => {
|
const yForDb = (db) => {
|
||||||
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
return Math.round((1 - n) * (height - 1));
|
return Math.round((1 - n) * (height - 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
const parseColor = typeof cwWindow.trxParseCssColor === "function"
|
const parseColor = typeof window.trxParseCssColor === "function"
|
||||||
? cwWindow.trxParseCssColor
|
? window.trxParseCssColor
|
||||||
: null;
|
: null;
|
||||||
const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
const axisColor: Rgba = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
|
||||||
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
|
||||||
const hGridCount = 4;
|
const hGridCount = 4;
|
||||||
const gridSegments: number[] = [];
|
const gridSegments = [];
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||||
gridSegments.push(0, y, width, y);
|
gridSegments.push(0, y, width, y);
|
||||||
@@ -308,7 +267,7 @@ function drawCwTonePicker(): void {
|
|||||||
|
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
const tickSegments: number[] = [];
|
const tickSegments = [];
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
@@ -316,9 +275,9 @@ function drawCwTonePicker(): void {
|
|||||||
}
|
}
|
||||||
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
const linePoints: number[] = [];
|
const linePoints = [];
|
||||||
for (let x = 0; x < width; x += 1) {
|
for (let x = 0; x < width; x += 1) {
|
||||||
linePoints.push(x, yForDb(smoothed[x] ?? -140));
|
linePoints.push(x, yForDb(smoothed[x]));
|
||||||
}
|
}
|
||||||
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
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));
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
@@ -326,7 +285,7 @@ function drawCwTonePicker(): void {
|
|||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
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);
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||||
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
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]);
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
|
||||||
@@ -335,14 +294,14 @@ function drawCwTonePicker(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise<void> {
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
const clamped = toneClampForRange(tone, range);
|
const clamped = toneClampForRange(tone, range);
|
||||||
if (cwToneInput && syncInput) {
|
if (cwToneInput && syncInput) {
|
||||||
cwToneInput.value = String(clamped);
|
cwToneInput.value = clamped;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("CW tone set failed", e);
|
console.error("CW tone set failed", e);
|
||||||
}
|
}
|
||||||
@@ -350,43 +309,40 @@ async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cwAutoInput) {
|
if (cwAutoInput) {
|
||||||
cwAutoInput.addEventListener("change", () => {
|
cwAutoInput.addEventListener("change", async () => {
|
||||||
void (async () => {
|
const enabled = cwAutoInput.checked;
|
||||||
const enabled = cwAutoInput.checked;
|
cwAutoLocalOverride = enabled;
|
||||||
cwAutoLocalOverride = enabled;
|
applyCwAutoUi(enabled);
|
||||||
applyCwAutoUi(enabled);
|
try {
|
||||||
try {
|
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
drawCwTonePicker();
|
||||||
drawCwTonePicker();
|
} catch (e) {
|
||||||
} catch (error: unknown) {
|
console.error("CW auto toggle failed", e);
|
||||||
console.error("CW auto toggle failed", error);
|
} finally {
|
||||||
} finally {
|
cwAutoLocalOverride = null;
|
||||||
cwAutoLocalOverride = null;
|
}
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.addEventListener("change", () => {
|
cwWpmInput.addEventListener("change", async () => {
|
||||||
void (async () => {
|
if (cwAutoInput && cwAutoInput.checked) return;
|
||||||
if (cwAutoInput?.checked) return;
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
cwWpmInput.value = wpm;
|
||||||
cwWpmInput.value = String(wpm);
|
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||||
try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
catch (e) { console.error("CW WPM set failed", e); }
|
||||||
catch (error: unknown) { console.error("CW WPM set failed", error); }
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.addEventListener("change", () => {
|
cwToneInput.addEventListener("change", async () => {
|
||||||
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
if (cwAutoInput?.checked) return;
|
||||||
|
await setCwTone(cwToneInput.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cwToneCanvas) {
|
if (cwToneCanvas) {
|
||||||
cwToneCanvas.addEventListener("click", (event) => {
|
cwToneCanvas.addEventListener("click", async (event) => {
|
||||||
if (cwAutoInput?.checked) return;
|
if (cwAutoInput?.checked) return;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
if (rect.width <= 0) return;
|
if (rect.width <= 0) return;
|
||||||
@@ -394,33 +350,31 @@ if (cwToneCanvas) {
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
void setCwTone(tone);
|
await setCwTone(tone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCwHistoryView(): void {
|
window.resetCwHistoryView = function() {
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLine = null;
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||||
void (async () => {
|
if (!confirm("Clear all CW decode history? This cannot be undone.")) return;
|
||||||
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
try {
|
||||||
try {
|
await postPath("/clear_cw_decode");
|
||||||
await hostCore.postPath("/clear_cw_decode");
|
window.resetCwHistoryView();
|
||||||
resetCwHistoryView();
|
} catch (e) {
|
||||||
} catch (error: unknown) {
|
console.error("CW history clear failed", e);
|
||||||
console.error("CW history clear failed", error);
|
}
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
function onServerCw(evt: CwEvent): void {
|
window.onServerCw = function(evt) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
if (evt.text && cwOutputEl) {
|
if (evt.text && cwOutputEl) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
@@ -436,9 +390,7 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
lastLine.textContent += evt.text;
|
lastLine.textContent += evt.text;
|
||||||
}
|
}
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
const firstChild = cwOutputEl.firstChild;
|
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||||
if (!firstChild) break;
|
|
||||||
cwOutputEl.removeChild(firstChild);
|
|
||||||
}
|
}
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -465,10 +417,10 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||||
}
|
}
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cwTonePickerRaf != null) return;
|
if (cwTonePickerRaf != null) return;
|
||||||
@@ -476,24 +428,17 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
cwTonePickerRaf = null;
|
cwTonePickerRaf = null;
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
function restoreCwHistory(events: CwEvent[]): void {
|
window.restoreCwHistory = function(events) {
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
onServerCw(evt);
|
window.onServerCw(evt);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
cwWindow.trxPluginRuntime.registerDecoder({
|
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
id: "cw",
|
|
||||||
onMessage: onServerCw,
|
|
||||||
restore: restoreCwHistory,
|
|
||||||
reset: resetCwHistoryView,
|
|
||||||
});
|
|
||||||
|
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
// 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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
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 (!confirm("Clear all FT2 decode history? This cannot be undone.")) 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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// --- FT4 Decoder Plugin (server-side decode) ---
|
||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
// 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 = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
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 ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
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 (!confirm("Clear all FT4 decode history? This cannot be undone.")) 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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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 = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
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 ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
||||||
|
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 += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${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 += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} 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 (!confirm("Clear all FT8 decode history? This cannot be undone.")) 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);
|
||||||
|
};
|
||||||
+202
-111
@@ -2,37 +2,10 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
|
||||||
|
|
||||||
import {
|
|
||||||
aprsAgeText,
|
|
||||||
aprsCategoryLabel,
|
|
||||||
aprsHexBytes,
|
|
||||||
aprsPacketCategory,
|
|
||||||
collapseAprsDuplicates,
|
|
||||||
normalizeAprsPacket,
|
|
||||||
renderAprsInfo,
|
|
||||||
renderLocalAprsSymbol,
|
|
||||||
type AprsPacket,
|
|
||||||
type AprsTypeFilter,
|
|
||||||
} from "./aprs-shared";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
|
||||||
|
|
||||||
interface HfAprsBridge {
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
|
||||||
getDecodeRigMeta?: () => unknown;
|
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
}
|
|
||||||
const hfAprsWindow = window as unknown as HfAprsBridge;
|
|
||||||
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
|
|
||||||
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||||
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
const hfAprsFilterInput = document.getElementById("hf-aprs-filter") as HTMLInputElement | null;
|
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
@@ -40,43 +13,92 @@ const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
|||||||
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
let hfAprsFilterText = "";
|
let hfAprsFilterText = "";
|
||||||
let hfAprsPacketHistory: AprsPacket[] = [];
|
let hfAprsPacketHistory = [];
|
||||||
let hfAprsOnlyPos = false;
|
let hfAprsOnlyPos = false;
|
||||||
let hfAprsHideCrc = false;
|
let hfAprsHideCrc = false;
|
||||||
let hfAprsCollapseDup = false;
|
let hfAprsCollapseDup = false;
|
||||||
let hfAprsTypeFilter: AprsTypeFilter = "all";
|
let hfAprsTypeFilter = "all";
|
||||||
|
|
||||||
function currentHfAprsHistoryRetentionMs(): number {
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function"
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
? hfAprsWindow.getDecodeHistoryRetentionMs()
|
? window.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneHfAprsPacketHistory() {
|
function pruneHfAprsPacketHistory() {
|
||||||
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleHfAprsHistoryRender() {
|
function scheduleHfAprsHistoryRender() {
|
||||||
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => { renderHfAprsHistory(); });
|
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hfAprsDistanceText(pkt: AprsPacket): string {
|
function hfAprsPacketCategory(pkt) {
|
||||||
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.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 hfAprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position": return "Position";
|
||||||
|
case "message": return "Message";
|
||||||
|
case "weather": return "Weather";
|
||||||
|
case "telemetry": return "Telemetry";
|
||||||
|
default: return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
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 hfAprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
function hfAprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||||
if (!hfAprsFilterText) return true;
|
if (!hfAprsFilterText) return true;
|
||||||
const haystack = [
|
const haystack = [
|
||||||
pkt.srcCall,
|
pkt.srcCall,
|
||||||
@@ -86,7 +108,7 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
pkt.type,
|
pkt.type,
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
aprsPacketCategory(pkt),
|
hfAprsPacketCategory(pkt),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -94,12 +116,22 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hfAprsVisiblePackets(): AprsPacket[] {
|
function hfAprsVisiblePackets() {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
function collapseHfAprsDuplicates(packets) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = hfAprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
@@ -114,7 +146,7 @@ function updateHfAprsSummary() {
|
|||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,21 +160,69 @@ function updateHfAprsChipState() {
|
|||||||
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
function renderHfAprsInfo(pkt) {
|
||||||
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 0x20 && b <= 0x7e) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 0x20 && code <= 0x7e) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "aprs-packet";
|
row.className = "aprs-packet";
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
|
||||||
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
const age = hfAprsAgeText(pkt._tsMs);
|
||||||
const category = aprsPacketCategory(pkt);
|
const category = hfAprsPacketCategory(pkt);
|
||||||
const categoryLabel = aprsCategoryLabel(category);
|
const categoryLabel = hfAprsCategoryLabel(category);
|
||||||
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
const posLink = pkt.lat != null && pkt.lon != null
|
const posLink = pkt.lat != null && pkt.lon != null
|
||||||
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||||
: "";
|
: "";
|
||||||
@@ -154,19 +234,19 @@ function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
`<span class="aprs-time">${ts}</span>` +
|
`<span class="aprs-time">${ts}</span>` +
|
||||||
hfBadge +
|
hfBadge +
|
||||||
symbolHtml +
|
symbolHtml +
|
||||||
`<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
|
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||||
`<span>>${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
|
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||||
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
|
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||||
pathBadge +
|
pathBadge +
|
||||||
crcBadge +
|
crcBadge +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-meta">` +
|
`<div class="aprs-row-meta">` +
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
|
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||||
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
|
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||||
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-detail">` +
|
`<div class="aprs-row-detail">` +
|
||||||
`<span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
|
||||||
(posLink ? `<span>${posLink}</span>` : "") +
|
(posLink ? `<span>${posLink}</span>` : "") +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="aprs-row-actions">` +
|
`<div class="aprs-row-actions">` +
|
||||||
@@ -177,43 +257,42 @@ function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
|
|||||||
`<details class="aprs-details">` +
|
`<details class="aprs-details">` +
|
||||||
`<summary>Details</summary>` +
|
`<summary>Details</summary>` +
|
||||||
`<div class="aprs-details-grid">` +
|
`<div class="aprs-details-grid">` +
|
||||||
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
|
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
|
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
|
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
|
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span>` +
|
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||||
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||||
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||||
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
|
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||||
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`</details>`;
|
`</details>`;
|
||||||
|
|
||||||
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
el.addEventListener("click", (evt) => {
|
el.addEventListener("click", (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
window.navigateToAprsMap(lat, lon);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
copyBtn.addEventListener("click", () => { void (async () => {
|
copyBtn.addEventListener("click", async () => {
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
try {
|
try {
|
||||||
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
|
if (navigator.clipboard?.writeText) {
|
||||||
if (clipboard) {
|
await navigator.clipboard.writeText(raw);
|
||||||
await clipboard.writeText(raw);
|
showHint("Coordinates copied", 1200);
|
||||||
hostCore.showHint("Coordinates copied", 1200);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (_e) {
|
||||||
hostCore.showHint("Copy failed", 1500);
|
showHint("Copy failed", 1500);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -228,26 +307,26 @@ function renderHfAprsHistory() {
|
|||||||
}
|
}
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const [index, packet] of visible.entries()) {
|
for (let i = 0; i < visible.length; i++) {
|
||||||
fragment.appendChild(renderHfAprsRow(packet, index === 0));
|
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
||||||
}
|
}
|
||||||
hfAprsPacketsEl.replaceChildren(fragment);
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
updateHfAprsSummary();
|
updateHfAprsSummary();
|
||||||
updateHfAprsChipState();
|
updateHfAprsChipState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetHfAprsHistoryView(): void {
|
window.resetHfAprsHistoryView = function() {
|
||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneHfAprsHistoryView(): void {
|
window.pruneHfAprsHistoryView = function() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
function addHfAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -258,14 +337,29 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
|||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
window.onServerHfAprsBatch = function(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
@@ -277,27 +371,31 @@ function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
|||||||
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
window.restoreHfAprsHistory = function(packets) {
|
||||||
|
window.onServerHfAprsBatch(packets);
|
||||||
|
};
|
||||||
|
|
||||||
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
|
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
await hostCore.postPath("/toggle_hf_aprs_decode");
|
await postPath("/toggle_hf_aprs_decode");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("HF APRS toggle failed", e);
|
console.error("HF APRS toggle failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => { void (async () => {
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", 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;
|
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_hf_aprs_decode");
|
await postPath("/clear_hf_aprs_decode");
|
||||||
resetHfAprsHistoryView();
|
window.resetHfAprsHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("HF APRS history clear failed", e);
|
console.error("HF APRS history clear failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
if (hfAprsOnlyPosBtn) {
|
if (hfAprsOnlyPosBtn) {
|
||||||
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
@@ -320,7 +418,7 @@ if (hfAprsCollapseDupBtn) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
(["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
@@ -337,17 +435,10 @@ if (hfAprsFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
// --- Server-side HF APRS decode handler ---
|
||||||
function onServerHfAprs(pkt: AprsPacket): void {
|
window.onServerHfAprs = function(pkt) {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
}
|
};
|
||||||
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
||||||
id: "hf_aprs",
|
|
||||||
onMessage: onServerHfAprs,
|
|
||||||
onBatch: onServerHfAprsBatch,
|
|
||||||
restore: onServerHfAprsBatch,
|
|
||||||
reset: resetHfAprsHistoryView,
|
|
||||||
prune: pruneHfAprsHistoryView,
|
|
||||||
});
|
|
||||||
+87
-108
@@ -2,22 +2,6 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import type {
|
|
||||||
SatelliteBookmark,
|
|
||||||
SatelliteScheduleConfig,
|
|
||||||
SatelliteScheduleEntry,
|
|
||||||
SatelliteSchedulerApi,
|
|
||||||
SatelliteSchedulerStatus,
|
|
||||||
} from "./satellite-types";
|
|
||||||
import type { SchedulerConfig, SchedulerService } from "./scheduler-types.js";
|
|
||||||
|
|
||||||
interface SatelliteSchedulerWindow {
|
|
||||||
trx?: { modules?: { scheduler?: SchedulerService } };
|
|
||||||
satScheduler?: SatelliteSchedulerApi;
|
|
||||||
trxUi?: { notify(message: string, options: { kind: "error" }): void };
|
|
||||||
}
|
|
||||||
const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|
||||||
|
|
||||||
// Satellite Pass Scheduling UI
|
// Satellite Pass Scheduling UI
|
||||||
// Manages the satellite overlay section within the background decoding scheduler.
|
// Manages the satellite overlay section within the background decoding scheduler.
|
||||||
// Communicates with scheduler.js via a thin window API for shared state access.
|
// Communicates with scheduler.js via a thin window API for shared state access.
|
||||||
@@ -27,91 +11,93 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
|
|
||||||
// ── DOM references (cached once) ──────────────────────────────────
|
// ── DOM references (cached once) ──────────────────────────────────
|
||||||
const dom = {
|
const dom = {
|
||||||
enabled: document.getElementById("scheduler-sat-enabled") as HTMLInputElement | null,
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
pretune: document.getElementById("scheduler-sat-pretune") as HTMLInputElement | null,
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
body: document.getElementById("scheduler-sat-body"),
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
tbody: document.getElementById("scheduler-sat-tbody"),
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
formWrap: document.getElementById("sch-sat-form-wrap"),
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
formTitle: document.getElementById("sch-sat-form-title"),
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
form: document.getElementById("sch-sat-form") as HTMLFormElement | null,
|
form: document.getElementById("sch-sat-form"),
|
||||||
formCancel: document.getElementById("sch-sat-form-cancel"),
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
preset: document.getElementById("scheduler-sat-preset") as HTMLSelectElement | null,
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
name: document.getElementById("scheduler-sat-name") as HTMLInputElement | null,
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
norad: document.getElementById("scheduler-sat-norad") as HTMLInputElement | null,
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
bookmark: document.getElementById("scheduler-sat-bookmark") as HTMLSelectElement | null,
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
minEl: document.getElementById("scheduler-sat-min-el") as HTMLInputElement | null,
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
priority: document.getElementById("scheduler-sat-priority") as HTMLInputElement | null,
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
centerHz: document.getElementById("scheduler-sat-center-hz") as HTMLInputElement | null,
|
centerHz: document.getElementById("scheduler-sat-center-hz"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Local state ───────────────────────────────────────────────────
|
// ── Local state ───────────────────────────────────────────────────
|
||||||
let editIdx: number | null = null;
|
let editIdx = null; // null = adding, number = editing
|
||||||
let eventsWired = false;
|
|
||||||
|
|
||||||
// ── Scheduler bridge ──────────────────────────────────────────────
|
// ── Scheduler bridge ──────────────────────────────────────────────
|
||||||
// These accessors use the shared typed scheduler service when available.
|
// These accessors call into scheduler.js via window.schedulerBridge,
|
||||||
function getBridge(): SchedulerService | null {
|
// which is set up by scheduler.js after it initializes.
|
||||||
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
|
function getBridge() {
|
||||||
|
return window.schedulerBridge || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfig(): SchedulerConfig | null {
|
function getConfig() {
|
||||||
const b = getBridge();
|
const b = getBridge();
|
||||||
return b?.getConfig() ?? null;
|
return typeof b.getConfig === "function" ? b.getConfig() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatus(): SatelliteSchedulerStatus | null {
|
function getStatus() {
|
||||||
const b = getBridge();
|
const b = getBridge();
|
||||||
return b?.getStatus() ?? null;
|
return typeof b.getStatus === "function" ? b.getStatus() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBookmarks(): SatelliteBookmark[] {
|
function getBookmarks() {
|
||||||
const b = getBridge();
|
const b = getBridge();
|
||||||
return b?.getBookmarks() ?? [];
|
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
getBridge()?.markDirty();
|
var b = getBridge();
|
||||||
|
if (typeof b.markDirty === "function") b.markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bmName(id: string): string {
|
function bmName(id) {
|
||||||
const bm = getBookmarks().find(function (b) { return b.id === id; });
|
const bm = getBookmarks().find(function (b) { return b.id === id; });
|
||||||
return bm ? bm.name : id;
|
return bm ? bm.name : String(id || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escHtml(s: string): string {
|
function escHtml(s) {
|
||||||
return s
|
return String(s)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFreq(hz: number): string {
|
function formatFreq(hz) {
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||||
return `${String(hz)} Hz`;
|
return hz + " Hz";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Satellite config helpers ──────────────────────────────────────
|
// ── Satellite config helpers ──────────────────────────────────────
|
||||||
function getSatelliteEntries(): SatelliteScheduleEntry[] {
|
function getSatelliteEntries() {
|
||||||
const config = getConfig();
|
var config = getConfig();
|
||||||
return (config && config.satellites && Array.isArray(config.satellites.entries))
|
return (config && config.satellites && Array.isArray(config.satellites.entries))
|
||||||
? config.satellites.entries
|
? config.satellites.entries
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSatelliteConfig(): SatelliteScheduleConfig {
|
function ensureSatelliteConfig() {
|
||||||
const config = getConfig();
|
var config = getConfig();
|
||||||
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites.entries) config.satellites.entries = [];
|
||||||
return config.satellites;
|
return config.satellites;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectSatelliteConfig(): SatelliteScheduleConfig {
|
function collectSatelliteConfig() {
|
||||||
const enabled = dom.enabled ? dom.enabled.checked : false;
|
var enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
return {
|
return {
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
@@ -121,12 +107,12 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
|
|
||||||
// ── Render: section ───────────────────────────────────────────────
|
// ── Render: section ───────────────────────────────────────────────
|
||||||
function renderSection() {
|
function renderSection() {
|
||||||
const config = getConfig();
|
var config = getConfig();
|
||||||
const satCfg = config?.satellites;
|
var satCfg = (config && config.satellites) || {};
|
||||||
const enabled = satCfg?.enabled ?? false;
|
var enabled = !!satCfg.enabled;
|
||||||
|
|
||||||
if (dom.enabled) dom.enabled.checked = enabled;
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
|
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
|
||||||
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
|
|
||||||
renderEntries();
|
renderEntries();
|
||||||
@@ -136,35 +122,35 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
// ── Render: entries table ─────────────────────────────────────────
|
// ── Render: entries table ─────────────────────────────────────────
|
||||||
function renderEntries() {
|
function renderEntries() {
|
||||||
if (!dom.tbody) return;
|
if (!dom.tbody) return;
|
||||||
const entries = getSatelliteEntries();
|
var entries = getSatelliteEntries();
|
||||||
const frag = document.createDocumentFragment();
|
var frag = document.createDocumentFragment();
|
||||||
|
|
||||||
entries.forEach(function (entry, idx) {
|
entries.forEach(function (entry, idx) {
|
||||||
const tr = document.createElement("tr");
|
var tr = document.createElement("tr");
|
||||||
|
|
||||||
const tdSat = document.createElement("td");
|
var tdSat = document.createElement("td");
|
||||||
tdSat.textContent = entry.satellite || "";
|
tdSat.textContent = entry.satellite || "";
|
||||||
tr.appendChild(tdSat);
|
tr.appendChild(tdSat);
|
||||||
|
|
||||||
const tdNorad = document.createElement("td");
|
var tdNorad = document.createElement("td");
|
||||||
tdNorad.textContent = String(entry.norad_id || "");
|
tdNorad.textContent = entry.norad_id || "";
|
||||||
tr.appendChild(tdNorad);
|
tr.appendChild(tdNorad);
|
||||||
|
|
||||||
const tdBm = document.createElement("td");
|
var tdBm = document.createElement("td");
|
||||||
tdBm.textContent = bmName(entry.bookmark_id);
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
tr.appendChild(tdBm);
|
tr.appendChild(tdBm);
|
||||||
|
|
||||||
const tdEl = document.createElement("td");
|
var tdEl = document.createElement("td");
|
||||||
tdEl.textContent = `${String(entry.min_elevation_deg)}\u00B0`;
|
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
|
||||||
tr.appendChild(tdEl);
|
tr.appendChild(tdEl);
|
||||||
|
|
||||||
const tdPrio = document.createElement("td");
|
var tdPrio = document.createElement("td");
|
||||||
tdPrio.textContent = String(entry.priority || 0);
|
tdPrio.textContent = entry.priority || 0;
|
||||||
tr.appendChild(tdPrio);
|
tr.appendChild(tdPrio);
|
||||||
|
|
||||||
const tdActions = document.createElement("td");
|
var tdActions = document.createElement("td");
|
||||||
|
|
||||||
const editBtn = document.createElement("button");
|
var editBtn = document.createElement("button");
|
||||||
editBtn.className = "sch-write";
|
editBtn.className = "sch-write";
|
||||||
editBtn.type = "button";
|
editBtn.type = "button";
|
||||||
editBtn.textContent = "Edit";
|
editBtn.textContent = "Edit";
|
||||||
@@ -173,7 +159,7 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
});
|
});
|
||||||
tdActions.appendChild(editBtn);
|
tdActions.appendChild(editBtn);
|
||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
var removeBtn = document.createElement("button");
|
||||||
removeBtn.className = "sch-write";
|
removeBtn.className = "sch-write";
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
removeBtn.textContent = "Remove";
|
removeBtn.textContent = "Remove";
|
||||||
@@ -192,12 +178,12 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
// ── Render: pass status ───────────────────────────────────────────
|
// ── Render: pass status ───────────────────────────────────────────
|
||||||
function renderPassStatus() {
|
function renderPassStatus() {
|
||||||
if (!dom.passStatus) return;
|
if (!dom.passStatus) return;
|
||||||
const entries = getSatelliteEntries();
|
var entries = getSatelliteEntries();
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
dom.passStatus.innerHTML = "";
|
dom.passStatus.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = getStatus();
|
var status = getStatus();
|
||||||
if (status && status.active_satellite) {
|
if (status && status.active_satellite) {
|
||||||
dom.passStatus.innerHTML =
|
dom.passStatus.innerHTML =
|
||||||
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
|
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
|
||||||
@@ -210,38 +196,37 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Render: bookmark dropdown ─────────────────────────────────────
|
// ── Render: bookmark dropdown ─────────────────────────────────────
|
||||||
function renderBookmarkSelect(selectedId: string | null): void {
|
function renderBookmarkSelect(selectedId) {
|
||||||
const bookmarkSelect = dom.bookmark;
|
if (!dom.bookmark) return;
|
||||||
if (!bookmarkSelect) return;
|
dom.bookmark.innerHTML = '<option value="">— none —</option>';
|
||||||
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
|
||||||
getBookmarks().forEach(function (bm) {
|
getBookmarks().forEach(function (bm) {
|
||||||
const opt = document.createElement("option");
|
var opt = document.createElement("option");
|
||||||
opt.value = bm.id;
|
opt.value = bm.id;
|
||||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
if (bm.id === selectedId) opt.selected = true;
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
bookmarkSelect.appendChild(opt);
|
dom.bookmark.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entry management ──────────────────────────────────────────────
|
// ── Entry management ──────────────────────────────────────────────
|
||||||
function removeEntry(idx: number): void {
|
function removeEntry(idx) {
|
||||||
const sat = ensureSatelliteConfig();
|
var sat = ensureSatelliteConfig();
|
||||||
sat.entries.splice(idx, 1);
|
sat.entries.splice(idx, 1);
|
||||||
renderEntries();
|
renderEntries();
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Form: open ────────────────────────────────────────────────────
|
// ── Form: open ────────────────────────────────────────────────────
|
||||||
function openForm(entry: SatelliteScheduleEntry | null, idx: number | null): void {
|
function openForm(entry, idx) {
|
||||||
editIdx = (idx != null) ? idx : null;
|
editIdx = (idx != null) ? idx : null;
|
||||||
|
|
||||||
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||||
if (dom.preset) dom.preset.value = "";
|
if (dom.preset) dom.preset.value = "";
|
||||||
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
|
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
|
||||||
if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
|
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
|
||||||
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
|
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
|
||||||
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
|
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
|
||||||
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
|
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
|
||||||
|
|
||||||
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
|
||||||
@@ -258,26 +243,24 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Form: submit ──────────────────────────────────────────────────
|
// ── Form: submit ──────────────────────────────────────────────────
|
||||||
function onFormSubmit(e: SubmitEvent): void {
|
function onFormSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const satellite = dom.name ? dom.name.value.trim() : "";
|
var satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
const bmId = dom.bookmark ? dom.bookmark.value : "";
|
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
|
||||||
if (!satellite) { satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" }); dom.name?.focus(); return; }
|
if (!satellite) { alert("Please enter a satellite name."); return; }
|
||||||
if (isNaN(noradId) || noradId <= 0) { satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); dom.norad?.focus(); return; }
|
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; }
|
||||||
if (!bmId) { satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" }); dom.bookmark?.focus(); return; }
|
if (!bmId) { alert("Please select a bookmark."); return; }
|
||||||
|
|
||||||
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
|
|
||||||
const sat = ensureSatelliteConfig();
|
var sat = ensureSatelliteConfig();
|
||||||
|
|
||||||
const existing = editIdx !== null ? sat.entries[editIdx] : undefined;
|
var entryData = {
|
||||||
const entryData: SatelliteScheduleEntry = {
|
|
||||||
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
|
|
||||||
satellite: satellite,
|
satellite: satellite,
|
||||||
norad_id: noradId,
|
norad_id: noradId,
|
||||||
bookmark_id: bmId,
|
bookmark_id: bmId,
|
||||||
@@ -288,8 +271,11 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (editIdx !== null) {
|
if (editIdx !== null) {
|
||||||
|
var existing = sat.entries[editIdx];
|
||||||
|
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
|
||||||
sat.entries[editIdx] = entryData;
|
sat.entries[editIdx] = entryData;
|
||||||
} else {
|
} else {
|
||||||
|
entryData.id = "sat_" + Date.now().toString(36);
|
||||||
sat.entries.push(entryData);
|
sat.entries.push(entryData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,19 +287,16 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
// ── Preset change handler ─────────────────────────────────────────
|
// ── Preset change handler ─────────────────────────────────────────
|
||||||
function onPresetChange() {
|
function onPresetChange() {
|
||||||
if (!dom.preset || !dom.preset.value) return;
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
const parts = dom.preset.value.split("|");
|
var parts = dom.preset.value.split("|");
|
||||||
if (dom.name) dom.name.value = parts[0] || "";
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
if (dom.norad) dom.norad.value = parts[1] || "";
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Wire all events ───────────────────────────────────────────────
|
// ── Wire all events ───────────────────────────────────────────────
|
||||||
function wireEvents() {
|
function wireEvents() {
|
||||||
if (eventsWired) return;
|
|
||||||
eventsWired = true;
|
|
||||||
if (dom.enabled) {
|
if (dom.enabled) {
|
||||||
const enabledInput = dom.enabled;
|
|
||||||
dom.enabled.addEventListener("change", function () {
|
dom.enabled.addEventListener("change", function () {
|
||||||
if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none";
|
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
|
||||||
markDirty();
|
markDirty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -329,14 +312,10 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ────────────────────────────────────────────────────
|
// ── Public API ────────────────────────────────────────────────────
|
||||||
satSchedulerWindow.satScheduler = {
|
window.satScheduler = {
|
||||||
wireEvents: wireEvents,
|
wireEvents: wireEvents,
|
||||||
renderSection: renderSection,
|
renderSection: renderSection,
|
||||||
renderPassStatus: renderPassStatus,
|
renderPassStatus: renderPassStatus,
|
||||||
collectSatelliteConfig: collectSatelliteConfig,
|
collectSatelliteConfig: collectSatelliteConfig,
|
||||||
};
|
};
|
||||||
if (getBridge()) {
|
|
||||||
wireEvents();
|
|
||||||
renderSection();
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
+88
-122
@@ -2,26 +2,6 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore } from "./host.js";
|
|
||||||
|
|
||||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
|
||||||
|
|
||||||
type SatelliteView = "live" | "history" | "predictions";
|
|
||||||
interface SatelliteBridge {
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
|
||||||
clearSatMapOverlays?: () => void;
|
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
satShowOnMap?: (south: number, west: number, north: number, east: number) => void;
|
|
||||||
enableMapSourceFilter?: (source: string) => void;
|
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
|
||||||
}
|
|
||||||
const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|
||||||
|
|
||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// History view: filterable table of all decoded images
|
||||||
@@ -36,16 +16,16 @@ const satDom = {
|
|||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
filterInput: document.getElementById("sat-filter") as HTMLInputElement | null,
|
filterInput: document.getElementById("sat-filter"),
|
||||||
sortSelect: document.getElementById("sat-sort") as HTMLSelectElement | null,
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
typeFilter: document.getElementById("sat-type-filter") as HTMLSelectElement | null,
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
predFilter: document.getElementById("sat-pred-filter"),
|
||||||
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||||
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
predCategory: document.getElementById("sat-pred-category"),
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
@@ -54,30 +34,30 @@ const satDom = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
const SAT_PRED_PAGE_SIZE = 50;
|
const SAT_PRED_PAGE_SIZE = 50;
|
||||||
let satPredShowAll = false;
|
let satPredShowAll = false;
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
let satActiveView = "live"; // "live" | "history" | "predictions"
|
||||||
let satPredData: SatellitePass[] = [];
|
let satPredData = [];
|
||||||
let satPredFilterText = "";
|
let satPredFilterText = "";
|
||||||
let satPredMinEl = 0;
|
let satPredMinEl = 0;
|
||||||
let satPredCategory = "all";
|
let satPredCategory = "all";
|
||||||
let satPredSatCount = 0;
|
let satPredSatCount = 0;
|
||||||
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
let satPredCountdownTimer = null;
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view) {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
@@ -91,7 +71,7 @@ function switchSatView(view: SatelliteView): void {
|
|||||||
renderSatHistoryTable();
|
renderSatHistoryTable();
|
||||||
} else if (view === "predictions") {
|
} else if (view === "predictions") {
|
||||||
satPredShowAll = false;
|
satPredShowAll = false;
|
||||||
void loadSatPredictions();
|
loadSatPredictions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,19 +80,19 @@ function clearPredictionDom() {
|
|||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
}
|
}
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
window.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
let _lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function (update: SatelliteLiveUpdate) {
|
window.updateSatLiveState = function (update) {
|
||||||
if (!satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
const lrptOn = !!update.lrpt_decode_enabled;
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
if (lrptOn !== lastSatLrptOn) {
|
if (lrptOn !== _lastSatLrptOn) {
|
||||||
lastSatLrptOn = lrptOn;
|
_lastSatLrptOn = lrptOn;
|
||||||
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||||
if (satDom.status) {
|
if (satDom.status) {
|
||||||
@@ -134,7 +114,7 @@ function renderSatLatestCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const img = satImageHistory[0];
|
const img = satImageHistory[0];
|
||||||
if (!img) return;
|
const decoder = img._decoder || "unknown";
|
||||||
const typeName = "Meteor LRPT";
|
const typeName = "Meteor LRPT";
|
||||||
const satellite = img.satellite || "";
|
const satellite = img.satellite || "";
|
||||||
const channels = img.channels || img.channel_a || "";
|
const channels = img.channels || img.channel_a || "";
|
||||||
@@ -143,7 +123,7 @@ function renderSatLatestCard() {
|
|||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
|
||||||
const meta = [typeName];
|
let meta = [typeName];
|
||||||
if (satellite) meta.push(satellite);
|
if (satellite) meta.push(satellite);
|
||||||
if (channels) meta.push(channels);
|
if (channels) meta.push(channels);
|
||||||
meta.push(`${lines} ${unit}`);
|
meta.push(`${lines} ${unit}`);
|
||||||
@@ -163,7 +143,7 @@ function renderSatLatestCard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getSatFilteredHistory(): SatelliteImage[] {
|
function getSatFilteredHistory() {
|
||||||
let items = satImageHistory;
|
let items = satImageHistory;
|
||||||
|
|
||||||
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
@@ -188,10 +168,11 @@ function getSatFilteredHistory(): SatelliteImage[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSatHistoryRow(img: SatelliteImage): HTMLElement {
|
function renderSatHistoryRow(img) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "sat-history-row";
|
row.className = "sat-history-row";
|
||||||
|
|
||||||
|
const decoder = img._decoder || "unknown";
|
||||||
const typeName = "Meteor LRPT";
|
const typeName = "Meteor LRPT";
|
||||||
const typeClass = "sat-type-lrpt";
|
const typeClass = "sat-type-lrpt";
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
@@ -223,8 +204,8 @@ function renderSatHistoryTable() {
|
|||||||
if (!satDom.historyList) return;
|
if (!satDom.historyList) return;
|
||||||
const items = getSatFilteredHistory();
|
const items = getSatFilteredHistory();
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const image of items) {
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
fragment.appendChild(renderSatHistoryRow(image));
|
fragment.appendChild(renderSatHistoryRow(items[i]));
|
||||||
}
|
}
|
||||||
satDom.historyList.replaceChildren(fragment);
|
satDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
@@ -241,7 +222,7 @@ function renderSatHistoryTable() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Add image to history ────────────────────────────────────────────
|
// ── Add image to history ────────────────────────────────────────────
|
||||||
function addSatImage(img: SatelliteImage, decoder: "lrpt"): void {
|
function addSatImage(img, decoder) {
|
||||||
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||||
img._tsMs = tsMs;
|
img._tsMs = tsMs;
|
||||||
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -256,114 +237,102 @@ function addSatImage(img: SatelliteImage, decoder: "lrpt"): void {
|
|||||||
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleSatUi("sat-latest", () => { renderSatLatestCard(); });
|
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
||||||
if (satActiveView === "history") {
|
if (satActiveView === "history") {
|
||||||
scheduleSatUi("sat-history", () => { renderSatHistoryTable(); });
|
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Server callbacks ────────────────────────────────────────────────
|
// ── Server callbacks ────────────────────────────────────────────────
|
||||||
function onServerLrptProgress(msg: LrptProgress): void {
|
window.onServerLrptProgress = function (msg) {
|
||||||
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
|
if (satDom.status && msg.mcu_count > 0) {
|
||||||
satDom.status.textContent = `Receiving \u2014 ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
|
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function onServerLrptImage(msg: SatelliteImage): void {
|
window.onServerLrptImage = function (msg) {
|
||||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
addSatImage(msg, "lrpt");
|
addSatImage(msg, "lrpt");
|
||||||
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
|
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
||||||
satWindow.addSatMapOverlay(msg);
|
window.addSatMapOverlay(msg);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetSatHistoryView(): void {
|
window.resetSatHistoryView = function () {
|
||||||
satImageHistory = [];
|
satImageHistory = [];
|
||||||
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
renderSatLatestCard();
|
renderSatLatestCard();
|
||||||
renderSatHistoryTable();
|
renderSatHistoryTable();
|
||||||
satWindow.clearSatMapOverlays?.();
|
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneSatHistoryView(): void {
|
window.pruneSatHistoryView = function () {
|
||||||
renderSatHistoryTable();
|
renderSatHistoryTable();
|
||||||
renderSatLatestCard();
|
renderSatLatestCard();
|
||||||
}
|
};
|
||||||
|
|
||||||
satWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "lrpt_image",
|
|
||||||
onMessage: onServerLrptImage,
|
|
||||||
reset: resetSatHistoryView,
|
|
||||||
prune: pruneSatHistoryView,
|
|
||||||
});
|
|
||||||
satWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "lrpt_progress",
|
|
||||||
onMessage: onServerLrptProgress,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Toggle buttons ──────────────────────────────────────────────────
|
// ── Toggle buttons ──────────────────────────────────────────────────
|
||||||
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
|
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
await hostCore.postPath("/toggle_lrpt_decode");
|
await postPath("/toggle_lrpt_decode");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("LRPT toggle failed", e);
|
console.error("LRPT toggle failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
// ── Filter / sort event listeners ───────────────────────────────────
|
// ── Filter / sort event listeners ───────────────────────────────────
|
||||||
const satFilterInput = satDom.filterInput;
|
satDom.filterInput?.addEventListener("input", () => {
|
||||||
satFilterInput?.addEventListener("input", () => {
|
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
||||||
satFilterText = satFilterInput.value.trim().toUpperCase();
|
|
||||||
renderSatHistoryTable();
|
renderSatHistoryTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
satDom.sortSelect?.addEventListener("change", () => { renderSatHistoryTable(); });
|
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
satDom.typeFilter?.addEventListener("change", () => { renderSatHistoryTable(); });
|
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
|
|
||||||
// ── Settings: clear history ─────────────────────────────────────────
|
// ── Settings: clear history ─────────────────────────────────────────
|
||||||
document
|
document
|
||||||
.getElementById("settings-clear-sat-history")
|
.getElementById("settings-clear-sat-history")
|
||||||
?.addEventListener("click", () => { void (async () => {
|
?.addEventListener("click", async () => {
|
||||||
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
if (!confirm("Clear all satellite decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_lrpt_decode");
|
await postPath("/clear_lrpt_decode");
|
||||||
resetSatHistoryView();
|
window.resetSatHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Weather satellite history clear failed", e);
|
console.error("Weather satellite history clear failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
function azToCardinal(deg: number): string {
|
function azToCardinal(deg) {
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
return dirs[Math.round(deg / 45) % 8];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPredTime(ms: number): string {
|
function formatPredTime(ms) {
|
||||||
const d = new Date(ms);
|
const d = new Date(ms);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : "";
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
return `${day}${hh}:${mm}`;
|
return `${day}${hh}:${mm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPredDuration(s: number): string {
|
function formatPredDuration(s) {
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
return `${s}s`;
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdown(ms: number): string {
|
function formatCountdown(ms) {
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
const m = Math.floor(totalSec / 60);
|
const m = Math.floor(totalSec / 60);
|
||||||
const s = totalSec % 60;
|
const s = totalSec % 60;
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function elevationClass(deg: number): string {
|
function elevationClass(deg) {
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
return "sat-pred-el-low";
|
return "sat-pred-el-low";
|
||||||
@@ -377,8 +346,8 @@ function stopCountdownTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCountdownTimer(container: HTMLElement | null): void {
|
function startCountdownTimer(container) {
|
||||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : [];
|
||||||
if (countdownEls.length === 0) return;
|
if (countdownEls.length === 0) return;
|
||||||
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
satPredCountdownTimer = setInterval(() => {
|
||||||
@@ -389,7 +358,7 @@ function startCountdownTimer(container: HTMLElement | null): void {
|
|||||||
const n = Date.now();
|
const n = Date.now();
|
||||||
let anyActive = false;
|
let anyActive = false;
|
||||||
for (const el of countdownEls) {
|
for (const el of countdownEls) {
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
const los = parseInt(el.dataset.los, 10);
|
||||||
const rem = los - n;
|
const rem = los - n;
|
||||||
if (rem > 0) {
|
if (rem > 0) {
|
||||||
el.textContent = formatCountdown(rem);
|
el.textContent = formatCountdown(rem);
|
||||||
@@ -406,7 +375,7 @@ function startCountdownTimer(container: HTMLElement | null): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Predictions: row builders ───────────────────────────────────────
|
// ── Predictions: row builders ───────────────────────────────────────
|
||||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
function buildCurrentPassRow(pass, now) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "sat-pred-row-current";
|
row.className = "sat-pred-row-current";
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
@@ -422,7 +391,7 @@ function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
function buildUpcomingPassRow(pass) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "sat-pred-row";
|
row.className = "sat-pred-row";
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
@@ -437,7 +406,7 @@ function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Predictions: filter state ───────────────────────────────────────
|
// ── Predictions: filter state ───────────────────────────────────────
|
||||||
function getFilteredPredictions(): SatellitePass[] {
|
function getFilteredPredictions() {
|
||||||
let items = satPredData;
|
let items = satPredData;
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
@@ -449,26 +418,23 @@ function applyPredFilters() {
|
|||||||
renderSatPredictions(getFilteredPredictions());
|
renderSatPredictions(getFilteredPredictions());
|
||||||
}
|
}
|
||||||
|
|
||||||
const satPredictionFilter = satDom.predFilter;
|
satDom.predFilter?.addEventListener("input", () => {
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
applyPredFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
const satPredictionMinElevation = satDom.predMinEl;
|
satDom.predMinEl?.addEventListener("change", () => {
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
applyPredFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
const satPredictionCategory = satDom.predCategory;
|
satDom.predCategory?.addEventListener("change", () => {
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
satPredCategory = satDom.predCategory.value;
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
applyPredFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
function renderSatPredictions(passes, error) {
|
||||||
stopCountdownTimer();
|
stopCountdownTimer();
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -542,14 +508,14 @@ function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Predictions: data loading ───────────────────────────────────────
|
// ── Predictions: data loading ───────────────────────────────────────
|
||||||
async function loadSatPredictions(): Promise<void> {
|
async function loadSatPredictions() {
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/sat_passes");
|
const resp = await fetch("/sat_passes");
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const data = await resp.json() as SatellitePassResponse;
|
const data = await resp.json();
|
||||||
satPredSatCount = data.satellite_count || 0;
|
satPredSatCount = data.satellite_count || 0;
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
satPredData = [];
|
satPredData = [];
|
||||||
@@ -558,20 +524,20 @@ async function loadSatPredictions(): Promise<void> {
|
|||||||
satPredData = data.passes || [];
|
satPredData = data.passes || [];
|
||||||
renderSatPredictions(getFilteredPredictions());
|
renderSatPredictions(getFilteredPredictions());
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (e) {
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
window.satShowOnMap = function (south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof window.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
window.enableMapSourceFilter("sat");
|
||||||
}
|
}
|
||||||
const lat = (south + north) / 2;
|
const lat = (south + north) / 2;
|
||||||
const lon = (west + east) / 2;
|
const lon = (west + east) / 2;
|
||||||
if (satWindow.navigateToAprsMap) {
|
if (window.navigateToAprsMap) {
|
||||||
satWindow.navigateToAprsMap(lat, lon);
|
window.navigateToAprsMap(lat, lon);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
+350
-405
File diff suppressed because it is too large
Load Diff
+146
-171
@@ -2,74 +2,27 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
interface VirtualChannel {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
freq_hz: number;
|
|
||||||
mode: string;
|
|
||||||
bandwidth_hz?: number;
|
|
||||||
subscribers: number;
|
|
||||||
permanent: boolean;
|
|
||||||
}
|
|
||||||
interface ChannelsEvent { remote?: string | null; channels?: VirtualChannel[] }
|
|
||||||
interface SessionEvent { session_id?: string | null }
|
|
||||||
interface SchedulerReleaseState {
|
|
||||||
connected_sessions?: number;
|
|
||||||
released_sessions?: number;
|
|
||||||
all_released?: boolean;
|
|
||||||
current_session_released?: boolean;
|
|
||||||
}
|
|
||||||
// Callbacks the application publishes as documented transitional window
|
|
||||||
// properties; all other application state and services arrive through the
|
|
||||||
// typed host contract in ./host.ts.
|
|
||||||
interface VirtualChannelBridge {
|
|
||||||
renderRdsOverlays?: () => void;
|
|
||||||
refreshRdsUi?: () => void;
|
|
||||||
trx?: { modules?: { vchan?: VirtualChannelService } };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VirtualChannelService {
|
|
||||||
readonly channels: readonly VirtualChannel[];
|
|
||||||
readonly activeId: string | null;
|
|
||||||
activeChannel(): VirtualChannel | null;
|
|
||||||
applyCapabilities(capabilities: { filter_controls?: boolean } | null): void;
|
|
||||||
handleSession(data: string): void;
|
|
||||||
handleChannels(data: string): void;
|
|
||||||
isOnVirtual(): boolean;
|
|
||||||
interceptMode(mode: string): Promise<boolean>;
|
|
||||||
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
|
|
||||||
interceptFrequency(frequencyHz: number): boolean;
|
|
||||||
interceptFreqDisplay(): boolean;
|
|
||||||
takeSchedulerControl(): Promise<void>;
|
|
||||||
releaseToScheduler(): Promise<void>;
|
|
||||||
}
|
|
||||||
const vchanWindow = window as unknown as VirtualChannelBridge;
|
|
||||||
|
|
||||||
// --- Virtual Channels Plugin ---
|
// --- Virtual Channels Plugin ---
|
||||||
//
|
//
|
||||||
// Handles the `session` and `channels` SSE events emitted by /events and
|
// Handles the `session` and `channels` SSE events emitted by /events and
|
||||||
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
||||||
|
|
||||||
let vchanSessionId: string | null = null;
|
let vchanSessionId = null;
|
||||||
let vchanRigId: string | null = null;
|
let vchanRigId = null;
|
||||||
let vchanChannels: VirtualChannel[] = [];
|
let vchanChannels = [];
|
||||||
let vchanActiveId: string | null = null;
|
let vchanActiveId = null;
|
||||||
let schedulerReleaseState: SchedulerReleaseState | null = null;
|
let schedulerReleaseState = null;
|
||||||
let schedulerReleasePollTimer: ReturnType<typeof setInterval> | null = null;
|
let schedulerReleasePollTimer = null;
|
||||||
|
|
||||||
function vchanFmtFreq(hz: number): string {
|
function vchanFmtFreq(hz) {
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||||
return `${String(hz)}\u202fHz`;
|
return hz + "\u202fHz";
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulerReleaseSummaryText(state: SchedulerReleaseState | null): string {
|
function schedulerReleaseSummaryText(state) {
|
||||||
if (!state) return "Scheduler is controlling the rig.";
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
const connected = Number(state.connected_sessions) || 0;
|
const connected = Number(state.connected_sessions) || 0;
|
||||||
const released = Number(state.released_sessions) || 0;
|
const released = Number(state.released_sessions) || 0;
|
||||||
@@ -92,7 +45,7 @@ function schedulerReleaseSummaryText(state: SchedulerReleaseState | null): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function vchanRenderSchedulerRelease() {
|
function vchanRenderSchedulerRelease() {
|
||||||
const btn = document.getElementById("scheduler-release-btn") as HTMLButtonElement | null;
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
const status = document.getElementById("scheduler-release-status");
|
const status = document.getElementById("scheduler-release-status");
|
||||||
if (!btn || !status) return;
|
if (!btn || !status) return;
|
||||||
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
@@ -102,7 +55,7 @@ function vchanRenderSchedulerRelease() {
|
|||||||
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanPollSchedulerRelease(): Promise<void> {
|
async function vchanPollSchedulerRelease() {
|
||||||
if (!vchanSessionId) {
|
if (!vchanSessionId) {
|
||||||
schedulerReleaseState = null;
|
schedulerReleaseState = null;
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
@@ -111,7 +64,7 @@ async function vchanPollSchedulerRelease(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
schedulerReleaseState = await resp.json() as SchedulerReleaseState;
|
schedulerReleaseState = await resp.json();
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("scheduler release status failed", e);
|
console.error("scheduler release status failed", e);
|
||||||
@@ -122,12 +75,12 @@ function vchanStartSchedulerReleasePolling() {
|
|||||||
if (schedulerReleasePollTimer) {
|
if (schedulerReleasePollTimer) {
|
||||||
clearInterval(schedulerReleasePollTimer);
|
clearInterval(schedulerReleasePollTimer);
|
||||||
}
|
}
|
||||||
schedulerReleasePollTimer = setInterval(() => { void vchanPollSchedulerRelease(); }, 10000);
|
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanToggleSchedulerRelease() {
|
async function vchanToggleSchedulerRelease() {
|
||||||
if (!vchanSessionId) return;
|
if (!vchanSessionId) return;
|
||||||
const rigId = vchanRigId || hostState.lastActiveRigId || null;
|
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/scheduler-control", {
|
const resp = await fetch("/scheduler-control", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -135,7 +88,7 @@ async function vchanToggleSchedulerRelease() {
|
|||||||
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
|
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
schedulerReleaseState = await resp.json() as SchedulerReleaseState;
|
schedulerReleaseState = await resp.json();
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("scheduler release toggle failed", e);
|
console.error("scheduler release toggle failed", e);
|
||||||
@@ -152,46 +105,46 @@ async function vchanTakeSchedulerControl() {
|
|||||||
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
schedulerReleaseState = await resp.json() as SchedulerReleaseState;
|
schedulerReleaseState = await resp.json();
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("scheduler control takeover failed", e);
|
console.error("scheduler control takeover failed", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||||
|
|
||||||
// Called by app.js when the SSE `session` event arrives.
|
// Called by app.js when the SSE `session` event arrives.
|
||||||
function vchanHandleSession(data: string): void {
|
function vchanHandleSession(data) {
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(data) as SessionEvent;
|
const d = JSON.parse(data);
|
||||||
vchanSessionId = d.session_id || null;
|
vchanSessionId = d.session_id || null;
|
||||||
void vchanPollSchedulerRelease();
|
vchanPollSchedulerRelease();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("vchan: bad session event", e);
|
console.warn("vchan: bad session event", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by app.js when the SSE `channels` event arrives.
|
// Called by app.js when the SSE `channels` event arrives.
|
||||||
function vchanHandleChannels(data: string): void {
|
function vchanHandleChannels(data) {
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(data) as ChannelsEvent;
|
const d = JSON.parse(data);
|
||||||
vchanRigId = d.remote || null;
|
vchanRigId = d.remote || null;
|
||||||
vchanChannels = d.channels || [];
|
vchanChannels = d.channels || [];
|
||||||
const ids = new Set(vchanChannels.map(c => c.id));
|
const ids = new Set(vchanChannels.map(c => c.id));
|
||||||
const primaryChannel = vchanChannels[0];
|
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
|
||||||
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
|
||||||
// First channels event for this session — auto-subscribe to channel 0
|
// First channels event for this session — auto-subscribe to channel 0
|
||||||
// so we join the same tuned channel as other users on this rig.
|
// so we join the same tuned channel as other users on this rig.
|
||||||
// Use a direct subscribe (no scheduler control takeover) to avoid
|
// Use a direct subscribe (no scheduler control takeover) to avoid
|
||||||
// side-effects on initial connect.
|
// side-effects on initial connect.
|
||||||
void vchanAutoJoinPrimary(primaryChannel.id);
|
vchanAutoJoinPrimary(vchanChannels[0].id);
|
||||||
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
||||||
vchanActiveId = vchanChannels[0]?.id ?? null;
|
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||||
vchanReconnectAudio();
|
vchanReconnectAudio();
|
||||||
}
|
}
|
||||||
vchanRender();
|
vchanRender();
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
vchanWindow.renderRdsOverlays?.();
|
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("vchan: bad channels event", e);
|
console.warn("vchan: bad channels event", e);
|
||||||
}
|
}
|
||||||
@@ -220,13 +173,13 @@ function vchanRender() {
|
|||||||
del.title = "Delete channel";
|
del.title = "Delete channel";
|
||||||
del.addEventListener("click", e => {
|
del.addEventListener("click", e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void vchanDelete(ch.id);
|
vchanDelete(ch.id);
|
||||||
});
|
});
|
||||||
btn.appendChild(del);
|
btn.appendChild(del);
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
|
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
picker.appendChild(btn);
|
picker.appendChild(btn);
|
||||||
@@ -238,11 +191,13 @@ function vchanRender() {
|
|||||||
addBtn.className = "vchan-add";
|
addBtn.className = "vchan-add";
|
||||||
addBtn.textContent = "+";
|
addBtn.textContent = "+";
|
||||||
addBtn.title = "Allocate new virtual channel at current frequency";
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||||
addBtn.addEventListener("click", () => { void vchanAllocate(); });
|
addBtn.addEventListener("click", vchanAllocate);
|
||||||
picker.appendChild(addBtn);
|
picker.appendChild(addBtn);
|
||||||
|
|
||||||
vchanSyncAccentUI();
|
vchanSyncAccentUI();
|
||||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
|
}
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,10 +205,10 @@ async function vchanAllocate() {
|
|||||||
if (!vchanSessionId || !vchanRigId) return;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
|
||||||
// Use the last known rig frequency and mode as the starting point.
|
// Use the last known rig frequency and mode as the starting point.
|
||||||
const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
|
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
||||||
? hostState.lastFreqHz
|
? lastFreqHz
|
||||||
: 0;
|
: 0;
|
||||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
const modeEl = document.getElementById("mode");
|
||||||
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -267,7 +222,7 @@ async function vchanAllocate() {
|
|||||||
console.warn("vchan: allocate failed —", msg);
|
console.warn("vchan: allocate failed —", msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ch = await resp.json() as VirtualChannel;
|
const ch = await resp.json();
|
||||||
vchanActiveId = ch.id;
|
vchanActiveId = ch.id;
|
||||||
// The SSE `channels` event will trigger vchanRender(); optimistically
|
// The SSE `channels` event will trigger vchanRender(); optimistically
|
||||||
// mark active so the picker feels responsive even before the event arrives.
|
// mark active so the picker feels responsive even before the event arrives.
|
||||||
@@ -278,7 +233,7 @@ async function vchanAllocate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanDelete(channelId: string): Promise<void> {
|
async function vchanDelete(channelId) {
|
||||||
if (!vchanRigId) return;
|
if (!vchanRigId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
@@ -297,7 +252,7 @@ async function vchanDelete(channelId: string): Promise<void> {
|
|||||||
// Lightweight auto-join for initial connect: registers the session on
|
// Lightweight auto-join for initial connect: registers the session on
|
||||||
// channel 0 without taking scheduler control or reconnecting audio
|
// channel 0 without taking scheduler control or reconnecting audio
|
||||||
// (audio isn't started yet at this point).
|
// (audio isn't started yet at this point).
|
||||||
async function vchanAutoJoinPrimary(channelId: string): Promise<void> {
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
@@ -319,7 +274,7 @@ async function vchanAutoJoinPrimary(channelId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanSubscribe(channelId: string): Promise<void> {
|
async function vchanSubscribe(channelId) {
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
try {
|
try {
|
||||||
await vchanTakeSchedulerControl();
|
await vchanTakeSchedulerControl();
|
||||||
@@ -349,24 +304,26 @@ async function vchanSubscribe(channelId: string): Promise<void> {
|
|||||||
// - primary channel: /audio (no param)
|
// - primary channel: /audio (no param)
|
||||||
// Always updates _audioChannelOverride so that starting audio later
|
// Always updates _audioChannelOverride so that starting audio later
|
||||||
// connects to the correct channel. Only reconnects if RX audio is active.
|
// connects to the correct channel. Only reconnects if RX audio is active.
|
||||||
function vchanReconnectAudio(): void {
|
function vchanReconnectAudio() {
|
||||||
// Always update the override so startRxAudio picks up the right URL,
|
// Always update the override so startRxAudio picks up the right URL,
|
||||||
// even when audio isn't currently running.
|
// even when audio isn't currently running.
|
||||||
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
hostState.audioChannelOverride = ch?.id ?? null;
|
if (typeof _audioChannelOverride !== "undefined") {
|
||||||
if (!hostState.rxActive) return;
|
_audioChannelOverride = ch ? ch.id : null;
|
||||||
hostCore.stopRxAudio();
|
}
|
||||||
|
if (typeof rxActive === "undefined" || !rxActive) return;
|
||||||
|
if (typeof stopRxAudio === "function") stopRxAudio();
|
||||||
// Delay so the server has time to set up the per-channel encoder.
|
// Delay so the server has time to set up the per-channel encoder.
|
||||||
// The server-side audio_ws handler also polls for up to 2 s, so this
|
// The server-side audio_ws handler also polls for up to 2 s, so this
|
||||||
// just needs to be long enough for the WS upgrade to reach the server.
|
// just needs to be long enough for the WS upgrade to reach the server.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hostCore.startRxAudio();
|
if (typeof startRxAudio === "function") startRxAudio();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by app.js from applyCapabilities().
|
// Called by app.js from applyCapabilities().
|
||||||
// Shows the channel picker only for SDR rigs.
|
// Shows the channel picker only for SDR rigs.
|
||||||
function vchanApplyCapabilities(caps: { filter_controls?: boolean } | null): void {
|
function vchanApplyCapabilities(caps) {
|
||||||
const picker = document.getElementById("vchan-picker");
|
const picker = document.getElementById("vchan-picker");
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||||
@@ -378,12 +335,12 @@ function vchanApplyCapabilities(caps: { filter_controls?: boolean } | null): voi
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Returns true when the active channel is a non-primary (virtual) channel.
|
// Returns true when the active channel is a non-primary (virtual) channel.
|
||||||
function vchanIsOnVirtual(): boolean {
|
function vchanIsOnVirtual() {
|
||||||
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
return vchanActiveId !== vchanChannels[0]?.id;
|
return vchanActiveId !== vchanChannels[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vchanActiveChannel(): VirtualChannel | null {
|
function vchanActiveChannel() {
|
||||||
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,15 +348,19 @@ function vchanActiveChannel(): VirtualChannel | null {
|
|||||||
function vchanUpdateFreqDisplay() {
|
function vchanUpdateFreqDisplay() {
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
const el = document.getElementById("freq") as HTMLInputElement | null;
|
const el = document.getElementById("freq");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
|
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
||||||
|
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
||||||
|
} else {
|
||||||
|
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the mode picker to the active virtual channel's mode.
|
// Sync the mode picker to the active virtual channel's mode.
|
||||||
// Called whenever the active channel changes or the channel list is refreshed.
|
// Called whenever the active channel changes or the channel list is refreshed.
|
||||||
function vchanSyncModeDisplay() {
|
function vchanSyncModeDisplay() {
|
||||||
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
|
const modeEl = document.getElementById("mode");
|
||||||
if (!modeEl) return;
|
if (!modeEl) return;
|
||||||
if (vchanIsOnVirtual()) {
|
if (vchanIsOnVirtual()) {
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
@@ -407,21 +368,23 @@ function vchanSyncModeDisplay() {
|
|||||||
}
|
}
|
||||||
// When on primary channel, app.js rig-state updates handle the picker.
|
// When on primary channel, app.js rig-state updates handle the picker.
|
||||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
if (typeof hostState.lastModeName === "string") {
|
if (typeof lastModeName !== "undefined") {
|
||||||
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
|
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
||||||
hostCore.setJogDivisor(10);
|
if (typeof setJogDivisor === "function") setJogDivisor(10);
|
||||||
hostCore.resetRdsDisplay();
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
|
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
|
||||||
hostCore.resetRdsDisplay();
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
}
|
}
|
||||||
hostState.lastModeName = modeUpper;
|
lastModeName = modeUpper;
|
||||||
}
|
}
|
||||||
hostCore.updateWfmControls();
|
if (typeof updateWfmControls === "function") updateWfmControls();
|
||||||
hostCore.updateSdrSquelchControlVisibility();
|
if (typeof updateSdrSquelchControlVisibility === "function") {
|
||||||
if (vchanWindow.refreshRdsUi) {
|
updateSdrSquelchControlVisibility();
|
||||||
vchanWindow.refreshRdsUi();
|
}
|
||||||
} else {
|
if (typeof refreshRdsUi === "function") {
|
||||||
hostCore.positionRdsPsOverlay();
|
refreshRdsUi();
|
||||||
|
} else if (typeof positionRdsPsOverlay === "function") {
|
||||||
|
positionRdsPsOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,16 +393,21 @@ function vchanSyncBwDisplay() {
|
|||||||
if (!vchanIsOnVirtual()) return;
|
if (!vchanIsOnVirtual()) return;
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
const bwEl = document.getElementById("spectrum-bw-input") as HTMLInputElement | null;
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
if (!bwEl) return;
|
if (!bwEl) return;
|
||||||
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
||||||
let bwHz = ch.bandwidth_hz || 0;
|
let bwHz = ch.bandwidth_hz || 0;
|
||||||
if (bwHz === 0) {
|
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
|
||||||
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
|
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
}
|
}
|
||||||
if (bwHz > 0) {
|
if (bwHz > 0) {
|
||||||
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||||
hostState.currentBandwidthHz = bwHz;
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
|
currentBandwidthHz = bwHz;
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
} else {
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,39 +422,47 @@ function vchanSyncAccentUI() {
|
|||||||
vchanUpdateFreqDisplay();
|
vchanUpdateFreqDisplay();
|
||||||
vchanSyncModeDisplay();
|
vchanSyncModeDisplay();
|
||||||
vchanSyncBwDisplay();
|
vchanSyncBwDisplay();
|
||||||
} else {
|
} else if (typeof _origRefreshFreqDisplay === "function") {
|
||||||
hostCore.refreshFreqDisplay();
|
_origRefreshFreqDisplay();
|
||||||
|
}
|
||||||
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
}
|
}
|
||||||
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function vchanSetChannelFreq(freqHz: number): void {
|
// Saved reference to the original refreshFreqDisplay from app.js.
|
||||||
|
let _origRefreshFreqDisplay = null;
|
||||||
|
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
// Validate against current SDR capture window.
|
// Validate against current SDR capture window.
|
||||||
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
|
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
|
||||||
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
|
lastSpectrumData.sample_rate > 0) {
|
||||||
const center = hostState.lastSpectrumData.center_hz;
|
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
|
||||||
|
const center = Number(lastSpectrumData.center_hz);
|
||||||
if (Math.abs(freqHz - center) > halfSpan) {
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
hostCore.showHint(
|
if (typeof showHint === "function") {
|
||||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
showHint(
|
||||||
3000
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
);
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
||||||
void vchanTakeSchedulerControl();
|
vchanTakeSchedulerControl();
|
||||||
void fetch(
|
fetch(
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
||||||
}
|
}
|
||||||
).catch((error: unknown) => { console.error("vchan: set freq error", error); });
|
).catch(e => console.error("vchan: set freq error", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanSetChannelBandwidth(bwHz: number): Promise<void> {
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
try {
|
try {
|
||||||
await vchanTakeSchedulerControl();
|
await vchanTakeSchedulerControl();
|
||||||
@@ -504,7 +480,7 @@ async function vchanSetChannelBandwidth(bwHz: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function vchanSetChannelMode(mode: string): Promise<void> {
|
async function vchanSetChannelMode(mode) {
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
try {
|
try {
|
||||||
await vchanTakeSchedulerControl();
|
await vchanTakeSchedulerControl();
|
||||||
@@ -525,66 +501,65 @@ async function vchanSetChannelMode(mode: string): Promise<void> {
|
|||||||
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
||||||
// sending /set_mode to the server. Returns true if the change was handled
|
// sending /set_mode to the server. Returns true if the change was handled
|
||||||
// by the virtual channel (caller should skip the server request).
|
// by the virtual channel (caller should skip the server request).
|
||||||
async function vchanInterceptMode(mode: string): Promise<boolean> {
|
window.vchanInterceptMode = async function(mode) {
|
||||||
if (!vchanIsOnVirtual()) return false;
|
if (!vchanIsOnVirtual()) return false;
|
||||||
await vchanSetChannelMode(mode);
|
await vchanSetChannelMode(mode);
|
||||||
return true;
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
||||||
// server. Returns true if the change was handled by the virtual channel.
|
// server. Returns true if the change was handled by the virtual channel.
|
||||||
async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
|
window.vchanInterceptBandwidth = async function(bwHz) {
|
||||||
if (!vchanIsOnVirtual()) return false;
|
if (!vchanIsOnVirtual()) return false;
|
||||||
await vchanSetChannelBandwidth(bwHz);
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
// Called by setRigFrequency before it posts /set_freq. When a non-primary
|
|
||||||
// channel is active the change belongs to the channel API instead of the rig,
|
|
||||||
// so this applies the optimistic local update and reports the tune as handled.
|
|
||||||
function vchanInterceptFrequency(freqHz: number): boolean {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
const targetHz = Math.round(freqHz);
|
|
||||||
hostCore.armOptimisticFrequency(targetHz);
|
|
||||||
hostCore.applyLocalTunedFrequency(targetHz);
|
|
||||||
vchanSetChannelFreq(freqHz);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by the application's refreshFreqDisplay. While a virtual channel is
|
|
||||||
// active the field belongs to that channel, so report the render as handled.
|
|
||||||
function vchanInterceptFreqDisplay(): boolean {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
vchanUpdateFreqDisplay();
|
|
||||||
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,
|
|
||||||
interceptFrequency: vchanInterceptFrequency,
|
|
||||||
interceptFreqDisplay: vchanInterceptFreqDisplay,
|
|
||||||
takeSchedulerControl: vchanTakeSchedulerControl,
|
|
||||||
releaseToScheduler: vchanToggleSchedulerRelease,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||||
|
// frequency changes are redirected to the active virtual channel instead of
|
||||||
|
// the server when on a non-primary channel.
|
||||||
|
(function() {
|
||||||
|
const _orig = window.setRigFrequency;
|
||||||
|
window.setRigFrequency = function(freqHz) {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
// Optimistic local update first, then fire-and-forget channel API.
|
||||||
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
|
++_freqOptimisticSeq;
|
||||||
|
_freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
|
applyLocalTunedFrequency(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||||
|
vchanTakeSchedulerControl();
|
||||||
|
if (typeof _orig === "function") _orig(freqHz);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
(function initSchedulerReleaseControl() {
|
(function initSchedulerReleaseControl() {
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
void vchanToggleSchedulerRelease();
|
vchanToggleSchedulerRelease();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
vchanStartSchedulerReleasePolling();
|
vchanStartSchedulerReleasePolling();
|
||||||
vchanRenderSchedulerRelease();
|
vchanRenderSchedulerRelease();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
||||||
|
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
||||||
|
// constantly overwrite it with channel 0's freq).
|
||||||
|
(function() {
|
||||||
|
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
||||||
|
window.refreshFreqDisplay = function() {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
||||||
|
};
|
||||||
|
})();
|
||||||
+104
-127
@@ -2,101 +2,54 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore } from "./host.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
|
||||||
|
|
||||||
interface VdesMessage {
|
|
||||||
rig_id?: string | null;
|
|
||||||
message_type?: number;
|
|
||||||
bit_len?: number;
|
|
||||||
raw_bytes?: number[];
|
|
||||||
lat?: number | null;
|
|
||||||
lon?: number | null;
|
|
||||||
vessel_name?: string | null;
|
|
||||||
callsign?: string | null;
|
|
||||||
destination?: string | null;
|
|
||||||
message_label?: string | null;
|
|
||||||
session_id?: number | null;
|
|
||||||
source_id?: number | null;
|
|
||||||
destination_id?: number | null;
|
|
||||||
data_count?: number | null;
|
|
||||||
asm_identifier?: number | null;
|
|
||||||
ack_nack_mask?: number | null;
|
|
||||||
channel_quality?: number | null;
|
|
||||||
payload_preview?: string | null;
|
|
||||||
link_id?: number | null;
|
|
||||||
sync_score?: number | null;
|
|
||||||
sync_errors?: number | null;
|
|
||||||
phase_rotation?: number | null;
|
|
||||||
fec_state?: string | null;
|
|
||||||
ts_ms?: number | null;
|
|
||||||
_tsMs?: number;
|
|
||||||
_ts?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VdesBridge {
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
|
||||||
vdesMapAddPoint?: (message: VdesMessage) => void;
|
|
||||||
updateVdesBar?: () => void;
|
|
||||||
clearVdesBar?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vdesWindow = window as unknown as VdesBridge;
|
|
||||||
const escapeVdesHtml = (input: string): string => hostCore.escapeMapHtml(input);
|
|
||||||
|
|
||||||
// --- VDES Decoder Plugin (server-side decode) ---
|
// --- VDES Decoder Plugin (server-side decode) ---
|
||||||
const vdesStatus = document.getElementById("vdes-status");
|
const vdesStatus = document.getElementById("vdes-status");
|
||||||
const vdesMessagesEl = document.getElementById("vdes-messages");
|
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
const vdesFilterInput = document.getElementById("vdes-filter") as HTMLInputElement | null;
|
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
let vdesFilterText = "";
|
let vdesFilterText = "";
|
||||||
let vdesMessageHistory: VdesMessage[] = [];
|
let vdesMessageHistory = [];
|
||||||
|
|
||||||
function currentVdesHistoryRetentionMs(): number {
|
function currentVdesHistoryRetentionMs() {
|
||||||
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function"
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
? vdesWindow.getDecodeHistoryRetentionMs()
|
? window.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneVdesMessageHistory() {
|
function pruneVdesMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleVdesUi(key: string, job: () => void): void {
|
function scheduleVdesUi(key, job) {
|
||||||
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
vdesWindow.trxScheduleUiFrameJob(key, job);
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleVdesHistoryRender() {
|
function scheduleVdesHistoryRender() {
|
||||||
scheduleVdesUi("vdes-history", () => { renderVdesHistory(); });
|
scheduleVdesUi("vdes-history", () => renderVdesHistory());
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleVdesBarUpdate() {
|
function scheduleVdesBarUpdate() {
|
||||||
scheduleVdesUi("vdes-bar", () => { updateVdesBar(); });
|
scheduleVdesUi("vdes-bar", () => updateVdesBar());
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentVdesCenterText() {
|
function currentVdesCenterText() {
|
||||||
const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
const hz = raw ? Number(raw) : 0;
|
const hz = raw ? Number(raw) : 0;
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vdesAgeText(tsMs: number | undefined): string {
|
function vdesAgeText(tsMs) {
|
||||||
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return "just now";
|
||||||
@@ -107,11 +60,11 @@ function vdesAgeText(tsMs: number | undefined): string {
|
|||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function vdesHexPreview(rawBytes: number[] | undefined): string {
|
function vdesHexPreview(rawBytes) {
|
||||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
return rawBytes
|
return rawBytes
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
.map((value) => value.toString(16).padStart(2, "0"))
|
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -131,7 +84,7 @@ function updateVdesSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyVdesFilterToRow(row: HTMLElement): void {
|
function applyVdesFilterToRow(row) {
|
||||||
if (!vdesFilterText) {
|
if (!vdesFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
return;
|
return;
|
||||||
@@ -140,7 +93,12 @@ function applyVdesFilterToRow(row: HTMLElement): void {
|
|||||||
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVdesRow(msg: VdesMessage): HTMLElement {
|
function applyVdesFilterToAll() {
|
||||||
|
if (!vdesMessagesEl) return;
|
||||||
|
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVdesRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "vdes-message";
|
row.className = "vdes-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
@@ -192,32 +150,32 @@ function renderVdesRow(msg: VdesMessage): HTMLElement {
|
|||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
`<div class="vdes-row-head">` +
|
`<div class="vdes-row-head">` +
|
||||||
`<span class="vdes-time">${ts}</span>` +
|
`<span class="vdes-time">${ts}</span>` +
|
||||||
`<span class="vdes-call">${escapeVdesHtml(title)}</span>` +
|
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||||
`<span class="vdes-badge">${escapeVdesHtml(label)}</span>` +
|
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||||
(labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") +
|
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
|
||||||
(linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") +
|
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
|
||||||
(srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") +
|
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
|
||||||
(dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") +
|
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
|
||||||
(syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") +
|
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
|
||||||
(phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") +
|
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
|
||||||
`<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span>` +
|
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="vdes-row-meta">` +
|
`<div class="vdes-row-meta">` +
|
||||||
`<span>${escapeVdesHtml(currentVdesCenterText())}</span>` +
|
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||||
`<span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||||
(sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") +
|
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
|
||||||
(asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") +
|
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
|
||||||
(countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") +
|
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
|
||||||
(ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") +
|
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
|
||||||
(cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") +
|
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
|
||||||
(info ? `<span>${escapeVdesHtml(info)}</span>` : "") +
|
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||||
(fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") +
|
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
|
||||||
`<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="vdes-row-detail">` +
|
`<div class="vdes-row-detail">` +
|
||||||
(previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") +
|
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
|
||||||
(previewText ? `<span>·</span>` : "") +
|
(previewText ? `<span>·</span>` : "") +
|
||||||
`<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span>` +
|
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
applyVdesFilterToRow(row);
|
applyVdesFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
@@ -226,9 +184,9 @@ function renderVdesRow(msg: VdesMessage): HTMLElement {
|
|||||||
function updateVdesBar() {
|
function updateVdesBar() {
|
||||||
if (!vdesBarOverlay) return;
|
if (!vdesBarOverlay) return;
|
||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -238,19 +196,19 @@ function updateVdesBar() {
|
|||||||
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
const label = escapeVdesHtml(msg.callsign || "VDES");
|
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||||
const title = escapeVdesHtml(msg.vessel_name || "Burst");
|
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||||
const detail = [
|
const detail = [
|
||||||
`${msg.bit_len || 0} bits`,
|
`${msg.bit_len || 0} bits`,
|
||||||
msg.message_label ? escapeVdesHtml(msg.message_label) : null,
|
msg.message_label ? escapeMapHtml(msg.message_label) : null,
|
||||||
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : 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.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||||
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_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.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.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
msg.destination ? escapeVdesHtml(msg.destination) : null,
|
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||||
escapeVdesHtml(vdesAgeText(msg._tsMs)),
|
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -259,17 +217,17 @@ function updateVdesBar() {
|
|||||||
vdesBarOverlay.innerHTML = html;
|
vdesBarOverlay.innerHTML = html;
|
||||||
vdesBarOverlay.style.display = "flex";
|
vdesBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
vdesWindow.updateVdesBar = updateVdesBar;
|
window.updateVdesBar = updateVdesBar;
|
||||||
vdesWindow.clearVdesBar = function() {
|
window.clearVdesBar = function() {
|
||||||
resetVdesHistoryView();
|
window.resetVdesHistoryView();
|
||||||
};
|
};
|
||||||
|
|
||||||
function resetVdesHistoryView(): void {
|
window.resetVdesHistoryView = function() {
|
||||||
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
vdesMessageHistory = [];
|
vdesMessageHistory = [];
|
||||||
updateVdesBar();
|
updateVdesBar();
|
||||||
renderVdesHistory();
|
renderVdesHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
function renderVdesHistory() {
|
function renderVdesHistory() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
@@ -278,14 +236,14 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addVdesMessage(msg: VdesMessage): void {
|
function addVdesMessage(msg) {
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
msg._tsMs = tsMs;
|
msg._tsMs = tsMs;
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -300,17 +258,39 @@ function addVdesMessage(msg: VdesMessage): void {
|
|||||||
scheduleVdesHistoryRender();
|
scheduleVdesHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
function normalizeServerVdesMessage(msg) {
|
||||||
return {
|
return {
|
||||||
...msg,
|
|
||||||
rig_id: msg.rig_id || null,
|
rig_id: msg.rig_id || null,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
bit_len: msg.bit_len,
|
||||||
|
raw_bytes: msg.raw_bytes,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
message_label: msg.message_label,
|
||||||
|
session_id: msg.session_id,
|
||||||
|
source_id: msg.source_id,
|
||||||
|
destination_id: msg.destination_id,
|
||||||
|
data_count: msg.data_count,
|
||||||
|
asm_identifier: msg.asm_identifier,
|
||||||
|
ack_nack_mask: msg.ack_nack_mask,
|
||||||
|
channel_quality: msg.channel_quality,
|
||||||
|
payload_preview: msg.payload_preview,
|
||||||
|
link_id: msg.link_id,
|
||||||
|
sync_score: msg.sync_score,
|
||||||
|
sync_errors: msg.sync_errors,
|
||||||
|
phase_rotation: msg.phase_rotation,
|
||||||
|
fec_state: msg.fec_state,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
window.onServerVdesBatch = function(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const normalized: VdesMessage[] = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
@@ -320,8 +300,8 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
window.vdesMapAddPoint(next);
|
||||||
}
|
}
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -330,17 +310,21 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
|
|||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
scheduleVdesBarUpdate();
|
scheduleVdesBarUpdate();
|
||||||
scheduleVdesHistoryRender();
|
scheduleVdesHistoryRender();
|
||||||
}
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => {
|
window.restoreVdesHistory = function(messages) {
|
||||||
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
window.onServerVdesBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Clear all VDES decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_vdes_decode");
|
await postPath("/clear_vdes_decode");
|
||||||
resetVdesHistoryView();
|
window.resetVdesHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("VDES history clear failed", e);
|
console.error("VDES history clear failed", e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
|
|
||||||
if (vdesFilterInput) {
|
if (vdesFilterInput) {
|
||||||
vdesFilterInput.addEventListener("input", () => {
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
@@ -349,27 +333,20 @@ if (vdesFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
window.onServerVdes = function(msg) {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
window.vdesMapAddPoint(next);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneVdesHistoryView(): void {
|
window.pruneVdesHistoryView = function() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
updateVdesBar();
|
updateVdesBar();
|
||||||
renderVdesHistory();
|
renderVdesHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<VdesMessage>({
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
|
||||||
id: "vdes",
|
|
||||||
onMessage: onServerVdes,
|
|
||||||
onBatch: onServerVdesBatch,
|
|
||||||
restore: onServerVdesBatch,
|
|
||||||
reset: resetVdesHistoryView,
|
|
||||||
prune: pruneVdesHistoryView,
|
|
||||||
});
|
|
||||||
+106
-165
@@ -2,41 +2,6 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
import { hostCore } from "./host.js";
|
|
||||||
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
interface WefaxImage {
|
|
||||||
ts_ms?: number;
|
|
||||||
ioc?: number;
|
|
||||||
lpm?: number;
|
|
||||||
line_count?: number;
|
|
||||||
path?: string;
|
|
||||||
_tsMs?: number;
|
|
||||||
_ts?: string;
|
|
||||||
_dataUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WefaxProgress {
|
|
||||||
state?: string;
|
|
||||||
line_data?: string;
|
|
||||||
line_count?: number;
|
|
||||||
pixels_per_line?: number;
|
|
||||||
ioc?: number;
|
|
||||||
lpm?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WefaxBridge {
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
|
||||||
syncWefaxToggle?: (enabled: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wefaxWindow = window as unknown as WefaxBridge & PluginRuntimeWindow;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// wefax.js — WEFAX decoder plugin for trx-frontend-http
|
// wefax.js — WEFAX decoder plugin for trx-frontend-http
|
||||||
// Live view: decoder state, live canvas, latest image card
|
// Live view: decoder state, live canvas, latest image card
|
||||||
@@ -44,18 +9,18 @@ const wefaxWindow = window as unknown as WefaxBridge & PluginRuntimeWindow;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const wefaxDom = {
|
var wefaxDom = {
|
||||||
status: document.getElementById('wefax-status'),
|
status: document.getElementById('wefax-status'),
|
||||||
liveView: document.getElementById('wefax-live-view'),
|
liveView: document.getElementById('wefax-live-view'),
|
||||||
historyView: document.getElementById('wefax-history-view'),
|
historyView: document.getElementById('wefax-history-view'),
|
||||||
liveContainer: document.getElementById('wefax-live-container'),
|
liveContainer: document.getElementById('wefax-live-container'),
|
||||||
liveInfo: document.getElementById('wefax-live-info'),
|
liveInfo: document.getElementById('wefax-live-info'),
|
||||||
liveCanvas: document.getElementById('wefax-live-canvas') as HTMLCanvasElement | null,
|
liveCanvas: document.getElementById('wefax-live-canvas'),
|
||||||
liveLatest: document.getElementById('wefax-live-latest'),
|
liveLatest: document.getElementById('wefax-live-latest'),
|
||||||
historyList: document.getElementById('wefax-history-list'),
|
historyList: document.getElementById('wefax-history-list'),
|
||||||
historyCount: document.getElementById('wefax-history-count'),
|
historyCount: document.getElementById('wefax-history-count'),
|
||||||
filterInput: document.getElementById('wefax-filter') as HTMLInputElement | null,
|
filterInput: document.getElementById('wefax-filter'),
|
||||||
sortSelect: document.getElementById('wefax-sort') as HTMLSelectElement | null,
|
sortSelect: document.getElementById('wefax-sort'),
|
||||||
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
|
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
|
||||||
clearBtn: document.getElementById('wefax-clear-btn'),
|
clearBtn: document.getElementById('wefax-clear-btn'),
|
||||||
viewLiveBtn: document.getElementById('wefax-view-live'),
|
viewLiveBtn: document.getElementById('wefax-view-live'),
|
||||||
@@ -63,25 +28,25 @@ const wefaxDom = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let wefaxImageHistory: WefaxImage[] = [];
|
var wefaxImageHistory = [];
|
||||||
const WEFAX_MAX_IMAGES = 100;
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
let wefaxLiveCtx: CanvasRenderingContext2D | null = null;
|
var wefaxLiveCtx = null;
|
||||||
let wefaxLiveLineCount = 0;
|
var wefaxLiveLineCount = 0;
|
||||||
let wefaxLivePixelsPerLine = 1809;
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
let wefaxActiveView: 'live' | 'history' = 'live';
|
var wefaxActiveView = 'live';
|
||||||
let wefaxFilterText = '';
|
var wefaxFilterText = '';
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
function currentWefaxHistoryRetentionMs(): number {
|
function currentWefaxHistoryRetentionMs() {
|
||||||
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1000;
|
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneWefaxHistory() {
|
function pruneWefaxHistory() {
|
||||||
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
|
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: unknown): string {
|
function escapeHtml(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
@@ -89,16 +54,16 @@ function escapeHtml(s: unknown): string {
|
|||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleWefaxUi(key: string, job: () => void): void {
|
function scheduleWefaxUi(key, job) {
|
||||||
if (typeof wefaxWindow.trxScheduleUiFrameJob === 'function') {
|
if (typeof window.trxScheduleUiFrameJob === 'function') {
|
||||||
wefaxWindow.trxScheduleUiFrameJob(key, job);
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchWefaxView(view: 'live' | 'history'): void {
|
function switchWefaxView(view) {
|
||||||
wefaxActiveView = view;
|
wefaxActiveView = view;
|
||||||
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
|
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
|
||||||
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
|
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
|
||||||
@@ -116,39 +81,33 @@ if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', func
|
|||||||
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
|
||||||
|
|
||||||
// ── Live canvas rendering ───────────────────────────────────────────
|
// ── Live canvas rendering ───────────────────────────────────────────
|
||||||
function resetLiveCanvas(pixelsPerLine: number): void {
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
const canvas = wefaxDom.liveCanvas;
|
|
||||||
if (!canvas) return;
|
|
||||||
wefaxLivePixelsPerLine = pixelsPerLine;
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
wefaxLiveLineCount = 0;
|
wefaxLiveLineCount = 0;
|
||||||
canvas.width = pixelsPerLine;
|
wefaxDom.liveCanvas.width = pixelsPerLine;
|
||||||
canvas.height = 800;
|
wefaxDom.liveCanvas.height = 800;
|
||||||
wefaxLiveCtx = canvas.getContext('2d');
|
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
|
||||||
if (!wefaxLiveCtx) return;
|
|
||||||
wefaxLiveCtx.fillStyle = '#000';
|
wefaxLiveCtx.fillStyle = '#000';
|
||||||
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
|
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintLine(lineBytes: Uint8Array): void {
|
function paintLine(lineBytes) {
|
||||||
const canvas = wefaxDom.liveCanvas;
|
if (!wefaxLiveCtx) return;
|
||||||
if (!wefaxLiveCtx || !canvas) return;
|
var y = wefaxLiveLineCount;
|
||||||
const y = wefaxLiveLineCount;
|
|
||||||
|
|
||||||
if (y >= canvas.height) {
|
if (y >= wefaxDom.liveCanvas.height) {
|
||||||
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
|
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
canvas.height *= 2;
|
wefaxDom.liveCanvas.height *= 2;
|
||||||
wefaxLiveCtx = canvas.getContext('2d');
|
|
||||||
if (!wefaxLiveCtx) return;
|
|
||||||
wefaxLiveCtx.putImageData(old, 0, 0);
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = wefaxLivePixelsPerLine;
|
var w = wefaxLivePixelsPerLine;
|
||||||
const imgData = wefaxLiveCtx.createImageData(w, 1);
|
var imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
const d = imgData.data;
|
var d = imgData.data;
|
||||||
for (let x = 0; x < w; x++) {
|
for (var x = 0; x < w; x++) {
|
||||||
const v = lineBytes[x] ?? 0;
|
var v = x < lineBytes.length ? lineBytes[x] : 0;
|
||||||
const i = x * 4;
|
var i = x * 4;
|
||||||
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
||||||
}
|
}
|
||||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
@@ -164,24 +123,23 @@ function renderWefaxLatestCard() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = wefaxImageHistory[0];
|
var img = wefaxImageHistory[0];
|
||||||
if (!img) return;
|
var ts = img._ts || '--';
|
||||||
const ts = img._ts || '--';
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
var meta = [
|
||||||
const meta = [
|
img.ioc + ' IOC',
|
||||||
`${String(img.ioc ?? '--')} IOC`,
|
img.lpm + ' LPM',
|
||||||
`${String(img.lpm ?? '--')} LPM`,
|
img.line_count + ' lines',
|
||||||
`${String(img.line_count ?? 0)} lines`,
|
date + ' ' + ts,
|
||||||
`${date} ${ts}`,
|
|
||||||
].join(' \u00b7 ');
|
].join(' \u00b7 ');
|
||||||
|
|
||||||
const imgSrc = img._dataUrl
|
var imgSrc = img._dataUrl
|
||||||
? img._dataUrl
|
? img._dataUrl
|
||||||
: img.path
|
: img.path
|
||||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let html = '<div class="sat-latest-card">';
|
var html = '<div class="sat-latest-card">';
|
||||||
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
|
||||||
if (imgSrc) {
|
if (imgSrc) {
|
||||||
@@ -193,11 +151,11 @@ function renderWefaxLatestCard() {
|
|||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
var items = wefaxImageHistory;
|
||||||
|
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function (i) {
|
items = items.filter(function (i) {
|
||||||
const haystack = [
|
var haystack = [
|
||||||
String(i.ioc || ''),
|
String(i.ioc || ''),
|
||||||
String(i.lpm || ''),
|
String(i.lpm || ''),
|
||||||
String(i.line_count || ''),
|
String(i.line_count || ''),
|
||||||
@@ -206,28 +164,28 @@ function getWefaxFilteredHistory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
|
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
|
||||||
if (sortVal === 'oldest') items = items.slice().reverse();
|
if (sortVal === 'oldest') items = items.slice().reverse();
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWefaxHistoryRow(img: WefaxImage): HTMLElement {
|
function renderWefaxHistoryRow(img) {
|
||||||
const row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'sat-history-row';
|
row.className = 'sat-history-row';
|
||||||
|
|
||||||
const ts = img._ts || '--';
|
var ts = img._ts || '--';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||||
const ioc = img.ioc || '--';
|
var ioc = img.ioc || '--';
|
||||||
const lpm = img.lpm || '--';
|
var lpm = img.lpm || '--';
|
||||||
const lines = img.line_count || 0;
|
var lines = img.line_count || 0;
|
||||||
|
|
||||||
const imgSrc = img._dataUrl
|
var imgSrc = img._dataUrl
|
||||||
? img._dataUrl
|
? img._dataUrl
|
||||||
: img.path
|
: img.path
|
||||||
? '/images/' + escapeHtml(img.path.split('/').pop())
|
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||||
: null;
|
: null;
|
||||||
const link = imgSrc
|
var link = imgSrc
|
||||||
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
|
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
@@ -235,7 +193,7 @@ function renderWefaxHistoryRow(img: WefaxImage): HTMLElement {
|
|||||||
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
|
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
|
||||||
'<span>' + escapeHtml(String(ioc)) + '</span>',
|
'<span>' + escapeHtml(String(ioc)) + '</span>',
|
||||||
'<span>' + escapeHtml(String(lpm)) + '</span>',
|
'<span>' + escapeHtml(String(lpm)) + '</span>',
|
||||||
`<span>${String(lines)}</span>`,
|
'<span>' + lines + '</span>',
|
||||||
'<span>' + link + '</span>',
|
'<span>' + link + '</span>',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
@@ -245,28 +203,28 @@ function renderWefaxHistoryRow(img: WefaxImage): HTMLElement {
|
|||||||
function renderWefaxHistoryTable() {
|
function renderWefaxHistoryTable() {
|
||||||
if (!wefaxDom.historyList) return;
|
if (!wefaxDom.historyList) return;
|
||||||
pruneWefaxHistory();
|
pruneWefaxHistory();
|
||||||
const items = getWefaxFilteredHistory();
|
var items = getWefaxFilteredHistory();
|
||||||
const fragment = document.createDocumentFragment();
|
var fragment = document.createDocumentFragment();
|
||||||
for (const item of items) {
|
for (var i = 0; i < items.length; i++) {
|
||||||
fragment.appendChild(renderWefaxHistoryRow(item));
|
fragment.appendChild(renderWefaxHistoryRow(items[i]));
|
||||||
}
|
}
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
var total = wefaxImageHistory.length;
|
||||||
const shown = items.length;
|
var shown = items.length;
|
||||||
wefaxDom.historyCount.textContent =
|
wefaxDom.historyCount.textContent =
|
||||||
total === 0
|
total === 0
|
||||||
? 'No images yet'
|
? 'No images yet'
|
||||||
: shown === total
|
: shown === total
|
||||||
? `${String(total)} image${total === 1 ? '' : 's'}`
|
? total + ' image' + (total === 1 ? '' : 's')
|
||||||
: `${String(shown)} of ${String(total)} images`;
|
: shown + ' of ' + total + ' images';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add image to history ────────────────────────────────────────────
|
// ── Add image to history ────────────────────────────────────────────
|
||||||
function addWefaxImage(msg: WefaxImage): void {
|
function addWefaxImage(msg) {
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
msg._tsMs = tsMs;
|
msg._tsMs = tsMs;
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -275,14 +233,11 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Capture the live canvas as a data URI for thumbnails.
|
// Capture the live canvas as a data URI for thumbnails.
|
||||||
const canvas = wefaxDom.liveCanvas;
|
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
|
||||||
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
|
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
|
||||||
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
|
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
|
||||||
canvas.height = wefaxLiveLineCount;
|
|
||||||
wefaxLiveCtx = canvas.getContext('2d');
|
|
||||||
if (!wefaxLiveCtx) return;
|
|
||||||
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
try { msg._dataUrl = canvas.toDataURL('image/png'); } catch {}
|
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
wefaxImageHistory.unshift(msg);
|
wefaxImageHistory.unshift(msg);
|
||||||
@@ -297,7 +252,7 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
window.onServerWefaxProgress = function (msg) {
|
||||||
// State-only update (no image data): show decoder state in status.
|
// State-only update (no image data): show decoder state in status.
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -308,43 +263,43 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) {
|
if (msg.line_count <= 1 || !wefaxLiveCtx) {
|
||||||
resetLiveCanvas(msg.pixels_per_line || 1809);
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.line_data) {
|
if (msg.line_data) {
|
||||||
const binary = atob(msg.line_data);
|
var binary = atob(msg.line_data);
|
||||||
const bytes = new Uint8Array(binary.length);
|
var bytes = new Uint8Array(binary.length);
|
||||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
paintLine(bytes);
|
paintLine(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wefaxDom.liveInfo) {
|
if (wefaxDom.liveInfo) {
|
||||||
wefaxDom.liveInfo.textContent =
|
wefaxDom.liveInfo.textContent =
|
||||||
`Line ${String(msg.line_count ?? 0)} \u00b7 ${String(msg.ioc ?? '--')} IOC \u00b7 ${String(msg.lpm ?? '--')} LPM`;
|
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
|
||||||
}
|
}
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Receiving \u2014 line ${String(msg.line_count ?? 0)}`;
|
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
|
||||||
wefaxDom.status.style.color = 'var(--text-accent)';
|
wefaxDom.status.style.color = 'var(--text-accent)';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function onServerWefax(msg: WefaxImage): void {
|
window.onServerWefax = function (msg) {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Complete \u2014 ${String(msg.line_count ?? 0)} lines`;
|
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
|
||||||
wefaxDom.status.style.color = '';
|
wefaxDom.status.style.color = '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function restoreWefaxHistory(messages: WefaxImage[]): void {
|
window.restoreWefaxHistory = function (messages) {
|
||||||
if (!messages.length) return;
|
if (!messages || !messages.length) return;
|
||||||
for (const message of messages) {
|
for (var i = 0; i < messages.length; i++) {
|
||||||
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
|
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
|
||||||
message._tsMs = tsMs;
|
messages[i]._tsMs = tsMs;
|
||||||
message._ts = new Date(tsMs).toLocaleTimeString([], {
|
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
@@ -356,15 +311,15 @@ function restoreWefaxHistory(messages: WefaxImage[]): void {
|
|||||||
if (wefaxActiveView === 'history') {
|
if (wefaxActiveView === 'history') {
|
||||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function pruneWefaxHistoryView(): void {
|
window.pruneWefaxHistoryView = function () {
|
||||||
pruneWefaxHistory();
|
pruneWefaxHistory();
|
||||||
renderWefaxHistoryTable();
|
renderWefaxHistoryTable();
|
||||||
renderWefaxLatestCard();
|
renderWefaxLatestCard();
|
||||||
}
|
};
|
||||||
|
|
||||||
function resetWefaxHistoryView(): void {
|
window.resetWefaxHistoryView = function () {
|
||||||
wefaxImageHistory = [];
|
wefaxImageHistory = [];
|
||||||
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
@@ -376,13 +331,12 @@ function resetWefaxHistoryView(): void {
|
|||||||
wefaxDom.status.textContent = 'Idle';
|
wefaxDom.status.textContent = 'Idle';
|
||||||
wefaxDom.status.style.color = '';
|
wefaxDom.status.style.color = '';
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// ── Filter / sort handlers ──────────────────────────────────────────
|
// ── Filter / sort handlers ──────────────────────────────────────────
|
||||||
if (wefaxDom.filterInput) {
|
if (wefaxDom.filterInput) {
|
||||||
const filterInput = wefaxDom.filterInput;
|
|
||||||
wefaxDom.filterInput.addEventListener('input', function () {
|
wefaxDom.filterInput.addEventListener('input', function () {
|
||||||
wefaxFilterText = filterInput.value.trim().toUpperCase();
|
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
|
||||||
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -396,7 +350,7 @@ if (wefaxDom.sortSelect) {
|
|||||||
// Sync the Enable/Disable button from the SSE state update. This is
|
// Sync the Enable/Disable button from the SSE state update. This is
|
||||||
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
|
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
|
||||||
// WEFAX button always reflects the server state.
|
// WEFAX button always reflects the server state.
|
||||||
wefaxWindow.syncWefaxToggle = function (enabled: boolean) {
|
window.syncWefaxToggle = function (enabled) {
|
||||||
if (!wefaxDom.toggleBtn) return;
|
if (!wefaxDom.toggleBtn) return;
|
||||||
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
|
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
|
||||||
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
|
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
|
||||||
@@ -406,40 +360,27 @@ wefaxWindow.syncWefaxToggle = function (enabled: boolean) {
|
|||||||
|
|
||||||
// ── Button handlers ─────────────────────────────────────────────────
|
// ── Button handlers ─────────────────────────────────────────────────
|
||||||
if (wefaxDom.toggleBtn) {
|
if (wefaxDom.toggleBtn) {
|
||||||
const toggleButton = wefaxDom.toggleBtn;
|
wefaxDom.toggleBtn.addEventListener('click', async function () {
|
||||||
wefaxDom.toggleBtn.addEventListener('click', () => { void (async () => {
|
|
||||||
try {
|
try {
|
||||||
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
if (window.takeSchedulerControlForDecoderDisable) {
|
||||||
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
|
||||||
}
|
}
|
||||||
await hostCore.postPath('/toggle_wefax_decode');
|
await postPath('/toggle_wefax_decode');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WEFAX toggle failed', e);
|
console.error('WEFAX toggle failed', e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
}
|
}
|
||||||
if (wefaxDom.clearBtn) {
|
if (wefaxDom.clearBtn) {
|
||||||
wefaxDom.clearBtn.addEventListener('click', () => { void (async () => {
|
wefaxDom.clearBtn.addEventListener('click', async function () {
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath('/clear_wefax_decode');
|
await postPath('/clear_wefax_decode');
|
||||||
resetWefaxHistoryView();
|
window.resetWefaxHistoryView();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('WEFAX clear failed', e);
|
console.error('WEFAX clear failed', e);
|
||||||
}
|
}
|
||||||
})(); });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Initial render ──────────────────────────────────────────────────
|
// ── Initial render ──────────────────────────────────────────────────
|
||||||
renderWefaxLatestCard();
|
renderWefaxLatestCard();
|
||||||
|
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "wefax",
|
|
||||||
onMessage: onServerWefax,
|
|
||||||
restore: restoreWefaxHistory,
|
|
||||||
prune: pruneWefaxHistoryView,
|
|
||||||
reset: resetWefaxHistoryView,
|
|
||||||
});
|
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "wefax_progress",
|
|
||||||
onMessage: onServerWefaxProgress,
|
|
||||||
});
|
|
||||||
+123
-102
@@ -1,131 +1,147 @@
|
|||||||
import {
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
hostCore
|
//
|
||||||
} from "./chunk-KL66PICH.js";
|
// 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() {
|
function currentWsprHistoryRetentionMs() {
|
||||||
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneWsprMessageHistory() {
|
function pruneWsprMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
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() {
|
function scheduleWsprHistoryRender() {
|
||||||
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
||||||
renderWsprHistory();
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtWsprTime(tsMs) {
|
function fmtWsprTime(tsMs) {
|
||||||
if (!tsMs) return "--:--:--";
|
if (!tsMs) return "--:--:--";
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateWsprPeriodTimer() {
|
function updateWsprPeriodTimer() {
|
||||||
if (!wsprPeriodEl) return;
|
if (!wsprPeriodEl) return;
|
||||||
const nowSec = Math.floor(Date.now() / 1e3);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
||||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
const ss = String(remaining % 60).padStart(2, "0");
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateWsprPeriodTimer();
|
updateWsprPeriodTimer();
|
||||||
setInterval(updateWsprPeriodTimer, 500);
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
|
||||||
function renderWsprRow(msg) {
|
function renderWsprRow(msg) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "ft8-row";
|
row.className = "ft8-row";
|
||||||
row.dataset.decoder = "wspr";
|
row.dataset.decoder = "wspr";
|
||||||
const snr = finiteNumber(msg.snr_db);
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
const delta = finiteNumber(msg.dt_s);
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
||||||
const offsetHz = finiteNumber(msg.freq_hz);
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
||||||
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
||||||
const freq = rfHz?.toFixed(0) ?? "--";
|
const message = (msg.message || "").toString();
|
||||||
const message = msg.message ?? "";
|
|
||||||
row.dataset.message = message.toUpperCase();
|
row.dataset.message = message.toUpperCase();
|
||||||
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
applyWsprFilterToRow(row);
|
applyWsprFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addWsprMessage(msg) {
|
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);
|
wsprMessageHistory.unshift(msg);
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
scheduleWsprHistoryRender();
|
scheduleWsprHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerWsprMessage(msg) {
|
function normalizeServerWsprMessage(msg) {
|
||||||
const raw = msg.message ?? "";
|
const raw = (msg.message || "").toString();
|
||||||
const grids = extractAllGrids(raw);
|
const grids = extractAllGrids(raw);
|
||||||
const station = extractLikelyCallsign(raw);
|
const station = extractLikelyCallsign(raw);
|
||||||
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
const offsetHz = finiteNumber(msg.freq_hz);
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
||||||
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
? (baseHz + Number(msg.freq_hz))
|
||||||
|
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
||||||
return {
|
return {
|
||||||
raw,
|
raw,
|
||||||
grids,
|
grids,
|
||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
dt_s: msg.dt_s,
|
dt_s: msg.dt_s,
|
||||||
freq_hz: msg.freq_hz,
|
freq_hz: msg.freq_hz,
|
||||||
message: raw
|
message: raw,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function onServerWsprBatch(messages) {
|
|
||||||
|
window.onServerWsprBatch = function(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
wsprStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...msg,
|
...msg,
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
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.push(next.history);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
scheduleWsprHistoryRender();
|
scheduleWsprHistoryRender();
|
||||||
}
|
};
|
||||||
function pruneWsprHistoryView() {
|
|
||||||
|
window.restoreWsprHistory = function(messages) {
|
||||||
|
window.onServerWsprBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneWsprHistoryView = function() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
}
|
};
|
||||||
|
|
||||||
function escapeWsprHtml(input) {
|
function escapeWsprHtml(input) {
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWsprMessage(message) {
|
function renderWsprMessage(message) {
|
||||||
let out = "";
|
let out = "";
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -143,15 +159,16 @@ function renderWsprMessage(message) {
|
|||||||
}
|
}
|
||||||
i = j;
|
i = j;
|
||||||
} else {
|
} else {
|
||||||
out += escapeWsprHtml(ch ?? "");
|
out += escapeWsprHtml(ch);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAllGrids(message) {
|
function extractAllGrids(message) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const seen = /* @__PURE__ */ new Set();
|
const seen = new Set();
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
for (const token of parts) {
|
for (const token of parts) {
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
@@ -162,8 +179,9 @@ function extractAllGrids(message) {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractLikelyCallsign(message) {
|
function extractLikelyCallsign(message) {
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
for (const token of parts) {
|
for (const token of parts) {
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
if (token.length < 3 || token.length > 12) continue;
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
@@ -173,28 +191,32 @@ function extractLikelyCallsign(message) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFtxFarewellToken(token) {
|
function isFtxFarewellToken(token) {
|
||||||
const normalized = token.trim().toUpperCase();
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMaidenheadGridToken(token) {
|
function isMaidenheadGridToken(token) {
|
||||||
const normalized = token.trim().toUpperCase();
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAlphaNum(ch) {
|
function isAlphaNum(ch) {
|
||||||
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
return /[A-Za-z0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
function activateWsprHistoryLocator(target) {
|
|
||||||
if (!(target instanceof Element)) return false;
|
function activateWsprHistoryLocator(targetEl) {
|
||||||
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||||
if (!locatorEl) return false;
|
if (!locatorEl) return false;
|
||||||
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
if (!grid) return false;
|
if (!grid) return false;
|
||||||
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
if (typeof window.navigateToMapLocator === "function") {
|
||||||
wsprWindow.navigateToMapLocator(grid, "wspr");
|
window.navigateToMapLocator(grid, "wspr");
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWsprFilterToRow(row) {
|
function applyWsprFilterToRow(row) {
|
||||||
if (!wsprFilterText) {
|
if (!wsprFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
@@ -203,18 +225,26 @@ function applyWsprFilterToRow(row) {
|
|||||||
const message = row.dataset.message || "";
|
const message = row.dataset.message || "";
|
||||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
function resetWsprHistoryView() {
|
|
||||||
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
function applyWsprFilterToAll() {
|
||||||
|
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => applyWsprFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.resetWsprHistoryView = function() {
|
||||||
|
wsprMessagesEl.innerHTML = "";
|
||||||
wsprMessageHistory = [];
|
wsprMessageHistory = [];
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
||||||
}
|
};
|
||||||
|
|
||||||
if (wsprFilterInput) {
|
if (wsprFilterInput) {
|
||||||
wsprFilterInput.addEventListener("input", () => {
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsprMessagesEl) {
|
if (wsprMessagesEl) {
|
||||||
wsprMessagesEl.addEventListener("click", (event) => {
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
@@ -228,44 +258,35 @@ if (wsprMessagesEl) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
|
||||||
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
void (async () => {
|
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
await hostCore.postPath("/toggle_wspr_decode");
|
await postPath("/toggle_wspr_decode");
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error("WSPR toggle failed", error);
|
console.error("WSPR toggle failed", e);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
||||||
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return;
|
||||||
try {
|
try {
|
||||||
await hostCore.postPath("/clear_wspr_decode");
|
await postPath("/clear_wspr_decode");
|
||||||
resetWsprHistoryView();
|
window.resetWsprHistoryView();
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error("WSPR history clear failed", error);
|
console.error("WSPR history clear failed", e);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
function onServerWspr(msg) {
|
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
window.onServerWspr = function(msg) {
|
||||||
|
wsprStatus.textContent = "Receiving";
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...msg,
|
...msg,
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
freq_hz: next.rfHz,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
};
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "wspr",
|
|
||||||
onMessage: onServerWspr,
|
|
||||||
onBatch: onServerWsprBatch,
|
|
||||||
restore: onServerWsprBatch,
|
|
||||||
prune: pruneWsprHistoryView,
|
|
||||||
reset: resetWsprHistoryView
|
|
||||||
});
|
|
||||||
+49
-26
@@ -1,8 +1,13 @@
|
|||||||
// src/screenshot.ts
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
var screenshotWindow = window;
|
//
|
||||||
(function() {
|
// 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 () {
|
||||||
"use strict";
|
"use strict";
|
||||||
const T = screenshotWindow.trx;
|
const T = window.trx;
|
||||||
|
|
||||||
function isVisibleForSnapshot(el) {
|
function isVisibleForSnapshot(el) {
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
@@ -12,6 +17,7 @@ var screenshotWindow = window;
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
return rect.width > 0 && rect.height > 0;
|
return rect.width > 0 && rect.height > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||||
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -26,6 +32,7 @@ var screenshotWindow = window;
|
|||||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||||
if (!isVisibleForSnapshot(el)) return null;
|
if (!isVisibleForSnapshot(el)) return null;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
@@ -38,29 +45,30 @@ var screenshotWindow = window;
|
|||||||
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||||
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||||
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||||
|
|
||||||
const bgAlpha = Math.min(bg[3], maxAlpha);
|
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||||
if (bgAlpha > 0.01) {
|
if (bgAlpha > 0.01) {
|
||||||
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||||
ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
|
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
const borderAlpha = Math.min(border[3], maxAlpha);
|
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||||
if (borderWidth > 0 && borderAlpha > 0.01) {
|
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));
|
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.lineWidth = borderWidth;
|
||||||
ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
|
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
return { x, y, w, h, style };
|
return { x, y, w, h, style };
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
const words = (text || "").split(/\s+/).filter(Boolean);
|
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||||
if (!words.length) return;
|
if (!words.length) return;
|
||||||
let line = "";
|
let line = "";
|
||||||
let lineIdx = 0;
|
let lineIdx = 0;
|
||||||
for (let i = 0; i < words.length; i += 1) {
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
const word = words[i] ?? "";
|
const candidate = line ? `${line} ${words[i]}` : words[i];
|
||||||
const candidate = line ? `${line} ${word}` : word;
|
|
||||||
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||||
line = candidate;
|
line = candidate;
|
||||||
continue;
|
continue;
|
||||||
@@ -68,12 +76,13 @@ var screenshotWindow = window;
|
|||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
lineIdx += 1;
|
lineIdx += 1;
|
||||||
if (lineIdx >= maxLines) return;
|
if (lineIdx >= maxLines) return;
|
||||||
line = word;
|
line = words[i];
|
||||||
}
|
}
|
||||||
if (line && lineIdx < maxLines) {
|
if (line && lineIdx < maxLines) {
|
||||||
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||||
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||||
if (!chrome) return;
|
if (!chrome) return;
|
||||||
@@ -82,7 +91,7 @@ var screenshotWindow = window;
|
|||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
const style = chrome.style;
|
const style = chrome.style;
|
||||||
const fontSize = parseFloat(style.fontSize) || 12;
|
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 padX = 6;
|
||||||
const padY = 4;
|
const padY = 4;
|
||||||
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||||
@@ -101,11 +110,12 @@ var screenshotWindow = window;
|
|||||||
chrome.y + padY + lineCursor * lineHeight,
|
chrome.y + padY + lineCursor * lineHeight,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
maxLines - lineCursor
|
maxLines - lineCursor,
|
||||||
);
|
);
|
||||||
lineCursor += 1;
|
lineCursor += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawAxisLabels(ctx, axisEl, rootRect) {
|
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||||
if (!isVisibleForSnapshot(axisEl)) return;
|
if (!isVisibleForSnapshot(axisEl)) return;
|
||||||
for (const node of axisEl.children) {
|
for (const node of axisEl.children) {
|
||||||
@@ -122,6 +132,7 @@ var screenshotWindow = window;
|
|||||||
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSpectrumSnapshotCanvas() {
|
function buildSpectrumSnapshotCanvas() {
|
||||||
const rootEl = document.querySelector(".signal-visual-block");
|
const rootEl = document.querySelector(".signal-visual-block");
|
||||||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||||
@@ -134,7 +145,8 @@ var screenshotWindow = window;
|
|||||||
try {
|
try {
|
||||||
if (typeof gl.flush === "function") gl.flush();
|
if (typeof gl.flush === "function") gl.flush();
|
||||||
if (typeof gl.finish === "function") gl.finish();
|
if (typeof gl.finish === "function") gl.finish();
|
||||||
} catch {
|
} catch (_) {
|
||||||
|
// Ignore transient WebGL state errors and capture the last good frame.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rootRect = rootEl.getBoundingClientRect();
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
@@ -145,9 +157,11 @@ var screenshotWindow = window;
|
|||||||
const ctx = out.getContext("2d");
|
const ctx = out.getContext("2d");
|
||||||
if (!ctx) return null;
|
if (!ctx) return null;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||||
ctx.fillStyle = bg;
|
ctx.fillStyle = bg;
|
||||||
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||||
|
|
||||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||||
for (const canvas of canvases) {
|
for (const canvas of canvases) {
|
||||||
@@ -158,21 +172,28 @@ var screenshotWindow = window;
|
|||||||
rect.left - rootRect.left,
|
rect.left - rootRect.left,
|
||||||
rect.top - rootRect.top,
|
rect.top - rootRect.top,
|
||||||
rect.width,
|
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 = [
|
const decoderOverlayIds = [
|
||||||
"ais-bar-overlay",
|
"ais-bar-overlay",
|
||||||
"vdes-bar-overlay",
|
"vdes-bar-overlay",
|
||||||
"ft8-bar-overlay",
|
"ft8-bar-overlay",
|
||||||
"aprs-bar-overlay",
|
"aprs-bar-overlay",
|
||||||
"rds-ps-overlay"
|
"rds-ps-overlay",
|
||||||
];
|
];
|
||||||
for (const id of decoderOverlayIds) {
|
for (const id of decoderOverlayIds) {
|
||||||
const overlayEl = document.getElementById(id);
|
const overlayEl = document.getElementById(id);
|
||||||
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||||
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spectrum axis labels and bookmark chips (includes freq bar).
|
||||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||||
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||||
@@ -180,8 +201,10 @@ var screenshotWindow = window;
|
|||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||||
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clickCanvasDownload(href, fileName) {
|
function clickCanvasDownload(href, fileName) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = href;
|
a.href = href;
|
||||||
@@ -190,10 +213,9 @@ var screenshotWindow = window;
|
|||||||
a.style.display = "none";
|
a.style.display = "none";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => a.remove());
|
||||||
a.remove();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCanvasAsPng(canvas, fileName) {
|
function saveCanvasAsPng(canvas, fileName) {
|
||||||
if (!canvas) return Promise.resolve(false);
|
if (!canvas) return Promise.resolve(false);
|
||||||
if (typeof canvas.toBlob === "function") {
|
if (typeof canvas.toBlob === "function") {
|
||||||
@@ -206,12 +228,10 @@ var screenshotWindow = window;
|
|||||||
}
|
}
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
clickCanvasDownload(url, fileName);
|
clickCanvasDownload(url, fileName);
|
||||||
setTimeout(() => {
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, 1e3);
|
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
} catch {
|
} catch (_) {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,24 +239,27 @@ var screenshotWindow = window;
|
|||||||
try {
|
try {
|
||||||
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
} catch {
|
} catch (_) {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureSpectrumScreenshot() {
|
async function captureSpectrumScreenshot() {
|
||||||
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||||
if (!snapshotCanvas) {
|
if (!snapshotCanvas) {
|
||||||
T.showHint("Spectrum view not ready", 1300);
|
T.showHint("Spectrum view not ready", 1300);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||||
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
screenshotWindow.trx.modules.screenshot = {
|
|
||||||
|
// Register module API
|
||||||
|
window.trx.screenshot = {
|
||||||
captureSpectrumScreenshot,
|
captureSpectrumScreenshot,
|
||||||
buildSpectrumSnapshotCanvas,
|
buildSpectrumSnapshotCanvas,
|
||||||
saveCanvasAsPng
|
saveCanvasAsPng,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -1229,20 +1229,12 @@ small { color: var(--text-muted); }
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
/* One row, always. Controls that do not fit are moved into the overflow menu
|
|
||||||
* by ui-core rather than wrapping: wrapping made the header's height depend on
|
|
||||||
* the viewport width in a way that was not even monotonic (112px at 1440,
|
|
||||||
* 169px at 1100, 131px at 900), which is what made the bar feel unstable. */
|
|
||||||
.top-bar-actions {
|
.top-bar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.45rem 0.6rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
}
|
|
||||||
/* Every control in the bar is the same height and none of them stretch. */
|
|
||||||
.top-bar-actions > * {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
.header-bar-btn.header-audio-btn {
|
.header-bar-btn.header-audio-btn {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
@@ -1411,13 +1403,10 @@ small { color: var(--text-muted); }
|
|||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
padding: 0.9rem 0.2rem 0;
|
padding: 0.9rem 0.2rem 0;
|
||||||
}
|
}
|
||||||
/* Inline, not stacked: the summary used to sit under the select, making this
|
|
||||||
* the only 54px control in a bar of 32px ones. */
|
|
||||||
.header-rig-switch {
|
.header-rig-switch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.35rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.header-rig-switch select {
|
.header-rig-switch select {
|
||||||
min-width: 8rem;
|
min-width: 8rem;
|
||||||
@@ -1428,7 +1417,6 @@ small { color: var(--text-muted); }
|
|||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
align-self: start;
|
|
||||||
}
|
}
|
||||||
.header-rig-switch button {
|
.header-rig-switch button {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
@@ -1531,19 +1519,7 @@ small { color: var(--text-muted); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
/* Never wraps. It must still be able to shrink below its content, or it
|
flex-wrap: wrap;
|
||||||
* overlaps the controls; the menus it anchors are reparented to the body on
|
|
||||||
* open, so clipping here no longer reaches them. */
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.tab-bar-nav .tab { flex: 0 0 auto; }
|
|
||||||
/* Icons before scrolling: every tab already carries one, and four icons plus
|
|
||||||
* More always fit, so the strip never has to hide a destination. */
|
|
||||||
@media (max-width: 1360px) and (min-width: 701px) {
|
|
||||||
.tab-bar-nav .tab .tab-label { display: none; }
|
|
||||||
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
|
|
||||||
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
|
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1566,7 +1542,6 @@ small { color: var(--text-muted); }
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tab-label { display: block; }
|
.tab-label { display: block; }
|
||||||
.tab-more-icon { display: none; }
|
|
||||||
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
|
||||||
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
|
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
|
||||||
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
|
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
|
||||||
@@ -2534,7 +2509,6 @@ body.map-fake-fullscreen-active {
|
|||||||
word-break: break-all;
|
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 { 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 { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
|
||||||
.aprs-pos:hover { text-decoration: underline; }
|
.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; }
|
.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; }
|
||||||
@@ -2865,272 +2839,6 @@ canvas:focus-visible, [tabindex]:focus-visible {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared interaction primitives ───────────────────────────────────── */
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute !important;
|
|
||||||
width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
||||||
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
||||||
}
|
|
||||||
.toast-region {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 120;
|
|
||||||
right: var(--space-4);
|
|
||||||
bottom: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
width: min(28rem, calc(100vw - 2rem));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.toast {
|
|
||||||
--toast-accent: var(--accent-yellow);
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
min-height: 3rem;
|
|
||||||
padding: 0.7rem 0.75rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--toast-accent) 35%, var(--border-light));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--card-bg) 96%, var(--toast-accent) 4%);
|
|
||||||
color: var(--text);
|
|
||||||
box-shadow: 0 14px 38px color-mix(in srgb, #000 38%, transparent), inset 3px 0 0 var(--toast-accent);
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(0.65rem) scale(0.98);
|
|
||||||
transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.toast::before {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: color-mix(in srgb, var(--toast-accent) 18%, transparent);
|
|
||||||
color: var(--toast-accent);
|
|
||||||
content: "i";
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.toast > span { min-width: 0; line-height: 1.35; }
|
|
||||||
.toast-visible { opacity: 1; transform: translateY(0); }
|
|
||||||
.toast-success { --toast-accent: var(--accent-green); }
|
|
||||||
.toast-success::before { content: "✓"; }
|
|
||||||
.toast-error { --toast-accent: var(--accent-red); }
|
|
||||||
.toast-error::before { content: "!"; }
|
|
||||||
.toast button {
|
|
||||||
width: auto;
|
|
||||||
min-height: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
border-color: color-mix(in srgb, var(--toast-accent) 55%, var(--btn-border));
|
|
||||||
color: var(--toast-accent);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.ui-dialog {
|
|
||||||
max-width: none;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
color: var(--text);
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: 0 24px 70px color-mix(in srgb, #000 55%, transparent);
|
|
||||||
}
|
|
||||||
.ui-dialog::backdrop { background: rgba(3, 8, 18, 0.72); backdrop-filter: blur(5px); }
|
|
||||||
.ui-dialog-card {
|
|
||||||
width: min(29rem, calc(100vw - 2rem));
|
|
||||||
margin: 0;
|
|
||||||
padding: var(--space-6);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(145deg, color-mix(in srgb, var(--card-bg) 94%, var(--text) 6%), var(--card-bg));
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.ui-dialog-card h2 { margin: 0 0 var(--space-2); color: var(--text-heading); font-size: var(--fs-md); line-height: 1.3; }
|
|
||||||
.ui-dialog-card p { margin: 0; color: var(--text-muted); line-height: 1.55; }
|
|
||||||
.ui-dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
|
|
||||||
.ui-dialog-actions button { min-width: 6rem; }
|
|
||||||
.ui-dialog-actions .danger { background: var(--accent-red); border-color: var(--accent-red); color: #fff; font-weight: 700; }
|
|
||||||
.ui-dialog-actions .danger:hover:not(:disabled) { background: color-mix(in srgb, var(--accent-red) 82%, #fff); border-color: color-mix(in srgb, var(--accent-red) 82%, #fff); }
|
|
||||||
.form-error { flex: 1 1 100%; min-height: 1.2em; color: var(--accent-red); font-size: var(--fs-sm); font-weight: 600; }
|
|
||||||
button.is-active {
|
|
||||||
border-color: var(--accent-green);
|
|
||||||
background: color-mix(in srgb, var(--btn-bg) 82%, var(--accent-green) 18%);
|
|
||||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-green) 32%, transparent), 0 0 0 1px color-mix(in srgb, var(--accent-green) 12%, transparent);
|
|
||||||
}
|
|
||||||
#ptt-btn.is-active { background: var(--accent-red) !important; border-color: var(--accent-red) !important; color: white !important; }
|
|
||||||
.is-busy { cursor: progress !important; opacity: 0.68; }
|
|
||||||
.operator-layout-picker {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 2rem;
|
|
||||||
padding-left: 0.6rem;
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--input-bg) 88%, transparent);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
/* The prefix made the least-used control the widest in the bar; the
|
|
||||||
* select carries its own aria-label and title instead. */
|
|
||||||
.operator-layout-picker { padding-left: 0; }
|
|
||||||
.operator-layout-picker select { max-width: 8rem; }
|
|
||||||
.operator-layout-picker select {
|
|
||||||
width: auto;
|
|
||||||
min-height: 2rem;
|
|
||||||
max-width: 9rem;
|
|
||||||
padding: 0 1.75rem 0 0.45rem;
|
|
||||||
border: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.operator-layout-picker:focus-within { border-color: var(--accent-green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-green) 20%, transparent); }
|
|
||||||
.operator-layout-picker select:focus-visible { outline: 0; }
|
|
||||||
.advanced-radio-controls {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--border-light) 75%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--input-bg) 68%, transparent);
|
|
||||||
overflow: clip;
|
|
||||||
transition: border-color var(--dur-fast) var(--ease-standard), background-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.advanced-radio-controls:hover { border-color: var(--border-light); }
|
|
||||||
.advanced-radio-controls[open] { background: color-mix(in srgb, var(--surface) 86%, transparent); }
|
|
||||||
.advanced-radio-controls summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
color: var(--text-heading);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.advanced-radio-controls summary::-webkit-details-marker { display: none; }
|
|
||||||
.advanced-radio-controls summary::before {
|
|
||||||
content: "›";
|
|
||||||
color: var(--accent-text);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
transform: rotate(0deg);
|
|
||||||
transition: transform var(--dur-base) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.advanced-radio-controls[open] summary::before { transform: rotate(90deg); }
|
|
||||||
.advanced-radio-controls[open] summary { border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
|
|
||||||
.advanced-radio-body { display: grid; gap: var(--space-3); padding: var(--space-3); }
|
|
||||||
.advanced-radio-body > * { margin: 0; }
|
|
||||||
body[data-operator-layout="digital"] .controls-col-wfm { display: none !important; }
|
|
||||||
body[data-operator-layout="broadcast"] #tx-power-col,
|
|
||||||
body[data-operator-layout="broadcast"] #tx-meters,
|
|
||||||
body[data-operator-layout="broadcast"] #tx-limit-row,
|
|
||||||
body[data-operator-layout="broadcast"] #vfo-row,
|
|
||||||
body[data-operator-layout="broadcast"] #sam-controls-col,
|
|
||||||
body[data-operator-layout="broadcast"] #advanced-radio-controls {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] .controls-row {
|
|
||||||
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col,
|
|
||||||
body[data-operator-layout="broadcast"] #audio-row,
|
|
||||||
body[data-operator-layout="broadcast"] #spectrum-bw-row {
|
|
||||||
border-color: color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
|
||||||
background: color-mix(in srgb, var(--accent-green) 7%, transparent);
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] #audio-row {
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] #spectrum-bw-row {
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--accent-green) 42%, var(--border-light));
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
body[data-operator-layout="broadcast"] #ais-bar-overlay,
|
|
||||||
body[data-operator-layout="broadcast"] #vdes-bar-overlay,
|
|
||||||
body[data-operator-layout="broadcast"] #ft8-bar-overlay,
|
|
||||||
body[data-operator-layout="broadcast"] #aprs-bar-overlay,
|
|
||||||
body[data-operator-layout="broadcast"] #hf-aprs-bar-overlay,
|
|
||||||
body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
|
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
|
|
||||||
}
|
|
||||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
|
||||||
* About are occasional destinations: they live behind More rather than
|
|
||||||
* competing with the operating tabs for the row and then scrolling out of
|
|
||||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
|
||||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
|
||||||
.tab-bar-nav .tab[data-tab="statistics"],
|
|
||||||
.tab-bar-nav .tab[data-tab="recorder"],
|
|
||||||
.tab-bar-nav .tab[data-tab="settings"],
|
|
||||||
.tab-bar-nav .tab[data-tab="about"] { display: none; }
|
|
||||||
.mobile-more-btn { display: flex; flex: 0 0 auto; }
|
|
||||||
.mobile-more-menu, .decoder-tab-select { display: none; }
|
|
||||||
.tab-bar-nav { position: relative; }
|
|
||||||
.mobile-more-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.4rem);
|
|
||||||
right: 0;
|
|
||||||
right: max(0.75rem, env(safe-area-inset-right));
|
|
||||||
bottom: calc(5.4rem + env(safe-area-inset-bottom));
|
|
||||||
z-index: 80;
|
|
||||||
min-width: 12rem;
|
|
||||||
padding: var(--space-2);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--card-bg) 97%, transparent);
|
|
||||||
box-shadow: 0 18px 45px color-mix(in srgb, #000 42%, transparent);
|
|
||||||
}
|
|
||||||
.mobile-more-menu.is-open { display: grid; gap: 0.2rem; animation: trx-menu-in var(--dur-base) var(--ease-out); }
|
|
||||||
.mobile-more-menu button {
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
height: 2.65rem;
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.mobile-more-menu button:hover { background: var(--btn-hover-bg); border-color: transparent; }
|
|
||||||
.decoder-state-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.45rem;
|
|
||||||
height: 0.45rem;
|
|
||||||
margin-left: 0.4rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--text-muted) 70%, transparent);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: color-mix(in srgb, var(--text-muted) 60%, transparent);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.decoder-state-dot[data-state="active"] { background: var(--accent-green); box-shadow: 0 0 0.35rem color-mix(in srgb, var(--accent-green) 65%, transparent); }
|
|
||||||
.decoder-state-dot[data-state="error"] { background: var(--accent-red); }
|
|
||||||
@keyframes trx-menu-in { from { opacity: 0; transform: translateY(0.4rem) scale(0.98); } to { opacity: 1; transform: none; } }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbars ───────────────────────────────────────────────────────── */
|
/* ── Scrollbars ───────────────────────────────────────────────────────── */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
@@ -3245,7 +2953,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.38rem;
|
padding: 0.38rem;
|
||||||
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border-light) 82%, transparent);
|
||||||
@@ -3280,61 +2988,29 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||||
}
|
}
|
||||||
.tab-icon, .tab-more-icon { display: block; }
|
.tab-icon { display: block; }
|
||||||
/* Shorten long tab labels to keep bottom nav compact */
|
/* Shorten long tab labels to keep bottom nav compact */
|
||||||
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
|
||||||
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
|
||||||
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
|
.tab[data-tab="statistics"] .tab-label { font-size: 0.6rem; }
|
||||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
|
||||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
|
||||||
.mobile-more-btn { display: flex; }
|
|
||||||
.mobile-more-menu {
|
|
||||||
position: fixed;
|
|
||||||
top: auto;
|
|
||||||
right: max(0.75rem, env(safe-area-inset-right));
|
|
||||||
bottom: calc(5.4rem + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
.mobile-more-btn[aria-expanded="true"] {
|
|
||||||
color: var(--accent-text);
|
|
||||||
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
|
|
||||||
}
|
|
||||||
.tab .tab-label, .tab[data-tab] .tab-label { font-size: 0.75rem; }
|
|
||||||
.decoder-tab-select {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.8rem;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
padding: 0 var(--space-3);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: color-mix(in srgb, var(--input-bg) 90%, var(--card-bg));
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
|
|
||||||
}
|
|
||||||
#tab-digital-modes > .sub-tab-bar { display: none; }
|
|
||||||
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
|
|
||||||
.top-bar-actions {
|
.top-bar-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
.operator-layout-picker { max-width: 100%; }
|
|
||||||
.operator-layout-picker select { max-width: 10rem; }
|
|
||||||
.header-rig-switch,
|
.header-rig-switch,
|
||||||
.header-style-pick {
|
.header-style-pick {
|
||||||
flex: 0 0 auto;
|
flex: 1 1 12rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.header-rig-switch select,
|
.header-rig-switch select,
|
||||||
.header-style-pick select {
|
.header-style-pick select {
|
||||||
width: auto;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.header-bar-btn {
|
.header-bar-btn {
|
||||||
flex: 0 0 auto;
|
flex: 1 1 calc(50% - 0.3rem);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
#tab-main,
|
#tab-main,
|
||||||
@@ -4234,27 +3910,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.toast-region {
|
|
||||||
right: max(0.65rem, env(safe-area-inset-right));
|
|
||||||
width: calc(100vw - max(1.3rem, env(safe-area-inset-left) + env(safe-area-inset-right)));
|
|
||||||
}
|
|
||||||
.toast { grid-template-columns: 1.25rem minmax(0, 1fr); }
|
|
||||||
.toast button { grid-column: 2; justify-self: start; }
|
|
||||||
.ui-dialog-card { padding: var(--space-5); }
|
|
||||||
.ui-dialog-actions { flex-direction: column-reverse; }
|
|
||||||
.ui-dialog-actions button { width: 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Preserve legibility after the component-specific compact-mobile rules. */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
button, input, select, textarea, .tab-label, .sub-tab,
|
|
||||||
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
|
|
||||||
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
|
|
||||||
font-size: max(0.75rem, 12px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Theme styles have been moved to themes.css */
|
/* Theme styles have been moved to themes.css */
|
||||||
|
|
||||||
|
|
||||||
@@ -5188,6 +4843,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.stats-controls {
|
.stats-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5405,59 +5061,3 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
button, input, select, textarea, .tab-label, .sub-tab,
|
|
||||||
.label, .hint, .subtitle, .wfm-control-label, .wfm-intf-val,
|
|
||||||
.ft8-header, .ft8-message, .aprs-packet, .ais-message, .vdes-message {
|
|
||||||
font-size: max(0.75rem, 12px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* --- Top bar overflow menu ------------------------------------------------ */
|
|
||||||
.top-bar-more { position: relative; }
|
|
||||||
.top-bar-more[hidden] { display: none; }
|
|
||||||
.top-bar-more-btn {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
min-height: 2rem;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.top-bar-more-menu {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.35rem);
|
|
||||||
right: 0;
|
|
||||||
z-index: 40;
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
min-width: 12rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
.top-bar-more-menu.is-open { display: flex; }
|
|
||||||
.top-bar-more-menu > * { width: 100%; }
|
|
||||||
.top-bar-more-menu select { width: 100%; max-width: none; }
|
|
||||||
|
|
||||||
|
|
||||||
/* Page title for destinations reached through the Tools menu. The tab strip
|
|
||||||
* cannot show which of them is active, so each states its own name.
|
|
||||||
*
|
|
||||||
* The margin is the spacing on the plain block panels; #tab-statistics is a
|
|
||||||
* flex column with its own `gap`, where the margin lands on top of that gap
|
|
||||||
* and doubles the space under the title. */
|
|
||||||
.section-heading {
|
|
||||||
margin: 0 0 var(--space-3);
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#tab-statistics > .section-heading { margin-bottom: 0; }
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import vm from "node:vm";
|
|
||||||
|
|
||||||
class ClassList {
|
|
||||||
constructor() { this.values = new Set(); }
|
|
||||||
add(...names) { names.forEach(name => this.values.add(name)); }
|
|
||||||
remove(...names) { names.forEach(name => this.values.delete(name)); }
|
|
||||||
contains(name) { return this.values.has(name); }
|
|
||||||
toggle(name, force) {
|
|
||||||
const enabled = force === undefined ? !this.contains(name) : force;
|
|
||||||
if (enabled) this.add(name); else this.remove(name);
|
|
||||||
return enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Element {
|
|
||||||
constructor(tagName, document) {
|
|
||||||
this.tagName = tagName.toUpperCase();
|
|
||||||
this.ownerDocument = document;
|
|
||||||
this.children = [];
|
|
||||||
this.dataset = {};
|
|
||||||
this.attributes = new Map();
|
|
||||||
this.classList = new ClassList();
|
|
||||||
this.listeners = new Map();
|
|
||||||
this.style = {};
|
|
||||||
this.options = [];
|
|
||||||
this.value = "";
|
|
||||||
this.textContent = "";
|
|
||||||
}
|
|
||||||
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
|
|
||||||
get id() { return this._id || ""; }
|
|
||||||
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
|
|
||||||
set innerHTML(value) {
|
|
||||||
this._innerHTML = value;
|
|
||||||
if (value.includes('value="confirm"')) {
|
|
||||||
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
|
|
||||||
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
|
|
||||||
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
|
|
||||||
this.append(title, message, confirm);
|
|
||||||
this._confirmButton = confirm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
get innerHTML() { return this._innerHTML || ""; }
|
|
||||||
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
|
|
||||||
append(...children) { children.forEach(child => this.appendChild(child)); }
|
|
||||||
insertBefore(child) { return this.appendChild(child); }
|
|
||||||
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
|
|
||||||
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
|
|
||||||
setAttribute(name, value) { this.attributes.set(name, String(value)); }
|
|
||||||
getAttribute(name) { return this.attributes.get(name); }
|
|
||||||
addEventListener(type, listener) { this.listeners.set(type, listener); }
|
|
||||||
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
|
|
||||||
querySelector(selector) {
|
|
||||||
if (selector === '[value="confirm"]') return this._confirmButton || null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
querySelectorAll(selector) {
|
|
||||||
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
|
|
||||||
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
|
|
||||||
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
|
|
||||||
showModal() { this.open = true; }
|
|
||||||
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
|
|
||||||
focus() { this.focused = true; }
|
|
||||||
click() { this.clicked = true; }
|
|
||||||
}
|
|
||||||
|
|
||||||
class DocumentFixture {
|
|
||||||
constructor() {
|
|
||||||
this.readyState = "loading";
|
|
||||||
this.elements = new Map();
|
|
||||||
this.body = new Element("body", this);
|
|
||||||
}
|
|
||||||
createElement(tagName) { return new Element(tagName, this); }
|
|
||||||
getElementById(id) { return this.elements.get(id) || null; }
|
|
||||||
querySelector() { return null; }
|
|
||||||
querySelectorAll() { return []; }
|
|
||||||
addEventListener() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const document = new DocumentFixture();
|
|
||||||
const storage = new Map();
|
|
||||||
const localStorage = {
|
|
||||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
|
||||||
setItem(key, value) { storage.set(key, String(value)); },
|
|
||||||
};
|
|
||||||
const window = { document, localStorage, addEventListener() {} };
|
|
||||||
const context = vm.createContext({
|
|
||||||
window, document, localStorage,
|
|
||||||
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
|
|
||||||
MutationObserver: class MutationObserver { observe() {} },
|
|
||||||
requestAnimationFrame(callback) { callback(); },
|
|
||||||
setTimeout() { return 1; },
|
|
||||||
clearTimeout() {},
|
|
||||||
console,
|
|
||||||
});
|
|
||||||
|
|
||||||
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
|
|
||||||
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
|
|
||||||
const ui = window.trxUi;
|
|
||||||
|
|
||||||
const toast = ui.notify("Saved", { kind: "success" });
|
|
||||||
assert.equal(toast.getAttribute("role"), "status");
|
|
||||||
assert.equal(toast.classList.contains("toast-success"), true);
|
|
||||||
assert.equal(document.getElementById("toast-region").children.length, 1);
|
|
||||||
|
|
||||||
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
|
|
||||||
const dialog = document.getElementById("ui-confirm-dialog");
|
|
||||||
assert.equal(dialog.open, true);
|
|
||||||
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
|
|
||||||
dialog.close("confirm");
|
|
||||||
assert.equal(await confirmation, true);
|
|
||||||
|
|
||||||
const tabBar = new Element("div", document);
|
|
||||||
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
|
|
||||||
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
|
|
||||||
tabBar.append(firstTab, secondTab);
|
|
||||||
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
|
|
||||||
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
|
|
||||||
ui.prepareTabList(tabBar, "primary");
|
|
||||||
assert.equal(firstTab.getAttribute("aria-selected"), "true");
|
|
||||||
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
|
|
||||||
assert.equal(secondTab.focused, true);
|
|
||||||
assert.equal(secondTab.clicked, true);
|
|
||||||
|
|
||||||
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
|
|
||||||
ui.setActiveRig("rig-a");
|
|
||||||
assert.equal(document.body.dataset.operatorLayout, "compact");
|
|
||||||
ui.setLayoutCapabilities({ broadcast: true });
|
|
||||||
ui.setActiveRig("rig-a");
|
|
||||||
assert.equal(document.body.dataset.operatorLayout, "broadcast");
|
|
||||||
ui.setActiveRig("rig-b");
|
|
||||||
ui.applyLayout("digital");
|
|
||||||
assert.equal(document.body.dataset.operatorLayout, "compact");
|
|
||||||
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
|
|
||||||
|
|
||||||
console.log("ui-core component tests passed");
|
|
||||||
-232
File diff suppressed because one or more lines are too long
+93
-127
@@ -2,39 +2,17 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
type Rgba = [number, number, number, number];
|
(function initTrxWebGl(global) {
|
||||||
type ColorInput = string | number[] | { r: number; g: number; b: number; a?: number };
|
|
||||||
interface TextureEntry { texture: WebGLTexture | null; width: number; height: number }
|
|
||||||
interface ColorLocations {
|
|
||||||
pos: number;
|
|
||||||
color: number;
|
|
||||||
resolution: WebGLUniformLocation | null;
|
|
||||||
}
|
|
||||||
interface TextureLocations {
|
|
||||||
pos: number;
|
|
||||||
uv: number;
|
|
||||||
resolution: WebGLUniformLocation | null;
|
|
||||||
alpha: WebGLUniformLocation | null;
|
|
||||||
tex: WebGLUniformLocation | null;
|
|
||||||
}
|
|
||||||
interface TrxWebGlGlobals {
|
|
||||||
trxParseCssColor?: (value: string) => Rgba;
|
|
||||||
trxHslToRgba?: (h: number, s: number, l: number, a?: number) => Rgba;
|
|
||||||
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options?: WebGLContextAttributes) => object;
|
|
||||||
trxClearCssColorCache?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function initTrxWebGl(global: TrxWebGlGlobals) {
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const cssColorCache = new Map<string, Rgba>();
|
const cssColorCache = new Map();
|
||||||
let cssColorProbe: HTMLSpanElement | null = null;
|
let cssColorProbe = null;
|
||||||
|
|
||||||
function clearCssColorCache() {
|
function clearCssColorCache() {
|
||||||
cssColorCache.clear();
|
cssColorCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureCssColorProbe(): HTMLSpanElement {
|
function ensureCssColorProbe() {
|
||||||
if (cssColorProbe) return cssColorProbe;
|
if (cssColorProbe) return cssColorProbe;
|
||||||
const el = document.createElement("span");
|
const el = document.createElement("span");
|
||||||
el.style.position = "absolute";
|
el.style.position = "absolute";
|
||||||
@@ -47,10 +25,10 @@ interface TrxWebGlGlobals {
|
|||||||
return cssColorProbe;
|
return cssColorProbe;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRgbString(value: string): Rgba | null {
|
function parseRgbString(value) {
|
||||||
const m = /^rgba?\(([^)]+)\)$/.exec(value.trim());
|
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const parts = m[1]?.split(",").map((p) => p.trim()) ?? [];
|
const parts = m[1].split(",").map((p) => p.trim());
|
||||||
if (parts.length < 3) return null;
|
if (parts.length < 3) return null;
|
||||||
const r = Number(parts[0]);
|
const r = Number(parts[0]);
|
||||||
const g = Number(parts[1]);
|
const g = Number(parts[1]);
|
||||||
@@ -65,8 +43,8 @@ interface TrxWebGlGlobals {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHexColor(value: string): Rgba | null {
|
function parseHexColor(value) {
|
||||||
const raw = value.trim();
|
const raw = String(value || "").trim();
|
||||||
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
||||||
let hex = raw.slice(1);
|
let hex = raw.slice(1);
|
||||||
if (hex.length === 3 || hex.length === 4) {
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
@@ -80,10 +58,9 @@ interface TrxWebGlGlobals {
|
|||||||
return [r, g, b, a];
|
return [r, g, b, a];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseCssColor(value: string): Rgba {
|
function parseCssColor(value) {
|
||||||
const key = value;
|
const key = String(value ?? "");
|
||||||
const cached = cssColorCache.get(key);
|
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
|
||||||
if (cached) return [...cached];
|
|
||||||
|
|
||||||
let parsed = parseHexColor(key) || parseRgbString(key);
|
let parsed = parseHexColor(key) || parseRgbString(key);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
@@ -93,18 +70,18 @@ interface TrxWebGlGlobals {
|
|||||||
const computed = getComputedStyle(probe).color;
|
const computed = getComputedStyle(probe).color;
|
||||||
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
||||||
}
|
}
|
||||||
cssColorCache.set(key, [...parsed]);
|
cssColorCache.set(key, parsed.slice());
|
||||||
return [...parsed];
|
return parsed.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hslToRgba(h: number, s: number, l: number, a = 1): Rgba {
|
function hslToRgba(h, s, l, a = 1) {
|
||||||
const hue = ((((h || 0) % 360) + 360) % 360) / 360;
|
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
|
||||||
const sat = Math.max(0, Math.min(1, (s || 0) / 100));
|
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
|
||||||
const lig = Math.max(0, Math.min(1, (l || 0) / 100));
|
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
|
||||||
|
|
||||||
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
||||||
const p = 2 * lig - q;
|
const p = 2 * lig - q;
|
||||||
const hueToRgb = (t: number): number => {
|
const hueToRgb = (t) => {
|
||||||
let tt = t;
|
let tt = t;
|
||||||
if (tt < 0) tt += 1;
|
if (tt < 0) tt += 1;
|
||||||
if (tt > 1) tt -= 1;
|
if (tt > 1) tt -= 1;
|
||||||
@@ -117,29 +94,31 @@ interface TrxWebGlGlobals {
|
|||||||
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
||||||
const g = sat === 0 ? lig : hueToRgb(hue);
|
const g = sat === 0 ? lig : hueToRgb(hue);
|
||||||
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
||||||
return [r, g, b, Math.max(0, Math.min(1, a))];
|
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeColor(input: ColorInput, alphaMul = 1): Rgba {
|
function normalizeColor(input, alphaMul = 1) {
|
||||||
let rgba: Rgba;
|
let rgba;
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
const arr = input;
|
const arr = input.map((v) => Number(v));
|
||||||
if (arr.length >= 4) {
|
if (arr.length >= 4) {
|
||||||
rgba = [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 1];
|
rgba = [arr[0], arr[1], arr[2], arr[3]];
|
||||||
} else {
|
} else {
|
||||||
rgba = [0, 0, 0, 1];
|
rgba = [0, 0, 0, 1];
|
||||||
}
|
}
|
||||||
} else if (typeof input === "string") {
|
} else if (typeof input === "string") {
|
||||||
rgba = parseCssColor(input);
|
rgba = parseCssColor(input);
|
||||||
} else {
|
} else if (input && typeof input === "object") {
|
||||||
rgba = [
|
rgba = [
|
||||||
input.r || 0,
|
Number(input.r) || 0,
|
||||||
input.g || 0,
|
Number(input.g) || 0,
|
||||||
input.b || 0,
|
Number(input.b) || 0,
|
||||||
input.a ?? 1,
|
Number(input.a ?? 1),
|
||||||
];
|
];
|
||||||
|
} else {
|
||||||
|
rgba = [0, 0, 0, 1];
|
||||||
}
|
}
|
||||||
const out: Rgba = [
|
const out = [
|
||||||
Math.max(0, Math.min(1, rgba[0])),
|
Math.max(0, Math.min(1, rgba[0])),
|
||||||
Math.max(0, Math.min(1, rgba[1])),
|
Math.max(0, Math.min(1, rgba[1])),
|
||||||
Math.max(0, Math.min(1, rgba[2])),
|
Math.max(0, Math.min(1, rgba[2])),
|
||||||
@@ -148,9 +127,8 @@ interface TrxWebGlGlobals {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
|
function compileShader(gl, type, source) {
|
||||||
const shader = gl.createShader(type);
|
const shader = gl.createShader(type);
|
||||||
if (shader === null) throw new Error("Unable to create WebGL shader");
|
|
||||||
gl.shaderSource(shader, source);
|
gl.shaderSource(shader, source);
|
||||||
gl.compileShader(shader);
|
gl.compileShader(shader);
|
||||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
@@ -161,7 +139,7 @@ interface TrxWebGlGlobals {
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProgram(gl: WebGLRenderingContext, vertexSrc: string, fragmentSrc: string): WebGLProgram {
|
function createProgram(gl, vertexSrc, fragmentSrc) {
|
||||||
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
||||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
||||||
const program = gl.createProgram();
|
const program = gl.createProgram();
|
||||||
@@ -178,11 +156,11 @@ interface TrxWebGlGlobals {
|
|||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushColoredVertex(target: number[], x: number, y: number, rgba: Rgba) {
|
function pushColoredVertex(target, x, y, rgba) {
|
||||||
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentToQuadVertices(out: number[], x0: number, y0: number, x1: number, y1: number, halfW: number, rgba: Rgba) {
|
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
|
||||||
const dx = x1 - x0;
|
const dx = x1 - x0;
|
||||||
const dy = y1 - y0;
|
const dy = y1 - y0;
|
||||||
const len = Math.hypot(dx, dy);
|
const len = Math.hypot(dx, dy);
|
||||||
@@ -205,33 +183,23 @@ interface TrxWebGlGlobals {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TrxWebGlRenderer {
|
class TrxWebGlRenderer {
|
||||||
readonly canvas: HTMLCanvasElement;
|
constructor(canvas, options = {}) {
|
||||||
readonly options: WebGLContextAttributes;
|
|
||||||
readonly gl: WebGLRenderingContext | null;
|
|
||||||
readonly ready: boolean;
|
|
||||||
readonly textures = new Map<string, TextureEntry>();
|
|
||||||
private _colorScratch = new Float32Array(4096 * 6);
|
|
||||||
private _colorGpuSize = 0;
|
|
||||||
private readonly _texScratch = new Float32Array(6 * 4);
|
|
||||||
private colorProgram!: WebGLProgram;
|
|
||||||
private colorBuffer!: WebGLBuffer | null;
|
|
||||||
private colorLoc!: ColorLocations;
|
|
||||||
private textureProgram!: WebGLProgram;
|
|
||||||
private textureBuffer!: WebGLBuffer | null;
|
|
||||||
private textureLoc!: TextureLocations;
|
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, options: WebGLContextAttributes = {}) {
|
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
||||||
this.gl = (canvas.getContext("webgl", this.options) ||
|
this.gl =
|
||||||
canvas.getContext("experimental-webgl", this.options)) as WebGLRenderingContext | null;
|
canvas?.getContext("webgl", this.options) ||
|
||||||
|
canvas?.getContext("experimental-webgl", this.options) ||
|
||||||
|
null;
|
||||||
this.ready = !!this.gl;
|
this.ready = !!this.gl;
|
||||||
|
this.textures = new Map();
|
||||||
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
|
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
|
||||||
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
|
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
|
||||||
|
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
|
||||||
|
this._colorGpuSize = 0; // current GPU buffer size (floats)
|
||||||
|
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
|
||||||
if (!this.ready) return;
|
if (!this.ready) return;
|
||||||
|
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
if (!gl) return;
|
|
||||||
gl.disable(gl.DEPTH_TEST);
|
gl.disable(gl.DEPTH_TEST);
|
||||||
gl.disable(gl.CULL_FACE);
|
gl.disable(gl.CULL_FACE);
|
||||||
gl.enable(gl.BLEND);
|
gl.enable(gl.BLEND);
|
||||||
@@ -300,8 +268,8 @@ interface TrxWebGlGlobals {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureSize(cssWidth: number, cssHeight: number, dpr = (window.devicePixelRatio || 1)): boolean {
|
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
|
||||||
if (!this.gl) return false;
|
if (!this.ready) return false;
|
||||||
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
||||||
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
||||||
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
||||||
@@ -313,26 +281,24 @@ interface TrxWebGlGlobals {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(color: ColorInput) {
|
clear(color) {
|
||||||
if (!this.gl) return;
|
if (!this.ready) return;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTriangles(vertices: number[]) {
|
drawTriangles(vertices) {
|
||||||
if (!this.gl) return;
|
|
||||||
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTriangleStrip(vertices: number[]) {
|
drawTriangleStrip(vertices) {
|
||||||
if (!this.gl) return;
|
|
||||||
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _drawColorGeometry(vertices: number[], mode: number) {
|
_drawColorGeometry(vertices, mode) {
|
||||||
if (!this.gl || vertices.length === 0) return;
|
if (!this.ready || !vertices || vertices.length === 0) return;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const count = vertices.length;
|
const count = vertices.length;
|
||||||
|
|
||||||
@@ -367,88 +333,88 @@ interface TrxWebGlGlobals {
|
|||||||
gl.drawArrays(mode, 0, count / 6);
|
gl.drawArrays(mode, 0, count / 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
fillRect(x: number, y: number, w: number, h: number, color: ColorInput) {
|
fillRect(x, y, w, h, color) {
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
const v: number[] = [];
|
const v = [];
|
||||||
pushColoredVertex(v, x, y, rgba);
|
pushColoredVertex(v, x, y, rgba);
|
||||||
pushColoredVertex(v, x + w, y, rgba);
|
pushColoredVertex(v, x + w, y, rgba);
|
||||||
pushColoredVertex(v, x + w, y + h, rgba);
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
pushColoredVertex(v, x, y, rgba);
|
pushColoredVertex(v, x, y, rgba);
|
||||||
pushColoredVertex(v, x + w, y + h, rgba);
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
pushColoredVertex(v, x, y + h, rgba);
|
pushColoredVertex(v, x, y + h, rgba);
|
||||||
this.drawTriangles(v);
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
fillGradientRect(x: number, y: number, w: number, h: number, colorTL: ColorInput, colorTR: ColorInput, colorBR: ColorInput, colorBL: ColorInput) {
|
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
const tl = normalizeColor(colorTL);
|
const tl = normalizeColor(colorTL);
|
||||||
const tr = normalizeColor(colorTR);
|
const tr = normalizeColor(colorTR);
|
||||||
const br = normalizeColor(colorBR);
|
const br = normalizeColor(colorBR);
|
||||||
const bl = normalizeColor(colorBL);
|
const bl = normalizeColor(colorBL);
|
||||||
const v: number[] = [];
|
const v = [];
|
||||||
pushColoredVertex(v, x, y, tl);
|
pushColoredVertex(v, x, y, tl);
|
||||||
pushColoredVertex(v, x + w, y, tr);
|
pushColoredVertex(v, x + w, y, tr);
|
||||||
pushColoredVertex(v, x + w, y + h, br);
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
pushColoredVertex(v, x, y, tl);
|
pushColoredVertex(v, x, y, tl);
|
||||||
pushColoredVertex(v, x + w, y + h, br);
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
pushColoredVertex(v, x, y + h, bl);
|
pushColoredVertex(v, x, y + h, bl);
|
||||||
this.drawTriangles(v);
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPolyline(points: number[], color: ColorInput, width = 1) {
|
drawPolyline(points, color, width = 1) {
|
||||||
if (!Array.isArray(points) || points.length < 4) return;
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
const halfW = Math.max(0.5, width || 1) / 2;
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
const verts: number[] = [];
|
const verts = [];
|
||||||
for (let i = 0; i < points.length - 2; i += 2) {
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
segmentToQuadVertices(
|
segmentToQuadVertices(
|
||||||
verts,
|
verts,
|
||||||
points[i] ?? 0, points[i + 1] ?? 0,
|
points[i], points[i + 1],
|
||||||
points[i + 2] ?? 0, points[i + 3] ?? 0,
|
points[i + 2], points[i + 3],
|
||||||
halfW,
|
halfW,
|
||||||
rgba,
|
rgba,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.drawTriangles(verts);
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawSegments(segments: number[], color: ColorInput, width = 1) {
|
drawSegments(segments, color, width = 1) {
|
||||||
if (!Array.isArray(segments) || segments.length < 4) return;
|
if (!Array.isArray(segments) || segments.length < 4) return;
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
const halfW = Math.max(0.5, width || 1) / 2;
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
const verts: number[] = [];
|
const verts = [];
|
||||||
for (let i = 0; i < segments.length - 3; i += 4) {
|
for (let i = 0; i < segments.length - 3; i += 4) {
|
||||||
segmentToQuadVertices(
|
segmentToQuadVertices(
|
||||||
verts,
|
verts,
|
||||||
segments[i] ?? 0, segments[i + 1] ?? 0,
|
segments[i], segments[i + 1],
|
||||||
segments[i + 2] ?? 0, segments[i + 3] ?? 0,
|
segments[i + 2], segments[i + 3],
|
||||||
halfW,
|
halfW,
|
||||||
rgba,
|
rgba,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.drawTriangles(verts);
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawFilledArea(points: number[], baselineY: number, color: ColorInput) {
|
drawFilledArea(points, baselineY, color) {
|
||||||
if (!Array.isArray(points) || points.length < 4) return;
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
const verts: number[] = [];
|
const verts = [];
|
||||||
for (let i = 0; i < points.length; i += 2) {
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
pushColoredVertex(verts, points[i] ?? 0, baselineY, rgba);
|
pushColoredVertex(verts, points[i], baselineY, rgba);
|
||||||
pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba);
|
pushColoredVertex(verts, points[i], points[i + 1], rgba);
|
||||||
}
|
}
|
||||||
this.drawTriangleStrip(verts);
|
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPoints(points: number[], size: number, color: ColorInput) {
|
drawPoints(points, size, color) {
|
||||||
if (!Array.isArray(points) || points.length < 2) return;
|
if (!Array.isArray(points) || points.length < 2) return;
|
||||||
const radius = Math.max(1, size || 1);
|
const radius = Math.max(1, Number(size) || 1);
|
||||||
const rgba = normalizeColor(color);
|
const rgba = normalizeColor(color);
|
||||||
const verts: number[] = [];
|
const verts = [];
|
||||||
for (let i = 0; i < points.length; i += 2) {
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
const x = (points[i] ?? 0) - radius;
|
const x = points[i] - radius;
|
||||||
const y = (points[i + 1] ?? 0) - radius;
|
const y = points[i + 1] - radius;
|
||||||
const w = radius * 2;
|
const w = radius * 2;
|
||||||
const h = radius * 2;
|
const h = radius * 2;
|
||||||
pushColoredVertex(verts, x, y, rgba);
|
pushColoredVertex(verts, x, y, rgba);
|
||||||
@@ -458,15 +424,15 @@ interface TrxWebGlGlobals {
|
|||||||
pushColoredVertex(verts, x + w, y + h, rgba);
|
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||||
pushColoredVertex(verts, x, y + h, rgba);
|
pushColoredVertex(verts, x, y + h, rgba);
|
||||||
}
|
}
|
||||||
this.drawTriangles(verts);
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
drawDashedVerticalLine(x: number, y0: number, y1: number, dashLen: number, gapLen: number, color: ColorInput, width = 1) {
|
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
|
||||||
const dash = Math.max(1, dashLen || 1);
|
const dash = Math.max(1, Number(dashLen) || 1);
|
||||||
const gap = Math.max(1, gapLen || 1);
|
const gap = Math.max(1, Number(gapLen) || 1);
|
||||||
const top = Math.min(y0, y1);
|
const top = Math.min(y0, y1);
|
||||||
const bottom = Math.max(y0, y1);
|
const bottom = Math.max(y0, y1);
|
||||||
const segments: number[] = [];
|
const segments = [];
|
||||||
for (let y = top; y < bottom; y += dash + gap) {
|
for (let y = top; y < bottom; y += dash + gap) {
|
||||||
const segEnd = Math.min(bottom, y + dash);
|
const segEnd = Math.min(bottom, y + dash);
|
||||||
segments.push(x, y, x, segEnd);
|
segments.push(x, y, x, segEnd);
|
||||||
@@ -474,8 +440,8 @@ interface TrxWebGlGlobals {
|
|||||||
this.drawSegments(segments, color, width);
|
this.drawSegments(segments, color, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadRgbaTexture(name: string, width: number, height: number, data: ArrayBufferView, filter: "linear" | "nearest" = "linear"): WebGLTexture | null {
|
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||||||
if (!this.gl || !name) return null;
|
if (!this.ready || !name || !data) return null;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
let entry = this.textures.get(name);
|
let entry = this.textures.get(name);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -520,8 +486,8 @@ interface TrxWebGlGlobals {
|
|||||||
return entry.texture;
|
return entry.texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawTexture(name: string, x: number, y: number, w: number, h: number, alpha = 1, flipY = true) {
|
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
|
||||||
if (!this.gl || !name || w <= 0 || h <= 0) return;
|
if (!this.ready || !name || w <= 0 || h <= 0) return;
|
||||||
const entry = this.textures.get(name);
|
const entry = this.textures.get(name);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
@@ -550,7 +516,7 @@ interface TrxWebGlGlobals {
|
|||||||
gl.enableVertexAttribArray(this.textureLoc.uv);
|
gl.enableVertexAttribArray(this.textureLoc.uv);
|
||||||
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
||||||
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
|
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.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||||
gl.uniform1i(this.textureLoc.tex, 0);
|
gl.uniform1i(this.textureLoc.tex, 0);
|
||||||
@@ -558,7 +524,7 @@ interface TrxWebGlGlobals {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRenderer(canvas: HTMLCanvasElement, options: WebGLContextAttributes = {}) {
|
function createRenderer(canvas, options) {
|
||||||
return new TrxWebGlRenderer(canvas, options);
|
return new TrxWebGlRenderer(canvas, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,4 +532,4 @@ interface TrxWebGlGlobals {
|
|||||||
global.trxHslToRgba = hslToRgba;
|
global.trxHslToRgba = hslToRgba;
|
||||||
global.createTrxWebGlRenderer = createRenderer;
|
global.createTrxWebGlRenderer = createRenderer;
|
||||||
global.trxClearCssColorCache = clearCssColorCache;
|
global.trxClearCssColorCache = clearCssColorCache;
|
||||||
})(window as TrxWebGlGlobals);
|
})(window);
|
||||||
@@ -2,53 +2,8 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// 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};
|
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::<Vec<_>>();
|
|
||||||
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) {
|
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
||||||
let days = secs.div_euclid(86_400);
|
let days = secs.div_euclid(86_400);
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
@@ -65,7 +20,6 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
generate_asset_manifest();
|
|
||||||
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
Ok(d) => d.as_secs() as i64,
|
Ok(d) => d.as_secs() as i64,
|
||||||
Err(_) => 0,
|
Err(_) => 0,
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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<dyn std::error::Error>> {
|
|
||||||
let mut output = format!(
|
|
||||||
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\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(())
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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",
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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 }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
|
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
-->
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
- `plugins/host.ts` declares the typed `window.trx` state and services that
|
|
||||||
feature bundles read; feature entries import it rather than reaching for bare
|
|
||||||
`window` properties, which the module graph does not publish.
|
|
||||||
- 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.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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<string, unknown>;
|
|
||||||
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<AuthSession> {
|
|
||||||
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<AuthSession> {
|
|
||||||
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<void> {
|
|
||||||
const response = await fetch("/auth/logout", { method: "POST" });
|
|
||||||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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<T> = (value: unknown) => value is T;
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
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<T>(path: string, validate: Validator<T>): Promise<T> {
|
|
||||||
return this.request(path, { cache: "no-store" }, validate);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async post<T>(
|
|
||||||
path: string,
|
|
||||||
body: unknown,
|
|
||||||
validate: Validator<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return this.request(
|
|
||||||
path,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
validate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(
|
|
||||||
path: string,
|
|
||||||
init: RequestInit,
|
|
||||||
validate: Validator<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
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<T>(
|
|
||||||
event: MessageEvent<string>,
|
|
||||||
validate: Validator<T>,
|
|
||||||
): 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;
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// 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<Band>, supported_modes: Array<RigMode>, 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<RigVfoEntry>,
|
|
||||||
/**
|
|
||||||
* 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<number> | null, };
|
|
||||||
|
|
||||||
export type SpectrumData = {
|
|
||||||
/**
|
|
||||||
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
|
|
||||||
*/
|
|
||||||
bins: Array<number>,
|
|
||||||
/**
|
|
||||||
* 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<VchanRdsEntry> | 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<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
|
|
||||||
|
|
||||||
export type RigListResponse = { active_remote: string | null, rigs: Array<RigListItem>, };
|
|
||||||
|
|
||||||
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, 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<string>,
|
|
||||||
/**
|
|
||||||
* 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, };
|
|
||||||
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import "./webgl-renderer.js";
|
|
||||||
import "./ui-core.js";
|
|
||||||
import "./plugin-runtime.js";
|
|
||||||
import "./leaflet-ais-tracksymbol.js";
|
|
||||||
import "./app.js";
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export type CborValue = number | string | boolean | null | undefined
|
|
||||||
| CborValue[] | { [key: string]: CborValue };
|
|
||||||
|
|
||||||
interface DecodeState { offset: number }
|
|
||||||
|
|
||||||
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
|
||||||
|
|
||||||
function decodeUint(
|
|
||||||
view: DataView,
|
|
||||||
bytes: Uint8Array,
|
|
||||||
state: DecodeState,
|
|
||||||
additional: number,
|
|
||||||
): number {
|
|
||||||
const offset = state.offset;
|
|
||||||
if (additional < 24) return additional;
|
|
||||||
const widths: Partial<Record<number, number>> = { 24: 1, 25: 2, 26: 4, 27: 8 };
|
|
||||||
const width = widths[additional];
|
|
||||||
if (width === undefined) 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: number): number {
|
|
||||||
const sign = (bits & 0x8000) ? -1 : 1;
|
|
||||||
const exponent = (bits >> 10) & 0x1f;
|
|
||||||
const fraction = bits & 0x03ff;
|
|
||||||
if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024);
|
|
||||||
if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
|
|
||||||
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
|
|
||||||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
|
||||||
const initial = bytes[state.offset++];
|
|
||||||
if (initial === undefined) throw new Error("CBOR payload truncated");
|
|
||||||
const major = initial >> 5;
|
|
||||||
const additional = initial & 0x1f;
|
|
||||||
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: Record<string, CborValue> = {};
|
|
||||||
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 undefined;
|
|
||||||
const widths: Partial<Record<number, number>> = { 25: 2, 26: 4, 27: 8 };
|
|
||||||
const width = widths[additional];
|
|
||||||
if (width === undefined) 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decodeCbor(buffer: ArrayBuffer | Uint8Array): CborValue {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export interface DecoderDescriptor {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
activation: string;
|
|
||||||
active_modes: string[];
|
|
||||||
background_decode: boolean;
|
|
||||||
bookmark_selectable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DecoderRegistryBridge {
|
|
||||||
decoderRegistry?: DecoderDescriptor[];
|
|
||||||
onDecoderRegistryReady?: (callback: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridge = window as DecoderRegistryBridge;
|
|
||||||
const readyCallbacks: Array<() => void> = [];
|
|
||||||
|
|
||||||
export let decoderRegistry: DecoderDescriptor[] = [];
|
|
||||||
|
|
||||||
export function onDecoderRegistryReady(callback: () => void): void {
|
|
||||||
if (decoderRegistry.length > 0) callback();
|
|
||||||
else readyCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyDecoderRegistryVisibility(): void {
|
|
||||||
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
|
||||||
const alwaysShow = new Set(["overview", "rds", "sat"]);
|
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>(
|
|
||||||
"#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<HTMLElement>('[id^="about-dec-"]').forEach((element) => {
|
|
||||||
const id = element.id.replace("about-dec-", "");
|
|
||||||
if (alwaysShow.has(id) || knownIds.has(id)) return;
|
|
||||||
const row = element.closest<HTMLElement>("tr");
|
|
||||||
if (row) row.style.display = "none";
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll<HTMLElement>('[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<HTMLElement>("#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: unknown): value is DecoderDescriptor {
|
|
||||||
if (typeof value !== "object" || value === null) return false;
|
|
||||||
const item = value as Partial<DecoderDescriptor>;
|
|
||||||
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: unknown): DecoderDescriptor[] {
|
|
||||||
if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) {
|
|
||||||
throw new TypeError("The decoder registry response is malformed");
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadDecoderRegistry(onLoaded: () => void): Promise<void> {
|
|
||||||
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: unknown) {
|
|
||||||
console.error("Failed to fetch decoder registry:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary compatibility boundary for lazy modules that have not yet been
|
|
||||||
// changed to receive the registry service through their plugin context.
|
|
||||||
bridge.decoderRegistry = decoderRegistry;
|
|
||||||
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export function escapeHtml(input: unknown): string {
|
|
||||||
return String(input)
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll("\"", """);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export function formatDuration(milliseconds: number): string {
|
|
||||||
const seconds = Math.floor(milliseconds / 1000);
|
|
||||||
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: string[] = [];
|
|
||||||
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(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFrequency(frequencyHz: number): string {
|
|
||||||
if (!Number.isFinite(frequencyHz)) return "--";
|
|
||||||
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
|
|
||||||
if (frequencyHz >= 10_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
|
|
||||||
return `${(frequencyHz / 1_000).toFixed(1)} kHz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFrequencyForStep(frequencyHz: number, stepHz: number): string {
|
|
||||||
if (!Number.isFinite(frequencyHz)) return "--";
|
|
||||||
if (stepHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(6);
|
|
||||||
if (stepHz >= 1_000) return (frequencyHz / 1_000).toFixed(3);
|
|
||||||
if (stepHz >= 1) return String(Math.round(frequencyHz));
|
|
||||||
return formatFrequency(frequencyHz);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFrequencyForHumans(frequencyHz: number): string {
|
|
||||||
if (!Number.isFinite(frequencyHz)) return "--";
|
|
||||||
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
|
|
||||||
if (frequencyHz >= 1_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
|
|
||||||
if (frequencyHz >= 1_000) return `${(frequencyHz / 1_000).toFixed(3)} kHz`;
|
|
||||||
return `${Math.round(frequencyHz)} Hz`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatWavelength(frequencyHz: number): string {
|
|
||||||
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
|
|
||||||
const meters = 299_792_458 / frequencyHz;
|
|
||||||
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseFrequencyInput(
|
|
||||||
value: string,
|
|
||||||
defaultStepHz: number,
|
|
||||||
mode: string,
|
|
||||||
): number | null {
|
|
||||||
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 *= 1_000_000_000;
|
|
||||||
else if (unit.startsWith("mh") || unit === "m") frequency *= 1_000_000;
|
|
||||||
else if (unit.startsWith("kh") || unit === "k") frequency *= 1_000;
|
|
||||||
else if (!unit) {
|
|
||||||
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
|
|
||||||
if (mode.toUpperCase() === "WFM") {
|
|
||||||
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
|
|
||||||
return Math.round(frequency * 1_000_000);
|
|
||||||
}
|
|
||||||
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
|
|
||||||
return Math.round((frequency / 10) * 1_000_000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (defaultStepHz >= 1_000_000) frequency *= 1_000_000;
|
|
||||||
else if (defaultStepHz >= 1_000) frequency *= 1_000;
|
|
||||||
else if (defaultStepHz < 1) {
|
|
||||||
if (frequency < 1_000) frequency *= 1_000_000;
|
|
||||||
else if (frequency < 1_000_000) frequency *= 1_000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Math.round(frequency);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatByteSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export interface LatLon {
|
|
||||||
readonly lat: number;
|
|
||||||
readonly lon: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function locatorToLatLon(locator: unknown): LatLon | null {
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDistanceKm(distanceKm: number): string | null {
|
|
||||||
if (!Number.isFinite(distanceKm)) return null;
|
|
||||||
return distanceKm < 1 ? `${Math.round(distanceKm * 1000)} m` : `${distanceKm.toFixed(1)} km`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTimeAgo(timestampMs: number): string | null {
|
|
||||||
if (!timestampMs) return null;
|
|
||||||
const seconds = Math.round((Date.now() - timestampMs) / 1000);
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function latLonToMaidenhead(lat: number, lon: number): string {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
const STORAGE_PREFIX = "trx_";
|
|
||||||
|
|
||||||
export function saveSetting(key: string, value: unknown): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
|
|
||||||
} catch {
|
|
||||||
// Storage can be unavailable in private browsing or under a strict policy.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadSetting<T>(key: string, fallback: T): T {
|
|
||||||
try {
|
|
||||||
const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
|
||||||
return value === null ? fallback : JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
|
||||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
|
||||||
|
|
||||||
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
|
||||||
main: "/",
|
|
||||||
bookmarks: "/bookmarks",
|
|
||||||
"digital-modes": "/digital-modes",
|
|
||||||
map: "/map",
|
|
||||||
statistics: "/statistics",
|
|
||||||
recorder: "/recorder",
|
|
||||||
settings: "/settings",
|
|
||||||
about: "/about",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function normalizeTabPath(pathname: string): string {
|
|
||||||
const raw = pathname.length > 0 ? pathname : "/";
|
|
||||||
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tabFromPath(pathname: string): TabName {
|
|
||||||
const normalized = normalizeTabPath(pathname);
|
|
||||||
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
|
|
||||||
return match ? match[0] as TabName : "main";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTabHistory(name: TabName, replace = false): void {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
-97
@@ -1,97 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export interface SpectrumFrame {
|
|
||||||
bins: readonly number[] | ArrayBufferView;
|
|
||||||
center_hz: number;
|
|
||||||
sample_rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InterferenceLevels { aci?: number; cci?: number }
|
|
||||||
export type BandwidthLimits = readonly [defaultHz: number, minHz: number, maxHz: number, stepHz: number];
|
|
||||||
|
|
||||||
function clampPercent(value: unknown): number {
|
|
||||||
const numeric = Number(value) || 0;
|
|
||||||
return Math.max(0, Math.min(100, numeric)) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateOccupiedBandwidth(
|
|
||||||
data: SpectrumFrame | null,
|
|
||||||
centerHz: number,
|
|
||||||
mode: string,
|
|
||||||
limits: BandwidthLimits,
|
|
||||||
interference: InterferenceLevels = {},
|
|
||||||
): number | null {
|
|
||||||
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins)
|
|
||||||
|| !Number.isFinite(centerHz)) return null;
|
|
||||||
|
|
||||||
const bins = Array.from(data.bins as ArrayLike<number>);
|
|
||||||
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 ? 12_000 : stepBw) / hzPerBin));
|
|
||||||
const occupiedExtent = (direction: -1 | 1, limitBins: number): number => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export type NumericBins = number[] | Int8Array | Uint8Array | Float32Array | Float64Array;
|
|
||||||
|
|
||||||
export function isNumericBins(value: unknown): value is NumericBins {
|
|
||||||
return Array.isArray(value)
|
|
||||||
? value.every((item) => typeof item === "number")
|
|
||||||
: ArrayBuffer.isView(value) && !(value instanceof DataView);
|
|
||||||
}
|
|
||||||
|
|
||||||
const base64Lookup = new Uint8Array(128).fill(255);
|
|
||||||
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
||||||
for (let index = 0; index < base64Alphabet.length; index += 1) {
|
|
||||||
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
|
|
||||||
}
|
|
||||||
let spectrumBinBuffer = new Int8Array(0);
|
|
||||||
|
|
||||||
export function decodeBase64Int8(value: string): Int8Array {
|
|
||||||
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] : undefined;
|
|
||||||
if (decoded === undefined || 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 & 0xff;
|
|
||||||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 0xff;
|
|
||||||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 0xff;
|
|
||||||
}
|
|
||||||
return spectrumBinBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nthScratch = new Float64Array(0);
|
|
||||||
|
|
||||||
export function nthElement(values: NumericBins, target: number): number | null {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateNoiseFloorDb(bins: unknown): number | null {
|
|
||||||
if (!isNumericBins(bins) || bins.length === 0) return null;
|
|
||||||
return nthElement(bins, Math.floor(bins.length * 0.15));
|
|
||||||
}
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
||||||
interface AisSymbolOptions {
|
|
||||||
heading: number | null;
|
|
||||||
course: number | null;
|
|
||||||
speed: number | null;
|
|
||||||
color: string;
|
|
||||||
outline: string;
|
|
||||||
size: number;
|
|
||||||
interactive: boolean;
|
|
||||||
keyboard: boolean;
|
|
||||||
riseOnHover: boolean;
|
|
||||||
icon?: unknown;
|
|
||||||
}
|
|
||||||
interface LeafletMapAdapter { on(event: string, listener: () => void): void; off(event: string, listener: () => void): void; getZoom(): number }
|
|
||||||
interface TrackSymbolInstance {
|
|
||||||
options: AisSymbolOptions;
|
|
||||||
_icon?: HTMLElement;
|
|
||||||
_map?: LeafletMapAdapter;
|
|
||||||
_boundZoomRefresh?: (() => void) | null;
|
|
||||||
_refreshIcon(): void;
|
|
||||||
}
|
|
||||||
interface TrackSymbolConstructor { new(latlng: unknown, options?: Partial<AisSymbolOptions>): TrackSymbolInstance }
|
|
||||||
interface LeafletAdapter {
|
|
||||||
Marker: {
|
|
||||||
extend(definition: object & ThisType<TrackSymbolInstance>): TrackSymbolConstructor;
|
|
||||||
prototype: {
|
|
||||||
initialize(this: TrackSymbolInstance, latlng: unknown, options: AisSymbolOptions): void;
|
|
||||||
onAdd(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
|
||||||
onRemove(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Util: { extend<T extends object>(target: T, ...sources: object[]): T };
|
|
||||||
divIcon(options: object): unknown;
|
|
||||||
TrxAisTrackSymbol?: TrackSymbolConstructor;
|
|
||||||
trxAisTrackSymbol?: (latlng: unknown, options?: Partial<AisSymbolOptions>) => TrackSymbolInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
(function() {
|
|
||||||
const leaflet = (globalThis as unknown as { L?: LeafletAdapter }).L;
|
|
||||||
if (!leaflet) return;
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteAngle(value: number | null): number | null {
|
|
||||||
if (value === null || !Number.isFinite(value)) return null;
|
|
||||||
const normalized = ((value % 360) + 360) % 360;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function svgColor(value: string, fallback: string): string {
|
|
||||||
const text = value || fallback || "";
|
|
||||||
return text.replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSymbolHtml(options: AisSymbolOptions, zoom: number): string {
|
|
||||||
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
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
|
||||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
|
||||||
`</g>`
|
|
||||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
|
||||||
|
|
||||||
const courseLine = course != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
|
||||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
|
||||||
`</g>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
|
||||||
courseLine +
|
|
||||||
body +
|
|
||||||
`</svg>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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: unknown, options?: Partial<AisSymbolOptions>) {
|
|
||||||
const merged = leaflet.Util.extend({} as AisSymbolOptions, 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: LeafletMapAdapter) {
|
|
||||||
leaflet.Marker.prototype.onAdd.call(this, map);
|
|
||||||
this._refreshIcon();
|
|
||||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
|
||||||
map.on("zoomend", this._boundZoomRefresh);
|
|
||||||
},
|
|
||||||
|
|
||||||
onRemove: function(map: LeafletMapAdapter) {
|
|
||||||
if (this._boundZoomRefresh) {
|
|
||||||
map.off("zoomend", this._boundZoomRefresh);
|
|
||||||
this._boundZoomRefresh = null;
|
|
||||||
}
|
|
||||||
leaflet.Marker.prototype.onRemove.call(this, map);
|
|
||||||
},
|
|
||||||
|
|
||||||
setAisState: function(next: Partial<AisSymbolOptions>) {
|
|
||||||
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: unknown, options?: Partial<AisSymbolOptions>) {
|
|
||||||
const Constructor = leaflet.TrxAisTrackSymbol;
|
|
||||||
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
|
||||||
return new Constructor(latlng, options);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|
||||||
"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"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const loaded = new Set<string>();
|
|
||||||
const loading = new Map<string, Promise<void>>();
|
|
||||||
async function loadPlugin(path: string): Promise<void> {
|
|
||||||
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: unknown) => {
|
|
||||||
loading.delete(path);
|
|
||||||
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
|
|
||||||
});
|
|
||||||
loading.set(path, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPlugins(group: string): Promise<void> {
|
|
||||||
if (!(group in pluginGroups)) return;
|
|
||||||
for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadEagerPlugins(): Promise<void> {
|
|
||||||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPluginsForTab(tab: string): Promise<void> {
|
|
||||||
await loadPlugins(tab);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import type { DecoderPlugin, PluginRuntimeWindow, TrxPluginRuntime } from "./plugins/runtime-contract";
|
|
||||||
|
|
||||||
type QueuedAction =
|
|
||||||
| { kind: "message"; payload: unknown }
|
|
||||||
| { kind: "batch" | "restore"; payload: unknown[] };
|
|
||||||
|
|
||||||
const decoders = new Map<string, DecoderPlugin>();
|
|
||||||
const queued = new Map<string, QueuedAction[]>();
|
|
||||||
const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
|
|
||||||
|
|
||||||
function enqueue(id: string, action: QueuedAction): void {
|
|
||||||
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: DecoderPlugin, action: QueuedAction): boolean {
|
|
||||||
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: string, action: QueuedAction): boolean {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime: TrxPluginRuntime = {
|
|
||||||
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => void {
|
|
||||||
if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
|
|
||||||
const erased = plugin as DecoderPlugin;
|
|
||||||
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 as unknown as PluginRuntimeWindow).trxPluginRuntime = runtime;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export type AprsCategory = "position" | "message" | "weather" | "telemetry" | "other";
|
|
||||||
export type AprsTypeFilter = "all" | AprsCategory;
|
|
||||||
|
|
||||||
export interface AprsPacket {
|
|
||||||
rig_id?: string | null;
|
|
||||||
receiver?: unknown;
|
|
||||||
srcCall?: string;
|
|
||||||
destCall?: string;
|
|
||||||
path?: string;
|
|
||||||
info?: string;
|
|
||||||
info_bytes?: number[];
|
|
||||||
type?: string;
|
|
||||||
crcOk?: boolean;
|
|
||||||
ts_ms?: number | null;
|
|
||||||
lat?: number | null;
|
|
||||||
lon?: number | null;
|
|
||||||
symbolTable?: string | null;
|
|
||||||
symbolCode?: string | null;
|
|
||||||
_tsMs?: number;
|
|
||||||
_ts?: string;
|
|
||||||
src_call?: string;
|
|
||||||
dest_call?: string;
|
|
||||||
packet_type?: string;
|
|
||||||
crc_ok?: boolean;
|
|
||||||
symbol_table?: string | null;
|
|
||||||
symbol_code?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aprsCategoryLabel(category: AprsCategory): string {
|
|
||||||
switch (category) {
|
|
||||||
case "position": return "Position";
|
|
||||||
case "message": return "Message";
|
|
||||||
case "weather": return "Weather";
|
|
||||||
case "telemetry": return "Telemetry";
|
|
||||||
default: return "Other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aprsAgeText(timestampMs: number | undefined): string {
|
|
||||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
|
||||||
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1000);
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aprsPacketSignature(packet: AprsPacket): string {
|
|
||||||
return [packet.srcCall ?? "", packet.destCall ?? "", packet.path ?? "", packet.info ?? "", packet.type ?? "",
|
|
||||||
packet.lat?.toFixed(4) ?? "", packet.lon?.toFixed(4) ?? ""].join("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collapseAprsDuplicates(packets: AprsPacket[]): AprsPacket[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return packets.filter((packet) => {
|
|
||||||
const signature = aprsPacketSignature(packet);
|
|
||||||
if (seen.has(signature)) return false;
|
|
||||||
seen.add(signature);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function aprsHexBytes(bytes: number[] | undefined): string {
|
|
||||||
if (!bytes?.length) return "--";
|
|
||||||
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderAprsInfo(packet: AprsPacket): string {
|
|
||||||
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: number): string {
|
|
||||||
return byte >= 0x20 && byte <= 0x7e
|
|
||||||
? escapeAprsCharacter(String.fromCharCode(byte))
|
|
||||||
: `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAprsCharacter(character: string): string {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code >= 0x20 && code <= 0x7e
|
|
||||||
? escapeAprsCharacter(character)
|
|
||||||
: `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeAprsCharacter(character: string): string {
|
|
||||||
if (character === "<") return "<";
|
|
||||||
if (character === ">") return ">";
|
|
||||||
if (character === "&") return "&";
|
|
||||||
if (character === '"') return """;
|
|
||||||
return character;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
|
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
|
||||||
const symbol = escapeHtml(packet.symbolCode);
|
|
||||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
|
||||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): AprsPacket {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import { initializeFtxDecoder } from "./ftx-family";
|
|
||||||
|
|
||||||
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3_750 });
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import { initializeFtxDecoder } from "./ftx-family";
|
|
||||||
|
|
||||||
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7_500 });
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import {
|
|
||||||
initializeFt8FamilyBar,
|
|
||||||
initializeFtxDecoder,
|
|
||||||
installFtxCompatibilityHelpers,
|
|
||||||
} from "./ftx-family";
|
|
||||||
|
|
||||||
installFtxCompatibilityHelpers();
|
|
||||||
initializeFt8FamilyBar();
|
|
||||||
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15_000, periodDigits: 0 });
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import { hostCore } from "./host.js";
|
|
||||||
|
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
|
||||||
|
|
||||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
|
||||||
|
|
||||||
export interface FtxMessage {
|
|
||||||
message?: string | undefined;
|
|
||||||
ts_ms?: number | undefined;
|
|
||||||
_tsMs?: number | undefined;
|
|
||||||
snr_db?: number | undefined;
|
|
||||||
dt_s?: number | undefined;
|
|
||||||
freq_hz?: number | undefined;
|
|
||||||
receiver?: unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocatorDetails { grid: string; station: string | null; source: string | null; target: string | null }
|
|
||||||
interface BarFrames { count: number; newestTsMs: number; html: string }
|
|
||||||
interface ConfirmApi {
|
|
||||||
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
|
|
||||||
}
|
|
||||||
interface FtxBridge {
|
|
||||||
ft8BaseHz?: number;
|
|
||||||
getDecodeHistoryRetentionMs?: () => number;
|
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
|
||||||
renderFt8Message?: (message: string) => string;
|
|
||||||
ft8EscapeHtml?: (message: string) => string;
|
|
||||||
ft8ExtractLocatorDetails?: (message: string) => LocatorDetails[];
|
|
||||||
ft8ExtractAllGrids?: (message: string) => string[];
|
|
||||||
ft8ExtractLikelyCallsign?: (message: string) => string | null;
|
|
||||||
getDecodeRigMeta?: () => unknown;
|
|
||||||
mapAddLocator?: (
|
|
||||||
raw: string,
|
|
||||||
grids: string[],
|
|
||||||
decoder: FtxDecoderId,
|
|
||||||
station: string | null,
|
|
||||||
message: FtxMessage,
|
|
||||||
) => void;
|
|
||||||
navigateToMapLocator?: (grid: string, decoder: FtxDecoderId) => void;
|
|
||||||
clearMapMarkersByType?: (decoder: FtxDecoderId) => void;
|
|
||||||
setFt8FamilyBarDecoder?: (decoder: FtxDecoderId) => void;
|
|
||||||
updateFt8Bar?: () => void;
|
|
||||||
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
|
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
|
|
||||||
trxUi: ConfirmApi;
|
|
||||||
clearFt8Bar?: () => void;
|
|
||||||
closeFt8Bar?: () => void;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FtxConfig {
|
|
||||||
id: FtxDecoderId;
|
|
||||||
label: "FT2" | "FT4" | "FT8";
|
|
||||||
periodMs: number;
|
|
||||||
periodDigits?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bridge = window as unknown as FtxBridge & PluginRuntimeWindow;
|
|
||||||
|
|
||||||
// Legacy ft8.js owned this formatter locally; the shared module kept reading it
|
|
||||||
// off `window`, where nothing published it, so bar timestamps rendered empty.
|
|
||||||
function formatBarTime(timestampMs: number): string {
|
|
||||||
if (!timestampMs) return "--:--:--";
|
|
||||||
return new Date(timestampMs)
|
|
||||||
.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteNumber(value: unknown): number | null {
|
|
||||||
const number = typeof value === "number" ? value : Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAlphaNumeric(value: string | undefined): boolean {
|
|
||||||
return value !== undefined && /[A-Za-z0-9]/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGrid(value: string): boolean {
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized)
|
|
||||||
&& normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeFtxHtml(input: string): string {
|
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractFtxGrids(message: string): string[] {
|
|
||||||
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenize(message: string): string[] {
|
|
||||||
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCallsign(token: string | undefined): token is string {
|
|
||||||
return token !== undefined && 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractFtxLocatorDetails(message: string): LocatorDetails[] {
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractFtxCallsign(message: string): string | null {
|
|
||||||
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderFtxMessage(message: string): string {
|
|
||||||
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)
|
|
||||||
? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`
|
|
||||||
: escapeFtxHtml(token);
|
|
||||||
index = end;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installFtxCompatibilityHelpers(): void {
|
|
||||||
bridge.renderFt8Message = renderFtxMessage;
|
|
||||||
bridge.ft8EscapeHtml = escapeFtxHtml;
|
|
||||||
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
|
|
||||||
bridge.ft8ExtractAllGrids = extractFtxGrids;
|
|
||||||
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeFt8FamilyBar(): void {
|
|
||||||
const labels: Record<FtxDecoderId, string> = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
|
|
||||||
const builders: Partial<Record<FtxDecoderId, () => BarFrames>> = {};
|
|
||||||
const dismissed: Record<FtxDecoderId, number> = { ft8: 0, ft4: 0, ft2: 0 };
|
|
||||||
const overlay = document.getElementById("ft8-bar-overlay");
|
|
||||||
let active: FtxDecoderId = "ft8";
|
|
||||||
const update = (): void => {
|
|
||||||
if (!overlay) return;
|
|
||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.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 = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initializeFtxDecoder(config: FtxConfig): void {
|
|
||||||
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`) as HTMLInputElement | null;
|
|
||||||
let filterText = "";
|
|
||||||
let history: FtxMessage[] = [];
|
|
||||||
|
|
||||||
const renderMessage = (message: string): string => {
|
|
||||||
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
|
||||||
};
|
|
||||||
const retentionMs = (): number => bridge.getDecodeHistoryRetentionMs?.() ?? 86_400_000;
|
|
||||||
const prune = (): void => {
|
|
||||||
const cutoff = Date.now() - retentionMs();
|
|
||||||
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
|
||||||
};
|
|
||||||
const schedule = (job: () => void): void => {
|
|
||||||
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
|
||||||
else job();
|
|
||||||
};
|
|
||||||
const displayFrequency = (value: unknown): number | null => {
|
|
||||||
const raw = finiteNumber(value);
|
|
||||||
if (raw === null) return null;
|
|
||||||
const base = finiteNumber(bridge.ft8BaseHz);
|
|
||||||
return base !== null && base > 0 && raw >= 0 && raw < 100_000 ? base + raw : raw;
|
|
||||||
};
|
|
||||||
const renderRow = (message: FtxMessage): HTMLDivElement => {
|
|
||||||
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 = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
const render = (): void => {
|
|
||||||
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: FtxMessage): FtxMessage => {
|
|
||||||
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: FtxMessage[]): void => {
|
|
||||||
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 = (): void => {
|
|
||||||
history = [];
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
render();
|
|
||||||
bridge.clearMapMarkersByType?.(id);
|
|
||||||
};
|
|
||||||
const barFrames = (): BarFrames => {
|
|
||||||
const recent = history
|
|
||||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
|
||||||
.slice(0, 8);
|
|
||||||
let html = "";
|
|
||||||
for (const message of recent) {
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
|
|
||||||
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 is string => part !== null).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
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: FtxMessage) => { receiveBatch([message]); },
|
|
||||||
onBatch: receiveBatch,
|
|
||||||
restore: receiveBatch,
|
|
||||||
prune: () => { prune(); render(); },
|
|
||||||
reset,
|
|
||||||
});
|
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
|
||||||
|
|
||||||
const updatePeriod = (): void => {
|
|
||||||
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).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<HTMLElement>(".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 hostCore.postPath(`/toggle_${id}_decode`);
|
|
||||||
} catch (error: unknown) { 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 hostCore.postPath(`/clear_${id}_decode`); reset(); }
|
|
||||||
catch (error: unknown) { console.error(`${label} history clear failed`, error); }
|
|
||||||
})(); });
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// Typed view of the `window.trx` host contract for lazy feature bundles.
|
|
||||||
//
|
|
||||||
// Feature entries are separate esbuild bundles, so they cannot share module
|
|
||||||
// instances with the application entry and must reach application state and
|
|
||||||
// services through the host namespace documented in
|
|
||||||
// `docs/frontend-architecture.md`. Declaring that contract once here keeps the
|
|
||||||
// feature bundles from re-deriving it — and from drifting back to bare `window`
|
|
||||||
// properties, which the module graph no longer publishes.
|
|
||||||
|
|
||||||
export interface HostDecoderDescriptor {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
activation?: string;
|
|
||||||
active_modes?: string[];
|
|
||||||
bookmark_selectable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostState {
|
|
||||||
readonly serverLat: number | null;
|
|
||||||
readonly serverLon: number | null;
|
|
||||||
readonly authEnabled: boolean;
|
|
||||||
readonly authRole: string | null;
|
|
||||||
readonly lastActiveRigId: string | null;
|
|
||||||
readonly lastRigIds: string[];
|
|
||||||
readonly lastRigDisplayNames: Record<string, string>;
|
|
||||||
readonly lastFreqHz: number | null;
|
|
||||||
readonly lastSpectrumData: { sample_rate: number; center_hz: number } | null;
|
|
||||||
readonly jogUnit: number;
|
|
||||||
readonly rxActive: boolean;
|
|
||||||
readonly decoderRegistry: readonly HostDecoderDescriptor[];
|
|
||||||
lastModeName: string;
|
|
||||||
currentBandwidthHz: number;
|
|
||||||
audioChannelOverride: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostCore {
|
|
||||||
postPath(path: string): Promise<unknown>;
|
|
||||||
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
|
|
||||||
armOptimisticFrequency(frequencyHz: number): void;
|
|
||||||
escapeMapHtml(value: string): string;
|
|
||||||
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
||||||
showHint(message: string, durationMs?: number): void;
|
|
||||||
formatFreqForStep(frequencyHz: number, stepHz: number): string;
|
|
||||||
refreshFreqDisplay(): void;
|
|
||||||
setJogDivisor(divisor: number): void;
|
|
||||||
mwDefaultsForMode(mode: string): [number, number, number, number];
|
|
||||||
resetRdsDisplay(): void;
|
|
||||||
positionRdsPsOverlay(): void;
|
|
||||||
updateWfmControls(): void;
|
|
||||||
updateSdrSquelchControlVisibility(): void;
|
|
||||||
updateDocumentTitle(rds: unknown): void;
|
|
||||||
activeChannelRds(): unknown;
|
|
||||||
startRxAudio(): void;
|
|
||||||
stopRxAudio(): void;
|
|
||||||
setRigFrequency(frequencyHz: number): void;
|
|
||||||
syncBandwidthInput(bandwidthHz: number): void;
|
|
||||||
scheduleSpectrumDraw(): void;
|
|
||||||
onDecoderRegistryReady(callback: () => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HostWindow {
|
|
||||||
trx: { state: HostState; core: HostCore };
|
|
||||||
}
|
|
||||||
|
|
||||||
const host = window as unknown as HostWindow;
|
|
||||||
|
|
||||||
export const hostState: HostState = host.trx.state;
|
|
||||||
export const hostCore: HostCore = host.trx.core;
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
export interface DecoderPlugin<TMessage = unknown> {
|
|
||||||
readonly id: string;
|
|
||||||
onMessage?(message: TMessage): void;
|
|
||||||
onBatch?(messages: TMessage[]): void;
|
|
||||||
restore?(messages: TMessage[]): void;
|
|
||||||
reset?(): void;
|
|
||||||
prune?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrxPluginRuntime {
|
|
||||||
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => void;
|
|
||||||
dispatch(id: string, message: unknown): boolean;
|
|
||||||
dispatchBatch(id: string, messages: unknown[]): boolean;
|
|
||||||
restore(id: string, messages: unknown[]): boolean;
|
|
||||||
reset(id: string): boolean;
|
|
||||||
resetAll(): void;
|
|
||||||
prune(id: string): boolean;
|
|
||||||
clearQueued(): void;
|
|
||||||
hasDecoder(id: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginRuntimeWindow { trxPluginRuntime: TrxPluginRuntime }
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user