diff --git a/docs/User-Manual.md b/docs/User-Manual.md index 8230e3bb..19dc9736 100644 --- a/docs/User-Manual.md +++ b/docs/User-Manual.md @@ -464,7 +464,7 @@ and each page answers that differently: |------|-------| | 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. | +| 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 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/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index aaff423e..2bdbcb9b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -149,17 +149,32 @@ function resetHfAprsHistoryView() { if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; hfAprsPacketHistory = []; renderHfAprsHistory(); + hfAprsWindow.clearMapMarkersByType?.("hf_aprs"); } function pruneHfAprsHistoryView() { pruneHfAprsPacketHistory(); renderHfAprsHistory(); } +function plotHfAprsPacket(pkt) { + if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return; + hfAprsWindow.aprsMapAddStation( + pkt.srcCall ?? "", + pkt.lat, + pkt.lon, + pkt.info ?? "", + pkt.symbolTable, + pkt.symbolCode, + pkt, + "hf_aprs" + ); +} function addHfAprsPacket(pkt) { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); hfAprsPacketHistory.unshift(pkt); pruneHfAprsPacketHistory(); + plotHfAprsPacket(pkt); scheduleHfAprsHistoryRender(); } function normalizeServerHfAprsPacket(pkt) { @@ -174,6 +189,7 @@ function onServerHfAprsBatch(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" }); + plotHfAprsPacket(next); normalized.push(next); } normalized.reverse(); @@ -248,5 +264,9 @@ window.trxPluginRuntime.registerDecoder({ restore: onServerHfAprsBatch, reset: resetHfAprsHistoryView, prune: pruneHfAprsHistoryView, - rerender: renderHfAprsHistory + rerender: renderHfAprsHistory, + // Oldest first, so station tracks are rebuilt in the order they happened. + syncMap: () => { + for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(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 836cf6a3..f493d173 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 @@ -60,7 +60,7 @@ var mapWindow = window; const decodeContactPaths = /* @__PURE__ */ new Map(); let selectedMapQsoKey = null; const mapMarkers = /* @__PURE__ */ new Set(); - const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; const MAP_FILTER_ALL_KEY = "__all"; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; @@ -128,6 +128,12 @@ var mapWindow = window; } return trimmed; } + function aprsEntrySource(entry) { + return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs"; + } + function aprsStationKey(call, source) { + return source === "aprs" ? call : `${source}:${call}`; + } function refreshAprsTrack(call, entry) { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { @@ -149,7 +155,7 @@ var mapWindow = window; lineJoin: "round", interactive: false }); - track.__trxType = "aprs"; + track.__trxType = aprsEntrySource(entry); track._aprsCall = call; entry.track = track; } @@ -381,6 +387,7 @@ var mapWindow = window; } function mapSourceLabel(type) { if (type === "bookmark") return "Bookmarks"; + if (type === "hf_aprs") return "HF APRS"; return String(type || "").toUpperCase(); } function locatorFilterColor(type) { @@ -396,6 +403,7 @@ var mapWindow = window; if (type === "vdes") return "#a78bfa"; if (type === "sat") return "#f59e0b"; if (type === "aprs") return "#00d17f"; + if (type === "hf_aprs") return "#fb7185"; return locatorFilterColor(type); } function bandForHz(hz) { @@ -931,10 +939,8 @@ var mapWindow = window; } } for (const entry of stationMarkers.values()) { - if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { - availableSources.add("aprs"); - break; - } + if (!entry?.visibleInHistoryWindow) continue; + availableSources.add(aprsEntrySource(entry)); } const bandMap = /* @__PURE__ */ new Map(); for (const entry of locatorMarkers.values()) { @@ -966,7 +972,7 @@ var mapWindow = window; for (const key of Array.from(mapLocatorFilter.bands)) { if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); } - const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ key, label: mapSourceLabel(key), color: mapSourceColor(key), @@ -1028,9 +1034,10 @@ var mapWindow = window; } return parts.join(" ").toLowerCase(); } - if (type === "aprs") { - const call = marker?._aprsCall ? String(marker._aprsCall) : ""; - const entry = stationMarkers.get(call); + if (type === "aprs" || type === "hf_aprs") { + const key = marker?._aprsCall ? String(marker._aprsCall) : ""; + const entry = stationMarkers.get(key); + const call = entry?.call ?? key; const info = entry?.info ? String(entry.info) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; return `${call} ${info} ${pktRaw}`.toLowerCase(); @@ -1200,9 +1207,10 @@ var mapWindow = window; } }; mapWindow.clearMapMarkersByType = function(type) { - if (type === "aprs") { + if (type === "aprs" || type === "hf_aprs") { selectedAprsTrackCall = null; - stationMarkers.forEach((entry) => { + stationMarkers.forEach((entry, key) => { + if (aprsEntrySource(entry) !== type) return; if (entry && entry.marker) { if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); mapMarkers.delete(entry.marker); @@ -1211,8 +1219,8 @@ var mapWindow = window; if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); mapMarkers.delete(entry.track); } + stationMarkers.delete(key); }); - stationMarkers.clear(); return; } if (type === "ais") { @@ -1448,13 +1456,15 @@ var mapWindow = window; if (!ll) return; const entry = stationMarkers.get(marker._aprsCall); if (!entry) return; - e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); + const source = aprsEntrySource(entry); + const call = entry.call ?? String(marker._aprsCall); + e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt)); refreshAprsTrack(String(marker._aprsCall), entry); - if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { + if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) { entry.track.addTo(aprsMap); } selectedAprsTrackCall = String(marker._aprsCall); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds); return; } if (marker._aisMmsi) { @@ -1848,28 +1858,33 @@ var mapWindow = window; } return null; } - function _aprsAddMarkerToMap(call, entry) { + function _aprsAddMarkerToMap(key, entry) { if (!aprsMap || entry.lat == null || entry.lon == null) return; - refreshAprsTrack(call, entry); + refreshAprsTrack(key, entry); + const source = aprsEntrySource(entry); + const call = entry.call ?? key; const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); + const color = mapSourceColor(source); const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { radius: 6, - color: "#00d17f", - fillColor: "#00d17f", + color, + fillColor: color, fillOpacity: 0.8 }).addTo(aprsMap).bindPopup(popupContent); - marker.__trxType = "aprs"; + marker.__trxType = source; marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._aprsCall = call; + marker._aprsCall = key; entry.marker = marker; mapMarkers.add(marker); } - mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) { const nextPoint = [lat, lon]; const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const msgRigId = pkt?.rig_id || T.lastActiveRigId; - const existing = stationMarkers.get(call); + const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs"; + const key = aprsStationKey(call, stationSource); + const existing = stationMarkers.get(key); if (existing) { existing.pkt = pkt; existing.lat = lat; @@ -1888,7 +1903,8 @@ var mapWindow = window; } else if (prevPoint) { prevPoint.tsMs = tsMs; } - pruneAprsEntry(call, existing, mapHistoryCutoffMs()); + existing.call = call; + pruneAprsEntry(key, existing, mapHistoryCutoffMs()); if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { existing.marker.setLatLng([lat, lon]); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); @@ -1899,7 +1915,8 @@ var mapWindow = window; track: null, trackHistory: [{ lat, lon, tsMs }], trackPoints: [nextPoint], - type: "aprs", + type: stationSource, + call, pkt, lat, lon, @@ -1908,9 +1925,9 @@ var mapWindow = window; symbolCode, rigIds: new Set(msgRigId ? [msgRigId] : []) }; - stationMarkers.set(call, entry); - pruneAprsEntry(call, entry, mapHistoryCutoffMs()); - if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); + stationMarkers.set(key, entry); + pruneAprsEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry); if (aprsMap) scheduleDecodeMapMaintenance(); } }; 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/map-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts index df12c147..be273574 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 @@ -31,7 +31,7 @@ function mapEl(id: string): MapElement { return element as MapElement; } -type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat"; +type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat"; interface TrackPoint { lat: number; lon: number; tsMs: number } interface DecodeDetail { station?: string | null; source?: string | null; target?: string | null; @@ -83,6 +83,9 @@ interface MapEntry { bookmarks?: Array>; bounds?: Leaflet.LatLngBoundsExpression; symbolTable?: string; symbolCode?: string; bandLabel?: string | null; + /** Callsign as heard. The map key qualifies it with the source, so that a + * station worked on both 144 MHz and 30 m keeps one marker per band. */ + call?: string; overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null; pathKey?: string; sourceGrid?: string; targetGrid?: string; from?: LatLon; to?: LatLon; @@ -171,7 +174,7 @@ interface MapWindow { clearMapMarkersByType?(type: DecoderSource): void; navigateToAprsMap?(lat: number, lon: number): void; navigateToMapLocator?(grid: string, preferredType?: string | null): void; - aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void; + aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void; aisMapAddVessel?(message: MapMessage): void; vdesMapAddPoint?(message: MapMessage): void; syncBookmarkMapLocators?(bookmarks: Array>): void; @@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow; const decodeContactPaths = new Map(); let selectedMapQsoKey: string | null = null; const mapMarkers = new Set(); - const DEFAULT_MAP_SOURCE_FILTER: Record = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const DEFAULT_MAP_SOURCE_FILTER: Record = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const mapFilter: Record = { ...DEFAULT_MAP_SOURCE_FILTER }; /** Chip key that clears a selection rather than naming a band or a source. */ const MAP_FILTER_ALL_KEY = "__all"; @@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow; return trimmed; } + /** The source a station marker belongs to, defaulting to VHF APRS for + * entries stored before HF APRS had a source of its own. */ + function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource { + return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs"; + } + + /** Map key for a station: the callsign alone on VHF, source-qualified on HF, + * so one station heard on both bands keeps a marker for each. */ + function aprsStationKey(call: string, source: DecoderSource): string { + return source === "aprs" ? call : `${source}:${call}`; + } + function refreshAprsTrack(call: string, entry: MapEntry): void { if (!entry) return; if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { @@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow; lineJoin: "round", interactive: false, }) as unknown as TrxLayer; - track.__trxType = "aprs"; + track.__trxType = aprsEntrySource(entry); track._aprsCall = call; entry.track = track; } @@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow; function mapSourceLabel(type: DecoderSource): string { if (type === "bookmark") return "Bookmarks"; + if (type === "hf_aprs") return "HF APRS"; return String(type || "").toUpperCase(); } @@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow; if (type === "vdes") return "#a78bfa"; if (type === "sat") return "#f59e0b"; if (type === "aprs") return "#00d17f"; + // Far enough from the VHF green to tell the two apart at a glance. + if (type === "hf_aprs") return "#fb7185"; return locatorFilterColor(type); } @@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow; } } for (const entry of stationMarkers.values()) { - if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { - availableSources.add("aprs"); - break; - } + if (!entry?.visibleInHistoryWindow) continue; + availableSources.add(aprsEntrySource(entry)); } const bandMap = new Map(); for (const entry of locatorMarkers.values()) { @@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow; if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); } - const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[]) + const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[]) .filter((key) => availableSources.has(key)) .map((key) => ({ key, @@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow; } return parts.join(" ").toLowerCase(); } - if (type === "aprs") { - const call = marker?._aprsCall ? String(marker._aprsCall) : ""; - const entry = stationMarkers.get(call); + if (type === "aprs" || type === "hf_aprs") { + const key = marker?._aprsCall ? String(marker._aprsCall) : ""; + const entry = stationMarkers.get(key); + const call = entry?.call ?? key; const info = entry?.info ? String(entry.info) : ""; const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; return `${call} ${info} ${pktRaw}`.toLowerCase(); @@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow; }; mapWindow.clearMapMarkersByType = function(type) { - if (type === "aprs") { + if (type === "aprs" || type === "hf_aprs") { selectedAprsTrackCall = null; - stationMarkers.forEach((entry) => { + stationMarkers.forEach((entry, key) => { + if (aprsEntrySource(entry) !== type) return; if (entry && entry.marker) { if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); mapMarkers.delete(entry.marker); @@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow; if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); mapMarkers.delete(entry.track); } + stationMarkers.delete(key); }); - stationMarkers.clear(); return; } @@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow; if (!ll) return; const entry = stationMarkers.get(marker._aprsCall); if (!entry) return; - e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); + const source = aprsEntrySource(entry); + const call = entry.call ?? String(marker._aprsCall); + e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt)); refreshAprsTrack(String(marker._aprsCall), entry); - if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { + if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) { entry.track.addTo(aprsMap); } selectedAprsTrackCall = String(marker._aprsCall); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds); return; } @@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow; return null; } - function _aprsAddMarkerToMap(call: string, entry: MapEntry): void { + function _aprsAddMarkerToMap(key: string, entry: MapEntry): void { if (!aprsMap || entry.lat == null || entry.lon == null) return; - refreshAprsTrack(call, entry); + refreshAprsTrack(key, entry); + const source = aprsEntrySource(entry); + const call = entry.call ?? key; const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); + const color = mapSourceColor(source); const marker = (icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { - radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8 + radius: 6, color, fillColor: color, fillOpacity: 0.8 }).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer; - marker.__trxType = "aprs"; + marker.__trxType = source; marker.__trxRigIds = entry.rigIds || new Set(); - marker._aprsCall = call; + marker._aprsCall = key; entry.marker = marker; mapMarkers.add(marker); } - mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) { const nextPoint: Leaflet.LatLngTuple = [lat, lon]; const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); const msgRigId = pkt?.rig_id || T.lastActiveRigId; - const existing = stationMarkers.get(call); + const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs"; + const key = aprsStationKey(call, stationSource); + const existing = stationMarkers.get(key); if (existing) { existing.pkt = pkt; existing.lat = lat; @@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow; } else if (prevPoint) { prevPoint.tsMs = tsMs; } - pruneAprsEntry(call, existing, mapHistoryCutoffMs()); + existing.call = call; + pruneAprsEntry(key, existing, mapHistoryCutoffMs()); if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { existing.marker.setLatLng([lat, lon]); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); @@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow; track: null, trackHistory: [{ lat, lon, tsMs }], trackPoints: [nextPoint], - type: "aprs", + type: stationSource, + call, pkt, lat, lon, @@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow; symbolCode, rigIds: new Set(msgRigId ? [msgRigId] : []), }; - stationMarkers.set(call, entry); - pruneAprsEntry(call, entry, mapHistoryCutoffMs()); - if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); + stationMarkers.set(key, entry); + pruneAprsEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry); if (aprsMap) scheduleDecodeMapMaintenance(); } }; 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/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index a7ef6118..8e72d16d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -20,6 +20,12 @@ interface HfAprsBridge { getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; navigateToAprsMap?: (lat: number, lon: number) => void; + aprsMapAddStation?: ( + call: string, lat: number, lon: number, info: string, + symbolTable: string | null | undefined, symbolCode: string | null | undefined, + packet: AprsPacket, source: "aprs" | "hf_aprs", + ) => void; + clearMapMarkersByType?: (type: string) => void; getDecodeRigMeta?: () => unknown; takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise; trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; @@ -177,6 +183,7 @@ function resetHfAprsHistoryView(): void { if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; hfAprsPacketHistory = []; renderHfAprsHistory(); + hfAprsWindow.clearMapMarkersByType?.("hf_aprs"); } function pruneHfAprsHistoryView(): void { @@ -184,6 +191,17 @@ function pruneHfAprsHistoryView(): void { renderHfAprsHistory(); } +/** Hands a positioned packet to the map, if the map module is loaded yet. + * HF traffic goes on as its own source: it is a different band and a + * different path, and the map filter offers it separately. */ +function plotHfAprsPacket(pkt: AprsPacket): void { + if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return; + hfAprsWindow.aprsMapAddStation( + pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", + pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs", + ); +} + function addHfAprsPacket(pkt: AprsPacket): void { const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); pkt._tsMs = tsMs; @@ -192,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void { hfAprsPacketHistory.unshift(pkt); pruneHfAprsPacketHistory(); + plotHfAprsPacket(pkt); + scheduleHfAprsHistoryRender(); } @@ -209,6 +229,7 @@ function onServerHfAprsBatch(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" }); + plotHfAprsPacket(next); normalized.push(next); } normalized.reverse(); @@ -290,4 +311,6 @@ renderHfAprsHistory(); reset: resetHfAprsHistoryView, prune: pruneHfAprsHistoryView, rerender: renderHfAprsHistory, + // Oldest first, so station tracks are rebuilt in the order they happened. + syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); }, }); 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..7f98859a 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 @@ -25,6 +25,13 @@ const BEACON = { // A second rig listening in the background, on its own band. Both pages // describe the rig on screen, so its traffic belongs in neither the panel nor // the mini view — only on the map, which shows the whole station. +// HF APRS travels a different band and a different path from the VHF list, so +// it goes on the map under a source of its own that the filter can hide. +const HF_BEACON = { + type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon", + packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94, + symbol_table: "/", symbol_code: ">", rig_id: "rig-a", +}; const OTHER_RIG_VESSEL = { ...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY", lat: 51.92, lon: 4.48, rig_id: "rig-b", @@ -32,7 +39,7 @@ const OTHER_RIG_VESSEL = { // AIS is what the mini view for vessels is gated on; the rig has to be on it. const fixture = await startWebFixture({ - spectrum: true, decodes: [VESSEL, BEACON, OTHER_RIG_VESSEL], mode: "AIS", + spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS", }); const { browser, page, runtimeErrors } = await startBrowser(chromium); @@ -104,6 +111,36 @@ try { return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) }; }); 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. @@ -120,6 +157,8 @@ try { // left the client on its retry path and the history path untested. const HISTORY_AIS = 900; const HISTORY_APRS = 300; +const HISTORY_FT8 = 12; +const HISTORY_WSPR = 8; const historyFixture = await startWebFixture({ spectrum: true, mode: "AIS", @@ -129,6 +168,14 @@ const historyFixture = await startWebFixture({ vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, })), + ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({ + message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2, + freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, + })), + wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({ + message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5, + freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, + })), aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", info: `history ${index}`, packet_type: "position", crc_ok: true, @@ -216,6 +263,27 @@ try { assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`); assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`); + // The Statistics page counts the same history. Its decode log lives in the + // map module, which is lazy, so every decode that arrived before the module + // did used to be recorded into nothing at all — the page opened empty and + // only filled in from decodes heard afterwards, and it took a reload landing + // on the tab (module loaded at startup, before the history) to show the lot. + await replay.page.evaluate(() => window.navigateToTab("statistics")); + await replay.page.waitForTimeout(1500); + // The counters are written with toLocaleString(), so a four-figure count + // arrives as "1,220" — whichever separator the runner's locale picks. Read + // the digits rather than the formatting. + const counted = await replay.page.evaluate(() => { + const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, "")); + return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") }; + }); + assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR, + `the statistics counted ${counted.decodes} decodes`); + // Grid squares come from the FT8 and WSPR spots, which had no map replay of + // their own: the locators of everything heard before the map loaded were lost. + assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR, + `the statistics counted ${counted.grids} grid squares`); + assert.deepEqual(replay.runtimeErrors, []); } finally { await replay.browser.close();