chore: complete TypeScript migration cleanup
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / frontend (push) Failing after 44s
CI / reuse (push) Failing after 2s

This commit was merged in pull request #22.
This commit is contained in:
sjg
2026-08-01 22:39:37 +02:00
parent a1a8d1d1d3
commit 695434942f
27 changed files with 52 additions and 21815 deletions
+5 -1
View File
@@ -18,7 +18,11 @@ path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-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/leaflet-ais-tracksymbol.js", "src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json",
] ]
SPDX-FileCopyrightText = "2026 Stan Grams <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"
+5 -1
View File
@@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
application served as embedded static assets (gzip-compressed with ETag application served as embedded static assets (gzip-compressed with ETag
caching) from the Actix-Web server. caching) from the Actix-Web server.
## Current asset inventory ## Historical asset inventory
This inventory records the frontend before the TypeScript migration. The
first-party JavaScript copies listed here have since been replaced by strict
TypeScript source and deterministic output under `assets/web/generated`.
| File | Lines | Size | | File | Lines | Size |
|------|------:|-----:| |------|------:|-----:|
+39 -2
View File
@@ -8,7 +8,44 @@ SPDX-License-Identifier: GPL-2.0-or-later
> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/` > **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/`
> >
> **Status**: Proposed > **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 ## 1. Decision
@@ -20,7 +57,7 @@ the main safety benefits.
The migration must be incremental. Every intermediate commit and pull request The migration must be incremental. Every intermediate commit and pull request
must leave the frontend buildable and usable. must leave the frontend buildable and usable.
## 2. Current State ## 2. Baseline State
The frontend currently contains roughly 21,700 lines of first-party The frontend currently contains roughly 21,700 lines of first-party
JavaScript. Its largest components include: JavaScript. Its largest components include:
File diff suppressed because it is too large Load Diff
@@ -1,180 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
if (additional < 24) return additional;
if (additional === 24) {
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 1;
return bytes[offset];
}
if (additional === 25) {
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 2;
return view.getUint16(offset);
}
if (additional === 26) {
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 4;
return view.getUint32(offset);
}
if (additional === 27) {
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getBigUint64(offset);
state.offset += 8;
const numeric = Number(value);
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
return numeric;
}
throw new Error("Unsupported CBOR additional info");
}
function decodeCborFloat16(bits) {
const sign = (bits & 0x8000) ? -1 : 1;
const exponent = (bits >> 10) & 0x1f;
const fraction = bits & 0x03ff;
if (exponent === 0) {
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
}
if (exponent === 0x1f) {
return fraction === 0 ? sign * Infinity : Number.NaN;
}
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
}
function decodeCborItem(view, bytes, state) {
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
const initial = bytes[state.offset++];
const major = initial >> 5;
const additional = initial & 0x1f;
if (major === 0) return decodeCborUint(view, bytes, state, additional);
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
if (major === 2) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return Array.from(chunk);
}
if (major === 3) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.subarray(state.offset, state.offset + length);
state.offset += length;
return 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);
value[String(key)] = decodeCborItem(view, bytes, state);
}
return value;
}
if (major === 6) {
decodeCborUint(view, bytes, state, additional);
return decodeCborItem(view, bytes, state);
}
if (major === 7) {
if (additional === 20) return false;
if (additional === 21) return true;
if (additional === 22) return null;
if (additional === 23) return undefined;
if (additional === 25) {
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
const bits = view.getUint16(state.offset);
state.offset += 2;
return decodeCborFloat16(bits);
}
if (additional === 26) {
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat32(state.offset);
state.offset += 4;
return value;
}
if (additional === 27) {
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat64(state.offset);
state.offset += 8;
return value;
}
}
throw new Error("Unsupported CBOR major type");
}
function decodeCborPayload(buffer) {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const state = { offset: 0 };
const value = decodeCborItem(view, bytes, state);
if (state.offset !== bytes.length) {
throw new Error("Unexpected trailing bytes in decode history payload");
}
return value;
}
async function fetchAndDecodeHistory(url, batchLimit) {
self.postMessage({ type: "status", phase: "fetching" });
const resp = await fetch(url, { credentials: "same-origin" });
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
const payload = await resp.arrayBuffer();
if (!payload || payload.byteLength === 0) {
self.postMessage({ type: "start", total: 0 });
self.postMessage({ type: "done", total: 0 });
return;
}
self.postMessage({ type: "status", phase: "decoding" });
const history = decodeCborPayload(payload);
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
const items = history && Array.isArray(history[key]) ? history[key] : [];
return sum + items.length;
}, 0);
self.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 = 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;
self.postMessage({
type: "group",
kind,
messages,
processed,
total,
});
}
}
self.postMessage({ type: "done", total });
}
self.onmessage = (event) => {
const data = event?.data || {};
if (data?.type !== "fetch-history") return;
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
.catch((err) => {
self.postMessage({
type: "error",
message: err && err.message ? err.message : String(err || "unknown worker failure"),
});
});
};
@@ -1,120 +0,0 @@
(function() {
if (typeof L === "undefined") return;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function finiteAngle(value) {
if (!Number.isFinite(value)) return null;
const normalized = ((Number(value) % 360) + 360) % 360;
return normalized;
}
function svgColor(value, fallback) {
const text = String(value || fallback || "");
return text.replace(/"/g, "&quot;");
}
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);
};
})();
File diff suppressed because it is too large Load Diff
@@ -1,407 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status");
const aisMessagesEl = document.getElementById("ais-messages");
const aisFilterInput = document.getElementById("ais-filter");
const aisBarOverlay = document.getElementById("ais-bar-overlay");
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
const aisVesselCountEl = document.getElementById("ais-vessel-count");
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
const AIS_DEFAULT_A_HZ = 161_975_000;
const AIS_CHANNEL_SPACING_HZ = 50_000;
let aisFilterText = "";
let aisMessageHistory = [];
function currentAisHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAisMessageHistory() {
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
}
function scheduleAisUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAisHistoryRender() {
scheduleAisUi("ais-history", () => renderAisHistory());
}
function scheduleAisBarUpdate() {
scheduleAisUi("ais-bar", () => updateAisBar());
}
function formatAisMhz(freqHz) {
return `${(freqHz / 1_000_000).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 = String(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 = escapeMapHtml(aisDisplayName(msg));
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
if (!url) return label;
return `<a class="title-link" href="${escapeMapHtml(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 (!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 aisMotionText(msg) {
const parts = [
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
msg.heading_deg != null ? `${Number(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 (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
return "";
}
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
function aisLatestByVessel(messages) {
const byMmsi = 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 || 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}">${escapeMapHtml(channel.label)}</span>` +
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` +
`<div class="ais-row-meta">` +
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
`</div>` +
`<div class="ais-row-detail">` +
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") +
`<span>${escapeMapHtml(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 applyAisFilterToAll() {
if (!aisMessagesEl) return;
const rows = aisMessagesEl.querySelectorAll(".ais-message");
rows.forEach((row) => applyAisFilterToRow(row));
}
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 >= 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 ${escapeMapHtml(String(msg.mmsi))}`,
escapeMapHtml(channel.label),
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
distance ? escapeMapHtml(distance) : null,
escapeMapHtml(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";
}
window.updateAisBar = updateAisBar;
window.clearAisBar = function() {
window.resetAisHistoryView();
};
window.resetAisHistoryView = function() {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
renderAisHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
};
function renderAisHistory() {
pruneAisMessageHistory();
if (!aisMessagesEl) {
updateAisSummary();
return;
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < aisMessageHistory.length; i += 1) {
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
}
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 && window.aisMapAddVessel) {
window.aisMapAddVessel(msg);
}
}
function normalizeServerAisMessage(msg) {
return {
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,
};
}
window.onServerAisBatch = function(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 && window.aisMapAddVessel) {
window.aisMapAddVessel(next);
}
normalized.push(next);
}
normalized.reverse();
aisMessageHistory = normalized.concat(aisMessageHistory);
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
};
window.restoreAisHistory = function(messages) {
window.onServerAisBatch(messages);
};
window.pruneAisHistoryView = function() {
pruneAisMessageHistory();
updateAisBar();
renderAisHistory();
};
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ais_decode");
window.resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
}
});
if (aisFilterInput) {
aisFilterInput.addEventListener("input", () => {
aisFilterText = aisFilterInput.value.trim().toUpperCase();
renderAisHistory();
});
}
window.onServerAis = function(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
};
updateAisSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
@@ -1,498 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status");
const aprsPacketsEl = document.getElementById("aprs-packets");
const aprsFilterInput = document.getElementById("aprs-filter");
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
const aprsTotalCountEl = document.getElementById("aprs-total-count");
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aprsFilterText = "";
let aprsPacketHistory = [];
let aprsBarDismissedAtMs = 0;
let aprsOnlyPos = false;
let aprsHideCrc = false;
let aprsCollapseDup = false;
let aprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleAprsUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => renderAprsHistory());
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => updateAprsBar());
}
function renderAprsInfo(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 += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
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 += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
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 (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
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 (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 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() {
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 || 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">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
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
? `<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">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(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">${escapeMapHtml(pkt.srcCall || "--")}</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">${escapeMapHtml(pkt.type || "--")}</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">${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">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">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderAprsRow(visible[i], i === 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 >= 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">&times;</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">${escapeMapHtml(pkt.srcCall)}</span>`;
const dest = escapeMapHtml(pkt.destCall || "");
const info = escapeMapHtml(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";
}
window.updateAprsBar = updateAprsBar;
window.clearAprsBar = function() {
window.resetAprsHistoryView();
};
window.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
window.resetAprsHistoryView = function() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
};
window.pruneAprsHistoryView = function() {
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 && window.aprsMapAddStation) {
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
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,
};
}
window.onServerAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
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 && window.aprsMapAddStation) {
window.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();
};
window.restoreAprsHistory = function(packets) {
window.onServerAprsBatch(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_aprs_decode");
window.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();
});
}
// --- Server-side APRS decode handler ---
window.onServerAprs = function(pkt) {
aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
};
renderAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
@@ -1,410 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
(function () {
"use strict";
function bgdSupportedIds() {
return (window.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 " + 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 " + 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 " + 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 " + r.status);
return r.json();
});
}
function apiGetBookmarks() {
return fetch("/bookmarks").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
});
}
function loadBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
.then(function ([config, bookmarks]) {
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
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) {
var ids = bgdSupportedIds();
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
var explicit = decoders
.map(function (item) { return String(item || "").trim().toLowerCase(); })
.filter(function (item, index, arr) {
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
});
if (explicit.length > 0) return explicit;
// Fall back: infer from mode via mode-bound entries in the registry.
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
return (window.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" || (typeof authEnabled !== "undefined" && !authEnabled);
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) {
var 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) {
var row = document.createElement("label");
row.className = "bgd-checklist-row";
var decoders = bookmarkDecoderKinds(bookmark);
var 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.target.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,
bookmark_ids: Array.isArray(currentConfig && currentConfig.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.");
})
.catch(function (err) {
showToast("Save failed: " + err.message, true);
})
.finally(function () {
if (btn) btn.disabled = false;
});
}
async function resetBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
if (!await window.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.");
})
.catch(function (err) {
showToast("Reset failed: " + err.message, true);
});
}
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
}
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 = Array.isArray(status && status.entries) ? status.entries : [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (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 (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 "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span";
case "waiting_for_spectrum": return "\u25B3 Waiting";
case "waiting_for_user": return "\u25B3 No user";
case "missing_bookmark": return "\u2717 Missing";
case "no_supported_decoders": return "\u2717 Unsupported";
case "disabled": return "\u25B3 Disabled";
case "handled_by_scheduler": return "\u25B3 Scheduler";
case "scheduler_has_control": return "\u25B3 Scheduler";
case "handled_by_virtual_channel": return "\u25B3 VChan";
default: return "\u25B3 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 hz + " Hz";
}
function escHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
var btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
}
function clearBgdDirty() {
bgdDirty = false;
var 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";
}, 3000);
}
function selectAllBookmarks() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
var 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", resetBackgroundDecode);
}
}
window.initBackgroundDecode = initBackgroundDecode;
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
})();
@@ -1,807 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- Bookmarks Tab ---
/** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general";
/** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix, scope) {
const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
var bmList = [];
var bmRevision = 0;
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
var bmOverlayList = [];
var bmOverlayRevision = 0;
let bmFilteredList = [];
let bmEditId = null;
let bmEditScope = null;
let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25;
const bmSelected = new Set();
function bmFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz";
}
function bmEsc(str) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function bmCanControl() {
return (
(typeof authEnabled !== "undefined" && !authEnabled) ||
(typeof authRole !== "undefined" && authRole === "control")
);
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() {
const canCtrl = bmCanControl();
const addBtn = document.getElementById("bm-add-btn");
const selectAllBtn = document.getElementById("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() {
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
return rig || "general";
}
async function bmFetchOverlay() {
const overlayScope = bmListScope();
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
if (!resp.ok) throw new Error("HTTP " + resp.status);
bmOverlayList = await resp.json();
} catch (e) {
console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = [];
}
bmOverlayRevision++;
if (typeof window.syncBookmarkMapLocators === "function") {
window.syncBookmarkMapLocators(bmOverlayList);
}
if (typeof scheduleSpectrumDraw === "function") 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 = [];
}
bmRevision++;
bmSelected.clear();
bmUpdateSelectionUi();
bmSyncAccess();
bmApplyFilters();
bmRefreshCategoryFilter(categoryFilter);
await overlayPromise;
}
function bmApplyFilters() {
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter
? bmList.filter((bm) => String(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 = document.getElementById("bm-category-filter");
const modeSel = document.getElementById("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) => String(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 = document.getElementById("bm-tbody");
const emptyEl = document.getElementById("bm-empty");
const paginatorEl = document.getElementById("bm-paginator");
const pageSummaryEl = document.getElementById("bm-page-summary");
const pageIndicatorEl = document.getElementById("bm-page-indicator");
const prevBtn = document.getElementById("bm-page-prev");
const nextBtn = document.getElementById("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);
}
// Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders() {
return (window.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
.map(d => d.id);
}
// Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders) {
const set = new Set(decoders || []);
(window.decoderRegistry || [])
.filter(d => d.bookmark_selectable)
.forEach(d => {
const el = document.getElementById("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
// Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() {
const container = document.getElementById("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
(window.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 = document.getElementById("bm-form-wrap");
if (!wrap) return;
bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null;
// Rebuild decoder checkboxes from registry (handles race where registry
// loaded after initial build).
bmBuildDecoderCheckboxes();
document.getElementById("bm-id").value = bm ? bm.id : "";
document.getElementById("bm-name").value = bm ? bm.name : "";
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
document.getElementById("bm-mode").value = bm ? bm.mode : "";
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
bmWriteDecoders(bm ? bm.decoders : []);
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
document.getElementById("bm-name").focus();
}
function bmCloseForm() {
const wrap = document.getElementById("bm-form-wrap");
if (wrap) wrap.style.display = "none";
bmEditId = null;
}
function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream)
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
}
if (typeof lastModeName === "string" && lastModeName) {
document.getElementById("bm-mode").value = lastModeName;
}
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
}
// Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (window.decoderRegistry || [])
.filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => {
const btn = document.getElementById(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.map(d => d.id);
bmWriteDecoders(activeDecoders);
}
async function bmSave(e) {
e.preventDefault();
const id = document.getElementById("bm-id").value;
const name = document.getElementById("bm-name").value.trim();
const freqStr = document.getElementById("bm-freq").value;
const freq_hz = parseInt(freqStr, 10);
const mode = document.getElementById("bm-mode").value.trim();
const bwStr = document.getElementById("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
const category = document.getElementById("bm-category-input").value.trim();
const comment = document.getElementById("bm-comment").value.trim();
const decoders = bmReadDecoders();
const formError = document.getElementById("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 ? document.getElementById("bm-name")
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("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(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to save bookmark:", err);
if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
}
}
async function bmDelete(id) {
if (!await window.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 : undefined;
try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE",
});
if (!resp.ok) throw new Error("HTTP " + resp.status);
await bmFetch(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmark:", err);
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
}
}
async function bmApply(bm) {
try {
// --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof modeEl !== "undefined" && modeEl) {
modeEl.value = String(bm.mode || "").toUpperCase();
}
if (bm.bandwidth_hz) {
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bm.bandwidth_hz;
}
window.currentBandwidthHz = bm.bandwidth_hz;
if (typeof syncBandwidthInput === "function") {
syncBandwidthInput(bm.bandwidth_hz);
}
}
if (typeof applyLocalTunedFrequency === "function") {
// Set optimistic guard before applying so SSE cannot snap back.
if (typeof _freqOptimisticSeq !== "undefined") {
++_freqOptimisticSeq;
_freqOptimisticHz = bm.freq_hz;
}
// 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
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => {
if (typeof vchanTakeSchedulerControl === "function") {
await vchanTakeSchedulerControl();
}
const onVirtual = typeof vchanInterceptMode === "function"
&& await vchanInterceptMode(bm.mode);
if (!onVirtual) {
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
if (!bwHandledByVchan) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
}
}
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
// when on a virtual channel, so this call works correctly in both cases.
// It also does its own optimistic update (applyLocalTunedFrequency) but
// 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.
// - Decoders incompatible with the new mode are always turned off
// (even when the bookmark has no explicit decoder selection).
// - For compatible decoders, if the bookmark specifies a set, the
// toggles are driven to match that set; otherwise they're left
// alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
const st = await statusResp.json();
const toggles = [];
for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey];
const compatible = Array.isArray(d.active_modes)
&& d.active_modes.includes(modeUp);
let wanted;
if (!compatible) {
// Always disable decoders that don't apply to the new mode.
wanted = false;
} else if (hasDecoders) {
wanted = bm.decoders.includes(d.id);
} else {
// Mode-compatible and no bookmark selection: leave as-is.
wanted = currentlyOn;
}
if (wanted !== currentlyOn) {
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
}
}
if (toggles.length) await Promise.all(toggles);
})() : Promise.resolve();
// Don't await — let the network calls settle in the background.
// Errors are logged but don't block the UI.
Promise.all([tunePromise, decoderPromise]).catch(
(err) => console.error("Bookmark apply background error:", err)
);
} catch (err) {
console.error("Failed to apply bookmark:", err);
}
}
function bmUpdateSelectionUi() {
const count = bmSelected.size;
const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl;
const btn = document.getElementById("bm-del-selected-btn");
const countEl = document.getElementById("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none";
if (countEl) countEl.textContent = count;
const moveWrap = document.getElementById("bm-move-selected-wrap");
const moveCountEl = document.getElementById("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
if (moveCountEl) moveCountEl.textContent = count;
if (visible) bmPopulateMoveTarget();
const selectAllBtn = document.getElementById("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";
}
}
/** Populate the move-target dropdown with all scopes except the current one. */
function bmPopulateMoveTarget() {
const sel = document.getElementById("bm-move-target");
if (!sel) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? 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 = document.getElementById("bm-move-target")?.value;
if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!await window.trxUi.confirm({
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
danger: false,
})) return;
try {
// Group selected IDs by their owning scope (skip if already in target).
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(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to move bookmarks:", err);
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
}
}
function bmSyncSelectAllCheckbox() {
const selectAll = document.getElementById("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 window.trxUi.confirm({
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete",
})) return;
try {
// Group selected IDs by their owning scope.
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(document.getElementById("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmarks:", err);
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
}
}
/** Populate the scope picker with "General" + one option per rig. */
function bmPopulateScopePicker() {
const picker = document.getElementById("bm-scope-picker");
if (!picker) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
// Preserve current selection if still valid.
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;
}
// --- Event wiring ---
(function initBookmarks() {
// Set initial button visibility (auth may already be resolved by the time
// scripts run if auth is disabled; otherwise bmFetch() will sync it).
bmSyncAccess();
// Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes();
if (typeof window.onDecoderRegistryReady === "function") {
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
// Scope picker
bmPopulateScopePicker();
const scopePicker = document.getElementById("bm-scope-picker");
if (scopePicker) {
scopePicker.addEventListener("change", (e) => {
bmScope = e.target.value;
bmFetch(document.getElementById("bm-category-filter")?.value || "");
});
}
// Refresh list and sync access when the Bookmarks tab is activated
document.querySelector(".tab-bar").addEventListener("click", (e) => {
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
if (!btn) return;
bmFetch(document.getElementById("bm-category-filter").value);
});
// Add Bookmark button — open form and prefill from current rig state
document.getElementById("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null);
bmPrefillFromStatus();
});
// Category filter dropdown
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
bmFetch(e.target.value);
});
// Mode filter dropdown (client-side, no re-fetch)
document.getElementById("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters();
});
// Text search filter (client-side, no re-fetch)
document.getElementById("bm-text-filter").addEventListener("input", () => {
bmApplyFilters();
});
document.getElementById("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1);
});
document.getElementById("bm-page-next").addEventListener("click", () => {
bmChangePage(1);
});
// Form submit
document.getElementById("bm-form").addEventListener("submit", bmSave);
// Form cancel
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = document.getElementById("bm-form-wrap");
if (formWrap) {
formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
bmCloseForm();
}
});
// Select-all checkbox
document.getElementById("bm-select-all").addEventListener("change", (e) => {
const checked = e.target.checked;
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = checked;
if (checked) bmSelected.add(cb.dataset.bmId);
else bmSelected.delete(cb.dataset.bmId);
});
bmUpdateSelectionUi();
});
// Select All (across all pages) button
document.getElementById("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));
}
// Sync visible page checkboxes
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = bmSelected.has(cb.dataset.bmId);
});
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
});
// Delete Selected button
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
bmDeleteSelected();
});
// Move Selected button
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
bmMoveSelected();
});
// Table action buttons and row checkboxes (event delegation)
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
const checkbox = e.target.closest(".bm-row-sel");
if (checkbox) {
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
else bmSelected.delete(checkbox.dataset.bmId);
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) await bmApply(bm);
} else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm);
} else if (delBtn) {
await bmDelete(delBtn.dataset.bmId);
}
});
// Pre-load bookmarks so spectrum markers are visible immediately.
bmFetch("");
})();
@@ -1,451 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto");
const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone");
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
const cwToneGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
: null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range");
const cwBarOverlay = document.getElementById("cw-bar-overlay");
const CW_MAX_LINES = 200;
const CW_TONE_MIN_HZ = 100;
const CW_TONE_MAX_HZ = 10_000;
const CW_WPM_MIN = 5;
const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
let cwBarCurrentLine = null; // accumulates chars until gap/newline
let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
// does not immediately undo the user's choice.
let cwAutoLocalOverride = null;
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);
}
}
window.applyCwAutoUi = applyCwAutoUi;
// Called by app.js render() when a server-state snapshot arrives. Ignores
// the update while cwAutoLocalOverride is set (user change still in-flight).
window.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);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(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">&times;</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 ? `${line.wpm} WPM` : null,
line.tone_hz ? `${line.tone_hz} Hz` : null,
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
`</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
window.updateCwBar = updateCwBar;
window.clearCwBar = function() {
window.resetCwHistoryView();
};
window.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(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = String(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;
if (toneMaxHz < toneMinHz) {
return null;
}
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 (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.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 ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
}
const bins = window.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate);
const centerHz = Number(window.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];
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x];
}
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 window.trxParseCssColor === "function"
? window.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 <= 1000 ? 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]));
}
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))]);
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 = clamped;
}
try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
drawCwTonePicker();
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
} finally {
cwAutoLocalOverride = null;
}
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = wpm;
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (e) { console.error("CW WPM set failed", e); }
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
if (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (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;
await setCwTone(tone);
});
}
window.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
} catch (e) {
console.error("CW history clear failed", e);
}
});
// --- Server-side CW decode handler ---
window.onServerCw = function(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// Append decoded text to output
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || 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) {
cwOutputEl.removeChild(cwOutputEl.firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
// Bar history accumulation (regardless of pause state)
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 = clampCwWpm(evt.wpm);
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
}
}
if (cwTonePickerRaf != null) return;
cwTonePickerRaf = requestAnimationFrame(() => {
cwTonePickerRaf = null;
drawCwTonePicker();
});
};
window.restoreCwHistory = function(events) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
window.onServerCw(evt);
}
};
window.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
@@ -1,207 +0,0 @@
// --- 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 (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft2_decode");
window.resetFt2HistoryView();
} catch (e) { console.error("FT2 history clear failed", e); }
});
window.onServerFt2 = function(msg) {
if (ft2Status) ft2Status.textContent = "Receiving";
const next = normalizeServerFt2Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt2Message(next.history);
};
@@ -1,207 +0,0 @@
// --- 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 (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft4_decode");
window.resetFt4HistoryView();
} catch (e) { console.error("FT4 history clear failed", e); }
});
window.onServerFt4 = function(msg) {
if (ft4Status) ft4Status.textContent = "Receiving";
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt4Message(next.history);
};
@@ -1,486 +0,0 @@
// 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">&times;</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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function ft8IsAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch);
}
function activateFt8HistoryLocator(targetEl) {
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
if (!locatorEl) return false;
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
if (!grid) return false;
if (typeof window.navigateToMapLocator === "function") {
window.navigateToMapLocator(grid, "ft8");
}
return true;
}
function applyFt8FilterToRow(row) {
if (!ft8FilterText) {
row.style.display = "";
return;
}
const message = row.dataset.message || "";
row.style.display = message.includes(ft8FilterText) ? "" : "none";
}
function applyFt8FilterToAll() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyFt8FilterToRow(row));
}
function updateFt8RowRf(row) {
const freqEl = row.querySelector(".ft8-freq");
if (!freqEl) return;
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
if (Number.isFinite(displayFreqHz)) {
freqEl.textContent = displayFreqHz.toFixed(0);
} else {
freqEl.textContent = "--";
}
}
window.updateFt8RfDisplay = function() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => updateFt8RowRf(row));
updateFt8Bar();
};
window.resetFt8HistoryView = function() {
ft8MessagesEl.innerHTML = "";
ft8MessageHistory = [];
updateFt8Bar();
renderFt8History();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
};
if (ft8FilterInput) {
ft8FilterInput.addEventListener("input", () => {
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
renderFt8History();
});
}
if (ft8MessagesEl) {
ft8MessagesEl.addEventListener("click", (event) => {
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
ft8MessagesEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
}
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
ft8DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
await postPath("/toggle_ft8_decode");
} catch (e) {
console.error("FT8 toggle failed", e);
}
});
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_ft8_decode");
window.resetFt8HistoryView();
} catch (e) {
console.error("FT8 history clear failed", e);
}
});
// --- Server-side FT8 decode handler ---
window.onServerFt8 = function(msg) {
ft8Status.textContent = "Receiving";
const next = normalizeServerFt8Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
...msg,
freq_hz: next.rfHz,
locator_details: next.locatorDetails,
});
}
addFt8Message(next.history);
};
@@ -1,444 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
let hfAprsFilterText = "";
let hfAprsPacketHistory = [];
let hfAprsOnlyPos = false;
let hfAprsHideCrc = false;
let hfAprsCollapseDup = false;
let hfAprsTypeFilter = "all";
function currentHfAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneHfAprsPacketHistory() {
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleHfAprsHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
return;
}
renderHfAprsHistory();
}
function hfAprsPacketCategory(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 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 (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
}
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 (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(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) : "",
hfAprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
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() {
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} ${hfAprsAgeText(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 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 += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
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 += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
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");
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 || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = hfAprsAgeText(pkt._tsMs);
const category = hfAprsPacketCategory(pkt);
const categoryLabel = hfAprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
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 hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
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
? `<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">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(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">${escapeMapHtml(pkt.srcCall || "--")}</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">${escapeMapHtml(pkt.type || "--")}</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">${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">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">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
if (!hfAprsPacketsEl) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
}
hfAprsPacketsEl.replaceChildren(fragment);
updateHfAprsSummary();
updateHfAprsChipState();
}
window.resetHfAprsHistoryView = function() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
};
window.pruneHfAprsHistoryView = function() {
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 {
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,
};
}
window.onServerHfAprsBatch = function(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();
};
window.restoreHfAprsHistory = function(packets) {
window.onServerHfAprsBatch(packets);
};
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
});
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_hf_aprs_decode");
window.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();
});
}
// --- Server-side HF APRS decode handler ---
window.onServerHfAprs = function(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
};
renderHfAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
@@ -1,321 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Satellite Pass Scheduling UI
// Manages the satellite overlay section within the background decoding scheduler.
// Communicates with scheduler.js via a thin window API for shared state access.
(function () {
"use strict";
// ── DOM references (cached once) ──────────────────────────────────
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"),
};
// ── Local state ───────────────────────────────────────────────────
let editIdx = null; // null = adding, number = editing
// ── Scheduler bridge ──────────────────────────────────────────────
// These accessors call into scheduler.js via window.schedulerBridge,
// which is set up by scheduler.js after it initializes.
function getBridge() {
return window.schedulerBridge || {};
}
function getConfig() {
const b = getBridge();
return typeof b.getConfig === "function" ? b.getConfig() : null;
}
function getStatus() {
const b = getBridge();
return typeof b.getStatus === "function" ? b.getStatus() : null;
}
function getBookmarks() {
const b = getBridge();
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
}
function markDirty() {
var b = getBridge();
if (typeof b.markDirty === "function") b.markDirty();
}
function bmName(id) {
const bm = getBookmarks().find(function (b) { return b.id === id; });
return bm ? bm.name : String(id || "");
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatFreq(hz) {
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
return hz + " Hz";
}
// ── Satellite config helpers ──────────────────────────────────────
function getSatelliteEntries() {
var config = getConfig();
return (config && config.satellites && Array.isArray(config.satellites.entries))
? config.satellites.entries
: [];
}
function ensureSatelliteConfig() {
var config = getConfig();
if (!config) return { 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;
}
function collectSatelliteConfig() {
var enabled = dom.enabled ? dom.enabled.checked : false;
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
return {
enabled: enabled,
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
entries: getSatelliteEntries(),
};
}
// ── Render: section ───────────────────────────────────────────────
function renderSection() {
var config = getConfig();
var satCfg = (config && config.satellites) || {};
var enabled = !!satCfg.enabled;
if (dom.enabled) dom.enabled.checked = enabled;
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
if (dom.body) dom.body.style.display = enabled ? "" : "none";
renderEntries();
renderPassStatus();
}
// ── Render: entries table ─────────────────────────────────────────
function renderEntries() {
if (!dom.tbody) return;
var entries = getSatelliteEntries();
var frag = document.createDocumentFragment();
entries.forEach(function (entry, idx) {
var tr = document.createElement("tr");
var tdSat = document.createElement("td");
tdSat.textContent = entry.satellite || "";
tr.appendChild(tdSat);
var tdNorad = document.createElement("td");
tdNorad.textContent = entry.norad_id || "";
tr.appendChild(tdNorad);
var tdBm = document.createElement("td");
tdBm.textContent = bmName(entry.bookmark_id);
tr.appendChild(tdBm);
var tdEl = document.createElement("td");
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
tr.appendChild(tdEl);
var tdPrio = document.createElement("td");
tdPrio.textContent = entry.priority || 0;
tr.appendChild(tdPrio);
var tdActions = document.createElement("td");
var editBtn = document.createElement("button");
editBtn.className = "sch-write";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", function () {
openForm(entry, idx);
});
tdActions.appendChild(editBtn);
var 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);
}
// ── Render: pass status ───────────────────────────────────────────
function renderPassStatus() {
if (!dom.passStatus) return;
var entries = getSatelliteEntries();
if (entries.length === 0) {
dom.passStatus.innerHTML = "";
return;
}
var 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>';
}
}
// ── Render: bookmark dropdown ─────────────────────────────────────
function renderBookmarkSelect(selectedId) {
if (!dom.bookmark) return;
dom.bookmark.innerHTML = '<option value="">— none —</option>';
getBookmarks().forEach(function (bm) {
var 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;
dom.bookmark.appendChild(opt);
});
}
// ── Entry management ──────────────────────────────────────────────
function removeEntry(idx) {
var sat = ensureSatelliteConfig();
sat.entries.splice(idx, 1);
renderEntries();
markDirty();
}
// ── Form: open ────────────────────────────────────────────────────
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 ? (entry.norad_id || "") : "";
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
renderBookmarkSelect(entry ? entry.bookmark_id : null);
if (dom.formWrap) {
dom.formWrap.style.display = "flex";
if (dom.name) dom.name.focus();
}
}
// ── Form: close ───────────────────────────────────────────────────
function closeForm() {
if (dom.formWrap) dom.formWrap.style.display = "none";
editIdx = null;
}
// ── Form: submit ──────────────────────────────────────────────────
function onFormSubmit(e) {
e.preventDefault();
var satellite = dom.name ? dom.name.value.trim() : "";
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; }
if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; }
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
var sat = ensureSatelliteConfig();
var entryData = {
satellite: 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) {
var existing = sat.entries[editIdx];
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
sat.entries[editIdx] = entryData;
} else {
entryData.id = "sat_" + Date.now().toString(36);
sat.entries.push(entryData);
}
closeForm();
renderEntries();
markDirty();
}
// ── Preset change handler ─────────────────────────────────────────
function onPresetChange() {
if (!dom.preset || !dom.preset.value) return;
var parts = dom.preset.value.split("|");
if (dom.name) dom.name.value = parts[0] || "";
if (dom.norad) dom.norad.value = parts[1] || "";
}
// ── Wire all events ───────────────────────────────────────────────
function wireEvents() {
if (dom.enabled) {
dom.enabled.addEventListener("change", function () {
if (dom.body) dom.body.style.display = dom.enabled.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);
}
// ── Public API ────────────────────────────────────────────────────
window.satScheduler = {
wireEvents: wireEvents,
renderSection: renderSection,
renderPassStatus: renderPassStatus,
collectSatelliteConfig: collectSatelliteConfig,
};
})();
@@ -1,546 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- SAT Plugin ---
// Live view: decoder state, latest image card
// History view: filterable table of all decoded images
// Predictions view: next 24 h passes for ham satellites
// ── DOM references (cached once) ───────────────────────────────────
const 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"),
};
// ── State ───────────────────────────────────────────────────────────
let satImageHistory = [];
const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false;
let satFilterText = "";
let satActiveView = "live"; // "live" | "history" | "predictions"
let satPredData = [];
let satPredFilterText = "";
let satPredMinEl = 0;
let satPredCategory = "all";
let satPredSatCount = 0;
let satPredCountdownTimer = null;
// ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
// ── View switching ──────────────────────────────────────────────────
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;
loadSatPredictions();
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
window.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
// ── Live view: decoder state ────────────────────────────────────────
let _lastSatLrptOn = null;
window.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 \u2014 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];
const decoder = img._decoder || "unknown";
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() : "";
let 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(" &middot; ")}</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;
}
// ── History view: table ─────────────────────────────────────────────
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 decoder = img._decoder || "unknown";
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 (let i = 0; i < items.length; i += 1) {
fragment.appendChild(renderSatHistoryRow(items[i]));
}
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`;
}
}
// ── Add image to history ────────────────────────────────────────────
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());
}
}
// ── Server callbacks ────────────────────────────────────────────────
window.onServerLrptProgress = function (msg) {
if (satDom.status && msg.mcu_count > 0) {
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
}
};
window.onServerLrptImage = function (msg) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
window.addSatMapOverlay(msg);
}
};
window.resetSatHistoryView = function () {
satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard();
renderSatHistoryTable();
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
};
window.pruneSatHistoryView = function () {
renderSatHistoryTable();
renderSatLatestCard();
};
// ── Toggle buttons ──────────────────────────────────────────────────
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
});
// ── Filter / sort event listeners ───────────────────────────────────
satDom.filterInput?.addEventListener("input", () => {
satFilterText = satDom.filterInput.value.trim().toUpperCase();
renderSatHistoryTable();
});
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
// ── Settings: clear history ─────────────────────────────────────────
document
.getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_lrpt_decode");
window.resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
}
});
// ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8];
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = 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 / 1000));
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";
}
// ── Predictions: countdown timer management ─────────────────────────
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container ? 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 = parseInt(el.dataset.los, 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1000);
}
// ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${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)}\u00B0</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)} \u2192 ${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)}\u00B0</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`,
].join("");
return row;
}
// ── Predictions: filter state ───────────────────────────────────────
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());
}
satDom.predFilter?.addEventListener("input", () => {
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
applyPredFilters();
});
satDom.predMinEl?.addEventListener("change", () => {
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
applyPredFilters();
});
satDom.predCategory?.addEventListener("change", () => {
satPredCategory = satDom.predCategory.value;
applyPredFilters();
});
// ── Predictions: main render ────────────────────────────────────────
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);
// ── Current passes ──
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);
}
}
// ── Upcoming passes ──
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\u2026</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
// ── Status ──
if (satDom.predStatus) {
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
// ── Countdown timer ──
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
// ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
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 (e) {
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
}
}
// ── Navigate to map centered on satellite image bounds ──────────────
window.satShowOnMap = function (south, west, north, east) {
if (typeof window.enableMapSourceFilter === "function") {
window.enableMapSourceFilter("sat");
}
const lat = (south + north) / 2;
const lon = (west + east) / 2;
if (window.navigateToAprsMap) {
window.navigateToAprsMap(lat, lon);
}
};
// ── Initial render ──────────────────────────────────────────────────
renderSatLatestCard();
renderSatHistoryTable();
File diff suppressed because it is too large Load Diff
@@ -1,565 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- Virtual Channels Plugin ---
//
// Handles the `session` and `channels` SSE events emitted by /events and
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
let vchanSessionId = null;
let vchanRigId = null;
let vchanChannels = [];
let vchanActiveId = null;
let schedulerReleaseState = null;
let schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz";
}
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(vchanPollSchedulerRelease, 10000);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? 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);
}
}
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
// Called by app.js when the SSE `session` event arrives.
function vchanHandleSession(data) {
try {
const d = JSON.parse(data);
vchanSessionId = d.session_id || null;
vchanPollSchedulerRelease();
} catch (e) {
console.warn("vchan: bad session event", e);
}
}
// Called by app.js when the SSE `channels` event arrives.
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));
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
// First channels event for this session — auto-subscribe to channel 0
// so we join the same tuned channel as other users on this rig.
// Use a direct subscribe (no scheduler control takeover) to avoid
// side-effects on initial connect.
vchanAutoJoinPrimary(vchanChannels[0].id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
// Active channel was evicted — fall back to channel 0 and reconnect audio.
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
vchanReconnectAudio();
}
vchanRender();
vchanRenderSchedulerRelease();
if (typeof renderRdsOverlays === "function") 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 = "\u00d7";
del.title = "Delete channel";
del.addEventListener("click", e => {
e.stopPropagation();
vchanDelete(ch.id);
});
btn.appendChild(del);
}
btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
});
picker.appendChild(btn);
});
// "+" button — allocate a new channel at the current VFO frequency.
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", vchanAllocate);
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
? 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;
// The SSE `channels` event will trigger vchanRender(); optimistically
// mark active so the picker feels responsive even before the event arrives.
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);
}
// Channel list updates via SSE `channels` event.
} catch (e) {
console.error("vchan: delete error", e);
}
}
// Lightweight auto-join for initial connect: registers the session on
// channel 0 without taking scheduler control or reconnecting audio
// (audio isn't started yet at this point).
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);
}
}
// Reconnect the audio WebSocket to the appropriate endpoint:
// - virtual channel: /audio?channel_id=<uuid>
// - primary channel: /audio (no param)
// Always updates _audioChannelOverride so that starting audio later
// connects to the correct channel. Only reconnects if RX audio is active.
function vchanReconnectAudio() {
// Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
if (typeof _audioChannelOverride !== "undefined") {
_audioChannelOverride = ch ? ch.id : null;
}
if (typeof rxActive === "undefined" || !rxActive) return;
if (typeof stopRxAudio === "function") stopRxAudio();
// 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
// just needs to be long enough for the WS upgrade to reach the server.
setTimeout(() => {
if (typeof startRxAudio === "function") startRxAudio();
}, 300);
}
// Called by app.js from applyCapabilities().
// Shows the channel picker only for SDR rigs.
function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
vchanRenderSchedulerRelease();
}
// ---------------------------------------------------------------------------
// Freq / mode interception + UI accent
// ---------------------------------------------------------------------------
// Returns true when the active channel is a non-primary (virtual) channel.
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0].id;
}
function vchanActiveChannel() {
return vchanChannels.find(c => c.id === vchanActiveId) || null;
}
// Update the main freq input to show the virtual channel's frequency.
function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel();
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
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.
// Called whenever the active channel changes or the channel list is refreshed.
function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode");
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof lastModeName !== "undefined") {
if (modeUpper === "WFM" && lastModeName !== "WFM") {
if (typeof setJogDivisor === "function") setJogDivisor(10);
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
}
lastModeName = modeUpper;
}
if (typeof updateWfmControls === "function") updateWfmControls();
if (typeof updateSdrSquelchControlVisibility === "function") {
updateSdrSquelchControlVisibility();
}
if (typeof refreshRdsUi === "function") {
refreshRdsUi();
} else if (typeof positionRdsPsOverlay === "function") {
positionRdsPsOverlay();
}
}
// Sync the BW input to the active virtual channel's bandwidth.
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel();
if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
if (typeof currentBandwidthHz !== "undefined") {
currentBandwidthHz = bwHz;
window.currentBandwidthHz = bwHz;
} else {
window.currentBandwidthHz = bwHz;
}
}
}
// Add / remove the vchan accent class from the freq and BW inputs.
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 if (typeof _origRefreshFreqDisplay === "function") {
_origRefreshFreqDisplay();
}
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
updateDocumentTitle(activeChannelRds());
}
}
// Saved reference to the original refreshFreqDisplay from app.js.
let _origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window.
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
lastSpectrumData.sample_rate > 0) {
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
const center = Number(lastSpectrumData.center_hz);
if (Math.abs(freqHz - center) > halfSpan) {
if (typeof showHint === "function") {
showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
}
return;
}
}
// Fire-and-forget: scheduler control + channel freq PUT run in background.
vchanTakeSchedulerControl();
fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
}
).catch(e => console.error("vchan: set freq error", e));
}
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);
}
}
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
// sending /set_mode to the server. Returns true if the change was handled
// by the virtual channel (caller should skip the server request).
window.vchanInterceptMode = async function(mode) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode);
return true;
};
// Called by app.js bandwidth setters before sending /set_bandwidth to the
// server. Returns true if the change was handled by the virtual channel.
window.vchanInterceptBandwidth = async function(bwHz) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelBandwidth(bwHz);
return true;
};
// 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() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
btn.addEventListener("click", () => {
vchanToggleSchedulerRelease();
});
}
vchanStartSchedulerReleasePolling();
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();
};
})();
@@ -1,352 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- VDES Decoder Plugin (server-side decode) ---
const vdesStatus = document.getElementById("vdes-status");
const vdesMessagesEl = document.getElementById("vdes-messages");
const vdesFilterInput = document.getElementById("vdes-filter");
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
let vdesFilterText = "";
let vdesMessageHistory = [];
function currentVdesHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneVdesMessageHistory() {
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
}
function scheduleVdesUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.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 / 1_000_000).toFixed(3)} MHz`;
}
function vdesAgeText(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 vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes
.slice(0, 20)
.map((value) => Number(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 applyVdesFilterToAll() {
if (!vdesMessagesEl) return;
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
}
function renderVdesRow(msg) {
const row = document.createElement("div");
row.className = "vdes-message";
const ts = msg._ts || 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">${escapeMapHtml(title)}</span>` +
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
`</div>` +
`<div class="vdes-row-meta">` +
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
`</div>` +
`<div class="vdes-row-detail">` +
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
(previewText ? `<span>·</span>` : "") +
`<span class="vdes-raw">${escapeMapHtml(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 >= 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 = escapeMapHtml(msg.callsign || "VDES");
const title = escapeMapHtml(msg.vessel_name || "Burst");
const detail = [
`${msg.bit_len || 0} bits`,
msg.message_label ? escapeMapHtml(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 ? escapeMapHtml(msg.destination) : null,
escapeMapHtml(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";
}
window.updateVdesBar = updateVdesBar;
window.clearVdesBar = function() {
window.resetVdesHistoryView();
};
window.resetVdesHistoryView = function() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
};
function renderVdesHistory() {
pruneVdesMessageHistory();
if (!vdesMessagesEl) {
updateVdesSummary();
return;
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
}
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 {
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,
};
}
window.onServerVdesBatch = function(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 && window.vdesMapAddPoint) {
window.vdesMapAddPoint(next);
}
normalized.push(next);
}
normalized.reverse();
vdesMessageHistory = normalized.concat(vdesMessageHistory);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
};
window.restoreVdesHistory = function(messages) {
window.onServerVdesBatch(messages);
};
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_vdes_decode");
window.resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
});
if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => {
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
renderVdesHistory();
});
}
window.onServerVdes = function(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
window.vdesMapAddPoint(next);
}
};
window.pruneVdesHistoryView = function() {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
};
updateVdesSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
@@ -1,386 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// Live view: decoder state, live canvas, latest image card
// History view: filterable table of all decoded images
// ---------------------------------------------------------------------------
// ── DOM references (cached once) ───────────────────────────────────
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'),
};
// ── State ───────────────────────────────────────────────────────────
var wefaxImageHistory = [];
var WEFAX_MAX_IMAGES = 100;
var wefaxLiveCtx = null;
var wefaxLiveLineCount = 0;
var wefaxLivePixelsPerLine = 1809;
var wefaxActiveView = 'live';
var wefaxFilterText = '';
// ── Helpers ─────────────────────────────────────────────────────────
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function scheduleWefaxUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === 'function') {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
// ── View switching ──────────────────────────────────────────────────
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'); });
// ── Live canvas rendering ───────────────────────────────────────────
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxDom.liveCanvas.width = pixelsPerLine;
wefaxDom.liveCanvas.height = 800;
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
}
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
var y = wefaxLiveLineCount;
if (y >= wefaxDom.liveCanvas.height) {
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
wefaxDom.liveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
var w = wefaxLivePixelsPerLine;
var imgData = wefaxLiveCtx.createImageData(w, 1);
var d = imgData.data;
for (var x = 0; x < w; x++) {
var v = x < lineBytes.length ? lineBytes[x] : 0;
var i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// ── Live view: latest image card ────────────────────────────────────
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;
}
var img = wefaxImageHistory[0];
var ts = img._ts || '--';
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
var meta = [
img.ioc + ' IOC',
img.lpm + ' LPM',
img.line_count + ' lines',
date + ' ' + ts,
].join(' \u00b7 ');
var imgSrc = img._dataUrl
? img._dataUrl
: img.path
? '/images/' + escapeHtml(img.path.split('/').pop())
: null;
var 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;
}
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
var items = wefaxImageHistory;
if (wefaxFilterText) {
items = items.filter(function (i) {
var haystack = [
String(i.ioc || ''),
String(i.lpm || ''),
String(i.line_count || ''),
].join(' ').toUpperCase();
return haystack.indexOf(wefaxFilterText) >= 0;
});
}
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
if (sortVal === 'oldest') items = items.slice().reverse();
return items;
}
function renderWefaxHistoryRow(img) {
var row = document.createElement('div');
row.className = 'sat-history-row';
var ts = img._ts || '--';
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
var ioc = img.ioc || '--';
var lpm = img.lpm || '--';
var lines = img.line_count || 0;
var imgSrc = img._dataUrl
? img._dataUrl
: img.path
? '/images/' + escapeHtml(img.path.split('/').pop())
: null;
var 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>' + lines + '</span>',
'<span>' + link + '</span>',
].join('');
return row;
}
function renderWefaxHistoryTable() {
if (!wefaxDom.historyList) return;
pruneWefaxHistory();
var items = getWefaxFilteredHistory();
var fragment = document.createDocumentFragment();
for (var i = 0; i < items.length; i++) {
fragment.appendChild(renderWefaxHistoryRow(items[i]));
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
var total = wefaxImageHistory.length;
var shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
? 'No images yet'
: shown === total
? total + ' image' + (total === 1 ? '' : 's')
: shown + ' of ' + total + ' images';
}
}
// ── Add image to history ────────────────────────────────────────────
function addWefaxImage(msg) {
var 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',
});
// Capture the live canvas as a data URI for thumbnails.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
}
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);
}
}
// ── SSE event handlers (public API) ─────────────────────────────────
window.onServerWefaxProgress = function (msg) {
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
// Highlight active states, dim idle/scanning.
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
}
return;
}
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
if (msg.line_data) {
var binary = atob(msg.line_data);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
if (wefaxDom.liveInfo) {
wefaxDom.liveInfo.textContent =
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
}
if (wefaxDom.status) {
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
wefaxDom.status.style.color = 'var(--text-accent)';
}
};
window.onServerWefax = function (msg) {
addWefaxImage(msg);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
wefaxDom.status.style.color = '';
}
};
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (var i = 0; i < messages.length; i++) {
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
messages[i]._tsMs = tsMs;
messages[i]._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);
}
};
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
renderWefaxHistoryTable();
renderWefaxLatestCard();
};
window.resetWefaxHistoryView = function () {
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 = '';
}
};
// ── Filter / sort handlers ──────────────────────────────────────────
if (wefaxDom.filterInput) {
wefaxDom.filterInput.addEventListener('input', function () {
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
});
}
if (wefaxDom.sortSelect) {
wefaxDom.sortSelect.addEventListener('change', function () {
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
});
}
// ── Toggle button sync ──────────────────────────────────────────────
// Sync the Enable/Disable button from the SSE state update. This is
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
// WEFAX button always reflects the server state.
window.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' : '';
};
// ── Button handlers ─────────────────────────────────────────────────
if (wefaxDom.toggleBtn) {
wefaxDom.toggleBtn.addEventListener('click', async function () {
try {
if (window.takeSchedulerControlForDecoderDisable) {
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
}
await postPath('/toggle_wefax_decode');
} catch (e) {
console.error('WEFAX toggle failed', e);
}
});
}
if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener('click', async function () {
try {
await postPath('/clear_wefax_decode');
window.resetWefaxHistoryView();
} catch (e) {
console.error('WEFAX clear failed', e);
}
});
}
// ── Initial render ──────────────────────────────────────────────────
renderWefaxLatestCard();
@@ -1,292 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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 = [];
function currentWsprHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneWsprMessageHistory() {
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleWsprHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
return;
}
renderWsprHistory();
}
function fmtWsprTime(tsMs) {
if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function updateWsprPeriodTimer() {
if (!wsprPeriodEl) return;
const nowSec = Math.floor(Date.now() / 1000);
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
const ss = String(remaining % 60).padStart(2, "0");
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
}
updateWsprPeriodTimer();
setInterval(updateWsprPeriodTimer, 500);
function renderWsprRow(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
row.dataset.decoder = "wspr";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
const message = (msg.message || "").toString();
row.dataset.message = message.toUpperCase();
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);
return row;
}
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
}
wsprMessagesEl.replaceChildren(fragment);
}
function addWsprMessage(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
wsprMessageHistory.unshift(msg);
pruneWsprMessageHistory();
scheduleWsprHistoryRender();
}
function normalizeServerWsprMessage(msg) {
const raw = (msg.message || "").toString();
const grids = extractAllGrids(raw);
const station = extractLikelyCallsign(raw);
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
? (baseHz + Number(msg.freq_hz))
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
return {
raw,
grids,
station,
rfHz,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
dt_s: msg.dt_s,
freq_hz: msg.freq_hz,
message: raw,
},
};
}
window.onServerWsprBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
freq_hz: next.rfHz,
});
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
wsprMessageHistory = normalized.concat(wsprMessageHistory);
pruneWsprMessageHistory();
scheduleWsprHistoryRender();
};
window.restoreWsprHistory = function(messages) {
window.onServerWsprBatch(messages);
};
window.pruneWsprHistoryView = function() {
pruneWsprMessageHistory();
renderWsprHistory();
};
function escapeWsprHtml(input) {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function renderWsprMessage(message) {
let out = "";
let i = 0;
while (i < message.length) {
const ch = message[i];
if (isAlphaNum(ch)) {
let j = i + 1;
while (j < message.length && isAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (isMaidenheadGridToken(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 += escapeWsprHtml(token);
}
i = j;
} else {
out += escapeWsprHtml(ch);
i += 1;
}
}
return out;
}
function extractAllGrids(message) {
const out = [];
const seen = new Set();
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
for (const token of parts) {
if (!token) continue;
if (isMaidenheadGridToken(token) && !seen.has(token)) {
seen.add(token);
out.push(token);
}
}
return out;
}
function extractLikelyCallsign(message) {
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
for (const token of parts) {
if (!token) continue;
if (token.length < 3 || token.length > 12) continue;
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
if (isMaidenheadGridToken(token)) continue;
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
}
return null;
}
function isFtxFarewellToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR";
}
function isMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
}
function isAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch);
}
function activateWsprHistoryLocator(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, "wspr");
}
return true;
}
function applyWsprFilterToRow(row) {
if (!wsprFilterText) {
row.style.display = "";
return;
}
const message = row.dataset.message || "";
row.style.display = message.includes(wsprFilterText) ? "" : "none";
}
function applyWsprFilterToAll() {
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyWsprFilterToRow(row));
}
window.resetWsprHistoryView = function() {
wsprMessagesEl.innerHTML = "";
wsprMessageHistory = [];
renderWsprHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
};
if (wsprFilterInput) {
wsprFilterInput.addEventListener("input", () => {
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
renderWsprHistory();
});
}
if (wsprMessagesEl) {
wsprMessagesEl.addEventListener("click", (event) => {
if (!activateWsprHistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
wsprMessagesEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (!activateWsprHistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
}
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
wsprDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await postPath("/toggle_wspr_decode");
} catch (e) {
console.error("WSPR toggle failed", e);
}
});
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_wspr_decode");
window.resetWsprHistoryView();
} catch (e) {
console.error("WSPR history clear failed", e);
}
});
window.onServerWspr = function(msg) {
wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
freq_hz: next.rfHz,
});
}
addWsprMessage(next.history);
};
@@ -1,265 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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";
const T = window.trx;
function isVisibleForSnapshot(el) {
if (!el) return false;
const style = getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return false;
const opacity = Number(style.opacity);
if (Number.isFinite(opacity) && opacity <= 0) return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function drawRoundedRectPath(ctx, x, y, w, h, r) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
if (!isVisibleForSnapshot(el)) return null;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const x = rect.left - rootRect.left;
const y = rect.top - rootRect.top;
const w = rect.width;
const h = rect.height;
const radius = parseFloat(style.borderTopLeftRadius) || 0;
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
ctx.fill();
}
const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) {
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
ctx.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
ctx.stroke();
}
return { x, y, w, h, style };
}
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) return;
let line = "";
let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i];
if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate;
continue;
}
ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1;
if (lineIdx >= maxLines) return;
line = words[i];
}
if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight);
}
}
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
if (!chrome) return;
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
if (!clean) return;
const style = chrome.style;
const fontSize = parseFloat(style.fontSize) || 12;
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
const padX = 6;
const padY = 4;
const maxWidth = Math.max(20, chrome.w - padX * 2);
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "top";
const lines = clean.split(/\n+/);
let lineCursor = 0;
for (const line of lines) {
if (lineCursor >= maxLines) break;
drawWrappedText(
ctx,
line,
chrome.x + padX,
chrome.y + padY + lineCursor * lineHeight,
maxWidth,
lineHeight,
maxLines - lineCursor,
);
lineCursor += 1;
}
}
function drawAxisLabels(ctx, axisEl, rootRect) {
if (!isVisibleForSnapshot(axisEl)) return;
for (const node of axisEl.children) {
if (!(node instanceof HTMLElement)) continue;
if (!(node.matches("span") || node.matches("button"))) continue;
if (!isVisibleForSnapshot(node)) continue;
const chrome = drawElementChrome(ctx, node, rootRect);
const text = (node.textContent || "").trim();
if (!chrome || !text) continue;
const style = chrome.style;
ctx.fillStyle = style.color || "#ffffff";
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
ctx.textBaseline = "middle";
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
}
}
function buildSpectrumSnapshotCanvas() {
const rootEl = document.querySelector(".signal-visual-block");
const spectrumPanelEl = document.getElementById("spectrum-panel");
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
return null;
}
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
const gl = renderer?.gl;
if (!gl) continue;
try {
if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish();
} catch (_) {
// Ignore transient WebGL state errors and capture the last good frame.
}
}
const rootRect = rootEl.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const out = document.createElement("canvas");
out.width = Math.max(1, Math.round(rootRect.width * dpr));
out.height = Math.max(1, Math.round(rootRect.height * dpr));
const ctx = out.getContext("2d");
if (!ctx) return null;
ctx.scale(dpr, dpr);
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
ctx.fillStyle = bg;
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
for (const canvas of canvases) {
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
const rect = canvas.getBoundingClientRect();
ctx.drawImage(
canvas,
rect.left - rootRect.left,
rect.top - rootRect.top,
rect.width,
rect.height,
);
}
// Decoder overlays over the signal view.
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
// replicated on canvas, so frosted-glass overlays would otherwise obscure
// the spectrum).
const decoderOverlayIds = [
"ais-bar-overlay",
"vdes-bar-overlay",
"ft8-bar-overlay",
"aprs-bar-overlay",
"rds-ps-overlay",
];
for (const id of decoderOverlayIds) {
const overlayEl = document.getElementById(id);
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
}
// Spectrum axis labels and bookmark chips (includes freq bar).
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
drawAxisLabels(ctx, spectrumDbAxis, 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-right"), rootRect);
return out;
}
function clickCanvasDownload(href, fileName) {
const a = document.createElement("a");
a.href = href;
a.download = fileName;
a.rel = "noopener";
a.style.display = "none";
document.body.appendChild(a);
a.click();
requestAnimationFrame(() => a.remove());
}
function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") {
return new Promise((resolve) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
resolve(false);
return;
}
const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000);
resolve(true);
}, "image/png");
} catch (_) {
resolve(false);
}
});
}
try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true);
} catch (_) {
return Promise.resolve(false);
}
}
async function captureSpectrumScreenshot() {
const snapshotCanvas = buildSpectrumSnapshotCanvas();
if (!snapshotCanvas) {
T.showHint("Spectrum view not ready", 1300);
return false;
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved;
}
// Register module API
window.trx.modules.screenshot = {
captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas,
saveCanvasAsPng,
};
})();
@@ -1,355 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
"use strict";
// Shared UI primitives. Keeping these outside app.js prevents navigation,
// feedback, dialogs, and layout preferences from growing separate state models.
(function initUiCore() {
const api = window.trxUi = window.trxUi || {};
function ensureLiveRegions() {
if (!document.getElementById("toast-region")) {
const region = document.createElement("div");
region.id = "toast-region";
region.className = "toast-region";
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.appendChild(region);
}
if (!document.getElementById("ui-confirm-dialog")) {
const dialog = document.createElement("dialog");
dialog.id = "ui-confirm-dialog";
dialog.className = "ui-dialog";
dialog.innerHTML = `
<form method="dialog" class="ui-dialog-card">
<h2 id="ui-confirm-title">Confirm action</h2>
<p id="ui-confirm-message"></p>
<div class="ui-dialog-actions">
<button value="cancel" type="submit">Cancel</button>
<button value="confirm" type="submit" class="danger">Confirm</button>
</div>
</form>`;
document.body.appendChild(dialog);
}
}
api.notify = function notify(message, options = {}) {
ensureLiveRegions();
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
const toast = document.createElement("div");
toast.className = `toast toast-${kind}`;
toast.setAttribute("role", kind === "error" ? "alert" : "status");
const text = document.createElement("span");
text.textContent = message;
toast.appendChild(text);
if (action && typeof action.run === "function") {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label || "Retry";
button.addEventListener("click", () => { action.run(); toast.remove(); });
toast.appendChild(button);
}
document.getElementById("toast-region").appendChild(toast);
requestAnimationFrame(() => toast.classList.add("toast-visible"));
if (duration > 0) setTimeout(() => toast.remove(), duration);
return toast;
};
api.confirm = function confirmAction(options = {}) {
ensureLiveRegions();
const dialog = document.getElementById("ui-confirm-dialog");
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
const confirmButton = dialog.querySelector('[value="confirm"]');
confirmButton.textContent = options.confirmLabel || "Confirm";
confirmButton.classList.toggle("danger", options.danger !== false);
return new Promise((resolve) => {
const finish = () => resolve(dialog.returnValue === "confirm");
dialog.addEventListener("close", finish, { once: true });
dialog.showModal();
});
};
api.setButtonState = function setButtonState(button, options = {}) {
if (!button) return;
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
button.classList.toggle("is-active", active);
button.classList.toggle("is-busy", busy);
button.setAttribute("aria-pressed", String(active));
button.setAttribute("aria-busy", String(busy));
button.disabled = disabled || busy;
const label = active ? activeLabel : inactiveLabel;
if (label) button.textContent = label;
};
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
if (!bar) return;
if (bar._accessibleTabsPrepared) return;
bar._accessibleTabsPrepared = true;
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
const buttons = Array.from(bar.querySelectorAll(selector));
bar.setAttribute("role", "tablist");
buttons.forEach((button, index) => {
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(button.classList.contains("active")));
button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
const key = button.dataset.tab || button.dataset.subtab;
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
if (panel) {
if (!button.id) button.id = `${kind}-tab-${key}`;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", button.id);
}
});
bar.addEventListener("keydown", (event) => {
if (!buttons.includes(event.target)) return;
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
if (!direction) return;
event.preventDefault();
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
next.focus();
next.click();
});
};
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
if (!bar) return;
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
const active = tab === selected;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
});
};
const layouts = {
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
function savedLayoutName() {
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
}
function layoutAvailable(layout) {
return !layout.capability || layoutCapabilities[layout.capability] === true;
}
function unavailableLayoutMessage() {
const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
}
function refreshLayoutOptions() {
const select = document.getElementById("operator-layout-select");
if (!select) return;
const previous = select.value || document.body.dataset.operatorLayout || "compact";
select.replaceChildren();
Object.entries(layouts).forEach(([value, layout]) => {
if (!layoutAvailable(layout)) return;
select.add(new Option(layout.label, value));
});
const available = Array.from(select.options).some(option => option.value === previous);
select.value = available ? previous : "compact";
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
select.title = unavailableLayoutMessage();
}
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
Object.keys(layoutCapabilities).forEach((name) => {
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
});
refreshLayoutOptions();
const select = document.getElementById("operator-layout-select");
const saved = savedLayoutName();
if (select && Array.from(select.options).some(option => option.value === saved)) {
select.value = saved;
api.applyLayout(saved, { persist: false });
}
};
api.setActiveRig = function setActiveRig(rigId) {
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
const saved = savedLayoutName();
const select = document.getElementById("operator-layout-select");
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
api.applyLayout(select?.value || saved, { persist: false });
};
api.applyLayout = function applyLayout(name, options = {}) {
const requestedLayout = layouts[name];
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls");
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls");
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
if (options.navigate && typeof window.navigateToTab === "function") {
window.navigateToTab(layout.preferredTab);
}
};
function installLayoutControls() {
const actions = document.querySelector(".top-bar-actions");
if (actions && !document.getElementById("operator-layout-select")) {
const label = document.createElement("label");
label.className = "operator-layout-picker";
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
const select = label.querySelector("select");
const savedLayout = savedLayoutName();
actions.insertBefore(label, actions.firstChild);
select.value = savedLayout;
refreshLayoutOptions();
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
api.applyLayout(select.value);
}
const tray = document.querySelector(".controls-tray");
if (tray && !document.getElementById("advanced-radio-controls")) {
const details = document.createElement("details");
details.id = "advanced-radio-controls";
details.className = "advanced-radio-controls";
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
const body = details.querySelector(".advanced-radio-body");
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
const more = document.createElement("button");
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
menu.id = "mobile-more-menu";
menu.className = "mobile-more-menu";
menu.setAttribute("role", "menu");
more.setAttribute("aria-controls", menu.id);
const closeMore = (restoreFocus = false) => {
if (!menu.classList.contains("is-open")) return;
menu.classList.remove("is-open");
more.setAttribute("aria-expanded", "false");
if (restoreFocus) more.focus();
};
api.closeMobileOverlays = closeMore;
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
const source = nav.querySelector(`[data-tab="${tabName}"]`);
if (!source) return;
const item = document.createElement("button");
item.type = "button";
item.setAttribute("role", "menuitem");
item.dataset.navigateTab = tabName;
item.textContent = source.textContent.trim();
item.addEventListener("click", () => {
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
closeMore();
});
menu.appendChild(item);
});
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
});
document.addEventListener("click", (event) => {
if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMore(true);
});
window.addEventListener("resize", () => closeMore());
window.addEventListener("popstate", () => closeMore());
nav.append(more, menu);
}
function installDecoderPicker() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar || document.getElementById("decoder-tab-select")) return;
const select = document.createElement("select");
select.id = "decoder-tab-select";
select.className = "decoder-tab-select";
select.setAttribute("aria-label", "Decoder view");
const groups = [
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
];
groups.forEach(([label, ids]) => {
const group = document.createElement("optgroup");
group.label = label;
ids.forEach((id) => {
const button = bar.querySelector(`[data-subtab="${id}"]`);
if (button) group.appendChild(new Option(button.textContent.trim(), id));
});
select.appendChild(group);
});
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
bar.insertAdjacentElement("afterend", select);
}
function installDecoderBadges() {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
if (!bar) return;
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
const id = button.dataset.subtab;
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
const dot = document.createElement("span");
dot.className = "decoder-state-dot";
dot.setAttribute("aria-hidden", "true");
button.appendChild(dot);
const status = document.getElementById(`${id}-status`);
if (!status) return;
const sync = () => {
const value = status.textContent.toLowerCase();
const state = /receiv|decod|connected|listening/.test(value) ? "active"
: /error|fail|disconnected/.test(value) ? "error" : "idle";
dot.dataset.state = state;
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
};
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
sync();
});
}
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason?.message || "An operation failed unexpectedly";
api.notify(message, { kind: "error" });
});
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
else api.init();
})();
@@ -1,535 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
(function initTrxWebGl(global) {
"use strict";
const cssColorCache = new Map();
let cssColorProbe = null;
function clearCssColorCache() {
cssColorCache.clear();
}
function ensureCssColorProbe() {
if (cssColorProbe) return cssColorProbe;
const el = document.createElement("span");
el.style.position = "absolute";
el.style.left = "-9999px";
el.style.top = "-9999px";
el.style.pointerEvents = "none";
el.style.opacity = "0";
document.body.appendChild(el);
cssColorProbe = el;
return cssColorProbe;
}
function parseRgbString(value) {
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
if (!m) return null;
const parts = m[1].split(",").map((p) => p.trim());
if (parts.length < 3) return null;
const r = Number(parts[0]);
const g = Number(parts[1]);
const b = Number(parts[2]);
const a = parts.length > 3 ? Number(parts[3]) : 1;
if (![r, g, b, a].every(Number.isFinite)) return null;
return [
Math.max(0, Math.min(1, r / 255)),
Math.max(0, Math.min(1, g / 255)),
Math.max(0, Math.min(1, b / 255)),
Math.max(0, Math.min(1, a)),
];
}
function parseHexColor(value) {
const raw = String(value || "").trim();
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
let hex = raw.slice(1);
if (hex.length === 3 || hex.length === 4) {
hex = hex.split("").map((ch) => ch + ch).join("");
}
if (!(hex.length === 6 || hex.length === 8)) return null;
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return [r, g, b, a];
}
function parseCssColor(value) {
const key = String(value ?? "");
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
let parsed = parseHexColor(key) || parseRgbString(key);
if (!parsed) {
const probe = ensureCssColorProbe();
probe.style.color = "";
probe.style.color = key;
const computed = getComputedStyle(probe).color;
parsed = parseRgbString(computed) || [0, 0, 0, 1];
}
cssColorCache.set(key, parsed.slice());
return parsed.slice();
}
function hslToRgba(h, s, l, a = 1) {
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
const sat = Math.max(0, Math.min(1, (Number(s) || 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 p = 2 * lig - q;
const hueToRgb = (t) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
const g = sat === 0 ? lig : hueToRgb(hue);
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
}
function normalizeColor(input, alphaMul = 1) {
let rgba;
if (Array.isArray(input)) {
const arr = input.map((v) => Number(v));
if (arr.length >= 4) {
rgba = [arr[0], arr[1], arr[2], arr[3]];
} else {
rgba = [0, 0, 0, 1];
}
} else if (typeof input === "string") {
rgba = parseCssColor(input);
} else if (input && typeof input === "object") {
rgba = [
Number(input.r) || 0,
Number(input.g) || 0,
Number(input.b) || 0,
Number(input.a ?? 1),
];
} else {
rgba = [0, 0, 0, 1];
}
const out = [
Math.max(0, Math.min(1, rgba[0])),
Math.max(0, Math.min(1, rgba[1])),
Math.max(0, Math.min(1, rgba[2])),
Math.max(0, Math.min(1, rgba[3] * alphaMul)),
];
return out;
}
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader) || "shader compile error";
gl.deleteShader(shader);
throw new Error(log);
}
return shader;
}
function createProgram(gl, vertexSrc, fragmentSrc) {
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const log = gl.getProgramInfoLog(program) || "program link error";
gl.deleteProgram(program);
throw new Error(log);
}
return program;
}
function pushColoredVertex(target, x, y, rgba) {
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
}
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (!(len > 0.0001)) return;
const nx = (-dy / len) * halfW;
const ny = (dx / len) * halfW;
const ax = x0 - nx, ay = y0 - ny;
const bx = x0 + nx, by = y0 + ny;
const cx = x1 + nx, cy = y1 + ny;
const dx2 = x1 - nx, dy2 = y1 - ny;
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, bx, by, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, ax, ay, rgba);
pushColoredVertex(out, cx, cy, rgba);
pushColoredVertex(out, dx2, dy2, rgba);
}
class TrxWebGlRenderer {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.options = { alpha: true, premultipliedAlpha: false, ...options };
this.gl =
canvas?.getContext("webgl", this.options) ||
canvas?.getContext("experimental-webgl", this.options) ||
null;
this.ready = !!this.gl;
this.textures = new Map();
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
// 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;
const gl = this.gl;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
const colorVertexSrc =
"attribute vec2 a_pos;\n" +
"attribute vec4 a_color;\n" +
"uniform vec2 u_resolution;\n" +
"varying vec4 v_color;\n" +
"void main() {\n" +
" vec2 zeroToOne = a_pos / u_resolution;\n" +
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
" v_color = a_color;\n" +
"}\n";
const colorFragmentSrc =
"precision mediump float;\n" +
"varying vec4 v_color;\n" +
"void main() {\n" +
" gl_FragColor = v_color;\n" +
"}\n";
const textureVertexSrc =
"attribute vec2 a_pos;\n" +
"attribute vec2 a_uv;\n" +
"uniform vec2 u_resolution;\n" +
"varying vec2 v_uv;\n" +
"void main() {\n" +
" vec2 zeroToOne = a_pos / u_resolution;\n" +
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
" v_uv = a_uv;\n" +
"}\n";
const textureFragmentSrc =
"precision mediump float;\n" +
"varying vec2 v_uv;\n" +
"uniform sampler2D u_tex;\n" +
"uniform float u_alpha;\n" +
"void main() {\n" +
" vec4 c = texture2D(u_tex, v_uv);\n" +
" gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
"}\n";
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
this.colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
this.colorLoc = {
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
color: gl.getAttribLocation(this.colorProgram, "a_color"),
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
};
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
this.textureBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
this.textureLoc = {
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
};
}
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
if (!this.ready) return false;
const nextW = Math.max(1, Math.round(cssWidth * dpr));
const nextH = Math.max(1, Math.round(cssHeight * dpr));
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
if (changed) {
this.canvas.width = nextW;
this.canvas.height = nextH;
}
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
return changed;
}
clear(color) {
if (!this.ready) return;
const gl = this.gl;
const rgba = normalizeColor(color);
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
gl.clear(gl.COLOR_BUFFER_BIT);
}
drawTriangles(vertices) {
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
}
drawTriangleStrip(vertices) {
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
}
_drawColorGeometry(vertices, mode) {
if (!this.ready || !vertices || vertices.length === 0) return;
const gl = this.gl;
const count = vertices.length;
// Grow scratch buffer if needed (doubles each time to amortise copies).
if (count > this._colorScratch.length) {
let newLen = this._colorScratch.length;
while (newLen < count) newLen *= 2;
this._colorScratch = new Float32Array(newLen);
}
// Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
this._colorScratch.set(vertices);
const view = this._colorScratch.subarray(0, count);
gl.useProgram(this.colorProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
// Only reallocate the GPU buffer when it is too small; otherwise use
// bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
if (count > this._colorGpuSize) {
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
this._colorGpuSize = this._colorScratch.length;
} else {
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
}
gl.enableVertexAttribArray(this.colorLoc.pos);
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
gl.enableVertexAttribArray(this.colorLoc.color);
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
gl.drawArrays(mode, 0, count / 6);
}
fillRect(x, y, w, h, color) {
if (w <= 0 || h <= 0) return;
const rgba = normalizeColor(color);
const v = [];
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y + h, rgba);
this._drawColorGeometry(v, this.gl.TRIANGLES);
}
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
if (w <= 0 || h <= 0) return;
const tl = normalizeColor(colorTL);
const tr = normalizeColor(colorTR);
const br = normalizeColor(colorBR);
const bl = normalizeColor(colorBL);
const v = [];
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y, tr);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y + h, bl);
this._drawColorGeometry(v, this.gl.TRIANGLES);
}
drawPolyline(points, color, width = 1) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, Number(width) || 1) / 2;
const verts = [];
for (let i = 0; i < points.length - 2; i += 2) {
segmentToQuadVertices(
verts,
points[i], points[i + 1],
points[i + 2], points[i + 3],
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawSegments(segments, color, width = 1) {
if (!Array.isArray(segments) || segments.length < 4) return;
const rgba = normalizeColor(color);
const halfW = Math.max(0.5, Number(width) || 1) / 2;
const verts = [];
for (let i = 0; i < segments.length - 3; i += 4) {
segmentToQuadVertices(
verts,
segments[i], segments[i + 1],
segments[i + 2], segments[i + 3],
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawFilledArea(points, baselineY, color) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
pushColoredVertex(verts, points[i], baselineY, rgba);
pushColoredVertex(verts, points[i], points[i + 1], rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
}
drawPoints(points, size, color) {
if (!Array.isArray(points) || points.length < 2) return;
const radius = Math.max(1, Number(size) || 1);
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
const x = points[i] - radius;
const y = points[i + 1] - radius;
const w = radius * 2;
const h = radius * 2;
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y, rgba);
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y + h, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
}
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
const dash = Math.max(1, Number(dashLen) || 1);
const gap = Math.max(1, Number(gapLen) || 1);
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const segments = [];
for (let y = top; y < bottom; y += dash + gap) {
const segEnd = Math.min(bottom, y + dash);
segments.push(x, y, x, segEnd);
}
this.drawSegments(segments, color, width);
}
uploadRgbaTexture(name, width, height, data, filter = "linear") {
if (!this.ready || !name || !data) return null;
const gl = this.gl;
let entry = this.textures.get(name);
if (!entry) {
const texture = gl.createTexture();
entry = { texture, width: 0, height: 0 };
this.textures.set(name, entry);
}
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (entry.width !== width || entry.height !== height) {
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
width,
height,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
entry.width = width;
entry.height = height;
} else {
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
width,
height,
gl.RGBA,
gl.UNSIGNED_BYTE,
data,
);
}
return entry.texture;
}
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
if (!this.ready || !name || w <= 0 || h <= 0) return;
const entry = this.textures.get(name);
if (!entry) return;
const gl = this.gl;
const s = this._texScratch;
const x2 = x + w, y2 = y + h;
if (flipY) {
s[0]=x; s[1]=y; s[2]=0; s[3]=1;
s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
s[12]=x; s[13]=y; s[14]=0;s[15]=1;
s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
} else {
s[0]=x; s[1]=y; s[2]=0; s[3]=0;
s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
s[12]=x; s[13]=y; s[14]=0;s[15]=0;
s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
}
gl.useProgram(this.textureProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
gl.enableVertexAttribArray(this.textureLoc.pos);
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(this.textureLoc.uv);
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.uniform1i(this.textureLoc.tex, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
function createRenderer(canvas, options) {
return new TrxWebGlRenderer(canvas, options);
}
global.trxParseCssColor = parseCssColor;
global.trxHslToRgba = hslToRgba;
global.createTrxWebGlRenderer = createRenderer;
global.trxClearCssColorCache = clearCssColorCache;
})(window);
@@ -19,12 +19,13 @@ use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS}; use ts_rs::{Config, TS};
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut output = String::from( let mut output = format!(
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\n\ "// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\n\
//\n\ //\n\
// SPDX-License-Identifier: GPL-2.0-or-later\n\n\ // SPDX-License-{}: GPL-2.0-or-later\n\n\
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\ // Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\
// Do not edit manually.\n\n", // Do not edit manually.\n\n",
"Identifier",
); );
let config = Config::default(); let config = Config::default();