[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
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>
This commit was merged in pull request #41.
This commit is contained in:
@@ -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();
|
||||||
@@ -1533,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);
|
||||||
@@ -2148,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()) {
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="map-overlay-panel">
|
<div class="map-overlay-panel">
|
||||||
<div class="map-overlay-filters">
|
<div class="map-overlay-filters">
|
||||||
<div class="map-locator-filter-group">
|
<div class="map-locator-filter-group">
|
||||||
<span class="map-locator-filter-label">Filter by</span>
|
<span class="map-locator-filter-label">Filter</span>
|
||||||
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
<div id="map-locator-phase" class="map-locator-phase-row"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="map-locator-filter-group">
|
<div class="map-locator-filter-group">
|
||||||
@@ -1016,10 +1016,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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 class="map-locator-filter-group">
|
<div class="map-locator-filter-group">
|
||||||
<span class="map-locator-filter-label">History</span>
|
<span class="map-locator-filter-label">History</span>
|
||||||
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
|
||||||
@@ -1035,11 +1031,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="map-locator-filter-group">
|
<div class="map-locator-filter-group">
|
||||||
<span class="map-locator-filter-label">Paths</span>
|
<span class="map-locator-filter-label">Paths</span>
|
||||||
<div class="map-locator-phase-row">
|
<div class="map-locator-phase-row">
|
||||||
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" title="TRX paths are drawn from a station popup">TRX Paths On</button>
|
<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>
|
||||||
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" title="Directed decode paths are drawn when the target locator is known">Contact Paths On</button>
|
<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>
|
||||||
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 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 class="map-overlay-actions">
|
<div class="map-overlay-actions">
|
||||||
<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>
|
||||||
|
|||||||
@@ -2570,7 +2570,10 @@ button.map-qso-card:focus-visible {
|
|||||||
z-index: 410;
|
z-index: 410;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row nowrap;
|
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;
|
gap: 0.5rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-height: calc(100% - 1.4rem);
|
max-height: calc(100% - 1.4rem);
|
||||||
@@ -2590,6 +2593,9 @@ button.map-qso-card:focus-visible {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
align-items: center;
|
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;
|
gap: 0.35rem 0.5rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -2603,6 +2609,9 @@ button.map-qso-card:focus-visible {
|
|||||||
.map-overlay-actions {
|
.map-overlay-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 0 auto;
|
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;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
@@ -2625,17 +2634,29 @@ button.map-qso-card:focus-visible {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
border-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 {
|
.map-overlay-panel .map-filter-grow {
|
||||||
flex: 1 1 9rem;
|
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 {
|
.map-overlay-panel .map-locator-filter-label {
|
||||||
min-width: 0;
|
min-width: 3.5rem;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
white-space: nowrap;
|
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 {
|
.map-overlay-panel .map-history-select {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -3471,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;
|
||||||
@@ -4196,9 +4234,23 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
border-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 {
|
.map-overlay-actions {
|
||||||
justify-content: flex-end;
|
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 {
|
.map-overlay-panel .map-paths-hint {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -230,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
|
||||||
@@ -1079,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 {
|
||||||
@@ -1234,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();
|
||||||
@@ -1863,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];
|
||||||
@@ -2584,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() {
|
||||||
|
|||||||
@@ -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: "" },
|
||||||
@@ -257,6 +257,24 @@ try {
|
|||||||
// Both controls belong to the bar now, not to a floating corner block.
|
// Both controls belong to the bar now, not to a floating corner block.
|
||||||
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
|
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
|
||||||
.map((button) => button.id).join(","),
|
.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,
|
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.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
|
||||||
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
|
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
|
||||||
`the bar carries "${bar.actionsInBar}" at ${width}px`);
|
`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.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`);
|
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
|
// 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.
|
// 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.locator("#map-overlay-toggle-btn").click();
|
||||||
|
|||||||
Reference in New Issue
Block a user