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