diff --git a/static/js/map-utils.js b/static/js/map-utils.js new file mode 100644 index 0000000..bc29d98 --- /dev/null +++ b/static/js/map-utils.js @@ -0,0 +1,353 @@ +/** + * 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.getContainer || !map.getContainer()) 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 moveend', buildGraticule); + cleanupFns.push(() => { + map.off('zoomend moveend', buildGraticule); + removeGraticule(); + }); + } + handles.showGraticule = () => { + buildGraticule(); + map.on('zoomend moveend', buildGraticule); + }; + handles.hideGraticule = () => { + map.off('zoomend moveend', buildGraticule); + removeGraticule(); + }; + + handles.removeAll = () => cleanupFns.forEach(fn => fn()); + 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 the top of each ring + const labelLat = center[0] + (dist * (unit === 'km' ? 0.009 : 0.0166)); + 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 }; + }, +}; diff --git a/tests/test_map_utils.py b/tests/test_map_utils.py new file mode 100644 index 0000000..394db74 --- /dev/null +++ b/tests/test_map_utils.py @@ -0,0 +1,19 @@ +# tests/test_map_utils.py + + +def test_map_utils_js_is_served(client): + """map-utils.js is accessible as a static file.""" + resp = client.get("/static/js/map-utils.js") + assert resp.status_code == 200 + data = resp.data.decode() + assert "MapUtils" in data + assert "MapUtils.init" in data + assert "addTacticalOverlays" in data + + +def test_map_utils_css_is_served(client): + """map-utils.css is accessible as a static file.""" + resp = client.get("/static/css/core/map-utils.css") + assert resp.status_code == 200 + data = resp.data.decode() + assert "map-hud-panel" in data