2025-08-24 14:04:17 +02:00
|
|
|
// Map and visualization management module
|
2026-02-13 15:21:56 +01:00
|
|
|
import { escapeHtml } from './html-utils.js';
|
|
|
|
|
|
2025-08-24 14:04:17 +02:00
|
|
|
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;
|
2025-08-24 21:09:33 +02:00
|
|
|
this.showRange = true;
|
2025-08-24 14:04:17 +02:00
|
|
|
this.selectedSource = null;
|
|
|
|
|
this.heatmapLayer = null;
|
|
|
|
|
|
|
|
|
|
// Data references
|
|
|
|
|
this.sourcesData = new Map();
|
2025-08-24 15:09:54 +02:00
|
|
|
|
|
|
|
|
// Map theme
|
|
|
|
|
this.isDarkMode = false;
|
|
|
|
|
this.currentTileLayer = null;
|
|
|
|
|
this.coverageTileLayer = null;
|
2025-08-24 14:04:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2025-08-24 15:09:54 +02:00
|
|
|
// Light tile layer by default
|
|
|
|
|
this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
2025-08-24 14:04:17 +02:00
|
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
|
|
|
subdomains: 'abcd',
|
|
|
|
|
maxZoom: 19
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
2025-08-24 16:24:46 +02:00
|
|
|
// Add scale control for distance estimation
|
|
|
|
|
L.control.scale({
|
|
|
|
|
metric: true,
|
|
|
|
|
imperial: true,
|
|
|
|
|
position: 'bottomright'
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
2025-08-24 14:04:17 +02:00
|
|
|
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);
|
|
|
|
|
|
2025-08-24 15:09:54 +02:00
|
|
|
this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
2025-08-24 14:04:17 +02:00
|
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
|
|
|
}).addTo(this.coverageMap);
|
2025-08-24 16:24:46 +02:00
|
|
|
|
|
|
|
|
// Add scale control for distance estimation
|
|
|
|
|
L.control.scale({
|
|
|
|
|
metric: true,
|
|
|
|
|
imperial: true,
|
|
|
|
|
position: 'bottomright'
|
|
|
|
|
}).addTo(this.coverageMap);
|
2025-08-24 14:04:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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',
|
2025-08-24 16:24:46 +02:00
|
|
|
weight: 2,
|
|
|
|
|
opacity: 0.7 - (index * 0.15),
|
|
|
|
|
dashArray: '8,4'
|
2025-08-24 14:04:17 +02:00
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
|
|
|
|
this.rangeCircles.set(`${id}_${range}`, circle);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createSourcePopupContent(source, aircraftData) {
|
2026-02-13 15:21:56 +01:00
|
|
|
const esc = escapeHtml;
|
2025-08-24 14:04:17 +02:00
|
|
|
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
|
|
|
|
|
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
|
2026-02-13 15:21:56 +01:00
|
|
|
|
2025-08-24 14:04:17 +02:00
|
|
|
return `
|
|
|
|
|
<div class="source-popup">
|
2026-02-13 15:21:56 +01:00
|
|
|
<h3>${esc(source.name)}</h3>
|
|
|
|
|
<p><strong>ID:</strong> ${esc(source.id)}</p>
|
|
|
|
|
<p><strong>Location:</strong> ${esc(source.latitude.toFixed(4))}°, ${esc(source.longitude.toFixed(4))}°</p>
|
2025-08-24 14:04:17 +02:00
|
|
|
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
2026-02-13 15:21:56 +01:00
|
|
|
<p><strong>Aircraft:</strong> ${esc(aircraftCount)}</p>
|
|
|
|
|
<p><strong>Messages:</strong> ${esc(source.messages || 0)}</p>
|
|
|
|
|
<p><strong>Last Seen:</strong> ${source.last_seen ? esc(new Date(source.last_seen).toLocaleString()) : 'N/A'}</p>
|
2025-08-24 14:04:17 +02:00
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSourcesLegend() {
|
|
|
|
|
const legend = document.getElementById('sources-legend');
|
|
|
|
|
if (!legend) return;
|
|
|
|
|
|
|
|
|
|
legend.innerHTML = '';
|
|
|
|
|
|
2025-09-01 17:32:03 +02:00
|
|
|
// 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) {
|
2026-02-13 15:21:56 +01:00
|
|
|
const esc = escapeHtml;
|
2025-08-24 14:04:17 +02:00
|
|
|
const item = document.createElement('div');
|
|
|
|
|
item.className = 'legend-item';
|
|
|
|
|
item.innerHTML = `
|
|
|
|
|
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
2026-02-13 15:21:56 +01:00
|
|
|
<span title="${esc(source.host)}:${esc(source.port)}">${esc(source.name)}</span>
|
2025-08-24 14:04:17 +02:00
|
|
|
`;
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-24 15:09:54 +02:00
|
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
|
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
|
|
|
}).addTo(this.coverageMap);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.isDarkMode;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-24 14:04:17 +02:00
|
|
|
// Coverage map methods
|
|
|
|
|
updateCoverageControls() {
|
|
|
|
|
const select = document.getElementById('coverage-source');
|
|
|
|
|
if (!select) return;
|
|
|
|
|
|
|
|
|
|
select.innerHTML = '<option value="">Select Source</option>';
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-08-24 18:24:08 +02:00
|
|
|
// 🚧 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 🚧');
|
|
|
|
|
}
|
2025-08-24 14:04:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSelectedSource(sourceId) {
|
|
|
|
|
this.selectedSource = sourceId;
|
|
|
|
|
}
|
2025-08-24 16:24:46 +02:00
|
|
|
|
|
|
|
|
getSourcePositions() {
|
|
|
|
|
const positions = [];
|
|
|
|
|
for (const [id, source] of this.sourcesData) {
|
|
|
|
|
if (source.latitude && source.longitude) {
|
|
|
|
|
positions.push([source.latitude, source.longitude]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return positions;
|
|
|
|
|
}
|
2025-08-24 14:04:17 +02:00
|
|
|
}
|