[feat](trx-frontend-http): improve locator map plotting and themed filters

Refine map plotting and filter UX in HTTP frontend plugins.\n\n- support plotting multiple locator squares from FT8/WSPR messages\n- show locator lists in popup content as newline-separated entries\n- add WSPR map layer filter toggle and marker typing\n- style filter controls for strong dark/light mode contrast\n- keep themed behavior aligned with map and control updates\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-13 02:11:31 +01:00
parent f637cf23d3
commit 4f35be539f
5 changed files with 95 additions and 22 deletions
@@ -990,7 +990,7 @@ let aprsMapBaseLayer = null;
let aprsMapReceiverMarker = null; let aprsMapReceiverMarker = null;
const stationMarkers = new Map(); const stationMarkers = new Map();
const mapMarkers = new Set(); const mapMarkers = new Set();
const mapFilter = { aprs: true, ft8: true }; const mapFilter = { aprs: true, ft8: true, wspr: true };
function mapTileSpecForTheme(theme) { function mapTileSpecForTheme(theme) {
if (theme === "dark") { if (theme === "dark") {
@@ -1044,6 +1044,7 @@ function initAprsMap() {
const aprsFilter = document.getElementById("map-filter-aprs"); const aprsFilter = document.getElementById("map-filter-aprs");
const ft8Filter = document.getElementById("map-filter-ft8"); const ft8Filter = document.getElementById("map-filter-ft8");
const wsprFilter = document.getElementById("map-filter-wspr");
if (aprsFilter) { if (aprsFilter) {
aprsFilter.addEventListener("change", () => { aprsFilter.addEventListener("change", () => {
mapFilter.aprs = aprsFilter.checked; mapFilter.aprs = aprsFilter.checked;
@@ -1056,6 +1057,12 @@ function initAprsMap() {
applyMapFilter(); applyMapFilter();
}); });
} }
if (wsprFilter) {
wsprFilter.addEventListener("change", () => {
mapFilter.wspr = wsprFilter.checked;
applyMapFilter();
});
}
} }
function sizeAprsMapToViewport() { function sizeAprsMapToViewport() {
@@ -1141,27 +1148,43 @@ function applyMapFilter() {
if (!aprsMap) return; if (!aprsMap) return;
mapMarkers.forEach((marker) => { mapMarkers.forEach((marker) => {
const type = marker.__trxType; const type = marker.__trxType;
const visible = (type === "aprs" && mapFilter.aprs) || (type === "ft8" && mapFilter.ft8); const visible =
(type === "aprs" && mapFilter.aprs) ||
(type === "ft8" && mapFilter.ft8) ||
(type === "wspr" && mapFilter.wspr);
const onMap = aprsMap.hasLayer(marker); const onMap = aprsMap.hasLayer(marker);
if (visible && !onMap) marker.addTo(aprsMap); if (visible && !onMap) marker.addTo(aprsMap);
if (!visible && onMap) marker.removeFrom(aprsMap); if (!visible && onMap) marker.removeFrom(aprsMap);
}); });
} }
window.ft8MapAddLocator = function(message, grid) { function escapeMapHtml(input) {
return String(input)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
window.ft8MapAddLocator = function(message, grids, type = "ft8") {
if (!aprsMap) initAprsMap(); if (!aprsMap) initAprsMap();
if (!aprsMap) return; if (!aprsMap) return;
if (!Array.isArray(grids) || grids.length === 0) return;
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
const locatorsLines = unique.map((g) => escapeMapHtml(g)).join("<br>");
for (const grid of unique) {
const bounds = maidenheadToBounds(grid); const bounds = maidenheadToBounds(grid);
if (!bounds) return; if (!bounds) continue;
const popupContent = `<b>${grid}</b><br>${message}`; const popupContent = `<b>${escapeMapHtml(grid)}</b><br>${locatorsLines}`;
const marker = L.rectangle(bounds, { const marker = L.rectangle(bounds, {
color: "#ffb020", color: "#ffb020",
weight: 1, weight: 1,
fillColor: "#ffb020", fillColor: "#ffb020",
fillOpacity: 0.25, fillOpacity: 0.25,
}).addTo(aprsMap).bindPopup(popupContent); }).addTo(aprsMap).bindPopup(popupContent);
marker.__trxType = "ft8"; marker.__trxType = type === "wspr" ? "wspr" : "ft8";
mapMarkers.add(marker); mapMarkers.add(marker);
}
applyMapFilter(); applyMapFilter();
}; };
@@ -165,6 +165,7 @@
<div class="map-controls"> <div class="map-controls">
<label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label> <label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label>
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label> <label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
</div> </div>
<div id="aprs-map"></div> <div id="aprs-map"></div>
</div> </div>
@@ -70,7 +70,9 @@ function renderFt8Message(message) {
return out; return out;
} }
function extractFirstGrid(message) { function extractAllGrids(message) {
const out = [];
const seen = new Set();
let i = 0; let i = 0;
while (i < message.length) { while (i < message.length) {
if (isAlphaNum(message[i])) { if (isAlphaNum(message[i])) {
@@ -78,13 +80,16 @@ function extractFirstGrid(message) {
while (j < message.length && isAlphaNum(message[j])) j++; while (j < message.length && isAlphaNum(message[j])) j++;
const token = message.slice(i, j); const token = message.slice(i, j);
const grid = token.toUpperCase(); const grid = token.toUpperCase();
if (/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(grid)) return grid; if (/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(grid) && !seen.has(grid)) {
seen.add(grid);
out.push(grid);
}
i = j; i = j;
} else { } else {
i += 1; i += 1;
} }
} }
return null; return out;
} }
function escapeHtml(input) { function escapeHtml(input) {
@@ -149,9 +154,9 @@ document.getElementById("ft8-clear-btn").addEventListener("click", async () => {
// --- Server-side FT8 decode handler --- // --- Server-side FT8 decode handler ---
window.onServerFt8 = function(msg) { window.onServerFt8 = function(msg) {
ft8Status.textContent = "Receiving"; ft8Status.textContent = "Receiving";
const grid = extractFirstGrid(msg.message || ""); const grids = extractAllGrids((msg.message || "").toString());
if (grid && window.ft8MapAddLocator) { if (grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(msg.message, grid); window.ft8MapAddLocator(msg.message || "", grids);
} }
addFt8Message({ addFt8Message({
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
@@ -54,6 +54,20 @@ function escapeWsprHtml(input) {
.replaceAll("\"", "&quot;"); .replaceAll("\"", "&quot;");
} }
function extractAllGrids(message) {
const out = [];
const seen = new Set();
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
for (const token of parts) {
if (!token) continue;
if (/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(token) && !seen.has(token)) {
seen.add(token);
out.push(token);
}
}
return out;
}
function applyWsprFilterToRow(row) { function applyWsprFilterToRow(row) {
if (!wsprFilterText) { if (!wsprFilterText) {
row.style.display = ""; row.style.display = "";
@@ -86,11 +100,16 @@ document.getElementById("wspr-clear-btn").addEventListener("click", async () =>
window.onServerWspr = function(msg) { window.onServerWspr = function(msg) {
wsprStatus.textContent = "Receiving"; wsprStatus.textContent = "Receiving";
const raw = (msg.message || "").toString();
const grids = extractAllGrids(raw);
if (grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(raw, grids, "wspr");
}
addWsprMessage({ addWsprMessage({
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
dt_s: msg.dt_s, dt_s: msg.dt_s,
freq_hz: msg.freq_hz, freq_hz: msg.freq_hz,
message: msg.message, message: raw,
}); });
}; };
@@ -20,6 +20,9 @@
--audio-level-border: #2d3748; --audio-level-border: #2d3748;
--audio-level-fill-start: #00d17f; --audio-level-fill-start: #00d17f;
--audio-level-fill-end: #f0ad4e; --audio-level-fill-end: #f0ad4e;
--filter-bg: #1b2431;
--filter-fg: #e5e7eb;
--filter-border: #334155;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -44,6 +47,9 @@
--audio-level-border: #b8c5da; --audio-level-border: #b8c5da;
--audio-level-fill-start: #0f9d61; --audio-level-fill-start: #0f9d61;
--audio-level-fill-end: #b57600; --audio-level-fill-end: #b57600;
--filter-bg: #eef3fb;
--filter-fg: #1f2937;
--filter-border: #b8c5da;
} }
body { font-family: sans-serif; margin: 0; min-height: 100vh; box-sizing: border-box; display: flex; align-items: flex-start; justify-content: center; padding-top: 2em; background: var(--bg); color: var(--text); } body { font-family: sans-serif; margin: 0; min-height: 100vh; box-sizing: border-box; display: flex; align-items: flex-start; justify-content: center; padding-top: 2em; background: var(--bg); color: var(--text); }
@@ -338,7 +344,16 @@ small { color: var(--text-muted); }
.aprs-pos:hover { text-decoration: underline; } .aprs-pos:hover { text-decoration: underline; }
.aprs-byte { color: var(--accent-yellow); background: rgba(255, 214, 0, 0.12); border: 1px solid rgba(255, 214, 0, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-size: 0.78em; } .aprs-byte { color: var(--accent-yellow); background: rgba(255, 214, 0, 0.12); border: 1px solid rgba(255, 214, 0, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-size: 0.78em; }
.ft8-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } .ft8-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
.ft8-filter { flex: 1; min-width: 10rem; } .ft8-filter {
flex: 1;
min-width: 10rem;
background: var(--filter-bg);
color: var(--filter-fg);
border: 1px solid var(--filter-border);
border-radius: 6px;
padding: 0.45rem 0.55rem;
}
.ft8-filter::placeholder { color: color-mix(in srgb, var(--filter-fg) 55%, transparent); }
.ft8-header { display: flex; gap: 0.6rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); border-bottom: 1px solid var(--border); padding: 0 0 0.35rem 0; margin-bottom: 0.35rem; } .ft8-header { display: flex; gap: 0.6rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); border-bottom: 1px solid var(--border); padding: 0 0 0.35rem 0; margin-bottom: 0.35rem; }
#ft8-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; } #ft8-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85rem; padding: 0.35rem 0.5rem; }
.ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; } .ft8-row { display: flex; gap: 0.6rem; line-height: 1.4; border-bottom: 1px solid var(--border); padding: 0.25rem 0; }
@@ -350,6 +365,16 @@ small { color: var(--text-muted); }
.ft8-msg { flex: 1; } .ft8-msg { flex: 1; }
.ft8-locator { color: var(--accent-green); background: rgba(0, 209, 127, 0.12); border: 1px solid rgba(0, 209, 127, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-weight: 600; } .ft8-locator { color: var(--accent-green); background: rgba(0, 209, 127, 0.12); border: 1px solid rgba(0, 209, 127, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-weight: 600; }
.map-controls { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.6rem; color: var(--text-muted); font-size: 0.82rem; } .map-controls { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.6rem; color: var(--text-muted); font-size: 0.82rem; }
.map-controls label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--filter-border);
background: var(--filter-bg);
color: var(--filter-fg);
}
.map-controls input[type="checkbox"] { margin-right: 0.3rem; } .map-controls input[type="checkbox"] { margin-right: 0.3rem; }
.cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; } .cw-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }