[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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-01 17:19:00 +01:00
parent dd051beee3
commit db425156a4
3 changed files with 60 additions and 5 deletions
@@ -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(" &middot; ");
let rows = "";
if (type) rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(type)}</td></tr>`;
if (path) rows += `<tr><td class="aprs-popup-label">Path</td><td>${escapeMapHtml(path)}</td></tr>`;
if (lat != null && lon != null)
rows += `<tr><td class="aprs-popup-label">Pos</td><td>${lat.toFixed(5)}, ${lon.toFixed(5)}</td></tr>`;
return `<div class="aprs-popup">` +
`<div class="aprs-popup-call">${escapeMapHtml(call)}</div>` +
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
(info ? `<div class="aprs-popup-info">${escapeMapHtml(info)}</div>` : "") +
`</div>`;
}
window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
if (!aprsMap) initAprsMap();
if (!aprsMap) return;
const popupContent = `<b>${call}</b><br>${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();
}
@@ -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();
@@ -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; }