diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index c5c3820b..836cf6a3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -62,6 +62,7 @@ var mapWindow = window; 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 mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; + const MAP_FILTER_ALL_KEY = "__all"; const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; let mapSearchFilter = ""; let mapRigFilter = ""; @@ -838,38 +839,36 @@ var mapWindow = window; 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) : []; - const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); - if (kind === "source") { - if (noneSelected) { - helperText = "All sources visible — click to filter"; - } - } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { - helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; - } + const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0; + 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] : !!selectedSet?.has(item.key); - if (kind === "source" && noneSelected) { + 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, phase) { if (!container) return; @@ -977,11 +976,10 @@ var mapWindow = window; renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); if (!phaseEl || !choiceEl || !choiceLabelEl) return; renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); + 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(); @@ -1533,7 +1531,13 @@ var mapWindow = window; 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) { + 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; mapFilter[sourceKey] = !mapFilter[sourceKey]; const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); @@ -2148,14 +2152,16 @@ var mapWindow = window; 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() { if (C.decodeHistoryMapRenderingDeferred()) { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 3f638c0e..de45e33f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -1003,7 +1003,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
- Filter by + Filter
@@ -1016,10 +1016,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
- Search - -
History +
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();