class SkyView { constructor() { this.map = null; this.aircraftMarkers = new Map(); this.aircraftTrails = new Map(); this.historicalTracks = new Map(); this.websocket = null; this.aircraftData = []; this.showTrails = false; this.showHistoricalTracks = false; this.currentView = 'map'; this.charts = {}; this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' }; this.lastUpdateTime = new Date(); this.init(); } init() { this.loadConfig().then(() => { this.initializeViews(); this.initializeMap(); this.initializeWebSocket(); this.initializeEventListeners(); this.initializeCharts(); this.initializeClocks(); 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: '
', className: 'origin-marker', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(this.map).bindPopup(`Origin
${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()); const historyBtn = document.getElementById('toggle-history'); historyBtn.addEventListener('click', () => this.toggleHistoricalTracks()); } 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 } } } }); } 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)`; } updateAircraftData(data) { this.aircraftData = data.aircraft || []; this.lastUpdateTime = new Date(); 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); this.updateMarkerRotation(marker, aircraft.track, aircraft); 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); } if (this.showHistoricalTracks && aircraft.track_history && aircraft.track_history.length > 1) { this.displayHistoricalTrack(aircraft.hex, aircraft.track_history, aircraft.flight); } }); } createAircraftMarker(aircraft, pos) { const hasPosition = aircraft.lat && aircraft.lon; const size = hasPosition ? 24 : 18; const icon = L.divIcon({ html: this.getAircraftIcon(aircraft), className: 'aircraft-marker', iconSize: [size, size], iconAnchor: [size/2, size/2] }); const marker = L.marker(pos, { icon }).addTo(this.map); marker.bindPopup(this.createPopupContent(aircraft), { className: 'aircraft-popup' }); marker.on('click', () => { if (this.showHistoricalTracks) { this.loadAircraftHistory(aircraft.hex); } }); return marker; } 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'; } getAircraftIcon(aircraft) { const rotation = aircraft.track || 0; const hasPosition = aircraft.lat && aircraft.lon; const type = this.getAircraftType(aircraft); const size = hasPosition ? 24 : 18; let color, icon; switch (type) { case 'cargo': color = hasPosition ? "#ff8c00" : "#666666"; icon = ` `; break; case 'military': color = hasPosition ? "#ff4444" : "#666666"; icon = ` `; break; case 'ga': color = hasPosition ? "#ffff00" : "#666666"; icon = ` `; break; case 'ground': color = "#888888"; icon = ` G`; break; default: // commercial color = hasPosition ? "#00ff88" : "#666666"; icon = ` `; break; } return ` ${icon} `; } updateMarkerRotation(marker, track, aircraft) { if (track !== undefined) { const hasPosition = aircraft.lat && aircraft.lon; const size = hasPosition ? 24 : 18; const icon = L.divIcon({ html: this.getAircraftIcon(aircraft), className: 'aircraft-marker', iconSize: [size, size], iconAnchor: [size/2, size/2] }); marker.setIcon(icon); } } createPopupContent(aircraft) { 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'; return `
`; } 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]; } 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)) || aircraft.hex.toLowerCase().includes(searchTerm) || (aircraft.squawk && aircraft.squawk.includes(searchTerm)) ); } const sortBy = document.getElementById('sort-select').value; this.sortAircraft(filteredData, sortBy); filteredData.forEach(aircraft => { const type = this.getAircraftType(aircraft); 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)}`) : '-'; const row = document.createElement('tr'); // 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'; } row.innerHTML = ` ${aircraft.hex} ${aircraft.flight || '-'} ${aircraft.squawk || '-'} ${altitudeStr} ${aircraft.gs || '-'} ${distanceStr} ${aircraft.track || '-'}° ${aircraft.messages || '-'} ${age} ${rssiStr} `; 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); 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); 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)); return R * c; // Return a number, not a string } 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; const distances = this.aircraftData .map(a => this.calculateDistance(a)) .filter(d => d !== null); const maxDistance = distances.length > 0 ? Math.max(...distances) : 0; document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} nm`; 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); } // 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'; } } catch (error) { console.error('Failed to fetch stats:', error); } } 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(`Historical Track
${flight || hex}
${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(`Start
${new Date(start.timestamp).toLocaleString()}`); L.circleMarker([end.lat, end.lon], { color: '#ffffff', fillColor: '#ff0000', fillOpacity: 0.8, radius: 4 }).addTo(this.map).bindPopup(`End
${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); } }); } } document.addEventListener('DOMContentLoaded', () => { new SkyView(); });