// Map and visualization management module import { escapeHtml } from './html-utils.js'; export class MapManager { constructor() { this.map = null; this.coverageMap = null; this.mapOrigin = null; // Source markers and overlays this.sourceMarkers = new Map(); this.rangeCircles = new Map(); this.showSources = true; this.showRange = true; this.selectedSource = null; this.heatmapLayer = null; // Data references this.sourcesData = new Map(); // Map theme this.isDarkMode = false; this.currentTileLayer = null; this.coverageTileLayer = null; } async initializeMap() { // Get origin from server let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback try { const response = await fetch('/api/origin'); if (response.ok) { origin = await response.json(); } } catch (error) { console.warn('Could not fetch origin, using default:', error); } // Store origin for reset functionality this.mapOrigin = origin; this.map = L.map('map').setView([origin.latitude, origin.longitude], 10); // Light tile layer by default this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19 }).addTo(this.map); // Add scale control for distance estimation L.control.scale({ metric: true, imperial: true, position: 'bottomright' }).addTo(this.map); return this.map; } async initializeCoverageMap() { if (!this.coverageMap) { // Get origin from server let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback try { const response = await fetch('/api/origin'); if (response.ok) { origin = await response.json(); } } catch (error) { console.warn('Could not fetch origin for coverage map, using default:', error); } this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10); this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors' }).addTo(this.coverageMap); // Add scale control for distance estimation L.control.scale({ metric: true, imperial: true, position: 'bottomright' }).addTo(this.coverageMap); } return this.coverageMap; } updateSourcesData(data) { if (data.sources) { this.sourcesData.clear(); data.sources.forEach(source => { this.sourcesData.set(source.id, source); }); } } updateSourceMarkers() { if (!this.map || !this.showSources) return; // Remove markers for sources that no longer exist const currentSourceIds = new Set(this.sourcesData.keys()); for (const [id, marker] of this.sourceMarkers) { if (!currentSourceIds.has(id)) { this.map.removeLayer(marker); this.sourceMarkers.delete(id); } } // Update or create markers for current sources for (const [id, source] of this.sourcesData) { if (source.latitude && source.longitude) { if (this.sourceMarkers.has(id)) { // Update existing marker const marker = this.sourceMarkers.get(id); // Update marker style if status changed marker.setStyle({ radius: source.active ? 10 : 6, fillColor: source.active ? '#00d4ff' : '#666666', fillOpacity: 0.8 }); // Update popup content if it's open if (marker.isPopupOpen()) { marker.setPopupContent(this.createSourcePopupContent(source)); } } else { // Create new marker const marker = L.circleMarker([source.latitude, source.longitude], { radius: source.active ? 10 : 6, fillColor: source.active ? '#00d4ff' : '#666666', color: '#ffffff', weight: 2, fillOpacity: 0.8, className: 'source-marker' }).addTo(this.map); marker.bindPopup(this.createSourcePopupContent(source), { maxWidth: 300 }); this.sourceMarkers.set(id, marker); } } } this.updateSourcesLegend(); } updateRangeCircles() { if (!this.map || !this.showRange) return; // Clear existing circles this.rangeCircles.forEach(circle => this.map.removeLayer(circle)); this.rangeCircles.clear(); // Add range circles for active sources for (const [id, source] of this.sourcesData) { if (source.active && source.latitude && source.longitude) { // Add multiple range circles (50km, 100km, 200km) const ranges = [50000, 100000, 200000]; ranges.forEach((range, index) => { const circle = L.circle([source.latitude, source.longitude], { radius: range, fillColor: 'transparent', color: '#00d4ff', weight: 2, opacity: 0.7 - (index * 0.15), dashArray: '8,4' }).addTo(this.map); this.rangeCircles.set(`${id}_${range}`, circle); }); } } } createSourcePopupContent(source, aircraftData) { const esc = escapeHtml; const aircraftCount = aircraftData ? Array.from(aircraftData.values()) .filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0; return `

${esc(source.name)}

ID: ${esc(source.id)}

Location: ${esc(source.latitude.toFixed(4))}°, ${esc(source.longitude.toFixed(4))}°

Status: ${source.active ? 'Active' : 'Inactive'}

Aircraft: ${esc(aircraftCount)}

Messages: ${esc(source.messages || 0)}

Last Seen: ${source.last_seen ? esc(new Date(source.last_seen).toLocaleString()) : 'N/A'}

`; } updateSourcesLegend() { const legend = document.getElementById('sources-legend'); if (!legend) return; legend.innerHTML = ''; // Sort sources alphabetically by name const sortedSources = Array.from(this.sourcesData.values()).sort((a, b) => a.name.localeCompare(b.name) ); for (const source of sortedSources) { const esc = escapeHtml; const item = document.createElement('div'); item.className = 'legend-item'; item.innerHTML = ` ${esc(source.name)} `; legend.appendChild(item); } } resetMap() { if (this.mapOrigin && this.map) { this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10); } } toggleRangeCircles() { this.showRange = !this.showRange; if (this.showRange) { this.updateRangeCircles(); } else { this.rangeCircles.forEach(circle => this.map.removeLayer(circle)); this.rangeCircles.clear(); } return this.showRange; } toggleSources() { this.showSources = !this.showSources; if (this.showSources) { this.updateSourceMarkers(); } else { this.sourceMarkers.forEach(marker => this.map.removeLayer(marker)); this.sourceMarkers.clear(); } return this.showSources; } toggleDarkMode() { this.isDarkMode = !this.isDarkMode; const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; const tileUrl = this.isDarkMode ? darkUrl : lightUrl; const tileOptions = { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 19 }; // Update main map if (this.map && this.currentTileLayer) { this.map.removeLayer(this.currentTileLayer); this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map); } // Update coverage map if (this.coverageMap && this.coverageTileLayer) { this.coverageMap.removeLayer(this.coverageTileLayer); this.coverageTileLayer = L.tileLayer(tileUrl, { attribution: '© OpenStreetMap contributors' }).addTo(this.coverageMap); } return this.isDarkMode; } // Coverage map methods updateCoverageControls() { const select = document.getElementById('coverage-source'); if (!select) return; select.innerHTML = ''; for (const [id, source] of this.sourcesData) { const option = document.createElement('option'); option.value = id; option.textContent = source.name; select.appendChild(option); } } async updateCoverageDisplay() { if (!this.selectedSource || !this.coverageMap) return; try { const response = await fetch(`/api/coverage/${this.selectedSource}`); const data = await response.json(); // Clear existing coverage markers this.coverageMap.eachLayer(layer => { if (layer instanceof L.CircleMarker) { this.coverageMap.removeLayer(layer); } }); // Add coverage points data.points.forEach(point => { const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength L.circleMarker([point.lat, point.lon], { radius: 3, fillColor: this.getSignalColor(point.signal), color: 'white', weight: 1, fillOpacity: intensity }).addTo(this.coverageMap); }); } catch (error) { console.error('Failed to load coverage data:', error); } } async toggleHeatmap() { if (!this.selectedSource) { alert('Please select a source first'); return false; } if (this.heatmapLayer) { this.coverageMap.removeLayer(this.heatmapLayer); this.heatmapLayer = null; return false; } else { try { const response = await fetch(`/api/heatmap/${this.selectedSource}`); const data = await response.json(); // Create heatmap layer (simplified) this.createHeatmapOverlay(data); return true; } catch (error) { console.error('Failed to load heatmap data:', error); return false; } } } getSignalColor(signal) { if (signal > -10) return '#00ff88'; if (signal > -20) return '#ffff00'; if (signal > -30) return '#ff8c00'; return '#ff4444'; } createHeatmapOverlay(data) { // 🚧 Under Construction: Heatmap visualization not yet implemented // Planned: Use Leaflet.heat library for proper heatmap rendering console.log('Heatmap overlay requested but not yet implemented'); // Show user-visible notice if (window.uiManager) { window.uiManager.showError('Heatmap visualization is under construction 🚧'); } } setSelectedSource(sourceId) { this.selectedSource = sourceId; } getSourcePositions() { const positions = []; for (const [id, source] of this.sourcesData) { if (source.latitude && source.longitude) { positions.push([source.latitude, source.longitude]); } } return positions; } }