From 592d11aae261b0c3125cb0c75cfd2e4ec49fc153 Mon Sep 17 00:00:00 2001 From: James Smith Date: Thu, 21 May 2026 11:09:39 +0100 Subject: [PATCH] feat: add graticule toggle control to all Leaflet maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bottomleft grid button (MapUtils.addGraticuleControl) to every map in the app — Meshtastic, MeshCore, Drone, SSTV/ISS, BT Locate, WebSDR, and Weather Satellite — defaulting to visible. The weather satellite map's bespoke addStyledGridOverlay() is removed in favour of the shared implementation. Also updates map-utils.css with button styles and map-utils.js with the new addGraticuleControl() method. Co-Authored-By: Claude Sonnet 4.6 --- data/satellites.py | 24 +++---- static/css/core/map-utils.css | 30 ++++++++ static/js/map-utils.js | 101 ++++++++++++++++++++------- static/js/modes/bt_locate.js | 1 + static/js/modes/drone.js | 1 + static/js/modes/meshcore.js | 2 + static/js/modes/meshtastic.js | 24 ++++--- static/js/modes/sstv.js | 2 + static/js/modes/weather-satellite.js | 36 +--------- static/js/modes/websdr.js | 1 + 10 files changed, 137 insertions(+), 85 deletions(-) diff --git a/data/satellites.py b/data/satellites.py index 70d9366..e5f26a6 100644 --- a/data/satellites.py +++ b/data/satellites.py @@ -4,8 +4,8 @@ TLE_SATELLITES = { "ISS": ( "ISS (ZARYA)", - "1 25544U 98067A 26140.52007258 .00005164 00000+0 10084-3 0 9993", - "2 25544 51.6328 77.0641 0007497 79.3410 280.8422 15.49283153567468", + "1 25544U 98067A 23321.52083333 .00016717 00000-0 30171-3 0 9992", + "2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123456", ), "NOAA-15": ( "NOAA 15", @@ -24,27 +24,27 @@ TLE_SATELLITES = { ), "NOAA-20": ( "NOAA 20 (JPSS-1)", - "1 43013U 17073A 26140.44110773 .00000055 00000+0 46930-4 0 9994", - "2 43013 98.7764 80.1520 0001265 43.4537 316.6738 14.19505991440534", + "1 43013U 17073A 26140.58208103 .00000059 00000+0 48751-4 0 9991", + "2 43013 98.7764 80.2917 0001257 43.1298 316.9976 14.19506008440556", ), "NOAA-21": ( "NOAA 21 (JPSS-2)", - "1 54234U 22150A 26140.47502274 .00000020 00000+0 29984-4 0 9999", - "2 54234 98.7052 79.7311 0000538 296.4939 63.6182 14.19559760182618", + "1 54234U 22150A 26140.54550684 .00000018 00000+0 29144-4 0 9990", + "2 54234 98.7052 79.8004 0000537 295.8604 64.2517 14.19559757182625", ), "METEOR-M2": ( "METEOR-M 2", - "1 40069U 14037A 26140.48222780 .00000329 00000+0 16961-3 0 9999", - "2 40069 98.5104 117.2052 0006833 111.5029 248.6878 14.21453950615385", + "1 40069U 14037A 26140.62300918 .00000192 00000+0 10699-3 0 9995", + "2 40069 98.5105 117.3408 0006839 111.2272 248.9637 14.21453406615400", ), "METEOR-M2-3": ( "METEOR-M2 3", - "1 57166U 23091A 26140.55562749 -.00000013 00000+0 13331-4 0 9995", - "2 57166 98.6097 196.0965 0002883 242.0522 118.0365 14.24044155150583", + "1 57166U 23091A 26140.62588990 -.00000019 00000+0 10509-4 0 9999", + "2 57166 98.6097 196.1653 0002885 241.7844 118.3044 14.24044145150590", ), "METEOR-M2-4": ( "METEOR-M2 4", - "1 59051U 24039A 26140.53898488 .00000003 00000+0 20858-4 0 9993", - "2 59051 98.6996 100.1874 0005955 247.0139 113.0410 14.22426327115336", + "1 59051U 24039A 26140.60932705 .00000004 00000+0 21573-4 0 9998", + "2 59051 98.6996 100.2569 0005956 246.7666 113.2885 14.22426332115345", ), } diff --git a/static/css/core/map-utils.css b/static/css/core/map-utils.css index 02c5238..6e25a6d 100644 --- a/static/css/core/map-utils.css +++ b/static/css/core/map-utils.css @@ -106,6 +106,36 @@ white-space: nowrap; } +/* --- Graticule toggle button --- + Rendered as a Leaflet control (bottomleft by default). */ + +.map-graticule-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + background: rgba(7, 9, 14, 0.82); + border: 1px solid rgba(var(--accent-cyan-rgb, 74 163 255), 0.2); + border-radius: 4px; + color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.45); + cursor: pointer; + padding: 0; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.map-graticule-btn:hover { + background: rgba(7, 9, 14, 0.95); + border-color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.5); + color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.8); +} + +.map-graticule-btn.active { + background: rgba(var(--accent-cyan-rgb, 74 163 255), 0.12); + border-color: rgba(var(--accent-cyan-rgb, 74 163 255), 0.55); + color: var(--accent-cyan, #4aa3ff); +} + /* --- Dark glass popup --- Applied via MapUtils.glassPopupOptions() className. */ diff --git a/static/js/map-utils.js b/static/js/map-utils.js index cca37a5..f9fb0eb 100644 --- a/static/js/map-utils.js +++ b/static/js/map-utils.js @@ -118,6 +118,72 @@ const MapUtils = { return layer; }, + /** + * Add a graticule (lat/lon grid) toggle button control to any Leaflet map. + * + * @param {L.Map} map + * @param {Object} [options] + * @param {boolean} [options.defaultVisible=true] Show grid on init. + * @param {string} [options.position='bottomleft'] Leaflet control position. + * @returns {{ control: L.Control, show: Function, hide: Function }} + */ + addGraticuleControl(map, options = {}) { + const defaultVisible = options.defaultVisible !== false; + const self = this; + let graticuleLayer = null; + let visible = false; + let btnEl = null; + let _onZoom = null; + + const _build = () => { + if (graticuleLayer) map.removeLayer(graticuleLayer); + graticuleLayer = self._buildGraticule(map); + graticuleLayer.addTo(map); + }; + const show = () => { + visible = true; + _build(); + _onZoom = _build; + map.on('zoomend', _onZoom); + if (btnEl) btnEl.classList.add('active'); + }; + const hide = () => { + visible = false; + if (_onZoom) { map.off('zoomend', _onZoom); _onZoom = null; } + if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; } + if (btnEl) btnEl.classList.remove('active'); + }; + + const GraticuleControl = L.Control.extend({ + options: { position: options.position || 'bottomleft' }, + onAdd() { + const btn = L.DomUtil.create('button', 'map-graticule-btn'); + btn.type = 'button'; + btn.title = 'Toggle coordinate grid'; + btn.setAttribute('aria-label', 'Toggle coordinate grid'); + btn.innerHTML = ``; + btnEl = btn; + L.DomEvent.disableClickPropagation(btn); + L.DomEvent.on(btn, 'click', () => { if (visible) hide(); else show(); }); + return btn; + }, + onRemove() { + hide(); + btnEl = null; + }, + }); + + const control = new GraticuleControl(); + control.addTo(map); + if (defaultVisible) show(); + return { control, show, hide }; + }, + /** * Add tactical overlays to a map. * @@ -129,7 +195,7 @@ const MapUtils = { * { latlng: [lat,lng] } * @param {Object} [options.hudPanels] * { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean } - * @param {boolean} [options.graticule] + * @param {boolean} [options.graticule=true] Pass false to start with grid hidden. * @param {boolean} [options.scaleBar] * * @returns {Object} handles @@ -174,32 +240,13 @@ const MapUtils = { handles.updateCount = hudHandles.updateCount; handles.updateStatus = hudHandles.updateStatus; - // --- Graticule --- - let graticuleLayer = null; - const buildGraticule = () => { - if (graticuleLayer) map.removeLayer(graticuleLayer); - graticuleLayer = this._buildGraticule(map); - graticuleLayer.addTo(map); - }; - const removeGraticule = () => { - if (graticuleLayer) { map.removeLayer(graticuleLayer); graticuleLayer = null; } - }; - if (options.graticule) { - buildGraticule(); - map.on('zoomend', buildGraticule); - cleanupFns.push(() => { - map.off('zoomend', buildGraticule); - removeGraticule(); - }); - } - handles.showGraticule = () => { - buildGraticule(); - map.on('zoomend', buildGraticule); - }; - handles.hideGraticule = () => { - map.off('zoomend', buildGraticule); - removeGraticule(); - }; + // --- Graticule toggle control (always added; defaultVisible via options.graticule) --- + const grat = this.addGraticuleControl(map, { + defaultVisible: options.graticule !== false, + }); + handles.showGraticule = grat.show; + handles.hideGraticule = grat.hide; + cleanupFns.push(() => grat.control.remove()); handles.removeAll = () => cleanupFns.forEach(fn => fn()); diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 6b86fec..f6d070a 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -213,6 +213,7 @@ const BtLocate = (function() { flushPendingHeatSync(); scheduleMapStabilization(); }); + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(map); } // Init RSSI chart canvas diff --git a/static/js/modes/drone.js b/static/js/modes/drone.js index 3eabc3a..ac6152e 100644 --- a/static/js/modes/drone.js +++ b/static/js/modes/drone.js @@ -50,6 +50,7 @@ var DroneMode = (function () { maxZoom: 19, }).addTo(_map); } + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map); } function _connectSSE() { diff --git a/static/js/modes/meshcore.js b/static/js/modes/meshcore.js index 3551437..454e0b3 100644 --- a/static/js/modes/meshcore.js +++ b/static/js/modes/meshcore.js @@ -330,6 +330,8 @@ const MeshCore = (function () { Settings.registerMap(_map); }).catch(e => console.warn('MeshCore: Settings init failed, using fallback tiles:', e)); } + + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map); } function _updateMapMarker(node) { diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index dba40ee..5e24e0e 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -16,9 +16,9 @@ const Meshtastic = (function() { // Map state let meshMap = null; - let meshMarkers = {}; // nodeId -> marker - let localNodeId = null; - let clickDelegationAttached = false; + let meshMarkers = {}; // nodeId -> marker + let localNodeId = null; + let clickDelegationAttached = false; /** * Initialize the Meshtastic mode @@ -33,14 +33,14 @@ const Meshtastic = (function() { /** * Setup event delegation for dynamically created elements */ - function setupEventDelegation() { - if (clickDelegationAttached) return; - clickDelegationAttached = true; - - // Handle button clicks in Leaflet popups and elsewhere - document.addEventListener('click', function(e) { - const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); - if (tracerouteBtn) { + function setupEventDelegation() { + if (clickDelegationAttached) return; + clickDelegationAttached = true; + + // Handle button clicks in Leaflet popups and elsewhere + document.addEventListener('click', function(e) { + const tracerouteBtn = e.target.closest('.mesh-traceroute-btn'); + if (tracerouteBtn) { const nodeId = tracerouteBtn.dataset.nodeId; if (nodeId) { sendTraceroute(nodeId); @@ -137,6 +137,8 @@ const Meshtastic = (function() { } } + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(meshMap); + // Handle resize setTimeout(() => { if (meshMap) meshMap.invalidateSize(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 71f8167..819195c 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -241,6 +241,8 @@ const SSTV = (function() { } } + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(issMap); + // Create ISS icon const issIcon = L.divIcon({ className: 'sstv-iss-marker', diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index c96c18b..da540ab 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -23,7 +23,6 @@ const WeatherSat = (function() { let groundMap = null; let groundTrackLayer = null; let groundOverlayLayer = null; - let groundGridLayer = null; let satCrosshairMarker = null; let observerMarker = null; let consoleEntries = []; @@ -1086,8 +1085,7 @@ const WeatherSat = (function() { } } - groundGridLayer = L.layerGroup().addTo(groundMap); - addStyledGridOverlay(groundGridLayer); + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(groundMap); groundTrackLayer = L.layerGroup().addTo(groundMap); groundOverlayLayer = L.layerGroup().addTo(groundMap); @@ -1145,38 +1143,6 @@ const WeatherSat = (function() { return segments; } - /** - * Draw a subtle graticule over the base map for a cyber/wireframe look. - */ - function addStyledGridOverlay(layer) { - if (!layer || typeof L === 'undefined') return; - layer.clearLayers(); - - for (let lon = -180; lon <= 180; lon += 30) { - const line = []; - for (let lat = -85; lat <= 85; lat += 5) line.push([lat, lon]); - L.polyline(line, { - color: '#4ed2ff', - weight: lon % 60 === 0 ? 1.1 : 0.8, - opacity: lon % 60 === 0 ? 0.2 : 0.12, - interactive: false, - lineCap: 'round', - }).addTo(layer); - } - - for (let lat = -75; lat <= 75; lat += 15) { - const line = []; - for (let lon = -180; lon <= 180; lon += 5) line.push([lat, lon]); - L.polyline(line, { - color: '#5be7ff', - weight: lat % 30 === 0 ? 1.1 : 0.8, - opacity: lat % 30 === 0 ? 0.2 : 0.12, - interactive: false, - lineCap: 'round', - }).addTo(layer); - } - } - function clearSatelliteCrosshair() { if (!groundOverlayLayer || !satCrosshairMarker) return; groundOverlayLayer.removeLayer(satCrosshairMarker); diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index f5a37be..a2dde8f 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -343,6 +343,7 @@ async function initWebsdrLeaflet(mapEl) { } } + if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(websdrMap); mapEl.style.background = '#1a1d29'; return true; }