diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 1b941d4d..46d67167 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -2570,7 +2570,10 @@ button.map-qso-card:focus-visible {
z-index: 410;
display: flex;
flex-flow: row nowrap;
- align-items: center;
+ /* Stretched, not centred: the rule dividing the filters from the buttons is
+ 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);
@@ -2590,6 +2593,9 @@ button.map-qso-card:focus-visible {
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;
}
@@ -2603,6 +2609,9 @@ button.map-qso-card:focus-visible {
.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;
}
@@ -2625,17 +2634,29 @@ button.map-qso-card:focus-visible {
padding-right: 0;
border-right: 0;
}
-/* The search field takes whatever room the fixed-width groups leave. */
+/* 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: 0;
+ 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;
@@ -3471,6 +3492,23 @@ body.map-fake-fullscreen-active {
border-color: color-mix(in srgb, var(--border-light) 68%, 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 {
font-size: 0.78rem;
font-weight: 600;
@@ -4196,9 +4234,23 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
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;
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts
index 221c859f..df12c147 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts
@@ -230,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
const mapMarkers = new Set();
const DEFAULT_MAP_SOURCE_FILTER: Record = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record = { ...DEFAULT_MAP_SOURCE_FILTER };
+ /** Chip key that clears a selection rather than naming a band or a source. */
+ const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter: { phase: "band" | "type"; bands: Set } = { phase: "band", bands: new Set() };
let mapSearchFilter = "";
let mapRigFilter = ""; // "" = all rigs
@@ -1079,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`;
return;
}
- let helperText = "";
+ const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
- const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
- if (kind === "source") {
- if (noneSelected) {
- helperText = "All sources visible \u2014 click to filter";
- }
- } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
- helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
- }
+ // Selecting nothing selects everything, for both kinds.
+ const showingAll = kind === "source"
+ ? sourceKeys.every((k) => !mapFilter[k])
+ : !(selectedSet instanceof Set) || selectedSet.size === 0;
+ // An "All" chip carries what a sentence of helper text used to say, in a
+ // width the bar can afford, and gives the selection somewhere to be undone.
+ 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 = `All`;
+ container.appendChild(allChip);
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
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");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
+ btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `${escapeMapHtml(item.label)}`;
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 {
@@ -1234,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
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") {
- choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
- choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1863,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
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
const sourceKey = key as MapFilterKey;
mapFilter[sourceKey] = !mapFilter[sourceKey];
@@ -2584,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
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() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
- btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
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() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
- btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
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() {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs
index b7bfed91..25c5919e 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
-/* global document, getComputedStyle */
+/* global document, getComputedStyle, window */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
@@ -257,6 +257,24 @@ try {
// 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,
};
});
@@ -266,10 +284,66 @@ try {
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();