Compare commits

...
Author SHA1 Message Date
sjg 09634eb851 [fix](trx-frontend-http): tidy up the map's filter bar
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m7s
CI / frontend (push) Successful in 2m57s
CI / reuse (push) Successful in 3s
CI / frontend (pull_request) Successful in 3m47s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m19s
The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.

Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.

The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.

The map layout test now covers the chips, the divider's height and the
rows' shared start.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 21:43:57 +02:00
sjg 90ab7781ad [fix](trx-frontend-http): stop the browser offering saved values for frequency
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / frontend (pull_request) Successful in 3m48s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m23s
CI / frontend (push) Successful in 2m54s
CI / reuse (push) Successful in 3s
The tuned and centre frequency readouts are text inputs, so the browser
keeps what has been typed into them and offers it back in a dropdown --
Edge does this out of the box, dropping stale frequencies from other
sessions over the reading.

Turn autofill off on both, along with autocorrect and spellcheck, which
have no business near a number either.

Fixes #39

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:36:22 +02:00
sjg 88d04253ca [fix](trx-frontend-http): move the map's fullscreen and filter toggles into the bar
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.

Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.

Fixes #38

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:34:09 +02:00
sjg 08005c5c07 [feat](trx-frontend-http): lay the map filters out as a bar across the top
CI / test (push) Successful in 8m6s
CI / frontend (push) Successful in 3m45s
CI / reuse (push) Successful in 3s
CI / lint (push) Successful in 2m15s
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.

It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:54:21 +02:00
sjg 026f816ddb [fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s
The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

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

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:34:45 +02:00
sjg d31b6f545f [test](trx-frontend-http): watch the history progress from inside the page
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 8m18s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 2s
The replay-progress check polled the overlay from the test every 100ms.
A replay that starts and finishes between two polls is never sampled, and
the test then reports that no progress was shown at all -- the source of
the intermittent "no progress was shown while the history loaded" failure.

Record the samples from a MutationObserver installed before the page's own
scripts run, so a fast replay is observed rather than missed.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:57 +02:00
sjg 39f551c914 [fix](trx-frontend-http): hold the APRS symbol column open for frames without one
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.

Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:33 +02:00
19 changed files with 616 additions and 169 deletions
@@ -285,9 +285,11 @@ function addAisMessage(msg) {
pruneAisMessageHistory(); pruneAisMessageHistory();
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(msg);
aisWindow.aisMapAddVessel(msg); }
} function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
} }
function normalizeServerAisMessage(msg) { function normalizeServerAisMessage(msg) {
return { return {
@@ -308,9 +310,7 @@ function onServerAisBatch(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"
}); });
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(next);
aisWindow.aisMapAddVessel(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -352,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAisBatch, onBatch: onServerAisBatch,
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
}
}); });
@@ -1060,6 +1060,9 @@ var runtime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
clearQueued() { clearQueued() {
queued.clear(); queued.clear();
}, },
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OZI7SINT.js"; } from "./chunk-OPEIVJGD.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -196,15 +196,17 @@ function pruneAprsHistoryView() {
updateAprsBar(); updateAprsBar();
renderAprsHistory(); renderAprsHistory();
} }
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt) { function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt); aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(pkt);
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate(); if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender(); scheduleAprsHistoryRender();
} }
@@ -221,9 +223,7 @@ function onServerAprsBatch(packets) {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(next);
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true; if (next.crcOk) hasCrcOk = true;
normalized.push(next); normalized.push(next);
} }
@@ -287,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAprsBatch, onBatch: onServerAprsBatch,
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
}); });
@@ -117,6 +117,9 @@ function aprsSymbolSprite(symbolTable, symbolCode) {
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}` label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
}; };
} }
function renderAprsSymbolSlot(packet, escapeHtml) {
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
function renderLocalAprsSymbol(packet, escapeHtml) { function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return ""; if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
@@ -221,7 +224,7 @@ function renderAprsPacketRow(packet, options = {}) {
const summary = summarizeAprsPayload(packet); const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null; const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`; row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => { row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => { element.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-OZI7SINT.js"; } from "./chunk-OPEIVJGD.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -1,6 +1,6 @@
import { import {
aprsSymbolSprite aprsSymbolSprite
} from "./chunk-OZI7SINT.js"; } from "./chunk-OPEIVJGD.js";
// src/map-core.ts // src/map-core.ts
function mapEl(id) { function mapEl(id) {
@@ -62,6 +62,7 @@ var mapWindow = window;
const mapMarkers = /* @__PURE__ */ new Set(); 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, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
let mapSearchFilter = ""; let mapSearchFilter = "";
let mapRigFilter = ""; let mapRigFilter = "";
@@ -838,38 +839,36 @@ var mapWindow = window;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`; container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return; return;
} }
let helperText = ""; const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
if (kind === "source") { const allChip = document.createElement("button");
if (noneSelected) { allChip.type = "button";
helperText = "All sources visible — click to filter"; allChip.className = "map-locator-chip map-locator-chip-all";
} if (showingAll) allChip.classList.add("is-active");
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { allChip.dataset.filterKind = kind;
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
} allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) { for (const item of items) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "map-locator-chip"; btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key); const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) { if (showingAll) {
btn.classList.add("is-default"); btn.classList.add("is-default");
} else if (!isActive) { } else if (!isActive) {
btn.classList.add("is-inactive"); btn.classList.add("is-inactive");
} }
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind; btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key; btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color); btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`; btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn); container.appendChild(btn);
} }
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
} }
function renderMapLocatorPhaseRow(container, phase) { function renderMapLocatorPhaseRow(container, phase) {
if (!container) return; if (!container) return;
@@ -977,11 +976,10 @@ var mapWindow = window;
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
if (!phaseEl || !choiceEl || !choiceLabelEl) return; if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") { if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else { } else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
} }
syncLocatorMarkerStyles(); syncLocatorMarkerStyles();
@@ -1335,7 +1333,8 @@ var mapWindow = window;
function applyMapOverlayPanelVisibility() { function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel"); const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return; if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
} }
function updateMapOverlayToggleButton() { function updateMapOverlayToggleButton() {
const btn = mapEl("map-overlay-toggle-btn"); const btn = mapEl("map-overlay-toggle-btn");
@@ -1532,7 +1531,13 @@ var mapWindow = window;
const kind = String(chip.dataset.filterKind || ""); const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || ""); const key = String(chip.dataset.filterKey || "");
if (!key) return; if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { if (key === MAP_FILTER_ALL_KEY) {
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
const sourceKey = key; const sourceKey = key;
mapFilter[sourceKey] = !mapFilter[sourceKey]; mapFilter[sourceKey] = !mapFilter[sourceKey];
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
@@ -2147,14 +2152,16 @@ var mapWindow = window;
function updateMapContactPathsToggle() { function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle"); const btn = mapEl("map-contact-paths-toggle");
if (!btn) return; if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
} }
function updateMapP2pPathsToggle() { function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle"); const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return; if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
} }
function scheduleDecodeMapMaintenance() { function scheduleDecodeMapMaintenance() {
if (C.decodeHistoryMapRenderingDeferred()) { if (C.decodeHistoryMapRenderingDeferred()) {
@@ -3093,5 +3100,6 @@ var mapWindow = window;
bandForHz, bandForHz,
reverseGeocodeLocation reverseGeocodeLocation
}; };
window.trxPluginRuntime.syncMapAll();
autoInitIfVisible(); autoInitIfVisible();
})(); })();
@@ -220,9 +220,7 @@ function onServerVdesBatch(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit" second: "2-digit"
}); });
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -248,13 +246,15 @@ if (vdesFilterInput) {
renderVdesHistory(); renderVdesHistory();
}); });
} }
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) { function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
} }
function pruneVdesHistoryView() { function pruneVdesHistoryView() {
pruneVdesMessageHistory(); pruneVdesMessageHistory();
@@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerVdesBatch, onBatch: onServerVdesBatch,
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
}
}); });
@@ -209,11 +209,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div> <div class="label"><span>Signal strength</span></div>
</div> </div>
<div class="freq-field frequency-col"> <div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" /> <input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="freq-label"><span>Frequency</span></div> <div class="label" id="freq-label"><span>Frequency</span></div>
</div> </div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;"> <div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" /> <input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div> <div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div> </div>
</div> </div>
@@ -1001,48 +1001,52 @@ SPDX-License-Identifier: GPL-2.0-or-later
<template id="tmpl-map"> <template id="tmpl-map">
<div id="map-stage"> <div id="map-stage">
<div class="map-overlay-panel"> <div class="map-overlay-panel">
<div class="map-locator-filter-group"> <div class="map-overlay-filters">
<span class="map-locator-filter-label">Filter by</span> <div class="map-locator-filter-group">
<div id="map-locator-phase" class="map-locator-phase-row"></div> <span class="map-locator-filter-label">Filter</span>
</div> <div id="map-locator-phase" class="map-locator-phase-row"></div>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span> <div class="map-locator-filter-group">
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div> <span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
</div> <div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label">Rig</span> <div class="map-locator-filter-group">
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig"> <span class="map-locator-filter-label">Rig</span>
<option value="">All</option> <select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
</select> <option value="">All</option>
</div> </select>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label">Search</span> <div class="map-locator-filter-group">
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." /> <span class="map-locator-filter-label">History</span>
</div> <select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<div class="map-locator-filter-group"> <option value="15">15 min</option>
<span class="map-locator-filter-label">History</span> <option value="30">30 min</option>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit"> <option value="60">1 hr</option>
<option value="15">15 min</option> <option value="180">3 hrs</option>
<option value="30">30 min</option> <option value="360">6 hrs</option>
<option value="60">1 hr</option> <option value="720">12 hrs</option>
<option value="180">3 hrs</option> <option value="1440">24 hrs</option>
<option value="360">6 hrs</option> </select>
<option value="720">12 hrs</option> </div>
<option value="1440">24 hrs</option> <div class="map-locator-filter-group">
</select> <span class="map-locator-filter-label">Paths</span>
</div> <div class="map-locator-phase-row">
<div class="map-locator-filter-group"> <button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
<span class="map-locator-filter-label">Paths</span> <button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
<div class="map-locator-phase-row"> <span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button> </div>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button> </div>
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span> <!-- Last, so the search field takes whatever the fixed-width
groups leave on the bar's final row rather than a sliver. -->
<div class="map-locator-filter-group map-filter-grow">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div> </div>
</div> </div>
</div> <div class="map-overlay-actions">
<div class="map-corner-controls"> <button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button> <button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button> </div>
</div> </div>
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div> <div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
<div id="aprs-map"></div> <div id="aprs-map"></div>
@@ -2556,18 +2556,29 @@ button.map-qso-card:focus-visible {
border-color: color-mix(in srgb, var(--accent-blue, #5b9bd5) 35%, transparent); border-color: color-mix(in srgb, var(--accent-blue, #5b9bd5) 35%, transparent);
background: color-mix(in srgb, var(--accent-blue, #5b9bd5) 10%, transparent); background: color-mix(in srgb, var(--accent-blue, #5b9bd5) 10%, transparent);
} }
/* A toolbar across the top of the map rather than a panel parked in a corner:
it leaves the map itself unobscured, and the bottom-left band legend keeps
its place. Fullscreen and the filter toggle ride at its right-hand end;
only the filters themselves collapse, so the button that hides them is
still there to bring them back. */
.map-overlay-panel { .map-overlay-panel {
position: absolute; position: absolute;
top: 0.7rem;
/* Clear of Leaflet's zoom buttons, which draw over the top-left corner. */
left: 3.4rem;
right: 0.7rem; right: 0.7rem;
bottom: 0.7rem;
z-index: 410; z-index: 410;
display: flex; display: flex;
flex-direction: column; flex-flow: row nowrap;
gap: 0.45rem; /* Stretched, not centred: the rule dividing the filters from the buttons is
width: min(30rem, calc(100% - 4.9rem)); drawn on the button block's edge, and it has to run the height of the bar
rather than float beside it as a stub. */
align-items: stretch;
gap: 0.5rem;
width: auto;
max-height: calc(100% - 1.4rem); max-height: calc(100% - 1.4rem);
padding: 0.7rem 0.75rem; padding: 0.45rem 0.6rem;
border-radius: 0.8rem; border-radius: 0.7rem;
border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent); border: 1px solid color-mix(in srgb, var(--border-light) 74%, transparent);
background: color-mix(in srgb, var(--card-bg) 82%, transparent); background: color-mix(in srgb, var(--card-bg) 82%, transparent);
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24); box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
@@ -2577,22 +2588,94 @@ button.map-qso-card:focus-visible {
overflow: auto; overflow: auto;
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
} }
.map-overlay-filters {
display: flex;
flex: 1 1 auto;
flex-flow: row wrap;
align-items: center;
/* Rows keep their own height when the bar is taller than they are, so the
groups stay a bar's two rows rather than drifting apart to fill it. */
align-content: center;
gap: 0.35rem 0.5rem;
min-width: 0;
}
.map-overlay-filters.is-hidden {
display: none;
}
/* With the filters collapsed the bar has no reason to span the map. */
.map-overlay-panel.filters-hidden {
left: auto;
}
.map-overlay-actions {
display: flex;
flex: 0 0 auto;
/* The block spans the bar so its divider can; the buttons still sit level
with the middle of it. */
align-self: stretch;
align-items: center;
gap: 0.4rem;
}
/* Set off from the filters, but only while there are filters to set off. */
.map-overlay-panel:not(.filters-hidden) .map-overlay-actions {
padding-left: 0.5rem;
border-left: 1px solid color-mix(in srgb, var(--border-light) 55%, transparent);
}
.map-overlay-panel .map-locator-filter-group {
flex: 0 1 auto;
/* The label stays beside its control; only the bar itself wraps. */
flex-wrap: nowrap;
align-items: center;
gap: 0.35rem;
min-width: 0;
padding-right: 0.5rem;
border-right: 1px solid color-mix(in srgb, var(--border-light) 55%, transparent);
}
.map-overlay-panel .map-locator-filter-group:last-child {
padding-right: 0;
border-right: 0;
}
/* The search field takes whatever room the fixed-width groups leave, up to a
width past which a text box spanning the map reads as a mistake. */
.map-overlay-panel .map-filter-grow {
flex: 1 1 9rem;
max-width: 26rem;
}
/* One gutter for every label, so whichever group starts a row starts it in the
same place: at their natural widths the labels staggered each row's first
control by however much the label above it was wider or narrower. */
.map-overlay-panel .map-locator-filter-label {
min-width: 3.5rem;
padding-top: 0;
font-size: 0.72rem;
letter-spacing: 0.02em;
white-space: nowrap;
}
/* The two rows of the bar are led by the two pairs of phase buttons, and
SOURCE|BAND is wider than TRX|CONTACT: left to their own widths the groups
after them missed each other by four pixels. One width for both pairs. */
.map-overlay-panel .map-locator-phase-row {
flex: 0 0 auto;
min-width: 9rem;
}
.map-overlay-panel .map-history-select {
flex: 0 0 auto;
width: auto;
max-width: 8rem;
}
.map-overlay-panel .map-search-input {
flex: 1 1 6rem;
min-width: 4.5rem;
}
/* The buttons carry this as a tooltip; a sentence of it would swallow the bar. */
.map-overlay-panel .map-paths-hint {
display: none;
}
.map-overlay-panel.is-hidden { .map-overlay-panel.is-hidden {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transform: translateY(0.25rem); transform: translateY(0.25rem);
pointer-events: none; pointer-events: none;
} }
.map-corner-controls {
position: absolute;
top: 0.7rem;
right: 0.7rem;
z-index: 410;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.4rem;
}
.map-fullscreen-btn { .map-fullscreen-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -3153,6 +3236,8 @@ body.map-fake-fullscreen-active {
displays can swap in the @2x variants. */ displays can swap in the @2x variants. */
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; background-size: 384px 144px; vertical-align: middle; margin-right: 0.3rem; } .aprs-symbol { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; background-size: 384px 144px; vertical-align: middle; margin-right: 0.3rem; }
.aprs-symbol-marker { margin-right: 0; } .aprs-symbol-marker { margin-right: 0; }
/* Holds the column open for a frame that carries no symbol. */
.aprs-symbol-empty { background-image: none; }
.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0.png'); } .aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0.png'); }
.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1.png'); } .aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1.png'); }
.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2.png'), url('/vendor/aprs-symbols-24-1.png'); } .aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2.png'), url('/vendor/aprs-symbols-24-1.png'); }
@@ -3407,6 +3492,23 @@ body.map-fake-fullscreen-active {
border-color: color-mix(in srgb, var(--border-light) 68%, transparent); border-color: color-mix(in srgb, var(--border-light) 68%, transparent);
background: color-mix(in srgb, var(--input-bg) 96%, transparent); background: color-mix(in srgb, var(--input-bg) 96%, transparent);
} }
/* "All" clears the selection rather than naming a band or a source, so it
borrows the phase buttons' look instead of a colour of its own — and says
in one chip's width what a line of helper text used to say in the bar. */
.map-locator-chip-all {
--chip-color: var(--border-light);
color: var(--text-muted);
}
.map-locator-chip-all.is-active {
border-color: var(--accent-green);
background: color-mix(in srgb, var(--accent-green) 10%, var(--input-bg));
color: var(--accent-green);
}
.map-locator-chip-all .map-locator-chip-text {
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.map-locator-chip-text { .map-locator-chip-text {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
@@ -4117,15 +4219,42 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
} }
.map-overlay-panel { .map-overlay-panel {
top: 0.55rem; top: 0.55rem;
left: 0.55rem; left: 3.25rem;
width: calc(100% - 1.1rem); right: 0.55rem;
flex-flow: column nowrap;
align-items: stretch;
width: auto;
max-height: min(16.5rem, calc(100% - 1.1rem)); max-height: min(16.5rem, calc(100% - 1.1rem));
padding: 0.6rem 0.65rem; padding: 0.6rem 0.65rem;
border-radius: 0.7rem; border-radius: 0.7rem;
} }
.map-corner-controls { .map-overlay-panel .map-locator-filter-group {
top: 0.55rem; flex-wrap: wrap;
right: 0.55rem; align-items: flex-start;
padding-right: 0;
border-right: 0;
}
/* Stacked in a column the panel scrolls, and the search field is no use at
the bottom of it: it goes back to the top, where it needs no scrolling. */
.map-overlay-panel .map-filter-grow {
order: -1;
max-width: none;
}
.map-overlay-actions {
justify-content: flex-end;
}
/* Stacked, the buttons sit under the filters rather than beside them, so the
rule that divides them has to lie across the panel, not down its left. */
.map-overlay-panel:not(.filters-hidden) .map-overlay-actions {
padding-left: 0;
padding-top: 0.5rem;
border-left: 0;
border-top: 1px solid color-mix(in srgb, var(--border-light) 55%, transparent);
}
.map-overlay-panel .map-paths-hint {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
} }
.map-band-legend { .map-band-legend {
left: 0.55rem; left: 0.55rem;
@@ -4,6 +4,7 @@
import type * as Leaflet from "leaflet"; import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared"; import { aprsSymbolSprite } from "./plugins/aprs-shared";
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
export {}; export {};
@@ -229,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
const mapMarkers = new Set<TrxLayer>(); const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() }; const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
let mapSearchFilter = ""; let mapSearchFilter = "";
let mapRigFilter = ""; // "" = all rigs let mapRigFilter = ""; // "" = all rigs
@@ -1078,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`; container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return; return;
} }
let helperText = ""; const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : []; const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); // Selecting nothing selects everything, for both kinds.
if (kind === "source") { const showingAll = kind === "source"
if (noneSelected) { ? sourceKeys.every((k) => !mapFilter[k])
helperText = "All sources visible \u2014 click to filter"; : !(selectedSet instanceof Set) || selectedSet.size === 0;
} // An "All" chip carries what a sentence of helper text used to say, in a
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { // width the bar can afford, and gives the selection somewhere to be undone.
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; const allChip = document.createElement("button");
} allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) { for (const item of items) {
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.className = "map-locator-chip"; btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key); const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) { // Nothing is filtered out yet, so no chip is dimmed as if it were.
if (showingAll) {
btn.classList.add("is-default"); btn.classList.add("is-default");
} else if (!isActive) { } else if (!isActive) {
btn.classList.add("is-inactive"); btn.classList.add("is-inactive");
} }
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind; btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key; btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color); btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`; btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn); container.appendChild(btn);
} }
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
} }
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void { function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
@@ -1233,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
if (!phaseEl || !choiceEl || !choiceLabelEl) return; if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
// The phase buttons next door already name the dimension; "Show" is the
// rest of the sentence, and it keeps the bar's labels a uniform width.
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") { if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else { } else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
} }
syncLocatorMarkerStyles(); syncLocatorMarkerStyles();
@@ -1636,7 +1644,10 @@ const mapWindow = window as unknown as MapWindow;
function applyMapOverlayPanelVisibility() { function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel"); const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return; if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); // Only the filters collapse. The bar itself stays, because it carries the
// button that brings them back.
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
} }
function updateMapOverlayToggleButton() { function updateMapOverlayToggleButton() {
@@ -1859,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
const kind = String(chip.dataset.filterKind || ""); const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || ""); const key = String(chip.dataset.filterKey || "");
if (!key) return; if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { if (key === MAP_FILTER_ALL_KEY) {
// Back to no selection at all, which is what shows everything.
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
// toggle the clicked source; when none are selected everything is shown // toggle the clicked source; when none are selected everything is shown
const sourceKey = key as MapFilterKey; const sourceKey = key as MapFilterKey;
mapFilter[sourceKey] = !mapFilter[sourceKey]; mapFilter[sourceKey] = !mapFilter[sourceKey];
@@ -2580,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
syncDecodeContactPathVisibility(); syncDecodeContactPathVisibility();
} }
// The buttons light up when they are on, so the label need not repeat it —
// an "On"/"Off" suffix on each cost the bar most of a row.
function updateMapContactPathsToggle() { function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle"); const btn = mapEl("map-contact-paths-toggle");
if (!btn) return; if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled
? "Directed decode paths are drawn when the target locator is known"
: "Directed decode paths are hidden";
} }
function updateMapP2pPathsToggle() { function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle"); const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return; if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled
? "TRX paths are drawn from a station popup"
: "TRX paths are hidden";
} }
function scheduleDecodeMapMaintenance() { function scheduleDecodeMapMaintenance() {
@@ -3662,6 +3688,18 @@ const mapWindow = window as unknown as MapWindow;
reverseGeocodeLocation, reverseGeocodeLocation,
}; };
// Everything the decoders already hold goes onto the map now. This module is
// lazy -- it arrives when the Map tab is first opened, long after startup
// restored the decode history -- and until it does, aprsMapAddStation and
// friends are undefined, so every restored position was dropped on the floor.
// The map then showed only what arrived live after it loaded, which is why it
// took a second reload (with the module cached, and so loaded early enough to
// win the race against the history fetch) for the stations to appear.
//
// The add functions are keyed by callsign/MMSI/point, so replaying costs
// nothing on a second call and cannot duplicate a marker.
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
// If the map tab is already visible (direct /map URL), init immediately. // If the map tab is already visible (direct /map URL), init immediately.
autoInitIfVisible(); autoInitIfVisible();
})(); })();
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune(); plugin.prune();
return true; return true;
}, },
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); }, clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id), hasDecoder: (id) => decoders.has(id),
}; };
@@ -389,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(msg);
aisWindow.aisMapAddVessel(msg); }
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotAisMessage(msg: AisMessage): void {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
} }
function normalizeServerAisMessage(msg: AisMessage): AisMessage { function normalizeServerAisMessage(msg: AisMessage): AisMessage {
@@ -414,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { plotAisMessage(next);
aisWindow.aisMapAddVessel(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -462,4 +464,6 @@ updateAisSummary();
restore: onServerAisBatch, restore: onServerAisBatch,
reset: resetAisHistoryView, reset: resetAisHistoryView,
prune: pruneAisHistoryView, prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
}); });
@@ -182,6 +182,13 @@ export function aprsSymbolSprite(
}; };
} }
/** An empty slot of the symbol's size, so a frame without one still lines up
* with the frames around it in the list. */
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
return renderLocalAprsSymbol(packet, escapeHtml)
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string { export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
if (!packet.symbolTable || !packet.symbolCode) return ""; if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
@@ -336,7 +343,7 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
`<summary class="decode-line">` + `<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` + `<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderLocalAprsSymbol(packet, escapeAprsHtml) + renderAprsSymbolSlot(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` + `<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` + `${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
@@ -229,6 +229,12 @@ function pruneAprsHistoryView(): void {
renderAprsHistory(); renderAprsHistory();
} }
/** Hands a positioned packet to the map, if the map module is loaded yet. */
function plotAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt: AprsPacket): void { function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs; pkt._tsMs = tsMs;
@@ -237,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
aprsPacketHistory.unshift(pkt); aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory(); pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(pkt);
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate(); if (pkt.crcOk) scheduleAprsBarUpdate();
@@ -260,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs; next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) { plotAprsPacket(next);
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true; if (next.crcOk) hasCrcOk = true;
normalized.push(next); normalized.push(next);
} }
@@ -334,4 +336,6 @@ renderAprsHistory();
restore: onServerAprsBatch, restore: onServerAprsBatch,
reset: resetAprsHistoryView, reset: resetAprsHistoryView,
prune: pruneAprsHistoryView, prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
}); });
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void; restore?(messages: TMessage[]): void;
reset?(): void; reset?(): void;
prune?(): void; prune?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
} }
export interface TrxPluginRuntime { export interface TrxPluginRuntime {
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean; reset(id: string): boolean;
resetAll(): void; resetAll(): void;
prune(id: string): boolean; prune(id: string): boolean;
syncMapAll(): void;
clearQueued(): void; clearQueued(): void;
hasDecoder(id: string): boolean; hasDecoder(id: string): boolean;
} }
@@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -349,13 +347,17 @@ if (vdesFilterInput) {
}); });
} }
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotVdesMessage(msg: VdesMessage): void {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg: VdesMessage): void { function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving"; if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg); const next = normalizeServerVdesMessage(msg);
addVdesMessage(next); addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) { plotVdesMessage(next);
vdesWindow.vdesMapAddPoint(next);
}
} }
function pruneVdesHistoryView(): void { function pruneVdesHistoryView(): void {
@@ -372,4 +374,6 @@ updateVdesSummary();
restore: onServerVdesBatch, restore: onServerVdesBatch,
reset: resetVdesHistoryView, reset: resetVdesHistoryView,
prune: pruneVdesHistoryView, prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
}); });
@@ -376,6 +376,18 @@ try {
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`); `text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them"); assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
// The frequency readouts are typed into, so the browser remembers what went
// in and offers it back in a dropdown over the reading — Edge does this by
// default. Nothing here wants to be autofilled from what was tuned last week.
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
const input = document.getElementById(id);
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
}));
for (const field of autofill) {
assert.equal(field.autocomplete, "off",
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
}
} finally { } finally {
await browser.close(); await browser.close();
await fixture.close(); await fixture.close();
@@ -11,7 +11,7 @@ import assert from "node:assert/strict";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs"; import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window, location */ /* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
const VESSEL = { const VESSEL = {
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND", type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
@@ -115,12 +115,34 @@ const historyFixture = await startWebFixture({
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({ aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1", src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true, info: `history ${index}`, packet_type: "position", crc_ok: true,
lat: 54.3, lon: 18.6, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000, lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
ts_ms: Date.now() - (index + 1) * 1000,
})), })),
}, },
}); });
const replay = await startBrowser(chromium); const replay = await startBrowser(chromium);
// Installed before the page's own scripts, so nothing can be missed: every
// time the progress element becomes visible, its geometry is recorded.
await replay.page.addInitScript(() => {
window.__historyProgressSamples = [];
const watch = () => {
const element = document.getElementById("decode-history-overlay");
if (!element) { requestAnimationFrame(watch); return; }
const sample = () => {
if (element.classList.contains("is-hidden")) return;
const rect = element.getBoundingClientRect();
window.__historyProgressSamples.push({
width: Math.round(rect.width),
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
});
};
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
sample();
};
watch();
});
try { try {
await replay.page.setViewportSize({ width: 1400, height: 900 }); await replay.page.setViewportSize({ width: 1400, height: 900 });
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
@@ -128,20 +150,11 @@ try {
// While it loads, the operator can still see the radio. This used to be a // While it loads, the operator can still see the radio. This used to be a
// full-screen scrim over everything for as long as the replay ran. // full-screen scrim over everything for as long as the replay ran.
const samples = []; //
for (let attempt = 0; attempt < 30; attempt++) { // Watched from inside the page rather than polled from here: a fast replay
samples.push(await replay.page.evaluate(() => { // can start and finish between two polls, and then the test reports that no
const element = document.getElementById("decode-history-overlay"); // progress was ever shown when what happened is that it blinked.
if (!element || element.classList.contains("is-hidden")) return null; const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
const rect = element.getBoundingClientRect();
return {
width: Math.round(rect.width),
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
};
}));
await replay.page.waitForTimeout(100);
}
const shown = samples.filter(Boolean);
assert.ok(shown.length > 0, "no progress was shown while the history loaded"); assert.ok(shown.length > 0, "no progress was shown while the history loaded");
for (const sample of shown) { for (const sample of shown) {
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`); assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
@@ -159,6 +172,33 @@ try {
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`); assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished"); assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
// Opening the map for the first time has to show the stored history too.
// The map module is lazy, so at the moment the history was restored its
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
// position was dropped. Nothing replayed them when the module finally
// arrived, so the map came up empty and only filled in from decodes heard
// afterwards -- a station heard once was never plotted at all, and it took a
// second reload (module cached, so loaded early enough to beat the history
// fetch) before the map showed anything.
//
// This fixture serves no live decode stream on purpose: with one, fresh
// frames arriving after the module loads would paper over the whole thing.
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
await replay.page.locator('.tab[data-tab="map"]').click();
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await replay.page.waitForTimeout(1500);
const plotted = await replay.page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
assert.deepEqual(replay.runtimeErrors, []); assert.deepEqual(replay.runtimeErrors, []);
} finally { } finally {
await replay.browser.close(); await replay.browser.close();
@@ -174,7 +214,11 @@ const APRS_FRAMES = [
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 }, { packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
].map((frame, index) => ({ ].map((frame, index) => ({
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true, src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a", ts_ms: Date.now() - index * 1000, // Only position reports carry a symbol here, which is the case the columns
// have to survive: a frame without one used to close the gap and shift
// everything after it left.
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
...frame, ...frame,
})); }));
@@ -224,6 +268,20 @@ try {
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`); assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`); assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
// Frames with and without a symbol line up: the slot is held open either way.
const columns = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
})));
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
"the sample has to mix frames with and without a symbol to test this");
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
// The frame as it arrived is still there, one click away. // The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" }) await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".decode-line").first().click(); .locator(".decode-line").first().click();
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs"; import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle */ /* global document, getComputedStyle, window */
const BOOKMARKS = [ const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" }, { id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
@@ -222,3 +222,159 @@ try {
await retry.browser.close(); await retry.browser.close();
await retryFixture.close(); await retryFixture.close();
} }
// The map's filter panel is a bar across the top of the map, not a window
// sitting on it: it has to stay one or two rows tall, span most of the width,
// and keep clear of what shares the map's corners — Leaflet's zoom buttons and
// the band legend. A panel that grew a column would cover the map it filters.
// Fullscreen and the filter toggle ride at the bar's right-hand end, so only
// the filters collapse: the bar itself has to survive Hide Filters, or there
// is nothing left to click to bring them back.
const mapFixture = await startWebFixture({ spectrum: true });
const mapView = await startBrowser(chromium);
try {
for (const width of [1600, 1200]) {
await mapView.page.setViewportSize({ width, height: 950 });
await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" });
await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await mapView.page.waitForTimeout(1200);
const bar = await mapView.page.evaluate(() => {
const box = (element) => (element ? element.getBoundingClientRect() : null);
const panel = document.querySelector(".map-overlay-panel");
const stage = box(document.getElementById("map-stage"));
const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom"));
const legend = box(document.getElementById("map-band-legend"));
const panelBox = box(panel);
const hits = (a, b) => !!a && !!b
&& a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
return {
widthPct: Math.round((panelBox.width / stage.width) * 100),
height: Math.round(panelBox.height),
outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1,
hitsZoom: hits(panelBox, zoom),
hitsLegend: hits(panelBox, legend),
// Both controls belong to the bar now, not to a floating corner block.
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
.map((button) => button.id).join(","),
// The rule dividing them from the filters is drawn on this block's
// edge, so the block has to run the height of the filters beside it —
// centred, it left the rule floating as a stub against a two-row bar.
actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height
- box(panel.querySelector(".map-overlay-actions")).height),
// Every label sits in a gutter of one width, so whichever group leads
// a row leads it from the same place: with the labels at their natural
// widths, a row starting "Search" began 10px off one starting "Filter".
rowStarts: (() => {
const leaders = new Map();
for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) {
const rect = box(group);
// Groups of a row differ in height, so bucket them by their middle.
const row = Math.round((rect.top + rect.height / 2) / 20);
if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group);
}
return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left));
})(),
clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1,
};
});
assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`);
assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`);
assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`);
assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
`the bar carries "${bar.actionsInBar}" at ${width}px`);
assert.ok(bar.actionsShort <= 1,
`the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`);
assert.equal(new Set(bar.rowStarts).size, 1,
`the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`);
assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`);
assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`);
}
// Band chips only exist once something has been heard on a band. The bar
// used to explain "all bands visible by default" in a line of prose wedged
// between the chips and the next group, which is neither what a toolbar is
// for nor a width it can spare: an "All" chip says it and undoes a
// selection. Nothing is dimmed while nothing is filtered out, either — every
// chip used to come up greyed at the very moment all of them were showing.
await mapView.page.evaluate(() => {
const ts = Date.now();
for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) {
window.trxPluginRuntime.dispatch("ft8",
{ message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" });
}
});
await mapView.page.waitForTimeout(1500);
const chipRow = () => mapView.page.evaluate(() => {
const row = document.getElementById("map-locator-choice-filter");
const chips = [...row.querySelectorAll(".map-locator-chip")];
const all = row.querySelector(".map-locator-chip-all");
return {
bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()),
prose: row.querySelector(".map-locator-empty")?.textContent ?? null,
allActive: all?.classList.contains("is-active") ?? null,
dimmed: chips.filter((chip) => chip.classList.contains("is-inactive"))
.map((chip) => chip.textContent.trim()),
selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true"
&& chip !== all).map((chip) => chip.textContent.trim()),
};
});
const unfiltered = await chipRow();
assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"),
`the chip row is showing ${unfiltered.bands.join(",")}`);
assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`);
assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map");
assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`);
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click();
await mapView.page.waitForTimeout(300);
const filtered = await chipRow();
assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`);
assert.equal(filtered.allActive, false, "All stayed lit with a band picked out");
assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such");
// And back: All is how a selection is undone without hunting for the chips
// that are in it.
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click();
await mapView.page.waitForTimeout(300);
const cleared = await chipRow();
assert.equal(cleared.allActive, true, "All did not clear the band selection");
assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`);
assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`);
// Hiding gives the map back, but leaves the bar itself — collapsed to its
// two controls at the right-hand edge — so the filters can be brought back.
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const toggled = await mapView.page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const stage = document.getElementById("map-stage").getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0;
return {
filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"),
widthPct: Math.round((panelBox.width / stage.width) * 100),
rightGap: Math.round(stage.right - panelBox.right),
label: document.getElementById("map-overlay-toggle-btn").textContent.trim(),
togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"),
};
});
assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters");
assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it");
assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`);
assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`);
assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`);
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const restored = await mapView.page.evaluate(() =>
!document.querySelector(".map-overlay-filters").classList.contains("is-hidden"));
assert.equal(restored, true, "Show Filters did not bring the filters back");
assert.deepEqual(mapView.runtimeErrors, []);
} finally {
await mapView.browser.close();
await mapFixture.close();
}