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.