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 @@
-