Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 17300170cc [test](trx-frontend-http): read the statistics counters past their formatting
CI / test (pull_request) Successful in 7m52s
CI / lint (push) Successful in 2m19s
CI / lint (pull_request) Successful in 2m23s
CI / frontend (pull_request) Successful in 4m31s
CI / reuse (pull_request) Successful in 5s
CI / test (push) Successful in 7m43s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 6s
The new assertion parsed the decode counter with Number() on its text.  The
counters are written with toLocaleString(), so the 1220 records the history
fixture serves arrive as "1,220" and the parse gave NaN.  It passed here and
failed on CI because the count in the local repro was 18 — below the point
where grouping appears — and the runner's locale groups where mine did not.

Read the digits and ignore the separator, whichever one the locale picks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 09:43:43 +02:00
sjgandClaude Opus 5 e41c13917d [fix](trx-frontend-http): put HF APRS on the map, under its own source
CI / lint (pull_request) Successful in 2m22s
CI / test (pull_request) Successful in 8m39s
CI / frontend (pull_request) Failing after 1m24s
CI / reuse (pull_request) Successful in 5s
The HF APRS list never plotted anything.  Its plugin ships in the map plugin
group and its packets carry positions, but nothing ever handed one to the
map, so a station heard on 30 m appeared in the panel and nowhere else — and
unlike the replay gaps around it, no reload brought it back.

Plot it, and not as more VHF APRS.  HF is a different band and a different
path, and lumping the two together would leave no way to tell them apart or
to look at one without the other, so it goes on as a source of its own: its
own colour, its own chip in the map's Show filter, its own entry in the
source legend, and its own clear.

Station entries are keyed by source and callsign rather than callsign alone,
so a station worked on both bands keeps a marker for each while the popups,
the search text and the tracks still show the callsign as heard.

decode-flow feeds an HF beacon alongside the VHF one and checks all of it:
both reach the map under their own sources, the Show row offers HF APRS next
to APRS, and turning that chip off takes the HF station off the map while the
VHF one stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 09:05:26 +02:00
sjgandClaude Opus 5 ae7df31d91 [fix](trx-frontend-http): replay what arrived before a lazy view loaded
CI / lint (pull_request) Successful in 2m23s
CI / test (pull_request) Successful in 8m36s
CI / frontend (pull_request) Failing after 1m23s
CI / reuse (pull_request) Successful in 5s
Opening the Map or Statistics page showed only what had been decoded since
the moment it was opened, and a reload — landing straight on the tab, so its
module loads at startup ahead of the history — was the only way to see the
rest.  Two things were being thrown away.

The decode log the Statistics page counts lives in the map module, which is
lazy.  Recording into a module that is not loaded yet is a no-op, and unlike
the map markers nothing replayed the log when it finally arrived, so every
decode heard before the first visit was simply never counted.  Hold those
records in the client and hand them over when the module attaches.

The map's own replay covered APRS, AIS and VDES, whose plugins implement
syncMap, but not the grid squares: the FTx family and WSPR plotted locators
as decodes arrived and had no replay at all, so everything they heard before
the map loaded was lost, and the unique-grid counter with it.  Both plot
through a helper now, which their syncMap replays oldest first.  A replayed
WSPR spot carries the frequency it was heard on rather than one worked out
against wherever the dial has moved to since.

Pinned in decode-flow, whose history fixture gains FT8 and WSPR spots: after
a first visit the statistics count every stored decode and every grid square,
which before this change were 0 and 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:56:06 +02:00
sjgandClaude Opus 5 bf3bcc8a84 [fix](trx-frontend-http): show one rig at a time on the digital modes page
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the
decode stream carries every rig's traffic to every browser.  The decoder
panels listed all of it: a station a background rig copied on another band
appeared in the APRS list next to the selected rig's, the vessel counts and
the "latest seen" lines counted both, the status lines said "Receiving"
because some other rig was, and the CW pane interleaved two rigs into one
stream of text that read as neither.

The page is about the rig the operator selected — the one whose spectrum is
on screen and whose audio is playing — so each panel now shows what that rig
heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV
frame is painting, and the CW pane.

Nothing is dropped on the way in.  The map is the whole station's view, has
its own rig filter, and would empty out if the plugins stopped feeding it, so
the histories still hold every rig and the map still plots them.  That also
means a switch loses nothing: the runtime gained a rerender hook, which the
rig switch calls, and switching back brings the other rig's traffic up again.
The CW pane is the exception — a running stream of text cannot be unpicked
after the fact — so it starts empty on the rig switched to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-07 08:11:13 +02:00
32 changed files with 876 additions and 232 deletions
+23
View File
@@ -454,6 +454,29 @@ listener and every rig's audio listener.
---
## Multiple Rigs in the Web UI
A client connected to several rigs decodes all of them at once, whichever one
is on screen. The rig picker in the header decides which rig the UI is about,
and each page answers that differently:
| Page | Shows |
|------|-------|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
| Statistics | The whole station, including the per-rig comparison. |
Switching rigs repaints the radio and digital modes pages for the rig now
selected. Nothing is lost by switching: the traffic other rigs heard is still
held, and switching back brings it up again. The one exception is the CW pane,
which is a single running stream of copied text rather than a list of frames,
so it starts empty on the rig you switch to.
Each browser tab keeps its own selection, so two tabs can watch two rigs.
---
## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the
@@ -1,6 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -143,18 +144,22 @@ function aisLatestByVessel(messages) {
}
return Array.from(byMmsi.values());
}
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -272,7 +277,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -304,10 +309,10 @@ function normalizeServerAisMessage(msg) {
}
function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -347,8 +352,9 @@ if (aisFilterInput) {
});
}
function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
window.trxPluginRuntime.registerDecoder({
@@ -358,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -1065,6 +1065,9 @@ var runtime = {
plugin.prune();
return true;
},
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
@@ -3577,6 +3580,7 @@ function resetDecoderStateOnRigSwitch() {
if (el) el.textContent = "--";
});
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return;
@@ -5885,6 +5889,7 @@ function navigateToTab(name, options = {}) {
if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => {
console.error(error);
});
@@ -7615,11 +7620,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"sstv",
"sstv_progress"
]);
var pendingDecodeStats = [];
var PENDING_DECODE_STATS_MAX = 5e4;
function recordDecodeStat(kind, rig, tsMs) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7665,9 +7694,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
}
window.trx.modules.map?.scheduleStatsRender();
scheduleStatsRenderIfLoaded();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -6,8 +6,9 @@ import {
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -85,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -220,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
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" });
@@ -282,8 +288,9 @@ if (aprsFilterInput) {
});
}
function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -293,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -1,6 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -156,9 +157,10 @@ function initializeFtxDecoder(config) {
const render = () => {
prune();
if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -166,19 +168,22 @@ function initializeFtxDecoder(config) {
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message) => {
const plotLocator = (message) => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
};
const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
@@ -194,7 +199,9 @@ function initializeFtxDecoder(config) {
};
const receiveBatch = (messages) => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -232,7 +239,15 @@ function initializeFtxDecoder(config) {
prune();
render();
},
reset
reset,
rerender: () => {
bridge.updateFt8Bar?.();
render();
},
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...history].reverse()) plotLocator(message);
}
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => {
@@ -8,7 +8,11 @@ function isActiveRigDecode(rigId) {
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export {
isActiveRigDecode
isActiveRigDecode,
forActiveRig
};
@@ -1,6 +1,6 @@
import {
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -329,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})();
});
function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
@@ -370,10 +371,10 @@ function onServerCw(evt) {
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -398,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
}
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
@@ -1,7 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
@@ -1,7 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
@@ -2,8 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-K3D6FOP5.js";
import "./chunk-BQQXSNLC.js";
} from "./chunk-PJ5Q7CWJ.js";
import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore,
hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function plotHfAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "",
pkt.lat,
pkt.lon,
pkt.info ?? "",
pkt.symbolTable,
pkt.symbolCode,
pkt,
"hf_aprs"
);
}
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
}
function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
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" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
});
}
function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
}
});
@@ -60,7 +60,7 @@ var mapWindow = window;
const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null;
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 DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_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() };
@@ -128,6 +128,12 @@ var mapWindow = window;
}
return trimmed;
}
function aprsEntrySource(entry) {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
function aprsStationKey(call, source) {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round",
interactive: false
});
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -381,6 +387,7 @@ var mapWindow = window;
}
function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
key,
label: mapSourceLabel(key),
color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
}
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return;
}
if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
}
return null;
}
function _aprsAddMarkerToMap(call, entry) {
function _aprsAddMarkerToMap(key, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry);
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6,
color: "#00d17f",
fillColor: "#00d17f",
color,
fillColor: color,
fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call);
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : [])
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1);
}
function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -131,15 +136,20 @@ function onImage(msg) {
sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
}
scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
}
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1];
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -160,8 +170,9 @@ function renderLatestCard() {
</div>`;
}
function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage,
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView
reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -1,6 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-BQQXSNLC.js";
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -63,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -185,7 +190,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -212,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
}
function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -254,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -272,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html;
}
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function(i) {
const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().length;
const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
}
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
}
}
function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
}
function onServerWefax(msg) {
addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax,
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView
reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import {
hostCore
} from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
}
function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
}
})();
});
function onServerWspr(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving";
function plotWsprLocator(msg) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...rfHz === null ? {} : { freq_hz: rfHz }
});
}
function onServerWspr(msg) {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
wsprWindow.trxPluginRuntime.registerDecoder({
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch,
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => {
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
}
});
@@ -2240,9 +2240,10 @@ function resetDecoderStateOnRigSwitch() {
});
// The decode stream is not rig-scoped, so the histories behind the mini views
// survive the switch: drop the outgoing rig's frames from them now instead of
// leaving them on screen until the next state update.
// and the decoder panels survive the switch: repaint both for the rig now
// selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
}
function resetWfmStereoIndicator() {
@@ -4911,6 +4912,8 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
// Passes go stale while the page is closed, so each visit reloads them.
// The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.();
// The map module owns the decode log; this is the moment it can exist.
flushPendingDecodeStats();
}).catch((error: unknown) => { console.error(error); });
if (name === "map") {
_initMapWhenReady();
@@ -6596,11 +6599,49 @@ const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
]);
// The decode log the Statistics page counts lives in the map module, which is
// lazy: it arrives when the Map or Statistics tab is first opened, long after
// the history replayed and the live decodes started coming. Recording into a
// module that is not there yet dropped every one of them, and nothing replayed
// them afterwards -- so the page opened empty and only filled in from decodes
// heard after it, which is why it took a reload (landing on the tab, so the
// module loads at startup) to show the whole picture. Hold them here until
// the module arrives, then hand them over.
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
const pendingDecodeStats: PendingDecodeStat[] = [];
// The module's own log keeps 50k entries; there is no point holding more here.
const PENDING_DECODE_STATS_MAX = 50_000;
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
const stats = window.trx.modules.map;
if (stats) {
stats.statsRecordDecode(kind, rig, tsMs);
return;
}
pendingDecodeStats.push({ kind, rig, tsMs });
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
}
}
function flushPendingDecodeStats() {
const stats = window.trx.modules.map;
if (!stats || pendingDecodeStats.length === 0) return;
for (const entry of pendingDecodeStats.splice(0)) {
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
}
stats.scheduleStatsRender();
}
function scheduleStatsRenderIfLoaded() {
window.trx.modules.map?.scheduleStatsRender();
}
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender();
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
scheduleStatsRenderIfLoaded();
}
}
@@ -6650,9 +6691,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
// Record statistics for restored history messages.
if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) {
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
window.trx.modules.map?.scheduleStatsRender();
scheduleStatsRenderIfLoaded();
}
window.trxPluginRuntime.restore(kind, messages);
}
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
return element as MapElement;
}
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
interface TrackPoint { lat: number; lon: number; tsMs: number }
interface DecodeDetail {
station?: string | null; source?: string | null; target?: string | null;
@@ -83,6 +83,9 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
/** Callsign as heard. The map key qualifies it with the source, so that a
* station worked on both 144 MHz and 30 m keeps one marker per band. */
call?: string;
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon;
@@ -171,7 +174,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
aisMapAddVessel?(message: MapMessage): void;
vdesMapAddPoint?(message: MapMessage): void;
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
const decodeContactPaths = new Map<string, MapEntry>();
let selectedMapQsoKey: string | null = null;
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 DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_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";
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
return trimmed;
}
/** The source a station marker belongs to, defaulting to VHF APRS for
* entries stored before HF APRS had a source of its own. */
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
}
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
* so one station heard on both bands keeps a marker for each. */
function aprsStationKey(call: string, source: DecoderSource): string {
return source === "aprs" ? call : `${source}:${call}`;
}
function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round",
interactive: false,
}) as unknown as TrxLayer;
track.__trxType = "aprs";
track.__trxType = aprsEntrySource(entry);
track._aprsCall = call;
entry.track = track;
}
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase();
}
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f";
// Far enough from the VHF green to tell the two apart at a glance.
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type);
}
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
}
}
for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
availableSources.add("aprs");
break;
}
if (!entry?.visibleInHistoryWindow) continue;
availableSources.add(aprsEntrySource(entry));
}
const bandMap = new Map<string, FilterChip>();
for (const entry of locatorMarkers.values()) {
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
}
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
.filter((key) => availableSources.has(key))
.map((key) => ({
key,
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
}
return parts.join(" ").toLowerCase();
}
if (type === "aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call);
if (type === "aprs" || type === "hf_aprs") {
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
};
mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") {
if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => {
stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker);
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track);
}
stationMarkers.delete(key);
});
stationMarkers.clear();
return;
}
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
const source = aprsEntrySource(entry);
const call = entry.call ?? String(marker._aprsCall);
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap);
}
selectedAprsTrackCall = String(marker._aprsCall);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
return;
}
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
return null;
}
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
if (!aprsMap || entry.lat == null || entry.lon == null) return;
refreshAprsTrack(call, entry);
refreshAprsTrack(key, entry);
const source = aprsEntrySource(entry);
const call = entry.call ?? key;
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], {
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
radius: 6, color, fillColor: color, fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = "aprs";
marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = call;
marker._aprsCall = key;
entry.marker = marker;
mapMarkers.add(marker);
}
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
const existing = stationMarkers.get(call);
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
const key = aprsStationKey(call, stationSource);
const existing = stationMarkers.get(key);
if (existing) {
existing.pkt = pkt;
existing.lat = lat;
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) {
prevPoint.tsMs = tsMs;
}
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint],
type: "aprs",
type: stationSource,
call,
pkt,
lat,
lon,
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []),
};
stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
stationMarkers.set(key, entry);
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance();
}
};
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
@@ -4,13 +4,14 @@
import { hostState } from "./host.js";
// The decode panels are aggregate views on purpose: they list what every rig
// heard, and the history they restore from is not rig-scoped either. The mini
// views over the waterfall are not aggregates they read as a caption for the
// spectrum underneath, so a frame a background rig copied on another band
// belongs to a different picture entirely. Each decode names the rig that
// heard it (the client stamps `rig_id` as the frame leaves the audio
// connection), which is all the overlays need to keep to the rig on screen.
// The decode stream is not rig-scoped: every rig a client is connected to sends
// its decodes to every browser, each frame naming the rig that heard it (the
// client stamps `rig_id` as it leaves the audio connection). The map wants all
// of them — it has its own filter, and plotting only one rig would empty it —
// but the radio page and the decoder panels describe one rig at a time: the one
// the operator selected, whose spectrum is on screen and whose audio is
// playing. A frame a background rig copied on another band belongs to neither
// picture, so both filter on this.
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser
@@ -18,3 +19,8 @@ export function isActiveRigDecode(rigId: string | null | undefined): boolean {
if (!activeRigId || !rigId) return true;
return rigId === activeRigId;
}
/** The decodes of `items` that the selected rig heard, in their original order. */
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
@@ -6,7 +6,7 @@ import { hostCore, hostState } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
@@ -191,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values());
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aisRigMessages(): AisMessage[] {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
const latest = rigMessages[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
@@ -371,7 +378,7 @@ function renderAisHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
@@ -410,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -455,8 +463,9 @@ if (aisFilterInput) {
}
function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
const message = normalizeServerAisMessage(msg);
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
}
updateAisSummary();
@@ -467,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -4,7 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -107,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function aprsRigPackets(): AprsPacket[] {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -259,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
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" });
@@ -327,8 +336,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
const packet = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
}
renderAprsHistory();
@@ -339,6 +349,7 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -432,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
// --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// The panel is a single stream of copied text: characters from a background
// rig do not belong in it at all, and interleaving them would make both
// rigs unreadable. The bar histories below keep every rig, each on its own
// line, and pick out the selected rig when they paint.
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
// Append decoded text to output
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -475,10 +480,10 @@ function onServerCw(evt: CwEvent): void {
}
updateCwBar();
}
if (cwSignalIndicator) {
if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
@@ -506,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
// The copied text of a rig that is no longer on screen cannot be unpicked
// from the pane, so the switch starts the new rig's stream from empty.
rerender: () => {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
updateCwBar();
},
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,7 +4,7 @@
import { hostCore } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -229,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => {
prune();
if (!messagesElement) return;
// The history holds every rig's decodes, for the map; the panel is about
// the rig the operator is working.
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
for (const message of rigMessages) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
@@ -239,19 +242,28 @@ export function initializeFtxDecoder(config: FtxConfig): void {
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message: FtxMessage): FtxMessage => {
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
* Split out of normalize so a decode can be replayed onto a map that
* arrived later: the module is lazy, and everything decoded before it
* loaded had nowhere to go. */
const plotLocator = (message: FtxMessage): void => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
// Already an RF frequency on a replay, an audio offset on arrival; the
// conversion only fires below 100 kHz, so it is right either way.
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
}
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
};
const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return {
// The rig that heard it, kept so the mini view can tell a decode of the
// rig on screen from one a background rig made on another band.
@@ -267,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
};
const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
// "Receiving" is the panel's own status, and the panel is the rig's.
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
status.textContent = "Receiving";
}
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
@@ -305,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch,
prune: () => { prune(); render(); },
reset,
rerender: () => { bridge.updateFt8Bar?.(); render(); },
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import {
aprsAgeText,
aprsPacketCategory,
@@ -19,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
aprsMapAddStation?: (
call: string, lat: number, lon: number, info: string,
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
packet: AprsPacket, source: "aprs" | "hf_aprs",
) => void;
clearMapMarkersByType?: (type: string) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText);
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function hfAprsRigPackets(): AprsPacket[] {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch);
}
const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
const latest = rigPackets[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
}
function pruneHfAprsHistoryView(): void {
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet.
* HF traffic goes on as its own source: it is a different band and a
* different path, and the map filter offers it separately. */
function plotHfAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
hfAprsWindow.aprsMapAddStation(
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
);
}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender();
}
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
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" });
plotHfAprsPacket(next);
normalized.push(next);
}
normalized.reverse();
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
const packet = normalizeServerHfAprsPacket(pkt);
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
}
renderHfAprsHistory();
@@ -278,4 +310,7 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
rerender: renderHfAprsHistory,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
});
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Repaint the plugin's panel from what it already holds. Called when the
* selected rig changes: the panels show one rig's traffic, and the decodes
* behind them arrived long before the switch. */
rerender?(): 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;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
@@ -14,11 +14,13 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface SstvImage {
rig_id?: string | null;
ts_ms?: number;
vis?: number;
mode?: string;
@@ -32,6 +34,7 @@ interface SstvImage {
}
interface SstvProgress {
rig_id?: string | null;
state?: string;
mode?: string;
width?: number;
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void {
// The live canvas draws one picture at a time: a background rig's rows would
// paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row.
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) {
sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}`
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
// ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void {
if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1];
const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) {
sstvDom.liveLatest.innerHTML = "";
return;
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
}
function filteredHistory(): SstvImage[] {
// Every rig's pictures are kept; the panel shows the selected rig's.
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase();
const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice();
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching;
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory,
prune: renderHistoryTable,
reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
});
sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress",
@@ -6,7 +6,7 @@ import { hostCore } from "./host.js";
export {};
import { isActiveRigDecode } from "./active-rig.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage {
@@ -117,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase();
}
/** What the selected rig heard. The history holds every rig's traffic, for
* the map; the panel is about the rig the operator is working. */
function vdesRigMessages(): VdesMessage[] {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
@@ -281,7 +288,7 @@ function renderVdesHistory() {
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
@@ -312,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
// "Receiving" is the panel's own status, and the panel is the rig's.
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -357,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next);
plotVdesMessage(next);
}
@@ -377,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
interface WefaxImage {
rig_id?: string | null;
ts_ms?: number;
ioc?: number;
lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
}
interface WefaxProgress {
rig_id?: string | null;
state?: string;
line_data?: string;
line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
}
// ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML =
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = wefaxImageHistory[0];
const img = rigImages[0];
if (!img) return;
const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
// Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) {
items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const total = wefaxRigImages().length;
const shown = items.length;
wefaxDom.historyCount.textContent =
total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void {
// The live canvas draws one picture at a time: a background rig's lines
// would paint straight over the one being watched.
if (!isActiveRigDecode(msg.rig_id)) return;
// State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg);
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -15,6 +16,10 @@ interface WsprMessage {
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
rig_id?: string | null | undefined;
/** RF frequency the spot was heard on, kept so a map replay does not
* recompute it against wherever the dial has moved to since. */
_rfHz?: number | null | undefined;
receiver?: unknown;
[key: string]: unknown;
}
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void {
pruneWsprMessageHistory();
if (!wsprMessagesEl) return;
// The history holds every rig's decodes, for the map; the panel is about the
// rig the operator is working.
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
for (const message of rigMessages) {
fragment.appendChild(renderWsprRow(message));
}
wsprMessagesEl.replaceChildren(fragment);
}
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station,
rfHz,
history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = [];
for (const msg of messages) {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
// "Receiving" is the panel's own status, and the panel is the rig's.
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
plotWsprLocator(msg);
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})();
});
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus) wsprStatus.textContent = "Receiving";
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
* The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
// A replayed spot carries the frequency it was heard on; a fresh one has it
// worked out from the dial it just arrived against.
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg,
...(rfHz === null ? {} : { freq_hz: rfHz }),
});
}
function onServerWspr(msg: WsprMessage): void {
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg);
plotWsprLocator(msg);
addWsprMessage(next.history);
}
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView,
rerender: renderWsprHistory,
// Oldest first, so the map builds the grids up in the order they were heard.
syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); },
});
@@ -2,11 +2,12 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The mini views over the waterfall caption the spectrum below them, so they
// show only what the rig on screen heard. The panels on the decoder tabs stay
// aggregate — every rig's traffic lands there — which is the distinction these
// tests pin down: a decode from a background rig belongs in the list and not in
// the overlay.
// The radio page and the digital modes page both describe one rig: the one the
// operator selected. Every rig a client is connected to feeds the same decode
// stream, so both the mini views over the waterfall and the panels behind them
// filter on the rig that heard each decode. The map is the exception and keeps
// all of them — it has its own rig filter — which is why the histories these
// views read from are never pruned by rig.
import assert from "node:assert/strict";
import test from "node:test";
@@ -65,12 +66,14 @@ async function runPlugin(entry, { window, document: doc, extras = {} }) {
return context;
}
test("the APRS mini view keeps to the active rig while the panel lists both", async () => {
test("the APRS mini view and panel both keep to the active rig", async () => {
const overlay = new ElementFixture();
const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([
["aprs-bar-overlay", overlay],
["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")],
]);
const window = {
@@ -86,7 +89,8 @@ test("the APRS mini view keeps to the active rig while the panel lists both", as
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
assert.equal(packets.children.length, 2, "the APRS panel dropped a frame it should still list");
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
});
test("the APRS mini view shows every frame until a rig is known", async () => {
@@ -196,3 +200,149 @@ test("the FT8 mini view keeps to the active rig", async () => {
assert.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
});
// ── The digital modes panels ───────────────────────────────────────────────
// The same rule one page over: a panel lists the selected rig's decodes only.
test("the AIS panel lists the active rig's vessels only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["ais-messages", messages],
["ais-vessel-count", count],
["mode", new ElementFixture("AIS")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
aisMapAddVessel: () => {},
};
await runPlugin("ais", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
});
test("the VDES panel lists the active rig's bursts only", async () => {
const messages = new ElementFixture();
const count = new ElementFixture();
const elements = new Map([
["vdes-messages", messages],
["vdes-frame-count", count],
["mode", new ElementFixture("VDES")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
vdesMapAddPoint: () => {},
};
await runPlugin("vdes", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
});
test("the FT8 panel lists the active rig's decodes only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["ft8-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
setInterval() { return 1; },
};
await runPlugin("ft8", { window, document: createDocument(elements) });
const now = Date.now();
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
});
test("the WSPR panel lists the active rig's spots only", async () => {
const messages = new ElementFixture();
const elements = new Map([
["wspr-messages", messages],
["mode", new ElementFixture("DIG")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: () => {},
};
await runPlugin("wspr", {
window,
document: createDocument(elements),
extras: { setInterval: () => 1 },
});
const now = Date.now();
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
});
test("the CW pane copies the active rig only", async () => {
const output = new ElementFixture();
const elements = new Map([
["cw-output", output],
["cw-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("CW")],
]);
const window = {
...createHost({ state: { lastActiveRigId: "rig-a" } }),
trxUi: { confirm: async () => true },
addEventListener() {},
};
await runPlugin("cw", { window, document: createDocument(elements) });
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
const copied = output.children.map((line) => line.textContent).join("");
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
});
test("switching rigs repaints the panels through the runtime", async () => {
const packets = new ElementFixture();
const elements = new Map([
["aprs-packets", packets],
["aprs-bar-overlay", new ElementFixture()],
["mode", new ElementFixture("PKT")],
]);
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
// it as they paint.
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
let activeRigId = "rig-a";
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
await runPlugin("aprs", { window, document: createDocument(elements) });
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
assert.equal(packets.children.length, 1);
activeRigId = "rig-b";
window.trxPluginRuntime.rerenderAll();
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
const shown = packets.children.map((row) => row.innerHTML).join("");
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
assert.doesNotMatch(shown, /SP1AAA/);
});
@@ -22,9 +22,16 @@ const BEACON = {
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// A second rig listening in the background, on its own band. Its traffic
// belongs in the panels, which aggregate every rig, but not in the mini view,
// which captions the spectrum of the rig on screen.
// A second rig listening in the background, on its own band. Both pages
// describe the rig on screen, so its traffic belongs in neither the panel nor
// the mini view — only on the map, which shows the whole station.
// HF APRS travels a different band and a different path from the VHF list, so
// it goes on the map under a source of its own that the filter can hide.
const HF_BEACON = {
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b",
@@ -32,7 +39,7 @@ const OTHER_RIG_VESSEL = {
// 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, OTHER_RIG_VESSEL], mode: "AIS",
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
@@ -51,12 +58,13 @@ try {
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
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})`);
assert.equal(panels.otherRig, true, "the AIS panel dropped the background rig's vessel");
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
@@ -102,8 +110,41 @@ try {
: 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");
// Both APRS stations are on it, each under its own source: the HF one used
// to be dropped entirely, since the HF list never plotted anything.
const aprsSources = await page.evaluate(() => {
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
});
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
`the map holds ${JSON.stringify(aprsSources)}`);
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
await page.waitForTimeout(400);
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
.map((chip) => chip.dataset.filterKey));
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
`the Show chips are ${JSON.stringify(chips)}`);
// Turning that chip off takes the HF station off the map and leaves the VHF
// one on: the two are filtered apart, which is the whole point of the split.
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
await page.waitForTimeout(500);
const shownAfterFilter = await page.evaluate(() => {
const map = window.trx.modules.map;
const onMap = [];
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
});
return onMap.sort();
});
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
// The map is the whole station's view — the one place a background rig's
// traffic belongs — so both vessels are on it even though the panel and the
// mini view show only the selected rig's.
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
assert.deepEqual(runtimeErrors, []);
} finally {
@@ -116,6 +157,8 @@ try {
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
@@ -125,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
freq_hz: 1500 + index, 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,
@@ -212,6 +263,27 @@ try {
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`);
// The Statistics page counts the same history. Its decode log lives in the
// map module, which is lazy, so every decode that arrived before the module
// did used to be recorded into nothing at all — the page opened empty and
// only filled in from decodes heard afterwards, and it took a reload landing
// on the tab (module loaded at startup, before the history) to show the lot.
await replay.page.evaluate(() => window.navigateToTab("statistics"));
await replay.page.waitForTimeout(1500);
// The counters are written with toLocaleString(), so a four-figure count
// arrives as "1,220" — whichever separator the runner's locale picks. Read
// the digits rather than the formatting.
const counted = await replay.page.evaluate(() => {
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
});
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.decodes} decodes`);
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
// their own: the locators of everything heard before the map loaded were lost.
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
`the statistics counted ${counted.grids} grid squares`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();