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