diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index 91f47f32..9fda5775 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -285,9 +285,11 @@ function addAisMessage(msg) { pruneAisMessageHistory(); scheduleAisBarUpdate(); scheduleAisHistoryRender(); - if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(msg); - } + plotAisMessage(msg); +} +function plotAisMessage(msg) { + if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return; + aisWindow.aisMapAddVessel(msg); } function normalizeServerAisMessage(msg) { return { @@ -308,9 +310,7 @@ function onServerAisBatch(messages) { minute: "2-digit", second: "2-digit" }); - if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(next); - } + plotAisMessage(next); normalized.push(next); } normalized.reverse(); @@ -352,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({ onBatch: onServerAisBatch, restore: onServerAisBatch, reset: resetAisHistoryView, - prune: pruneAisHistoryView + prune: pruneAisHistoryView, + // Oldest first, so vessel tracks are rebuilt in the order they happened. + syncMap: () => { + for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); + } }); 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 1d22e6ca..ab7aabe2 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 @@ -1060,6 +1060,9 @@ var runtime = { plugin.prune(); return true; }, + syncMapAll() { + for (const plugin of decoders.values()) plugin.syncMap?.(); + }, clearQueued() { queued.clear(); }, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index 40e2d987..0436b5df 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -196,15 +196,17 @@ function pruneAprsHistoryView() { updateAprsBar(); renderAprsHistory(); } +function plotAprsPacket(pkt) { + if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return; + aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt); +} function addAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); aprsPacketHistory.unshift(pkt); pruneAprsPacketHistory(); - if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { - aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt); - } + plotAprsPacket(pkt); if (pkt.crcOk) scheduleAprsBarUpdate(); scheduleAprsHistoryRender(); } @@ -221,9 +223,7 @@ function onServerAprsBatch(packets) { const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { - aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next); - } + plotAprsPacket(next); if (next.crcOk) hasCrcOk = true; normalized.push(next); } @@ -287,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({ onBatch: onServerAprsBatch, restore: onServerAprsBatch, reset: resetAprsHistoryView, - prune: pruneAprsHistoryView + prune: pruneAprsHistoryView, + // Oldest first, so station tracks are rebuilt in the order they happened. + syncMap: () => { + for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); + } }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index 05298ff6..ac60f41c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -3093,5 +3093,6 @@ var mapWindow = window; bandForHz, reverseGeocodeLocation }; + window.trxPluginRuntime.syncMapAll(); autoInitIfVisible(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index 7f0e0574..ff9cf002 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -220,9 +220,7 @@ function onServerVdesBatch(messages) { minute: "2-digit", second: "2-digit" }); - if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { - vdesWindow.vdesMapAddPoint(next); - } + plotVdesMessage(next); normalized.push(next); } normalized.reverse(); @@ -248,13 +246,15 @@ if (vdesFilterInput) { renderVdesHistory(); }); } +function plotVdesMessage(msg) { + if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return; + vdesWindow.vdesMapAddPoint(msg); +} function onServerVdes(msg) { if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); addVdesMessage(next); - if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { - vdesWindow.vdesMapAddPoint(next); - } + plotVdesMessage(next); } function pruneVdesHistoryView() { pruneVdesMessageHistory(); @@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({ onBatch: onServerVdesBatch, restore: onServerVdesBatch, reset: resetVdesHistoryView, - prune: pruneVdesHistoryView + prune: pruneVdesHistoryView, + // Oldest first, so tracks are rebuilt in the order they happened. + syncMap: () => { + for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); + } }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts index 0168bbec..57b6d968 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts @@ -4,6 +4,7 @@ import type * as Leaflet from "leaflet"; import { aprsSymbolSprite } from "./plugins/aprs-shared"; +import type { PluginRuntimeWindow } from "./plugins/runtime-contract"; export {}; @@ -3662,6 +3663,18 @@ const mapWindow = window as unknown as MapWindow; reverseGeocodeLocation, }; + // Everything the decoders already hold goes onto the map now. This module is + // lazy -- it arrives when the Map tab is first opened, long after startup + // restored the decode history -- and until it does, aprsMapAddStation and + // friends are undefined, so every restored position was dropped on the floor. + // The map then showed only what arrived live after it loaded, which is why it + // took a second reload (with the module cached, and so loaded early enough to + // win the race against the history fetch) for the stations to appear. + // + // The add functions are keyed by callsign/MMSI/point, so replaying costs + // nothing on a second call and cannot duplicate a marker. + (window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll(); + // If the map tab is already visible (direct /map URL), init immediately. autoInitIfVisible(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts index 836c3e92..91a4804b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-runtime.ts @@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = { plugin.prune(); return true; }, + syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); }, clearQueued() { queued.clear(); }, hasDecoder: (id) => decoders.has(id), }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 7b6e15e3..b08c0939 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -389,9 +389,13 @@ function addAisMessage(msg: AisMessage): void { scheduleAisBarUpdate(); scheduleAisHistoryRender(); - if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(msg); - } + plotAisMessage(msg); +} + +/** Hands a positioned message to the map, if the map module is loaded yet. */ +function plotAisMessage(msg: AisMessage): void { + if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return; + aisWindow.aisMapAddVessel(msg); } function normalizeServerAisMessage(msg: AisMessage): AisMessage { @@ -414,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void { minute: "2-digit", second: "2-digit", }); - if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(next); - } + plotAisMessage(next); normalized.push(next); } normalized.reverse(); @@ -462,4 +464,6 @@ updateAisSummary(); restore: onServerAisBatch, reset: resetAisHistoryView, prune: pruneAisHistoryView, + // Oldest first, so vessel tracks are rebuilt in the order they happened. + syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index 45e55674..307e394e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -229,6 +229,12 @@ function pruneAprsHistoryView(): void { renderAprsHistory(); } +/** Hands a positioned packet to the map, if the map module is loaded yet. */ +function plotAprsPacket(pkt: AprsPacket): void { + if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return; + aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt); +} + function addAprsPacket(pkt: AprsPacket): void { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; @@ -237,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void { aprsPacketHistory.unshift(pkt); pruneAprsPacketHistory(); - if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { - aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt); - } + plotAprsPacket(pkt); if (pkt.crcOk) scheduleAprsBarUpdate(); @@ -260,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void { const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); next._tsMs = tsMs; next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { - aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next); - } + plotAprsPacket(next); if (next.crcOk) hasCrcOk = true; normalized.push(next); } @@ -334,4 +336,6 @@ renderAprsHistory(); restore: onServerAprsBatch, reset: resetAprsHistoryView, prune: pruneAprsHistoryView, + // Oldest first, so station tracks are rebuilt in the order they happened. + syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); }, }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts index 80712f51..077c0c7d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/runtime-contract.ts @@ -9,6 +9,9 @@ export interface DecoderPlugin { restore?(messages: TMessage[]): void; reset?(): void; prune?(): void; + /** Replay everything the plugin is holding onto the map. Called when the map + * module attaches, which can happen long after the decodes arrived. */ + syncMap?(): void; } export interface TrxPluginRuntime { @@ -19,6 +22,7 @@ export interface TrxPluginRuntime { reset(id: string): boolean; resetAll(): void; prune(id: string): boolean; + syncMapAll(): void; clearQueued(): void; hasDecoder(id: string): boolean; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts index ec56f718..0e6b46ce 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.ts @@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void { minute: "2-digit", second: "2-digit", }); - if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { - vdesWindow.vdesMapAddPoint(next); - } + plotVdesMessage(next); normalized.push(next); } normalized.reverse(); @@ -349,13 +347,17 @@ if (vdesFilterInput) { }); } +/** Hands a positioned message to the map, if the map module is loaded yet. */ +function plotVdesMessage(msg: VdesMessage): void { + if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return; + vdesWindow.vdesMapAddPoint(msg); +} + function onServerVdes(msg: VdesMessage): void { if (vdesStatus) vdesStatus.textContent = "Receiving"; const next = normalizeServerVdesMessage(msg); addVdesMessage(next); - if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { - vdesWindow.vdesMapAddPoint(next); - } + plotVdesMessage(next); } function pruneVdesHistoryView(): void { @@ -372,4 +374,6 @@ updateVdesSummary(); restore: onServerVdesBatch, reset: resetVdesHistoryView, prune: pruneVdesHistoryView, + // Oldest first, so tracks are rebuilt in the order they happened. + syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); }, }); 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 ae9ab891..e0d0cf00 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 @@ -115,7 +115,8 @@ const historyFixture = await startWebFixture({ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", info: `history ${index}`, packet_type: "position", crc_ok: true, - lat: 54.3, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, + lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a", + ts_ms: Date.now() - (index + 1) * 1000, })), }, }); @@ -171,6 +172,33 @@ try { assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`); assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished"); + // Opening the map for the first time has to show the stored history too. + // The map module is lazy, so at the moment the history was restored its + // aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every + // position was dropped. Nothing replayed them when the module finally + // arrived, so the map came up empty and only filled in from decodes heard + // afterwards -- a station heard once was never plotted at all, and it took a + // second reload (module cached, so loaded early enough to beat the history + // fetch) before the map showed anything. + // + // This fixture serves no live decode stream on purpose: with one, fresh + // frames arriving after the module loads would paper over the whole thing. + const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map); + assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing"); + + await replay.page.locator('.tab[data-tab="map"]').click(); + await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" }); + await replay.page.waitForTimeout(1500); + const plotted = await replay.page.evaluate(() => { + const map = window.trx.modules.map; + const size = (collection) => (collection instanceof Map + ? collection.size + : Object.keys(collection ?? {}).length); + return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; + }); + assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); + assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); + assert.deepEqual(replay.runtimeErrors, []); } finally { await replay.browser.close();