[feat](trx-frontend-http): add interactive Map tab with Leaflet

Add a Map sub-tab under Plugins that displays an interactive
OpenStreetMap via Leaflet.js showing:
- Receiver location (blue marker) from server config lat/lon
- APRS station positions (green markers) updated in real-time

The map lazy-initializes on first tab switch, handles tile rendering
on tab visibility changes, and deduplicates station markers by
callsign. Also includes the fallback snapshot lat/lon fields in the
API layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-08 21:44:55 +01:00
parent e4db312814
commit 80aadf54ab
5 changed files with 90 additions and 7 deletions
@@ -152,6 +152,8 @@ function setDisabled(disabled) {
let serverVersion = null;
let serverCallsign = null;
let serverLat = null;
let serverLon = null;
function updateTitle() {
let title = "trx-rs";
@@ -166,6 +168,8 @@ function render(update) {
if (update.info && update.info.model) rigName = update.info.model;
if (update.server_version) serverVersion = update.server_version;
if (update.server_callsign) serverCallsign = update.server_callsign;
if (update.server_latitude != null) serverLat = update.server_latitude;
if (update.server_longitude != null) serverLon = update.server_longitude;
updateTitle();
initialized = !!update.initialized;
@@ -637,6 +641,49 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
connect();
// --- Leaflet Map (lazy-initialized) ---
let aprsMap = null;
let aprsMapReceiverMarker = null;
const stationMarkers = new Map();
function initAprsMap() {
if (aprsMap) return;
const mapEl = document.getElementById("aprs-map");
if (!mapEl) return;
const hasLocation = serverLat != null && serverLon != null;
const center = hasLocation ? [serverLat, serverLon] : [20, 0];
const zoom = hasLocation ? 10 : 2;
aprsMap = L.map("aprs-map").setView(center, zoom);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(aprsMap);
if (hasLocation) {
const popupText = serverCallsign ? serverCallsign : "Receiver";
aprsMapReceiverMarker = L.circleMarker([serverLat, serverLon], {
radius: 8, color: "#3388ff", fillColor: "#3388ff", fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(popupText);
}
}
window.aprsMapAddStation = function(call, lat, lon, info) {
if (!aprsMap) initAprsMap();
if (!aprsMap) return;
const existing = stationMarkers.get(call);
if (existing) {
existing.setLatLng([lat, lon]);
existing.setPopupContent(`<b>${call}</b><br>${info}`);
} else {
const marker = L.circleMarker([lat, lon], {
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
}).addTo(aprsMap).bindPopup(`<b>${call}</b><br>${info}`);
stationMarkers.set(call, marker);
}
};
// --- Sub-tab navigation (Plugins tab) ---
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
bar.addEventListener("click", (e) => {
@@ -647,6 +694,10 @@ document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
const parent = bar.parentElement;
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
parent.querySelector(`#subtab-${btn.dataset.subtab}`).style.display = "";
if (btn.dataset.subtab === "map") {
initAprsMap();
if (aprsMap) setTimeout(() => aprsMap.invalidateSize(), 50);
}
});
});
@@ -6,6 +6,8 @@
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/dseg14-classic/400.css" />
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
<div class="card" id="card">
@@ -120,6 +122,7 @@
<div id="tab-plugins" class="tab-panel" style="display:none;">
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="overview">Overview</button>
<button class="sub-tab" data-subtab="map">Map</button>
<button class="sub-tab" data-subtab="aprs">APRS</button>
<button class="sub-tab" data-subtab="cw">CW</button>
</div>
@@ -137,6 +140,9 @@
</div>
</div>
</div>
<div id="subtab-map" class="sub-tab-panel" style="display:none;">
<div id="aprs-map"></div>
</div>
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
<div class="aprs-controls">
<button id="aprs-toggle-btn" type="button">Start APRS</button>
@@ -31,11 +31,12 @@ function crc16ccitt(bytes) {
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
// Uses mark/space correlation detector (non-coherent FSK matched filter).
function createDemodulator(sampleRate) {
function createDemodulator(sampleRate, windowFactor) {
const BAUD = 1200;
const MARK = 1200;
const SPACE = 2200;
const samplesPerBit = sampleRate / BAUD;
const corrFactor = windowFactor || 1.0;
// Debug counters
let dbgSamples = 0;
@@ -60,8 +61,8 @@ function createDemodulator(sampleRate) {
let markPhase = 0;
let spacePhase = 0;
// Sliding-window matched filter (1 bit period)
const corrLen = Math.round(samplesPerBit);
// Sliding-window matched filter
const corrLen = Math.max(2, Math.round(samplesPerBit * corrFactor));
const markIBuf = new Float32Array(corrLen);
const markQBuf = new Float32Array(corrLen);
const spaceIBuf = new Float32Array(corrLen);
@@ -453,6 +454,9 @@ function addAprsPacket(pkt) {
posHtml = ` <a class="aprs-pos" href="${osmUrl}" target="_blank">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`;
}
row.innerHTML = `<span class="aprs-time">${ts}</span>${symbolHtml}<span class="aprs-call">${pkt.srcCall}</span>&gt;${pkt.destCall}${pkt.path ? "," + pkt.path : ""}: <span title="${pkt.type}">${pkt.info}</span>${posHtml}${crcTag}`;
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info);
}
aprsPacketsEl.prepend(row);
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
@@ -471,7 +475,7 @@ function startAprs() {
aprsWs.binaryType = "arraybuffer";
aprsStatus.textContent = "Connecting…";
let demodulator = null;
let demodulators = null;
aprsWs.onopen = () => {
aprsStatus.textContent = "Waiting for stream info…";
@@ -484,7 +488,12 @@ function startAprs() {
const sr = info.sample_rate || 48000;
const ch = info.channels || 1;
aprsAudioCtx = new AudioContext({ sampleRate: sr });
demodulator = createDemodulator(sr);
// Multiple decoders with different correlation window lengths
// for robustness — different windows produce different error patterns
demodulators = [
createDemodulator(sr, 1.0),
createDemodulator(sr, 0.5),
];
let aprsFrameCount = 0;
aprsDecoder = new AudioDecoder({
@@ -504,8 +513,22 @@ function startAprs() {
mono[i] = buf[i * frame.numberOfChannels];
}
}
const frames = demodulator.processBuffer(mono);
for (const result of frames) {
// Run all decoders and merge results, preferring CRC-ok frames
const seen = new Set();
const allResults = [];
for (const demod of demodulators) {
for (const result of demod.processBuffer(mono)) {
const hex = Array.from(result.payload.subarray(0, Math.min(14, result.payload.length)))
.map(b => b.toString(16).padStart(2, "0")).join("");
const key = hex + ":" + result.payload.length;
if (seen.has(key)) continue;
seen.add(key);
allResults.push(result);
}
}
// Show CRC-ok frames first, then CRC-fail frames
allResults.sort((a, b) => (b.crcOk ? 1 : 0) - (a.crcOk ? 1 : 0));
for (const result of allResults) {
const ax25 = parseAX25(result.payload);
if (!ax25) continue;
const pkt = parseAPRS(ax25);
@@ -212,6 +212,7 @@ small { color: var(--text-muted); }
.sub-tab { background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.35rem 0.75rem; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; height: auto; }
.sub-tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.sub-tab:hover:not(.active) { color: var(--text); }
#aprs-map { height: 400px; border-radius: 6px; }
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
#aprs-packets { 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; }
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
@@ -365,6 +365,8 @@ async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot,
initialized: state.initialized,
server_callsign: state.server_callsign,
server_version: state.server_version,
server_latitude: state.server_latitude,
server_longitude: state.server_longitude,
})
}