Initial implementation of SkyView - ADS-B aircraft tracker
- Go application with embedded static files for dump1090 frontend - TCP client for SBS-1/BaseStation format (port 30003) - Real-time WebSocket updates with aircraft tracking - Modern web frontend with Leaflet maps and mobile-responsive design - Aircraft table with filtering/sorting and statistics dashboard - Origin configuration for receiver location and distance calculations - Automatic config.json loading from current directory - Foreground execution by default with optional -daemon flag 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
8ce4f4c397
19 changed files with 1971 additions and 0 deletions
521
static/js/app.js
Normal file
521
static/js/app.js
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
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: '<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());
|
||||
}
|
||||
|
||||
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 `<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate(${rotation}deg);">
|
||||
<polygon points="10,2 8,18 10,16 12,18" fill="#00a8ff" stroke="#ffffff" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
updateMarkerRotation(marker, track) {
|
||||
if (track !== undefined) {
|
||||
const icon = L.divIcon({
|
||||
html: `<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate(${track}deg);">
|
||||
<polygon points="10,2 8,18 10,16 12,18" fill="#00a8ff" stroke="#ffffff" stroke-width="1"/>
|
||||
</svg>`,
|
||||
className: 'aircraft-marker',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
marker.setIcon(icon);
|
||||
}
|
||||
}
|
||||
|
||||
createPopupContent(aircraft) {
|
||||
return `
|
||||
<div class="aircraft-popup">
|
||||
<div class="flight">${aircraft.flight || aircraft.hex}</div>
|
||||
<div class="details">
|
||||
<div>Altitude:</div><div>${aircraft.alt_baro || 'N/A'} ft</div>
|
||||
<div>Speed:</div><div>${aircraft.gs || 'N/A'} kts</div>
|
||||
<div>Track:</div><div>${aircraft.track || 'N/A'}°</div>
|
||||
<div>Squawk:</div><div>${aircraft.squawk || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<td>${aircraft.flight || '-'}</td>
|
||||
<td>${aircraft.hex}</td>
|
||||
<td>${aircraft.alt_baro || '-'}</td>
|
||||
<td>${aircraft.gs || '-'}</td>
|
||||
<td>${aircraft.track || '-'}</td>
|
||||
<td>${this.calculateDistance(aircraft) || '-'}</td>
|
||||
<td>${aircraft.messages || '-'}</td>
|
||||
<td>${aircraft.seen ? aircraft.seen.toFixed(1) : '-'}s</td>
|
||||
`;
|
||||
|
||||
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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue