mirror of
https://github.com/smittix/intercept.git
synced 2026-04-26 07:40:01 -07:00
354 lines
12 KiB
JavaScript
354 lines
12 KiB
JavaScript
/**
|
|
* 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: `<span>${Math.round(dist)} ${unit}</span>`,
|
|
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: `<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="14" cy="14" r="4" stroke="#4aa3ff" stroke-width="1.5"/>
|
|
<line x1="14" y1="2" x2="14" y2="9" stroke="#4aa3ff" stroke-width="1.5"/>
|
|
<line x1="14" y1="19" x2="14" y2="26" stroke="#4aa3ff" stroke-width="1.5"/>
|
|
<line x1="2" y1="14" x2="9" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
|
|
<line x1="19" y1="14" x2="26" y2="14" stroke="#4aa3ff" stroke-width="1.5"/>
|
|
</svg>`,
|
|
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 = `
|
|
<span class="map-hud-mode">${modeName}</span>
|
|
<span class="map-hud-count">0</span>
|
|
`;
|
|
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 = `
|
|
<span class="map-hud-clock"></span>
|
|
<span class="map-hud-dot"></span>
|
|
`;
|
|
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 };
|
|
},
|
|
};
|