feat: add graticule toggle control to all Leaflet maps

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 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-05-21 11:09:39 +01:00
parent 30a0085f1d
commit 592d11aae2
10 changed files with 137 additions and 85 deletions
+12 -12
View File
@@ -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",
),
}
+30
View File
@@ -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. */
+74 -27
View File
@@ -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 = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<line x1="0" y1="4.67" x2="14" y2="4.67" stroke="currentColor" stroke-width="1"/>
<line x1="0" y1="9.33" x2="14" y2="9.33" stroke="currentColor" stroke-width="1"/>
<line x1="4.67" y1="0" x2="4.67" y2="14" stroke="currentColor" stroke-width="1"/>
<line x1="9.33" y1="0" x2="9.33" y2="14" stroke="currentColor" stroke-width="1"/>
</svg>`;
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());
+1
View File
@@ -213,6 +213,7 @@ const BtLocate = (function() {
flushPendingHeatSync();
scheduleMapStabilization();
});
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(map);
}
// Init RSSI chart canvas
+1
View File
@@ -50,6 +50,7 @@ var DroneMode = (function () {
maxZoom: 19,
}).addTo(_map);
}
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(_map);
}
function _connectSSE() {
+2
View File
@@ -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) {
+13 -11
View File
@@ -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();
+2
View File
@@ -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',
+1 -35
View File
@@ -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);
+1
View File
@@ -343,6 +343,7 @@ async function initWebsdrLeaflet(mapEl) {
}
}
if (typeof MapUtils !== 'undefined') MapUtils.addGraticuleControl(websdrMap);
mapEl.style.background = '#1a1d29';
return true;
}