[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 serverVersion = null;
|
||||||
let serverCallsign = null;
|
let serverCallsign = null;
|
||||||
|
let serverLat = null;
|
||||||
|
let serverLon = null;
|
||||||
|
|
||||||
function updateTitle() {
|
function updateTitle() {
|
||||||
let title = "trx-rs";
|
let title = "trx-rs";
|
||||||
@@ -166,6 +168,8 @@ function render(update) {
|
|||||||
if (update.info && update.info.model) rigName = update.info.model;
|
if (update.info && update.info.model) rigName = update.info.model;
|
||||||
if (update.server_version) serverVersion = update.server_version;
|
if (update.server_version) serverVersion = update.server_version;
|
||||||
if (update.server_callsign) serverCallsign = update.server_callsign;
|
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();
|
updateTitle();
|
||||||
|
|
||||||
initialized = !!update.initialized;
|
initialized = !!update.initialized;
|
||||||
@@ -637,6 +641,49 @@ document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
|||||||
|
|
||||||
connect();
|
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) ---
|
// --- Sub-tab navigation (Plugins tab) ---
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
||||||
bar.addEventListener("click", (e) => {
|
bar.addEventListener("click", (e) => {
|
||||||
@@ -647,6 +694,10 @@ document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
|||||||
const parent = bar.parentElement;
|
const parent = bar.parentElement;
|
||||||
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
||||||
parent.querySelector(`#subtab-${btn.dataset.subtab}`).style.display = "";
|
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="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/dseg14-classic/400.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/dseg14-classic/400.css" />
|
||||||
<link rel="stylesheet" href="/style.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card" id="card">
|
<div class="card" id="card">
|
||||||
@@ -120,6 +122,7 @@
|
|||||||
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
<div id="tab-plugins" class="tab-panel" style="display:none;">
|
||||||
<div class="sub-tab-bar">
|
<div class="sub-tab-bar">
|
||||||
<button class="sub-tab active" data-subtab="overview">Overview</button>
|
<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="aprs">APRS</button>
|
||||||
<button class="sub-tab" data-subtab="cw">CW</button>
|
<button class="sub-tab" data-subtab="cw">CW</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +140,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="aprs-controls">
|
<div class="aprs-controls">
|
||||||
<button id="aprs-toggle-btn" type="button">Start APRS</button>
|
<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)
|
// AFSK Bell 202 Demodulator (1200 baud, mark=1200Hz, space=2200Hz)
|
||||||
// Uses mark/space correlation detector (non-coherent FSK matched filter).
|
// Uses mark/space correlation detector (non-coherent FSK matched filter).
|
||||||
function createDemodulator(sampleRate) {
|
function createDemodulator(sampleRate, windowFactor) {
|
||||||
const BAUD = 1200;
|
const BAUD = 1200;
|
||||||
const MARK = 1200;
|
const MARK = 1200;
|
||||||
const SPACE = 2200;
|
const SPACE = 2200;
|
||||||
const samplesPerBit = sampleRate / BAUD;
|
const samplesPerBit = sampleRate / BAUD;
|
||||||
|
const corrFactor = windowFactor || 1.0;
|
||||||
|
|
||||||
// Debug counters
|
// Debug counters
|
||||||
let dbgSamples = 0;
|
let dbgSamples = 0;
|
||||||
@@ -60,8 +61,8 @@ function createDemodulator(sampleRate) {
|
|||||||
let markPhase = 0;
|
let markPhase = 0;
|
||||||
let spacePhase = 0;
|
let spacePhase = 0;
|
||||||
|
|
||||||
// Sliding-window matched filter (1 bit period)
|
// Sliding-window matched filter
|
||||||
const corrLen = Math.round(samplesPerBit);
|
const corrLen = Math.max(2, Math.round(samplesPerBit * corrFactor));
|
||||||
const markIBuf = new Float32Array(corrLen);
|
const markIBuf = new Float32Array(corrLen);
|
||||||
const markQBuf = new Float32Array(corrLen);
|
const markQBuf = new Float32Array(corrLen);
|
||||||
const spaceIBuf = 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>`;
|
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}`;
|
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);
|
aprsPacketsEl.prepend(row);
|
||||||
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
while (aprsPacketsEl.children.length > APRS_MAX_PACKETS) {
|
||||||
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
aprsPacketsEl.removeChild(aprsPacketsEl.lastChild);
|
||||||
@@ -471,7 +475,7 @@ function startAprs() {
|
|||||||
aprsWs.binaryType = "arraybuffer";
|
aprsWs.binaryType = "arraybuffer";
|
||||||
aprsStatus.textContent = "Connecting…";
|
aprsStatus.textContent = "Connecting…";
|
||||||
|
|
||||||
let demodulator = null;
|
let demodulators = null;
|
||||||
|
|
||||||
aprsWs.onopen = () => {
|
aprsWs.onopen = () => {
|
||||||
aprsStatus.textContent = "Waiting for stream info…";
|
aprsStatus.textContent = "Waiting for stream info…";
|
||||||
@@ -484,7 +488,12 @@ function startAprs() {
|
|||||||
const sr = info.sample_rate || 48000;
|
const sr = info.sample_rate || 48000;
|
||||||
const ch = info.channels || 1;
|
const ch = info.channels || 1;
|
||||||
aprsAudioCtx = new AudioContext({ sampleRate: sr });
|
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;
|
let aprsFrameCount = 0;
|
||||||
aprsDecoder = new AudioDecoder({
|
aprsDecoder = new AudioDecoder({
|
||||||
@@ -504,8 +513,22 @@ function startAprs() {
|
|||||||
mono[i] = buf[i * frame.numberOfChannels];
|
mono[i] = buf[i * frame.numberOfChannels];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const frames = demodulator.processBuffer(mono);
|
// Run all decoders and merge results, preferring CRC-ok frames
|
||||||
for (const result of 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);
|
const ax25 = parseAX25(result.payload);
|
||||||
if (!ax25) continue;
|
if (!ax25) continue;
|
||||||
const pkt = parseAPRS(ax25);
|
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 { 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.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
|
||||||
.sub-tab:hover:not(.active) { color: var(--text); }
|
.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-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-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; }
|
.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,
|
initialized: state.initialized,
|
||||||
server_callsign: state.server_callsign,
|
server_callsign: state.server_callsign,
|
||||||
server_version: state.server_version,
|
server_version: state.server_version,
|
||||||
|
server_latitude: state.server_latitude,
|
||||||
|
server_longitude: state.server_longitude,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user