From db425156a4bb739b82af73fdaada0e660c36b78a Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 1 Mar 2026 17:19:00 +0100 Subject: [PATCH] [feat](trx-frontend-http): rich APRS map tooltips with distance and age Each station popup now shows: - Callsign/SSID header - Age (s/min/h ago, from _tsMs stamped on receive) - Distance from receiver (Haversine, km or m) - Packet type and via path - Full info/comment string Adds haversineKm(), formatTimeAgo(), buildAprsPopupHtml() helpers in app.js and .aprs-popup-* CSS. Passes full packet object as 7th arg to aprsMapAddStation from aprs.js. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 53 +++++++++++++++++-- .../assets/web/plugins/aprs.js | 5 +- .../trx-frontend-http/assets/web/style.css | 7 +++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index f6ad49a..f4e232d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -2491,14 +2491,61 @@ window.navigateToAprsMap = function(lat, lon) { } }; -window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode) { +function haversineKm(lat1, lon1, lat2, lon2) { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function formatTimeAgo(tsMs) { + if (!tsMs) return null; + const secs = Math.round((Date.now() - tsMs) / 1000); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins} min ago`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hrs}h ${remMins}min ago` : `${hrs}h ago`; +} + +function buildAprsPopupHtml(call, lat, lon, info, pkt) { + const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : (pkt?._ts || null); + const distKm = (serverLat != null && serverLon != null) + ? haversineKm(serverLat, serverLon, lat, lon) + : null; + const distStr = distKm != null + ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`) + : null; + const path = pkt?.path || null; + const type = pkt?.type || null; + + let meta = [age, distStr].filter(Boolean).join(" · "); + let rows = ""; + if (type) rows += `Type${escapeMapHtml(type)}`; + if (path) rows += `Path${escapeMapHtml(path)}`; + if (lat != null && lon != null) + rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; + + return `
` + + `
${escapeMapHtml(call)}
` + + (meta ? `
${meta}
` : "") + + (rows ? `${rows}
` : "") + + (info ? `
${escapeMapHtml(info)}
` : "") + + `
`; +} + +window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { if (!aprsMap) initAprsMap(); if (!aprsMap) return; - const popupContent = `${call}
${info}`; + const popupContent = buildAprsPopupHtml(call, lat, lon, info, pkt); const existing = stationMarkers.get(call); if (existing) { existing.marker.setLatLng([lat, lon]); existing.marker.setPopupContent(popupContent); + existing.pkt = pkt; } else { const icon = aprsSymbolIcon(symbolTable, symbolCode); const marker = icon @@ -2507,7 +2554,7 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8 }).addTo(aprsMap).bindPopup(popupContent); marker.__trxType = "aprs"; - stationMarkers.set(call, { marker, type: "aprs" }); + stationMarkers.set(call, { marker, type: "aprs", pkt }); mapMarkers.add(marker); applyMapFilter(); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js index 19a20ec..b232b78 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js @@ -139,6 +139,7 @@ function addAprsPacket(pkt) { // Stamp timestamp for persistence pkt._ts = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + pkt._tsMs = Date.now(); // Persist to history aprsPacketHistory.unshift(pkt); @@ -150,7 +151,7 @@ function addAprsPacket(pkt) { const row = renderAprsRow(pkt); if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { - window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode); + window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); } aprsPacketsEl.prepend(row); while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) { @@ -171,7 +172,7 @@ for (let i = aprsPacketHistory.length - 1; i >= 0; i--) { const pkt = aprsPacketHistory[i]; aprsPacketsEl.prepend(renderAprsRow(pkt)); if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { - window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode); + window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); } } updateAprsBar(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index eeb4572..a51a9a9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1072,6 +1072,13 @@ small { color: var(--text-muted); } .aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } .aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; } .aprs-pos:hover { text-decoration: underline; } +.aprs-popup { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; min-width: 12rem; max-width: 22rem; } +.aprs-popup-call { font-weight: 700; font-size: 1em; color: var(--accent-green); margin-bottom: 0.18rem; } +.aprs-popup-meta { font-size: 0.85em; color: var(--text-muted); margin-bottom: 0.3rem; } +.aprs-popup-table { border-collapse: collapse; width: 100%; margin-bottom: 0.3rem; } +.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; } .aprs-bar-pos { background: none; border: none; padding: 0; margin-left: 0.4em; font-family: inherit; font-size: inherit; color: var(--accent-green); cursor: pointer; } .aprs-bar-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; }