Rework the map's filter bar #41
@@ -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