Add centralized escapeHtml() utility and apply it to every dynamic value inserted via innerHTML/template literals across the frontend. Data from VRS JSON sources and external CSV files (airline names, countries) flows through the backend as arbitrary strings that could contain HTML. While Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those escapes before the values reach innerHTML — enabling script injection. Affected modules: aircraft-manager, ui-manager, callsign-manager, map-manager, and the 3D radar labels in app.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
No EOL
14 KiB
JavaScript
387 lines
No EOL
14 KiB
JavaScript
// 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: '© <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);
|
|
|
|
// 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 `
|
|
<div class="source-popup">
|
|
<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>
|
|
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
|
<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>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
|
<span title="${esc(source.host)}:${esc(source.port)}">${esc(source.name)}</span>
|
|
`;
|
|
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: '© <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;
|
|
}
|
|
|
|
// 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) {
|
|
// 🚧 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;
|
|
}
|
|
} |