Backend Changes: - Added OriginConfig struct to server package - Modified NewServer to accept origin configuration - Added /api/origin endpoint to serve origin data to frontend - Updated main.go to pass origin configuration to server Frontend Changes: - Modified initializeMap() to fetch origin from API before map creation - Updated initializeCoverageMap() to also use origin data - Added fallback coordinates in case API request fails - Maps now center on the calculated origin position instead of hardcoded coordinates The map now properly centers on the calculated average position of enabled sources (or manually configured origin), providing a much better user experience with appropriate regional view. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1173 lines
No EOL
43 KiB
JavaScript
1173 lines
No EOL
43 KiB
JavaScript
// Import Three.js modules
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
|
|
class SkyView {
|
|
constructor() {
|
|
this.map = null;
|
|
this.coverageMap = null;
|
|
this.radar3d = null;
|
|
this.websocket = null;
|
|
|
|
// Data storage
|
|
this.aircraftData = new Map();
|
|
this.sourcesData = new Map();
|
|
this.stats = {};
|
|
|
|
// Map markers and overlays
|
|
this.aircraftMarkers = new Map();
|
|
this.sourceMarkers = new Map();
|
|
this.aircraftTrails = new Map();
|
|
this.rangeCircles = new Map();
|
|
this.heatmapLayer = null;
|
|
|
|
// UI state
|
|
this.currentView = 'map-view';
|
|
this.showTrails = false;
|
|
this.showRange = false;
|
|
this.showSources = true;
|
|
this.selectedSource = null;
|
|
|
|
// Charts
|
|
this.charts = {};
|
|
|
|
// Update tracking
|
|
this.lastUpdateTime = new Date();
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
try {
|
|
this.initializeViews();
|
|
this.initializeMap();
|
|
await this.initializeWebSocket();
|
|
this.initializeEventListeners();
|
|
this.initializeCharts();
|
|
this.initializeClocks();
|
|
this.initialize3DRadar();
|
|
|
|
this.startPeriodicTasks();
|
|
} catch (error) {
|
|
console.error('Initialization failed:', error);
|
|
this.showError('Failed to initialize application');
|
|
}
|
|
}
|
|
|
|
// View Management
|
|
initializeViews() {
|
|
const viewButtons = document.querySelectorAll('.view-btn');
|
|
const views = document.querySelectorAll('.view');
|
|
|
|
viewButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const viewId = btn.id.replace('-btn', '');
|
|
this.switchView(viewId);
|
|
});
|
|
});
|
|
}
|
|
|
|
switchView(viewId) {
|
|
// Update buttons
|
|
document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
|
|
const activeBtn = document.getElementById(`${viewId}-btn`);
|
|
if (activeBtn) {
|
|
activeBtn.classList.add('active');
|
|
}
|
|
|
|
// Update views (viewId already includes the full view ID like "map-view")
|
|
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
|
|
const activeView = document.getElementById(viewId);
|
|
if (activeView) {
|
|
activeView.classList.add('active');
|
|
} else {
|
|
console.warn(`View element not found: ${viewId}`);
|
|
return;
|
|
}
|
|
|
|
this.currentView = viewId;
|
|
|
|
// Handle view-specific initialization (extract base name for switch)
|
|
const baseName = viewId.replace('-view', '');
|
|
switch (baseName) {
|
|
case 'coverage':
|
|
this.initializeCoverageMap();
|
|
break;
|
|
case 'radar3d':
|
|
this.update3DRadar();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Map Initialization
|
|
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);
|
|
}
|
|
|
|
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
|
|
|
|
// Dark tile layer
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_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);
|
|
|
|
// Map controls
|
|
document.getElementById('center-map').addEventListener('click', () => this.centerMapOnAircraft());
|
|
document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails());
|
|
document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles());
|
|
document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources());
|
|
}
|
|
|
|
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);
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
}).addTo(this.coverageMap);
|
|
}
|
|
|
|
// Update coverage controls
|
|
this.updateCoverageControls();
|
|
|
|
// Coverage controls
|
|
document.getElementById('toggle-heatmap').addEventListener('click', () => this.toggleHeatmap());
|
|
document.getElementById('coverage-source').addEventListener('change', (e) => {
|
|
this.selectedSource = e.target.value;
|
|
this.updateCoverageDisplay();
|
|
});
|
|
}
|
|
|
|
// WebSocket Connection
|
|
async initializeWebSocket() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
|
|
try {
|
|
this.websocket = new WebSocket(wsUrl);
|
|
|
|
this.websocket.onopen = () => {
|
|
console.log('WebSocket connected');
|
|
this.updateConnectionStatus('connected');
|
|
};
|
|
|
|
this.websocket.onclose = () => {
|
|
console.log('WebSocket disconnected');
|
|
this.updateConnectionStatus('disconnected');
|
|
// Reconnect after 5 seconds
|
|
setTimeout(() => this.initializeWebSocket(), 5000);
|
|
};
|
|
|
|
this.websocket.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
this.updateConnectionStatus('disconnected');
|
|
};
|
|
|
|
this.websocket.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
this.handleWebSocketMessage(message);
|
|
} catch (error) {
|
|
console.error('Failed to parse WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('WebSocket connection failed:', error);
|
|
this.updateConnectionStatus('disconnected');
|
|
}
|
|
}
|
|
|
|
handleWebSocketMessage(message) {
|
|
switch (message.type) {
|
|
case 'initial_data':
|
|
case 'aircraft_update':
|
|
this.updateData(message.data);
|
|
break;
|
|
default:
|
|
console.log('Unknown message type:', message.type);
|
|
}
|
|
}
|
|
|
|
updateData(data) {
|
|
// Update aircraft data
|
|
if (data.aircraft) {
|
|
this.aircraftData.clear();
|
|
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
this.aircraftData.set(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
// Update sources data
|
|
if (data.sources) {
|
|
this.sourcesData.clear();
|
|
data.sources.forEach(source => {
|
|
this.sourcesData.set(source.id, source);
|
|
});
|
|
}
|
|
|
|
// Update statistics
|
|
if (data.stats) {
|
|
this.stats = data.stats;
|
|
}
|
|
|
|
this.lastUpdateTime = new Date();
|
|
|
|
// Update UI
|
|
this.updateMapMarkers();
|
|
this.updateAircraftTable();
|
|
this.updateStatistics();
|
|
this.updateHeaderInfo();
|
|
|
|
if (this.currentView === 'radar3d-view') {
|
|
this.update3DRadar();
|
|
}
|
|
}
|
|
|
|
// Map Updates
|
|
updateMapMarkers() {
|
|
// Clear stale aircraft markers
|
|
const currentICAOs = new Set(this.aircraftData.keys());
|
|
for (const [icao, marker] of this.aircraftMarkers) {
|
|
if (!currentICAOs.has(icao)) {
|
|
this.map.removeLayer(marker);
|
|
this.aircraftMarkers.delete(icao);
|
|
this.aircraftTrails.delete(icao);
|
|
}
|
|
}
|
|
|
|
// Update aircraft markers
|
|
for (const [icao, aircraft] of this.aircraftData) {
|
|
if (aircraft.Latitude && aircraft.Longitude) {
|
|
this.updateAircraftMarker(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
// Update source markers
|
|
if (this.showSources) {
|
|
this.updateSourceMarkers();
|
|
}
|
|
|
|
// Update range circles
|
|
if (this.showRange) {
|
|
this.updateRangeCircles();
|
|
}
|
|
}
|
|
|
|
updateAircraftMarker(icao, aircraft) {
|
|
const pos = [aircraft.Latitude, aircraft.Longitude];
|
|
|
|
if (this.aircraftMarkers.has(icao)) {
|
|
// Update existing marker
|
|
const marker = this.aircraftMarkers.get(icao);
|
|
marker.setLatLng(pos);
|
|
this.updateMarkerRotation(marker, aircraft.Track || 0);
|
|
this.updatePopupContent(marker, aircraft);
|
|
} else {
|
|
// Create new marker
|
|
const icon = this.createAircraftIcon(aircraft);
|
|
const marker = L.marker(pos, {
|
|
icon: icon,
|
|
rotationAngle: aircraft.Track || 0
|
|
}).addTo(this.map);
|
|
|
|
marker.bindPopup(this.createPopupContent(aircraft), {
|
|
maxWidth: 450,
|
|
className: 'aircraft-popup'
|
|
});
|
|
|
|
this.aircraftMarkers.set(icao, marker);
|
|
}
|
|
|
|
// Update trails
|
|
if (this.showTrails) {
|
|
this.updateAircraftTrail(icao, pos);
|
|
}
|
|
}
|
|
|
|
createAircraftIcon(aircraft) {
|
|
const type = this.getAircraftType(aircraft);
|
|
const color = this.getAircraftColor(type);
|
|
const size = aircraft.OnGround ? 12 : 16;
|
|
|
|
const svg = `
|
|
<svg width="${size * 2}" height="${size * 2}" viewBox="0 0 32 32">
|
|
<g transform="translate(16,16)">
|
|
<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z"
|
|
fill="${color}"
|
|
stroke="#ffffff"
|
|
stroke-width="1"
|
|
filter="drop-shadow(0 0 4px rgba(0,212,255,0.8))"/>
|
|
</g>
|
|
</svg>
|
|
`;
|
|
|
|
return L.divIcon({
|
|
html: svg,
|
|
iconSize: [size * 2, size * 2],
|
|
iconAnchor: [size, size],
|
|
className: 'aircraft-marker'
|
|
});
|
|
}
|
|
|
|
getAircraftType(aircraft) {
|
|
if (aircraft.OnGround) return 'ground';
|
|
if (aircraft.Category) {
|
|
const cat = aircraft.Category.toLowerCase();
|
|
if (cat.includes('military')) return 'military';
|
|
if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
|
|
if (cat.includes('light') || cat.includes('glider')) return 'ga';
|
|
}
|
|
if (aircraft.Callsign) {
|
|
const cs = aircraft.Callsign.toLowerCase();
|
|
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
|
|
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
|
|
}
|
|
return 'commercial';
|
|
}
|
|
|
|
getAircraftColor(type) {
|
|
const colors = {
|
|
commercial: '#00ff88',
|
|
cargo: '#ff8c00',
|
|
military: '#ff4444',
|
|
ga: '#ffff00',
|
|
ground: '#888888'
|
|
};
|
|
return colors[type] || colors.commercial;
|
|
}
|
|
|
|
updateMarkerRotation(marker, track) {
|
|
if (marker._icon) {
|
|
marker._icon.style.transform = `rotate(${track}deg)`;
|
|
}
|
|
}
|
|
|
|
updateSourceMarkers() {
|
|
// 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() {
|
|
// 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: 1,
|
|
opacity: 0.3 - (index * 0.1),
|
|
dashArray: '5,5'
|
|
}).addTo(this.map);
|
|
|
|
this.rangeCircles.set(`${id}_${range}`, circle);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
updateAircraftTrail(icao, pos) {
|
|
if (!this.aircraftTrails.has(icao)) {
|
|
this.aircraftTrails.set(icao, []);
|
|
}
|
|
|
|
const trail = this.aircraftTrails.get(icao);
|
|
trail.push(pos);
|
|
|
|
// Keep only last 50 positions
|
|
if (trail.length > 50) {
|
|
trail.shift();
|
|
}
|
|
|
|
// Draw polyline
|
|
const trailLine = L.polyline(trail, {
|
|
color: '#00d4ff',
|
|
weight: 2,
|
|
opacity: 0.6
|
|
}).addTo(this.map);
|
|
|
|
// Store reference for cleanup
|
|
if (!this.aircraftTrails.get(icao).polyline) {
|
|
this.aircraftTrails.get(icao).polyline = trailLine;
|
|
} else {
|
|
this.map.removeLayer(this.aircraftTrails.get(icao).polyline);
|
|
this.aircraftTrails.get(icao).polyline = trailLine;
|
|
}
|
|
}
|
|
|
|
// Popup Content
|
|
createPopupContent(aircraft) {
|
|
const type = this.getAircraftType(aircraft);
|
|
const country = this.getCountryFromICAO(aircraft.ICAO24 ? aircraft.ICAO24.toString(16).toUpperCase() : '');
|
|
const flag = this.getCountryFlag(country);
|
|
|
|
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0;
|
|
const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0;
|
|
const distance = this.calculateDistance(aircraft);
|
|
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
|
|
|
|
const sources = aircraft.Sources ? Object.keys(aircraft.Sources).map(id => {
|
|
const source = this.sourcesData.get(id);
|
|
const srcData = aircraft.Sources[id];
|
|
return `<span class="source-badge" title="Signal: ${srcData.SignalLevel?.toFixed(1)} dBFS">
|
|
${source?.name || id}
|
|
</span>`;
|
|
}).join('') : 'N/A';
|
|
|
|
return `
|
|
<div class="aircraft-popup">
|
|
<div class="popup-header">
|
|
<div class="flight-info">
|
|
<span class="icao-flag">${flag}</span>
|
|
<span class="flight-id">${aircraft.ICAO24 ? aircraft.ICAO24.toString(16).toUpperCase() : 'N/A'}</span>
|
|
${aircraft.Callsign ? `→ <span class="callsign">${aircraft.Callsign}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="popup-details">
|
|
<div class="detail-row">
|
|
<strong>Country:</strong> ${country}
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Type:</strong> ${type.charAt(0).toUpperCase() + type.slice(1)}
|
|
</div>
|
|
|
|
<div class="detail-grid">
|
|
<div class="detail-item">
|
|
<div class="label">Altitude:</div>
|
|
<div class="value">${altitude ? `${altitude} ft | ${altitudeM} m` : 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">Squawk:</div>
|
|
<div class="value">${aircraft.Squawk || 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">Speed:</div>
|
|
<div class="value">${aircraft.GroundSpeed ? `${aircraft.GroundSpeed} kt | ${speedKmh} km/h` : 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">Track:</div>
|
|
<div class="value">${aircraft.Track ? `${aircraft.Track}°` : 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">V/Rate:</div>
|
|
<div class="value">${aircraft.VerticalRate ? `${aircraft.VerticalRate} ft/min` : 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">Distance:</div>
|
|
<div class="value">${distanceKm} km</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sources-info">
|
|
<strong>Sources:</strong><br>
|
|
${sources}
|
|
</div>
|
|
|
|
<div class="detail-row">
|
|
<strong>Position:</strong> ${aircraft.Latitude?.toFixed(4)}°, ${aircraft.Longitude?.toFixed(4)}°
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Messages:</strong> ${aircraft.TotalMessages || 0}
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Age:</strong> ${aircraft.Age ? aircraft.Age.toFixed(1) : '0'}s
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
updatePopupContent(marker, aircraft) {
|
|
if (marker && marker.isPopupOpen()) {
|
|
const newContent = this.createPopupContent(aircraft);
|
|
marker.setPopupContent(newContent);
|
|
}
|
|
}
|
|
|
|
createSourcePopupContent(source) {
|
|
const aircraftCount = Array.from(this.aircraftData.values())
|
|
.filter(aircraft => aircraft.Sources && aircraft.Sources[source.id]).length;
|
|
|
|
return `
|
|
<div class="source-popup">
|
|
<h3>${source.name}</h3>
|
|
<p><strong>ID:</strong> ${source.id}</p>
|
|
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
|
|
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
|
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
|
|
<p><strong>Messages:</strong> ${source.messages || 0}</p>
|
|
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
updateSourcesLegend() {
|
|
const legend = document.getElementById('sources-legend');
|
|
legend.innerHTML = '';
|
|
|
|
for (const [id, source] of this.sourcesData) {
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.innerHTML = `
|
|
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
|
<span title="${source.host}:${source.port}">${source.name}</span>
|
|
`;
|
|
legend.appendChild(item);
|
|
}
|
|
}
|
|
|
|
// Table Management
|
|
updateAircraftTable() {
|
|
const tbody = document.getElementById('aircraft-tbody');
|
|
tbody.innerHTML = '';
|
|
|
|
let filteredData = Array.from(this.aircraftData.values());
|
|
|
|
// Apply filters
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
const sourceFilter = document.getElementById('source-filter').value;
|
|
|
|
if (searchTerm) {
|
|
filteredData = filteredData.filter(aircraft =>
|
|
(aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
|
|
(aircraft.ICAO24 && aircraft.ICAO24.toString(16).toLowerCase().includes(searchTerm)) ||
|
|
(aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
|
|
);
|
|
}
|
|
|
|
if (sourceFilter) {
|
|
filteredData = filteredData.filter(aircraft =>
|
|
aircraft.Sources && aircraft.Sources[sourceFilter]
|
|
);
|
|
}
|
|
|
|
// Sort data
|
|
const sortBy = document.getElementById('sort-select').value;
|
|
this.sortAircraft(filteredData, sortBy);
|
|
|
|
// Populate table
|
|
filteredData.forEach(aircraft => {
|
|
const row = this.createTableRow(aircraft);
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
// Update source filter options
|
|
this.updateSourceFilter();
|
|
}
|
|
|
|
createTableRow(aircraft) {
|
|
const type = this.getAircraftType(aircraft);
|
|
const icao = aircraft.ICAO24 ? aircraft.ICAO24.toString(16).toUpperCase() : 'N/A';
|
|
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
const distance = this.calculateDistance(aircraft);
|
|
const sources = aircraft.Sources ? Object.keys(aircraft.Sources).length : 0;
|
|
const bestSignal = this.getBestSignalFromSources(aircraft.Sources);
|
|
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td><span class="type-badge ${type}">${icao}</span></td>
|
|
<td>${aircraft.Callsign || '-'}</td>
|
|
<td>${aircraft.Squawk || '-'}</td>
|
|
<td>${altitude ? `${altitude} ft` : '-'}</td>
|
|
<td>${aircraft.GroundSpeed || '-'} kt</td>
|
|
<td>${distance ? distance.toFixed(1) : '-'} km</td>
|
|
<td>${aircraft.Track || '-'}°</td>
|
|
<td>${sources}</td>
|
|
<td><span class="${this.getSignalClass(bestSignal)}">${bestSignal ? bestSignal.toFixed(1) : '-'}</span></td>
|
|
<td>${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s</td>
|
|
`;
|
|
|
|
row.addEventListener('click', () => {
|
|
if (aircraft.Latitude && aircraft.Longitude) {
|
|
this.switchView('map');
|
|
this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
|
|
const marker = this.aircraftMarkers.get(icao);
|
|
if (marker) {
|
|
marker.openPopup();
|
|
}
|
|
}
|
|
});
|
|
|
|
return row;
|
|
}
|
|
|
|
getBestSignalFromSources(sources) {
|
|
if (!sources) return null;
|
|
let bestSignal = -999;
|
|
for (const [id, data] of Object.entries(sources)) {
|
|
if (data.SignalLevel > bestSignal) {
|
|
bestSignal = data.SignalLevel;
|
|
}
|
|
}
|
|
return bestSignal === -999 ? null : bestSignal;
|
|
}
|
|
|
|
getSignalClass(signal) {
|
|
if (!signal) return '';
|
|
if (signal > -10) return 'signal-strong';
|
|
if (signal > -20) return 'signal-good';
|
|
if (signal > -30) return 'signal-weak';
|
|
return 'signal-poor';
|
|
}
|
|
|
|
updateSourceFilter() {
|
|
const select = document.getElementById('source-filter');
|
|
const currentValue = select.value;
|
|
|
|
// Clear options except "All Sources"
|
|
select.innerHTML = '<option value="">All Sources</option>';
|
|
|
|
// Add source options
|
|
for (const [id, source] of this.sourcesData) {
|
|
const option = document.createElement('option');
|
|
option.value = id;
|
|
option.textContent = source.name;
|
|
if (id === currentValue) option.selected = true;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
sortAircraft(aircraft, sortBy) {
|
|
aircraft.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'distance':
|
|
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
case 'altitude':
|
|
return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
|
|
case 'speed':
|
|
return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
|
|
case 'flight':
|
|
return (a.Callsign || a.ICAO24?.toString(16) || '').localeCompare(b.Callsign || b.ICAO24?.toString(16) || '');
|
|
case 'icao':
|
|
return (a.ICAO24?.toString(16) || '').localeCompare(b.ICAO24?.toString(16) || '');
|
|
case 'squawk':
|
|
return (a.Squawk || '').localeCompare(b.Squawk || '');
|
|
case 'signal':
|
|
return (this.getBestSignalFromSources(b.Sources) || -999) - (this.getBestSignalFromSources(a.Sources) || -999);
|
|
case 'age':
|
|
return (a.Age || 0) - (b.Age || 0);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Statistics
|
|
updateStatistics() {
|
|
document.getElementById('total-aircraft').textContent = this.aircraftData.size;
|
|
document.getElementById('active-sources').textContent =
|
|
Array.from(this.sourcesData.values()).filter(s => s.active).length;
|
|
|
|
// Calculate max range
|
|
let maxDistance = 0;
|
|
for (const aircraft of this.aircraftData.values()) {
|
|
const distance = this.calculateDistance(aircraft);
|
|
if (distance && distance > maxDistance) {
|
|
maxDistance = distance;
|
|
}
|
|
}
|
|
document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} km`;
|
|
|
|
// Update message rate
|
|
const totalMessages = this.stats.total_messages || 0;
|
|
document.getElementById('messages-sec').textContent = Math.round(totalMessages / 60);
|
|
|
|
this.updateCharts();
|
|
}
|
|
|
|
updateHeaderInfo() {
|
|
document.getElementById('aircraft-count').textContent = `${this.aircraftData.size} aircraft`;
|
|
document.getElementById('sources-count').textContent = `${this.sourcesData.size} sources`;
|
|
|
|
this.updateClocks();
|
|
}
|
|
|
|
// Event Listeners
|
|
initializeEventListeners() {
|
|
document.getElementById('search-input').addEventListener('input', () => this.updateAircraftTable());
|
|
document.getElementById('sort-select').addEventListener('change', () => this.updateAircraftTable());
|
|
document.getElementById('source-filter').addEventListener('change', () => this.updateAircraftTable());
|
|
}
|
|
|
|
// Map Controls
|
|
centerMapOnAircraft() {
|
|
if (this.aircraftData.size === 0) return;
|
|
|
|
const validAircraft = Array.from(this.aircraftData.values())
|
|
.filter(a => a.Latitude && a.Longitude);
|
|
|
|
if (validAircraft.length === 0) return;
|
|
|
|
const group = new L.featureGroup(
|
|
validAircraft.map(a => L.marker([a.Latitude, a.Longitude]))
|
|
);
|
|
|
|
this.map.fitBounds(group.getBounds().pad(0.1));
|
|
}
|
|
|
|
toggleTrails() {
|
|
this.showTrails = !this.showTrails;
|
|
document.getElementById('toggle-trails').textContent =
|
|
this.showTrails ? 'Hide Trails' : 'Show Trails';
|
|
|
|
if (!this.showTrails) {
|
|
// Clear all trails
|
|
this.aircraftTrails.forEach((trail, icao) => {
|
|
if (trail.polyline) {
|
|
this.map.removeLayer(trail.polyline);
|
|
}
|
|
});
|
|
this.aircraftTrails.clear();
|
|
}
|
|
}
|
|
|
|
toggleRangeCircles() {
|
|
this.showRange = !this.showRange;
|
|
document.getElementById('toggle-range').textContent =
|
|
this.showRange ? 'Hide Range' : 'Show Range';
|
|
|
|
if (this.showRange) {
|
|
this.updateRangeCircles();
|
|
} else {
|
|
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
|
this.rangeCircles.clear();
|
|
}
|
|
}
|
|
|
|
toggleSources() {
|
|
this.showSources = !this.showSources;
|
|
document.getElementById('toggle-sources').textContent =
|
|
this.showSources ? 'Hide Sources' : 'Show Sources';
|
|
|
|
if (this.showSources) {
|
|
this.updateSourceMarkers();
|
|
} else {
|
|
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
|
|
this.sourceMarkers.clear();
|
|
}
|
|
}
|
|
|
|
// Coverage and Heatmap
|
|
updateCoverageControls() {
|
|
const select = document.getElementById('coverage-source');
|
|
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) 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;
|
|
}
|
|
|
|
if (this.heatmapLayer) {
|
|
this.coverageMap.removeLayer(this.heatmapLayer);
|
|
this.heatmapLayer = null;
|
|
document.getElementById('toggle-heatmap').textContent = 'Show Heatmap';
|
|
} else {
|
|
try {
|
|
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
|
|
const data = await response.json();
|
|
|
|
// Create heatmap layer (simplified)
|
|
// In a real implementation, you'd use a proper heatmap library
|
|
this.createHeatmapOverlay(data);
|
|
document.getElementById('toggle-heatmap').textContent = 'Hide Heatmap';
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load heatmap data:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
getSignalColor(signal) {
|
|
if (signal > -10) return '#00ff88';
|
|
if (signal > -20) return '#ffff00';
|
|
if (signal > -30) return '#ff8c00';
|
|
return '#ff4444';
|
|
}
|
|
|
|
// Charts (continued in next part due to length)
|
|
initializeCharts() {
|
|
this.charts.aircraft = new Chart(document.getElementById('aircraft-chart'), {
|
|
type: 'line',
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
label: 'Aircraft Count',
|
|
data: [],
|
|
borderColor: '#00d4ff',
|
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
tension: 0.4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
x: { display: false },
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: { color: '#888' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add other charts...
|
|
}
|
|
|
|
updateCharts() {
|
|
const now = new Date();
|
|
const timeLabel = now.toLocaleTimeString();
|
|
|
|
// Update aircraft count chart
|
|
if (this.charts.aircraft) {
|
|
const chart = this.charts.aircraft;
|
|
chart.data.labels.push(timeLabel);
|
|
chart.data.datasets[0].data.push(this.aircraftData.size);
|
|
|
|
if (chart.data.labels.length > 20) {
|
|
chart.data.labels.shift();
|
|
chart.data.datasets[0].data.shift();
|
|
}
|
|
|
|
chart.update('none');
|
|
}
|
|
}
|
|
|
|
// 3D Radar (basic implementation)
|
|
initialize3DRadar() {
|
|
try {
|
|
const container = document.getElementById('radar3d-container');
|
|
if (!container) return;
|
|
|
|
// Create scene
|
|
this.radar3d = {
|
|
scene: new THREE.Scene(),
|
|
camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000),
|
|
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
|
|
controls: null,
|
|
aircraftMeshes: new Map()
|
|
};
|
|
|
|
// Set up renderer
|
|
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
|
|
this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
|
|
container.appendChild(this.radar3d.renderer.domElement);
|
|
|
|
// Add lighting
|
|
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
|
|
this.radar3d.scene.add(ambientLight);
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
directionalLight.position.set(10, 10, 5);
|
|
this.radar3d.scene.add(directionalLight);
|
|
|
|
// Set up camera
|
|
this.radar3d.camera.position.set(0, 50, 50);
|
|
this.radar3d.camera.lookAt(0, 0, 0);
|
|
|
|
// Add controls
|
|
this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement);
|
|
this.radar3d.controls.enableDamping = true;
|
|
this.radar3d.controls.dampingFactor = 0.05;
|
|
|
|
// Add ground plane
|
|
const groundGeometry = new THREE.PlaneGeometry(200, 200);
|
|
const groundMaterial = new THREE.MeshLambertMaterial({
|
|
color: 0x2a4d3a,
|
|
transparent: true,
|
|
opacity: 0.5
|
|
});
|
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
|
ground.rotation.x = -Math.PI / 2;
|
|
this.radar3d.scene.add(ground);
|
|
|
|
// Add grid
|
|
const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44);
|
|
this.radar3d.scene.add(gridHelper);
|
|
|
|
// Start render loop
|
|
this.render3DRadar();
|
|
|
|
console.log('3D Radar initialized successfully');
|
|
} catch (error) {
|
|
console.error('Failed to initialize 3D radar:', error);
|
|
}
|
|
}
|
|
|
|
update3DRadar() {
|
|
if (!this.radar3d || !this.radar3d.scene) return;
|
|
|
|
try {
|
|
// Update aircraft positions in 3D space
|
|
this.aircraftData.forEach((aircraft, icao) => {
|
|
if (aircraft.Latitude && aircraft.Longitude) {
|
|
const key = icao.toString();
|
|
|
|
if (!this.radar3d.aircraftMeshes.has(key)) {
|
|
// Create new aircraft mesh
|
|
const geometry = new THREE.ConeGeometry(0.5, 2, 6);
|
|
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
this.radar3d.aircraftMeshes.set(key, mesh);
|
|
this.radar3d.scene.add(mesh);
|
|
}
|
|
|
|
const mesh = this.radar3d.aircraftMeshes.get(key);
|
|
|
|
// Convert lat/lon to local coordinates (simplified)
|
|
const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
|
|
const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000;
|
|
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
|
|
|
|
mesh.position.set(x, y, z);
|
|
|
|
// Orient mesh based on track
|
|
if (aircraft.Track !== undefined) {
|
|
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Remove old aircraft
|
|
this.radar3d.aircraftMeshes.forEach((mesh, key) => {
|
|
if (!this.aircraftData.has(parseInt(key, 16))) {
|
|
this.radar3d.scene.remove(mesh);
|
|
this.radar3d.aircraftMeshes.delete(key);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update 3D radar:', error);
|
|
}
|
|
}
|
|
|
|
render3DRadar() {
|
|
if (!this.radar3d) return;
|
|
|
|
requestAnimationFrame(() => this.render3DRadar());
|
|
|
|
if (this.radar3d.controls) {
|
|
this.radar3d.controls.update();
|
|
}
|
|
|
|
this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
|
|
}
|
|
|
|
// Utility functions
|
|
updateConnectionStatus(status) {
|
|
const statusEl = document.getElementById('connection-status');
|
|
statusEl.className = `connection-status ${status}`;
|
|
statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
|
}
|
|
|
|
initializeClocks() {
|
|
this.updateClocks();
|
|
}
|
|
|
|
updateClocks() {
|
|
const now = new Date();
|
|
const utcNow = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
|
|
|
|
this.updateClock('utc', utcNow);
|
|
this.updateClock('update', this.lastUpdateTime);
|
|
}
|
|
|
|
updateClock(prefix, time) {
|
|
const hours = time.getUTCHours();
|
|
const minutes = time.getUTCMinutes();
|
|
|
|
const hourAngle = (hours % 12) * 30 + minutes * 0.5;
|
|
const minuteAngle = minutes * 6;
|
|
|
|
const hourHand = document.getElementById(`${prefix}-hour`);
|
|
const minuteHand = document.getElementById(`${prefix}-minute`);
|
|
|
|
if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`;
|
|
if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`;
|
|
}
|
|
|
|
calculateDistance(aircraft) {
|
|
if (!aircraft.Latitude || !aircraft.Longitude) return null;
|
|
|
|
// Use closest source as reference point
|
|
let minDistance = Infinity;
|
|
for (const [id, srcData] of Object.entries(aircraft.Sources || {})) {
|
|
if (srcData.Distance && srcData.Distance < minDistance) {
|
|
minDistance = srcData.Distance;
|
|
}
|
|
}
|
|
|
|
return minDistance === Infinity ? null : minDistance;
|
|
}
|
|
|
|
getCountryFromICAO(icao) {
|
|
if (!icao || icao.length < 6) return 'Unknown';
|
|
|
|
const prefix = icao[0];
|
|
const countryMap = {
|
|
'4': 'Europe',
|
|
'A': 'United States',
|
|
'C': 'Canada',
|
|
'D': 'Germany',
|
|
'F': 'France',
|
|
'G': 'United Kingdom',
|
|
'I': 'Italy',
|
|
'J': 'Japan'
|
|
};
|
|
|
|
return countryMap[prefix] || 'Unknown';
|
|
}
|
|
|
|
getCountryFlag(country) {
|
|
const flags = {
|
|
'United States': '🇺🇸',
|
|
'Canada': '🇨🇦',
|
|
'Germany': '🇩🇪',
|
|
'France': '🇫🇷',
|
|
'United Kingdom': '🇬🇧',
|
|
'Italy': '🇮🇹',
|
|
'Japan': '🇯🇵',
|
|
'Europe': '🇪🇺'
|
|
};
|
|
return flags[country] || '🏳️';
|
|
}
|
|
|
|
startPeriodicTasks() {
|
|
// Update clocks every second
|
|
setInterval(() => this.updateClocks(), 1000);
|
|
|
|
// Periodic cleanup
|
|
setInterval(() => {
|
|
// Clean up old trail data, etc.
|
|
}, 30000);
|
|
}
|
|
|
|
showError(message) {
|
|
console.error(message);
|
|
// Could implement toast notifications here
|
|
}
|
|
}
|
|
|
|
// Initialize application when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.skyview = new SkyView();
|
|
}); |