[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:
@@ -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: '© <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>>${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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user