Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 597ba031f3 [feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / test (pull_request) Successful in 8m14s
CI / frontend (pull_request) Successful in 3m4s
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Failing after 1s
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.

A table identifier of / or \ selects the primary or alternate sheet
directly.  Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet.  Codes outside
0x21..0x7E have no cell and keep the old character box.

The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.

Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:53 +02:00
sjgandClaude Opus 5 8e14dddc21 [feat](trx-frontend-http): serve the vendored APRS symbol sprites
Embed the six sheets alongside the other vendored assets and serve them
under /vendor with the same immutable cache headers.

The browser computes a symbol's cell from a 16x6 grid of 24px cells, so
a re-vendored sheet at any other size would shift every station onto a
neighbouring icon -- wrong on every packet, and invisible unless you
know which glyph to expect.  Pin the geometry by parsing each embedded
PNG's IHDR in a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:36 +02:00
37 changed files with 1365 additions and 4289 deletions
@@ -160,18 +160,8 @@ function updateAisSummary() {
}
}
}
function aisSummaryText(msg) {
const parts = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg) {
const row = document.createElement("details");
const row = document.createElement("div");
row.className = "ais-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
@@ -184,8 +174,7 @@ function renderAisRow(msg) {
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)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
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,
@@ -196,16 +185,7 @@ function renderAisRow(msg) {
msg.destination,
aisTypeLabel(msg.message_type)
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
row.querySelectorAll("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
applyAisFilterToRow(row);
return row;
}
@@ -285,11 +265,9 @@ function addAisMessage(msg) {
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
plotAisMessage(msg);
}
function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
}
}
function normalizeServerAisMessage(msg) {
return {
@@ -310,7 +288,9 @@ function onServerAisBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
plotAisMessage(next);
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
normalized.push(next);
}
normalized.reverse();
@@ -352,9 +332,5 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
}
prune: pruneAisHistoryView
});
@@ -743,7 +743,7 @@ function elementById(id) {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.insertBefore(details, document.getElementById("audio-controls"));
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
@@ -810,21 +810,14 @@ function elementById(id) {
});
const barFits = () => {
const bar = actions.closest(".tab-bar");
const nav = bar?.querySelector(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll(".tab")).filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
if (!bar) return true;
const identity = bar.querySelector(".header-main");
const nav = bar.querySelector(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
const nav = document.querySelector(".tab-bar-nav");
const bar = actions.closest(".tab-bar");
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
@@ -837,25 +830,11 @@ function elementById(id) {
wrap.hidden = false;
menu.appendChild(element);
}
if (nav && !barFits()) nav.classList.add("nav-icons-only");
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest(".tab-bar");
const observer = new ResizeObserver(() => {
reflowOverflow();
});
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => {
reflowOverflow();
}).catch(() => {
});
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
@@ -1060,9 +1039,6 @@ var runtime = {
plugin.prune();
return true;
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
clearQueued() {
queued.clear();
},
@@ -1692,24 +1668,7 @@ function estimateNoiseFloorDb(bins) {
// src/plugin-loader.ts
var pluginGroups = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js",
"/ft4.js",
"/ft2.js",
"/wspr.js",
"/cw.js",
"/background-decode.js",
"/sat.js",
"/wefax.js",
"/ais.js",
"/vdes.js",
"/aprs.js",
"/hf-aprs.js"
],
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
@@ -1874,7 +1833,6 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg) {
const el = requiredElement("auth-error");
@@ -2068,7 +2026,6 @@ var loadingSub = requiredElement("loading-sub");
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
@@ -2218,19 +2175,10 @@ function syncTopBarAccess() {
}
}
var overviewDrawPending = false;
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
if (!decodeHistoryOverlayEl) return;
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
if (decodeHistoryProgressBarEl) {
if (fraction == null) {
decodeHistoryOverlayEl.dataset.phase = "fetching";
decodeHistoryProgressBarEl.style.width = "";
} else {
delete decodeHistoryOverlayEl.dataset.phase;
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
}
}
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
}
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
@@ -2289,7 +2237,6 @@ function formatSigStrength(dbm) {
return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`;
}
function refreshSigStrengthDisplay() {
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -2343,7 +2290,6 @@ async function restorePreviousTuneState() {
savePreviousTuneState();
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -2778,17 +2724,6 @@ if (headerStylePickSelect) {
function readyText() {
return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
var HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
var HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg) {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -2828,7 +2763,7 @@ function updateRigSubtitle(activeRigId) {
rigSubtitle.textContent = `Rig: ${name}`;
updateDocumentTitle(activeChannelRds());
}
function applyRigList(activeRigId, rigIds, displayNames) {
function applyRigList(activeRigId, rigIds, displayNames = {}) {
if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
@@ -2902,12 +2837,12 @@ function refreshOperatorLayoutCapabilities() {
});
}
function showHint(msg, duration) {
setPowerHint(msg);
powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => {
setPowerHint(readyText());
powerHint.textContent = readyText();
}, duration);
if (HINT_ERROR_RE.test(msg)) {
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -4178,7 +4113,6 @@ function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
var serverVersion = null;
var serverBuildDate = null;
@@ -4471,13 +4405,13 @@ function render(update) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
setPowerHint("Initializing rig…");
powerHint.textContent = "Initializing rig…";
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
setPowerHint("Rig not fully initialized yet");
powerHint.textContent = "Rig not fully initialized yet";
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -4510,7 +4444,6 @@ function render(update) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -4637,7 +4570,6 @@ function render(update) {
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
if (!onVirtual) {
modeEl.value = modeUpper2;
syncModePicker();
if (modeUpper2 === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -4772,7 +4704,6 @@ function render(update) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -4799,7 +4730,7 @@ function render(update) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
setPowerHint("State unknown");
powerHint.textContent = "State unknown";
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -4896,7 +4827,7 @@ function render(update) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
setPowerHint(readyText());
powerHint.textContent = readyText();
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -4974,11 +4905,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
setPowerHint("trx-server connection lost");
powerHint.textContent = "trx-server connection lost";
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) setPowerHint(readyText());
if (data.initialized) powerHint.textContent = readyText();
}
} catch (e) {
console.error("Bad event data", e);
@@ -5001,7 +4932,7 @@ function connect() {
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
setPowerHint("trx-client connection lost, retrying…");
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -5011,7 +4942,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15e3) {
setPowerHint("trx-client connection lost, retrying…");
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -5396,33 +5327,6 @@ if (jogMultEl) {
}
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
var modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -5431,7 +5335,6 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -5449,7 +5352,6 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
modeEl.addEventListener("change", applyModeFromPicker);
@@ -5646,39 +5548,6 @@ var _activeTab = "main";
function tabFromPath2(pathname = window.location.pathname) {
return tabFromPath(pathname);
}
var pendingMapTarget = null;
function applyMapTarget(target) {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target) {
pendingMapTarget = target;
navigateToTab("map");
if (window.trx.modules.map) {
requestAnimationFrame(() => {
drainPendingMapTarget();
});
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat, lon) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid, preferredType = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
var _mapInitTimer = null;
function _initMapWhenReady() {
const loadingEl2 = document.getElementById("map-loading");
@@ -5695,7 +5564,6 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -5725,10 +5593,7 @@ function navigateToTab(name, options = {}) {
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
if (toolsBtn) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -6073,7 +5938,6 @@ var trxCore = Object.freeze({
syncBandwidthInput,
scheduleSpectrumDraw,
onDecoderRegistryReady,
syncModePicker,
formatFreqForStep: formatFrequencyForStep,
refreshFreqDisplay,
setJogDivisor,
@@ -6286,18 +6150,14 @@ var wfmCciValEl = document.getElementById("wfm-cci-val");
var wfmAciFillEl = document.getElementById("wfm-aci-fill");
var wfmAciValEl = document.getElementById("wfm-aci-val");
var samControlsCol = document.getElementById("sam-controls-col");
var modeControlsRow = document.getElementById("mode-controls-row");
var samStereoWidthEl = document.getElementById("sam-stereo-width");
var samCarrierSyncEl = document.getElementById("sam-carrier-sync");
var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
var sdrSquelchDbEl = document.getElementById("sdr-squelch-db");
var sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
var sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle");
var squelchLineEl = document.getElementById("spectrum-squelch-line");
var squelchGripEl = document.getElementById("spectrum-squelch-grip");
var squelchLabelEl = document.getElementById("spectrum-squelch-label");
var sdrSquelchEl = document.getElementById("sdr-squelch");
var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
var SDR_SQUELCH_MIN_DB = -120;
var SDR_SQUELCH_MAX_DB = -30;
var syncFromServerSdrSquelch = false;
var sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -6386,195 +6246,89 @@ function normalizeWfmDenoiseLevel(value) {
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
var sdrSquelchEnabled = loadSetting("sdrSquelchEnabled", false);
var sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
function clampSdrSquelchPercent(value) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
function sdrSquelchPercentToServer(percent) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
}
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : state === "open" ? "Squelch on, open" : "Squelch on, closed"
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
}
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM" && !!canvas && canvas.clientHeight > 0 && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
function sdrSquelchServerToPercent(enabled, thresholdDb) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
}
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`
).catch(() => {
});
}
function setSdrSquelch(thresholdDb, enabled, options = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
var SDR_SQUELCH_HOLD_MS = 2e3;
var sdrSquelchLocalAt = 0;
var SQUELCH_NOISE_WINDOW_MS = 1e4;
var SQUELCH_NOISE_MARGIN_DB = 5;
var SQUELCH_MEASURE_MS = 1500;
var squelchMeterSamples = [];
function recordSquelchMeterSample(db) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
function squelchNoiseFloorDb() {
const now = Date.now();
const values = squelchMeterSamples.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS).map((sample) => sample.v).sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
function autoSquelchThresholdDb() {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled, thresholdDb) {
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
}
var squelchDragPointerId = null;
var squelchDragSubmitAt = 0;
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
});
sdrSquelchDbEl.addEventListener("blur", () => {
renderSdrSquelch();
function submitSdrSquelchPercent(percent) {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`
).catch(() => {
});
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
});
}
function applyAutoSquelch(threshold) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
let pct = 0;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
}
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
if (squelchGripEl) {
const dbFromClientY = (clientY) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
});
const endDrag = (event) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
}
event.preventDefault();
submitSdrSquelchPercent(pct);
});
}
if (wfmAudioModeEl) {
@@ -6703,7 +6457,6 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7434,10 +7187,15 @@ function volWheel(slider, pctEl, getGain, storageKey) {
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
}, { passive: false });
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
@@ -7536,15 +7294,16 @@ function connectDecode() {
let historySettled = false;
let historyWorkerDone = false;
let historyFallbackStarted = false;
let historyRetried = false;
let historyBatchDrainScheduled = false;
let historyTotal = 0;
let historyProcessed = 0;
const historyGroupQueue = [];
const liveBuffer = [];
function releaseLiveBuffer() {
if (historySettled) return;
function flushLiveBuffer() {
historySettled = true;
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
for (const msg of liveBuffer) {
try {
dispatchDecodeMessage(msg);
@@ -7553,23 +7312,19 @@ function connectDecode() {
}
liveBuffer.length = 0;
}
function finishHistoryReplay() {
clearTimeout(historyTimeout);
releaseLiveBuffer();
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
}
function updateHistoryReplayOverlay() {
setDecodeHistoryOverlayVisible(
true,
"Loading decode history…",
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
historyTotal > 0 ? historyProcessed / historyTotal : null
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
);
}
function maybeFinishHistoryReplay() {
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
if (historySettled) return;
if (historyWorkerDone && historyGroupQueue.length === 0) {
clearTimeout(historyTimeout);
flushLiveBuffer();
}
}
function pumpDecodeHistoryGroupQueue() {
historyBatchDrainScheduled = false;
@@ -7623,25 +7378,17 @@ function connectDecode() {
if (historyFallbackStarted || historySettled) return;
historyFallbackStarted = true;
loadDecodeHistoryOnMainThread((groups) => {
clearTimeout(historyTimeout);
const total = totalDecodeHistoryMessages(groups);
if (total > 0) {
enqueueDecodeHistoryGroups(groups);
} else {
finishHistoryReplay();
flushLiveBuffer();
}
}, (err) => {
console.error("Decode history fallback failed", err);
if (historyRetried) {
showHint("Decode history unavailable", 3e3);
finishHistoryReplay();
return;
}
historyRetried = true;
historyFallbackStarted = false;
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
setTimeout(() => {
startDecodeHistoryFallback();
}, 2e3);
clearTimeout(historyTimeout);
flushLiveBuffer();
});
}
function startDecodeHistoryWorkerReplay() {
@@ -7702,9 +7449,10 @@ function connectDecode() {
return true;
}
const historyTimeout = setTimeout(() => {
if (historySettled) return;
releaseLiveBuffer();
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
if (!historySettled) {
terminateDecodeHistoryWorker();
flushLiveBuffer();
}
}, 2e4);
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
decodeSource = new EventSource("/decode");
@@ -7726,7 +7474,7 @@ function connectDecode() {
const wasClosed = source.readyState === 2;
source.close();
terminateDecodeHistoryWorker();
if (!historySettled) releaseLiveBuffer();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
updateDecodeStatus("Decode not available (check client audio config)");
setTimeout(connectDecode, 1e4);
@@ -8190,7 +7938,6 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -8495,7 +8242,6 @@ function drawSpectrum(data) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
positionSquelchLine();
function hzToX(hz) {
return (hz - range.visLoHz) / range.visSpanHz * W;
}
@@ -8828,8 +8574,7 @@ function updateBookmarkAxis(range) {
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
axisEl.classList.toggle("bm-axis-visible", hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) {
axisEl.replaceChildren();
@@ -9124,15 +8869,31 @@ window.addEventListener("keydown", (event) => {
}
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0;
} else {
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB ? sdrSquelchThresholdDb : autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25;
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
}
}
nextPct = auto;
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -9535,8 +9296,8 @@ var bandplanCacheKey = "";
var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
var bandplanRegionSelect = document.getElementById("bandplan-region-select");
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
function loadBandplanJson() {
return fetch("/bandplan.json").then(async (response) => {
(function loadBandplanJson() {
fetch("/bandplan.json").then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
}).then((data) => {
@@ -9544,12 +9305,9 @@ function loadBandplanJson() {
bandplanData = data;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
if (lastSpectrumData) scheduleSpectrumDraw();
}).catch((err) => {
console.warn("Band plan unavailable", err);
}).catch(() => {
});
}
void loadBandplanJson();
})();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
bandplanRegionSelect.addEventListener("change", () => {
@@ -9611,28 +9369,24 @@ function bandplanVisibleSegments(region, loHz, hiHz) {
}
return result;
}
function _clearBandplanStrip(reserveSpace) {
function _hideBandplanStrip() {
if (!bandplanStripEl) return;
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
function updateBandplanStrip(range) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
_clearBandplanStrip(false);
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
_clearBandplanStrip(true);
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
const stripW = bandplanStripEl.clientWidth || 1;
if (bandplanCacheKey !== newKey) {
@@ -1,10 +1,13 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
@@ -111,27 +114,51 @@ function updateAprsChipState() {
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
async function copyAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
function renderAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => {
aprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyAprsCoords(text);
}
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})();
});
}
return row;
}
function renderAprsHistory() {
pruneAprsPacketHistory();
@@ -196,17 +223,15 @@ function pruneAprsHistoryView() {
updateAprsBar();
renderAprsHistory();
}
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
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();
plotAprsPacket(pkt);
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
@@ -223,7 +248,9 @@ function onServerAprsBatch(packets) {
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" });
plotAprsPacket(next);
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -287,9 +314,5 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
prune: pruneAprsHistoryView
});
@@ -328,7 +328,6 @@ function bmApply(bm) {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -1,254 +0,0 @@
// src/plugins/aprs-shared.ts
function escapeAprsHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
return character;
}
var APRS_SPRITE_COLUMNS = 16;
var APRS_SPRITE_CELL_PX = 24;
var APRS_SPRITE_FIRST_CODE = 33;
var APRS_SPRITE_LAST_CODE = 126;
function aprsSpriteOffset(code) {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
function aprsSymbolSprite(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
};
}
function renderAprsSymbolSlot(packet, escapeHtml) {
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
function fahrenheitToCelsius(fahrenheit) {
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
}
function summarizeAprsWeather(info) {
const parts = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
function summarizeAprsTelemetry(info) {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
function summarizeAprsMessage(info) {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
function summarizeAprsCourseSpeed(info) {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
function summarizeAprsPayload(packet) {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
export {
aprsPacketCategory,
aprsAgeText,
collapseAprsDuplicates,
aprsSymbolSprite,
normalizeAprsPacket,
renderAprsPacketRow
};
@@ -0,0 +1,156 @@
// src/plugins/aprs-shared.ts
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
return character;
}
var APRS_SPRITE_COLUMNS = 16;
var APRS_SPRITE_CELL_PX = 24;
var APRS_SPRITE_FIRST_CODE = 33;
var APRS_SPRITE_LAST_CODE = 126;
function aprsSpriteOffset(code) {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
function aprsSymbolSprite(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
};
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
export {
aprsPacketCategory,
aprsCategoryLabel,
aprsAgeText,
collapseAprsDuplicates,
aprsHexBytes,
renderAprsInfo,
aprsSymbolSprite,
renderLocalAprsSymbol,
normalizeAprsPacket
};
@@ -1,10 +1,13 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
@@ -12,6 +15,7 @@ import {
// src/plugins/hf-aprs.ts
var hfAprsWindow = window;
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
@@ -98,27 +102,51 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat, lon) => {
hfAprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyHfAprsCoords(text);
}
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
}
});
});
}
async function copyHfAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})();
});
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
@@ -1,6 +1,6 @@
import {
aprsSymbolSprite
} from "./chunk-OPEIVJGD.js";
} from "./chunk-ROTCLFXS.js";
// src/map-core.ts
function mapEl(id) {
@@ -62,7 +62,6 @@ var mapWindow = window;
const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
let mapSearchFilter = "";
let mapRigFilter = "";
@@ -839,36 +838,38 @@ var mapWindow = window;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
const noun = kind === "band" ? "bands" : "sources";
let helperText = "";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible — click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
if (showingAll) {
if (kind === "source" && noneSelected) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container, phase) {
if (!container) return;
@@ -976,10 +977,11 @@ var mapWindow = window;
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1333,8 +1335,7 @@ var mapWindow = window;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
const btn = mapEl("map-overlay-toggle-btn");
@@ -1531,13 +1532,7 @@ var mapWindow = window;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (key === MAP_FILTER_ALL_KEY) {
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
const sourceKey = key;
mapFilter[sourceKey] = !mapFilter[sourceKey];
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
@@ -1652,13 +1647,18 @@ var mapWindow = window;
return;
}
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = window.innerHeight;
if (footer) {
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
if (!mapIsFullscreen() && footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
if (fr.top > mapRect.top + 50) bottom = fr.top;
}
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
@@ -1674,7 +1674,16 @@ var mapWindow = window;
popupAnchor: [0, -12]
});
}
function focusMapPosition(lat, lon) {
mapWindow.navigateToAprsMap = function(lat, lon) {
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -1686,10 +1695,19 @@ var mapWindow = window;
});
});
}
}
function focusMapLocator(grid, preferredType = null) {
};
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -1734,7 +1752,7 @@ var mapWindow = window;
requestAnimationFrame(focusMarker);
});
return true;
}
};
function buildReceiverPopupHtml(rigIds) {
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
let meta = "";
@@ -2152,16 +2170,14 @@ var mapWindow = window;
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
if (C.decodeHistoryMapRenderingDeferred()) {
@@ -2321,7 +2337,7 @@ var mapWindow = window;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
focusMapLocator(entry.sourceGrid, entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
}
});
const head = document.createElement("div");
@@ -2427,7 +2443,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
focusMapLocator(entry.grid ?? "", entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -2533,7 +2549,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
focusMapLocator(entry.grid ?? "", entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -3038,8 +3054,6 @@ var mapWindow = window;
}
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3100,6 +3114,5 @@ var mapWindow = window;
bandForHz,
reverseGeocodeLocation
};
window.trxPluginRuntime.syncMapAll();
autoInitIfVisible();
})();
@@ -286,10 +286,7 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof hostState.lastModeName === "string") {
@@ -220,7 +220,9 @@ function onServerVdesBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
plotVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next);
}
normalized.reverse();
@@ -246,15 +248,13 @@ if (vdesFilterInput) {
renderVdesHistory();
});
}
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
plotVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
}
function pruneVdesHistoryView() {
pruneVdesMessageHistory();
@@ -268,9 +268,5 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
}
prune: pruneVdesHistoryView
});
@@ -22,7 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
@@ -53,7 +53,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<span class="tab-label">Bookmarks</span>
</button>
<button class="tab" data-tab="digital-modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
<span class="tab-label">Digital modes</span>
</button>
<button class="tab" data-tab="map">
@@ -142,10 +142,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
</div>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
@@ -209,27 +205,38 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div>
</div>
<div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
<div class="label" id="freq-label"><span>Frequency</span></div>
</div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col controls-col-mode label-below-col">
<div class="controls-col label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<!-- The select stays as the mode's value: a dozen call sites and
several plugins read #mode.value. It is hidden from sight and
from assistive tech; the buttons beside it are the control. -->
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
</div>
</div>
<div class="controls-col controls-col-center">
@@ -241,35 +248,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-step">
<div class="inline step-controls-inline">
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">
@@ -327,6 +305,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<div class="label"><span>SAM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="full-row label-below-row" id="vfo-row">
<div class="label"><span>VFO</span></div>
@@ -376,6 +362,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="vchan-picker" id="vchan-picker"></div>
</div>
</div>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
</div>
</div>
</details>
<div class="full-row label-below-row">
<div class="label"><span>Signal</span></div>
<div class="signal" style="gap: 1rem;">
@@ -413,51 +425,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<button id="rx-audio-btn" type="button">Play Audio</button>
<button id="tx-audio-btn" type="button">Transmit Audio</button>
<span class="audio-group audio-volume-group">
<span class="audio-group-label">Volume</span>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
</span>
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
</span>
<span class="audio-group audio-level-group">
<span class="audio-group-label">Level</span>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<small id="audio-status">Off</small>
</span>
</div>
</div>
</div>
</details>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<div class="scheduler-action-row">
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<small id="audio-status" style="min-width: 60px;">Off</small>
</div>
</div>
</div>
@@ -680,12 +654,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-filter-row">
<span class="aprs-counts">
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
</span>
<div class="ais-summary">
<div class="ais-summary-card">
<span class="ais-summary-label">Channels</span>
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Tracked</span>
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Latest</span>
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
</div>
</div>
<div id="ais-messages"></div>
</div>
@@ -715,6 +696,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -722,15 +717,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
<span class="aprs-filter-sep" aria-hidden="true"></span>
</div>
<div class="aprs-filter-row">
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="aprs-packets"></div>
</div>
@@ -740,6 +731,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -747,15 +752,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
<span class="aprs-filter-sep" aria-hidden="true"></span>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="hf-aprs-packets"></div>
</div>
@@ -1001,52 +1002,48 @@ SPDX-License-Identifier: GPL-2.0-or-later
<template id="tmpl-map">
<div id="map-stage">
<div class="map-overlay-panel">
<div class="map-overlay-filters">
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Filter</span>
<div id="map-locator-phase" class="map-locator-phase-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Rig</span>
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
<option value="">All</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hr</option>
<option value="180">3 hrs</option>
<option value="360">6 hrs</option>
<option value="720">12 hrs</option>
<option value="1440">24 hrs</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row">
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
</div>
</div>
<!-- Last, so the search field takes whatever the fixed-width
groups leave on the bar's final row rather than a sliver. -->
<div class="map-locator-filter-group map-filter-grow">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Filter by</span>
<div id="map-locator-phase" class="map-locator-phase-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Rig</span>
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
<option value="">All</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hr</option>
<option value="180">3 hrs</option>
<option value="360">6 hrs</option>
<option value="720">12 hrs</option>
<option value="1440">24 hrs</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row">
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
</div>
</div>
<div class="map-overlay-actions">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
</div>
</div>
<div class="map-corner-controls">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
</div>
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
<div id="aprs-map"></div>
@@ -1597,13 +1594,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div class="footer">
<div class="footer-meta">
<span class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · &copy; <span id="copyright-year"></span>
</span>
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
</div>
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
</div>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
<div class="decode-history-overlay-card">
@@ -1637,12 +1631,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
</div>
</div>
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="history-progress-text">
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
<div class="decode-history-overlay-card">
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
</div>
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
</div>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
"test:browser": "node tests/browser-smoke.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -172,8 +172,6 @@ interface TrxModules {
map?: {
aprsMap?: { invalidateSize(): void } | null;
initAprsMap(): void;
focusMapPosition?(lat: number, lon: number): void;
focusMapLocator?(grid: string, preferredType?: string | null): boolean;
sizeAprsMapToViewport(): void;
pruneMapHistory(): void;
updateMapBaseLayerForTheme(theme: string): void;
@@ -361,10 +359,6 @@ declare global {
trx: { state: TrxState; core: Readonly<Record<string, unknown>>; modules: TrxModules };
trxUi: TrxUi;
trxPluginRuntime: TrxPluginRuntime;
// Owned here, not by the lazy map module: decode rows link to the map long
// before it has been loaded.
navigateToAprsMap(lat: number, lon: number): void;
navigateToMapLocator(grid: string, preferredType?: string | null): void;
lastSpectrumData: SpectrumFrame | null;
lastFreqHz: number | null;
currentBandwidthHz: number;
@@ -483,8 +477,6 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
// The startup fetch may have run before there was a session to authorise it.
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg: string) {
@@ -712,7 +704,6 @@ const loadingSub = requiredElement("loading-sub");
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
@@ -881,29 +872,10 @@ function syncTopBarAccess() {
}
let overviewDrawPending = false;
// Progress, in the corner. This used to dim the whole page behind a scrim,
// which hid the waterfall and the decode panels for as long as the replay ran
// — and a replay only happens when there is a backlog worth watching arrive.
// `fraction` null leaves the bar indeterminate, for the part where the payload
// is still on the wire and there is nothing to count yet.
function setDecodeHistoryOverlayVisible(
visible: boolean,
title = "",
sub = "",
fraction: number | null = null,
) {
function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
if (!decodeHistoryOverlayEl) return;
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
if (decodeHistoryProgressBarEl) {
if (fraction == null) {
decodeHistoryOverlayEl.dataset.phase = "fetching";
decodeHistoryProgressBarEl.style.width = "";
} else {
delete decodeHistoryOverlayEl.dataset.phase;
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
}
}
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
}
@@ -972,8 +944,6 @@ function formatSigStrength(dbm: number | null) {
}
function refreshSigStrengthDisplay() {
// The squelch indicator reads the same meter, so it follows it here.
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -1033,7 +1003,6 @@ async function restorePreviousTuneState() {
savePreviousTuneState(); // save current as previous so B toggles back
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -1363,22 +1332,6 @@ function readyText() {
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
// The footer hint is a status pill whose dot is coloured from data-state,
// so the text has to be written through setPowerHint rather than assigned.
const HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
const HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg: string): "ok" | "busy" | "error" {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg: string) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId: string) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -1424,11 +1377,7 @@ function updateRigSubtitle(activeRigId: string | null) {
updateDocumentTitle(activeChannelRds());
}
// `displayNames` omitted means "no news", not "no names". The state updates
// carry only the rig ids — /rigs is what knows the names — and this defaulted
// to an empty map, so the first state frame after load wiped the names and the
// picker and header fell back to the lowercase ids for the rest of the session.
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames?: Record<string, string>) {
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record<string, string> = {}) {
if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
// Detect whether the rig list or active rig actually changed so we can
@@ -1512,10 +1461,10 @@ function refreshOperatorLayoutCapabilities() {
}
function showHint(msg: string, duration?: number) {
setPowerHint(msg);
powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { setPowerHint(readyText()); }, duration);
if (HINT_ERROR_RE.test(msg)) {
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -2998,7 +2947,6 @@ function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
let serverVersion: string | null = null;
@@ -3344,13 +3292,13 @@ function render(update: AppUpdate) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
setPowerHint("Initializing rig…");
powerHint.textContent = "Initializing rig…";
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
setPowerHint("Rig not fully initialized yet");
powerHint.textContent = "Rig not fully initialized yet";
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -3388,7 +3336,6 @@ function render(update: AppUpdate) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -3534,7 +3481,6 @@ function render(update: AppUpdate) {
// vchan.js will apply the correct mode via vchanSyncModeDisplay().
if (!onVirtual) {
modeEl.value = modeUpper;
syncModePicker();
if (modeUpper === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -3677,7 +3623,6 @@ function render(update: AppUpdate) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -3704,7 +3649,7 @@ function render(update: AppUpdate) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
setPowerHint("State unknown");
powerHint.textContent = "State unknown";
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
@@ -3815,7 +3760,7 @@ function render(update: AppUpdate) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
setPowerHint(readyText());
powerHint.textContent = readyText();
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -3902,11 +3847,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
setPowerHint("trx-server connection lost");
powerHint.textContent = "trx-server connection lost";
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) setPowerHint(readyText());
if (data.initialized) powerHint.textContent = readyText();
}
} catch (e) {
console.error("Bad event data", e);
@@ -3929,7 +3874,7 @@ function connect() {
source.onerror = () => {
// Check if this is an auth error by looking at readyState
if (source.readyState === EventSource.CLOSED) {
setPowerHint("trx-client connection lost, retrying\u2026");
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -3940,7 +3885,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15000) {
setPowerHint("trx-client connection lost, retrying\u2026");
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -4370,39 +4315,6 @@ if (jogMultEl) {
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
// The mode buttons are a view of the <select>, which stays the value everything
// else reads. Rebuilt when the rig's mode list changes, re-synced whenever
// anything writes to the select — including the plugins, via trxCore.
const modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -4411,7 +4323,6 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -4430,7 +4341,6 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
@@ -4641,54 +4551,6 @@ function tabFromPath(pathname = window.location.pathname) {
return tabFromPathname(pathname);
}
// Map links fire from decode rows — an AIS position, an APRS frame, an FT8
// grid — that exist long before the Map tab has ever been opened, and the map
// module is lazy. It used to install these two globals itself, so until
// something had opened the tab the AIS links threw ("not a function") and the
// APRS ones silently did nothing. The app owns them instead: it is the only
// place that can materialise the panel, load the module and update history,
// and the target waits here until the module is up.
type PendingMapTarget =
| { kind: "position"; lat: number; lon: number }
| { kind: "locator"; grid: string; preferredType: string | null };
let pendingMapTarget: PendingMapTarget | null = null;
function applyMapTarget(target: PendingMapTarget): boolean {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target: PendingMapTarget) {
pendingMapTarget = target;
navigateToTab("map");
// Already loaded: the tab switch has shown the panel, so focus can happen
// now. Otherwise _initMapWhenReady drains it once the module arrives.
if (window.trx.modules.map) {
requestAnimationFrame(() => { drainPendingMapTarget(); });
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat: number, lon: number) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid: string, preferredType: string | null = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
// (window.trx.modules.map) if they haven't loaded yet.
let _mapInitTimer: ReturnType<typeof setInterval> | null = null;
@@ -4708,7 +4570,6 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -4741,17 +4602,10 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
btn.classList.add("active");
// A destination the strip hides is reached through Tools, so mark that
// button instead — otherwise the strip looks identical on all four of them.
// Membership of the Tools menu is the test: it still reads from the grouping
// ui-core installs rather than a second copy of it, but unlike the tab's
// computed display it does not depend on anything being laid out. The first
// route navigation runs while the card is still behind the loading state,
// where every tab computes to display:none — which lit Tools up on every
// refresh of every page.
// Derived from what is actually hidden rather than from a second copy of the
// grouping, which would drift from the one ui-core installs.
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
if (toolsBtn) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -5018,7 +4872,7 @@ const trxCore = Object.freeze({
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, syncModePicker,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode,
resetRdsDisplay, positionRdsPsOverlay, updateWfmControls,
updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio,
@@ -5229,18 +5083,14 @@ const wfmCciValEl = document.getElementById("wfm-cci-val");
const wfmAciFillEl = document.getElementById("wfm-aci-fill");
const wfmAciValEl = document.getElementById("wfm-aci-val");
const samControlsCol = document.getElementById("sam-controls-col");
const modeControlsRow = document.getElementById("mode-controls-row");
const samStereoWidthEl = document.getElementById("sam-stereo-width") as HTMLInputElement | null;
const samCarrierSyncEl = document.getElementById("sam-carrier-sync") as HTMLSelectElement | null;
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
const sdrSquelchDbEl = document.getElementById("sdr-squelch-db") as HTMLInputElement | null;
const sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
const sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle") as HTMLButtonElement | null;
const squelchLineEl = document.getElementById("spectrum-squelch-line");
const squelchGripEl = document.getElementById("spectrum-squelch-grip");
const squelchLabelEl = document.getElementById("spectrum-squelch-label");
const sdrSquelchEl = document.getElementById("sdr-squelch") as HTMLInputElement | null;
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
const SDR_SQUELCH_MIN_DB = -120;
const SDR_SQUELCH_MAX_DB = -30;
let syncFromServerSdrSquelch = false;
const sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputElement | null;
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -5340,248 +5190,97 @@ function normalizeWfmDenoiseLevel(value: unknown) {
return "auto";
}
// The threshold is held in dB, the scale the spectrum axis and the S-meter are
// labelled in and the one the server compares against. It used to be a
// percentage over that range, which left the operator no way to relate the
// control to anything on screen — and 0% doubled as "disabled", so turning the
// squelch off to listen threw the setting away.
let sdrSquelchEnabled: boolean = loadSetting("sdrSquelchEnabled", false);
let sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value: number) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
function clampSdrSquelchPercent(value: number) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
/** Passing right now, as far as the meter can tell: the same comparison the
* DSP makes, against the same number it reports. */
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
function sdrSquelchPercentToServer(percent: number) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
}
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : (sdrSquelchIsPassing() ? "open" : "closed");
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : (state === "open" ? "Squelch on, open" : "Squelch on, closed"),
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
}
/** Places the line at its threshold on the spectrum's dB axis. */
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM"
&& !!canvas && canvas.clientHeight > 0
&& getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
// Pinned to the plot edge when the threshold sits outside the visible dB
// window rather than hidden: the grip is how it gets dragged back, and the
// label still reads the real value.
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
function sdrSquelchServerToPercent(enabled: boolean, thresholdDb: number | null) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
}
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}`
+ `&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`,
).catch(() => {});
}
function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?: boolean } = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
// Auto reads the meter, not the spectrum. The threshold is compared against
// the channel level the meter reports, and that sits a long way from the
// spectrum's per-bin noise floor — the gap is set by the FFT size and window,
// the channel bandwidth, the decimation and peak-versus-mean statistics.
// Measured across ordinary configurations it ranges from -1 dB to +22 dB, so
// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a
// narrow span and it simply never closed. Reading the same number the DSP
// compares needs no conversion at all.
// How long a local squelch change outranks the server's echo of the old one.
const SDR_SQUELCH_HOLD_MS = 2_000;
let sdrSquelchLocalAt = 0;
const SQUELCH_NOISE_WINDOW_MS = 10_000;
const SQUELCH_NOISE_MARGIN_DB = 5;
const SQUELCH_MEASURE_MS = 1_500;
const squelchMeterSamples: SignalSample[] = [];
function recordSquelchMeterSample(db: number) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
/** The level the channel rests at, taken low enough down the distribution that
* a burst of traffic inside the window cannot drag it up. */
function squelchNoiseFloorDb(): number | null {
const now = Date.now();
const values = squelchMeterSamples
.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS)
.map((sample) => sample.v)
.sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
/** Just clear of the noise the meter is actually reading. */
function autoSquelchThresholdDb(): number | null {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null) {
// Not while the operator is on the control: dragging the line or typing a
// level would fight the echo of the value the server last confirmed.
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
// Nor just after letting go: state frames are in flight continuously, and
// one sent before the new threshold was applied would snap the line back to
// where it was dragged from.
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
}
let squelchDragPointerId: number | null = null;
let squelchDragSubmitAt = 0;
function submitSdrSquelchPercent(percent: number) {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`,
).catch(() => {});
}
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
});
sdrSquelchDbEl.addEventListener("blur", () => { renderSdrSquelch(); });
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
function applyAutoSquelch(threshold: number) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLButtonElement | null;
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
// Nothing recorded yet — right after a connection or a rig switch. Listen
// for a moment rather than refusing, which is what a radio does.
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
let pct = 0; // default: Off
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
// Set threshold slightly above noise floor so squelch closes on noise
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
}
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
// Dragging the line, the same gesture as the bandwidth edges. Submits are
// throttled so the gate follows the drag by ear without a request per pixel.
if (squelchGripEl) {
const dbFromClientY = (clientY: number) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
});
const endDrag = (event: PointerEvent) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
}
event.preventDefault();
submitSdrSquelchPercent(pct);
});
}
@@ -5702,9 +5401,6 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
}
// Show compatibility warning for non-Chromium browsers
@@ -6412,10 +6108,15 @@ function volWheel(slider: HTMLInputElement, pctEl: HTMLElement, getGain: () => G
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
}, { passive: false });
}
@@ -6526,44 +6227,36 @@ function connectDecode() {
let historySettled = false;
let historyWorkerDone = false;
let historyFallbackStarted = false;
let historyRetried = false;
let historyBatchDrainScheduled = false;
let historyTotal = 0;
let historyProcessed = 0;
const historyGroupQueue: DecodeHistoryGroup[] = [];
const liveBuffer: DecodeMessage[] = [];
// Live decodes wait behind the history so the panels stay in order. Letting
// them through is not the same as being finished, and conflating the two is
// what made a slow history disappear: the safety valve released the buffer
// and tore the worker down with it, so whatever had not arrived never did.
function releaseLiveBuffer() {
if (historySettled) return;
function flushLiveBuffer() {
historySettled = true;
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
for (const msg of liveBuffer) {
try { dispatchDecodeMessage(msg); } catch (_) {}
}
liveBuffer.length = 0;
}
function finishHistoryReplay() {
clearTimeout(historyTimeout);
releaseLiveBuffer();
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
}
function updateHistoryReplayOverlay() {
setDecodeHistoryOverlayVisible(
true,
"Loading decode history…",
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
historyTotal > 0 ? historyProcessed / historyTotal : null,
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
);
}
function maybeFinishHistoryReplay() {
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
if (historySettled) return;
if (historyWorkerDone && historyGroupQueue.length === 0) {
clearTimeout(historyTimeout);
flushLiveBuffer();
}
}
function pumpDecodeHistoryGroupQueue() {
@@ -6625,26 +6318,17 @@ function connectDecode() {
if (historyFallbackStarted || historySettled) return;
historyFallbackStarted = true;
loadDecodeHistoryOnMainThread((groups) => {
clearTimeout(historyTimeout);
const total = totalDecodeHistoryMessages(groups);
if (total > 0) {
enqueueDecodeHistoryGroups(groups);
} else {
finishHistoryReplay();
flushLiveBuffer();
}
}, (err: unknown) => {
console.error("Decode history fallback failed", err);
// One retry, then say so. Failing silently here is why the history
// sometimes only turned up on a second reload: nothing asked again and
// nothing said anything was missing.
if (historyRetried) {
showHint("Decode history unavailable", 3000);
finishHistoryReplay();
return;
}
historyRetried = true;
historyFallbackStarted = false;
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
clearTimeout(historyTimeout);
flushLiveBuffer();
});
}
@@ -6713,13 +6397,12 @@ function connectDecode() {
return true;
}
// Safety valve: after 20 s, stop holding live decodes back — but keep
// loading. The history is what the operator is waiting for, and dropping it
// on the floor at the timeout is not something they can even see happen.
// Safety valve: if the history fetch hangs, unblock after 20 s.
const historyTimeout = setTimeout(() => {
if (historySettled) return;
releaseLiveBuffer();
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
if (!historySettled) {
terminateDecodeHistoryWorker();
flushLiveBuffer();
}
}, 20000);
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
@@ -6742,7 +6425,7 @@ function connectDecode() {
const wasClosed = source.readyState === 2;
source.close();
terminateDecodeHistoryWorker();
if (!historySettled) releaseLiveBuffer();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
updateDecodeStatus("Decode not available (check client audio config)");
setTimeout(connectDecode, 10000);
@@ -7301,7 +6984,6 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -7648,9 +7330,6 @@ function drawSpectrum(data: SpectrumFrame) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
// The squelch line rides the same axis: reposition it whenever the axis or
// the plot geometry moves under it.
positionSquelchLine();
function hzToX(hz: number) {
return ((hz - range.visLoHz) / range.visSpanHz) * W;
@@ -8059,12 +7738,7 @@ function updateBookmarkAxis(range: SpectrumRange) {
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
// The rail is kept up with nothing in range — blank, no caption — so tuning
// across bands only swaps its contents rather than taking the strip itself
// away. This function only runs with a spectrum range in hand, so rigs
// without a spectrum never get it.
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
axisEl.classList.toggle("bm-axis-visible", hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
@@ -8378,21 +8052,35 @@ window.addEventListener("keydown", (event) => {
return;
}
// Q — gate on or off, keeping the threshold. Picks one off the noise floor
// the first time, when there is nothing to keep.
// Q — toggle squelch (cycle 0 → auto → 0)
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0; // turn off
} else {
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB
? sdrSquelchThresholdDb
: (autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25);
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
// Auto: estimate from noise floor
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
}
}
nextPct = auto;
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -8844,13 +8532,8 @@ const bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
const bandplanRegionSelect = document.getElementById("bandplan-region-select") as HTMLSelectElement | null;
const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as HTMLInputElement | null;
// Fired at startup, and again once the session exists. The first attempt can
// land before the user is authenticated, and it used to fail silently and
// never retry, which is why the band plan sometimes only appeared after a
// manual reload. Redraws on arrival: the strip is painted from the spectrum
// draw, and a rig sitting between frames would otherwise stay blank.
function loadBandplanJson(): Promise<void> {
return fetch("/bandplan.json")
(function loadBandplanJson() {
fetch("/bandplan.json")
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
@@ -8860,13 +8543,9 @@ function loadBandplanJson(): Promise<void> {
bandplanData = data as BandplanData;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
if (lastSpectrumData) scheduleSpectrumDraw();
})
.catch((err) => {
console.warn("Band plan unavailable", err);
});
}
void loadBandplanJson();
.catch(() => {});
})();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
@@ -8938,36 +8617,27 @@ function bandplanVisibleSegments(region: string, loHz: number, hiHz: number): Vi
return result;
}
// Empties the strip. `reserveSpace` keeps its height: the strip is in flow, so
// collapsing it on a range with no allocations shifted the whole page down by
// its height every time tuning crossed out of a band. Space stays reserved
// whenever a band plan could be drawn at all, and is only given back when the
// feature is off, has no data, or there is no spectrum to annotate.
function _clearBandplanStrip(reserveSpace: boolean) {
function _hideBandplanStrip() {
if (!bandplanStripEl) return;
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
function updateBandplanStrip(range: SpectrumRange | null) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
_clearBandplanStrip(false);
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
_clearBandplanStrip(true);
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
@@ -4,7 +4,6 @@
import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared";
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
export {};
@@ -230,8 +229,6 @@ const mapWindow = window as unknown as MapWindow;
const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
let mapSearchFilter = "";
let mapRigFilter = ""; // "" = all rigs
@@ -1081,42 +1078,38 @@ const mapWindow = window as unknown as MapWindow;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
const noun = kind === "band" ? "bands" : "sources";
let helperText = "";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
// Selecting nothing selects everything, for both kinds.
const showingAll = kind === "source"
? sourceKeys.every((k) => !mapFilter[k])
: !(selectedSet instanceof Set) || selectedSet.size === 0;
// An "All" chip carries what a sentence of helper text used to say, in a
// width the bar can afford, and gives the selection somewhere to be undone.
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible \u2014 click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
// Nothing is filtered out yet, so no chip is dimmed as if it were.
if (showingAll) {
if (kind === "source" && noneSelected) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
@@ -1240,12 +1233,11 @@ const mapWindow = window as unknown as MapWindow;
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
// The phase buttons next door already name the dimension; "Show" is the
// rest of the sentence, and it keeps the bar's labels a uniform width.
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1644,10 +1636,7 @@ const mapWindow = window as unknown as MapWindow;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
// Only the filters collapse. The bar itself stays, because it carries the
// button that brings them back.
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
@@ -1870,14 +1859,7 @@ const mapWindow = window as unknown as MapWindow;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (key === MAP_FILTER_ALL_KEY) {
// Back to no selection at all, which is what shows everything.
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
// toggle the clicked source; when none are selected everything is shown
const sourceKey = key as MapFilterKey;
mapFilter[sourceKey] = !mapFilter[sourceKey];
@@ -1998,21 +1980,23 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap) aprsMap.invalidateSize();
return;
}
// Everything below is the windowed path — the fullscreen branch returned.
// The map tab is a whole page, so the stage fills the column down to the
// footer. Capping it at a fraction of the viewport, or at a width-derived
// aspect ratio, left a dead band under the map that grew with the window
// (and on narrow screens made the map barely a third of the page).
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = window.innerHeight;
if (footer) {
let bottom = mapIsFullscreen() && stage
? stage.getBoundingClientRect().bottom
: window.innerHeight;
if (!mapIsFullscreen() && footer) {
const fr = footer.getBoundingClientRect();
// Clamped to the viewport: once the column is tall enough to push the
// footer below the fold, growing into it would push it further still.
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
if (fr.top > mapRect.top + 50) bottom = fr.top;
}
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen()
? Math.floor(window.innerHeight * 0.9)
: Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
@@ -2034,7 +2018,15 @@ const mapWindow = window as unknown as MapWindow;
});
}
function focusMapPosition(lat: number, lon: number) {
mapWindow.navigateToAprsMap = function(lat, lon) {
// Activate the map tab
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -2046,12 +2038,20 @@ const mapWindow = window as unknown as MapWindow;
});
});
}
}
};
function focusMapLocator(grid: string, preferredType: string | null = null) {
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -2101,7 +2101,7 @@ const mapWindow = window as unknown as MapWindow;
requestAnimationFrame(focusMarker);
});
return true;
}
};
@@ -2598,26 +2598,18 @@ const mapWindow = window as unknown as MapWindow;
syncDecodeContactPathVisibility();
}
// The buttons light up when they are on, so the label need not repeat it —
// an "On"/"Off" suffix on each cost the bar most of a row.
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled
? "Directed decode paths are drawn when the target locator is known"
: "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled
? "TRX paths are drawn from a station popup"
: "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
@@ -2820,7 +2812,7 @@ const mapWindow = window as unknown as MapWindow;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
focusMapLocator(entry.sourceGrid, entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
}
});
@@ -2947,7 +2939,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
focusMapLocator(entry.grid ?? "", entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
});
}
@@ -3073,7 +3065,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
focusMapLocator(entry.grid ?? "", entry.sourceType);
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
});
}
@@ -3637,8 +3629,6 @@ const mapWindow = window as unknown as MapWindow;
// Register module API for core to call
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3688,18 +3678,6 @@ const mapWindow = window as unknown as MapWindow;
reverseGeocodeLocation,
};
// Everything the decoders already hold goes onto the map now. This module is
// lazy -- it arrives when the Map tab is first opened, long after startup
// restored the decode history -- and until it does, aprsMapAddStation and
// friends are undefined, so every restored position was dropped on the floor.
// The map then showed only what arrived live after it loaded, which is why it
// took a second reload (with the module cached, and so loaded early enough to
// win the race against the history fetch) for the stations to appear.
//
// The add functions are keyed by callsign/MMSI/point, so replaying costs
// nothing on a second call and cannot duplicate a marker.
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
// If the map tab is already visible (direct /map URL), init immediately.
autoInitIfVisible();
})();
@@ -5,14 +5,7 @@
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
],
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
@@ -81,7 +81,6 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
};
@@ -27,7 +27,6 @@ interface AisMessage {
}
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge {
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
@@ -213,21 +212,8 @@ function updateAisSummary() {
}
}
/** What the message says, in one line: where the vessel is and what it is
* doing, or for the static reports that carry no fix where it is going. */
function aisSummaryText(msg: AisMessage): string {
const parts: string[] = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg: AisMessage): HTMLElement {
const row = document.createElement("details");
const row = document.createElement("div");
row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit",
@@ -241,9 +227,8 @@ function renderAisRow(msg: AisMessage): HTMLElement {
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)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
? `<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>`
: "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
@@ -258,43 +243,23 @@ function renderAisRow(msg: AisMessage): HTMLElement {
.join(" ")
.toUpperCase();
row.innerHTML =
`<summary class="decode-line">` +
`<span class="ais-time">${escapeAisHtml(ts)}</span>` +
`<div class="ais-row-head">` +
`<span class="ais-time">${ts}</span>` +
`<span class="ais-call">${nameHtml}</span>` +
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
(distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
`<span>${escapeAisHtml(channel.freqText)}</span>` +
(route ? `<span>${escapeAisHtml(route)}</span>` : "") +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
(pos ? `<span>${pos}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(msg.lat != null && msg.lon != null
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
: "") +
(vesselUrl
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
: "") +
`</div>` +
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` +
`<div class="ais-row-meta">` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
`</div>` +
`<div class="ais-row-detail">` +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
@@ -389,13 +354,9 @@ function addAisMessage(msg: AisMessage): void {
scheduleAisBarUpdate();
scheduleAisHistoryRender();
plotAisMessage(msg);
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotAisMessage(msg: AisMessage): void {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
}
}
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
@@ -418,7 +379,9 @@ function onServerAisBatch(messages: AisMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
plotAisMessage(next);
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
normalized.push(next);
}
normalized.reverse();
@@ -464,6 +427,4 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -30,14 +30,6 @@ export interface AprsPacket {
symbol_code?: string | null;
}
function escapeAprsHtml(value: string): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
@@ -182,13 +174,6 @@ export function aprsSymbolSprite(
};
}
/** An empty slot of the symbol's size, so a frame without one still lines up
* with the frames around it in the list. */
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
return renderLocalAprsSymbol(packet, escapeHtml)
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
@@ -220,174 +205,3 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
symbolCode: packet.symbol_code ?? null,
};
}
// ── Payload summaries ──────────────────────────────────────────────────────
// APRS packs its meaning into the information field with a set of one-character
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
// list showed that field as it arrives on the air, so reading a weather report
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
// of plain text for the common types and leave the raw field to the expanded
// view, which is still the authority when a summary cannot be made.
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
function fahrenheitToCelsius(fahrenheit: number): number {
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
}
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
function summarizeAprsWeather(info: string): string | null {
const parts: string[] = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
function summarizeAprsTelemetry(info: string): string | null {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
/** `:DEST :text{01` → addressed message text. */
function summarizeAprsMessage(info: string): string | null {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
/** Course/speed appended to a position, as `088/036`. */
function summarizeAprsCourseSpeed(info: string): string | null {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
export function summarizeAprsPayload(packet: AprsPacket): string | null {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts: string[] = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
// Whatever the station wrote after the position report.
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
// Status and anything else: the text it carries, minus its type character.
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
// ── Frame row ──────────────────────────────────────────────────────────────
// Shared by the APRS and HF APRS lists, which had a copy each of the same
// forty lines of markup and drifted only by one badge.
export interface AprsRowOptions {
/** Marks the newest frame so it can flash on arrival. */
fresh?: boolean;
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
badge?: string;
/** Distance from the receiver, already formatted, or "" to leave it out. */
distance?: string;
/** Opens the map on a frame's position. */
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML =
`<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderAprsSymbolSlot(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` +
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
(hasPosition
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
: "") +
`</div>` +
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
(packet.info_bytes?.length
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
: "") +
`<div class="aprs-row-actions">` +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
@@ -6,10 +6,13 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow,
renderAprsInfo,
renderLocalAprsSymbol,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -138,24 +141,93 @@ function updateAprsChipState() {
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
async function copyAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
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">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
symbolHtml +
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})(); });
}
return row;
}
function renderAprsHistory() {
@@ -229,12 +301,6 @@ function pruneAprsHistoryView(): void {
renderAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet. */
function plotAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -243,7 +309,9 @@ function addAprsPacket(pkt: AprsPacket): void {
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
plotAprsPacket(pkt);
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
@@ -264,7 +332,9 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
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" });
plotAprsPacket(next);
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -336,6 +406,4 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -456,7 +456,6 @@ function bmApply(bm: Bookmark): void {
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -6,10 +6,13 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow,
renderAprsInfo,
renderLocalAprsSymbol,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -24,6 +27,7 @@ interface HfAprsBridge {
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
}
const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
@@ -124,27 +128,95 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
// HF traffic goes in the same row as VHF, marked with the band it came in on.
// This was a second copy of the same forty lines of markup.
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text: string) => { void copyHfAprsCoords(text); },
});
}
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");
async function copyHfAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
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">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})(); });
}
return row;
}
function renderHfAprsHistory() {
@@ -59,8 +59,6 @@ export interface HostCore {
setRigFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
/** Repaints the mode buttons from #mode after writing to it. */
syncModePicker(): void;
onDecoderRegistryReady(callback: () => void): void;
}
@@ -9,9 +9,6 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
}
export interface TrxPluginRuntime {
@@ -22,7 +19,6 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
}
@@ -403,10 +403,7 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
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();
@@ -320,7 +320,9 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
plotVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next);
}
normalized.reverse();
@@ -347,17 +349,13 @@ if (vdesFilterInput) {
});
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotVdesMessage(msg: VdesMessage): void {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
plotVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
}
function pruneVdesHistoryView(): void {
@@ -374,6 +372,4 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -317,10 +317,7 @@ function elementById<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
// Ahead of the collapsibles the markup ships: the radio's own settings
// come before audio and the scheduler, and appending would put the
// section built here last whatever the markup says.
tray.insertBefore(details, document.getElementById("audio-controls"));
tray.appendChild(details);
api.applyLayout(savedLayoutName(), { persist: false });
}
}
@@ -400,40 +397,31 @@ function elementById<T extends HTMLElement>(id: string): T {
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// What "fits" means, measured rather than predicted. Two things have to
// hold: the controls stay inside the bar, and no tab reaches them. The
// tabs are the test rather than the nav's own box because the nav may
// shrink below its content — its box gets smaller while the tabs inside
// keep their width and slide under the controls, so the container reports
// nothing wrong while destinations become unclickable.
//
// This was an arithmetic estimate — identity + nav.scrollWidth +
// actions.scrollWidth + a 48px allowance for the gaps — which under-counts
// whatever the allowance does not cover. On a platform whose fonts run
// wider than this machine's it declared a fit that overlapped by 9px, and
// nothing degraded because nothing thought anything was wrong. Reading
// the geometry costs a synchronous layout per step, at most five per pass,
// and cannot disagree with what the operator sees.
// Measured against the bar, not the actions container: the actions are
// sized by their content, so their own scrollWidth never exceeds their
// clientWidth. A viewport-width threshold is not enough either — how much
// fits depends on the rig name and the translated labels, so a bar that is
// wide enough on one rig clips a control on another.
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
// so it cannot be the test. What actually matters is that the controls stay
// inside the bar and the page tabs are not squeezed into a scroller: seeing
// every tab beats keeping the style picker inline.
// Compare natural widths against the space available. Rendered widths
// cannot answer this: the nav has min-width 0 and scrolls, so it always
// shrinks to the leftover space and always reports "scrolling", while the
// bar reports overflow even when nothing is clipped. scrollWidth on a
// scroll container is its unconstrained content width, which is what a fit
// test needs.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
const nav = bar?.querySelector<HTMLElement>(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll<HTMLElement>(".tab"))
.filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
const bar = actions.closest<HTMLElement>(".tab-bar");
// Measure from the roomiest state every time, so the decision is a
// function of the current widths alone and cannot ratchet.
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
@@ -446,32 +434,11 @@ function elementById<T extends HTMLElement>(id: string): T {
wrap.hidden = false;
menu.appendChild(element);
}
// Last resort, once every movable control is already in the menu: drop
// the tabs to their icons. Without it the nav — which may shrink below
// its content — keeps its tabs at full width and runs them under the
// controls, so the destinations nearest the controls become unclickable.
// Icon widths are fixed, so this always buys back the labels' width.
if (nav && !barFits()) nav.classList.add("nav-icons-only");
// Still short with the tabs down to icons: hand the squeeze to the
// identity block, which can ellipsise, rather than to the strip, which
// can only clip destinations out of reach.
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
// A resize is not the only thing that changes what fits: the rig name
// arrives from the server, the style picker fills in, a web font swaps in
// wider metrics. Each changes the bar's content without touching the
// window, and the strip stayed as it was through all of them.
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest<HTMLElement>(".tab-bar");
const observer = new ResizeObserver(() => { reflowOverflow(); });
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
}
function installMobileMore() {
@@ -29,7 +29,7 @@ class ElementFixture {
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
const state = {
authEnabled: false,
authRole: "control",
@@ -50,7 +50,6 @@ function hostFixture(overrides = {}) {
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
syncModePicker: () => { calls.syncModePicker += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
@@ -3,18 +3,189 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document, getComputedStyle, window, location */
/* global document */
const fixture = await startWebFixture();
const { selectedRigs } = fixture;
const { browser, page, runtimeErrors } = await startBrowser(chromium);
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
filter_controls: false,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const decoderRegistry = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", decoderRegistry],
["/rigs", rigsResponse],
["/status", {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: false,
signal_meter: false,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: false,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
}],
["/bandplan.json", {}],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
if (urlPath === "/") return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
jsonRoutes.get("/status").active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
if (url.pathname === "/audio") {
response.writeHead(404).end();
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address === "object");
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
try {
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(500);
assert.deepEqual(runtimeErrors, []);
await page.locator("#content").waitFor({ state: "visible" });
@@ -23,167 +194,6 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Digital modes: the decoders are a list down the side, and the panel for the
// selected one sits beside it. A horizontal strip put thirteen decoders in a
// scroller and marked the open one with a single underline among them.
await page.locator('.tab[data-tab="digital-modes"]').click();
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.locator('.sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(250);
const digital = await page.evaluate(() => {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar").getBoundingClientRect();
const panel = document.getElementById("subtab-ft8").getBoundingClientRect();
const shown = [...document.querySelectorAll("#tab-digital-modes > .sub-tab-panel")]
.filter((element) => getComputedStyle(element).display !== "none")
.map((element) => element.id);
return {
sidebarIsColumn: bar.height > bar.width,
panelBesideSidebar: Math.round(panel.left) >= Math.round(bar.right),
panelsShown: shown,
decoders: document.querySelectorAll("#tab-digital-modes > .sub-tab-bar .sub-tab").length,
};
});
assert.ok(digital.sidebarIsColumn, "the decoder list is not a sidebar");
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
// Each decoder's list fills its panel. FT8, FT4, FT2 and WSPR size against
// the panel with flex, so a panel sized to its own content collapsed them to
// their 120px minimum with the rest of the page left empty; the marine lists
// were sized by a viewport formula that stopped matching when the panel
// changed shape.
for (const [subtab, list] of [["ft8", "ft8-messages"], ["wspr", "wspr-messages"],
["ais", "ais-messages"], ["aprs", "aprs-packets"], ["hf-aprs", "hf-aprs-packets"]]) {
await page.locator(`.sub-tab[data-subtab="${subtab}"]`).click();
await page.waitForTimeout(150);
const filled = await page.evaluate((id) => {
const element = document.getElementById(id);
const panel = element.closest(".sub-tab-panel");
return {
list: Math.round(element.getBoundingClientRect().height),
panel: Math.round(panel.getBoundingClientRect().height),
scrolls: getComputedStyle(element).overflowY,
};
}, list);
assert.ok(filled.list > filled.panel * 0.6,
`${subtab}: the list is ${filled.list}px in a ${filled.panel}px panel`);
assert.equal(filled.scrolls, "auto", `${subtab}: the list does not scroll on its own`);
}
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Map links from decode rows, before anything has opened the Map tab. The
// lazy map module used to install these globals itself, so an AIS pin threw
// "not a function" and an APRS link silently did nothing until the tab had
// been visited once.
const mapLinkReady = await page.evaluate(() => ({
position: typeof window.navigateToAprsMap,
locator: typeof window.navigateToMapLocator,
mapModuleLoaded: !!window.trx.modules.map,
}));
assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads");
assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads");
assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing");
await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(500);
const followed = await page.evaluate(() => ({
path: location.pathname,
active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id),
mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height),
}));
assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`);
assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`);
assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`);
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Section order in the tray. "Advanced radio controls" is built at runtime,
// so it lands wherever ui-core puts it rather than where the markup says —
// appending, as it once did, always left it last.
const sections = await page.evaluate(() =>
[...document.querySelectorAll(".controls-tray > details")].map((section) =>
section.querySelector("summary").textContent.trim()));
assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
`tray sections are ${JSON.stringify(sections)}`);
// Mode is a button group over a hidden <select>, which stays the value a
// dozen call sites and several plugins read. The click has to reach it, and
// the select must not take part in layout while it does.
const modeBefore = await page.evaluate(() => ({
buttons: document.querySelectorAll("#mode-picker button").length,
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
}));
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
const target = await page.evaluate(() => {
const other = [...document.querySelectorAll("#mode-picker button")]
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
return other?.dataset.mode;
});
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
await page.waitForTimeout(200);
const modeAfter = await page.evaluate(() => ({
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
}));
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
// Mode-specific controls live on their own row, which has to leave with them:
// an empty one would still take a track and a gap in the tray and draw its
// divider under the controls every rig has.
const modeRowState = async () => page.evaluate(() => {
const row = document.getElementById("mode-controls-row");
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
});
await page.locator('#mode-picker button[data-mode="WFM"]').click();
await page.waitForTimeout(250);
const withWfm = await modeRowState();
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
await page.locator('#mode-picker button[data-mode="FM"]').click();
await page.waitForTimeout(250);
const withoutWfm = await modeRowState();
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
// Scheduler controls read left to right: step, hand back, then the entry on
// air. The separator is drawn by the current-entry block, so it can only sit
// in the right place if that block is last.
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
assert.deepEqual(schedulerRow,
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
// The footer status pill colours its dot from data-state, so a hint written
// straight to textContent would leave the dot stuck on the previous state.
const hint = await page.evaluate(() => {
const element = document.getElementById("power-hint");
return { state: element.dataset.state, text: element.textContent.trim() };
});
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
// Rig names, and they have to survive the state stream. The updates carry
// only rig ids — /rigs is what knows the names — and applying one used to
// clear the names, so the picker and the header fell back to the lowercase
// ids a second after load and stayed there.
await page.waitForTimeout(1500);
const rigLabels = await page.evaluate(() => ({
options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent),
subtitle: document.getElementById("rig-subtitle").textContent,
}));
assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"],
`the picker reads ${JSON.stringify(rigLabels.options)}`);
assert.equal(rigLabels.subtitle, "Rig: Primary fixture",
`the header reads "${rigLabels.subtitle}"`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -193,44 +203,6 @@ try {
await page.locator('.tab[data-tab="map"]').click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
assert.equal(new URL(page.url()).pathname, "/map");
// The selected destination is marked by a box on all four sides, so a rule
// that drops one edge (the mobile nav used to lose its bottom border) is a
// regression even though the tab still reads as "active".
const activeTab = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
}));
});
for (const edge of activeTab) {
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
}
// The map is full-bleed: it breaks out of the centred .card column and
// reaches both viewport edges, without pushing the page sideways.
const stage = await page.evaluate(() => {
const rect = document.getElementById("map-stage").getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(rect.right),
viewport: document.documentElement.clientWidth,
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
};
});
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
// ...and fills the column down to the footer. Capping the height at a
// fraction of the viewport left a dead band that grew with the window.
assert.ok(stage.gapToFooter <= 16,
`${stage.gapToFooter}px of dead space between the map and the footer`);
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/");
@@ -246,24 +218,6 @@ try {
assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []);
// Refreshing or deep-linking must mark the destination, not Tools. The first
// route navigation runs while the card is still behind the loading state, so
// a test that asked whether the tab was displayed saw "none" for every tab
// and lit Tools up on every refresh of every page.
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
const marked = await page.evaluate(() => ({
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
}));
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
}
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.deepEqual(runtimeErrors, []);
// --- Layout regressions -------------------------------------------------
// Every fault below shipped at some point while the rest of this file
// passed, because nothing here looked at geometry: a header whose height
@@ -312,83 +266,7 @@ try {
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
assert.ok(menu.onTop, "menu is painted underneath the page");
// Wider text than this machine renders. This suite passed on macOS twice
// while CI, whose system font is wider, put the tab strip into the controls —
// the second time by 9px at 1440px with no scaling at all, because the fit
// test was an arithmetic estimate whose fixed allowance for the gaps did not
// cover them at those metrics. A single scale factor cannot stand in for
// another platform's fonts, so sweep: somewhere in this range is whatever CI
// renders, and the strip has to hold at every step of it.
const applyTextScale = (scale) => page.addStyleTag({ content: `
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
` });
const measureBar = () => page.evaluate(() => {
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
return {
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
iconsOnly: nav.classList.contains("nav-icons-only"),
};
});
for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
await applyTextScale(scale);
// 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem
// a side above that width, leaving less room than 900px has.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(120);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
}
// A station name long enough that the bar cannot hold it, which is the rung
// below icons: the identity has to give, not the strip.
await page.evaluate(() => {
document.getElementById("rig-subtitle").textContent =
"Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west";
});
await applyTextScale(1.6);
for (const width of [1440, 1100]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(200);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
// ...and when the text changes under a bar that is not resized. The rig name
// arrives from the server, a web font swaps in: neither is a window resize,
// and the strip used to sit there as it was.
await page.setViewportSize({ width: 1440, height: 900 });
await page.waitForTimeout(200);
await applyTextScale(2.4);
await page.waitForTimeout(400);
const unresized = await measureBar();
assert.ok(unresized.overlap <= 0,
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
// The frequency readouts are typed into, so the browser remembers what went
// in and offers it back in a dropdown over the reading — Edge does this by
// default. Nothing here wants to be autofilled from what was tuned last week.
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
const input = document.getElementById(id);
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
}));
for (const field of autofill) {
assert.equal(field.autocomplete, "off",
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
}
} finally {
await browser.close();
await fixture.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
@@ -1,334 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// What happens to a decode after it arrives: the panel on its tab, the mini
// view over the waterfall, the marker on the map, and the link between them.
// Nothing exercised this before — the fixture served an empty decode stream —
// which is how the map links came to be broken for every decoder at once.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
const VESSEL = {
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
};
const BEACON = {
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
// The decoders with panels on this tab have to load with it. They used to
// come only with the map group, so these panels stayed empty — decodes
// queued in the plugin runtime — until something opened the Map tab.
const panels = await page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(1500);
const miniView = await page.evaluate(() => {
const bar = document.getElementById("ais-bar-overlay");
return {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
// been loaded, so the pin did nothing at all.
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(800);
const followed = await page.evaluate(() => {
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
return {
path: location.pathname,
lat: centre ? Number(centre.lat.toFixed(2)) : null,
lon: centre ? Number(centre.lng.toFixed(2)) : null,
};
});
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
// Both decoders put their own marker on it.
await page.waitForTimeout(1200);
const markers = await page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Stored history, which is what is on screen a second after a page load. The
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: {
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true,
lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
ts_ms: Date.now() - (index + 1) * 1000,
})),
},
});
const replay = await startBrowser(chromium);
// Installed before the page's own scripts, so nothing can be missed: every
// time the progress element becomes visible, its geometry is recorded.
await replay.page.addInitScript(() => {
window.__historyProgressSamples = [];
const watch = () => {
const element = document.getElementById("decode-history-overlay");
if (!element) { requestAnimationFrame(watch); return; }
const sample = () => {
if (element.classList.contains("is-hidden")) return;
const rect = element.getBoundingClientRect();
window.__historyProgressSamples.push({
width: Math.round(rect.width),
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
});
};
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
sample();
};
watch();
});
try {
await replay.page.setViewportSize({ width: 1400, height: 900 });
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
// While it loads, the operator can still see the radio. This used to be a
// full-screen scrim over everything for as long as the replay ran.
//
// Watched from inside the page rather than polled from here: a fast replay
// can start and finish between two polls, and then the test reports that no
// progress was ever shown when what happened is that it blinked.
const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
for (const sample of shown) {
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
assert.equal(sample.coversCentre, false, "the progress sits over the page");
}
// And all of it arrives, on the first load.
await replay.page.waitForTimeout(2000);
const restored = await replay.page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
}));
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
// Opening the map for the first time has to show the stored history too.
// The map module is lazy, so at the moment the history was restored its
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
// position was dropped. Nothing replayed them when the module finally
// arrived, so the map came up empty and only filled in from decodes heard
// afterwards -- a station heard once was never plotted at all, and it took a
// second reload (module cached, so loaded early enough to beat the history
// fetch) before the map showed anything.
//
// This fixture serves no live decode stream on purpose: with one, fresh
// frames arriving after the module loads would paper over the whole thing.
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
await replay.page.locator('.tab[data-tab="map"]').click();
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await replay.page.waitForTimeout(1500);
const plotted = await replay.page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
await historyFixture.close();
}
// The APRS list: one line per frame, with the information field read for the
// operator rather than shown as it arrives on the air.
const APRS_FRAMES = [
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
].map((frame, index) => ({
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
// Only position reports carry a symbol here, which is the case the columns
// have to survive: a frame without one used to close the gap and shift
// everything after it left.
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
...frame,
}));
// The AIS list is the same shape: a line per message, saying where the vessel
// is and what it is doing, with the identifiers behind it.
const AIS_MESSAGES = [
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
callsign: "PBTX", destination: "GDANSK" },
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
const aprsFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
});
const aprs = await startBrowser(chromium);
try {
await aprs.page.setViewportSize({ width: 1400, height: 900 });
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await aprs.page.waitForTimeout(2000);
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
await aprs.page.waitForTimeout(400);
const rows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
// Newest first, so find each by the type it carries rather than by position.
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
for (const row of rows) {
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
}
// Each payload read rather than echoed: 25 °C from t077, the addressee from
// a message, the sequence from telemetry, the fix from a position report.
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
// Frames with and without a symbol line up: the slot is held open either way.
const columns = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
})));
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
"the sample has to mix frames with and without a symbol to test this");
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
// The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".decode-line").first().click();
await aprs.page.waitForTimeout(200);
const expanded = await aprs.page.evaluate(() => {
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
return {
open: row.hasAttribute("open"),
raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
};
});
assert.equal(expanded.open, true, "the frame did not open");
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
// AIS, on the same row.
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
await aprs.page.waitForTimeout(300);
const aisRows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
for (const row of aisRows) {
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
}
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
// A static report carries no fix, so it says where the vessel is going.
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
await aprs.page.waitForTimeout(200);
const aisExpanded = await aprs.page.evaluate(() =>
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
assert.deepEqual(aprs.runtimeErrors, []);
} finally {
await aprs.browser.close();
await aprsFixture.close();
}
@@ -57,7 +57,6 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
setRigFrequency: record("setRigFrequency"),
syncBandwidthInput: record("syncBandwidthInput"),
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
syncModePicker: record("syncModePicker"),
onDecoderRegistryReady: record("onDecoderRegistryReady"),
...core,
},
@@ -1,380 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Geometry of the spectrum area while tuning. Everything above the spectrum is
// driven by what happens to be in the visible range — band plan allocations,
// bookmarks — and the strips that show them used to appear and disappear with
// it, so tuning across a band edge moved the whole page under the operator's
// cursor. Nothing else in the suite serves a rig with a spectrum.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
];
const BANDPLAN = {
iaru1: {
bands: [{
name: "40m",
low_hz: 7000000,
high_hz: 7200000,
segments: [
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
],
}],
},
};
// 40m has both bookmarks and allocations; 20m has neither.
const BAND_WITH_CONTENT = 7074000;
const BAND_WITHOUT_CONTENT = 14074000;
// The meter sits well away from the spectrum's noise floor, so a squelch that
// took its level from the plot would land somewhere else entirely.
const METER_DB = -70;
const fixture = await startWebFixture({
spectrum: true,
bookmarks: BOOKMARKS,
bandplan: BANDPLAN,
bandplanEnabled: true,
meterDb: METER_DB,
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
function readGeometry() {
const top = (selector) => {
const el = document.querySelector(selector);
return el ? Math.round(el.getBoundingClientRect().top) : null;
};
const axis = document.getElementById("spectrum-bookmark-axis");
const strip = document.getElementById("spectrum-bandplan-strip");
return {
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
axisEmpty: axis.classList.contains("bm-axis-empty"),
stripReserved: strip.classList.contains("bp-visible"),
stripEmpty: strip.classList.contains("bp-empty"),
overviewTop: top(".overview-strip"),
spectrumTop: top("#spectrum-panel"),
controlsTop: top(".controls-row"),
footerTop: top(".footer"),
docHeight: document.documentElement.scrollHeight,
};
}
const layoutOf = (geometry) => ({
overviewTop: geometry.overviewTop,
spectrumTop: geometry.spectrumTop,
controlsTop: geometry.controlsTop,
footerTop: geometry.footerTop,
docHeight: geometry.docHeight,
});
async function tuneTo(hz) {
fixture.setCenterHz(hz);
await page.waitForTimeout(900);
return page.evaluate(readGeometry);
}
try {
await page.setViewportSize({ width: 1600, height: 950 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
await page.waitForTimeout(1500);
const populated = await tuneTo(BAND_WITH_CONTENT);
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
// The point of both placeholders: tuning must not move anything.
assert.deepEqual(layoutOf(bare), layoutOf(populated),
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
const back = await tuneTo(BAND_WITH_CONTENT);
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
// The rail covers the top of the overview, so only the chips may take
// pointer events — the rest has to fall through to the plot behind it.
const hits = await page.evaluate(() => {
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
const chipRect = chip.getBoundingClientRect();
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
return {
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
besideChip: besideChip?.id || besideChip?.tagName,
};
});
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
// Squelch: the threshold is in the dB the spectrum axis is labelled in, so the
// line is the control. It used to be a percentage on a slider in the audio
// row, with nothing on screen to relate the number to.
await page.locator("summary", { hasText: "Audio controls" }).click();
await page.locator("#sdr-squelch-toggle").click();
// Auto parks it just above the noise the meter reports — not above the
// spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above
// the meter depending on span, bandwidth and decimation.
await page.locator("#sdr-squelch-auto").click();
await page.waitForTimeout(400);
const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value));
assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1,
`auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`);
const squelchOn = await page.evaluate(() => {
const line = document.getElementById("spectrum-squelch-line");
return {
shown: getComputedStyle(line).display !== "none",
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"),
top: Math.round(line.getBoundingClientRect().top),
};
});
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state");
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
// Dragging the line down lowers the threshold and tells the server.
const submitted = [];
page.on("request", (request) => {
if (request.url().includes("/set_sdr_squelch")) {
submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db")));
}
});
const grip = await page.locator("#spectrum-squelch-grip").boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
const dragged = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top),
}));
assert.ok(dragged.db < squelchOn.db,
`dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`);
assert.equal(dragged.label, dragged.db, "the line label did not follow the drag");
assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag");
assert.ok(submitted.includes(dragged.db),
`the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`);
// Turning it off leaves the threshold alone — the old control conflated the
// two, so dropping to zero to listen threw the setting away.
await page.locator("#sdr-squelch-toggle").click();
await page.waitForTimeout(300);
const squelchOff = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none",
dot: document.getElementById("sdr-squelch-state").dataset.state,
}));
assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold");
assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off");
assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// The band plan is fetched once at startup, which can land before the session
// exists. It used to fail silently and never retry, so the allocations only
// turned up if the operator reloaded the page by hand.
const retryFixture = await startWebFixture({
spectrum: true,
bandplan: BANDPLAN,
bandplanEnabled: true,
bandplanUnauthorizedFirst: true,
});
const retry = await startBrowser(chromium);
try {
await retry.page.setViewportSize({ width: 1600, height: 950 });
await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" });
await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" });
await retry.page.waitForTimeout(2000);
const strip = await retry.page.evaluate(() => {
const element = document.getElementById("spectrum-bandplan-strip");
return { segments: element.children.length, empty: element.classList.contains("bp-empty") };
});
assert.ok(strip.segments > 0,
"the band plan never arrived after its first request was refused");
assert.equal(strip.empty, false, "the strip is still showing its placeholder");
} finally {
await retry.browser.close();
await retryFixture.close();
}
// The map's filter panel is a bar across the top of the map, not a window
// sitting on it: it has to stay one or two rows tall, span most of the width,
// and keep clear of what shares the map's corners — Leaflet's zoom buttons and
// the band legend. A panel that grew a column would cover the map it filters.
// Fullscreen and the filter toggle ride at the bar's right-hand end, so only
// the filters collapse: the bar itself has to survive Hide Filters, or there
// is nothing left to click to bring them back.
const mapFixture = await startWebFixture({ spectrum: true });
const mapView = await startBrowser(chromium);
try {
for (const width of [1600, 1200]) {
await mapView.page.setViewportSize({ width, height: 950 });
await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" });
await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await mapView.page.waitForTimeout(1200);
const bar = await mapView.page.evaluate(() => {
const box = (element) => (element ? element.getBoundingClientRect() : null);
const panel = document.querySelector(".map-overlay-panel");
const stage = box(document.getElementById("map-stage"));
const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom"));
const legend = box(document.getElementById("map-band-legend"));
const panelBox = box(panel);
const hits = (a, b) => !!a && !!b
&& a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
return {
widthPct: Math.round((panelBox.width / stage.width) * 100),
height: Math.round(panelBox.height),
outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1,
hitsZoom: hits(panelBox, zoom),
hitsLegend: hits(panelBox, legend),
// Both controls belong to the bar now, not to a floating corner block.
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
.map((button) => button.id).join(","),
// The rule dividing them from the filters is drawn on this block's
// edge, so the block has to run the height of the filters beside it —
// centred, it left the rule floating as a stub against a two-row bar.
actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height
- box(panel.querySelector(".map-overlay-actions")).height),
// Every label sits in a gutter of one width, so whichever group leads
// a row leads it from the same place: with the labels at their natural
// widths, a row starting "Search" began 10px off one starting "Filter".
rowStarts: (() => {
const leaders = new Map();
for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) {
const rect = box(group);
// Groups of a row differ in height, so bucket them by their middle.
const row = Math.round((rect.top + rect.height / 2) / 20);
if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group);
}
return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left));
})(),
clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1,
};
});
assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`);
assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`);
assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`);
assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
`the bar carries "${bar.actionsInBar}" at ${width}px`);
assert.ok(bar.actionsShort <= 1,
`the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`);
assert.equal(new Set(bar.rowStarts).size, 1,
`the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`);
assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`);
assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`);
}
// Band chips only exist once something has been heard on a band. The bar
// used to explain "all bands visible by default" in a line of prose wedged
// between the chips and the next group, which is neither what a toolbar is
// for nor a width it can spare: an "All" chip says it and undoes a
// selection. Nothing is dimmed while nothing is filtered out, either — every
// chip used to come up greyed at the very moment all of them were showing.
await mapView.page.evaluate(() => {
const ts = Date.now();
for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) {
window.trxPluginRuntime.dispatch("ft8",
{ message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" });
}
});
await mapView.page.waitForTimeout(1500);
const chipRow = () => mapView.page.evaluate(() => {
const row = document.getElementById("map-locator-choice-filter");
const chips = [...row.querySelectorAll(".map-locator-chip")];
const all = row.querySelector(".map-locator-chip-all");
return {
bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()),
prose: row.querySelector(".map-locator-empty")?.textContent ?? null,
allActive: all?.classList.contains("is-active") ?? null,
dimmed: chips.filter((chip) => chip.classList.contains("is-inactive"))
.map((chip) => chip.textContent.trim()),
selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true"
&& chip !== all).map((chip) => chip.textContent.trim()),
};
});
const unfiltered = await chipRow();
assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"),
`the chip row is showing ${unfiltered.bands.join(",")}`);
assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`);
assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map");
assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`);
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click();
await mapView.page.waitForTimeout(300);
const filtered = await chipRow();
assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`);
assert.equal(filtered.allActive, false, "All stayed lit with a band picked out");
assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such");
// And back: All is how a selection is undone without hunting for the chips
// that are in it.
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click();
await mapView.page.waitForTimeout(300);
const cleared = await chipRow();
assert.equal(cleared.allActive, true, "All did not clear the band selection");
assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`);
assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`);
// Hiding gives the map back, but leaves the bar itself — collapsed to its
// two controls at the right-hand edge — so the filters can be brought back.
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const toggled = await mapView.page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const stage = document.getElementById("map-stage").getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0;
return {
filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"),
widthPct: Math.round((panelBox.width / stage.width) * 100),
rightGap: Math.round(stage.right - panelBox.right),
label: document.getElementById("map-overlay-toggle-btn").textContent.trim(),
togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"),
};
});
assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters");
assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it");
assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`);
assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`);
assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`);
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const restored = await mapView.page.evaluate(() =>
!document.querySelector(".map-overlay-filters").classList.contains("is-hidden"));
assert.equal(restored, true, "Show Filters did not bring the filters back");
assert.deepEqual(mapView.runtimeErrors, []);
} finally {
await mapView.browser.close();
await mapFixture.close();
}
@@ -1,407 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The static server the browser tests load the real web assets from. It was
// inline in browser-smoke.mjs until a second browser test needed a rig with a
// spectrum: the interesting layout lives in the spectrum area, and none of it
// could be exercised while the only fixture served a CAT-only rig.
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const DECODER_REGISTRY = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
// every run exercising the client's retry path instead of its history path.
function encodeCbor(value) {
const chunks = [];
const head = (major, length) => {
if (length < 24) return Buffer.from([(major << 5) | length]);
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
if (length < 0x10000) {
const buffer = Buffer.alloc(3);
buffer[0] = (major << 5) | 25;
buffer.writeUInt16BE(length, 1);
return buffer;
}
if (length < 0x1_0000_0000) {
const buffer = Buffer.alloc(5);
buffer[0] = (major << 5) | 26;
buffer.writeUInt32BE(length, 1);
return buffer;
}
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
const buffer = Buffer.alloc(9);
buffer[0] = (major << 5) | 27;
buffer.writeBigUInt64BE(BigInt(length), 1);
return buffer;
};
const write = (item) => {
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
if (typeof item === "number") {
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
const buffer = Buffer.alloc(9);
buffer[0] = 0xfb;
buffer.writeDoubleBE(item, 1);
chunks.push(buffer);
return;
}
if (typeof item === "string") {
const bytes = Buffer.from(item, "utf8");
chunks.push(head(3, bytes.length), bytes);
return;
}
if (Array.isArray(item)) {
chunks.push(head(4, item.length));
item.forEach(write);
return;
}
const entries = Object.entries(item);
chunks.push(head(5, entries.length));
for (const [key, entryValue] of entries) {
const keyBytes = Buffer.from(key, "utf8");
chunks.push(head(3, keyBytes.length), keyBytes);
write(entryValue);
}
};
write(value);
return Buffer.concat(chunks);
}
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
// so a deep link or a refresh serves the SPA shell, not a 404.
if (!path.extname(urlPath)) return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
/**
* Starts the fixture server.
*
* `spectrum` turns the rig into an SDR: `filter_controls` is what gates the
* spectrum panel, and frames are pushed on the /spectrum stream from a centre
* frequency the test moves with `setCenterHz` to simulate tuning across bands.
*/
export async function startWebFixture({
spectrum = false,
tx = false,
meterDb = -70,
decodes = [],
mode = "FM",
history = {},
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx,
filter_controls: spectrum,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
const state = { centerHz: 7074000 };
let bandplanServed = false;
const status = {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
num_vfos: 1,
lock: false,
lockable: tx,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx,
tx_limit: tx,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,
},
},
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
// Reported only by SDR backends, and what makes the client show the
// squelch control at all.
filter: spectrum
? {
bandwidth_hz: 12_000,
sdr_squelch_enabled: false,
sdr_squelch_threshold_db: -95,
sdr_agc_enabled: false,
}
: null,
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: bandplanEnabled,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
["/bookmarks", bookmarks],
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
// page has a spectrum range to place bookmarks and allocations against.
const spectrumBins = Buffer.alloc(512, 200);
const spectrumB64 = spectrumBins.toString("base64");
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
// Setting a control means the next status carries the new value, the way a
// real server echoes what it applied.
if (url.pathname === "/set_sdr_squelch") {
const enabled = url.searchParams.get("enabled") === "true";
const threshold = Number(url.searchParams.get("threshold_db"));
if (status.filter) {
status.filter.sdr_squelch_enabled = enabled;
if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold;
}
response.writeHead(200).end();
return;
}
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
status.active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
// Rejects the first band plan request the way the server did before it was
// classified as a public asset: the page asks for it at startup, which can
// land before the session exists.
if (bandplanUnauthorizedFirst && url.pathname === "/bandplan.json" && !bandplanServed) {
bandplanServed = true;
response.writeHead(401).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
// 200 means "audio is configured": the client hides the whole audio row —
// and the squelch control with it — when this 404s.
if (url.pathname === "/audio") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 }));
return;
}
if (spectrum && url.pathname === "/spectrum") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`);
}, 100);
request.on("close", () => clearInterval(timer));
return;
}
// The meter streams like the server's does: the squelch reads its noise
// level from here, so a static snapshot would leave it nothing to measure.
if (url.pathname === "/meter") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`);
}, 120);
request.on("close", () => clearInterval(timer));
return;
}
// Decodes arrive on this stream in the server's own shape: a routing
// "type" naming the decoder, snake_case fields inside. The mini views, the
// map markers and the history all hang off it, and serving nothing left
// every one of them untested.
if (url.pathname === "/decode/history") {
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
response.writeHead(200, { "content-type": "application/cbor" });
response.end(encodeCbor(payload));
return;
}
if (url.pathname === "/decode") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": decode stream\n\n");
// Repeats: a live decoder keeps producing, and the views that collapse
// by station or vessel need more than one frame to behave like they do
// in front of a radio.
let sent = 0;
const timer = setInterval(() => {
if (!decodes.length) return;
const decode = decodes[sent++ % decodes.length];
// Stamped as they leave: the client prunes anything older than the
// retention window, so a fixed epoch would be dropped on arrival.
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
}, 400);
request.on("close", () => clearInterval(timer));
return;
}
// The real server pushes rig state here every second or so; serving an
// open-but-silent stream meant nothing in the client's state-update path
// was ever exercised.
if (url.pathname === "/events") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
// Varying, as a real one is: the client skips a frame identical to the
// last, so a repeated payload exercises none of the state-update path.
const frame = () => JSON.stringify({
...status,
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
});
response.write(`data: ${frame()}\n\n`);
const timer = setInterval(() => {
response.write(`data: ${frame()}\n\n`);
}, 700);
request.on("close", () => clearInterval(timer));
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address !== "object") throw new Error("fixture server has no port");
return {
origin: `http://127.0.0.1:${address.port}`,
selectedRigs,
rigsResponse,
/** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */
setCenterHz(hz) { state.centerHz = hz; },
close() {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
},
};
}
/** Launches headless Chromium and records any uncaught page error. */
export async function startBrowser(chromium) {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
return { browser, page, runtimeErrors };
}
@@ -367,22 +367,14 @@ mod tests {
("aprs-symbols-24-1", status::APRS_SYMBOLS_ALTERNATE),
("aprs-symbols-24-2", status::APRS_SYMBOLS_OVERLAY),
] {
assert_eq!(
png_dimensions(bytes),
(384, 144),
"{name} is not a 16x6 grid"
);
assert_eq!(png_dimensions(bytes), (384, 144), "{name} is not a 16x6 grid");
}
for (name, bytes) in [
("aprs-symbols-24-0-2x", status::APRS_SYMBOLS_PRIMARY_2X),
("aprs-symbols-24-1-2x", status::APRS_SYMBOLS_ALTERNATE_2X),
("aprs-symbols-24-2-2x", status::APRS_SYMBOLS_OVERLAY_2X),
] {
assert_eq!(
png_dimensions(bytes),
(768, 288),
"{name} is not a 2x sheet"
);
assert_eq!(png_dimensions(bytes), (768, 288), "{name} is not a 2x sheet");
}
}
}
@@ -504,14 +504,7 @@ impl RouteAccess {
return Self::Public;
}
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
// users without a band plan, and everyone else without one whenever the
// page requested it before the session was established.
if path == "/bandplan.json" {
return Self::Public;
}
// Static assets
if path.starts_with("/style.css")
|| path.starts_with("/app.js")
|| path.ends_with(".js")
@@ -703,12 +696,6 @@ mod tests {
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
// asset suffix list, so this one has to be named.
assert_eq!(
RouteAccess::from_path("/bandplan.json"),
RouteAccess::Public
);
}
#[test]
@@ -41,8 +41,7 @@ pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers
// Each sheet is a 16x6 grid of 24px cells indexed by `symbol code - 0x21`:
// table 0 is the primary ('/') set, table 1 the alternate ('\') set, and
// table 2 the overlay characters drawn on top of an alternate symbol.
pub const APRS_SYMBOLS_PRIMARY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0.png");
pub const APRS_SYMBOLS_PRIMARY: &[u8] = include_bytes!("../assets/web/vendor/aprs-symbols-24-0.png");
pub const APRS_SYMBOLS_PRIMARY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0-2x.png");
pub const APRS_SYMBOLS_ALTERNATE: &[u8] =
@@ -837,6 +837,12 @@ impl ChannelDsp {
}
}
let signal_power = decimated
.iter()
.map(|s| s.re * s.re + s.im * s.im)
.sum::<f32>()
/ decimated.len() as f32;
let signal_db = 10.0 * signal_power.max(1e-12).log10();
const WFM_OUTPUT_GAIN: f32 = 0.50;
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
let mut out = decoder.process_iq(decimated);
@@ -878,14 +884,7 @@ impl ChannelDsp {
raw
}
};
// Against the meter reading, not the block level after the IQ AGC.
// The threshold arrives in the scale the operator sets it from — the
// S-meter and the spectrum — while the level measured here had been
// through the AGC, whose whole job is to hold it at a setpoint. For
// every mode that has one (FM, PKT, AIS, AM, SAM) the squelch was
// therefore comparing against a near-constant, and for the rest it was
// still off by the decimation correction the meter applies.
if !self.squelch.update(&self.mode, self.last_signal_db) {
if !self.squelch.update(&self.mode, signal_db) {
audio.fill(0.0);
}
@@ -934,93 +933,6 @@ mod tests {
dsp.process_block(&block);
}
/// Feeds one signal twice, with the squelch threshold set from the channel's
/// own meter reading: 6 dB above it must gate the audio, 6 dB below it must
/// pass. FM runs an IQ AGC, so a squelch measured after that stage compares
/// against a level pinned near the AGC setpoint — some 20 dB adrift of the
/// scale the operator reads the threshold off, and open on plain noise.
#[test]
fn squelch_follows_the_meter_the_threshold_is_set_from() {
const AMPLITUDE: f32 = 0.0025;
let (pcm_tx, mut pcm_rx) = broadcast::channel::<Vec<f32>>(4096);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(8);
let mut dsp = ChannelDsp::new(
0.0,
&RigMode::FM,
48_000,
8_000,
1,
20,
12_000,
75,
true,
false,
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
pcm_tx,
iq_tx,
);
// A 1 kHz tone on the carrier, so an open gate is audibly non-zero and
// a closed one is unambiguously silent.
let mut phase = 0.0_f32;
let mut mod_phase = 0.0_f32;
let mut feed = |dsp: &mut ChannelDsp, blocks: usize| {
for _ in 0..blocks {
let mut block = Vec::with_capacity(4096);
for _ in 0..4096 {
mod_phase += std::f32::consts::TAU * 1_000.0 / 48_000.0;
phase += std::f32::consts::TAU * (3_000.0 * mod_phase.sin()) / 48_000.0;
block.push(Complex::new(
AMPLITUDE * phase.cos(),
AMPLITUDE * phase.sin(),
));
}
dsp.process_block(&block);
}
};
let drain = |rx: &mut broadcast::Receiver<Vec<f32>>| {
let mut audio = Vec::new();
while let Ok(frame) = rx.try_recv() {
audio.extend_from_slice(&frame);
}
audio
};
let peak = |audio: &[f32]| audio.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
// Settle the meter on this signal, then read what the operator would.
feed(&mut dsp, 24);
let meter_db = dsp.signal_db();
assert!(
meter_db > -120.0,
"the meter never moved off its floor ({meter_db} dB)"
);
dsp.set_squelch(true, meter_db + 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let gated = drain(&mut pcm_rx);
assert!(!gated.is_empty(), "no audio frames were produced at all");
// From the second half on: the first frame out still carries the audio
// that was already buffered when the threshold changed.
assert_eq!(
peak(&gated[gated.len() / 2..]),
0.0,
"squelch set 6 dB above the meter ({meter_db} dB) still passed audio"
);
dsp.set_squelch(true, meter_db - 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let passed = drain(&mut pcm_rx);
assert!(!passed.is_empty(), "no audio frames were produced at all");
assert!(
peak(&passed[passed.len() / 2..]) > 0.0,
"squelch set 6 dB below the meter ({meter_db} dB) gated the audio"
);
}
#[test]
fn channel_dsp_set_mode() {
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);