Compare commits

...
2 Commits
Author SHA1 Message Date
sjg 2c56d82a81 [feat](trx-frontend-http): make the SQL label the squelch switch
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
Clicking SQL turns the squelch on and off.  The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.

The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:52 +02:00
sjg 0fc2115973 [fix](trx-frontend-http): make map links work before the map has loaded
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 <sjg@haxx.space>
2026-08-03 23:41:09 +02:00
8 changed files with 168 additions and 61 deletions
@@ -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;
@@ -6324,15 +6358,15 @@ function renderSdrSquelch() {
}
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.textContent = sdrSquelchEnabled ? "On" : "Off";
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.setAttribute(
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : state === "open" ? "Squelch open" : "Squelch closed"
state === "off" ? "Squelch off" : state === "open" ? "Squelch on, open" : "Squelch on, closed"
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
@@ -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,
@@ -416,11 +416,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
<span class="sql-title">SQL</span>
<span class="sql-state" id="sdr-squelch-state" data-state="off" role="img" aria-label="Squelch state"></span>
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
<button id="sdr-squelch-toggle" type="button" class="sql-auto-btn" aria-pressed="false" title="Enable or disable the squelch">Off</button>
</span>
<div id="audio-level">
<div id="audio-level-fill"></div>
@@ -1856,9 +1856,32 @@ small { color: var(--text-muted); }
font-size: 0.82rem;
white-space: nowrap;
}
.sql-title {
/* The name is the switch: one target instead of a label and a button that
said the same thing twice, with the dot carrying the state. */
.sql-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 1.5rem;
min-height: 0;
padding: 0 0.4rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
}
.sql-toggle:hover {
border-color: color-mix(in srgb, var(--border-light) 60%, transparent);
color: var(--text);
}
.sql-toggle[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
color: var(--accent-text);
}
.sql-state {
width: 0.5rem;
@@ -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<Record<string, unknown>>; 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<typeof setInterval> | null = null;
@@ -4630,6 +4684,7 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -5288,15 +5343,15 @@ function renderSdrSquelch() {
}
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.textContent = sdrSquelchEnabled ? "On" : "Off";
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : (sdrSquelchIsPassing() ? "open" : "closed");
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.setAttribute(
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : (state === "open" ? "Squelch open" : "Squelch closed"),
state === "off" ? "Squelch off" : (state === "open" ? "Squelch on, open" : "Squelch on, closed"),
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
@@ -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<HTMLElement>(".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<HTMLElement>(".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,
@@ -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.
@@ -144,12 +144,12 @@ try {
shown: getComputedStyle(line).display !== "none",
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
toggle: document.getElementById("sdr-squelch-toggle").textContent,
toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"),
top: Math.round(line.getBoundingClientRect().top),
};
});
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
assert.equal(squelchOn.toggle, "On", "the toggle did not follow the squelch state");
assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state");
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
// Dragging the line down lowers the threshold and tells the server.