diff --git a/internal/assets/assets.go b/assets/assets.go
similarity index 94%
rename from internal/assets/assets.go
rename to assets/assets.go
index fa6422f..1831910 100644
--- a/internal/assets/assets.go
+++ b/assets/assets.go
@@ -6,5 +6,6 @@ import "embed"
// Static contains all embedded static assets
// The files are accessed with paths like "static/index.html", "static/css/style.css", etc.
-//go:embed static/*
+//
+//go:embed static
var Static embed.FS
\ No newline at end of file
diff --git a/internal/assets/static/aircraft-icon.svg b/assets/static/aircraft-icon.svg
similarity index 100%
rename from internal/assets/static/aircraft-icon.svg
rename to assets/static/aircraft-icon.svg
diff --git a/internal/assets/static/css/style.css b/assets/static/css/style.css
similarity index 100%
rename from internal/assets/static/css/style.css
rename to assets/static/css/style.css
diff --git a/internal/assets/static/favicon.ico b/assets/static/favicon.ico
similarity index 100%
rename from internal/assets/static/favicon.ico
rename to assets/static/favicon.ico
diff --git a/internal/assets/static/index.html b/assets/static/index.html
similarity index 99%
rename from internal/assets/static/index.html
rename to assets/static/index.html
index 5dd05b6..036a832 100644
--- a/internal/assets/static/index.html
+++ b/assets/static/index.html
@@ -75,6 +75,7 @@
Center Map
+
Reset Map
Show Trails
Show Range
Show Sources
diff --git a/static/js/app.js b/assets/static/js/app.js
similarity index 98%
rename from static/js/app.js
rename to assets/static/js/app.js
index 0ff34f1..d1aa62e 100644
--- a/static/js/app.js
+++ b/assets/static/js/app.js
@@ -112,6 +112,9 @@ class SkyView {
console.warn('Could not fetch origin, using default:', error);
}
+ // Store origin for reset functionality
+ this.mapOrigin = origin;
+
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
// Dark tile layer
@@ -123,6 +126,7 @@ class SkyView {
// Map controls
document.getElementById('center-map').addEventListener('click', () => this.centerMapOnAircraft());
+ document.getElementById('reset-map').addEventListener('click', () => this.resetMap());
document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails());
document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles());
document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources());
@@ -775,11 +779,23 @@ class SkyView {
if (validAircraft.length === 0) return;
- const group = new L.featureGroup(
- validAircraft.map(a => L.marker([a.Latitude, a.Longitude]))
- );
-
- this.map.fitBounds(group.getBounds().pad(0.1));
+ if (validAircraft.length === 1) {
+ // Center on single aircraft
+ const aircraft = validAircraft[0];
+ this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
+ } else {
+ // Fit bounds to all aircraft
+ const bounds = L.latLngBounds(
+ validAircraft.map(a => [a.Latitude, a.Longitude])
+ );
+ this.map.fitBounds(bounds.pad(0.1));
+ }
+ }
+
+ resetMap() {
+ if (this.mapOrigin && this.map) {
+ this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
+ }
}
toggleTrails() {
diff --git a/internal/assets/static/js/app.js b/internal/assets/static/js/app.js
deleted file mode 100644
index 0ff34f1..0000000
--- a/internal/assets/static/js/app.js
+++ /dev/null
@@ -1,1173 +0,0 @@
-// Import Three.js modules
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-class SkyView {
- constructor() {
- this.map = null;
- this.coverageMap = null;
- this.radar3d = null;
- this.websocket = null;
-
- // Data storage
- this.aircraftData = new Map();
- this.sourcesData = new Map();
- this.stats = {};
-
- // Map markers and overlays
- this.aircraftMarkers = new Map();
- this.sourceMarkers = new Map();
- this.aircraftTrails = new Map();
- this.rangeCircles = new Map();
- this.heatmapLayer = null;
-
- // UI state
- this.currentView = 'map-view';
- this.showTrails = false;
- this.showRange = false;
- this.showSources = true;
- this.selectedSource = null;
-
- // Charts
- this.charts = {};
-
- // Update tracking
- this.lastUpdateTime = new Date();
-
- this.init();
- }
-
- async init() {
- try {
- this.initializeViews();
- this.initializeMap();
- await this.initializeWebSocket();
- this.initializeEventListeners();
- this.initializeCharts();
- this.initializeClocks();
- this.initialize3DRadar();
-
- this.startPeriodicTasks();
- } catch (error) {
- console.error('Initialization failed:', error);
- this.showError('Failed to initialize application');
- }
- }
-
- // View Management
- 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);
- });
- });
- }
-
- switchView(viewId) {
- // Update buttons
- document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
- const activeBtn = document.getElementById(`${viewId}-btn`);
- if (activeBtn) {
- activeBtn.classList.add('active');
- }
-
- // Update views (viewId already includes the full view ID like "map-view")
- document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
- const activeView = document.getElementById(viewId);
- if (activeView) {
- activeView.classList.add('active');
- } else {
- console.warn(`View element not found: ${viewId}`);
- return;
- }
-
- this.currentView = viewId;
-
- // Handle view-specific initialization (extract base name for switch)
- const baseName = viewId.replace('-view', '');
- switch (baseName) {
- case 'coverage':
- this.initializeCoverageMap();
- break;
- case 'radar3d':
- this.update3DRadar();
- break;
- }
- }
-
- // Map Initialization
- async initializeMap() {
- // Get origin from server
- let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
- try {
- const response = await fetch('/api/origin');
- if (response.ok) {
- origin = await response.json();
- }
- } catch (error) {
- console.warn('Could not fetch origin, using default:', error);
- }
-
- this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
-
- // Dark tile layer
- L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- attribution: '©
OpenStreetMap contributors ©
CARTO ',
- subdomains: 'abcd',
- maxZoom: 19
- }).addTo(this.map);
-
- // Map controls
- document.getElementById('center-map').addEventListener('click', () => this.centerMapOnAircraft());
- document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails());
- document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles());
- document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources());
- }
-
- async initializeCoverageMap() {
- if (!this.coverageMap) {
- // Get origin from server
- let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
- try {
- const response = await fetch('/api/origin');
- if (response.ok) {
- origin = await response.json();
- }
- } catch (error) {
- console.warn('Could not fetch origin for coverage map, using default:', error);
- }
-
- this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
-
- L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- attribution: '©
OpenStreetMap contributors'
- }).addTo(this.coverageMap);
- }
-
- // Update coverage controls
- this.updateCoverageControls();
-
- // Coverage controls
- document.getElementById('toggle-heatmap').addEventListener('click', () => this.toggleHeatmap());
- document.getElementById('coverage-source').addEventListener('change', (e) => {
- this.selectedSource = e.target.value;
- this.updateCoverageDisplay();
- });
- }
-
- // WebSocket Connection
- async initializeWebSocket() {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
- const wsUrl = `${protocol}//${window.location.host}/ws`;
-
- try {
- this.websocket = new WebSocket(wsUrl);
-
- this.websocket.onopen = () => {
- console.log('WebSocket connected');
- this.updateConnectionStatus('connected');
- };
-
- this.websocket.onclose = () => {
- console.log('WebSocket disconnected');
- this.updateConnectionStatus('disconnected');
- // Reconnect after 5 seconds
- setTimeout(() => this.initializeWebSocket(), 5000);
- };
-
- this.websocket.onerror = (error) => {
- console.error('WebSocket error:', error);
- this.updateConnectionStatus('disconnected');
- };
-
- this.websocket.onmessage = (event) => {
- try {
- const message = JSON.parse(event.data);
- this.handleWebSocketMessage(message);
- } catch (error) {
- console.error('Failed to parse WebSocket message:', error);
- }
- };
-
- } catch (error) {
- console.error('WebSocket connection failed:', error);
- this.updateConnectionStatus('disconnected');
- }
- }
-
- handleWebSocketMessage(message) {
- switch (message.type) {
- case 'initial_data':
- case 'aircraft_update':
- this.updateData(message.data);
- break;
- default:
- console.log('Unknown message type:', message.type);
- }
- }
-
- updateData(data) {
- // Update aircraft data
- if (data.aircraft) {
- this.aircraftData.clear();
- for (const [icao, aircraft] of Object.entries(data.aircraft)) {
- this.aircraftData.set(icao, aircraft);
- }
- }
-
- // Update sources data
- if (data.sources) {
- this.sourcesData.clear();
- data.sources.forEach(source => {
- this.sourcesData.set(source.id, source);
- });
- }
-
- // Update statistics
- if (data.stats) {
- this.stats = data.stats;
- }
-
- this.lastUpdateTime = new Date();
-
- // Update UI
- this.updateMapMarkers();
- this.updateAircraftTable();
- this.updateStatistics();
- this.updateHeaderInfo();
-
- if (this.currentView === 'radar3d-view') {
- this.update3DRadar();
- }
- }
-
- // Map Updates
- updateMapMarkers() {
- // Clear stale aircraft markers
- const currentICAOs = new Set(this.aircraftData.keys());
- for (const [icao, marker] of this.aircraftMarkers) {
- if (!currentICAOs.has(icao)) {
- this.map.removeLayer(marker);
- this.aircraftMarkers.delete(icao);
- this.aircraftTrails.delete(icao);
- }
- }
-
- // Update aircraft markers
- for (const [icao, aircraft] of this.aircraftData) {
- if (aircraft.Latitude && aircraft.Longitude) {
- this.updateAircraftMarker(icao, aircraft);
- }
- }
-
- // Update source markers
- if (this.showSources) {
- this.updateSourceMarkers();
- }
-
- // Update range circles
- if (this.showRange) {
- this.updateRangeCircles();
- }
- }
-
- updateAircraftMarker(icao, aircraft) {
- const pos = [aircraft.Latitude, aircraft.Longitude];
-
- if (this.aircraftMarkers.has(icao)) {
- // Update existing marker
- const marker = this.aircraftMarkers.get(icao);
- marker.setLatLng(pos);
- this.updateMarkerRotation(marker, aircraft.Track || 0);
- this.updatePopupContent(marker, aircraft);
- } else {
- // Create new marker
- const icon = this.createAircraftIcon(aircraft);
- const marker = L.marker(pos, {
- icon: icon,
- rotationAngle: aircraft.Track || 0
- }).addTo(this.map);
-
- marker.bindPopup(this.createPopupContent(aircraft), {
- maxWidth: 450,
- className: 'aircraft-popup'
- });
-
- this.aircraftMarkers.set(icao, marker);
- }
-
- // Update trails
- if (this.showTrails) {
- this.updateAircraftTrail(icao, pos);
- }
- }
-
- createAircraftIcon(aircraft) {
- const type = this.getAircraftType(aircraft);
- const color = this.getAircraftColor(type);
- const size = aircraft.OnGround ? 12 : 16;
-
- const svg = `
-
-
-
-
-
- `;
-
- return L.divIcon({
- html: svg,
- iconSize: [size * 2, size * 2],
- iconAnchor: [size, size],
- className: 'aircraft-marker'
- });
- }
-
- getAircraftType(aircraft) {
- if (aircraft.OnGround) return 'ground';
- if (aircraft.Category) {
- const cat = aircraft.Category.toLowerCase();
- if (cat.includes('military')) return 'military';
- if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
- if (cat.includes('light') || cat.includes('glider')) return 'ga';
- }
- if (aircraft.Callsign) {
- const cs = aircraft.Callsign.toLowerCase();
- if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
- if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
- }
- return 'commercial';
- }
-
- getAircraftColor(type) {
- const colors = {
- commercial: '#00ff88',
- cargo: '#ff8c00',
- military: '#ff4444',
- ga: '#ffff00',
- ground: '#888888'
- };
- return colors[type] || colors.commercial;
- }
-
- updateMarkerRotation(marker, track) {
- if (marker._icon) {
- marker._icon.style.transform = `rotate(${track}deg)`;
- }
- }
-
- updateSourceMarkers() {
- // Remove markers for sources that no longer exist
- const currentSourceIds = new Set(this.sourcesData.keys());
- for (const [id, marker] of this.sourceMarkers) {
- if (!currentSourceIds.has(id)) {
- this.map.removeLayer(marker);
- this.sourceMarkers.delete(id);
- }
- }
-
- // Update or create markers for current sources
- for (const [id, source] of this.sourcesData) {
- if (source.latitude && source.longitude) {
- if (this.sourceMarkers.has(id)) {
- // Update existing marker
- const marker = this.sourceMarkers.get(id);
-
- // Update marker style if status changed
- marker.setStyle({
- radius: source.active ? 10 : 6,
- fillColor: source.active ? '#00d4ff' : '#666666',
- fillOpacity: 0.8
- });
-
- // Update popup content if it's open
- if (marker.isPopupOpen()) {
- marker.setPopupContent(this.createSourcePopupContent(source));
- }
- } else {
- // Create new marker
- const marker = L.circleMarker([source.latitude, source.longitude], {
- radius: source.active ? 10 : 6,
- fillColor: source.active ? '#00d4ff' : '#666666',
- color: '#ffffff',
- weight: 2,
- fillOpacity: 0.8,
- className: 'source-marker'
- }).addTo(this.map);
-
- marker.bindPopup(this.createSourcePopupContent(source), {
- maxWidth: 300
- });
-
- this.sourceMarkers.set(id, marker);
- }
- }
- }
-
- this.updateSourcesLegend();
- }
-
- updateRangeCircles() {
- // Clear existing circles
- this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
- this.rangeCircles.clear();
-
- // Add range circles for active sources
- for (const [id, source] of this.sourcesData) {
- if (source.active && source.latitude && source.longitude) {
- // Add multiple range circles (50km, 100km, 200km)
- const ranges = [50000, 100000, 200000];
- ranges.forEach((range, index) => {
- const circle = L.circle([source.latitude, source.longitude], {
- radius: range,
- fillColor: 'transparent',
- color: '#00d4ff',
- weight: 1,
- opacity: 0.3 - (index * 0.1),
- dashArray: '5,5'
- }).addTo(this.map);
-
- this.rangeCircles.set(`${id}_${range}`, circle);
- });
- }
- }
- }
-
- updateAircraftTrail(icao, pos) {
- if (!this.aircraftTrails.has(icao)) {
- this.aircraftTrails.set(icao, []);
- }
-
- const trail = this.aircraftTrails.get(icao);
- trail.push(pos);
-
- // Keep only last 50 positions
- if (trail.length > 50) {
- trail.shift();
- }
-
- // Draw polyline
- const trailLine = L.polyline(trail, {
- color: '#00d4ff',
- weight: 2,
- opacity: 0.6
- }).addTo(this.map);
-
- // Store reference for cleanup
- if (!this.aircraftTrails.get(icao).polyline) {
- this.aircraftTrails.get(icao).polyline = trailLine;
- } else {
- this.map.removeLayer(this.aircraftTrails.get(icao).polyline);
- this.aircraftTrails.get(icao).polyline = trailLine;
- }
- }
-
- // Popup Content
- createPopupContent(aircraft) {
- const type = this.getAircraftType(aircraft);
- const country = this.getCountryFromICAO(aircraft.ICAO24 ? aircraft.ICAO24.toString(16).toUpperCase() : '');
- const flag = this.getCountryFlag(country);
-
- const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
- const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0;
- const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0;
- const distance = this.calculateDistance(aircraft);
- const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
-
- const sources = aircraft.Sources ? Object.keys(aircraft.Sources).map(id => {
- const source = this.sourcesData.get(id);
- const srcData = aircraft.Sources[id];
- return `
- ${source?.name || id}
- `;
- }).join('') : 'N/A';
-
- return `
-
- `;
- }
-
- updatePopupContent(marker, aircraft) {
- if (marker && marker.isPopupOpen()) {
- const newContent = this.createPopupContent(aircraft);
- marker.setPopupContent(newContent);
- }
- }
-
- createSourcePopupContent(source) {
- const aircraftCount = Array.from(this.aircraftData.values())
- .filter(aircraft => aircraft.Sources && aircraft.Sources[source.id]).length;
-
- return `
-
- `;
- }
-
- updateSourcesLegend() {
- const legend = document.getElementById('sources-legend');
- legend.innerHTML = '';
-
- for (const [id, source] of this.sourcesData) {
- const item = document.createElement('div');
- item.className = 'legend-item';
- item.innerHTML = `
-
-
${source.name}
- `;
- legend.appendChild(item);
- }
- }
-
- // Table Management
- updateAircraftTable() {
- const tbody = document.getElementById('aircraft-tbody');
- tbody.innerHTML = '';
-
- let filteredData = Array.from(this.aircraftData.values());
-
- // Apply filters
- const searchTerm = document.getElementById('search-input').value.toLowerCase();
- const sourceFilter = document.getElementById('source-filter').value;
-
- if (searchTerm) {
- filteredData = filteredData.filter(aircraft =>
- (aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
- (aircraft.ICAO24 && aircraft.ICAO24.toString(16).toLowerCase().includes(searchTerm)) ||
- (aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
- );
- }
-
- if (sourceFilter) {
- filteredData = filteredData.filter(aircraft =>
- aircraft.Sources && aircraft.Sources[sourceFilter]
- );
- }
-
- // Sort data
- const sortBy = document.getElementById('sort-select').value;
- this.sortAircraft(filteredData, sortBy);
-
- // Populate table
- filteredData.forEach(aircraft => {
- const row = this.createTableRow(aircraft);
- tbody.appendChild(row);
- });
-
- // Update source filter options
- this.updateSourceFilter();
- }
-
- createTableRow(aircraft) {
- const type = this.getAircraftType(aircraft);
- const icao = aircraft.ICAO24 ? aircraft.ICAO24.toString(16).toUpperCase() : 'N/A';
- const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
- const distance = this.calculateDistance(aircraft);
- const sources = aircraft.Sources ? Object.keys(aircraft.Sources).length : 0;
- const bestSignal = this.getBestSignalFromSources(aircraft.Sources);
-
- const row = document.createElement('tr');
- row.innerHTML = `
-
${icao}
-
${aircraft.Callsign || '-'}
-
${aircraft.Squawk || '-'}
-
${altitude ? `${altitude} ft` : '-'}
-
${aircraft.GroundSpeed || '-'} kt
-
${distance ? distance.toFixed(1) : '-'} km
-
${aircraft.Track || '-'}°
-
${sources}
-
${bestSignal ? bestSignal.toFixed(1) : '-'}
-
${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s
- `;
-
- row.addEventListener('click', () => {
- if (aircraft.Latitude && aircraft.Longitude) {
- this.switchView('map');
- this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
- const marker = this.aircraftMarkers.get(icao);
- if (marker) {
- marker.openPopup();
- }
- }
- });
-
- return row;
- }
-
- getBestSignalFromSources(sources) {
- if (!sources) return null;
- let bestSignal = -999;
- for (const [id, data] of Object.entries(sources)) {
- if (data.SignalLevel > bestSignal) {
- bestSignal = data.SignalLevel;
- }
- }
- return bestSignal === -999 ? null : bestSignal;
- }
-
- getSignalClass(signal) {
- if (!signal) return '';
- if (signal > -10) return 'signal-strong';
- if (signal > -20) return 'signal-good';
- if (signal > -30) return 'signal-weak';
- return 'signal-poor';
- }
-
- updateSourceFilter() {
- const select = document.getElementById('source-filter');
- const currentValue = select.value;
-
- // Clear options except "All Sources"
- select.innerHTML = '
All Sources ';
-
- // Add source options
- for (const [id, source] of this.sourcesData) {
- const option = document.createElement('option');
- option.value = id;
- option.textContent = source.name;
- if (id === currentValue) option.selected = true;
- select.appendChild(option);
- }
- }
-
- sortAircraft(aircraft, sortBy) {
- aircraft.sort((a, b) => {
- switch (sortBy) {
- case 'distance':
- return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
- case 'altitude':
- return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
- case 'speed':
- return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
- case 'flight':
- return (a.Callsign || a.ICAO24?.toString(16) || '').localeCompare(b.Callsign || b.ICAO24?.toString(16) || '');
- case 'icao':
- return (a.ICAO24?.toString(16) || '').localeCompare(b.ICAO24?.toString(16) || '');
- case 'squawk':
- return (a.Squawk || '').localeCompare(b.Squawk || '');
- case 'signal':
- return (this.getBestSignalFromSources(b.Sources) || -999) - (this.getBestSignalFromSources(a.Sources) || -999);
- case 'age':
- return (a.Age || 0) - (b.Age || 0);
- default:
- return 0;
- }
- });
- }
-
- // Statistics
- updateStatistics() {
- document.getElementById('total-aircraft').textContent = this.aircraftData.size;
- document.getElementById('active-sources').textContent =
- Array.from(this.sourcesData.values()).filter(s => s.active).length;
-
- // Calculate max range
- let maxDistance = 0;
- for (const aircraft of this.aircraftData.values()) {
- const distance = this.calculateDistance(aircraft);
- if (distance && distance > maxDistance) {
- maxDistance = distance;
- }
- }
- document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} km`;
-
- // Update message rate
- const totalMessages = this.stats.total_messages || 0;
- document.getElementById('messages-sec').textContent = Math.round(totalMessages / 60);
-
- this.updateCharts();
- }
-
- updateHeaderInfo() {
- document.getElementById('aircraft-count').textContent = `${this.aircraftData.size} aircraft`;
- document.getElementById('sources-count').textContent = `${this.sourcesData.size} sources`;
-
- this.updateClocks();
- }
-
- // Event Listeners
- initializeEventListeners() {
- document.getElementById('search-input').addEventListener('input', () => this.updateAircraftTable());
- document.getElementById('sort-select').addEventListener('change', () => this.updateAircraftTable());
- document.getElementById('source-filter').addEventListener('change', () => this.updateAircraftTable());
- }
-
- // Map Controls
- centerMapOnAircraft() {
- if (this.aircraftData.size === 0) return;
-
- const validAircraft = Array.from(this.aircraftData.values())
- .filter(a => a.Latitude && a.Longitude);
-
- if (validAircraft.length === 0) return;
-
- const group = new L.featureGroup(
- validAircraft.map(a => L.marker([a.Latitude, a.Longitude]))
- );
-
- this.map.fitBounds(group.getBounds().pad(0.1));
- }
-
- toggleTrails() {
- this.showTrails = !this.showTrails;
- document.getElementById('toggle-trails').textContent =
- this.showTrails ? 'Hide Trails' : 'Show Trails';
-
- if (!this.showTrails) {
- // Clear all trails
- this.aircraftTrails.forEach((trail, icao) => {
- if (trail.polyline) {
- this.map.removeLayer(trail.polyline);
- }
- });
- this.aircraftTrails.clear();
- }
- }
-
- toggleRangeCircles() {
- this.showRange = !this.showRange;
- document.getElementById('toggle-range').textContent =
- this.showRange ? 'Hide Range' : 'Show Range';
-
- if (this.showRange) {
- this.updateRangeCircles();
- } else {
- this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
- this.rangeCircles.clear();
- }
- }
-
- toggleSources() {
- this.showSources = !this.showSources;
- document.getElementById('toggle-sources').textContent =
- this.showSources ? 'Hide Sources' : 'Show Sources';
-
- if (this.showSources) {
- this.updateSourceMarkers();
- } else {
- this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
- this.sourceMarkers.clear();
- }
- }
-
- // Coverage and Heatmap
- updateCoverageControls() {
- const select = document.getElementById('coverage-source');
- select.innerHTML = '
Select Source ';
-
- for (const [id, source] of this.sourcesData) {
- const option = document.createElement('option');
- option.value = id;
- option.textContent = source.name;
- select.appendChild(option);
- }
- }
-
- async updateCoverageDisplay() {
- if (!this.selectedSource) return;
-
- try {
- const response = await fetch(`/api/coverage/${this.selectedSource}`);
- const data = await response.json();
-
- // Clear existing coverage markers
- this.coverageMap.eachLayer(layer => {
- if (layer instanceof L.CircleMarker) {
- this.coverageMap.removeLayer(layer);
- }
- });
-
- // Add coverage points
- data.points.forEach(point => {
- const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
- L.circleMarker([point.lat, point.lon], {
- radius: 3,
- fillColor: this.getSignalColor(point.signal),
- color: 'white',
- weight: 1,
- fillOpacity: intensity
- }).addTo(this.coverageMap);
- });
-
- } catch (error) {
- console.error('Failed to load coverage data:', error);
- }
- }
-
- async toggleHeatmap() {
- if (!this.selectedSource) {
- alert('Please select a source first');
- return;
- }
-
- if (this.heatmapLayer) {
- this.coverageMap.removeLayer(this.heatmapLayer);
- this.heatmapLayer = null;
- document.getElementById('toggle-heatmap').textContent = 'Show Heatmap';
- } else {
- try {
- const response = await fetch(`/api/heatmap/${this.selectedSource}`);
- const data = await response.json();
-
- // Create heatmap layer (simplified)
- // In a real implementation, you'd use a proper heatmap library
- this.createHeatmapOverlay(data);
- document.getElementById('toggle-heatmap').textContent = 'Hide Heatmap';
-
- } catch (error) {
- console.error('Failed to load heatmap data:', error);
- }
- }
- }
-
- getSignalColor(signal) {
- if (signal > -10) return '#00ff88';
- if (signal > -20) return '#ffff00';
- if (signal > -30) return '#ff8c00';
- return '#ff4444';
- }
-
- // Charts (continued in next part due to length)
- initializeCharts() {
- this.charts.aircraft = new Chart(document.getElementById('aircraft-chart'), {
- type: 'line',
- data: {
- labels: [],
- datasets: [{
- label: 'Aircraft Count',
- data: [],
- borderColor: '#00d4ff',
- backgroundColor: 'rgba(0, 212, 255, 0.1)',
- tension: 0.4
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: { display: false }
- },
- scales: {
- x: { display: false },
- y: {
- beginAtZero: true,
- ticks: { color: '#888' }
- }
- }
- }
- });
-
- // Add other charts...
- }
-
- updateCharts() {
- const now = new Date();
- const timeLabel = now.toLocaleTimeString();
-
- // Update aircraft count chart
- if (this.charts.aircraft) {
- const chart = this.charts.aircraft;
- chart.data.labels.push(timeLabel);
- chart.data.datasets[0].data.push(this.aircraftData.size);
-
- if (chart.data.labels.length > 20) {
- chart.data.labels.shift();
- chart.data.datasets[0].data.shift();
- }
-
- chart.update('none');
- }
- }
-
- // 3D Radar (basic implementation)
- initialize3DRadar() {
- try {
- const container = document.getElementById('radar3d-container');
- if (!container) return;
-
- // Create scene
- this.radar3d = {
- scene: new THREE.Scene(),
- camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000),
- renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
- controls: null,
- aircraftMeshes: new Map()
- };
-
- // Set up renderer
- this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
- this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
- container.appendChild(this.radar3d.renderer.domElement);
-
- // Add lighting
- const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
- this.radar3d.scene.add(ambientLight);
-
- const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
- directionalLight.position.set(10, 10, 5);
- this.radar3d.scene.add(directionalLight);
-
- // Set up camera
- this.radar3d.camera.position.set(0, 50, 50);
- this.radar3d.camera.lookAt(0, 0, 0);
-
- // Add controls
- this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement);
- this.radar3d.controls.enableDamping = true;
- this.radar3d.controls.dampingFactor = 0.05;
-
- // Add ground plane
- const groundGeometry = new THREE.PlaneGeometry(200, 200);
- const groundMaterial = new THREE.MeshLambertMaterial({
- color: 0x2a4d3a,
- transparent: true,
- opacity: 0.5
- });
- const ground = new THREE.Mesh(groundGeometry, groundMaterial);
- ground.rotation.x = -Math.PI / 2;
- this.radar3d.scene.add(ground);
-
- // Add grid
- const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44);
- this.radar3d.scene.add(gridHelper);
-
- // Start render loop
- this.render3DRadar();
-
- console.log('3D Radar initialized successfully');
- } catch (error) {
- console.error('Failed to initialize 3D radar:', error);
- }
- }
-
- update3DRadar() {
- if (!this.radar3d || !this.radar3d.scene) return;
-
- try {
- // Update aircraft positions in 3D space
- this.aircraftData.forEach((aircraft, icao) => {
- if (aircraft.Latitude && aircraft.Longitude) {
- const key = icao.toString();
-
- if (!this.radar3d.aircraftMeshes.has(key)) {
- // Create new aircraft mesh
- const geometry = new THREE.ConeGeometry(0.5, 2, 6);
- const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
- const mesh = new THREE.Mesh(geometry, material);
- this.radar3d.aircraftMeshes.set(key, mesh);
- this.radar3d.scene.add(mesh);
- }
-
- const mesh = this.radar3d.aircraftMeshes.get(key);
-
- // Convert lat/lon to local coordinates (simplified)
- const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
- const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000;
- const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
-
- mesh.position.set(x, y, z);
-
- // Orient mesh based on track
- if (aircraft.Track !== undefined) {
- mesh.rotation.y = -aircraft.Track * Math.PI / 180;
- }
- }
- });
-
- // Remove old aircraft
- this.radar3d.aircraftMeshes.forEach((mesh, key) => {
- if (!this.aircraftData.has(parseInt(key, 16))) {
- this.radar3d.scene.remove(mesh);
- this.radar3d.aircraftMeshes.delete(key);
- }
- });
- } catch (error) {
- console.error('Failed to update 3D radar:', error);
- }
- }
-
- render3DRadar() {
- if (!this.radar3d) return;
-
- requestAnimationFrame(() => this.render3DRadar());
-
- if (this.radar3d.controls) {
- this.radar3d.controls.update();
- }
-
- this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
- }
-
- // Utility functions
- updateConnectionStatus(status) {
- const statusEl = document.getElementById('connection-status');
- statusEl.className = `connection-status ${status}`;
- statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
- }
-
- initializeClocks() {
- this.updateClocks();
- }
-
- 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)`;
- }
-
- calculateDistance(aircraft) {
- if (!aircraft.Latitude || !aircraft.Longitude) return null;
-
- // Use closest source as reference point
- let minDistance = Infinity;
- for (const [id, srcData] of Object.entries(aircraft.Sources || {})) {
- if (srcData.Distance && srcData.Distance < minDistance) {
- minDistance = srcData.Distance;
- }
- }
-
- return minDistance === Infinity ? null : minDistance;
- }
-
- getCountryFromICAO(icao) {
- if (!icao || icao.length < 6) return 'Unknown';
-
- const prefix = icao[0];
- const countryMap = {
- '4': 'Europe',
- 'A': 'United States',
- 'C': 'Canada',
- 'D': 'Germany',
- 'F': 'France',
- 'G': 'United Kingdom',
- 'I': 'Italy',
- 'J': 'Japan'
- };
-
- return countryMap[prefix] || 'Unknown';
- }
-
- getCountryFlag(country) {
- const flags = {
- 'United States': '🇺🇸',
- 'Canada': '🇨🇦',
- 'Germany': '🇩🇪',
- 'France': '🇫🇷',
- 'United Kingdom': '🇬🇧',
- 'Italy': '🇮🇹',
- 'Japan': '🇯🇵',
- 'Europe': '🇪🇺'
- };
- return flags[country] || '🏳️';
- }
-
- startPeriodicTasks() {
- // Update clocks every second
- setInterval(() => this.updateClocks(), 1000);
-
- // Periodic cleanup
- setInterval(() => {
- // Clean up old trail data, etc.
- }, 30000);
- }
-
- showError(message) {
- console.error(message);
- // Could implement toast notifications here
- }
-}
-
-// Initialize application when DOM is ready
-document.addEventListener('DOMContentLoaded', () => {
- window.skyview = new SkyView();
-});
\ No newline at end of file
diff --git a/internal/beast/parser.go b/internal/beast/parser.go
index e1ac40d..2517765 100644
--- a/internal/beast/parser.go
+++ b/internal/beast/parser.go
@@ -20,12 +20,12 @@ const (
// Message represents a Beast format message
type Message struct {
- Type byte
- Timestamp uint64 // 48-bit timestamp in 12MHz ticks
- Signal uint8 // Signal level (RSSI)
- Data []byte // Mode S data
+ Type byte
+ Timestamp uint64 // 48-bit timestamp in 12MHz ticks
+ Signal uint8 // Signal level (RSSI)
+ Data []byte // Mode S data
ReceivedAt time.Time
- SourceID string // Identifier for the source receiver
+ SourceID string // Identifier for the source receiver
}
// Parser handles Beast binary format parsing
@@ -146,7 +146,7 @@ func (msg *Message) GetSignalStrength() float64 {
if msg.Signal == 0 {
return -50.0 // Minimum detectable signal
}
- return float64(msg.Signal)*(-50.0/255.0)
+ return float64(msg.Signal) * (-50.0 / 255.0)
}
// GetICAO24 extracts the ICAO 24-bit address from Mode S messages
@@ -154,11 +154,11 @@ func (msg *Message) GetICAO24() (uint32, error) {
if msg.Type == BeastModeAC {
return 0, errors.New("Mode A/C messages don't contain ICAO address")
}
-
+
if len(msg.Data) < 4 {
return 0, errors.New("insufficient data for ICAO address")
}
-
+
// ICAO address is in bytes 1-3 of Mode S messages
icao := uint32(msg.Data[1])<<16 | uint32(msg.Data[2])<<8 | uint32(msg.Data[3])
return icao, nil
@@ -178,10 +178,10 @@ func (msg *Message) GetTypeCode() (uint8, error) {
if df != 17 && df != 18 { // Extended squitter
return 0, errors.New("not an extended squitter message")
}
-
+
if len(msg.Data) < 5 {
return 0, errors.New("insufficient data for type code")
}
-
+
return (msg.Data[4] >> 3) & 0x1F, nil
-}
\ No newline at end of file
+}
diff --git a/internal/client/beast.go b/internal/client/beast.go
index fa146a1..d7df935 100644
--- a/internal/client/beast.go
+++ b/internal/client/beast.go
@@ -6,7 +6,7 @@ import (
"net"
"sync"
"time"
-
+
"skyview/internal/beast"
"skyview/internal/merger"
"skyview/internal/modes"
@@ -23,7 +23,7 @@ type BeastClient struct {
errChan chan error
stopChan chan struct{}
wg sync.WaitGroup
-
+
reconnectDelay time.Duration
maxReconnect time.Duration
}
@@ -60,9 +60,9 @@ func (c *BeastClient) Stop() {
// run is the main client loop
func (c *BeastClient) run(ctx context.Context) {
defer c.wg.Done()
-
+
reconnectDelay := c.reconnectDelay
-
+
for {
select {
case <-ctx.Done():
@@ -71,16 +71,16 @@ func (c *BeastClient) run(ctx context.Context) {
return
default:
}
-
+
// Connect to Beast TCP stream
addr := fmt.Sprintf("%s:%d", c.source.Host, c.source.Port)
fmt.Printf("Connecting to Beast stream at %s (%s)...\n", addr, c.source.Name)
-
+
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
fmt.Printf("Failed to connect to %s: %v\n", c.source.Name, err)
c.source.Active = false
-
+
// Exponential backoff
time.Sleep(reconnectDelay)
if reconnectDelay < c.maxReconnect {
@@ -88,21 +88,21 @@ func (c *BeastClient) run(ctx context.Context) {
}
continue
}
-
+
c.conn = conn
c.source.Active = true
reconnectDelay = c.reconnectDelay // Reset backoff
-
+
fmt.Printf("Connected to %s at %s\n", c.source.Name, addr)
-
+
// Create parser for this connection
c.parser = beast.NewParser(conn, c.source.ID)
-
+
// Start processing messages
c.wg.Add(2)
go c.readMessages()
go c.processMessages()
-
+
// Wait for disconnect
select {
case <-ctx.Done():
@@ -116,7 +116,7 @@ func (c *BeastClient) run(ctx context.Context) {
c.conn.Close()
c.source.Active = false
}
-
+
// Wait for goroutines to finish
time.Sleep(1 * time.Second)
}
@@ -131,7 +131,7 @@ func (c *BeastClient) readMessages() {
// processMessages decodes and merges aircraft data
func (c *BeastClient) processMessages() {
defer c.wg.Done()
-
+
for {
select {
case <-c.stopChan:
@@ -140,13 +140,13 @@ func (c *BeastClient) processMessages() {
if msg == nil {
return
}
-
+
// Decode Mode S message
aircraft, err := c.decoder.Decode(msg.Data)
if err != nil {
continue // Skip invalid messages
}
-
+
// Update merger with new data
c.merger.UpdateAircraft(
c.source.ID,
@@ -154,7 +154,7 @@ func (c *BeastClient) processMessages() {
msg.GetSignalStrength(),
msg.ReceivedAt,
)
-
+
// Update source statistics
c.source.Messages++
}
@@ -180,10 +180,10 @@ func NewMultiSourceClient(merger *merger.Merger) *MultiSourceClient {
func (m *MultiSourceClient) AddSource(source *merger.Source) {
m.mu.Lock()
defer m.mu.Unlock()
-
+
// Register source with merger
m.merger.AddSource(source)
-
+
// Create and start client
client := NewBeastClient(source, m.merger)
m.clients = append(m.clients, client)
@@ -193,11 +193,11 @@ func (m *MultiSourceClient) AddSource(source *merger.Source) {
func (m *MultiSourceClient) Start(ctx context.Context) {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
for _, client := range m.clients {
client.Start(ctx)
}
-
+
// Start cleanup routine
go m.cleanupRoutine(ctx)
}
@@ -206,7 +206,7 @@ func (m *MultiSourceClient) Start(ctx context.Context) {
func (m *MultiSourceClient) Stop() {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
for _, client := range m.clients {
client.Stop()
}
@@ -216,7 +216,7 @@ func (m *MultiSourceClient) Stop() {
func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
-
+
for {
select {
case <-ctx.Done():
@@ -231,9 +231,9 @@ func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) {
func (m *MultiSourceClient) GetStatistics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
stats := m.merger.GetStatistics()
-
+
// Add client-specific stats
activeClients := 0
for _, client := range m.clients {
@@ -241,9 +241,9 @@ func (m *MultiSourceClient) GetStatistics() map[string]interface{} {
activeClients++
}
}
-
+
stats["active_clients"] = activeClients
stats["total_clients"] = len(m.clients)
-
+
return stats
-}
\ No newline at end of file
+}
diff --git a/internal/client/dump1090.go b/internal/client/dump1090.go
index 0e08352..382655a 100644
--- a/internal/client/dump1090.go
+++ b/internal/client/dump1090.go
@@ -52,7 +52,7 @@ func (c *Dump1090Client) startDataStream(ctx context.Context) {
func (c *Dump1090Client) connectAndRead(ctx context.Context) error {
address := fmt.Sprintf("%s:%d", c.config.Dump1090.Host, c.config.Dump1090.DataPort)
-
+
conn, err := net.Dial("tcp", address)
if err != nil {
return fmt.Errorf("failed to connect to %s: %w", address, err)
@@ -109,7 +109,7 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
if update.Latitude != 0 && update.Longitude != 0 {
existing.Latitude = update.Latitude
existing.Longitude = update.Longitude
-
+
// Add to track history if position changed significantly
if c.shouldAddTrackPoint(existing, update) {
trackPoint := parser.TrackPoint{
@@ -120,9 +120,9 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
Speed: update.GroundSpeed,
Track: update.Track,
}
-
+
existing.TrackHistory = append(existing.TrackHistory, trackPoint)
-
+
// Keep only last 200 points (about 3-4 hours at 1 point/minute)
if len(existing.TrackHistory) > 200 {
existing.TrackHistory = existing.TrackHistory[1:]
@@ -136,7 +136,7 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
existing.Squawk = update.Squawk
}
existing.OnGround = update.OnGround
-
+
// Preserve country and registration
if update.Country != "" && update.Country != "Unknown" {
existing.Country = update.Country
@@ -152,19 +152,19 @@ func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft)
if len(existing.TrackHistory) == 0 {
return true
}
-
+
lastPoint := existing.TrackHistory[len(existing.TrackHistory)-1]
-
+
// 2. At least 30 seconds since last point
if time.Since(lastPoint.Timestamp) < 30*time.Second {
return false
}
-
+
// 3. Position changed by at least 0.001 degrees (~100m)
latDiff := existing.Latitude - lastPoint.Latitude
lonDiff := existing.Longitude - lastPoint.Longitude
distanceChange := latDiff*latDiff + lonDiff*lonDiff
-
+
return distanceChange > 0.000001 // ~0.001 degrees squared
}
@@ -249,7 +249,7 @@ func (c *Dump1090Client) cleanupStaleAircraft() {
cutoff := time.Now().Add(-2 * time.Minute)
trackCutoff := time.Now().Add(-24 * time.Hour)
-
+
for hex, aircraft := range c.aircraftMap {
if aircraft.LastSeen.Before(cutoff) {
delete(c.aircraftMap, hex)
@@ -264,4 +264,4 @@ func (c *Dump1090Client) cleanupStaleAircraft() {
aircraft.TrackHistory = validTracks
}
}
-}
\ No newline at end of file
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 100034c..bc6cfc0 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -115,4 +115,4 @@ func loadFromEnv(cfg *Config) {
if name := os.Getenv("ORIGIN_NAME"); name != "" {
cfg.Origin.Name = name
}
-}
\ No newline at end of file
+}
diff --git a/internal/merger/merger.go b/internal/merger/merger.go
index f6acb1e..6dcfca7 100644
--- a/internal/merger/merger.go
+++ b/internal/merger/merger.go
@@ -4,7 +4,7 @@ import (
"math"
"sync"
"time"
-
+
"skyview/internal/modes"
)
@@ -26,31 +26,31 @@ type Source struct {
// AircraftState represents merged aircraft state from all sources
type AircraftState struct {
*modes.Aircraft
- Sources map[string]*SourceData `json:"sources"`
- LastUpdate time.Time `json:"last_update"`
- FirstSeen time.Time `json:"first_seen"`
- TotalMessages int64 `json:"total_messages"`
- PositionHistory []PositionPoint `json:"position_history"`
- SignalHistory []SignalPoint `json:"signal_history"`
- AltitudeHistory []AltitudePoint `json:"altitude_history"`
- SpeedHistory []SpeedPoint `json:"speed_history"`
- Distance float64 `json:"distance"` // Distance from closest receiver
- Bearing float64 `json:"bearing"` // Bearing from closest receiver
- Age float64 `json:"age"` // Seconds since last update
- MLATSources []string `json:"mlat_sources"` // Sources providing MLAT data
- PositionSource string `json:"position_source"` // Source providing current position
- UpdateRate float64 `json:"update_rate"` // Updates per second
+ Sources map[string]*SourceData `json:"sources"`
+ LastUpdate time.Time `json:"last_update"`
+ FirstSeen time.Time `json:"first_seen"`
+ TotalMessages int64 `json:"total_messages"`
+ PositionHistory []PositionPoint `json:"position_history"`
+ SignalHistory []SignalPoint `json:"signal_history"`
+ AltitudeHistory []AltitudePoint `json:"altitude_history"`
+ SpeedHistory []SpeedPoint `json:"speed_history"`
+ Distance float64 `json:"distance"` // Distance from closest receiver
+ Bearing float64 `json:"bearing"` // Bearing from closest receiver
+ Age float64 `json:"age"` // Seconds since last update
+ MLATSources []string `json:"mlat_sources"` // Sources providing MLAT data
+ PositionSource string `json:"position_source"` // Source providing current position
+ UpdateRate float64 `json:"update_rate"` // Updates per second
}
// SourceData represents data from a specific source
type SourceData struct {
- SourceID string `json:"source_id"`
- SignalLevel float64 `json:"signal_level"`
- Messages int64 `json:"messages"`
- LastSeen time.Time `json:"last_seen"`
- Distance float64 `json:"distance"`
- Bearing float64 `json:"bearing"`
- UpdateRate float64 `json:"update_rate"`
+ SourceID string `json:"source_id"`
+ SignalLevel float64 `json:"signal_level"`
+ Messages int64 `json:"messages"`
+ LastSeen time.Time `json:"last_seen"`
+ Distance float64 `json:"distance"`
+ Bearing float64 `json:"bearing"`
+ UpdateRate float64 `json:"update_rate"`
}
// Position/Signal/Altitude/Speed history points
@@ -116,7 +116,7 @@ func (m *Merger) AddSource(source *Source) {
func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signal float64, timestamp time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
-
+
// Get or create aircraft state
state, exists := m.aircraft[aircraft.ICAO24]
if !exists {
@@ -134,7 +134,7 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
updates: make([]time.Time, 0),
}
}
-
+
// Update or create source data
srcData, srcExists := state.Sources[sourceID]
if !srcExists {
@@ -143,12 +143,12 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
}
state.Sources[sourceID] = srcData
}
-
+
// Update source data
srcData.SignalLevel = signal
srcData.Messages++
srcData.LastSeen = timestamp
-
+
// Calculate distance and bearing from source
if source, ok := m.sources[sourceID]; ok && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
srcData.Distance, srcData.Bearing = calculateDistanceBearing(
@@ -156,23 +156,23 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
aircraft.Latitude, aircraft.Longitude,
)
}
-
+
// Update merged aircraft data (use best/newest data)
m.mergeAircraftData(state, aircraft, sourceID, timestamp)
-
+
// Update histories
m.updateHistories(state, aircraft, sourceID, signal, timestamp)
-
+
// Update metrics
m.updateUpdateRate(aircraft.ICAO24, timestamp)
-
+
// Update source statistics
if source, ok := m.sources[sourceID]; ok {
source.LastSeen = timestamp
source.Messages++
source.Active = true
}
-
+
state.LastUpdate = timestamp
state.TotalMessages++
}
@@ -182,7 +182,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
// Position - use source with best signal or most recent
if new.Latitude != 0 && new.Longitude != 0 {
updatePosition := false
-
+
if state.Latitude == 0 {
updatePosition = true
} else if srcData, ok := state.Sources[sourceID]; ok {
@@ -192,14 +192,14 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
updatePosition = true
}
}
-
+
if updatePosition {
state.Latitude = new.Latitude
state.Longitude = new.Longitude
state.PositionSource = sourceID
}
}
-
+
// Altitude - use most recent
if new.Altitude != 0 {
state.Altitude = new.Altitude
@@ -210,7 +210,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.GeomAltitude != 0 {
state.GeomAltitude = new.GeomAltitude
}
-
+
// Speed and track - use most recent
if new.GroundSpeed != 0 {
state.GroundSpeed = new.GroundSpeed
@@ -221,12 +221,12 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.Heading != 0 {
state.Heading = new.Heading
}
-
+
// Vertical rate - use most recent
if new.VerticalRate != 0 {
state.VerticalRate = new.VerticalRate
}
-
+
// Identity - use most recent non-empty
if new.Callsign != "" {
state.Callsign = new.Callsign
@@ -237,7 +237,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.Category != "" {
state.Category = new.Category
}
-
+
// Status - use most recent
if new.Emergency != "" {
state.Emergency = new.Emergency
@@ -245,7 +245,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
state.OnGround = new.OnGround
state.Alert = new.Alert
state.SPI = new.SPI
-
+
// Navigation accuracy - use best available
if new.NACp > state.NACp {
state.NACp = new.NACp
@@ -256,7 +256,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.SIL > state.SIL {
state.SIL = new.SIL
}
-
+
// Selected values - use most recent
if new.SelectedAltitude != 0 {
state.SelectedAltitude = new.SelectedAltitude
@@ -280,7 +280,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Source: sourceID,
})
}
-
+
// Signal history
if signal != 0 {
state.SignalHistory = append(state.SignalHistory, SignalPoint{
@@ -289,7 +289,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Source: sourceID,
})
}
-
+
// Altitude history
if aircraft.Altitude != 0 {
state.AltitudeHistory = append(state.AltitudeHistory, AltitudePoint{
@@ -298,7 +298,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
VRate: aircraft.VerticalRate,
})
}
-
+
// Speed history
if aircraft.GroundSpeed != 0 {
state.SpeedHistory = append(state.SpeedHistory, SpeedPoint{
@@ -307,7 +307,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Track: aircraft.Track,
})
}
-
+
// Trim histories if they exceed limit
if len(state.PositionHistory) > m.historyLimit {
state.PositionHistory = state.PositionHistory[len(state.PositionHistory)-m.historyLimit:]
@@ -327,13 +327,13 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) {
metric := m.updateMetrics[icao]
metric.updates = append(metric.updates, timestamp)
-
+
// Keep only last 30 seconds of updates
cutoff := timestamp.Add(-30 * time.Second)
for len(metric.updates) > 0 && metric.updates[0].Before(cutoff) {
metric.updates = metric.updates[1:]
}
-
+
if len(metric.updates) > 1 {
duration := metric.updates[len(metric.updates)-1].Sub(metric.updates[0]).Seconds()
if duration > 0 {
@@ -348,14 +348,14 @@ func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) {
func (m *Merger) getBestSignalSource(state *AircraftState) string {
var bestSource string
var bestSignal float64 = -999
-
+
for srcID, srcData := range state.Sources {
if srcData.SignalLevel > bestSignal {
bestSignal = srcData.SignalLevel
bestSource = srcID
}
}
-
+
return bestSource
}
@@ -363,21 +363,21 @@ func (m *Merger) getBestSignalSource(state *AircraftState) string {
func (m *Merger) GetAircraft() map[uint32]*AircraftState {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
// Create copy and calculate ages
result := make(map[uint32]*AircraftState)
now := time.Now()
-
+
for icao, state := range m.aircraft {
// Skip stale aircraft
if now.Sub(state.LastUpdate) > m.staleTimeout {
continue
}
-
+
// Calculate age
stateCopy := *state
stateCopy.Age = now.Sub(state.LastUpdate).Seconds()
-
+
// Find closest receiver distance
minDistance := float64(999999)
for _, srcData := range state.Sources {
@@ -387,10 +387,10 @@ func (m *Merger) GetAircraft() map[uint32]*AircraftState {
stateCopy.Bearing = srcData.Bearing
}
}
-
+
result[icao] = &stateCopy
}
-
+
return result
}
@@ -398,7 +398,7 @@ func (m *Merger) GetAircraft() map[uint32]*AircraftState {
func (m *Merger) GetSources() []*Source {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
sources := make([]*Source, 0, len(m.sources))
for _, src := range m.sources {
sources = append(sources, src)
@@ -410,23 +410,23 @@ func (m *Merger) GetSources() []*Source {
func (m *Merger) GetStatistics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
-
+
totalMessages := int64(0)
activeSources := 0
aircraftBySources := make(map[int]int) // Count by number of sources
-
+
for _, state := range m.aircraft {
totalMessages += state.TotalMessages
numSources := len(state.Sources)
aircraftBySources[numSources]++
}
-
+
for _, src := range m.sources {
if src.Active {
activeSources++
}
}
-
+
return map[string]interface{}{
"total_aircraft": len(m.aircraft),
"total_messages": totalMessages,
@@ -439,7 +439,7 @@ func (m *Merger) GetStatistics() map[string]interface{} {
func (m *Merger) CleanupStale() {
m.mu.Lock()
defer m.mu.Unlock()
-
+
now := time.Now()
for icao, state := range m.aircraft {
if now.Sub(state.LastUpdate) > m.staleTimeout {
@@ -454,26 +454,26 @@ func (m *Merger) CleanupStale() {
func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64) {
// Haversine formula for distance
const R = 6371.0 // Earth radius in km
-
+
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
-
+
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
math.Sin(dLon/2)*math.Sin(dLon/2)
-
+
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
distance := R * c
-
+
// Bearing calculation
y := math.Sin(dLon) * math.Cos(lat2*math.Pi/180)
x := math.Cos(lat1*math.Pi/180)*math.Sin(lat2*math.Pi/180) -
math.Sin(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Cos(dLon)
-
+
bearing := math.Atan2(y, x) * 180 / math.Pi
if bearing < 0 {
bearing += 360
}
-
+
return distance, bearing
-}
\ No newline at end of file
+}
diff --git a/internal/modes/decoder.go b/internal/modes/decoder.go
index 3673014..49bd32d 100644
--- a/internal/modes/decoder.go
+++ b/internal/modes/decoder.go
@@ -90,7 +90,7 @@ func (d *Decoder) Decode(data []byte) (*Aircraft, error) {
df := (data[0] >> 3) & 0x1F
icao := d.extractICAO(data, df)
-
+
aircraft := &Aircraft{
ICAO24: icao,
}
@@ -154,49 +154,49 @@ func (d *Decoder) decodeExtendedSquitter(data []byte, aircraft *Aircraft) (*Airc
// decodeIdentification extracts callsign and category
func (d *Decoder) decodeIdentification(data []byte, aircraft *Aircraft) {
tc := (data[4] >> 3) & 0x1F
-
+
// Category
aircraft.Category = d.getAircraftCategory(tc, data[4]&0x07)
-
+
// Callsign - 8 characters encoded in 6 bits each
chars := "#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######"
callsign := ""
-
+
// Extract 48 bits starting from bit 40
for i := 0; i < 8; i++ {
bitOffset := 40 + i*6
byteOffset := bitOffset / 8
bitShift := bitOffset % 8
-
+
var charCode uint8
if bitShift <= 2 {
charCode = (data[byteOffset] >> (2 - bitShift)) & 0x3F
} else {
- charCode = ((data[byteOffset] << (bitShift - 2)) & 0x3F) |
- (data[byteOffset+1] >> (10 - bitShift))
+ charCode = ((data[byteOffset] << (bitShift - 2)) & 0x3F) |
+ (data[byteOffset+1] >> (10 - bitShift))
}
-
+
if charCode < 64 {
callsign += string(chars[charCode])
}
}
-
+
aircraft.Callsign = callsign
}
// decodeAirbornePosition extracts position from CPR encoded data
func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
tc := (data[4] >> 3) & 0x1F
-
+
// Altitude
altBits := (uint16(data[5])<<4 | uint16(data[6])>>4) & 0x0FFF
aircraft.Altitude = d.decodeAltitudeBits(altBits, tc)
-
+
// CPR latitude/longitude
cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
oddFlag := (data[6] >> 2) & 0x01
-
+
// Store CPR values for later decoding
if oddFlag == 1 {
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
@@ -205,7 +205,7 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
}
-
+
// Try to decode position if we have both even and odd messages
d.decodeCPRPosition(aircraft)
}
@@ -214,42 +214,42 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
evenLat, evenExists := d.cprEvenLat[aircraft.ICAO24]
oddLat, oddExists := d.cprOddLat[aircraft.ICAO24]
-
+
if !evenExists || !oddExists {
return
}
-
+
evenLon := d.cprEvenLon[aircraft.ICAO24]
oddLon := d.cprOddLon[aircraft.ICAO24]
-
+
// CPR decoding algorithm
dLat := 360.0 / 60.0
j := math.Floor(evenLat*59 - oddLat*60 + 0.5)
-
+
latEven := dLat * (math.Mod(j, 60) + evenLat)
latOdd := dLat * (math.Mod(j, 59) + oddLat)
-
+
if latEven >= 270 {
latEven -= 360
}
if latOdd >= 270 {
latOdd -= 360
}
-
+
// Choose the most recent position
aircraft.Latitude = latOdd // Use odd for now, should check timestamps
-
+
// Longitude calculation
nl := d.nlFunction(aircraft.Latitude)
ni := math.Max(nl-1, 1)
dLon := 360.0 / ni
m := math.Floor(evenLon*(nl-1) - oddLon*nl + 0.5)
lon := dLon * (math.Mod(m, ni) + oddLon)
-
+
if lon >= 180 {
lon -= 360
}
-
+
aircraft.Longitude = lon
}
@@ -258,41 +258,41 @@ func (d *Decoder) nlFunction(lat float64) float64 {
if math.Abs(lat) >= 87 {
return 2
}
-
+
nz := 15.0
a := 1 - math.Cos(math.Pi/(2*nz))
b := math.Pow(math.Cos(math.Pi/180.0*math.Abs(lat)), 2)
nl := 2 * math.Pi / math.Acos(1-a/b)
-
+
return math.Floor(nl)
}
// decodeVelocity extracts speed and heading
func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) {
subtype := (data[4]) & 0x07
-
+
if subtype == 1 || subtype == 2 {
// Ground speed
ewRaw := uint16(data[5]&0x03)<<8 | uint16(data[6])
nsRaw := uint16(data[7])<<3 | uint16(data[8])>>5
-
+
ewVel := float64(ewRaw - 1)
nsVel := float64(nsRaw - 1)
-
+
if data[5]&0x04 != 0 {
ewVel = -ewVel
}
if data[7]&0x80 != 0 {
nsVel = -nsVel
}
-
+
aircraft.GroundSpeed = math.Sqrt(ewVel*ewVel + nsVel*nsVel)
aircraft.Track = math.Atan2(ewVel, nsVel) * 180 / math.Pi
if aircraft.Track < 0 {
aircraft.Track += 360
}
}
-
+
// Vertical rate
vrSign := (data[8] >> 3) & 0x01
vrBits := uint16(data[8]&0x07)<<6 | uint16(data[9])>>2
@@ -315,20 +315,20 @@ func (d *Decoder) decodeAltitudeBits(altCode uint16, tc uint8) int {
if altCode == 0 {
return 0
}
-
+
// Gray code to binary conversion
var n uint16
for i := uint(0); i < 12; i++ {
n ^= altCode >> i
}
-
+
alt := int(n)*25 - 1000
-
+
if tc >= 20 && tc <= 22 {
// GNSS altitude
return alt
}
-
+
return alt
}
@@ -398,7 +398,7 @@ func (d *Decoder) getAircraftCategory(tc uint8, ca uint8) string {
// decodeStatus handles aircraft status messages
func (d *Decoder) decodeStatus(data []byte, aircraft *Aircraft) {
subtype := data[4] & 0x07
-
+
if subtype == 1 {
// Emergency/priority status
emergency := (data[5] >> 5) & 0x07
@@ -428,7 +428,7 @@ func (d *Decoder) decodeTargetState(data []byte, aircraft *Aircraft) {
if altBits != 0 {
aircraft.SelectedAltitude = int(altBits)*32 - 32
}
-
+
// Barometric pressure setting
baroBits := uint16(data[7])<<1 | uint16(data[8])>>7
if baroBits != 0 {
@@ -447,25 +447,25 @@ func (d *Decoder) decodeOperationalStatus(data []byte, aircraft *Aircraft) {
// decodeSurfacePosition handles surface position messages
func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
aircraft.OnGround = true
-
+
// Movement
movement := uint8(data[4]&0x07)<<4 | uint8(data[5])>>4
if movement > 0 && movement < 125 {
aircraft.GroundSpeed = d.decodeGroundSpeed(movement)
}
-
+
// Track
trackValid := (data[5] >> 3) & 0x01
if trackValid != 0 {
trackBits := uint16(data[5]&0x07)<<4 | uint16(data[6])>>4
aircraft.Track = float64(trackBits) * 360.0 / 128.0
}
-
+
// CPR position (similar to airborne)
cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
oddFlag := (data[6] >> 2) & 0x01
-
+
if oddFlag == 1 {
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
@@ -473,7 +473,7 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
}
-
+
d.decodeCPRPosition(aircraft)
}
@@ -497,4 +497,4 @@ func (d *Decoder) decodeGroundSpeed(movement uint8) float64 {
return 175.0
}
return 0
-}
\ No newline at end of file
+}
diff --git a/internal/parser/sbs1.go b/internal/parser/sbs1.go
index 8106988..873c764 100644
--- a/internal/parser/sbs1.go
+++ b/internal/parser/sbs1.go
@@ -16,23 +16,23 @@ type TrackPoint struct {
}
type Aircraft struct {
- Hex string `json:"hex"`
- Flight string `json:"flight,omitempty"`
- Altitude int `json:"alt_baro,omitempty"`
- GroundSpeed int `json:"gs,omitempty"`
- Track int `json:"track,omitempty"`
- Latitude float64 `json:"lat,omitempty"`
- Longitude float64 `json:"lon,omitempty"`
- VertRate int `json:"vert_rate,omitempty"`
- Squawk string `json:"squawk,omitempty"`
- Emergency bool `json:"emergency,omitempty"`
- OnGround bool `json:"on_ground,omitempty"`
- LastSeen time.Time `json:"last_seen"`
- Messages int `json:"messages"`
+ Hex string `json:"hex"`
+ Flight string `json:"flight,omitempty"`
+ Altitude int `json:"alt_baro,omitempty"`
+ GroundSpeed int `json:"gs,omitempty"`
+ Track int `json:"track,omitempty"`
+ Latitude float64 `json:"lat,omitempty"`
+ Longitude float64 `json:"lon,omitempty"`
+ VertRate int `json:"vert_rate,omitempty"`
+ Squawk string `json:"squawk,omitempty"`
+ Emergency bool `json:"emergency,omitempty"`
+ OnGround bool `json:"on_ground,omitempty"`
+ LastSeen time.Time `json:"last_seen"`
+ Messages int `json:"messages"`
TrackHistory []TrackPoint `json:"track_history,omitempty"`
- RSSI float64 `json:"rssi,omitempty"`
- Country string `json:"country,omitempty"`
- Registration string `json:"registration,omitempty"`
+ RSSI float64 `json:"rssi,omitempty"`
+ Country string `json:"country,omitempty"`
+ Registration string `json:"registration,omitempty"`
}
type AircraftData struct {
@@ -117,7 +117,7 @@ func getCountryFromICAO(icao string) string {
}
prefix := icao[:1]
-
+
switch prefix {
case "4":
return getCountryFrom4xxxx(icao)
@@ -222,4 +222,3 @@ func getRegistrationFromICAO(icao string) string {
return icao
}
}
-
diff --git a/internal/server/server.go b/internal/server/server.go
index c69649c..6f974f8 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -14,7 +14,7 @@ import (
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
-
+
"skyview/internal/merger"
)
@@ -32,12 +32,12 @@ type Server struct {
staticFiles embed.FS
server *http.Server
origin OriginConfig
-
+
// WebSocket management
wsClients map[*websocket.Conn]bool
wsClientsMu sync.RWMutex
upgrader websocket.Upgrader
-
+
// Broadcast channels
broadcastChan chan []byte
stopChan chan struct{}
@@ -60,11 +60,11 @@ type AircraftUpdate struct {
// NewServer creates a new HTTP server
func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin OriginConfig) *Server {
return &Server{
- port: port,
- merger: merger,
- staticFiles: staticFiles,
- origin: origin,
- wsClients: make(map[*websocket.Conn]bool),
+ port: port,
+ merger: merger,
+ staticFiles: staticFiles,
+ origin: origin,
+ wsClients: make(map[*websocket.Conn]bool),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
@@ -81,25 +81,25 @@ func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin Ori
func (s *Server) Start() error {
// Start broadcast routine
go s.broadcastRoutine()
-
+
// Start periodic updates
go s.periodicUpdateRoutine()
-
+
// Setup routes
router := s.setupRoutes()
-
+
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
}
-
+
return s.server.ListenAndServe()
}
// Stop gracefully stops the server
func (s *Server) Stop() {
close(s.stopChan)
-
+
if s.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -109,7 +109,7 @@ func (s *Server) Stop() {
func (s *Server) setupRoutes() http.Handler {
router := mux.NewRouter()
-
+
// API routes
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
@@ -119,36 +119,36 @@ func (s *Server) setupRoutes() http.Handler {
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
api.HandleFunc("/coverage/{sourceId}", s.handleGetCoverage).Methods("GET")
api.HandleFunc("/heatmap/{sourceId}", s.handleGetHeatmap).Methods("GET")
-
+
// WebSocket
router.HandleFunc("/ws", s.handleWebSocket)
-
+
// Static files
router.PathPrefix("/static/").Handler(s.staticFileHandler())
router.HandleFunc("/favicon.ico", s.handleFavicon)
-
+
// Main page
router.HandleFunc("/", s.handleIndex)
-
+
// Enable CORS
return s.enableCORS(router)
}
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
-
+
// Convert ICAO keys to hex strings for JSON
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
-
+
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"aircraft": aircraftMap,
"count": len(aircraft),
}
-
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@@ -156,14 +156,14 @@ func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
icaoStr := vars["icao"]
-
+
// Parse ICAO hex string
icao, err := strconv.ParseUint(icaoStr, 16, 32)
if err != nil {
http.Error(w, "Invalid ICAO address", http.StatusBadRequest)
return
}
-
+
aircraft := s.merger.GetAircraft()
if state, exists := aircraft[uint32(icao)]; exists {
w.Header().Set("Content-Type", "application/json")
@@ -175,7 +175,7 @@ func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request
func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
sources := s.merger.GetSources()
-
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sources": sources,
@@ -185,7 +185,7 @@ func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
stats := s.merger.GetStatistics()
-
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
@@ -198,11 +198,11 @@ func (s *Server) handleGetOrigin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
-
+
// Generate coverage data based on signal strength
aircraft := s.merger.GetAircraft()
coveragePoints := make([]map[string]interface{}, 0)
-
+
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
coveragePoints = append(coveragePoints, map[string]interface{}{
@@ -214,7 +214,7 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
})
}
}
-
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"source": sourceID,
@@ -225,21 +225,21 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
-
+
// Generate heatmap data grid
aircraft := s.merger.GetAircraft()
heatmapData := make(map[string]interface{})
-
+
// Simple grid-based heatmap
grid := make([][]float64, 100)
for i := range grid {
grid[i] = make([]float64, 100)
}
-
+
// Find bounds
minLat, maxLat := 90.0, -90.0
minLon, maxLon := 180.0, -180.0
-
+
for _, state := range aircraft {
if _, exists := state.Sources[sourceID]; exists {
if state.Latitude < minLat {
@@ -256,19 +256,19 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
}
}
}
-
+
// Fill grid
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
latIdx := int((state.Latitude - minLat) / (maxLat - minLat) * 99)
lonIdx := int((state.Longitude - minLon) / (maxLon - minLon) * 99)
-
+
if latIdx >= 0 && latIdx < 100 && lonIdx >= 0 && lonIdx < 100 {
grid[latIdx][lonIdx] += srcData.SignalLevel
}
}
}
-
+
heatmapData["grid"] = grid
heatmapData["bounds"] = map[string]float64{
"minLat": minLat,
@@ -276,7 +276,7 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
"minLon": minLon,
"maxLon": maxLon,
}
-
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(heatmapData)
}
@@ -288,15 +288,15 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
defer conn.Close()
-
+
// Register client
s.wsClientsMu.Lock()
s.wsClients[conn] = true
s.wsClientsMu.Unlock()
-
+
// Send initial data
s.sendInitialData(conn)
-
+
// Handle client messages (ping/pong)
for {
_, _, err := conn.ReadMessage()
@@ -304,7 +304,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
break
}
}
-
+
// Unregister client
s.wsClientsMu.Lock()
delete(s.wsClients, conn)
@@ -315,25 +315,25 @@ func (s *Server) sendInitialData(conn *websocket.Conn) {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
-
+
// Convert ICAO keys to hex strings
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
-
+
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
-
+
msg := WebSocketMessage{
Type: "initial_data",
Timestamp: time.Now().Unix(),
Data: update,
}
-
+
conn.WriteJSON(msg)
}
@@ -358,7 +358,7 @@ func (s *Server) broadcastRoutine() {
func (s *Server) periodicUpdateRoutine() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
-
+
for {
select {
case <-s.stopChan:
@@ -373,25 +373,25 @@ func (s *Server) broadcastUpdate() {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
-
+
// Convert ICAO keys to hex strings
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
-
+
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
-
+
msg := WebSocketMessage{
Type: "aircraft_update",
Timestamp: time.Now().Unix(),
Data: update,
}
-
+
if data, err := json.Marshal(msg); err == nil {
select {
case s.broadcastChan <- data:
@@ -407,7 +407,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Page not found", http.StatusNotFound)
return
}
-
+
w.Header().Set("Content-Type", "text/html")
w.Write(data)
}
@@ -418,7 +418,7 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Favicon not found", http.StatusNotFound)
return
}
-
+
w.Header().Set("Content-Type", "image/x-icon")
w.Write(data)
}
@@ -427,21 +427,21 @@ func (s *Server) staticFileHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Remove /static/ prefix from URL path to get the actual file path
filePath := "static" + r.URL.Path[len("/static"):]
-
+
data, err := s.staticFiles.ReadFile(filePath)
if err != nil {
http.NotFound(w, r)
return
}
-
+
// Set content type
ext := path.Ext(filePath)
contentType := getContentType(ext)
w.Header().Set("Content-Type", contentType)
-
+
// Cache control
w.Header().Set("Cache-Control", "public, max-age=3600")
-
+
w.Write(data)
})
}
@@ -474,12 +474,12 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
-
+
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
-
+
handler.ServeHTTP(w, r)
})
-}
\ No newline at end of file
+}
diff --git a/internal/server/server_test.go b/internal/server/server_test.go
index 61e0e64..054ae7e 100644
--- a/internal/server/server_test.go
+++ b/internal/server/server_test.go
@@ -39,17 +39,17 @@ func TestCORSHeaders(t *testing.T) {
}
handler := New(cfg, testStaticFiles)
-
+
req := httptest.NewRequest("OPTIONS", "/api/aircraft", nil)
w := httptest.NewRecorder()
-
+
handler.ServeHTTP(w, req)
-
+
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Errorf("Expected CORS header, got %s", w.Header().Get("Access-Control-Allow-Origin"))
}
-
+
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
-}
\ No newline at end of file
+}
diff --git a/static/aircraft-icon.svg b/static/aircraft-icon.svg
deleted file mode 100644
index f2489d3..0000000
--- a/static/aircraft-icon.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/static/css/style.css b/static/css/style.css
deleted file mode 100644
index 0f2e125..0000000
--- a/static/css/style.css
+++ /dev/null
@@ -1,488 +0,0 @@
-* {
- 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;
-}
-
-.clock-section {
- flex: 1;
- display: flex;
- justify-content: center;
-}
-
-.clock-display {
- display: flex;
- gap: 2rem;
-}
-
-.clock {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.5rem;
-}
-
-.clock-face {
- position: relative;
- width: 60px;
- height: 60px;
- border: 2px solid #00a8ff;
- border-radius: 50%;
- background: #1a1a1a;
-}
-
-.clock-face::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- width: 4px;
- height: 4px;
- background: #00a8ff;
- border-radius: 50%;
- transform: translate(-50%, -50%);
- z-index: 3;
-}
-
-.clock-hand {
- position: absolute;
- background: #00a8ff;
- transform-origin: bottom center;
- border-radius: 2px;
-}
-
-.hour-hand {
- width: 3px;
- height: 18px;
- top: 12px;
- left: 50%;
- margin-left: -1.5px;
-}
-
-.minute-hand {
- width: 2px;
- height: 25px;
- top: 5px;
- left: 50%;
- margin-left: -1px;
-}
-
-.clock-label {
- font-size: 0.8rem;
- color: #888;
- text-align: center;
-}
-
-.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;
-}
-
-.legend {
- position: absolute;
- bottom: 10px;
- left: 10px;
- background: rgba(45, 45, 45, 0.95);
- border: 1px solid #404040;
- border-radius: 8px;
- padding: 1rem;
- z-index: 1000;
- min-width: 150px;
-}
-
-.legend h4 {
- margin: 0 0 0.5rem 0;
- font-size: 0.9rem;
- color: #ffffff;
-}
-
-.legend-item {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-bottom: 0.25rem;
- font-size: 0.8rem;
-}
-
-.legend-icon {
- width: 16px;
- height: 16px;
- border-radius: 2px;
- border: 1px solid #ffffff;
-}
-
-.legend-icon.commercial { background: #00ff88; }
-.legend-icon.cargo { background: #ff8c00; }
-.legend-icon.military { background: #ff4444; }
-.legend-icon.ga { background: #ffff00; }
-.legend-icon.ground { background: #888888; }
-
-.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;
-}
-
-.type-badge {
- padding: 0.2rem 0.4rem;
- border-radius: 3px;
- font-size: 0.7rem;
- font-weight: bold;
- color: #000000;
-}
-
-.type-badge.commercial { background: #00ff88; }
-.type-badge.cargo { background: #ff8c00; }
-.type-badge.military { background: #ff4444; }
-.type-badge.ga { background: #ffff00; }
-.type-badge.ground { background: #888888; color: #ffffff; }
-
-/* RSSI signal strength colors */
-.rssi-strong { color: #00ff88; }
-.rssi-good { color: #ffff00; }
-.rssi-weak { color: #ff8c00; }
-.rssi-poor { color: #ff4444; }
-
-.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 {
- transform: rotate(0deg);
- filter: drop-shadow(0 0 4px rgba(0,0,0,0.9));
- z-index: 1000;
-}
-
-.aircraft-popup {
- min-width: 300px;
- max-width: 400px;
-}
-
-.popup-header {
- border-bottom: 1px solid #404040;
- padding-bottom: 0.5rem;
- margin-bottom: 0.75rem;
-}
-
-.flight-info {
- font-size: 1.1rem;
- font-weight: bold;
-}
-
-.icao-flag {
- font-size: 1.2rem;
- margin-right: 0.5rem;
-}
-
-.flight-id {
- color: #00a8ff;
- font-family: monospace;
-}
-
-.callsign {
- color: #00ff88;
-}
-
-.popup-details {
- font-size: 0.9rem;
-}
-
-.detail-row {
- margin-bottom: 0.5rem;
- padding: 0.25rem 0;
-}
-
-.detail-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 0.5rem;
- margin: 0.75rem 0;
-}
-
-.detail-item {
- display: flex;
- flex-direction: column;
-}
-
-.detail-item .label {
- font-size: 0.8rem;
- color: #888;
- margin-bottom: 0.1rem;
-}
-
-.detail-item .value {
- font-weight: bold;
- color: #ffffff;
-}
-
-@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;
- }
-}
\ No newline at end of file
diff --git a/static/favicon.ico b/static/favicon.ico
deleted file mode 100644
index a0f2003..0000000
--- a/static/favicon.ico
+++ /dev/null
@@ -1 +0,0 @@
-data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
\ No newline at end of file
diff --git a/static/index.html b/static/index.html
deleted file mode 100644
index 5dd05b6..0000000
--- a/static/index.html
+++ /dev/null
@@ -1,226 +0,0 @@
-
-
-
-
-
-
SkyView - Multi-Source ADS-B Aircraft Tracker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Map
- Table
- Statistics
- Coverage
- 3D Radar
-
-
-
-
-
-
-
-
- Center Map
- Show Trails
- Show Range
- Show Sources
-
-
-
-
-
Aircraft Types
-
-
- Commercial
-
-
-
- Cargo
-
-
-
- Military
-
-
-
- General Aviation
-
-
-
- Ground
-
-
-
Sources
-
-
-
-
-
-
-
-
-
- Distance
- Altitude
- Speed
- Flight
- ICAO
- Squawk
- Signal
- Age
-
-
- All Sources
-
-
-
-
-
-
- ICAO
- Flight
- Squawk
- Altitude
- Speed
- Distance
- Track
- Sources
- Signal
- Age
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Aircraft Count Timeline
-
-
-
-
Message Rate by Source
-
-
-
-
Signal Strength Distribution
-
-
-
-
Altitude Distribution
-
-
-
-
-
-
-
-
-
- Select Source
-
- Toggle Heatmap
-
-
-
-
-
-
-
- Reset View
- Auto Rotate
-
-
- Range: 100 km
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file