[fix](trx-frontend): refine locator map controls and tooltips

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-03 21:53:59 +01:00
parent 68b6bfa0b7
commit 80de405a03
5 changed files with 121 additions and 89 deletions
@@ -3170,7 +3170,7 @@ let aprsRadioPath = null;
const stationMarkers = new Map();
const locatorMarkers = new Map();
const mapMarkers = new Set();
const mapFilter = { ais: true, vdes: true, aprs: true, ft8: true, wspr: true };
const mapFilter = { ais: true, vdes: true, aprs: true, bookmark: true, ft8: true, wspr: true };
const APRS_TRACK_MAX_POINTS = 64;
const AIS_TRACK_MAX_POINTS = 64;
const aisMarkers = new Map();
@@ -3404,13 +3404,7 @@ function initAprsMap() {
if (!bounds) continue;
entry.marker = L.rectangle(bounds, locatorStyleForCount(entry.bookmarks?.length || 1, "bookmark"))
.addTo(aprsMap)
.bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || []))
.bindTooltip(buildBookmarkLocatorTooltipHtml(entry.grid, entry.bookmarks || []), {
className: "bookmark-locator-tip-shell",
direction: "top",
sticky: true,
opacity: 1,
});
.bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || []));
entry.marker.__trxType = "bookmark";
mapMarkers.add(entry.marker);
}
@@ -3419,6 +3413,7 @@ function initAprsMap() {
const aisFilter = document.getElementById("map-filter-ais");
const vdesFilter = document.getElementById("map-filter-vdes");
const aprsFilter = document.getElementById("map-filter-aprs");
const bookmarkFilter = document.getElementById("map-filter-bookmark");
const ft8Filter = document.getElementById("map-filter-ft8");
const wsprFilter = document.getElementById("map-filter-wspr");
if (aisFilter) {
@@ -3445,6 +3440,12 @@ function initAprsMap() {
applyMapFilter();
});
}
if (bookmarkFilter) {
bookmarkFilter.addEventListener("change", () => {
mapFilter.bookmark = bookmarkFilter.checked;
applyMapFilter();
});
}
if (ft8Filter) {
ft8Filter.addEventListener("change", () => {
mapFilter.ft8 = ft8Filter.checked;
@@ -3990,7 +3991,7 @@ function applyMapFilter() {
mapMarkers.forEach((marker) => {
const type = marker.__trxType;
const visible =
type === "bookmark" ||
(type === "bookmark" && mapFilter.bookmark) ||
(type === "ais" && mapFilter.ais) ||
(type === "vdes" && mapFilter.vdes) ||
(type === "aprs" && mapFilter.aprs) ||
@@ -4024,6 +4025,57 @@ function locatorStyleForCount(count, type) {
};
}
function formatDecodeLocatorTime(tsMs) {
if (!Number.isFinite(tsMs)) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function buildDecodeLocatorTooltipHtml(grid, entry, type) {
const details = entry?.stationDetails instanceof Map
? Array.from(entry.stationDetails.values())
: [];
details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0));
const title = type === "wspr" ? "WSPR" : "FT8";
const rows = details
.map((detail) => {
const station = escapeMapHtml(String(detail?.station || "Unknown"));
const freq = Number.isFinite(detail?.freq_hz)
? `${Number(detail.freq_hz).toFixed(0)} Hz`
: "--";
const meta = [
Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null,
Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null,
escapeMapHtml(freq),
].filter(Boolean).join(" · ");
const message = detail?.message
? `<div class="decode-locator-tip-note">${escapeMapHtml(String(detail.message))}</div>`
: "";
return `<div class="decode-locator-tip-row">` +
`<div class="decode-locator-tip-head">` +
`<span class="decode-locator-tip-name">${station}</span>` +
`<span class="decode-locator-tip-time">${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}</span>` +
`</div>` +
(meta ? `<div class="decode-locator-tip-meta">${meta}</div>` : "") +
message +
`</div>`;
})
.join("");
const count = Math.max(
1,
details.length,
entry?.stations instanceof Set ? entry.stations.size : 0,
);
return `<div class="decode-locator-tip">` +
`<div class="decode-locator-tip-title">${escapeMapHtml(grid)}</div>` +
`<div class="decode-locator-tip-subtitle">${title} · ${count} station${count === 1 ? "" : "s"}</div>` +
rows +
`</div>`;
}
function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
const list = Array.isArray(bookmarks) ? bookmarks : [];
const rows = list
@@ -4039,36 +4091,6 @@ function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
return `<b>${escapeMapHtml(grid)}</b><br>Bookmarks: ${list.length || 1}` + (rows ? `<br>${rows}` : "");
}
function buildBookmarkLocatorTooltipHtml(grid, bookmarks) {
const list = Array.isArray(bookmarks) ? [...bookmarks] : [];
list.sort((a, b) => Number(a?.freq_hz || 0) - Number(b?.freq_hz || 0));
const rows = list
.map((bm) => {
const name = escapeMapHtml(String(bm?.name || "Bookmark"));
const freq = typeof bmFmtFreq === "function"
? escapeMapHtml(bmFmtFreq(bm?.freq_hz))
: escapeMapHtml(String(bm?.freq_hz || "--"));
const meta = [
bm?.mode ? escapeMapHtml(String(bm.mode)) : null,
bm?.category ? escapeMapHtml(String(bm.category)) : null,
].filter(Boolean).join(" · ");
return `<div class="bookmark-locator-tip-row">` +
`<div class="bookmark-locator-tip-head">` +
`<span class="bookmark-locator-tip-name">${name}</span>` +
`<span class="bookmark-locator-tip-freq">${freq}</span>` +
`</div>` +
(meta ? `<div class="bookmark-locator-tip-meta">${meta}</div>` : "") +
(bm?.comment ? `<div class="bookmark-locator-tip-note">${escapeMapHtml(String(bm.comment))}</div>` : "") +
`</div>`;
})
.join("");
return `<div class="bookmark-locator-tip">` +
`<div class="bookmark-locator-tip-title">${escapeMapHtml(grid)}</div>` +
`<div class="bookmark-locator-tip-subtitle">${list.length} bookmark${list.length === 1 ? "" : "s"}</div>` +
rows +
`</div>`;
}
window.syncBookmarkMapLocators = function(bookmarks) {
const list = Array.isArray(bookmarks) ? bookmarks : [];
const grouped = new Map();
@@ -4108,7 +4130,6 @@ window.syncBookmarkMapLocators = function(bookmarks) {
existing.marker.setBounds(next.bounds);
existing.marker.setStyle(locatorStyleForCount(next.bookmarks.length, "bookmark"));
existing.marker.setPopupContent(popupHtml);
existing.marker.setTooltipContent(buildBookmarkLocatorTooltipHtml(next.grid, next.bookmarks));
}
continue;
}
@@ -4123,13 +4144,7 @@ window.syncBookmarkMapLocators = function(bookmarks) {
if (aprsMap) {
entry.marker = L.rectangle(next.bounds, locatorStyleForCount(next.bookmarks.length, "bookmark"))
.addTo(aprsMap)
.bindPopup(popupHtml)
.bindTooltip(buildBookmarkLocatorTooltipHtml(next.grid, next.bookmarks), {
className: "bookmark-locator-tip-shell",
direction: "top",
sticky: true,
opacity: 1,
});
.bindPopup(popupHtml);
entry.marker.__trxType = "bookmark";
mapMarkers.add(entry.marker);
}
@@ -4138,37 +4153,56 @@ window.syncBookmarkMapLocators = function(bookmarks) {
applyMapFilter();
};
window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null) {
window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, details = null) {
if (!aprsMap) initAprsMap();
if (!aprsMap) return;
if (!Array.isArray(grids) || grids.length === 0) return;
const markerType = type === "wspr" ? "wspr" : "ft8";
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
const locatorsLines = unique.map((g) => escapeMapHtml(g)).join("<br>");
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
const detailEntry = {
station: stationId || null,
ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null,
snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null,
dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null,
freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null,
message: String(details?.message || message || "").trim() || null,
};
const detailKey = stationId || `${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
for (const grid of unique) {
const bounds = maidenheadToBounds(grid);
if (!bounds) continue;
const key = `${markerType}:${grid}`;
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
const existing = locatorMarkers.get(key);
if (existing) {
if (stationId) existing.stations.add(stationId);
const count = existing.stations.size || 1;
if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map();
existing.stationDetails.set(detailKey, { ...detailEntry });
const count = Math.max(existing.stationDetails.size, existing.stations.size || 0, 1);
const tooltipHtml = buildDecodeLocatorTooltipHtml(grid, existing, markerType);
existing.marker.setStyle(locatorStyleForCount(count, markerType));
existing.marker.setPopupContent(
`<b>${escapeMapHtml(grid)}</b><br>Stations: ${count}<br>${locatorsLines}`
);
existing.marker.setPopupContent(tooltipHtml);
existing.marker.setTooltipContent(tooltipHtml);
continue;
}
const stations = new Set();
if (stationId) stations.add(stationId);
const count = stations.size || 1;
const stationDetails = new Map();
stationDetails.set(detailKey, { ...detailEntry });
const count = Math.max(stationDetails.size, stations.size || 0, 1);
const tooltipHtml = buildDecodeLocatorTooltipHtml(grid, { stations, stationDetails }, markerType);
const marker = L.rectangle(bounds, locatorStyleForCount(count, markerType))
.addTo(aprsMap)
.bindPopup(`<b>${escapeMapHtml(grid)}</b><br>Stations: ${count}<br>${locatorsLines}`);
.bindPopup(tooltipHtml)
.bindTooltip(tooltipHtml, {
className: "decode-locator-tip-shell",
direction: "top",
sticky: true,
opacity: 1,
});
marker.__trxType = markerType;
locatorMarkers.set(key, { marker, stations });
locatorMarkers.set(key, { marker, stations, stationDetails });
mapMarkers.add(marker);
}
applyMapFilter();
@@ -573,6 +573,7 @@
<label><input type="checkbox" id="map-filter-ais" checked /> AIS</label>
<label><input type="checkbox" id="map-filter-vdes" checked /> VDES</label>
<label><input type="checkbox" id="map-filter-aprs" checked /> APRS</label>
<label><input type="checkbox" id="map-filter-bookmark" checked /> Bookmark Locators</label>
<label><input type="checkbox" id="map-filter-ft8" checked /> FT8</label>
<label><input type="checkbox" id="map-filter-wspr" checked /> WSPR</label>
</div>
@@ -30,12 +30,10 @@ function renderFt8Row(msg) {
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.offsetHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
const freq = Number.isFinite(msg.freq_hz) ? msg.freq_hz.toFixed(0) : "--";
const renderedMessage = renderFt8Message(rawMessage);
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
applyFt8FilterToRow(row);
@@ -53,9 +51,8 @@ function addFt8Message(msg) {
}
function ft8BarRfText(msg) {
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
if (!Number.isFinite(msg.freq_hz) || !Number.isFinite(baseHz)) return null;
return `${(baseHz + msg.freq_hz).toFixed(0)} Hz`;
if (!Number.isFinite(msg.freq_hz)) return null;
return `${msg.freq_hz.toFixed(0)} Hz`;
}
function updateFt8Bar() {
@@ -175,10 +172,9 @@ function applyFt8FilterToAll() {
function updateFt8RowRf(row) {
const freqEl = row.querySelector(".ft8-freq");
if (!freqEl) return;
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
const offset = row.dataset.offsetHz ? Number(row.dataset.offsetHz) : NaN;
if (Number.isFinite(baseHz) && Number.isFinite(offset)) {
freqEl.textContent = (baseHz + offset).toFixed(0);
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
if (Number.isFinite(storedFreqHz)) {
freqEl.textContent = storedFreqHz.toFixed(0);
} else {
freqEl.textContent = "--";
}
@@ -224,7 +220,7 @@ window.onServerFt8 = function(msg) {
const grids = extractAllGrids(raw);
const station = extractLikelyCallsign(raw);
if (grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(raw, grids, "ft8", station);
window.ft8MapAddLocator(raw, grids, "ft8", station, msg);
}
addFt8Message({
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
@@ -125,7 +125,7 @@ window.onServerWspr = function(msg) {
const grids = extractAllGrids(raw);
const station = extractLikelyCallsign(raw);
if (grids.length > 0 && window.ft8MapAddLocator) {
window.ft8MapAddLocator(raw, grids, "wspr", station);
window.ft8MapAddLocator(raw, grids, "wspr", station, msg);
}
addWsprMessage({
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
@@ -1456,67 +1456,68 @@ small { color: var(--text-muted); }
.aprs-popup-table td { padding: 0.06rem 0.3rem 0.06rem 0; vertical-align: top; font-size: 0.88em; }
.aprs-popup-label { color: var(--text-muted); white-space: nowrap; padding-right: 0.5rem !important; }
.aprs-popup-info { font-size: 0.85em; color: var(--text); border-top: 1px solid var(--border-light); padding-top: 0.25rem; margin-top: 0.1rem; word-break: break-word; }
.bookmark-locator-tip-shell {
.decode-locator-tip-shell {
background: transparent;
border: none;
box-shadow: none;
}
.bookmark-locator-tip-shell .leaflet-tooltip-content {
.decode-locator-tip-shell .leaflet-tooltip-content {
margin: 0;
}
.bookmark-locator-tip-shell.leaflet-tooltip-top::before {
.decode-locator-tip-shell.leaflet-tooltip-top::before {
border-top-color: color-mix(in srgb, var(--card-bg) 94%, transparent);
}
.bookmark-locator-tip {
min-width: 15rem;
max-width: 24rem;
.decode-locator-tip {
min-width: 16rem;
max-width: 26rem;
max-height: min(22rem, 60vh);
overflow: auto;
padding: 0.55rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--accent-green) 26%, var(--border-light));
border: 1px solid color-mix(in srgb, var(--accent-yellow) 26%, var(--border-light));
border-radius: 0.65rem;
background: color-mix(in srgb, var(--card-bg) 94%, transparent);
color: var(--text);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
.bookmark-locator-tip-title {
color: var(--accent-green);
.decode-locator-tip-title {
color: var(--accent-yellow);
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.03em;
}
.bookmark-locator-tip-subtitle {
.decode-locator-tip-subtitle {
margin-top: 0.1rem;
margin-bottom: 0.45rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.bookmark-locator-tip-row + .bookmark-locator-tip-row {
.decode-locator-tip-row + .decode-locator-tip-row {
margin-top: 0.45rem;
padding-top: 0.4rem;
border-top: 1px solid color-mix(in srgb, var(--border-light) 70%, transparent);
}
.bookmark-locator-tip-head {
.decode-locator-tip-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.bookmark-locator-tip-name {
.decode-locator-tip-name {
font-weight: 600;
color: var(--text-heading);
}
.bookmark-locator-tip-freq {
.decode-locator-tip-time {
flex: 0 0 auto;
font-size: 0.78rem;
color: var(--accent-yellow);
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
.bookmark-locator-tip-meta {
.decode-locator-tip-meta {
margin-top: 0.12rem;
font-size: 0.74rem;
color: var(--text-muted);
}
.bookmark-locator-tip-note {
.decode-locator-tip-note {
margin-top: 0.2rem;
font-size: 0.76rem;
line-height: 1.3;