From 0fc2115973021d5d75ef51298d80108468593e68 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Mon, 3 Aug 2026 23:41:09 +0200 Subject: [PATCH] [fix](trx-frontend-http): make map links work before the map has loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AIS and APRS mini views link each position to the map, and neither did anything: the map module installs itself lazily, and it was the one defining window.navigateToAprsMap, so until something had opened the Map tab the global did not exist. AIS calls it inline from onclick and threw "not a function"; APRS guards the call and so failed silently. The grid links on FT8, FT4, FT2 and WSPR rows went the same way through navigateToMapLocator. The app owns both globals now, installed at startup. They record the target, switch tabs through navigateToTab — the only path that materialises the panel from its template, loads the module and updates the history entry, none of which the module's own hand-rolled tab switch did — and the target is applied once the module reports ready. The module keeps the focusing, which is its job, and exposes it as focusMapPosition and focusMapLocator. The smoke test now calls the link from a cold page, asserting the map module is not loaded first so the check cannot pass by accident. Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 34 ++++++++++++ .../assets/web/generated/map-core.js | 34 +++--------- .../trx-frontend-http/frontend/src/app.ts | 55 +++++++++++++++++++ .../frontend/src/map-core.ts | 32 +++-------- .../frontend/tests/browser-smoke.mjs | 29 +++++++++- 5 files changed, 135 insertions(+), 49 deletions(-) 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 b8d2a20e..64bfda72 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 @@ -5601,6 +5601,39 @@ var _activeTab = "main"; function tabFromPath2(pathname = window.location.pathname) { return tabFromPath(pathname); } +var pendingMapTarget = null; +function applyMapTarget(target) { + const map = window.trx.modules.map; + if (!map) return false; + if (target.kind === "position") { + map.focusMapPosition?.(target.lat, target.lon); + } else { + map.focusMapLocator?.(target.grid, target.preferredType); + } + return true; +} +function requestMapTarget(target) { + pendingMapTarget = target; + navigateToTab("map"); + if (window.trx.modules.map) { + requestAnimationFrame(() => { + drainPendingMapTarget(); + }); + } +} +function drainPendingMapTarget() { + const target = pendingMapTarget; + if (!target) return; + if (applyMapTarget(target)) pendingMapTarget = null; +} +window.navigateToAprsMap = (lat, lon) => { + if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return; + requestMapTarget({ kind: "position", lat, lon }); +}; +window.navigateToMapLocator = (grid, preferredType = null) => { + if (!grid) return; + requestMapTarget({ kind: "locator", grid, preferredType }); +}; var _mapInitTimer = null; function _initMapWhenReady() { const loadingEl2 = document.getElementById("map-loading"); @@ -5617,6 +5650,7 @@ function _initMapWhenReady() { requestAnimationFrame(() => { map.sizeAprsMapToViewport(); map.aprsMap?.invalidateSize(); + drainPendingMapTarget(); }); }); return; 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 5c8f748e..ca02c8cb 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 @@ -1669,16 +1669,7 @@ var mapWindow = window; popupAnchor: [0, -12] }); } - mapWindow.navigateToAprsMap = function(lat, lon) { - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { - t.classList.remove("active"); - }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; + function focusMapPosition(lat, lon) { initAprsMap(); sizeAprsMapToViewport(); if (aprsMap) { @@ -1690,19 +1681,10 @@ var mapWindow = window; }); }); } - }; - mapWindow.navigateToMapLocator = function(grid, preferredType = null) { + } + function focusMapLocator(grid, preferredType = null) { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { - t.classList.remove("active"); - }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; initAprsMap(); sizeAprsMapToViewport(); if (!aprsMap) return false; @@ -1747,7 +1729,7 @@ var mapWindow = window; requestAnimationFrame(focusMarker); }); return true; - }; + } function buildReceiverPopupHtml(rigIds) { const call = T.serverCallsign || T.ownerCallsign || "Receiver"; let meta = ""; @@ -2332,7 +2314,7 @@ var mapWindow = window; selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; syncDecodeContactPathVisibility(); if (selectedMapQsoKey && entry.sourceGrid) { - mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); + focusMapLocator(entry.sourceGrid, entry.sourceType); } }); const head = document.createElement("div"); @@ -2438,7 +2420,7 @@ var mapWindow = window; card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + focusMapLocator(entry.grid ?? "", entry.sourceType); }); } const head = document.createElement("div"); @@ -2544,7 +2526,7 @@ var mapWindow = window; card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + focusMapLocator(entry.grid ?? "", entry.sourceType); }); } const head = document.createElement("div"); @@ -3049,6 +3031,8 @@ var mapWindow = window; } modules.map = { initAprsMap, + focusMapPosition, + focusMapLocator, sizeAprsMapToViewport, syncAprsReceiverMarker, updateMapRigFilter, 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 b9624c51..8d1fb44f 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 @@ -172,6 +172,8 @@ interface TrxModules { map?: { aprsMap?: { invalidateSize(): void } | null; initAprsMap(): void; + focusMapPosition?(lat: number, lon: number): void; + focusMapLocator?(grid: string, preferredType?: string | null): boolean; sizeAprsMapToViewport(): void; pruneMapHistory(): void; updateMapBaseLayerForTheme(theme: string): void; @@ -359,6 +361,10 @@ declare global { trx: { state: TrxState; core: Readonly>; modules: TrxModules }; trxUi: TrxUi; trxPluginRuntime: TrxPluginRuntime; + // Owned here, not by the lazy map module: decode rows link to the map long + // before it has been loaded. + navigateToAprsMap(lat: number, lon: number): void; + navigateToMapLocator(grid: string, preferredType?: string | null): void; lastSpectrumData: SpectrumFrame | null; lastFreqHz: number | null; currentBandwidthHz: number; @@ -4611,6 +4617,54 @@ function tabFromPath(pathname = window.location.pathname) { return tabFromPathname(pathname); } +// Map links fire from decode rows — an AIS position, an APRS frame, an FT8 +// grid — that exist long before the Map tab has ever been opened, and the map +// module is lazy. It used to install these two globals itself, so until +// something had opened the tab the AIS links threw ("not a function") and the +// APRS ones silently did nothing. The app owns them instead: it is the only +// place that can materialise the panel, load the module and update history, +// and the target waits here until the module is up. +type PendingMapTarget = + | { kind: "position"; lat: number; lon: number } + | { kind: "locator"; grid: string; preferredType: string | null }; +let pendingMapTarget: PendingMapTarget | null = null; + +function applyMapTarget(target: PendingMapTarget): boolean { + const map = window.trx.modules.map; + if (!map) return false; + if (target.kind === "position") { + map.focusMapPosition?.(target.lat, target.lon); + } else { + map.focusMapLocator?.(target.grid, target.preferredType); + } + return true; +} + +function requestMapTarget(target: PendingMapTarget) { + pendingMapTarget = target; + navigateToTab("map"); + // Already loaded: the tab switch has shown the panel, so focus can happen + // now. Otherwise _initMapWhenReady drains it once the module arrives. + if (window.trx.modules.map) { + requestAnimationFrame(() => { drainPendingMapTarget(); }); + } +} + +function drainPendingMapTarget() { + const target = pendingMapTarget; + if (!target) return; + if (applyMapTarget(target)) pendingMapTarget = null; +} + +window.navigateToAprsMap = (lat: number, lon: number) => { + if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return; + requestMapTarget({ kind: "position", lat, lon }); +}; +window.navigateToMapLocator = (grid: string, preferredType: string | null = null) => { + if (!grid) return; + requestMapTarget({ kind: "locator", grid, preferredType }); +}; + // Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js // (window.trx.modules.map) if they haven't loaded yet. let _mapInitTimer: ReturnType | null = null; @@ -4630,6 +4684,7 @@ function _initMapWhenReady() { requestAnimationFrame(() => { map.sizeAprsMapToViewport(); map.aprsMap?.invalidateSize(); + drainPendingMapTarget(); }); }); return; 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 f256a733..0168bbec 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 @@ -2016,15 +2016,7 @@ const mapWindow = window as unknown as MapWindow; }); } - mapWindow.navigateToAprsMap = function(lat, lon) { - // Activate the map tab - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; + function focusMapPosition(lat: number, lon: number) { initAprsMap(); sizeAprsMapToViewport(); if (aprsMap) { @@ -2036,20 +2028,12 @@ const mapWindow = window as unknown as MapWindow; }); }); } - }; + } - mapWindow.navigateToMapLocator = function(grid, preferredType = null) { + function focusMapLocator(grid: string, preferredType: string | null = null) { const normalizedGrid = String(grid || "").trim().toUpperCase(); if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none")); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); sizeAprsMapToViewport(); if (!aprsMap) return false; @@ -2099,7 +2083,7 @@ const mapWindow = window as unknown as MapWindow; requestAnimationFrame(focusMarker); }); return true; - }; + } @@ -2810,7 +2794,7 @@ const mapWindow = window as unknown as MapWindow; selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; syncDecodeContactPathVisibility(); if (selectedMapQsoKey && entry.sourceGrid) { - mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); + focusMapLocator(entry.sourceGrid, entry.sourceType); } }); @@ -2937,7 +2921,7 @@ const mapWindow = window as unknown as MapWindow; card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + focusMapLocator(entry.grid ?? "", entry.sourceType); }); } @@ -3063,7 +3047,7 @@ const mapWindow = window as unknown as MapWindow; card.className = "map-qso-card"; if (entry.grid) { card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + focusMapLocator(entry.grid ?? "", entry.sourceType); }); } @@ -3627,6 +3611,8 @@ const mapWindow = window as unknown as MapWindow; // Register module API for core to call modules.map = { initAprsMap, + focusMapPosition, + focusMapLocator, sizeAprsMapToViewport, syncAprsReceiverMarker, updateMapRigFilter, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs index e90c2989..e5daf7a8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs @@ -7,7 +7,7 @@ import { chromium } from "playwright-core"; import { startBrowser, startWebFixture } from "./web-fixture.mjs"; // page.evaluate callbacks run in the browser, not in this Node process. -/* global document, getComputedStyle */ +/* global document, getComputedStyle, window, location */ const fixture = await startWebFixture(); const { selectedRigs } = fixture; @@ -23,6 +23,33 @@ try { await page.locator("summary", { hasText: "Audio controls" }).click(); assert.equal(await page.locator("#rx-audio-btn").count(), 1); + // Map links from decode rows, before anything has opened the Map tab. The + // lazy map module used to install these globals itself, so an AIS pin threw + // "not a function" and an APRS link silently did nothing until the tab had + // been visited once. + const mapLinkReady = await page.evaluate(() => ({ + position: typeof window.navigateToAprsMap, + locator: typeof window.navigateToMapLocator, + mapModuleLoaded: !!window.trx.modules.map, + })); + assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads"); + assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads"); + assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing"); + + await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); }); + await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" }); + await page.waitForTimeout(500); + const followed = await page.evaluate(() => ({ + path: location.pathname, + active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id), + mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height), + })); + assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`); + assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`); + assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`); + await page.locator('.tab[data-tab="main"]').click(); + await page.waitForTimeout(200); + // Section order in the tray. "Advanced radio controls" is built at runtime, // so it lands wherever ui-core puts it rather than where the markup says — // appending, as it once did, always left it last.