[fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s

The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and
VDES over the history each already retains, and have map-core call
trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed
by callsign, MMSI and point, so replaying updates in place and cannot
duplicate a marker; the replay runs oldest-first so tracks are rebuilt in
the order they happened.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-05 07:34:45 +02:00
parent d31b6f545f
commit 026f816ddb
12 changed files with 114 additions and 40 deletions
@@ -285,9 +285,11 @@ function addAisMessage(msg) {
pruneAisMessageHistory(); pruneAisMessageHistory();
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(msg);
aisWindow.aisMapAddVessel(msg);
} }
function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
} }
function normalizeServerAisMessage(msg) { function normalizeServerAisMessage(msg) {
return { return {
@@ -308,9 +310,7 @@ function onServerAisBatch(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"
}); });
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(next);
aisWindow.aisMapAddVessel(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -352,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAisBatch, onBatch: onServerAisBatch,
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, 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);
}
}); });
@@ -1060,6 +1060,9 @@ var runtime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
clearQueued() { clearQueued() {
queued.clear(); queued.clear();
}, },
@@ -196,15 +196,17 @@ function pruneAprsHistoryView() {
updateAprsBar(); updateAprsBar();
renderAprsHistory(); 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) { function addAprsPacket(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" });
aprsPacketHistory.unshift(pkt); aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(pkt);
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate(); if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
} }
@@ -221,9 +223,7 @@ function onServerAprsBatch(packets) {
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" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(next);
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true; if (next.crcOk) hasCrcOk = true;
normalized.push(next); normalized.push(next);
} }
@@ -287,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAprsBatch, onBatch: onServerAprsBatch,
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, 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);
}
}); });
@@ -3093,5 +3093,6 @@ var mapWindow = window;
bandForHz, bandForHz,
reverseGeocodeLocation reverseGeocodeLocation
}; };
window.trxPluginRuntime.syncMapAll();
autoInitIfVisible(); autoInitIfVisible();
})(); })();
@@ -220,9 +220,7 @@ function onServerVdesBatch(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"
}); });
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -248,13 +246,15 @@ if (vdesFilterInput) {
renderVdesHistory(); renderVdesHistory();
}); });
} }
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
} }
function pruneVdesHistoryView() { function pruneVdesHistoryView() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
@@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerVdesBatch, onBatch: onServerVdesBatch,
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, 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);
}
}); });
@@ -4,6 +4,7 @@
import type * as Leaflet from "leaflet"; import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared"; import { aprsSymbolSprite } from "./plugins/aprs-shared";
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
export {}; export {};
@@ -3662,6 +3663,18 @@ const mapWindow = window as unknown as MapWindow;
reverseGeocodeLocation, 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. // If the map tab is already visible (direct /map URL), init immediately.
autoInitIfVisible(); autoInitIfVisible();
})(); })();
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
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),
}; };
@@ -389,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(msg);
aisWindow.aisMapAddVessel(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 { function normalizeServerAisMessage(msg: AisMessage): AisMessage {
@@ -414,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(next);
aisWindow.aisMapAddVessel(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -462,4 +464,6 @@ updateAisSummary();
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, 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); },
}); });
@@ -229,6 +229,12 @@ function pruneAprsHistoryView(): void {
renderAprsHistory(); 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 { function addAprsPacket(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;
@@ -237,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
aprsPacketHistory.unshift(pkt); aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(pkt);
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate(); 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(); 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" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(next);
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true; if (next.crcOk) hasCrcOk = true;
normalized.push(next); normalized.push(next);
} }
@@ -334,4 +336,6 @@ renderAprsHistory();
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, 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); },
}); });
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void; restore?(messages: TMessage[]): void;
reset?(): void; reset?(): void;
prune?(): 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 { export interface TrxPluginRuntime {
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean; reset(id: string): boolean;
resetAll(): void; resetAll(): void;
prune(id: string): boolean; prune(id: string): boolean;
syncMapAll(): void;
clearQueued(): void; clearQueued(): void;
hasDecoder(id: string): boolean; hasDecoder(id: string): boolean;
} }
@@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); 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 { function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
} }
function pruneVdesHistoryView(): void { function pruneVdesHistoryView(): void {
@@ -372,4 +374,6 @@ updateVdesSummary();
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, 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); },
}); });
@@ -115,7 +115,8 @@ const historyFixture = await startWebFixture({
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,
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.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished"); 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, []); assert.deepEqual(replay.runtimeErrors, []);
} finally { } finally {
await replay.browser.close(); await replay.browser.close();