diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 88b35e37..052bd6e9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -5889,6 +5889,7 @@ function navigateToTab(name, options = {}) { if (leavingSatellites) window.clearSatPredictionDom?.(); void loadPluginsForTab(name).then(() => { if (name === "satellites") window.refreshSatPredictions?.(); + flushPendingDecodeStats(); }).catch((error) => { console.error(error); }); @@ -7619,11 +7620,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([ "sstv", "sstv_progress" ]); +var pendingDecodeStats = []; +var PENDING_DECODE_STATS_MAX = 5e4; +function recordDecodeStat(kind, rig, tsMs) { + const stats = window.trx.modules.map; + if (stats) { + stats.statsRecordDecode(kind, rig, tsMs); + return; + } + pendingDecodeStats.push({ kind, rig, tsMs }); + if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) { + pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX); + } +} +function flushPendingDecodeStats() { + const stats = window.trx.modules.map; + if (!stats || pendingDecodeStats.length === 0) return; + for (const entry of pendingDecodeStats.splice(0)) { + stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs); + } + stats.scheduleStatsRender(); +} +function scheduleStatsRenderIfLoaded() { + window.trx.modules.map?.scheduleStatsRender(); +} function dispatchDecodeMessage(msg, skipStats = false) { if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { - window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); - window.trx.modules.map?.scheduleStatsRender(); + recordDecodeStat(msg.type, msg.rig_id || msg.remote || null); + scheduleStatsRenderIfLoaded(); } } var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; @@ -7669,9 +7694,9 @@ function restoreDecodeHistoryGroup(kind, messages) { if (!Array.isArray(messages) || messages.length === 0) return; if (!IMAGE_DECODE_KINDS.has(kind)) { for (const msg of messages) { - window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); + recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); } - window.trx.modules.map?.scheduleStatsRender(); + scheduleStatsRenderIfLoaded(); } window.trxPluginRuntime.restore(kind, messages); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js similarity index 96% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js index bf8b4031..e2dd2fc8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PAKFJPA2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-PJ5Q7CWJ.js @@ -168,19 +168,22 @@ function initializeFtxDecoder(config) { } messagesElement.replaceChildren(fragment); }; - const normalize = (message) => { + const plotLocator = (message) => { const raw = message.message ?? ""; const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + if (grids.length === 0) return; const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); const frequency = displayFrequency(message.freq_hz); - if (grids.length > 0) { - bridge.mapAddLocator?.(raw, grids, id, station, { - ...message, - freq_hz: frequency ?? message.freq_hz, - locator_details: locatorDetails - }); - } + bridge.mapAddLocator?.(raw, grids, id, station, { + ...message, + freq_hz: frequency ?? message.freq_hz, + locator_details: locatorDetails + }); + }; + const normalize = (message) => { + const frequency = displayFrequency(message.freq_hz); + plotLocator(message); return { // The rig that heard it, kept so the mini view can tell a decode of the // rig on screen from one a background rig made on another band. @@ -240,6 +243,10 @@ function initializeFtxDecoder(config) { 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); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index 7862c521..ad69d39d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PAKFJPA2.js"; +} from "./chunk-PJ5Q7CWJ.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 386a5c2c..30180ef0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,6 +1,6 @@ import { initializeFtxDecoder -} from "./chunk-PAKFJPA2.js"; +} from "./chunk-PJ5Q7CWJ.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index d0b23a76..ff94b312 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -2,7 +2,7 @@ import { initializeFt8FamilyBar, initializeFtxDecoder, installFtxCompatibilityHelpers -} from "./chunk-PAKFJPA2.js"; +} from "./chunk-PJ5Q7CWJ.js"; import "./chunk-S57W63QN.js"; import "./chunk-KL66PICH.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index 0d0a1f5b..bedd7384 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -95,6 +95,7 @@ function normalizeServerWsprMessage(msg) { rfHz, history: { rig_id: msg.rig_id ?? null, + _rfHz: rfHz, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, ts_ms: msg.ts_ms, snr_db: msg.snr_db, @@ -110,12 +111,7 @@ function onServerWsprBatch(messages) { for (const msg of messages) { const next = normalizeServerWsprMessage(msg); if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; - if (next.grids.length > 0 && wsprWindow.mapAddLocator) { - wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - ...next.rfHz === null ? {} : { freq_hz: next.rfHz } - }); - } + plotWsprLocator(msg); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); normalized.push(next.history); } @@ -255,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" } })(); }); +function plotWsprLocator(msg) { + const next = normalizeServerWsprMessage(msg); + if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return; + const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz; + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...rfHz === null ? {} : { freq_hz: rfHz } + }); +} function onServerWspr(msg) { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && wsprWindow.mapAddLocator) { - wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - ...next.rfHz === null ? {} : { freq_hz: next.rfHz } - }); - } + plotWsprLocator(msg); addWsprMessage(next.history); } wsprWindow.trxPluginRuntime.registerDecoder({ @@ -273,5 +273,9 @@ wsprWindow.trxPluginRuntime.registerDecoder({ restore: onServerWsprBatch, prune: pruneWsprHistoryView, reset: resetWsprHistoryView, - rerender: renderWsprHistory + 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); + } }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 82dee01e..a921b48e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -4912,6 +4912,8 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac // Passes go stale while the page is closed, so each visit reloads them. // The first visit is what imports the module, which renders on its own. if (name === "satellites") window.refreshSatPredictions?.(); + // The map module owns the decode log; this is the moment it can exist. + flushPendingDecodeStats(); }).catch((error: unknown) => { console.error(error); }); if (name === "map") { _initMapWhenReady(); @@ -6597,11 +6599,49 @@ const IMAGE_DECODE_KINDS = new Set([ "lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress", ]); +// The decode log the Statistics page counts lives in the map module, which is +// lazy: it arrives when the Map or Statistics tab is first opened, long after +// the history replayed and the live decodes started coming. Recording into a +// module that is not there yet dropped every one of them, and nothing replayed +// them afterwards -- so the page opened empty and only filled in from decodes +// heard after it, which is why it took a reload (landing on the tab, so the +// module loads at startup) to show the whole picture. Hold them here until +// the module arrives, then hand them over. +interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined } +const pendingDecodeStats: PendingDecodeStat[] = []; +// The module's own log keeps 50k entries; there is no point holding more here. +const PENDING_DECODE_STATS_MAX = 50_000; + +function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) { + const stats = window.trx.modules.map; + if (stats) { + stats.statsRecordDecode(kind, rig, tsMs); + return; + } + pendingDecodeStats.push({ kind, rig, tsMs }); + if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) { + pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX); + } +} + +function flushPendingDecodeStats() { + const stats = window.trx.modules.map; + if (!stats || pendingDecodeStats.length === 0) return; + for (const entry of pendingDecodeStats.splice(0)) { + stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs); + } + stats.scheduleStatsRender(); +} + +function scheduleStatsRenderIfLoaded() { + window.trx.modules.map?.scheduleStatsRender(); +} + function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) { if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) { - window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); - window.trx.modules.map?.scheduleStatsRender(); + recordDecodeStat(msg.type, msg.rig_id || msg.remote || null); + scheduleStatsRenderIfLoaded(); } } @@ -6651,9 +6691,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) { // Record statistics for restored history messages. if (!IMAGE_DECODE_KINDS.has(kind)) { for (const msg of messages) { - window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined); + recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined); } - window.trx.modules.map?.scheduleStatsRender(); + scheduleStatsRenderIfLoaded(); } window.trxPluginRuntime.restore(kind, messages); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts index 64b7d475..e27e2053 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ftx-family.ts @@ -242,19 +242,28 @@ export function initializeFtxDecoder(config: FtxConfig): void { } messagesElement.replaceChildren(fragment); }; - const normalize = (message: FtxMessage): FtxMessage => { + /** Hands a decode's grid squares to the map, if the map module is loaded yet. + * Split out of normalize so a decode can be replayed onto a map that + * arrived later: the module is lazy, and everything decoded before it + * loaded had nowhere to go. */ + const plotLocator = (message: FtxMessage): void => { const raw = message.message ?? ""; const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw); const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw); + if (grids.length === 0) return; const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw); + // Already an RF frequency on a replay, an audio offset on arrival; the + // conversion only fires below 100 kHz, so it is right either way. const frequency = displayFrequency(message.freq_hz); - if (grids.length > 0) { - bridge.mapAddLocator?.(raw, grids, id, station, { - ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails, - }); - } + bridge.mapAddLocator?.(raw, grids, id, station, { + ...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails, + }); + }; + const normalize = (message: FtxMessage): FtxMessage => { + const frequency = displayFrequency(message.freq_hz); + plotLocator(message); return { // The rig that heard it, kept so the mini view can tell a decode of the // rig on screen from one a background rig made on another band. @@ -312,6 +321,8 @@ export function initializeFtxDecoder(config: FtxConfig): void { prune: () => { prune(); render(); }, reset, rerender: () => { bridge.updateFt8Bar?.(); render(); }, + // Oldest first, so the map builds the grids up in the order they were heard. + syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); }, }); bridge.registerFt8FamilyBarRenderer?.(id, barFrames); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts index 192eef52..3ea9c6ea 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.ts @@ -17,6 +17,9 @@ interface WsprMessage { dt_s?: number | undefined; freq_hz?: number | undefined; rig_id?: string | null | undefined; + /** RF frequency the spot was heard on, kept so a map replay does not + * recompute it against wherever the dial has moved to since. */ + _rfHz?: number | null | undefined; receiver?: unknown; [key: string]: unknown; } @@ -135,6 +138,7 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str rfHz, history: { rig_id: msg.rig_id ?? null, + _rfHz: rfHz, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null, ts_ms: msg.ts_ms, snr_db: msg.snr_db, @@ -152,12 +156,7 @@ function onServerWsprBatch(messages: WsprMessage[]): void { const next = normalizeServerWsprMessage(msg); // "Receiving" is the panel's own status, and the panel is the rig's. if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; - if (next.grids.length > 0 && wsprWindow.mapAddLocator) { - wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - ...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), - }); - } + plotWsprLocator(msg); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now(); normalized.push(next.history); } @@ -317,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click" })(); }); +/** Hands a spot's grid squares to the map, if the map module is loaded yet. + * The module is lazy, so a spot heard before it arrived has to be replayable. */ +function plotWsprLocator(msg: WsprMessage): void { + const next = normalizeServerWsprMessage(msg); + if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return; + // A replayed spot carries the frequency it was heard on; a fresh one has it + // worked out from the dial it just arrived against. + const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz; + wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + ...(rfHz === null ? {} : { freq_hz: rfHz }), + }); +} + function onServerWspr(msg: WsprMessage): void { if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving"; const next = normalizeServerWsprMessage(msg); - if (next.grids.length > 0 && wsprWindow.mapAddLocator) { - wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, { - ...msg, - ...(next.rfHz === null ? {} : { freq_hz: next.rfHz }), - }); - } + plotWsprLocator(msg); addWsprMessage(next.history); } @@ -337,4 +345,6 @@ wsprWindow.trxPluginRuntime.registerDecoder({ prune: pruneWsprHistoryView, reset: resetWsprHistoryView, rerender: renderWsprHistory, + // Oldest first, so the map builds the grids up in the order they were heard. + syncMap: () => { for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs index 7689c121..05f1131f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -120,6 +120,8 @@ try { // left the client on its retry path and the history path untested. const HISTORY_AIS = 900; const HISTORY_APRS = 300; +const HISTORY_FT8 = 12; +const HISTORY_WSPR = 8; const historyFixture = await startWebFixture({ spectrum: true, mode: "AIS", @@ -129,6 +131,14 @@ const historyFixture = await startWebFixture({ vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), + ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({ + message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2, + freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, + })), + wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({ + message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5, + freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, + })), aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", info: `history ${index}`, packet_type: "position", crc_ok: true, @@ -216,6 +226,24 @@ try { assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); + // The Statistics page counts the same history. Its decode log lives in the + // map module, which is lazy, so every decode that arrived before the module + // did used to be recorded into nothing at all — the page opened empty and + // only filled in from decodes heard afterwards, and it took a reload landing + // on the tab (module loaded at startup, before the history) to show the lot. + await replay.page.evaluate(() => window.navigateToTab("statistics")); + await replay.page.waitForTimeout(1500); + const counted = await replay.page.evaluate(() => ({ + decodes: Number(document.getElementById("stats-total-decodes")?.textContent ?? "0"), + grids: Number(document.getElementById("stats-unique-grids")?.textContent ?? "0"), + })); + assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR, + `the statistics counted ${counted.decodes} decodes`); + // Grid squares come from the FT8 and WSPR spots, which had no map replay of + // their own: the locators of everything heard before the map loaded were lost. + assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR, + `the statistics counted ${counted.grids} grid squares`); + assert.deepEqual(replay.runtimeErrors, []); } finally { await replay.browser.close();