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:
Ole-Morten Duesund 2025-08-23 22:09:37 +02:00
commit 8ce4f4c397
19 changed files with 1971 additions and 0 deletions

5
static/aircraft-icon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00a8ff" stroke="#ffffff" stroke-width="1">
<path d="M12 2l-2 16 2-2 2 2-2-16z"/>
<path d="M4 10l8-2-1 2-7 0z"/>
<path d="M20 10l-8-2 1 2 7 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 224 B

317
static/css/style.css Normal file
View file

@ -0,0 +1,317 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a1a;
color: #ffffff;
height: 100vh;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: #2d2d2d;
border-bottom: 1px solid #404040;
}
.header h1 {
font-size: 1.5rem;
color: #00a8ff;
}
.stats-summary {
display: flex;
gap: 1rem;
font-size: 0.9rem;
}
.connection-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.connection-status.connected {
background: #27ae60;
}
.connection-status.disconnected {
background: #e74c3c;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view-toggle {
display: flex;
background: #2d2d2d;
border-bottom: 1px solid #404040;
}
.view-btn {
padding: 0.75rem 1.5rem;
background: transparent;
border: none;
color: #ffffff;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s ease;
}
.view-btn:hover {
background: #404040;
}
.view-btn.active {
border-bottom-color: #00a8ff;
background: #404040;
}
.view {
flex: 1;
display: none;
overflow: hidden;
}
.view.active {
display: flex;
flex-direction: column;
}
#map {
flex: 1;
z-index: 1;
}
.map-controls {
position: absolute;
top: 80px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.map-controls button {
padding: 0.5rem 1rem;
background: #2d2d2d;
border: 1px solid #404040;
color: #ffffff;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
}
.map-controls button:hover {
background: #404040;
}
.table-controls {
display: flex;
gap: 1rem;
padding: 1rem;
background: #2d2d2d;
border-bottom: 1px solid #404040;
}
.table-controls input,
.table-controls select {
padding: 0.5rem;
background: #404040;
border: 1px solid #606060;
color: #ffffff;
border-radius: 4px;
}
.table-controls input {
flex: 1;
}
.table-container {
flex: 1;
overflow: auto;
}
#aircraft-table {
width: 100%;
border-collapse: collapse;
}
#aircraft-table th,
#aircraft-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #404040;
}
#aircraft-table th {
background: #2d2d2d;
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}
#aircraft-table tr:hover {
background: #404040;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.stat-card {
background: #2d2d2d;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #404040;
text-align: center;
}
.stat-card h3 {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.5rem;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #00a8ff;
}
.charts-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
flex: 1;
}
.chart-card {
background: #2d2d2d;
padding: 1rem;
border-radius: 8px;
border: 1px solid #404040;
display: flex;
flex-direction: column;
}
.chart-card h3 {
margin-bottom: 1rem;
color: #888;
}
.chart-card canvas {
flex: 1;
max-height: 300px;
}
.aircraft-marker {
width: 20px;
height: 20px;
transform: rotate(0deg);
filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
}
.aircraft-popup {
min-width: 200px;
}
.aircraft-popup .flight {
font-weight: bold;
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #00a8ff;
}
.aircraft-popup .details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25rem;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.header {
padding: 0.75rem 1rem;
}
.header h1 {
font-size: 1.25rem;
}
.stats-summary {
font-size: 0.8rem;
gap: 0.5rem;
}
.table-controls {
flex-direction: column;
gap: 0.5rem;
}
.charts-container {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
padding: 0.5rem;
}
.stat-card {
padding: 1rem;
}
.stat-value {
font-size: 1.5rem;
}
.map-controls {
top: 70px;
right: 5px;
}
.map-controls button {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
#aircraft-table {
font-size: 0.8rem;
}
#aircraft-table th,
#aircraft-table td {
padding: 0.5rem 0.25rem;
}
}

102
static/index.html Normal file
View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SkyView - ADS-B Aircraft Tracker</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div id="app">
<header class="header">
<h1>SkyView</h1>
<div class="stats-summary">
<span id="aircraft-count">0 aircraft</span>
<span id="connection-status" class="connected">Connected</span>
</div>
</header>
<main class="main-content">
<div class="view-toggle">
<button id="map-view-btn" class="view-btn active">Map</button>
<button id="table-view-btn" class="view-btn">Table</button>
<button id="stats-view-btn" class="view-btn">Stats</button>
</div>
<div id="map-view" class="view active">
<div id="map"></div>
<div class="map-controls">
<button id="center-map">Center Map</button>
<button id="toggle-trails">Toggle Trails</button>
</div>
</div>
<div id="table-view" class="view">
<div class="table-controls">
<input type="text" id="search-input" placeholder="Search by flight, callsign, or hex...">
<select id="sort-select">
<option value="distance">Distance</option>
<option value="altitude">Altitude</option>
<option value="speed">Speed</option>
<option value="flight">Flight</option>
</select>
</div>
<div class="table-container">
<table id="aircraft-table">
<thead>
<tr>
<th>Flight</th>
<th>Hex</th>
<th>Altitude</th>
<th>Speed</th>
<th>Track</th>
<th>Distance</th>
<th>Msgs</th>
<th>Seen</th>
</tr>
</thead>
<tbody id="aircraft-tbody">
</tbody>
</table>
</div>
</div>
<div id="stats-view" class="view">
<div class="stats-grid">
<div class="stat-card">
<h3>Total Aircraft</h3>
<div class="stat-value" id="total-aircraft">0</div>
</div>
<div class="stat-card">
<h3>Messages/sec</h3>
<div class="stat-value" id="messages-sec">0</div>
</div>
<div class="stat-card">
<h3>Signal Strength</h3>
<div class="stat-value" id="signal-strength">0 dB</div>
</div>
<div class="stat-card">
<h3>Max Range</h3>
<div class="stat-value" id="max-range">0 nm</div>
</div>
</div>
<div class="charts-container">
<div class="chart-card">
<h3>Aircraft Count (24h)</h3>
<canvas id="aircraft-chart"></canvas>
</div>
<div class="chart-card">
<h3>Message Rate</h3>
<canvas id="message-chart"></canvas>
</div>
</div>
</div>
</main>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.min.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

521
static/js/app.js Normal file
View 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();
});