From 283125989be41d822deeaa0a0832934c7a769be7 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 4 Mar 2026 19:02:31 +0100 Subject: [PATCH] [fix](trx-frontend): animate selected locator paths Co-authored-by: OpenAI Codex Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 127 +++++++++++++++--- .../trx-frontend-http/assets/web/style.css | 6 + 2 files changed, 111 insertions(+), 22 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 06aaae4..93f6f17 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 @@ -3170,6 +3170,7 @@ let aprsMap = null; let aprsMapBaseLayer = null; let aprsMapReceiverMarker = null; let aprsRadioPath = null; +let selectedLocatorMarker = null; const stationMarkers = new Map(); const locatorMarkers = new Map(); const mapMarkers = new Set(); @@ -3278,6 +3279,70 @@ function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { }; } +function clearMapRadioPath() { + if (aprsRadioPath) { + aprsRadioPath.remove(); + aprsRadioPath = null; + } +} + +function setMapRadioPathTo(lat, lon, className = "aprs-radio-path") { + clearMapRadioPath(); + if (serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { + return; + } + aprsRadioPath = L.polyline( + [[serverLat, serverLon], [lat, lon]], + { className, weight: 2, interactive: false } + ).addTo(aprsMap); +} + +function locatorMarkerCenter(marker) { + if (!marker) return null; + if (typeof marker.getBounds === "function") { + const bounds = marker.getBounds(); + if (bounds && typeof bounds.getCenter === "function") { + const center = bounds.getCenter(); + if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { + return { lat: center.lat, lon: center.lng }; + } + } + } + if (typeof marker.getLatLng === "function") { + const ll = marker.getLatLng(); + if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { + return { lat: ll.lat, lon: ll.lng }; + } + } + return null; +} + +function setLocatorMarkerHighlight(marker, enabled) { + const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; + if (!element) return; + element.classList.toggle("trx-locator-selected", !!enabled); +} + +function setSelectedLocatorMarker(marker) { + if (selectedLocatorMarker && selectedLocatorMarker !== marker) { + setLocatorMarkerHighlight(selectedLocatorMarker, false); + } + selectedLocatorMarker = marker || null; + if (selectedLocatorMarker) { + setLocatorMarkerHighlight(selectedLocatorMarker, true); + } +} + +function isLocatorOverlay(marker) { + const type = marker?.__trxType; + return type === "bookmark" || type === "ft8" || type === "wspr"; +} + +function sendLocatorOverlayToBack(marker) { + if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; + marker.bringToBack(); +} + function renderMapLocatorChipRow(container, items, selectedSet, kind) { if (!container) return; container.innerHTML = ""; @@ -3489,6 +3554,10 @@ window.clearMapMarkersByType = function(type) { for (const [key, entry] of locatorMarkers.entries()) { if (!key.startsWith(prefix)) continue; if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); mapMarkers.delete(entry.marker); } @@ -3501,6 +3570,10 @@ window.clearMapMarkersByType = function(type) { for (const [key, entry] of locatorMarkers.entries()) { if (!key.startsWith("bookmark:")) continue; if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); mapMarkers.delete(entry.marker); } @@ -3557,7 +3630,8 @@ function initAprsMap() { // Rebuild popup content on open (keeps age/distance/rig list fresh) aprsMap.on("popupopen", function(e) { const marker = e.popup._source; - if (aprsRadioPath) { aprsRadioPath.remove(); aprsRadioPath = null; } + clearMapRadioPath(); + setSelectedLocatorMarker(null); if (selectedAisTrackMmsi) { const prevEntry = aisMarkers.get(String(selectedAisTrackMmsi)); if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { @@ -3572,23 +3646,19 @@ function initAprsMap() { } if (!marker) return; - - const ll = marker.getLatLng(); + const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; if (marker._aprsCall) { + if (!ll) return; const entry = stationMarkers.get(marker._aprsCall); if (!entry) return; e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); - if (serverLat != null && serverLon != null) { - aprsRadioPath = L.polyline( - [[serverLat, serverLon], [ll.lat, ll.lng]], - { className: "aprs-radio-path", weight: 2, interactive: false } - ).addTo(aprsMap); - } + setMapRadioPathTo(ll.lat, ll.lng, "aprs-radio-path"); return; } if (marker._aisMmsi) { + if (!ll) return; const entry = aisMarkers.get(String(marker._aisMmsi)); if (!entry || !entry.msg) return; e.popup.setContent(buildAisPopupHtml(entry.msg)); @@ -3597,30 +3667,31 @@ function initAprsMap() { entry.track.addTo(aprsMap); } selectedAisTrackMmsi = String(marker._aisMmsi); - if (serverLat != null && serverLon != null) { - aprsRadioPath = L.polyline( - [[serverLat, serverLon], [ll.lat, ll.lng]], - { className: "aprs-radio-path", weight: 2, interactive: false } - ).addTo(aprsMap); - } + setMapRadioPathTo(ll.lat, ll.lng, "aprs-radio-path"); return; } if (marker._vdesKey) { + if (!ll) return; const entry = vdesMarkers.get(String(marker._vdesKey)); if (!entry || !entry.msg) return; e.popup.setContent(buildVdesPopupHtml(entry.msg)); - if (serverLat != null && serverLon != null) { - aprsRadioPath = L.polyline( - [[serverLat, serverLon], [ll.lat, ll.lng]], - { className: "aprs-radio-path", weight: 2, interactive: false } - ).addTo(aprsMap); + setMapRadioPathTo(ll.lat, ll.lng, "aprs-radio-path"); + return; + } + + if (marker.__trxType === "bookmark" || marker.__trxType === "ft8" || marker.__trxType === "wspr") { + const center = locatorMarkerCenter(marker); + if (center) { + setSelectedLocatorMarker(marker); + setMapRadioPathTo(center.lat, center.lon, "locator-radio-path"); } } }); aprsMap.on("popupclose", function() { - if (aprsRadioPath) { aprsRadioPath.remove(); aprsRadioPath = null; } + clearMapRadioPath(); + setSelectedLocatorMarker(null); if (selectedAisTrackMmsi) { const entry = aisMarkers.get(String(selectedAisTrackMmsi)); if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { @@ -3646,6 +3717,7 @@ function initAprsMap() { .addTo(aprsMap) .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); mapMarkers.add(entry.marker); } @@ -4235,7 +4307,10 @@ function applyMapFilter() { (type === "wspr" && mapFilter.wspr) ); const onMap = aprsMap.hasLayer(marker); - if (visible && !onMap) marker.addTo(aprsMap); + if (visible && !onMap) { + marker.addTo(aprsMap); + sendLocatorOverlayToBack(marker); + } if (!visible && onMap) marker.removeFrom(aprsMap); }); } @@ -4349,6 +4424,10 @@ window.syncBookmarkMapLocators = function(bookmarks) { if (!key.startsWith("bookmark:")) continue; if (!grouped.has(key)) { if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); mapMarkers.delete(entry.marker); } @@ -4370,6 +4449,7 @@ window.syncBookmarkMapLocators = function(bookmarks) { existing.marker.setBounds(next.bounds); existing.marker.setStyle(locatorStyleForCount(next.bookmarks.length, "bookmark")); existing.marker.setPopupContent(popupHtml); + sendLocatorOverlayToBack(existing.marker); assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); } continue; @@ -4389,6 +4469,7 @@ window.syncBookmarkMapLocators = function(bookmarks) { .addTo(aprsMap) .bindPopup(popupHtml); entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); mapMarkers.add(entry.marker); } @@ -4431,6 +4512,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, const tooltipHtml = buildDecodeLocatorTooltipHtml(grid, existing, markerType); existing.marker.setStyle(locatorStyleForCount(count, markerType)); existing.marker.setPopupContent(tooltipHtml); + sendLocatorOverlayToBack(existing.marker); assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); rebuildMapLocatorFilters(); applyMapFilter(); @@ -4447,6 +4529,7 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null, .addTo(aprsMap) .bindPopup(tooltipHtml); marker.__trxType = markerType; + sendLocatorOverlayToBack(marker); const bandMeta = collectBandMeta( Array.from(stationDetails.values()).map((detail) => Number(detail?.freq_hz)) ); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index b8de3ee..a6fe339 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1514,9 +1514,15 @@ small { color: var(--text-muted); } word-break: break-word; } .aprs-radio-path { stroke: var(--accent-green) !important; stroke-opacity: 0.8 !important; stroke-dasharray: 10 5 !important; animation: aprs-radio-path-flow 0.7s linear infinite; } +.locator-radio-path { stroke: #6ee7b7 !important; stroke-opacity: 0.9 !important; stroke-dasharray: 12 6 !important; animation: aprs-radio-path-flow 0.7s linear infinite; } +.trx-locator-selected { stroke-opacity: 1 !important; stroke-width: 3.25px !important; filter: drop-shadow(0 0 6px rgba(110, 231, 183, 0.5)); animation: trx-locator-breathe 1.6s ease-in-out infinite; } .trx-receiver-marker { stroke: var(--accent-green) !important; fill: var(--accent-green) !important; } .receiver-popup-active { font-size: 0.75em; background: rgba(194,75,26,0.15); color: var(--accent-green); border: 1px solid rgba(194,75,26,0.3); border-radius: 3px; padding: 0 0.25rem; margin-left: 0.3rem; vertical-align: middle; } @keyframes aprs-radio-path-flow { to { stroke-dashoffset: -15; } } +@keyframes trx-locator-breathe { + 0%, 100% { stroke-width: 2.4px; stroke-opacity: 0.78; filter: drop-shadow(0 0 2px rgba(110, 231, 183, 0.18)); } + 50% { stroke-width: 4.2px; stroke-opacity: 1; filter: drop-shadow(0 0 10px rgba(110, 231, 183, 0.52)); } +} .aprs-bar-pos { background: none; border: none; padding: 0; margin-left: 0.4em; font-family: inherit; font-size: inherit; color: var(--accent-green); cursor: pointer; } .aprs-bar-pos:hover { text-decoration: underline; } .aprs-byte { color: var(--accent-yellow); background: rgba(255, 214, 0, 0.12); border: 1px solid rgba(255, 214, 0, 0.25); border-radius: 4px; padding: 0 0.2rem; margin: 0 0.1rem; font-size: 0.78em; }