class SkyView { constructor() { this.map = null; this.aircraftMarkers = new Map(); this.aircraftTrails = new Map(); this.websocket = null; this.aircraftData = []; this.showTrails = false; this.currentView = 'map'; this.charts = {}; this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' }; this.init(); } init() { this.loadConfig().then(() => { this.initializeViews(); this.initializeMap(); this.initializeWebSocket(); this.initializeEventListeners(); this.initializeCharts(); 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()); } 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 } } } }); } updateAircraftData(data) { this.aircraftData = data.aircraft || []; 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); 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); } }); } createAircraftMarker(aircraft, pos) { const icon = L.divIcon({ html: this.getAircraftIcon(aircraft), className: 'aircraft-marker', iconSize: [20, 20], iconAnchor: [10, 10] }); const marker = L.marker(pos, { icon }).addTo(this.map); marker.bindPopup(this.createPopupContent(aircraft), { className: 'aircraft-popup' }); return marker; } getAircraftIcon(aircraft) { const rotation = aircraft.track || 0; return ` `; } updateMarkerRotation(marker, track) { if (track !== undefined) { const icon = L.divIcon({ html: ` `, className: 'aircraft-marker', iconSize: [20, 20], iconAnchor: [10, 10] }); marker.setIcon(icon); } } createPopupContent(aircraft) { return `
${aircraft.flight || aircraft.hex}
Altitude:
${aircraft.alt_baro || 'N/A'} ft
Speed:
${aircraft.gs || 'N/A'} kts
Track:
${aircraft.track || 'N/A'}°
Squawk:
${aircraft.squawk || 'N/A'}
`; } 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) ); } const sortBy = document.getElementById('sort-select').value; this.sortAircraft(filteredData, sortBy); filteredData.forEach(aircraft => { const row = document.createElement('tr'); row.innerHTML = ` ${aircraft.flight || '-'} ${aircraft.hex} ${aircraft.alt_baro || '-'} ${aircraft.gs || '-'} ${aircraft.track || '-'} ${this.calculateDistance(aircraft) || '-'} ${aircraft.messages || '-'} ${aircraft.seen ? aircraft.seen.toFixed(1) : '-'}s `; 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); 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).toFixed(1); } 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 maxDistance = Math.max(...this.aircraftData .map(a => this.calculateDistance(a)) .filter(d => d !== null)) || 0; document.getElementById('max-range').textContent = `${maxDistance} 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); } if (stats.total && stats.total.signal_power) { document.getElementById('signal-strength').textContent = `${stats.total.signal_power.toFixed(1)} dB`; } } catch (error) { console.error('Failed to fetch stats:', error); } } } document.addEventListener('DOMContentLoaded', () => { new SkyView(); });