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 ## Tune Links
Every page of the web UI carries what the radio is doing in its address, so the Every page of the web UI carries what the radio is doing in its address, so the
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -143,18 +144,22 @@ function aisLatestByVessel(messages) {
} }
return Array.from(byMmsi.values()); return Array.from(byMmsi.values());
} }
function aisRigMessages() {
return forActiveRig(aisMessageHistory);
}
function updateAisSummary() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -272,7 +277,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -304,10 +309,10 @@ function normalizeServerAisMessage(msg) {
} }
function onServerAisBatch(messages) { function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -347,8 +352,9 @@ if (aisFilterInput) {
}); });
} }
function onServerAis(msg) { function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -358,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => {
updateAisBar();
renderAisHistory();
},
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
@@ -1065,6 +1065,9 @@ var runtime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() {
for (const plugin of decoders.values()) plugin.rerender?.();
},
syncMapAll() { syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.(); for (const plugin of decoders.values()) plugin.syncMap?.();
}, },
@@ -3577,6 +3580,7 @@ function resetDecoderStateOnRigSwitch() {
if (el) el.textContent = "--"; if (el) el.textContent = "--";
}); });
refreshDecodeBars(); refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { function resetWfmStereoIndicator() {
if (!wfmStFlagEl) return; if (!wfmStFlagEl) return;
@@ -5885,6 +5889,7 @@ function navigateToTab(name, options = {}) {
if (leavingSatellites) window.clearSatPredictionDom?.(); if (leavingSatellites) window.clearSatPredictionDom?.();
void loadPluginsForTab(name).then(() => { void loadPluginsForTab(name).then(() => {
if (name === "satellites") window.refreshSatPredictions?.(); if (name === "satellites") window.refreshSatPredictions?.();
flushPendingDecodeStats();
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
}); });
@@ -7615,11 +7620,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
"sstv", "sstv",
"sstv_progress" "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) { function dispatchDecodeMessage(msg, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
} }
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
@@ -7665,9 +7694,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (!IMAGE_DECODE_KINDS.has(kind)) { if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) { 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); window.trxPluginRuntime.restore(kind, messages);
} }
@@ -6,8 +6,9 @@ import {
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -85,20 +86,25 @@ function aprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText); return haystack.includes(aprsFilterText);
} }
function aprsRigPackets() {
return forActiveRig(aprsPacketHistory);
}
function aprsVisiblePackets() { function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -220,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
} }
function onServerAprsBatch(packets) { function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); const next = normalizeServerAprsPacket(pkt);
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -282,8 +288,9 @@ if (aprsFilterInput) {
}); });
} }
function onServerAprs(pkt) { function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -293,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => {
updateAprsBar();
renderAprsHistory();
},
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -156,9 +157,10 @@ function initializeFtxDecoder(config) {
const render = () => { const render = () => {
prune(); prune();
if (!messagesElement) return; if (!messagesElement) return;
const rigMessages = forActiveRig(history);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -166,19 +168,22 @@ function initializeFtxDecoder(config) {
} }
messagesElement.replaceChildren(fragment); messagesElement.replaceChildren(fragment);
}; };
const normalize = (message) => { const plotLocator = (message) => {
const raw = message.message ?? ""; const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(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 station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message,
...message, freq_hz: frequency ?? message.freq_hz,
freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails
locator_details: locatorDetails });
}); };
} const normalize = (message) => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the // 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. // rig on screen from one a background rig made on another band.
@@ -194,7 +199,9 @@ function initializeFtxDecoder(config) {
}; };
const receiveBatch = (messages) => { const receiveBatch = (messages) => {
if (messages.length === 0) return; 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); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -232,7 +239,15 @@ function initializeFtxDecoder(config) {
prune(); prune();
render(); 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); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => { const updatePeriod = () => {
@@ -8,7 +8,11 @@ function isActiveRigDecode(rigId) {
if (!activeRigId || !rigId) return true; if (!activeRigId || !rigId) return true;
return rigId === activeRigId; return rigId === activeRigId;
} }
function forActiveRig(items) {
return items.filter((item) => isActiveRigDecode(item.rig_id));
}
export { export {
isActiveRigDecode isActiveRigDecode,
forActiveRig
}; };
@@ -1,6 +1,6 @@
import { import {
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -329,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
})(); })();
}); });
function onServerCw(evt) { function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
if (evt.text && cwOutputEl) { if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl && forSelectedRig) {
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div"); const line = document.createElement("div");
@@ -370,10 +371,10 @@ function onServerCw(evt) {
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; 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))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -398,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
id: "cw", id: "cw",
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, 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() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
@@ -1,7 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts // src/plugins/ft2.ts
@@ -1,7 +1,7 @@
import { import {
initializeFtxDecoder initializeFtxDecoder
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts // src/plugins/ft4.ts
@@ -2,8 +2,8 @@ import {
initializeFt8FamilyBar, initializeFt8FamilyBar,
initializeFtxDecoder, initializeFtxDecoder,
installFtxCompatibilityHelpers installFtxCompatibilityHelpers
} from "./chunk-K3D6FOP5.js"; } from "./chunk-PJ5Q7CWJ.js";
import "./chunk-BQQXSNLC.js"; import "./chunk-S57W63QN.js";
import "./chunk-KL66PICH.js"; import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts // src/plugins/ft8.ts
@@ -5,6 +5,10 @@ import {
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OPEIVJGD.js"; } from "./chunk-OPEIVJGD.js";
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
].filter(Boolean).join(" ").toUpperCase(); ].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText); return haystack.includes(hfAprsFilterText);
} }
function hfAprsRigPackets() {
return forActiveRig(hfAprsPacketHistory);
}
function hfAprsVisiblePackets() { function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
var collapseHfAprsDuplicates = collapseAprsDuplicates; var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView() { function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
renderHfAprsHistory(); 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) { function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
function normalizeServerHfAprsPacket(pkt) { function normalizeServerHfAprsPacket(pkt) {
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
} }
function onServerHfAprsBatch(packets) { function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
}); });
} }
function onServerHfAprs(pkt) { function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({ window.trxPluginRuntime.registerDecoder({
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerHfAprsBatch, onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, 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(); const decodeContactPaths = /* @__PURE__ */ new Map();
let selectedMapQsoKey = null; let selectedMapQsoKey = null;
const mapMarkers = /* @__PURE__ */ new Set(); const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const 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 mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all"; const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
@@ -128,6 +128,12 @@ var mapWindow = window;
} }
return trimmed; 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) { function refreshAprsTrack(call, entry) {
if (!entry) return; if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -149,7 +155,7 @@ var mapWindow = window;
lineJoin: "round", lineJoin: "round",
interactive: false interactive: false
}); });
track.__trxType = "aprs"; track.__trxType = aprsEntrySource(entry);
track._aprsCall = call; track._aprsCall = call;
entry.track = track; entry.track = track;
} }
@@ -381,6 +387,7 @@ var mapWindow = window;
} }
function mapSourceLabel(type) { function mapSourceLabel(type) {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase(); return String(type || "").toUpperCase();
} }
function locatorFilterColor(type) { function locatorFilterColor(type) {
@@ -396,6 +403,7 @@ var mapWindow = window;
if (type === "vdes") return "#a78bfa"; if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b"; if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f"; if (type === "aprs") return "#00d17f";
if (type === "hf_aprs") return "#fb7185";
return locatorFilterColor(type); return locatorFilterColor(type);
} }
function bandForHz(hz) { function bandForHz(hz) {
@@ -931,10 +939,8 @@ var mapWindow = window;
} }
} }
for (const entry of stationMarkers.values()) { for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { if (!entry?.visibleInHistoryWindow) continue;
availableSources.add("aprs"); availableSources.add(aprsEntrySource(entry));
break;
}
} }
const bandMap = /* @__PURE__ */ new Map(); const bandMap = /* @__PURE__ */ new Map();
for (const entry of locatorMarkers.values()) { for (const entry of locatorMarkers.values()) {
@@ -966,7 +972,7 @@ var mapWindow = window;
for (const key of Array.from(mapLocatorFilter.bands)) { for (const key of Array.from(mapLocatorFilter.bands)) {
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); 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, key,
label: mapSourceLabel(key), label: mapSourceLabel(key),
color: mapSourceColor(key), color: mapSourceColor(key),
@@ -1028,9 +1034,10 @@ var mapWindow = window;
} }
return parts.join(" ").toLowerCase(); return parts.join(" ").toLowerCase();
} }
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : ""; const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call); const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : ""; const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase(); return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1200,9 +1207,10 @@ var mapWindow = window;
} }
}; };
mapWindow.clearMapMarkersByType = function(type) { mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null; selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => { stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) { if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker); mapMarkers.delete(entry.marker);
@@ -1211,8 +1219,8 @@ var mapWindow = window;
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track); mapMarkers.delete(entry.track);
} }
stationMarkers.delete(key);
}); });
stationMarkers.clear();
return; return;
} }
if (type === "ais") { if (type === "ais") {
@@ -1448,13 +1456,15 @@ var mapWindow = window;
if (!ll) return; if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; 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); 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); entry.track.addTo(aprsMap);
} }
selectedAprsTrackCall = String(marker._aprsCall); 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; return;
} }
if (marker._aisMmsi) { if (marker._aisMmsi) {
@@ -1848,28 +1858,33 @@ var mapWindow = window;
} }
return null; return null;
} }
function _aprsAddMarkerToMap(call, entry) { function _aprsAddMarkerToMap(key, entry) {
if (!aprsMap || entry.lat == null || entry.lon == null) return; 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 icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); 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], { const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
radius: 6, radius: 6,
color: "#00d17f", color,
fillColor: "#00d17f", fillColor: color,
fillOpacity: 0.8 fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupContent); }).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "aprs"; marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
marker._aprsCall = call; marker._aprsCall = key;
entry.marker = marker; entry.marker = marker;
mapMarkers.add(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 nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId; 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) { if (existing) {
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
@@ -1888,7 +1903,8 @@ var mapWindow = window;
} else if (prevPoint) { } else if (prevPoint) {
prevPoint.tsMs = tsMs; prevPoint.tsMs = tsMs;
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs()); existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -1899,7 +1915,8 @@ var mapWindow = window;
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }], trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: stationSource,
call,
pkt, pkt,
lat, lat,
lon, lon,
@@ -1908,9 +1925,9 @@ var mapWindow = window;
symbolCode, symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []) rigIds: new Set(msgRigId ? [msgRigId] : [])
}; };
stationMarkers.set(call, entry); stationMarkers.set(key, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs()); pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance(); if (aprsMap) scheduleDecodeMapMaintenance();
} }
}; };
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
liveRows = Math.max(liveRows, line + 1); liveRows = Math.max(liveRows, line + 1);
} }
function onProgress(msg) { function onProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state) { if (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0); beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
@@ -131,15 +136,20 @@ function onImage(msg) {
sstvHistory.push(image); sstvHistory.push(image);
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`; sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"}${image.lines ?? 0} lines`;
} }
scheduleUi("sstv-latest", renderLatestCard); scheduleUi("sstv-latest", renderLatestCard);
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable); if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
} }
function sstvRigImages() {
return forActiveRig(sstvHistory);
}
function renderLatestCard() { function renderLatestCard() {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -160,8 +170,9 @@ function renderLatestCard() {
</div>`; </div>`;
} }
function filteredHistory() { function filteredHistory() {
const rigImages = sstvRigImages();
const text = filterText.trim().toLowerCase(); 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"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
onMessage: onImage, onMessage: onImage,
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView reset: resetHistoryView,
rerender: () => {
renderLatestCard();
renderHistoryTable();
}
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -1,6 +1,7 @@
import { import {
forActiveRig,
isActiveRigDecode isActiveRigDecode
} from "./chunk-BQQXSNLC.js"; } from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -63,17 +64,21 @@ function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase(); return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
} }
function vdesRigMessages() {
return forActiveRig(vdesMessageHistory);
}
function updateVdesSummary() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -185,7 +190,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -212,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
} }
function onServerVdesBatch(messages) { function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -254,8 +259,8 @@ function plotVdesMessage(msg) {
vdesWindow.vdesMapAddPoint(msg); vdesWindow.vdesMapAddPoint(msg);
} }
function onServerVdes(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -272,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => {
updateVdesBar();
renderVdesHistory();
},
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
wefaxLiveCtx.putImageData(imgData, 0, y); wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++; wefaxLiveLineCount++;
} }
function wefaxRigImages() {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; 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>'; 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; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || "--"; const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
wefaxDom.liveLatest.innerHTML = html; wefaxDom.liveLatest.innerHTML = html;
} }
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function(i) { items = items.filter(function(i) {
const haystack = [ const haystack = [
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
} }
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.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`; 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) { function onServerWefaxProgress(msg) {
if (!isActiveRigDecode(msg.rig_id)) return;
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state; wefaxDom.status.textContent = msg.state;
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
} }
function onServerWefax(msg) { function onServerWefax(msg) {
addWefaxImage(msg); addWefaxImage(msg);
if (!isActiveRigDecode(msg.rig_id)) return;
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) { if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`; wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerWefax, onMessage: onServerWefax,
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView reset: resetWefaxHistoryView,
rerender: () => {
renderWefaxLatestCard();
renderWefaxHistoryTable();
}
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -1,3 +1,7 @@
import {
forActiveRig,
isActiveRigDecode
} from "./chunk-S57W63QN.js";
import { import {
hostCore hostCore
} from "./chunk-KL66PICH.js"; } from "./chunk-KL66PICH.js";
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
function renderWsprHistory() { function renderWsprHistory() {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
const rigMessages = forActiveRig(wsprMessageHistory);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
} }
function onServerWsprBatch(messages) { function onServerWsprBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { plotWsprLocator(msg);
...msg,
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
} }
})(); })();
}); });
function onServerWspr(msg) { function plotWsprLocator(msg) {
if (wsprStatus) wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
...msg, wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...next.rfHz === null ? {} : { freq_hz: next.rfHz } ...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); addWsprMessage(next.history);
} }
wsprWindow.trxPluginRuntime.registerDecoder({ wsprWindow.trxPluginRuntime.registerDecoder({
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
onBatch: onServerWsprBatch, onBatch: onServerWsprBatch,
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, 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 // 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 // and the decoder panels survive the switch: repaint both for the rig now
// leaving them on screen until the next state update. // selected instead of leaving the outgoing rig's traffic on screen.
refreshDecodeBars(); refreshDecodeBars();
window.trxPluginRuntime.rerenderAll();
} }
function resetWfmStereoIndicator() { 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. // 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. // The first visit is what imports the module, which renders on its own.
if (name === "satellites") window.refreshSatPredictions?.(); 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); }); }).catch((error: unknown) => { console.error(error); });
if (name === "map") { if (name === "map") {
_initMapWhenReady(); _initMapWhenReady();
@@ -6596,11 +6599,49 @@ const IMAGE_DECODE_KINDS = new Set([
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress", "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) { function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
window.trx.modules.map?.scheduleStatsRender(); scheduleStatsRenderIfLoaded();
} }
} }
@@ -6650,9 +6691,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
// Record statistics for restored history messages. // Record statistics for restored history messages.
if (!IMAGE_DECODE_KINDS.has(kind)) { if (!IMAGE_DECODE_KINDS.has(kind)) {
for (const msg of messages) { 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); window.trxPluginRuntime.restore(kind, messages);
} }
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
return element as 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 TrackPoint { lat: number; lon: number; tsMs: number }
interface DecodeDetail { interface DecodeDetail {
station?: string | null; source?: string | null; target?: string | null; station?: string | null; source?: string | null; target?: string | null;
@@ -83,6 +83,9 @@ interface MapEntry {
bookmarks?: Array<Record<string, unknown>>; bookmarks?: Array<Record<string, unknown>>;
bounds?: Leaflet.LatLngBoundsExpression; bounds?: Leaflet.LatLngBoundsExpression;
symbolTable?: string; symbolCode?: string; bandLabel?: string | null; 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; overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
pathKey?: string; sourceGrid?: string; targetGrid?: string; pathKey?: string; sourceGrid?: string; targetGrid?: string;
from?: LatLon; to?: LatLon; from?: LatLon; to?: LatLon;
@@ -171,7 +174,7 @@ interface MapWindow {
clearMapMarkersByType?(type: DecoderSource): void; clearMapMarkersByType?(type: DecoderSource): void;
navigateToAprsMap?(lat: number, lon: number): void; navigateToAprsMap?(lat: number, lon: number): void;
navigateToMapLocator?(grid: string, preferredType?: string | null): 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; aisMapAddVessel?(message: MapMessage): void;
vdesMapAddPoint?(message: MapMessage): void; vdesMapAddPoint?(message: MapMessage): void;
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): 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>(); const decodeContactPaths = new Map<string, MapEntry>();
let selectedMapQsoKey: string | null = null; let selectedMapQsoKey: string | null = null;
const mapMarkers = new Set<TrxLayer>(); 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 }; const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */ /** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all"; const MAP_FILTER_ALL_KEY = "__all";
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
return trimmed; 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 { function refreshAprsTrack(call: string, entry: MapEntry): void {
if (!entry) return; if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
lineJoin: "round", lineJoin: "round",
interactive: false, interactive: false,
}) as unknown as TrxLayer; }) as unknown as TrxLayer;
track.__trxType = "aprs"; track.__trxType = aprsEntrySource(entry);
track._aprsCall = call; track._aprsCall = call;
entry.track = track; entry.track = track;
} }
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
function mapSourceLabel(type: DecoderSource): string { function mapSourceLabel(type: DecoderSource): string {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "hf_aprs") return "HF APRS";
return String(type || "").toUpperCase(); return String(type || "").toUpperCase();
} }
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
if (type === "vdes") return "#a78bfa"; if (type === "vdes") return "#a78bfa";
if (type === "sat") return "#f59e0b"; if (type === "sat") return "#f59e0b";
if (type === "aprs") return "#00d17f"; 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); return locatorFilterColor(type);
} }
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
} }
} }
for (const entry of stationMarkers.values()) { for (const entry of stationMarkers.values()) {
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { if (!entry?.visibleInHistoryWindow) continue;
availableSources.add("aprs"); availableSources.add(aprsEntrySource(entry));
break;
}
} }
const bandMap = new Map<string, FilterChip>(); const bandMap = new Map<string, FilterChip>();
for (const entry of locatorMarkers.values()) { 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); 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)) .filter((key) => availableSources.has(key))
.map((key) => ({ .map((key) => ({
key, key,
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
} }
return parts.join(" ").toLowerCase(); return parts.join(" ").toLowerCase();
} }
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
const call = marker?._aprsCall ? String(marker._aprsCall) : ""; const key = marker?._aprsCall ? String(marker._aprsCall) : "";
const entry = stationMarkers.get(call); const entry = stationMarkers.get(key);
const call = entry?.call ?? key;
const info = entry?.info ? String(entry.info) : ""; const info = entry?.info ? String(entry.info) : "";
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
return `${call} ${info} ${pktRaw}`.toLowerCase(); return `${call} ${info} ${pktRaw}`.toLowerCase();
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
}; };
mapWindow.clearMapMarkersByType = function(type) { mapWindow.clearMapMarkersByType = function(type) {
if (type === "aprs") { if (type === "aprs" || type === "hf_aprs") {
selectedAprsTrackCall = null; selectedAprsTrackCall = null;
stationMarkers.forEach((entry) => { stationMarkers.forEach((entry, key) => {
if (aprsEntrySource(entry) !== type) return;
if (entry && entry.marker) { if (entry && entry.marker) {
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
mapMarkers.delete(entry.marker); 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); if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
mapMarkers.delete(entry.track); mapMarkers.delete(entry.track);
} }
stationMarkers.delete(key);
}); });
stationMarkers.clear();
return; return;
} }
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
if (!ll) return; if (!ll) return;
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; 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); 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); entry.track.addTo(aprsMap);
} }
selectedAprsTrackCall = String(marker._aprsCall); 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; return;
} }
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
return null; 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; 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 icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const color = mapSourceColor(source);
const marker = (icon const marker = (icon
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
: L.circleMarker([entry.lat, entry.lon], { : 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; }).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
marker.__trxType = "aprs"; marker.__trxType = source;
marker.__trxRigIds = entry.rigIds || new Set(); marker.__trxRigIds = entry.rigIds || new Set();
marker._aprsCall = call; marker._aprsCall = key;
entry.marker = marker; entry.marker = marker;
mapMarkers.add(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 nextPoint: Leaflet.LatLngTuple = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const msgRigId = pkt?.rig_id || T.lastActiveRigId; 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) { if (existing) {
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
} else if (prevPoint) { } else if (prevPoint) {
prevPoint.tsMs = tsMs; prevPoint.tsMs = tsMs;
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs()); existing.call = call;
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }], trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: stationSource,
call,
pkt, pkt,
lat, lat,
lon, lon,
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
symbolCode, symbolCode,
rigIds: new Set(msgRigId ? [msgRigId] : []), rigIds: new Set(msgRigId ? [msgRigId] : []),
}; };
stationMarkers.set(call, entry); stationMarkers.set(key, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs()); pruneAprsEntry(key, entry, mapHistoryCutoffMs());
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
if (aprsMap) scheduleDecodeMapMaintenance(); if (aprsMap) scheduleDecodeMapMaintenance();
} }
}; };
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); }, clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id), hasDecoder: (id) => decoders.has(id),
@@ -4,13 +4,14 @@
import { hostState } from "./host.js"; import { hostState } from "./host.js";
// The decode panels are aggregate views on purpose: they list what every rig // The decode stream is not rig-scoped: every rig a client is connected to sends
// heard, and the history they restore from is not rig-scoped either. The mini // its decodes to every browser, each frame naming the rig that heard it (the
// views over the waterfall are not aggregates they read as a caption for the // client stamps `rig_id` as it leaves the audio connection). The map wants all
// spectrum underneath, so a frame a background rig copied on another band // of them — it has its own filter, and plotting only one rig would empty it —
// belongs to a different picture entirely. Each decode names the rig that // but the radio page and the decoder panels describe one rig at a time: the one
// heard it (the client stamps `rig_id` as the frame leaves the audio // the operator selected, whose spectrum is on screen and whose audio is
// connection), which is all the overlays need to keep to the rig on screen. // 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 { export function isActiveRigDecode(rigId: string | null | undefined): boolean {
const activeRigId = hostState.lastActiveRigId; const activeRigId = hostState.lastActiveRigId;
// Before the rig list has arrived, and for a decode that reached the browser // 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; if (!activeRigId || !rigId) return true;
return rigId === activeRigId; 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 {}; export {};
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage { interface AisMessage {
@@ -191,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
return Array.from(byMmsi.values()); 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() { function updateAisSummary() {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) { if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`; aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
} }
const vessels = aisLatestByVessel(aisMessageHistory); const rigMessages = aisRigMessages();
const vessels = aisLatestByVessel(rigMessages);
if (aisVesselCountEl) { if (aisVesselCountEl) {
const count = vessels.length; const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
} }
if (aisLatestSeenEl) { if (aisLatestSeenEl) {
const latest = aisMessageHistory[0]; const latest = rigMessages[0];
if (!latest) { if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet"; aisLatestSeenEl.textContent = "No traffic yet";
} else { } else {
@@ -371,7 +378,7 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) { for (const message of aisRigMessages()) {
fragment.appendChild(renderAisRow(message)); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
@@ -410,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
function onServerAisBatch(messages: AisMessage[]): void { function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized: AisMessage[] = []; const normalized: AisMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -455,8 +463,9 @@ if (aisFilterInput) {
} }
function onServerAis(msg: AisMessage): void { function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving"; const message = normalizeServerAisMessage(msg);
addAisMessage(normalizeServerAisMessage(msg)); if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
addAisMessage(message);
} }
updateAisSummary(); updateAisSummary();
@@ -467,6 +476,7 @@ updateAisSummary();
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
rerender: () => { updateAisBar(); renderAisHistory(); },
// Oldest first, so vessel tracks are rebuilt in the order they happened. // Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); }, syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
}); });
@@ -4,7 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -107,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(aprsFilterText); 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[] { function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; const rigPackets = aprsRigPackets();
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(aprsFilterMatch); return packets.filter(aprsFilterMatch);
} }
function updateAprsSummary() { function updateAprsSummary() {
const rigPackets = aprsRigPackets();
const visible = aprsVisiblePackets(); const visible = aprsVisiblePackets();
if (aprsTotalCountEl) { if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; aprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (aprsVisibleCountEl) { if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`; aprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (aprsLatestSeenEl) { if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet"; aprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -259,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerAprsBatch(packets: AprsPacket[]): void { function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
let hasCrcOk = false; let hasCrcOk = false;
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
@@ -327,8 +336,9 @@ if (aprsFilterInput) {
// --- Server-side APRS decode handler --- // --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void { function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving"; const packet = normalizeServerAprsPacket(pkt);
addAprsPacket(normalizeServerAprsPacket(pkt)); if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
addAprsPacket(packet);
} }
renderAprsHistory(); renderAprsHistory();
@@ -339,6 +349,7 @@ renderAprsHistory();
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
rerender: () => { updateAprsBar(); renderAprsHistory(); },
// Oldest first, so station tracks are rebuilt in the order they happened. // Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); }, 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 --- // --- Server-side CW decode handler ---
function onServerCw(evt: CwEvent): void { function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; // The panel is a single stream of copied text: characters from a background
if (evt.text && cwOutputEl) { // 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 // Append decoded text to output
const now = Date.now(); const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
@@ -475,10 +480,10 @@ function onServerCw(evt: CwEvent): void {
} }
updateCwBar(); updateCwBar();
} }
if (cwSignalIndicator) { if (cwSignalIndicator && forSelectedRig) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; 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))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm)); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
@@ -506,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
onMessage: onServerCw, onMessage: onServerCw,
restore: restoreCwHistory, 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() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
@@ -4,7 +4,7 @@
import { hostCore } from "./host.js"; 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"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8"; export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -229,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
const render = (): void => { const render = (): void => {
prune(); prune();
if (!messagesElement) return; 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(); const fragment = document.createDocumentFragment();
let count = 0; let count = 0;
for (const message of history) { for (const message of rigMessages) {
if (count >= 200) break; if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue; if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message)); fragment.appendChild(renderRow(message));
@@ -239,19 +242,28 @@ export function initializeFtxDecoder(config: FtxConfig): void {
} }
messagesElement.replaceChildren(fragment); 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 raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid) ? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
if (grids.length === 0) return;
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); 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); const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) { bridge.mapAddLocator?.(raw, grids, id, station, {
bridge.mapAddLocator?.(raw, grids, id, station, { ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails, });
}); };
} const normalize = (message: FtxMessage): FtxMessage => {
const frequency = displayFrequency(message.freq_hz);
plotLocator(message);
return { return {
// The rig that heard it, kept so the mini view can tell a decode of the // 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. // 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 => { const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return; 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); history = messages.map(normalize).reverse().concat(history);
prune(); prune();
bridge.setFt8FamilyBarDecoder?.(id); bridge.setFt8FamilyBarDecoder?.(id);
@@ -305,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
restore: receiveBatch, restore: receiveBatch,
prune: () => { prune(); render(); }, prune: () => { 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); bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
@@ -4,6 +4,7 @@
import { hostCore, hostState } from "./host.js"; import { hostCore, hostState } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import { import {
aprsAgeText, aprsAgeText,
aprsPacketCategory, aprsPacketCategory,
@@ -19,6 +20,12 @@ interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => 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; getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> }; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
return haystack.includes(hfAprsFilterText); 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[] { function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; const rigPackets = hfAprsRigPackets();
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
return packets.filter(hfAprsFilterMatch); return packets.filter(hfAprsFilterMatch);
} }
const collapseHfAprsDuplicates = collapseAprsDuplicates; const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() { function updateHfAprsSummary() {
const rigPackets = hfAprsRigPackets();
const visible = hfAprsVisiblePackets(); const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) { if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
} }
if (hfAprsVisibleCountEl) { if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`; hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
} }
if (hfAprsLatestSeenEl) { if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0]; const latest = rigPackets[0];
if (!latest) { if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet"; hfAprsLatestSeenEl.textContent = "No packets yet";
} else { } else {
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = []; hfAprsPacketHistory = [];
renderHfAprsHistory(); renderHfAprsHistory();
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
} }
function pruneHfAprsHistoryView(): void { function pruneHfAprsHistoryView(): void {
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
renderHfAprsHistory(); 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 { function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
hfAprsPacketHistory.unshift(pkt); hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory(); pruneHfAprsPacketHistory();
plotHfAprsPacket(pkt);
scheduleHfAprsHistoryRender(); scheduleHfAprsHistoryRender();
} }
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
function onServerHfAprsBatch(packets: AprsPacket[]): void { function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return; if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = []; const normalized: AprsPacket[] = [];
for (const pkt of packets) { for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotHfAprsPacket(next);
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
// --- Server-side HF APRS decode handler --- // --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void { function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; const packet = normalizeServerHfAprsPacket(pkt);
addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(packet);
} }
renderHfAprsHistory(); renderHfAprsHistory();
@@ -278,4 +310,7 @@ renderHfAprsHistory();
restore: onServerHfAprsBatch, restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView, 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); },
}); });
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void; restore?(messages: TMessage[]): void;
reset?(): void; reset?(): void;
prune?(): 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 /** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */ * module attaches, which can happen long after the decodes arrived. */
syncMap?(): void; syncMap?(): void;
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean; reset(id: string): boolean;
resetAll(): void; resetAll(): void;
prune(id: string): boolean; prune(id: string): boolean;
rerenderAll(): void;
syncMapAll(): void; syncMapAll(): void;
clearQueued(): void; clearQueued(): void;
hasDecoder(id: string): boolean; hasDecoder(id: string): boolean;
@@ -14,11 +14,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface SstvImage { interface SstvImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
vis?: number; vis?: number;
mode?: string; mode?: string;
@@ -32,6 +34,7 @@ interface SstvImage {
} }
interface SstvProgress { interface SstvProgress {
rig_id?: string | null;
state?: string; state?: string;
mode?: string; mode?: string;
width?: number; width?: number;
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
// ── Server messages ───────────────────────────────────────────────── // ── Server messages ─────────────────────────────────────────────────
function onProgress(msg: SstvProgress): void { 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 (msg.state) {
if (sstvDom.status) sstvDom.status.textContent = msg.state; if (sstvDom.status) sstvDom.status.textContent = msg.state;
// A state update carries the geometry; a row update carries only the row. // 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(); if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
pruneHistory(); pruneHistory();
// Kept in the history either way, so switching to that rig finds it there.
if (!isActiveRigDecode(image.rig_id)) return;
if (sstvDom.status) { if (sstvDom.status) {
sstvDom.status.textContent = image.complete sstvDom.status.textContent = image.complete
? `Received ${image.mode ?? "picture"}` ? `Received ${image.mode ?? "picture"}`
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
// ── Rendering ─────────────────────────────────────────────────────── // ── Rendering ───────────────────────────────────────────────────────
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
function sstvRigImages(): SstvImage[] {
return forActiveRig(sstvHistory);
}
function renderLatestCard(): void { function renderLatestCard(): void {
if (!sstvDom.liveLatest) return; if (!sstvDom.liveLatest) return;
const latest = sstvHistory[sstvHistory.length - 1]; const rigImages = sstvRigImages();
const latest = rigImages[rigImages.length - 1];
if (!latest) { if (!latest) {
sstvDom.liveLatest.innerHTML = ""; sstvDom.liveLatest.innerHTML = "";
return; return;
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
} }
function filteredHistory(): SstvImage[] { 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 text = filterText.trim().toLowerCase();
const matching = text const matching = text
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
: sstvHistory.slice(); : rigImages;
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest"; const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0))); matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
return matching; return matching;
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
restore: restoreHistory, restore: restoreHistory,
prune: renderHistoryTable, prune: renderHistoryTable,
reset: resetHistoryView, reset: resetHistoryView,
rerender: () => { renderLatestCard(); renderHistoryTable(); },
}); });
sstvWindow.trxPluginRuntime.registerDecoder({ sstvWindow.trxPluginRuntime.registerDecoder({
id: "sstv_progress", id: "sstv_progress",
@@ -6,7 +6,7 @@ import { hostCore } from "./host.js";
export {}; export {};
import { isActiveRigDecode } from "./active-rig.js"; import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract"; import type { PluginRuntimeWindow } from "./runtime-contract";
interface VdesMessage { interface VdesMessage {
@@ -117,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
.toUpperCase(); .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() { function updateVdesSummary() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
if (vdesChannelSummaryEl) { if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText(); vdesChannelSummaryEl.textContent = currentVdesCenterText();
} }
const rigMessages = vdesRigMessages();
if (vdesFrameCountEl) { if (vdesFrameCountEl) {
const count = vdesMessageHistory.length; const count = rigMessages.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
} }
if (vdesLatestSeenEl) { if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0]; const latest = rigMessages[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
} }
} }
@@ -281,7 +288,7 @@ function renderVdesHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) { for (const message of vdesRigMessages()) {
fragment.appendChild(renderVdesRow(message)); fragment.appendChild(renderVdesRow(message));
} }
vdesMessagesEl.replaceChildren(fragment); vdesMessagesEl.replaceChildren(fragment);
@@ -312,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
function onServerVdesBatch(messages: VdesMessage[]): void { function onServerVdesBatch(messages: VdesMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized: VdesMessage[] = []; const normalized: VdesMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerVdesMessage(msg); 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(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { next._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -357,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
} }
function onServerVdes(msg: VdesMessage): void { function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
addVdesMessage(next); addVdesMessage(next);
plotVdesMessage(next); plotVdesMessage(next);
} }
@@ -377,6 +385,7 @@ updateVdesSummary();
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
rerender: () => { updateVdesBar(); renderVdesHistory(); },
// Oldest first, so tracks are rebuilt in the order they happened. // Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); }, syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
}); });
@@ -4,11 +4,13 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
interface WefaxImage { interface WefaxImage {
rig_id?: string | null;
ts_ms?: number; ts_ms?: number;
ioc?: number; ioc?: number;
lpm?: number; lpm?: number;
@@ -20,6 +22,7 @@ interface WefaxImage {
} }
interface WefaxProgress { interface WefaxProgress {
rig_id?: string | null;
state?: string; state?: string;
line_data?: string; line_data?: string;
line_count?: number; line_count?: number;
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
} }
// ── Live view: latest image card ──────────────────────────────────── // ── Live view: latest image card ────────────────────────────────────
/** The pictures the selected rig decoded, newest first. */
function wefaxRigImages(): WefaxImage[] {
return forActiveRig(wefaxImageHistory);
}
function renderWefaxLatestCard() { function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return; if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) { const rigImages = wefaxRigImages();
if (rigImages.length === 0) {
wefaxDom.liveLatest.innerHTML = 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>'; '<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; return;
} }
const img = wefaxImageHistory[0]; const img = rigImages[0];
if (!img) return; if (!img) return;
const ts = img._ts || '--'; const ts = img._ts || '--';
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ''; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
// ── History view: table ───────────────────────────────────────────── // ── History view: table ─────────────────────────────────────────────
function getWefaxFilteredHistory() { function getWefaxFilteredHistory() {
let items = wefaxImageHistory; // Every rig's pictures are kept; the panel shows the selected rig's.
let items = wefaxRigImages();
if (wefaxFilterText) { if (wefaxFilterText) {
items = items.filter(function (i) { items = items.filter(function (i) {
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
wefaxDom.historyList.replaceChildren(fragment); wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) { if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length; const total = wefaxRigImages().length;
const shown = items.length; const shown = items.length;
wefaxDom.historyCount.textContent = wefaxDom.historyCount.textContent =
total === 0 total === 0
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
// ── SSE event handlers (public API) ───────────────────────────────── // ── SSE event handlers (public API) ─────────────────────────────────
function onServerWefaxProgress(msg: WefaxProgress): void { 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. // State-only update (no image data): show decoder state in status.
if (msg.state && !msg.line_data) { if (msg.state && !msg.line_data) {
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
function onServerWefax(msg: WefaxImage): void { function onServerWefax(msg: WefaxImage): void {
addWefaxImage(msg); 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.liveContainer) wefaxDom.liveContainer.style.display = 'none';
if (wefaxDom.status) { if (wefaxDom.status) {
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
restore: restoreWefaxHistory, restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView, prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView, reset: resetWefaxHistoryView,
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
}); });
wefaxWindow.trxPluginRuntime.registerDecoder({ wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress", id: "wefax_progress",
@@ -4,6 +4,7 @@
import { hostCore } from "./host.js"; import { hostCore } from "./host.js";
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js"; import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {}; export {};
@@ -15,6 +16,10 @@ interface WsprMessage {
snr_db?: number | undefined; snr_db?: number | undefined;
dt_s?: number | undefined; dt_s?: number | undefined;
freq_hz?: 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; receiver?: unknown;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
function renderWsprHistory(): void { function renderWsprHistory(): void {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; 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(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (const message of rigMessages) {
const message = wsprMessageHistory[i]; fragment.appendChild(renderWsprRow(message));
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
station, station,
rfHz, rfHz,
history: { history: {
rig_id: msg.rig_id ?? null,
_rfHz: rfHz,
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
function onServerWsprBatch(messages: WsprMessage[]): void { function onServerWsprBatch(messages: WsprMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized: WsprMessage[] = []; const normalized: WsprMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { // "Receiving" is the panel's own status, and the panel is the rig's.
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
...msg, plotWsprLocator(msg);
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
});
}
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
})(); })();
}); });
function onServerWspr(msg: WsprMessage): void { /** Hands a spot's grid squares to the map, if the map module is loaded yet.
if (wsprStatus) wsprStatus.textContent = "Receiving"; * The module is lazy, so a spot heard before it arrived has to be replayable. */
function plotWsprLocator(msg: WsprMessage): void {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && wsprWindow.mapAddLocator) { if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { // A replayed spot carries the frequency it was heard on; a fresh one has it
...msg, // worked out from the dial it just arrived against.
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), 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); addWsprMessage(next.history);
} }
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
restore: onServerWsprBatch, restore: onServerWsprBatch,
prune: pruneWsprHistoryView, 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); },
}); });
@@ -2,11 +2,12 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
// The mini views over the waterfall caption the spectrum below them, so they // The radio page and the digital modes page both describe one rig: the one the
// show only what the rig on screen heard. The panels on the decoder tabs stay // operator selected. Every rig a client is connected to feeds the same decode
// aggregate — every rig's traffic lands there — which is the distinction these // stream, so both the mini views over the waterfall and the panels behind them
// tests pin down: a decode from a background rig belongs in the list and not in // filter on the rig that heard each decode. The map is the exception and keeps
// the overlay. // 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 assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
@@ -65,12 +66,14 @@ async function runPlugin(entry, { window, document: doc, extras = {} }) {
return context; 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 overlay = new ElementFixture();
const packets = new ElementFixture(); const packets = new ElementFixture();
const totals = new ElementFixture();
const elements = new Map([ const elements = new Map([
["aprs-bar-overlay", overlay], ["aprs-bar-overlay", overlay],
["aprs-packets", packets], ["aprs-packets", packets],
["aprs-total-count", totals],
["mode", new ElementFixture("PKT")], ["mode", new ElementFixture("PKT")],
]); ]);
const window = { 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.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.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 () => { 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.match(overlay.innerHTML, /SP1AAA/);
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view"); 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, packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a", symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
}; };
// A second rig listening in the background, on its own band. Its traffic // A second rig listening in the background, on its own band. Both pages
// belongs in the panels, which aggregate every rig, but not in the mini view, // describe the rig on screen, so its traffic belongs in neither the panel nor
// which captions the spectrum of the rig on screen. // 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 = { const OTHER_RIG_VESSEL = {
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
lat: 51.92, lon: 4.48, rig_id: "rig-b", 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. // AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ 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); const { browser, page, runtimeErrors } = await startBrowser(chromium);
@@ -51,12 +58,13 @@ try {
aisStatus: document.getElementById("ais-status")?.textContent ?? "", aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "", aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false, otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
mapLoaded: !!window.trx.modules.map, mapLoaded: !!window.trx.modules.map,
})); }));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing"); 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.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`); 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. // The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click(); await page.locator('.tab[data-tab="main"]').click();
@@ -102,8 +110,41 @@ try {
: Object.keys(collection ?? {}).length); : Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
}); });
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map"); assert.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, []); assert.deepEqual(runtimeErrors, []);
} finally { } finally {
@@ -116,6 +157,8 @@ try {
// left the client on its retry path and the history path untested. // left the client on its retry path and the history path untested.
const HISTORY_AIS = 900; const HISTORY_AIS = 900;
const HISTORY_APRS = 300; const HISTORY_APRS = 300;
const HISTORY_FT8 = 12;
const HISTORY_WSPR = 8;
const historyFixture = await startWebFixture({ const historyFixture = await startWebFixture({
spectrum: true, spectrum: true,
mode: "AIS", mode: "AIS",
@@ -125,6 +168,14 @@ const historyFixture = await startWebFixture({
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, 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) => ({ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true, 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.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); assert.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, []); assert.deepEqual(replay.runtimeErrors, []);
} finally { } finally {
await replay.browser.close(); await replay.browser.close();