2025-08-23 22:09:37 +02:00
|
|
|
class SkyView {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.map = null;
|
|
|
|
|
this.aircraftMarkers = new Map();
|
|
|
|
|
this.aircraftTrails = new Map();
|
2025-08-23 22:52:16 +02:00
|
|
|
this.historicalTracks = new Map();
|
2025-08-23 22:09:37 +02:00
|
|
|
this.websocket = null;
|
|
|
|
|
this.aircraftData = [];
|
|
|
|
|
this.showTrails = false;
|
2025-08-23 22:52:16 +02:00
|
|
|
this.showHistoricalTracks = false;
|
2025-08-23 22:09:37 +02:00
|
|
|
this.currentView = 'map';
|
|
|
|
|
this.charts = {};
|
|
|
|
|
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
|
2025-08-23 23:20:31 +02:00
|
|
|
this.lastUpdateTime = new Date();
|
2025-08-23 22:09:37 +02:00
|
|
|
|
|
|
|
|
this.init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
this.loadConfig().then(() => {
|
|
|
|
|
this.initializeViews();
|
|
|
|
|
this.initializeMap();
|
|
|
|
|
this.initializeWebSocket();
|
|
|
|
|
this.initializeEventListeners();
|
|
|
|
|
this.initializeCharts();
|
2025-08-23 23:20:31 +02:00
|
|
|
this.initializeClocks();
|
2025-08-23 22:09:37 +02:00
|
|
|
|
|
|
|
|
this.startPeriodicUpdates();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadConfig() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/config');
|
|
|
|
|
const config = await response.json();
|
|
|
|
|
if (config.origin) {
|
|
|
|
|
this.origin = config.origin;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to load config, using defaults:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
viewButtons.forEach(b => b.classList.remove('active'));
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
|
|
|
|
|
views.forEach(v => v.classList.remove('active'));
|
|
|
|
|
document.getElementById(viewId).classList.add('active');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switchView(view) {
|
|
|
|
|
this.currentView = view;
|
|
|
|
|
if (view === 'map' && this.map) {
|
|
|
|
|
setTimeout(() => this.map.invalidateSize(), 100);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initializeMap() {
|
|
|
|
|
this.map = L.map('map', {
|
|
|
|
|
center: [this.origin.latitude, this.origin.longitude],
|
|
|
|
|
zoom: 8,
|
|
|
|
|
zoomControl: true
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
|
|
|
attribution: '© OpenStreetMap contributors'
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
|
|
|
|
L.marker([this.origin.latitude, this.origin.longitude], {
|
|
|
|
|
icon: L.divIcon({
|
|
|
|
|
html: '<div style="background: #e74c3c; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;"></div>',
|
|
|
|
|
className: 'origin-marker',
|
|
|
|
|
iconSize: [16, 16],
|
|
|
|
|
iconAnchor: [8, 8]
|
|
|
|
|
})
|
|
|
|
|
}).addTo(this.map).bindPopup(`<b>Origin</b><br>${this.origin.name}`);
|
|
|
|
|
|
|
|
|
|
L.circle([this.origin.latitude, this.origin.longitude], {
|
|
|
|
|
radius: 185200,
|
|
|
|
|
fillColor: 'transparent',
|
|
|
|
|
color: '#404040',
|
|
|
|
|
weight: 1,
|
|
|
|
|
opacity: 0.5
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
|
|
|
|
const centerBtn = document.getElementById('center-map');
|
|
|
|
|
centerBtn.addEventListener('click', () => this.centerMapOnAircraft());
|
|
|
|
|
|
|
|
|
|
const trailsBtn = document.getElementById('toggle-trails');
|
|
|
|
|
trailsBtn.addEventListener('click', () => this.toggleTrails());
|
2025-08-23 22:52:16 +02:00
|
|
|
|
|
|
|
|
const historyBtn = document.getElementById('toggle-history');
|
|
|
|
|
historyBtn.addEventListener('click', () => this.toggleHistoricalTracks());
|
2025-08-23 22:09:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initializeWebSocket() {
|
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
|
|
|
|
|
|
|
|
this.websocket = new WebSocket(wsUrl);
|
|
|
|
|
|
|
|
|
|
this.websocket.onopen = () => {
|
|
|
|
|
document.getElementById('connection-status').textContent = 'Connected';
|
|
|
|
|
document.getElementById('connection-status').className = 'connection-status connected';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.websocket.onclose = () => {
|
|
|
|
|
document.getElementById('connection-status').textContent = 'Disconnected';
|
|
|
|
|
document.getElementById('connection-status').className = 'connection-status disconnected';
|
|
|
|
|
setTimeout(() => this.initializeWebSocket(), 5000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.websocket.onmessage = (event) => {
|
|
|
|
|
const message = JSON.parse(event.data);
|
|
|
|
|
if (message.type === 'aircraft_update') {
|
|
|
|
|
this.updateAircraftData(message.data);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initializeEventListeners() {
|
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
|
|
|
const sortSelect = document.getElementById('sort-select');
|
|
|
|
|
|
|
|
|
|
searchInput.addEventListener('input', () => this.filterAircraftTable());
|
|
|
|
|
sortSelect.addEventListener('change', () => this.sortAircraftTable());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initializeCharts() {
|
|
|
|
|
const aircraftCtx = document.getElementById('aircraft-chart').getContext('2d');
|
|
|
|
|
const messageCtx = document.getElementById('message-chart').getContext('2d');
|
|
|
|
|
|
|
|
|
|
this.charts.aircraft = new Chart(aircraftCtx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: {
|
|
|
|
|
labels: [],
|
|
|
|
|
datasets: [{
|
|
|
|
|
label: 'Aircraft Count',
|
|
|
|
|
data: [],
|
|
|
|
|
borderColor: '#00a8ff',
|
|
|
|
|
backgroundColor: 'rgba(0, 168, 255, 0.1)',
|
|
|
|
|
tension: 0.4
|
|
|
|
|
}]
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
plugins: {
|
|
|
|
|
legend: { display: false }
|
|
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
y: { beginAtZero: true }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.charts.messages = new Chart(messageCtx, {
|
|
|
|
|
type: 'line',
|
|
|
|
|
data: {
|
|
|
|
|
labels: [],
|
|
|
|
|
datasets: [{
|
|
|
|
|
label: 'Messages/sec',
|
|
|
|
|
data: [],
|
|
|
|
|
borderColor: '#2ecc71',
|
|
|
|
|
backgroundColor: 'rgba(46, 204, 113, 0.1)',
|
|
|
|
|
tension: 0.4
|
|
|
|
|
}]
|
|
|
|
|
},
|
|
|
|
|
options: {
|
|
|
|
|
responsive: true,
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
plugins: {
|
|
|
|
|
legend: { display: false }
|
|
|
|
|
},
|
|
|
|
|
scales: {
|
|
|
|
|
y: { beginAtZero: true }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 23:20:31 +02:00
|
|
|
initializeClocks() {
|
|
|
|
|
this.updateClocks();
|
|
|
|
|
setInterval(() => this.updateClocks(), 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
updateAircraftData(data) {
|
|
|
|
|
this.aircraftData = data.aircraft || [];
|
2025-08-23 23:20:31 +02:00
|
|
|
this.lastUpdateTime = new Date();
|
2025-08-23 22:09:37 +02:00
|
|
|
this.updateMapMarkers();
|
|
|
|
|
this.updateAircraftTable();
|
|
|
|
|
this.updateStats();
|
|
|
|
|
|
|
|
|
|
document.getElementById('aircraft-count').textContent = `${this.aircraftData.length} aircraft`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateMapMarkers() {
|
|
|
|
|
const currentHexCodes = new Set(this.aircraftData.map(a => a.hex));
|
|
|
|
|
|
|
|
|
|
this.aircraftMarkers.forEach((marker, hex) => {
|
|
|
|
|
if (!currentHexCodes.has(hex)) {
|
|
|
|
|
this.map.removeLayer(marker);
|
|
|
|
|
this.aircraftMarkers.delete(hex);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.aircraftData.forEach(aircraft => {
|
|
|
|
|
if (!aircraft.lat || !aircraft.lon) return;
|
|
|
|
|
|
|
|
|
|
const pos = [aircraft.lat, aircraft.lon];
|
|
|
|
|
|
|
|
|
|
if (this.aircraftMarkers.has(aircraft.hex)) {
|
|
|
|
|
const marker = this.aircraftMarkers.get(aircraft.hex);
|
|
|
|
|
marker.setLatLng(pos);
|
2025-08-23 22:45:43 +02:00
|
|
|
this.updateMarkerRotation(marker, aircraft.track, aircraft);
|
2025-08-23 22:09:37 +02:00
|
|
|
this.updatePopupContent(marker, aircraft);
|
|
|
|
|
} else {
|
|
|
|
|
const marker = this.createAircraftMarker(aircraft, pos);
|
|
|
|
|
this.aircraftMarkers.set(aircraft.hex, marker);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.showTrails) {
|
|
|
|
|
this.updateTrail(aircraft.hex, pos);
|
|
|
|
|
}
|
2025-08-23 22:52:16 +02:00
|
|
|
|
|
|
|
|
if (this.showHistoricalTracks && aircraft.track_history && aircraft.track_history.length > 1) {
|
|
|
|
|
this.displayHistoricalTrack(aircraft.hex, aircraft.track_history, aircraft.flight);
|
|
|
|
|
}
|
2025-08-23 22:09:37 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createAircraftMarker(aircraft, pos) {
|
2025-08-23 22:45:43 +02:00
|
|
|
const hasPosition = aircraft.lat && aircraft.lon;
|
|
|
|
|
const size = hasPosition ? 24 : 18;
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
const icon = L.divIcon({
|
|
|
|
|
html: this.getAircraftIcon(aircraft),
|
|
|
|
|
className: 'aircraft-marker',
|
2025-08-23 22:45:43 +02:00
|
|
|
iconSize: [size, size],
|
|
|
|
|
iconAnchor: [size/2, size/2]
|
2025-08-23 22:09:37 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const marker = L.marker(pos, { icon }).addTo(this.map);
|
|
|
|
|
|
|
|
|
|
marker.bindPopup(this.createPopupContent(aircraft), {
|
|
|
|
|
className: 'aircraft-popup'
|
|
|
|
|
});
|
2025-08-23 22:52:16 +02:00
|
|
|
|
|
|
|
|
marker.on('click', () => {
|
|
|
|
|
if (this.showHistoricalTracks) {
|
|
|
|
|
this.loadAircraftHistory(aircraft.hex);
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-08-23 22:09:37 +02:00
|
|
|
|
|
|
|
|
return marker;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:48:20 +02:00
|
|
|
getAircraftType(aircraft) {
|
|
|
|
|
if (aircraft.on_ground) return 'ground';
|
|
|
|
|
|
|
|
|
|
// Determine type based on flight number patterns and characteristics
|
|
|
|
|
const flight = aircraft.flight || '';
|
|
|
|
|
|
|
|
|
|
// Cargo airlines (simplified patterns)
|
|
|
|
|
if (/^(UPS|FDX|FX|ABX|ATN|5Y|QY)/i.test(flight)) return 'cargo';
|
|
|
|
|
|
|
|
|
|
// Military patterns (basic)
|
|
|
|
|
if (/^(RCH|CNV|MAC|EVAC|ARMY|NAVY|AF|USAF)/i.test(flight)) return 'military';
|
|
|
|
|
|
|
|
|
|
// General aviation (no airline code pattern)
|
|
|
|
|
if (flight.length > 0 && !/^[A-Z]{2,3}[0-9]/.test(flight)) return 'ga';
|
|
|
|
|
|
|
|
|
|
// Default commercial
|
|
|
|
|
return 'commercial';
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
getAircraftIcon(aircraft) {
|
|
|
|
|
const rotation = aircraft.track || 0;
|
2025-08-23 22:45:43 +02:00
|
|
|
const hasPosition = aircraft.lat && aircraft.lon;
|
2025-08-23 22:48:20 +02:00
|
|
|
const type = this.getAircraftType(aircraft);
|
2025-08-23 22:45:43 +02:00
|
|
|
const size = hasPosition ? 24 : 18;
|
|
|
|
|
|
2025-08-23 22:48:20 +02:00
|
|
|
let color, icon;
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'cargo':
|
|
|
|
|
color = hasPosition ? "#ff8c00" : "#666666";
|
|
|
|
|
icon = `<rect x="8" y="6" width="8" height="12" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
|
|
|
<polygon points="12,2 10,6 14,6" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="4,10 8,8 8,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="20,10 16,8 16,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'military':
|
|
|
|
|
color = hasPosition ? "#ff4444" : "#666666";
|
|
|
|
|
icon = `<polygon points="12,2 9,20 12,18 15,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
|
|
|
<polygon points="6,8 12,6 12,10" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="18,8 12,6 12,10" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="8,16 12,14 12,18" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="16,16 12,14 12,18" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'ga':
|
|
|
|
|
color = hasPosition ? "#ffff00" : "#666666";
|
|
|
|
|
icon = `<polygon points="12,2 11,20 12,19 13,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
|
|
|
<polygon points="7,12 12,10 12,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="17,12 12,10 12,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
|
|
|
break;
|
|
|
|
|
case 'ground':
|
|
|
|
|
color = "#888888";
|
|
|
|
|
icon = `<circle cx="12" cy="12" r="6" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
|
|
|
<text x="12" y="16" text-anchor="middle" font-size="8" fill="#ffffff">G</text>`;
|
|
|
|
|
break;
|
|
|
|
|
default: // commercial
|
|
|
|
|
color = hasPosition ? "#00ff88" : "#666666";
|
|
|
|
|
icon = `<polygon points="12,2 10,20 12,18 14,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
|
|
|
<polygon points="5,10 12,8 12,12" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
|
|
|
<polygon points="19,10 12,8 12,12" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:45:43 +02:00
|
|
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 3px rgba(0,0,0,0.8));">
|
2025-08-23 22:48:20 +02:00
|
|
|
${icon}
|
2025-08-23 22:09:37 +02:00
|
|
|
</svg>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:45:43 +02:00
|
|
|
updateMarkerRotation(marker, track, aircraft) {
|
2025-08-23 22:09:37 +02:00
|
|
|
if (track !== undefined) {
|
2025-08-23 22:45:43 +02:00
|
|
|
const hasPosition = aircraft.lat && aircraft.lon;
|
|
|
|
|
const size = hasPosition ? 24 : 18;
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
const icon = L.divIcon({
|
2025-08-23 22:45:43 +02:00
|
|
|
html: this.getAircraftIcon(aircraft),
|
2025-08-23 22:09:37 +02:00
|
|
|
className: 'aircraft-marker',
|
2025-08-23 22:45:43 +02:00
|
|
|
iconSize: [size, size],
|
|
|
|
|
iconAnchor: [size/2, size/2]
|
2025-08-23 22:09:37 +02:00
|
|
|
});
|
|
|
|
|
marker.setIcon(icon);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createPopupContent(aircraft) {
|
2025-08-23 23:20:31 +02:00
|
|
|
const type = this.getAircraftType(aircraft);
|
|
|
|
|
const distance = this.calculateDistance(aircraft);
|
|
|
|
|
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
|
|
|
|
|
const altitudeM = aircraft.alt_baro ? Math.round(aircraft.alt_baro * 0.3048) : 'N/A';
|
|
|
|
|
const speedKmh = aircraft.gs ? Math.round(aircraft.gs * 1.852) : 'N/A';
|
|
|
|
|
const trackText = aircraft.track ? `${aircraft.track}° (${this.getTrackDirection(aircraft.track)})` : 'N/A';
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
return `
|
|
|
|
|
<div class="aircraft-popup">
|
2025-08-23 23:20:31 +02:00
|
|
|
<div class="popup-header">
|
|
|
|
|
<div class="flight-info">
|
|
|
|
|
<span class="icao-flag">${this.getCountryFlag(aircraft.country)}</span>
|
|
|
|
|
<span class="flight-id">${aircraft.hex}</span>
|
|
|
|
|
${aircraft.flight ? `→ <span class="callsign">${aircraft.flight}</span>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="popup-details">
|
|
|
|
|
<div class="detail-row">
|
|
|
|
|
<strong>Country of registration:</strong> ${aircraft.country || 'Unknown'}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="detail-row">
|
|
|
|
|
<strong>Registration:</strong> ${aircraft.registration || aircraft.hex}
|
|
|
|
|
</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">${aircraft.alt_baro ? `▲ ${aircraft.alt_baro} 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.gs ? `${aircraft.gs} kt | ${speedKmh} km/h` : 'N/A'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="detail-item">
|
|
|
|
|
<div class="label">RSSI:</div>
|
|
|
|
|
<div class="value">${aircraft.rssi ? `${aircraft.rssi.toFixed(1)} dBFS` : 'N/A'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="detail-item">
|
|
|
|
|
<div class="label">Track:</div>
|
|
|
|
|
<div class="value">${trackText}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="detail-item">
|
|
|
|
|
<div class="label">Last seen:</div>
|
|
|
|
|
<div class="value">${aircraft.seen ? `${aircraft.seen.toFixed(1)}s ago` : 'now'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="detail-row">
|
|
|
|
|
<strong>Position:</strong> ${aircraft.lat && aircraft.lon ?
|
|
|
|
|
`${aircraft.lat.toFixed(3)}°, ${aircraft.lon.toFixed(3)}°` : 'N/A'}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="detail-row">
|
|
|
|
|
<strong>Distance from Site:</strong> ${distance ? `${distance} NM | ${distanceKm} km` : 'N/A'}
|
|
|
|
|
</div>
|
2025-08-23 22:09:37 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
2025-08-23 23:20:31 +02:00
|
|
|
|
|
|
|
|
getCountryFlag(country) {
|
|
|
|
|
const flags = {
|
|
|
|
|
'United States': '🇺🇸',
|
|
|
|
|
'United Kingdom': '🇬🇧',
|
|
|
|
|
'Germany': '🇩🇪',
|
|
|
|
|
'France': '🇫🇷',
|
|
|
|
|
'Netherlands': '🇳🇱',
|
|
|
|
|
'Sweden': '🇸🇪',
|
|
|
|
|
'Spain': '🇪🇸',
|
|
|
|
|
'Italy': '🇮🇹',
|
|
|
|
|
'Canada': '🇨🇦',
|
|
|
|
|
'Japan': '🇯🇵',
|
|
|
|
|
'Denmark': '🇩🇰',
|
|
|
|
|
'Austria': '🇦🇹',
|
|
|
|
|
'Belgium': '🇧🇪',
|
|
|
|
|
'Finland': '🇫🇮',
|
|
|
|
|
'Greece': '🇬🇷'
|
|
|
|
|
};
|
|
|
|
|
return flags[country] || '🏳️';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getTrackDirection(track) {
|
|
|
|
|
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
|
|
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
|
|
|
const index = Math.round(track / 22.5) % 16;
|
|
|
|
|
return directions[index];
|
|
|
|
|
}
|
2025-08-23 22:09:37 +02:00
|
|
|
|
|
|
|
|
updatePopupContent(marker, aircraft) {
|
|
|
|
|
marker.setPopupContent(this.createPopupContent(aircraft));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTrail(hex, pos) {
|
|
|
|
|
if (!this.aircraftTrails.has(hex)) {
|
|
|
|
|
this.aircraftTrails.set(hex, []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trail = this.aircraftTrails.get(hex);
|
|
|
|
|
trail.push(pos);
|
|
|
|
|
|
|
|
|
|
if (trail.length > 50) {
|
|
|
|
|
trail.shift();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const polyline = L.polyline(trail, {
|
|
|
|
|
color: '#00a8ff',
|
|
|
|
|
weight: 2,
|
|
|
|
|
opacity: 0.6
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleTrails() {
|
|
|
|
|
this.showTrails = !this.showTrails;
|
|
|
|
|
|
|
|
|
|
if (!this.showTrails) {
|
|
|
|
|
this.aircraftTrails.clear();
|
|
|
|
|
this.map.eachLayer(layer => {
|
|
|
|
|
if (layer instanceof L.Polyline) {
|
|
|
|
|
this.map.removeLayer(layer);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('toggle-trails').textContent =
|
|
|
|
|
this.showTrails ? 'Hide Trails' : 'Show Trails';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
centerMapOnAircraft() {
|
|
|
|
|
if (this.aircraftData.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const validAircraft = this.aircraftData.filter(a => a.lat && a.lon);
|
|
|
|
|
if (validAircraft.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const group = new L.featureGroup(
|
|
|
|
|
validAircraft.map(a => L.marker([a.lat, a.lon]))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.map.fitBounds(group.getBounds().pad(0.1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateAircraftTable() {
|
|
|
|
|
const tbody = document.getElementById('aircraft-tbody');
|
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
let filteredData = [...this.aircraftData];
|
|
|
|
|
|
|
|
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
|
|
|
if (searchTerm) {
|
|
|
|
|
filteredData = filteredData.filter(aircraft =>
|
|
|
|
|
(aircraft.flight && aircraft.flight.toLowerCase().includes(searchTerm)) ||
|
2025-08-23 23:20:31 +02:00
|
|
|
aircraft.hex.toLowerCase().includes(searchTerm) ||
|
|
|
|
|
(aircraft.squawk && aircraft.squawk.includes(searchTerm))
|
2025-08-23 22:09:37 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortBy = document.getElementById('sort-select').value;
|
|
|
|
|
this.sortAircraft(filteredData, sortBy);
|
|
|
|
|
|
|
|
|
|
filteredData.forEach(aircraft => {
|
2025-08-23 22:52:16 +02:00
|
|
|
const type = this.getAircraftType(aircraft);
|
2025-08-23 23:20:31 +02:00
|
|
|
const country = aircraft.country || 'Unknown';
|
|
|
|
|
const countryFlag = this.getCountryFlag(country);
|
|
|
|
|
const age = aircraft.seen ? aircraft.seen.toFixed(0) : '0';
|
|
|
|
|
const distance = this.calculateDistance(aircraft);
|
|
|
|
|
const distanceStr = distance ? distance.toFixed(1) : '-';
|
|
|
|
|
const altitudeStr = aircraft.alt_baro ?
|
|
|
|
|
(aircraft.alt_baro >= 0 ? `▲ ${aircraft.alt_baro}` : `▼ ${Math.abs(aircraft.alt_baro)}`) :
|
|
|
|
|
'-';
|
2025-08-23 22:52:16 +02:00
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
const row = document.createElement('tr');
|
2025-08-23 23:20:31 +02:00
|
|
|
// Color code RSSI values
|
|
|
|
|
let rssiStr = '-';
|
|
|
|
|
let rssiClass = '';
|
|
|
|
|
if (aircraft.rssi) {
|
|
|
|
|
const rssi = aircraft.rssi;
|
|
|
|
|
rssiStr = rssi.toFixed(1);
|
|
|
|
|
if (rssi > -10) rssiClass = 'rssi-strong';
|
|
|
|
|
else if (rssi > -20) rssiClass = 'rssi-good';
|
|
|
|
|
else if (rssi > -30) rssiClass = 'rssi-weak';
|
|
|
|
|
else rssiClass = 'rssi-poor';
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 22:09:37 +02:00
|
|
|
row.innerHTML = `
|
2025-08-23 23:20:31 +02:00
|
|
|
<td><span class="type-badge ${type}">${aircraft.hex}</span></td>
|
2025-08-23 22:09:37 +02:00
|
|
|
<td>${aircraft.flight || '-'}</td>
|
2025-08-23 23:20:31 +02:00
|
|
|
<td>${aircraft.squawk || '-'}</td>
|
|
|
|
|
<td>${altitudeStr}</td>
|
2025-08-23 22:09:37 +02:00
|
|
|
<td>${aircraft.gs || '-'}</td>
|
2025-08-23 23:20:31 +02:00
|
|
|
<td>${distanceStr}</td>
|
|
|
|
|
<td>${aircraft.track || '-'}°</td>
|
2025-08-23 22:09:37 +02:00
|
|
|
<td>${aircraft.messages || '-'}</td>
|
2025-08-23 23:20:31 +02:00
|
|
|
<td>${age}</td>
|
|
|
|
|
<td><span class="${rssiClass}">${rssiStr}</span></td>
|
2025-08-23 22:09:37 +02:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
row.addEventListener('click', () => {
|
|
|
|
|
if (aircraft.lat && aircraft.lon) {
|
|
|
|
|
this.switchView('map');
|
|
|
|
|
document.getElementById('map-view-btn').classList.add('active');
|
|
|
|
|
document.querySelectorAll('.view-btn:not(#map-view-btn)').forEach(btn =>
|
|
|
|
|
btn.classList.remove('active'));
|
|
|
|
|
document.querySelectorAll('.view:not(#map-view)').forEach(view =>
|
|
|
|
|
view.classList.remove('active'));
|
|
|
|
|
document.getElementById('map-view').classList.add('active');
|
|
|
|
|
|
|
|
|
|
this.map.setView([aircraft.lat, aircraft.lon], 12);
|
|
|
|
|
|
|
|
|
|
const marker = this.aircraftMarkers.get(aircraft.hex);
|
|
|
|
|
if (marker) {
|
|
|
|
|
marker.openPopup();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tbody.appendChild(row);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filterAircraftTable() {
|
|
|
|
|
this.updateAircraftTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sortAircraftTable() {
|
|
|
|
|
this.updateAircraftTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sortAircraft(aircraft, sortBy) {
|
|
|
|
|
aircraft.sort((a, b) => {
|
|
|
|
|
switch (sortBy) {
|
|
|
|
|
case 'distance':
|
|
|
|
|
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
|
|
|
case 'altitude':
|
|
|
|
|
return (b.alt_baro || 0) - (a.alt_baro || 0);
|
|
|
|
|
case 'speed':
|
|
|
|
|
return (b.gs || 0) - (a.gs || 0);
|
|
|
|
|
case 'flight':
|
|
|
|
|
return (a.flight || a.hex).localeCompare(b.flight || b.hex);
|
2025-08-23 23:20:31 +02:00
|
|
|
case 'icao':
|
|
|
|
|
return a.hex.localeCompare(b.hex);
|
|
|
|
|
case 'squawk':
|
|
|
|
|
return (a.squawk || '').localeCompare(b.squawk || '');
|
|
|
|
|
case 'age':
|
|
|
|
|
return (a.seen || 0) - (b.seen || 0);
|
|
|
|
|
case 'rssi':
|
|
|
|
|
return (b.rssi || -999) - (a.rssi || -999);
|
2025-08-23 22:09:37 +02:00
|
|
|
default:
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
calculateDistance(aircraft) {
|
|
|
|
|
if (!aircraft.lat || !aircraft.lon) return null;
|
|
|
|
|
|
|
|
|
|
const centerLat = this.origin.latitude;
|
|
|
|
|
const centerLng = this.origin.longitude;
|
|
|
|
|
|
|
|
|
|
const R = 3440.065;
|
|
|
|
|
const dLat = (aircraft.lat - centerLat) * Math.PI / 180;
|
|
|
|
|
const dLon = (aircraft.lon - centerLng) * Math.PI / 180;
|
|
|
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
|
|
|
Math.cos(centerLat * Math.PI / 180) * Math.cos(aircraft.lat * Math.PI / 180) *
|
|
|
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
|
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
2025-08-23 23:20:31 +02:00
|
|
|
return R * c; // Return a number, not a string
|
2025-08-23 22:09:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateStats() {
|
|
|
|
|
document.getElementById('total-aircraft').textContent = this.aircraftData.length;
|
|
|
|
|
|
|
|
|
|
const withPosition = this.aircraftData.filter(a => a.lat && a.lon).length;
|
|
|
|
|
const avgAltitude = this.aircraftData
|
|
|
|
|
.filter(a => a.alt_baro)
|
|
|
|
|
.reduce((sum, a) => sum + a.alt_baro, 0) / this.aircraftData.length || 0;
|
|
|
|
|
|
2025-08-23 23:20:31 +02:00
|
|
|
const distances = this.aircraftData
|
2025-08-23 22:09:37 +02:00
|
|
|
.map(a => this.calculateDistance(a))
|
2025-08-23 23:20:31 +02:00
|
|
|
.filter(d => d !== null);
|
|
|
|
|
const maxDistance = distances.length > 0 ? Math.max(...distances) : 0;
|
2025-08-23 22:09:37 +02:00
|
|
|
|
2025-08-23 23:20:31 +02:00
|
|
|
document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} nm`;
|
2025-08-23 22:09:37 +02:00
|
|
|
|
|
|
|
|
this.updateChartData();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateChartData() {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const timeLabel = now.toLocaleTimeString();
|
|
|
|
|
|
|
|
|
|
if (this.charts.aircraft) {
|
|
|
|
|
const chart = this.charts.aircraft;
|
|
|
|
|
chart.data.labels.push(timeLabel);
|
|
|
|
|
chart.data.datasets[0].data.push(this.aircraftData.length);
|
|
|
|
|
|
|
|
|
|
if (chart.data.labels.length > 20) {
|
|
|
|
|
chart.data.labels.shift();
|
|
|
|
|
chart.data.datasets[0].data.shift();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chart.update('none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.charts.messages) {
|
|
|
|
|
const chart = this.charts.messages;
|
|
|
|
|
const totalMessages = this.aircraftData.reduce((sum, a) => sum + (a.messages || 0), 0);
|
|
|
|
|
const messagesPerSec = totalMessages / 60;
|
|
|
|
|
|
|
|
|
|
chart.data.labels.push(timeLabel);
|
|
|
|
|
chart.data.datasets[0].data.push(messagesPerSec);
|
|
|
|
|
|
|
|
|
|
if (chart.data.labels.length > 20) {
|
|
|
|
|
chart.data.labels.shift();
|
|
|
|
|
chart.data.datasets[0].data.shift();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chart.update('none');
|
|
|
|
|
|
|
|
|
|
document.getElementById('messages-sec').textContent = Math.round(messagesPerSec);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
startPeriodicUpdates() {
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
if (this.websocket.readyState !== WebSocket.OPEN) {
|
|
|
|
|
this.fetchAircraftData();
|
|
|
|
|
}
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
this.fetchStatsData();
|
|
|
|
|
}, 10000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fetchAircraftData() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/aircraft');
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
this.updateAircraftData(data);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch aircraft data:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fetchStatsData() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/stats');
|
|
|
|
|
const stats = await response.json();
|
|
|
|
|
|
|
|
|
|
if (stats.total && stats.total.messages) {
|
|
|
|
|
const messagesPerSec = stats.total.messages.last1min / 60;
|
|
|
|
|
document.getElementById('messages-sec').textContent = Math.round(messagesPerSec);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-23 23:20:31 +02:00
|
|
|
// Calculate average RSSI from aircraft data
|
|
|
|
|
const aircraftWithRSSI = this.aircraftData.filter(a => a.rssi);
|
|
|
|
|
if (aircraftWithRSSI.length > 0) {
|
|
|
|
|
const avgRSSI = aircraftWithRSSI.reduce((sum, a) => sum + a.rssi, 0) / aircraftWithRSSI.length;
|
|
|
|
|
document.getElementById('signal-strength').textContent = `${avgRSSI.toFixed(1)} dBFS`;
|
|
|
|
|
} else {
|
|
|
|
|
document.getElementById('signal-strength').textContent = '0 dBFS';
|
2025-08-23 22:09:37 +02:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to fetch stats:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-23 22:52:16 +02:00
|
|
|
|
|
|
|
|
toggleHistoricalTracks() {
|
|
|
|
|
this.showHistoricalTracks = !this.showHistoricalTracks;
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('toggle-history');
|
|
|
|
|
btn.textContent = this.showHistoricalTracks ? 'Hide History' : 'Show History';
|
|
|
|
|
|
|
|
|
|
if (!this.showHistoricalTracks) {
|
|
|
|
|
this.clearAllHistoricalTracks();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadAircraftHistory(hex) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/aircraft/${hex}/history`);
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
if (data.track_history && data.track_history.length > 1) {
|
|
|
|
|
this.displayHistoricalTrack(hex, data.track_history, data.flight);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to load aircraft history:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
displayHistoricalTrack(hex, trackHistory, flight) {
|
|
|
|
|
this.clearHistoricalTrack(hex);
|
|
|
|
|
|
|
|
|
|
const points = trackHistory.map(point => [point.lat, point.lon]);
|
|
|
|
|
|
|
|
|
|
const polyline = L.polyline(points, {
|
|
|
|
|
color: '#ff6b6b',
|
|
|
|
|
weight: 3,
|
|
|
|
|
opacity: 0.8,
|
|
|
|
|
dashArray: '5, 5'
|
|
|
|
|
}).addTo(this.map);
|
|
|
|
|
|
|
|
|
|
polyline.bindPopup(`<b>Historical Track</b><br>${flight || hex}<br>${trackHistory.length} points`);
|
|
|
|
|
|
|
|
|
|
this.historicalTracks.set(hex, polyline);
|
|
|
|
|
|
|
|
|
|
// Add start/end markers
|
|
|
|
|
if (trackHistory.length > 0) {
|
|
|
|
|
const start = trackHistory[0];
|
|
|
|
|
const end = trackHistory[trackHistory.length - 1];
|
|
|
|
|
|
|
|
|
|
L.circleMarker([start.lat, start.lon], {
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
fillColor: '#00ff00',
|
|
|
|
|
fillOpacity: 0.8,
|
|
|
|
|
radius: 4
|
|
|
|
|
}).addTo(this.map).bindPopup(`<b>Start</b><br>${new Date(start.timestamp).toLocaleString()}`);
|
|
|
|
|
|
|
|
|
|
L.circleMarker([end.lat, end.lon], {
|
|
|
|
|
color: '#ffffff',
|
|
|
|
|
fillColor: '#ff0000',
|
|
|
|
|
fillOpacity: 0.8,
|
|
|
|
|
radius: 4
|
|
|
|
|
}).addTo(this.map).bindPopup(`<b>End</b><br>${new Date(end.timestamp).toLocaleString()}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearHistoricalTrack(hex) {
|
|
|
|
|
if (this.historicalTracks.has(hex)) {
|
|
|
|
|
this.map.removeLayer(this.historicalTracks.get(hex));
|
|
|
|
|
this.historicalTracks.delete(hex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearAllHistoricalTracks() {
|
|
|
|
|
this.historicalTracks.forEach(track => {
|
|
|
|
|
this.map.removeLayer(track);
|
|
|
|
|
});
|
|
|
|
|
this.historicalTracks.clear();
|
|
|
|
|
|
|
|
|
|
// Also remove start/end markers
|
|
|
|
|
this.map.eachLayer(layer => {
|
|
|
|
|
if (layer instanceof L.CircleMarker) {
|
|
|
|
|
this.map.removeLayer(layer);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-08-23 22:09:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
new SkyView();
|
|
|
|
|
});
|