/** * MapUtils — shared Leaflet map initialisation and tactical overlays. * * Usage: * const map = MapUtils.init('myMapDiv', { center: [51.5, -0.1], zoom: 8 }); * const overlays = MapUtils.addTacticalOverlays(map, { * rangeRings: { center: [51.5, -0.1], intervals: [50, 100, 150, 200] }, * observerReticle: { latlng: [51.5, -0.1] }, * hudPanels: { modeName: 'ADS-B', getContactCount: () => 0 }, * scaleBar: true, * }); * overlays.updateCount(42); */ const MapUtils = { /** * Initialise a Leaflet map with Settings-managed tile layer. * Adds a canvas fallback grid immediately, then upgrades to the * configured tile provider asynchronously without blocking. * * @param {string} containerId - DOM element id * @param {Object} [options] * @param {number[]} [options.center=[20,0]] * @param {number} [options.zoom=4] * @param {number} [options.minZoom=2] * @param {number} [options.maxZoom=18] * @param {boolean} [options.zoomControl=true] * @param {boolean} [options.attributionControl=true] * @returns {L.Map|null} */ init(containerId, options = {}) { const container = document.getElementById(containerId); if (!container) return null; // Guard against double init (e.g. back/forward cache restore) if (container._leaflet_id) return null; const map = L.map(containerId, { center: options.center || [20, 0], zoom: options.zoom ?? 4, minZoom: options.minZoom ?? 2, maxZoom: options.maxZoom ?? 18, zoomControl: options.zoomControl !== false, attributionControl: options.attributionControl !== false, }); const fallback = this.createFallbackGridLayer().addTo(map); this._upgradeTiles(map, fallback); return map; }, /** * Async: replace the fallback canvas grid with the Settings tile layer. * @private */ async _upgradeTiles(map, fallback) { if (typeof Settings === 'undefined') return; try { await Settings.init(); if (!map || map._removed) return; const layer = Settings.createTileLayer(); let loaded = false; layer.once('load', () => { loaded = true; if (map.hasLayer(fallback)) map.removeLayer(fallback); }); layer.on('tileerror', () => { if (!loaded) { console.warn('MapUtils: tile error — keeping fallback grid'); } }); layer.addTo(map); Settings.registerMap(map); } catch (e) { console.warn('MapUtils: settings init failed, keeping fallback:', e); } }, /** * Create a zero-network canvas fallback grid layer. * @returns {L.GridLayer} */ createFallbackGridLayer() { const layer = L.gridLayer({ tileSize: 256, updateWhenIdle: true, attribution: 'Local fallback grid', }); layer.createTile = function (coords) { const tile = document.createElement('canvas'); tile.width = 256; tile.height = 256; const ctx = tile.getContext('2d'); ctx.fillStyle = '#07090e'; ctx.fillRect(0, 0, 256, 256); // Major grid lines ctx.strokeStyle = 'rgba(74,163,255,0.12)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(256, 0); ctx.moveTo(0, 0); ctx.lineTo(0, 256); ctx.stroke(); // Minor grid lines ctx.strokeStyle = 'rgba(74,163,255,0.06)'; ctx.beginPath(); ctx.moveTo(128, 0); ctx.lineTo(128, 256); ctx.moveTo(0, 128); ctx.lineTo(256, 128); ctx.stroke(); ctx.fillStyle = 'rgba(74,163,255,0.25)'; ctx.font = '10px "JetBrains Mono", monospace'; ctx.fillText(`Z${coords.z} ${coords.x},${coords.y}`, 8, 18); return tile; }; return layer; }, /** * Add tactical overlays to a map. * * @param {L.Map} map * @param {Object} [options] * @param {Object} [options.rangeRings] * { center: [lat,lng], intervals: number[], unit: 'nm'|'km' } * @param {Object} [options.observerReticle] * { latlng: [lat,lng] } * @param {Object} [options.hudPanels] * { modeName: string, getContactCount: ()=>number, getSdrStatus: ()=>boolean } * @param {boolean} [options.graticule] * @param {boolean} [options.scaleBar] * * @returns {Object} handles * { updateCount(n), updateStatus(online), showGraticule(), hideGraticule(), * updateReticle(latlng), removeAll() } */ addTacticalOverlays(map, options = {}) { const handles = {}; const cleanupFns = []; // --- Scale bar --- if (options.scaleBar !== false) { const scale = L.control.scale({ imperial: true, metric: true, position: 'bottomright' }); scale.addTo(map); cleanupFns.push(() => scale.remove()); } // --- Range rings --- let rangeRingsLayer = null; if (options.rangeRings) { rangeRingsLayer = this._buildRangeRings(map, options.rangeRings); } handles.rangeRingsLayer = rangeRingsLayer; // --- Observer reticle --- let reticleMarker = null; if (options.observerReticle) { reticleMarker = this._buildReticle(options.observerReticle.latlng); reticleMarker.addTo(map); cleanupFns.push(() => map.removeLayer(reticleMarker)); } handles.updateReticle = (latlng) => { if (reticleMarker) reticleMarker.setLatLng(latlng); }; // --- HUD panels --- let hudHandles = { updateCount: () => {}, updateStatus: () => {} }; if (options.hudPanels) { hudHandles = this._buildHudPanels(map, options.hudPanels); cleanupFns.push(() => hudHandles.remove()); } 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(); }; handles.removeAll = () => cleanupFns.forEach(fn => fn()); // Auto-cleanup when Leaflet map is removed const autoCleanup = () => { cleanupFns.forEach(fn => fn()); map.off('remove', autoCleanup); }; map.on('remove', autoCleanup); const originalRemoveAll = handles.removeAll; handles.removeAll = () => { map.off('remove', autoCleanup); originalRemoveAll(); }; return handles; }, /** * Build dashed range rings around a centre point. * @private */ _buildRangeRings(map, opts) { const { center, intervals, unit = 'nm' } = opts; const metersPerUnit = unit === 'km' ? 1000 : 1852; const layer = L.layerGroup(); intervals.forEach(dist => { const meters = dist * metersPerUnit; L.circle(center, { radius: meters, color: '#4aa3ff', fillColor: 'transparent', fillOpacity: 0, weight: 1, opacity: 0.3, dashArray: '4 4', interactive: false, }).addTo(layer); // Label at accurate north point of ring (Leaflet handles earth curvature) const labelLat = L.circle(center, { radius: meters }).getBounds().getNorth(); L.marker([labelLat, center[1]], { icon: L.divIcon({ className: 'map-range-label', html: `${Math.round(dist)} ${unit}`, iconSize: [50, 14], iconAnchor: [25, 7], }), interactive: false, }).addTo(layer); }); layer.addTo(map); return layer; }, /** * Build a crosshair SVG marker. * @private */ _buildReticle(latlng) { const icon = L.divIcon({ className: 'map-reticle', html: ` `, iconSize: [28, 28], iconAnchor: [14, 14], }); return L.marker(latlng, { icon, interactive: false, zIndexOffset: -100 }); }, /** * Build HUD corner panels and attach them to the map container. * Returns update handles. * @private */ _buildHudPanels(map, opts) { const { modeName = '', getContactCount = () => 0, getSdrStatus = () => null } = opts; const container = map.getContainer(); // Top-left: mode name + contact count const tl = document.createElement('div'); tl.className = 'map-hud-panel map-hud-tl'; tl.innerHTML = ` ${modeName} 0 `; container.appendChild(tl); const countEl = tl.querySelector('.map-hud-count'); // Top-right: UTC clock + SDR status dot const tr = document.createElement('div'); tr.className = 'map-hud-panel map-hud-tr'; tr.innerHTML = ` `; container.appendChild(tr); const clockEl = tr.querySelector('.map-hud-clock'); const dotEl = tr.querySelector('.map-hud-dot'); // Clock tick const updateClock = () => { if (!document.body.contains(container)) return; clockEl.textContent = new Date().toISOString().substring(11, 19) + ' UTC'; }; updateClock(); const clockInterval = setInterval(updateClock, 1000); return { updateCount(n) { countEl.textContent = n; }, updateStatus(online) { dotEl.className = `map-hud-dot ${online === true ? 'online' : online === false ? 'offline' : ''}`; }, remove() { clearInterval(clockInterval); tl.remove(); tr.remove(); }, }; }, /** * Build a 10° lat/lon graticule as a Leaflet layer group. * Only draws lines visible in the current map bounds (+ 10% margin). * @private */ _buildGraticule(map) { const layer = L.layerGroup(); const bounds = map.getBounds().pad(0.1); const step = 10; const style = { color: 'rgba(74,163,255,0.12)', weight: 1, interactive: false }; const latMin = Math.floor(bounds.getSouth() / step) * step; const latMax = Math.ceil(bounds.getNorth() / step) * step; const lonMin = Math.floor(bounds.getWest() / step) * step; const lonMax = Math.ceil(bounds.getEast() / step) * step; for (let lat = latMin; lat <= latMax; lat += step) { if (lat < -90 || lat > 90) continue; L.polyline([[lat, lonMin], [lat, lonMax]], style).addTo(layer); } for (let lon = lonMin; lon <= lonMax; lon += step) { L.polyline([[-90, lon], [90, lon]], style).addTo(layer); } return layer; }, /** * Return Leaflet popup options for dark-glass style. * @returns {Object} */ glassPopupOptions() { return { className: 'map-glass-popup', maxWidth: 340 }; }, };