From 4cca188d9f2c95c48856428e00debd424d933f5b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 13 Mar 2026 00:45:12 +0100 Subject: [PATCH] [feat](trx-frontend-http): improve scheduler and decode map controls Remove settings rig pickers, restore the last scheduler cycle on release, fix FT8 locator role parsing, and add toggleable decode contact paths on the map. Co-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 224 ++++++++++++++++-- .../trx-frontend-http/assets/web/index.html | 13 +- .../assets/web/plugins/background-decode.js | 43 +--- .../assets/web/plugins/ft8.js | 104 +++++--- .../assets/web/plugins/scheduler.js | 44 +--- .../assets/web/plugins/vchan.js | 3 +- .../trx-frontend-http/assets/web/style.css | 28 +++ .../trx-frontend-http/src/scheduler.rs | 92 ++++++- 8 files changed, 425 insertions(+), 126 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 8927d5e..f451299 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 @@ -740,8 +740,8 @@ function applyRigList(activeRigId, rigIds, displayNames) { const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; populateRigPicker(headerRigSwitchSelect, lastRigIds, activeRigId, disableSwitch); updateRigSubtitle(activeRigId); - if (typeof reloadSchedulerRigSelect === "function") reloadSchedulerRigSelect(); - if (typeof reloadBackgroundDecodeRigSelect === "function") reloadBackgroundDecodeRigSelect(); + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); } async function refreshRigList() { @@ -3746,8 +3746,10 @@ let aprsRadioPath = null; let selectedLocatorMarker = null; let selectedLocatorPulseRaf = null; let mapFullscreenListenerBound = false; +let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; const stationMarkers = new Map(); const locatorMarkers = new Map(); +const decodeContactPaths = new Map(); const mapMarkers = new Set(); const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, wspr: true }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; @@ -4068,6 +4070,103 @@ function clearMapRadioPath() { } } +function clearDecodeContactPathRender(entry) { + if (!entry) return; + if (entry.line) { + entry.line.remove(); + entry.line = null; + } + if (entry.labelMarker) { + entry.labelMarker.remove(); + entry.labelMarker = null; + } +} + +function clearDecodeContactPaths() { + for (const entry of decodeContactPaths.values()) { + clearDecodeContactPathRender(entry); + } + decodeContactPaths.clear(); +} + +function formatDecodeContactDistance(distanceKm) { + const text = formatDistanceKm(distanceKm); + return text || "--"; +} + +function decodeLocatorPathVisibility(grid) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!normalizedGrid || !aprsMap) return false; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.grid !== normalizedGrid) continue; + if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; + if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; + } + return false; +} + +function midpointLatLon(a, b) { + if (!a || !b) return null; + if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { + return null; + } + return { + lat: (a.lat + b.lat) / 2, + lon: (a.lon + b.lon) / 2, + }; +} + +function ensureDecodeContactPathRendered(entry) { + if (!entry || !aprsMap) return; + const linePoints = [ + [entry.from.lat, entry.from.lon], + [entry.to.lat, entry.to.lon], + ]; + if (!entry.line) { + entry.line = L.polyline(linePoints, { + className: "decode-contact-path", + weight: 2.8, + interactive: false, + }).addTo(aprsMap); + } else { + entry.line.setLatLngs(linePoints); + if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); + } + const mid = midpointLatLon(entry.from, entry.to); + if (!mid) return; + const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; + const icon = L.divIcon({ + className: "decode-contact-distance-label", + html: `${escapeMapHtml(entry.distanceText)}`, + }); + if (!entry.labelMarker) { + entry.labelMarker = L.marker([mid.lat, mid.lon], { + icon, + interactive: false, + keyboard: false, + zIndexOffset: 900, + }).addTo(aprsMap); + } else { + entry.labelMarker.setLatLng([mid.lat, mid.lon]); + entry.labelMarker.setIcon(icon); + if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); + } + if (typeof entry.line.bringToBack === "function") entry.line.bringToBack(); +} + +function syncDecodeContactPathVisibility() { + for (const entry of decodeContactPaths.values()) { + const visible = mapDecodeContactPathsEnabled + && decodeLocatorPathVisibility(entry.sourceGrid) + && decodeLocatorPathVisibility(entry.targetGrid); + if (!visible) { + clearDecodeContactPathRender(entry); + continue; + } + ensureDecodeContactPathRendered(entry); + } +} + function setMapRadioPathTo(lat, lon, className = "aprs-radio-path") { clearMapRadioPath(); if (serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { @@ -4268,6 +4367,7 @@ function rebuildMapLocatorFilters() { renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); } syncLocatorMarkerStyles(); + syncDecodeContactPathVisibility(); } function markerPassesLocatorFilters(marker) { @@ -4447,6 +4547,7 @@ window.clearMapMarkersByType = function(type) { locatorMarkers.delete(key); } rebuildMapLocatorFilters(); + rebuildDecodeContactPaths(); } if (type === "bookmark") { @@ -4692,12 +4793,14 @@ function initAprsMap() { assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); mapMarkers.add(entry.marker); } + rebuildDecodeContactPaths(); rebuildMapLocatorFilters(); applyMapFilter(); const locatorPhaseEl = document.getElementById("map-locator-phase"); const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); const mapSearchEl = document.getElementById("map-search-filter"); + const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); const fullscreenBtn = document.getElementById("map-fullscreen-btn"); if (locatorPhaseEl) { locatorPhaseEl.addEventListener("click", (e) => { @@ -4752,6 +4855,15 @@ function initAprsMap() { applyMapFilter(); }); } + if (mapContactPathsToggleEl) { + updateMapContactPathsToggle(); + mapContactPathsToggleEl.addEventListener("click", () => { + mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; + saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); + updateMapContactPathsToggle(); + syncDecodeContactPathVisibility(); + }); + } if (fullscreenBtn) { fullscreenBtn.addEventListener("click", () => { toggleMapFullscreen(); @@ -5425,6 +5537,14 @@ function applyMapFilter() { if (!visible && onMap) marker.removeFrom(aprsMap); }); syncSelectedAisTrackVisibility(); + syncDecodeContactPathVisibility(); +} + +function updateMapContactPathsToggle() { + const btn = document.getElementById("map-contact-paths-toggle"); + if (!btn) return; + btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; + btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); } function escapeMapHtml(input) { @@ -5461,9 +5581,10 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) { const title = type === "wspr" ? "WSPR" : "FT8"; const rows = details .map((detail) => { - const station = escapeMapHtml(String(detail?.station || "Unknown")); + const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); const freq = formatMapPopupFreq(Number(detail?.freq_hz)); const meta = [ + detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, escapeMapHtml(freq), @@ -5493,6 +5614,62 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) { ``; } +function rebuildDecodeContactPaths() { + clearDecodeContactPaths(); + const stationLocators = new Map(); + const directedMessages = []; + for (const entry of locatorMarkers.values()) { + if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "wspr")) continue; + const grid = String(entry.grid || "").trim().toUpperCase(); + if (!grid || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + const target = String(detail?.target || "").trim().toUpperCase(); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + if (source) { + const prev = stationLocators.get(source); + if (!prev || tsMs >= prev.tsMs) { + stationLocators.set(source, { grid, tsMs }); + } + } + if (source && target && source !== target) { + directedMessages.push({ + source, + target, + sourceGrid: grid, + tsMs, + }); + } + } + } + for (const msg of directedMessages) { + const targetLocator = stationLocators.get(msg.target); + if (!targetLocator) continue; + if (msg.sourceGrid === targetLocator.grid) continue; + const sourceCenter = locatorToLatLon(msg.sourceGrid); + const targetCenter = locatorToLatLon(targetLocator.grid); + if (!sourceCenter || !targetCenter) continue; + const key = [msg.source, msg.target].sort().join("::"); + const prev = decodeContactPaths.get(key); + if (prev && prev.tsMs > msg.tsMs) continue; + decodeContactPaths.set(key, { + source: msg.source, + target: msg.target, + sourceGrid: msg.sourceGrid, + targetGrid: targetLocator.grid, + from: sourceCenter, + to: targetCenter, + tsMs: msg.tsMs, + distanceText: formatDecodeContactDistance( + haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon) + ), + line: null, + labelMarker: null, + }); + } + syncDecodeContactPathVisibility(); +} + function buildBookmarkLocatorPopupHtml(grid, bookmarks) { const list = Array.isArray(bookmarks) ? bookmarks : []; const rows = list @@ -5591,23 +5768,41 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, const markerType = type === "wspr" ? "wspr" : "ft8"; const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; - const detailEntry = { - station: stationId || null, - ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, - snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, - dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, - freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, - message: String(details?.message || message || "").trim() || null, - }; - const detailKey = stationId || `${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; + const locatorDetails = new Map(); + if (Array.isArray(details?.locator_details)) { + for (const locatorDetail of details.locator_details) { + const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); + if (!grid) continue; + locatorDetails.set(grid, locatorDetail); + } + } for (const grid of unique) { const bounds = maidenheadToBounds(grid); if (!bounds) continue; + const locatorDetail = locatorDetails.get(grid); + const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() + ? String(locatorDetail.source).trim().toUpperCase() + : ""; + const targetId = locatorDetail?.target && String(locatorDetail.target).trim() + ? String(locatorDetail.target).trim().toUpperCase() + : ""; + const detailStationId = sourceId || stationId; + const detailEntry = { + station: detailStationId || null, + source: sourceId || null, + target: targetId || null, + ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, + snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, + dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, + freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, + message: String(details?.message || message || "").trim() || null, + }; + const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; const key = `${markerType}:${grid}`; const existing = locatorMarkers.get(key); if (existing) { existing.grid = grid; - if (stationId) existing.stations.add(stationId); + if (detailStationId) existing.stations.add(detailStationId); if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map(); existing.stationDetails.set(detailKey, { ...detailEntry }); existing.sourceType = markerType; @@ -5626,7 +5821,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, } const stations = new Set(); - if (stationId) stations.add(stationId); + if (detailStationId) stations.add(detailStationId); const stationDetails = new Map(); stationDetails.set(detailKey, { ...detailEntry }); const bandMeta = collectBandMeta( @@ -5643,6 +5838,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, locatorMarkers.set(key, { marker, grid, stations, stationDetails, sourceType: markerType, bandMeta }); mapMarkers.add(marker); } + rebuildDecodeContactPaths(); rebuildMapLocatorFilters(); applyMapFilter(); }; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 88f0809..399e89b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -684,6 +684,13 @@ Search +
+ Paths +
+ + Directed FT8 contacts with both locators known +
+
@@ -699,9 +706,6 @@
-