Files
intercept/templates/adsb_dashboard.html
Smittix bb24bdb06c Fix aircraft dashboard audio endpoints (404 error)
Changed /spectrum/audio/* to /listening/audio/* to match the
actual listening_post blueprint URL prefix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:37:36 +00:00

1966 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
AIRCRAFT RADAR
<span>// INTERCEPT</span>
</div>
<div class="stats-badges">
<div class="stat-badge">
<span class="value" id="statTotal">0</span>
<span class="label">aircraft</span>
</div>
<div class="stat-badge">
<span class="value" id="statMaxRange">0</span>
<span class="label">nm max</span>
</div>
<div class="stat-badge">
<span class="value" id="statMsgRate">0</span>
<span class="label">msg/s</span>
</div>
</div>
<div class="status-bar">
<div class="status-item">
<div class="status-dot inactive" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="datetime" id="utcTime">--:--:-- UTC</div>
<a href="/?mode=aircraft" class="back-link">Main Dashboard</a>
</div>
</header>
<main class="dashboard">
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
<div id="radarMap"></div>
<div id="radarScope">
<canvas id="radarCanvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" id="mapViewBtn" onclick="setView('map')">MAP</button>
<button class="view-btn" id="radarViewBtn" onclick="setView('radar')">RADAR</button>
</div>
<!-- Selected Aircraft -->
<div class="panel selected-aircraft">
<div class="panel-header">
<span>SELECTED TARGET</span>
<div class="panel-indicator"></div>
</div>
<div class="selected-info" id="selectedInfo">
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>
</div>
</div>
<!-- Aircraft List -->
<div class="panel aircraft-list">
<div class="panel-header">
<span>TRACKED AIRCRAFT</span>
<div class="panel-indicator"></div>
</div>
<div class="aircraft-list-content" id="aircraftList">
<div class="no-aircraft">
<div>No aircraft detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<div class="control-group">
<label>
<input type="checkbox" id="showTrails" onchange="toggleTrails()">
Trails
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="showRangeRings" onchange="drawRangeRings()">
Range Rings
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()">
Alerts
</label>
</div>
<div class="control-group">
<span class="control-label">Filter:</span>
<select id="aircraftFilter" onchange="applyFilter()">
<option value="all">All</option>
<option value="military">Military</option>
<option value="civil">Civil</option>
<option value="emergency">Emergency</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Range:</span>
<select id="rangeSelect" onchange="updateRange()">
<option value="50">50 nm</option>
<option value="100">100 nm</option>
<option value="200" selected>200 nm</option>
<option value="300">300 nm</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Lat:</span>
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<span class="control-label">Lon:</span>
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<select id="gpsSource" onchange="toggleGpsDongleControls()" style="font-size: 10px;">
<option value="manual">Manual</option>
<option value="browser">Browser</option>
<option value="dongle">USB GPS</option>
<option value="gpsd">gpsd</option>
</select>
</div>
<div class="control-group" id="browserGpsGroup">
<button class="gps-btn" id="geolocateBtn" onclick="getGeolocation()">Locate</button>
</div>
<div class="control-group gps-dongle-controls" style="display: none;">
<select class="gps-device-select" id="gpsDeviceSelect" style="font-size: 10px; max-width: 120px;">
<option value="">GPS Device...</option>
</select>
<select id="gpsBaudrateSelect" style="font-size: 10px; width: 65px;">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select>
<button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button>
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
</div>
<div class="control-group gps-gpsd-controls" style="display: none;">
<input type="text" id="gpsdHost" value="localhost" placeholder="Host" style="width: 80px; font-size: 10px;">
<span style="color: #666;">:</span>
<input type="number" id="gpsdPort" value="2947" min="1" max="65535" style="width: 50px; font-size: 10px;">
<button class="gps-btn gps-connect-btn" onclick="startGpsdClient()">Connect</button>
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
</div>
<div class="control-group">
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
<span>Remote</span>
</label>
</div>
<div class="control-group remote-dump1090-controls" style="display: none;">
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 90px; font-size: 10px;">
<span style="color: #666;">:</span>
<input type="number" id="remoteSbsPort" value="30003" min="1" max="65535" style="width: 55px; font-size: 10px;">
</div>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
<div class="airband-divider"></div>
<div class="control-group airband-controls">
<span class="control-label" style="color: var(--accent-cyan);">AIRBAND:</span>
<select id="airbandFreqSelect" onchange="updateAirbandFreq()">
<option value="121.5">121.5 MHz (Guard)</option>
<option value="118.0">118.0 MHz</option>
<option value="119.1">119.1 MHz</option>
<option value="120.5">120.5 MHz</option>
<option value="123.45">123.45 MHz (Air-Air)</option>
<option value="127.85">127.85 MHz</option>
<option value="128.825">128.825 MHz</option>
<option value="132.0">132.0 MHz</option>
<option value="134.725">134.725 MHz</option>
<option value="custom">Custom...</option>
</select>
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" style="width: 70px; display: none;">
</div>
<div class="control-group airband-controls">
<span class="control-label">SDR:</span>
<select id="airbandDeviceSelect" style="width: 80px;">
<option value="0">Dev 0</option>
</select>
</div>
<div class="control-group airband-controls">
<span class="control-label">SQ:</span>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" style="width: 60px;">
</div>
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">
<span class="airband-icon"></span> LISTEN
</button>
<div class="airband-status">
<span id="airbandStatus" style="color: var(--text-muted);">OFF</span>
</div>
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
<!-- Airband Visualizer (compact) -->
<div class="airband-visualizer" id="airbandVisualizerContainer" style="display: none;">
<div class="signal-meter">
<div class="meter-bar">
<div class="meter-fill" id="airbandSignalMeter"></div>
<div class="meter-peak" id="airbandSignalPeak"></div>
</div>
</div>
<canvas id="airbandSpectrumCanvas" width="120" height="30"></canvas>
</div>
</div>
</main>
<script>
// ============================================
// STATE
// ============================================
let radarMap = null;
let aircraft = {};
let markers = {};
let selectedIcao = null;
let eventSource = null;
let isTracking = false;
let currentFilter = 'all';
let alertedAircraft = {};
let alertsEnabled = true;
let currentView = 'map'; // 'map' or 'radar'
// Aircraft trails
let aircraftTrails = {}; // ICAO -> [{lat, lon, alt, time}, ...]
let trailLines = {}; // ICAO -> L.polyline (array of segments)
let showTrails = false;
const MAX_TRAIL_POINTS = 100;
// Radar scope
let radarScope = null;
let radarAnimationId = null;
let maxRange = 200; // nautical miles
// Statistics
let stats = {
totalAircraftSeen: new Set(),
maxRange: 0,
messagesPerSecond: 0,
messageTimestamps: []
};
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
// ============================================
// AUDIO ALERTS
// ============================================
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function playAlertSound(type) {
if (!alertsEnabled) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'emergency') {
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
} else if (type === 'military') {
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
}
}
function showAlertBanner(message, color) {
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px; border-radius: 6px;
font-weight: bold; font-size: 13px; z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: slideDown 0.3s ease-out;
`;
banner.textContent = message;
document.body.appendChild(banner);
setTimeout(() => {
banner.style.opacity = '0';
banner.style.transition = 'opacity 0.3s';
setTimeout(() => banner.remove(), 300);
}, 5000);
}
function toggleAlerts() {
alertsEnabled = document.getElementById('alertToggle').checked;
}
// ============================================
// MILITARY/EMERGENCY DETECTION
// ============================================
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' },
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' },
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' },
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' },
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' },
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' },
];
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
const SQUAWK_CODES = {
'7500': { type: 'hijack', name: 'HIJACK' },
'7600': { type: 'radio', name: 'RADIO FAILURE' },
'7700': { type: 'mayday', name: 'EMERGENCY' }
};
function isMilitaryAircraft(icao, callsign) {
const icaoNum = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (icaoNum >= range.start && icaoNum <= range.end) {
return { military: true, country: range.country };
}
}
if (callsign) {
const upper = callsign.toUpperCase();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) {
return { military: true, type: 'callsign' };
}
}
}
return { military: false };
}
function checkSquawkCode(aircraft) {
if (aircraft.squawk && SQUAWK_CODES[aircraft.squawk]) {
return SQUAWK_CODES[aircraft.squawk];
}
return null;
}
// ============================================
// DISTANCE/BEARING CALCULATIONS
// ============================================
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const 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);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function calculateBearing(lat1, lon1, lat2, lon2) {
const dLon = (lon2 - lon1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
}
// ============================================
// STATISTICS
// ============================================
function updateStatistics(icao, ac) {
if (!ac.lat || !ac.lon) return;
stats.totalAircraftSeen.add(icao);
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > stats.maxRange) {
stats.maxRange = distance;
}
const now = Date.now();
stats.messageTimestamps.push(now);
stats.messageTimestamps = stats.messageTimestamps.filter(t => now - t < 5000);
stats.messagesPerSecond = stats.messageTimestamps.length / 5;
updateStatsDisplay();
}
function updateStatsDisplay() {
document.getElementById('statMaxRange').textContent = stats.maxRange.toFixed(0);
document.getElementById('statMsgRate').textContent = stats.messagesPerSecond.toFixed(1);
document.getElementById('statTotal').textContent = Object.keys(aircraft).length;
}
// ============================================
// AIRCRAFT TRAILS
// ============================================
function toggleTrails() {
showTrails = document.getElementById('showTrails').checked;
if (!showTrails) {
// Remove all trail lines from map
Object.keys(trailLines).forEach(icao => {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
});
} else {
// Draw existing trails
Object.keys(aircraftTrails).forEach(icao => {
updateTrailLine(icao);
});
}
}
function recordTrailPoint(icao, lat, lon, alt) {
if (!aircraftTrails[icao]) aircraftTrails[icao] = [];
const trail = aircraftTrails[icao];
// Only add if moved significantly
if (trail.length === 0 ||
Math.abs(trail[trail.length-1].lat - lat) > 0.0005 ||
Math.abs(trail[trail.length-1].lon - lon) > 0.0005) {
trail.push({ lat, lon, alt: alt || 0, time: Date.now() });
if (trail.length > MAX_TRAIL_POINTS) trail.shift();
}
}
function getAltitudeColor(alt) {
if (!alt || alt <= 0) return '#888888';
if (alt < 10000) return '#00ff88'; // Green - low
if (alt < 25000) return '#00d4ff'; // Cyan - medium
if (alt < 35000) return '#ffcc00'; // Yellow - high
return '#ff9500'; // Orange - very high
}
function updateTrailLine(icao) {
if (!showTrails || !radarMap) return;
const trail = aircraftTrails[icao];
if (!trail || trail.length < 2) return;
// Remove old trail lines
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
}
trailLines[icao] = [];
// Create gradient segments
const now = Date.now();
for (let i = 1; i < trail.length; i++) {
const p1 = trail[i-1];
const p2 = trail[i];
const age = (now - p2.time) / 1000; // seconds
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
const color = getAltitudeColor(p2.alt);
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], {
color: color,
weight: 2,
opacity: opacity
}).addTo(radarMap);
trailLines[icao].push(line);
}
}
function cleanupTrail(icao) {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
delete aircraftTrails[icao];
}
// ============================================
// RADAR SCOPE (PPI)
// ============================================
class RadarScope {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.sweepAngle = 0;
this.blips = []; // Aircraft blips with afterglow
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const container = this.canvas.parentElement;
const size = Math.min(container.clientWidth, container.clientHeight) - 40;
this.canvas.width = size;
this.canvas.height = size;
this.centerX = size / 2;
this.centerY = size / 2;
this.radius = (size / 2) - 30;
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
// Clear
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
// Draw range rings
this.drawRangeRings();
// Draw compass rose
this.drawCompassRose();
// Draw aircraft blips
this.drawBlips();
// Draw sweep line
this.drawSweep();
// Draw center point (observer)
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ffff00';
ctx.fill();
}
drawRangeRings() {
const ctx = this.ctx;
const rings = [0.25, 0.5, 0.75, 1.0];
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
ctx.lineWidth = 1;
rings.forEach((ratio, i) => {
const r = this.radius * ratio;
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, r, 0, Math.PI * 2);
ctx.stroke();
// Range label
const rangeNm = Math.round(maxRange * ratio);
ctx.fillStyle = 'rgba(0, 255, 255, 0.5)';
ctx.font = '10px JetBrains Mono';
ctx.fillText(`${rangeNm}`, this.centerX + r + 5, this.centerY + 4);
});
}
drawCompassRose() {
const ctx = this.ctx;
const directions = [
{ angle: 0, label: 'N' },
{ angle: 90, label: 'E' },
{ angle: 180, label: 'S' },
{ angle: 270, label: 'W' }
];
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 12px Orbitron';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
directions.forEach(d => {
const rad = (d.angle - 90) * Math.PI / 180;
const x = this.centerX + (this.radius + 15) * Math.cos(rad);
const y = this.centerY + (this.radius + 15) * Math.sin(rad);
ctx.fillText(d.label, x, y);
});
// Draw tick marks every 30 degrees
ctx.strokeStyle = 'rgba(0, 255, 255, 0.3)';
ctx.lineWidth = 1;
for (let angle = 0; angle < 360; angle += 30) {
const rad = (angle - 90) * Math.PI / 180;
const inner = this.radius - 5;
const outer = this.radius + 2;
ctx.beginPath();
ctx.moveTo(
this.centerX + inner * Math.cos(rad),
this.centerY + inner * Math.sin(rad)
);
ctx.lineTo(
this.centerX + outer * Math.cos(rad),
this.centerY + outer * Math.sin(rad)
);
ctx.stroke();
}
}
drawSweep() {
const ctx = this.ctx;
const rad = (this.sweepAngle - 90) * Math.PI / 180;
// Sweep line with gradient
const gradient = ctx.createLinearGradient(
this.centerX, this.centerY,
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
gradient.addColorStop(0, 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0.1)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.lineTo(
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.stroke();
// Sweep arc (afterglow)
const startAngle = (this.sweepAngle - 90 - 30) * Math.PI / 180;
const endAngle = (this.sweepAngle - 90) * Math.PI / 180;
const arcGradient = ctx.createConicGradient(startAngle, this.centerX, this.centerY);
arcGradient.addColorStop(0, 'rgba(0, 255, 255, 0)');
arcGradient.addColorStop(1, 'rgba(0, 255, 255, 0.15)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = arcGradient;
ctx.fill();
// Update sweep angle
this.sweepAngle = (this.sweepAngle + 2) % 360;
}
drawBlips() {
const ctx = this.ctx;
const now = Date.now();
// Update blips from aircraft data
this.blips = [];
Object.entries(aircraft).forEach(([icao, ac]) => {
if (!ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) return;
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > maxRange) return;
const bearing = calculateBearing(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
const ratio = distance / maxRange;
const rad = (bearing - 90) * Math.PI / 180;
const x = this.centerX + (this.radius * ratio) * Math.cos(rad);
const y = this.centerY + (this.radius * ratio) * Math.sin(rad);
this.blips.push({
x, y,
icao,
callsign: ac.callsign,
altitude: ac.altitude,
selected: icao === selectedIcao
});
});
// Draw blips
this.blips.forEach(blip => {
// Blip glow
const gradient = ctx.createRadialGradient(blip.x, blip.y, 0, blip.x, blip.y, 12);
gradient.addColorStop(0, blip.selected ? 'rgba(255, 255, 0, 0.8)' : 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(blip.x - 12, blip.y - 12, 24, 24);
// Blip dot
ctx.beginPath();
ctx.arc(blip.x, blip.y, blip.selected ? 5 : 3, 0, Math.PI * 2);
ctx.fillStyle = blip.selected ? '#ffff00' : '#00ffff';
ctx.fill();
// Label
if (blip.callsign || blip.selected) {
ctx.fillStyle = '#00ffff';
ctx.font = '9px JetBrains Mono';
ctx.textAlign = 'left';
ctx.fillText(blip.callsign || blip.icao, blip.x + 8, blip.y - 5);
if (blip.altitude) {
ctx.fillText(`${Math.round(blip.altitude/100)}`, blip.x + 8, blip.y + 7);
}
}
});
}
handleClick(event) {
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Find clicked blip
for (const blip of this.blips) {
const dx = x - blip.x;
const dy = y - blip.y;
if (dx * dx + dy * dy < 100) { // 10px radius
selectAircraft(blip.icao);
return;
}
}
}
}
function startRadarAnimation() {
if (radarAnimationId) return;
function animate() {
if (currentView === 'radar' && radarScope) {
radarScope.draw();
}
radarAnimationId = requestAnimationFrame(animate);
}
animate();
}
function stopRadarAnimation() {
if (radarAnimationId) {
cancelAnimationFrame(radarAnimationId);
radarAnimationId = null;
}
}
// ============================================
// VIEW TOGGLE
// ============================================
function setView(view) {
currentView = view;
const mapEl = document.getElementById('radarMap');
const scopeEl = document.getElementById('radarScope');
const mapBtn = document.getElementById('mapViewBtn');
const radarBtn = document.getElementById('radarViewBtn');
if (view === 'map') {
mapEl.style.display = 'block';
scopeEl.classList.remove('active');
mapBtn.classList.add('active');
radarBtn.classList.remove('active');
stopRadarAnimation();
// Invalidate map size after showing
setTimeout(() => radarMap && radarMap.invalidateSize(), 100);
} else {
mapEl.style.display = 'none';
scopeEl.classList.add('active');
mapBtn.classList.remove('active');
radarBtn.classList.add('active');
if (!radarScope) {
radarScope = new RadarScope('radarCanvas');
document.getElementById('radarCanvas').addEventListener('click', (e) => radarScope.handleClick(e));
}
radarScope.resize();
startRadarAnimation();
}
}
function updateRange() {
maxRange = parseInt(document.getElementById('rangeSelect').value);
drawRangeRings();
}
// ============================================
// RANGE RINGS (MAP)
// ============================================
function drawRangeRings() {
if (!radarMap) return;
if (rangeRingsLayer) {
radarMap.removeLayer(rangeRingsLayer);
rangeRingsLayer = null;
}
const showRings = document.getElementById('showRangeRings')?.checked;
if (!showRings) return;
rangeRingsLayer = L.layerGroup();
const distances = [maxRange * 0.25, maxRange * 0.5, maxRange * 0.75, maxRange];
distances.forEach(nm => {
const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters,
color: '#00ff88',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.4,
dashArray: '5, 5'
});
const labelLat = observerLocation.lat + (nm * 0.0166);
const label = L.marker([labelLat, observerLocation.lon], {
icon: L.divIcon({
className: 'range-label',
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
iconSize: [40, 12],
iconAnchor: [20, 6]
})
});
rangeRingsLayer.addLayer(circle);
rangeRingsLayer.addLayer(label);
});
// Observer marker
if (observerMarker) radarMap.removeLayer(observerMarker);
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(radarMap);
rangeRingsLayer.addTo(radarMap);
}
function updateObserverLoc() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom());
}
drawRangeRings();
}
}
function getGeolocation() {
if (!navigator.geolocation) {
alert('Geolocation not supported');
return;
}
if (!window.isSecureContext) {
alert('GPS requires HTTPS. Enter coordinates manually.');
return;
}
const btn = document.getElementById('geolocateBtn');
btn.textContent = '...';
navigator.geolocation.getCurrentPosition(
(position) => {
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
if (radarMap) {
radarMap.setView([observerLocation.lat, observerLocation.lon], 8);
}
drawRangeRings();
btn.textContent = 'Locate';
},
(error) => {
alert('Location error: ' + error.message);
btn.textContent = 'Locate';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// ============================================
// GPS DONGLE FUNCTIONS
// ============================================
function toggleGpsDongleControls() {
const source = document.getElementById('gpsSource').value;
const browserGroup = document.getElementById('browserGpsGroup');
const dongleControls = document.querySelector('.gps-dongle-controls');
const gpsdControls = document.querySelector('.gps-gpsd-controls');
// Hide all first
browserGroup.style.display = 'none';
dongleControls.style.display = 'none';
gpsdControls.style.display = 'none';
if (source === 'dongle') {
dongleControls.style.display = 'flex';
refreshGpsDevices();
} else if (source === 'browser') {
browserGroup.style.display = 'flex';
} else if (source === 'gpsd') {
gpsdControls.style.display = 'flex';
}
// 'manual' keeps everything hidden
}
async function refreshGpsDevices() {
try {
const response = await fetch('/gps/devices');
const data = await response.json();
if (data.status === 'ok') {
gpsDevices = data.devices;
const select = document.getElementById('gpsDeviceSelect');
select.innerHTML = '<option value="">GPS Device...</option>';
gpsDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.path;
option.textContent = device.name;
option.disabled = !device.accessible;
select.appendChild(option);
});
}
} catch (e) {
console.warn('Failed to get GPS devices:', e);
}
}
async function startGpsDongle() {
const devicePath = document.getElementById('gpsDeviceSelect').value;
const baudrate = parseInt(document.getElementById('gpsBaudrateSelect').value) || 9600;
if (!devicePath) {
alert('Please select a GPS device');
return;
}
try {
const response = await fetch('/gps/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: devicePath, baudrate: baudrate })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsButtons(true, '.gps-dongle-controls');
} else {
alert('Failed to start GPS: ' + data.message);
}
} catch (e) {
alert('GPS connection error: ' + e.message);
}
}
async function startGpsdClient() {
const host = document.getElementById('gpsdHost').value || 'localhost';
const port = parseInt(document.getElementById('gpsdPort').value) || 2947;
try {
const response = await fetch('/gps/gpsd/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: host, port: port })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsButtons(true, '.gps-gpsd-controls');
} else {
alert('Failed to connect to gpsd: ' + data.message);
}
} catch (e) {
alert('gpsd connection error: ' + e.message);
}
}
function updateGpsButtons(connected, containerSelector) {
// Update buttons in the specified container
const container = document.querySelector(containerSelector);
if (container) {
const connectBtn = container.querySelector('.gps-connect-btn');
const disconnectBtn = container.querySelector('.gps-disconnect-btn');
if (connectBtn) connectBtn.style.display = connected ? 'none' : 'block';
if (disconnectBtn) disconnectBtn.style.display = connected ? 'block' : 'none';
}
}
async function stopGpsDongle() {
try {
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
// Reset buttons in both containers
updateGpsButtons(false, '.gps-dongle-controls');
updateGpsButtons(false, '.gps-gpsd-controls');
} catch (e) {
console.warn('GPS stop error:', e);
}
}
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('GPS data received:', data);
if (data.type === 'position' && data.latitude && data.longitude) {
observerLocation.lat = data.latitude;
observerLocation.lon = data.longitude;
document.getElementById('obsLat').value = data.latitude.toFixed(4);
document.getElementById('obsLon').value = data.longitude.toFixed(4);
if (radarMap) {
console.log('GPS: Updating map to', data.latitude, data.longitude);
radarMap.setView([data.latitude, data.longitude], radarMap.getZoom());
}
drawRangeRings();
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
console.warn('GPS stream error:', e);
gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block';
document.querySelector('.gps-disconnect-btn').style.display = 'none';
};
}
// ============================================
// FILTERING
// ============================================
function applyFilter() {
currentFilter = document.getElementById('aircraftFilter').value;
// Clear markers and redraw
Object.keys(markers).forEach(icao => {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
});
Object.keys(markerState).forEach(icao => delete markerState[icao]);
pendingMarkerUpdates.clear();
Object.keys(aircraft).forEach(icao => {
if (aircraft[icao].lat && aircraft[icao].lon) {
pendingMarkerUpdates.add(icao);
}
});
scheduleUIUpdate();
}
function passesFilter(icao, ac) {
if (currentFilter === 'all') return true;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (currentFilter === 'military') return militaryInfo.military;
if (currentFilter === 'civil') return !militaryInfo.military;
if (currentFilter === 'emergency') return !!squawkInfo;
return true;
}
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
initMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
checkAdsbTools();
});
function checkAdsbTools() {
fetch('/adsb/tools')
.then(r => r.json())
.then(data => {
if (data.needs_readsb) {
showReadsbWarning(data.soapy_types);
}
})
.catch(() => {});
}
function showReadsbWarning(sdrTypes) {
const typeList = sdrTypes.join(', ') || 'SoapySDR device';
const warning = document.createElement('div');
warning.id = 'readsbWarning';
warning.style.cssText = `
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(245, 158, 11, 0.95);
color: #000;
padding: 15px 25px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 500px;
text-align: left;
font-family: 'Inter', sans-serif;
`;
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${typeList} Detected - readsb Required</div>
<div style="margin-bottom: 10px;">ADS-B tracking with ${typeList} requires <strong>readsb</strong> compiled with SoapySDR support.</div>
<details style="font-size: 11px;">
<summary style="cursor: pointer; margin-bottom: 8px;">Installation Instructions</summary>
<div style="background: rgba(0,0,0,0.1); padding: 10px; border-radius: 4px; margin-top: 5px;">
<code style="display: block; white-space: pre-wrap; font-family: 'JetBrains Mono', monospace; font-size: 10px;">sudo apt install build-essential libsoapysdr-dev librtlsdr-dev
git clone https://github.com/wiedehopf/readsb.git
cd readsb
make HAVE_SOAPYSDR=1
sudo make install</code>
</div>
</details>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 8px; right: 12px; background: none; border: none; color: #000; cursor: pointer; font-size: 16px; font-weight: bold;">×</button>
`;
document.body.appendChild(warning);
}
function updateClock() {
const now = new Date();
document.getElementById('utcTime').textContent =
now.toISOString().substring(11, 19) + ' UTC';
}
function initMap() {
radarMap = L.map('radarMap', {
center: [51.5, -0.1],
zoom: 7,
minZoom: 3,
maxZoom: 15
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(radarMap);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
radarMap.setView([pos.coords.latitude, pos.coords.longitude], 8);
observerLocation.lat = pos.coords.latitude;
observerLocation.lon = pos.coords.longitude;
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
}, () => {}, { timeout: 5000 });
}
}
// ============================================
// TRACKING CONTROL
// ============================================
function toggleRemoteDump1090() {
const useRemote = document.getElementById('useRemoteDump1090').checked;
const controls = document.querySelector('.remote-dump1090-controls');
controls.style.display = useRemote ? 'flex' : 'none';
}
function getRemoteDump1090Config() {
const useRemote = document.getElementById('useRemoteDump1090').checked;
if (!useRemote) return null;
const host = document.getElementById('remoteSbsHost').value.trim();
const port = parseInt(document.getElementById('remoteSbsPort').value) || 30003;
if (!host) {
alert('Please enter remote dump1090 host address');
return false;
}
return { host, port };
}
async function toggleTracking() {
const btn = document.getElementById('startBtn');
if (!isTracking) {
// Check for remote dump1090 config
const remoteConfig = getRemoteDump1090Config();
if (remoteConfig === false) return;
const requestBody = {};
if (remoteConfig) {
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
const response = await fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
alert('Invalid response: ' + text);
return;
}
if (data.status === 'success' || data.status === 'started' || data.status === 'already_running') {
startEventStream();
drawRangeRings();
isTracking = true;
btn.textContent = 'STOP';
btn.classList.add('active');
document.getElementById('trackingDot').classList.remove('inactive');
document.getElementById('trackingStatus').textContent = 'TRACKING';
} else {
alert('Failed to start: ' + (data.message || JSON.stringify(data)));
}
} catch (err) {
alert('Error: ' + err.message);
}
} else {
try {
await fetch('/adsb/stop', { method: 'POST' });
} catch (err) {}
stopEventStream();
isTracking = false;
btn.textContent = 'START';
btn.classList.remove('active');
document.getElementById('trackingDot').classList.add('inactive');
document.getElementById('trackingStatus').textContent = 'STANDBY';
}
}
function startEventStream() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/adsb/stream');
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'aircraft') {
updateAircraft(data);
}
} catch (err) {}
};
eventSource.onerror = () => {};
}
function stopEventStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
// ============================================
// AIRCRAFT UPDATES
// ============================================
let pendingUIUpdate = false;
let pendingMarkerUpdates = new Set();
const MAX_MARKER_UPDATES_PER_FRAME = 20;
function scheduleUIUpdate() {
if (pendingUIUpdate) return;
pendingUIUpdate = true;
requestAnimationFrame(() => {
updateStatsDisplay();
renderAircraftList();
let updateCount = 0;
const toProcess = [];
for (const icao of pendingMarkerUpdates) {
if (updateCount < MAX_MARKER_UPDATES_PER_FRAME) {
updateMarkerImmediate(icao);
toProcess.push(icao);
updateCount++;
}
}
toProcess.forEach(icao => pendingMarkerUpdates.delete(icao));
if (pendingMarkerUpdates.size > 0) {
pendingUIUpdate = false;
scheduleUIUpdate();
return;
}
if (selectedIcao && aircraft[selectedIcao]) {
showAircraftDetails(selectedIcao);
}
pendingUIUpdate = false;
});
}
function updateAircraft(data) {
const icao = data.icao;
if (!icao) return;
aircraft[icao] = {
...aircraft[icao],
...data,
lastSeen: Date.now()
};
checkAndAlertAircraft(icao, aircraft[icao]);
updateStatistics(icao, aircraft[icao]);
// Record trail point
if (data.lat && data.lon) {
recordTrailPoint(icao, data.lat, data.lon, data.altitude);
if (showTrails) {
updateTrailLine(icao);
}
pendingMarkerUpdates.add(icao);
}
scheduleUIUpdate();
}
const markerState = {};
function updateMarkerImmediate(icao) {
const ac = aircraft[icao];
if (!ac || !ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
delete markerState[icao];
}
return;
}
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const callsign = ac.callsign || icao;
const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A';
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color;
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt;
if (markers[icao]) {
markers[icao].setLatLng([ac.lat, ac.lon]);
if (iconChanged) {
markers[icao].setIcon(createMarkerIcon(rotation, color));
}
if (tooltipChanged) {
markers[icao].unbindTooltip();
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
} else {
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
markerState[icao] = { rotation, color, callsign, alt };
}
function createMarkerIcon(rotation, color) {
return L.divIcon({
className: 'aircraft-marker',
html: `<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color}; filter: drop-shadow(0 0 5px ${color});">
<path fill="currentColor" d="M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
// ============================================
// AIRCRAFT LIST
// ============================================
let renderedAircraftOrder = [];
let lastFullRebuild = 0;
const MAX_AIRCRAFT_DISPLAY = 50;
const MIN_REBUILD_INTERVAL = 2000;
function renderAircraftList() {
const container = document.getElementById('aircraftList');
const sortedAircraft = Object.entries(aircraft)
.filter(([icao, ac]) => passesFilter(icao, ac))
.map(([icao, ac]) => ({ ...ac, icao }))
.sort((a, b) => (b.altitude || 0) - (a.altitude || 0))
.slice(0, MAX_AIRCRAFT_DISPLAY);
if (sortedAircraft.length === 0) {
if (container.querySelector('.no-aircraft')) return;
container.innerHTML = `<div class="no-aircraft"><div>No aircraft detected</div></div>`;
renderedAircraftOrder = [];
return;
}
const newOrder = sortedAircraft.map(ac => ac.icao);
const orderChanged = newOrder.length !== renderedAircraftOrder.length ||
newOrder.some((icao, i) => icao !== renderedAircraftOrder[i]);
const now = Date.now();
const canRebuild = now - lastFullRebuild > MIN_REBUILD_INTERVAL;
if (orderChanged && canRebuild) {
lastFullRebuild = now;
const fragment = document.createDocumentFragment();
sortedAircraft.forEach(ac => {
const div = document.createElement('div');
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
div.setAttribute('data-icao', ac.icao);
div.onclick = () => selectAircraft(ac.icao);
div.innerHTML = buildAircraftItemHTML(ac);
fragment.appendChild(div);
});
container.innerHTML = '';
container.appendChild(fragment);
renderedAircraftOrder = newOrder;
} else {
const existingItems = {};
container.querySelectorAll('[data-icao]').forEach(el => {
existingItems[el.getAttribute('data-icao')] = el;
});
sortedAircraft.forEach(ac => {
const existingItem = existingItems[ac.icao];
if (existingItem) {
existingItem.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
existingItem.innerHTML = buildAircraftItemHTML(ac);
}
});
}
}
function buildAircraftItemHTML(ac) {
const callsign = ac.callsign || '------';
const alt = ac.altitude ? ac.altitude.toLocaleString() : '---';
const speed = ac.speed || '---';
const heading = ac.heading ? ac.heading + '°' : '---';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<span style="background:#556b2f;color:#fff;padding:1px 4px;border-radius:2px;font-size:8px;margin-left:4px;">MIL</span>` : '';
return `
<div class="aircraft-header">
<span class="aircraft-callsign">${callsign}${badge}</span>
<span class="aircraft-icao">${ac.icao}</span>
</div>
<div class="aircraft-details">
<div class="aircraft-detail">
<div class="aircraft-detail-value">${alt}</div>
<div class="aircraft-detail-label">ALT</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value">${speed}</div>
<div class="aircraft-detail-label">SPD</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value">${heading}</div>
<div class="aircraft-detail-label">HDG</div>
</div>
</div>
`;
}
function selectAircraft(icao) {
selectedIcao = icao;
renderAircraftList();
showAircraftDetails(icao);
const ac = aircraft[icao];
if (ac && ac.lat && ac.lon && currentView === 'map') {
radarMap.setView([ac.lat, ac.lon], 10);
}
}
function showAircraftDetails(icao) {
const ac = aircraft[icao];
const container = document.getElementById('selectedInfo');
if (!ac) {
container.innerHTML = `
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>`;
return;
}
const callsign = ac.callsign || ac.icao;
const lat = ac.lat ? ac.lat.toFixed(4) + '°' : 'N/A';
const lon = ac.lon ? ac.lon.toFixed(4) + '°' : 'N/A';
const alt = ac.altitude ? ac.altitude.toLocaleString() + ' ft' : 'N/A';
const speed = ac.speed ? ac.speed + ' kts' : 'N/A';
const heading = ac.heading ? ac.heading + '°' : 'N/A';
const squawk = ac.squawk || 'N/A';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<div style="background:#556b2f;color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;text-align:center;margin-bottom:8px;">MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>` : '';
container.innerHTML = `
<div class="selected-callsign">${callsign}</div>
${badge}
<div class="telemetry-grid">
<div class="telemetry-item">
<div class="telemetry-label">ICAO</div>
<div class="telemetry-value">${ac.icao}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Squawk</div>
<div class="telemetry-value">${squawk}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lat</div>
<div class="telemetry-value">${lat}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lon</div>
<div class="telemetry-value">${lon}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Altitude</div>
<div class="telemetry-value">${alt}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Speed</div>
<div class="telemetry-value">${speed}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Heading</div>
<div class="telemetry-value">${heading}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
</div>
</div>`;
}
function cleanupOldAircraft() {
const now = Date.now();
const timeout = 60000;
let needsUpdate = false;
Object.keys(aircraft).forEach(icao => {
if (now - aircraft[icao].lastSeen > timeout) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
}
cleanupTrail(icao);
delete aircraft[icao];
delete alertedAircraft[icao];
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
}
}
});
if (needsUpdate) {
scheduleUIUpdate();
}
}
// ============================================
// AIRBAND AUDIO
// ============================================
let isAirbandPlaying = false;
// Web Audio API for airband visualization
let airbandAudioContext = null;
let airbandAnalyser = null;
let airbandSource = null;
let airbandVisualizerId = null;
let airbandPeakLevel = 0;
function initAirbandVisualizer() {
const audioPlayer = document.getElementById('airbandPlayer');
if (!airbandAudioContext) {
airbandAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (airbandAudioContext.state === 'suspended') {
airbandAudioContext.resume();
}
if (!airbandSource) {
try {
airbandSource = airbandAudioContext.createMediaElementSource(audioPlayer);
airbandAnalyser = airbandAudioContext.createAnalyser();
airbandAnalyser.fftSize = 128;
airbandAnalyser.smoothingTimeConstant = 0.7;
airbandSource.connect(airbandAnalyser);
airbandAnalyser.connect(airbandAudioContext.destination);
} catch (e) {
console.warn('Could not create airband audio source:', e);
return;
}
}
document.getElementById('airbandVisualizerContainer').style.display = 'flex';
drawAirbandVisualizer();
}
function drawAirbandVisualizer() {
if (!airbandAnalyser) return;
const canvas = document.getElementById('airbandSpectrumCanvas');
const ctx = canvas.getContext('2d');
const bufferLength = airbandAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
airbandVisualizerId = requestAnimationFrame(draw);
airbandAnalyser.getByteFrequencyData(dataArray);
// Signal meter
let sum = 0;
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
const average = sum / bufferLength;
const levelPercent = (average / 255) * 100;
if (levelPercent > airbandPeakLevel) {
airbandPeakLevel = levelPercent;
} else {
airbandPeakLevel *= 0.95;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = levelPercent + '%';
if (meterPeak) meterPeak.style.left = Math.min(airbandPeakLevel, 100) + '%';
// Draw spectrum
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / bufferLength * 2;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const hue = 200 - (i / bufferLength) * 60;
ctx.fillStyle = `hsl(${hue}, 80%, ${40 + (dataArray[i] / 255) * 30}%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
}
draw();
}
function stopAirbandVisualizer() {
if (airbandVisualizerId) {
cancelAnimationFrame(airbandVisualizerId);
airbandVisualizerId = null;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = '0%';
if (meterPeak) meterPeak.style.left = '0%';
airbandPeakLevel = 0;
const container = document.getElementById('airbandVisualizerContainer');
if (container) container.style.display = 'none';
}
function initAirband() {
// Populate device selector
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR</option>';
} else {
devices.forEach((dev, i) => {
const opt = document.createElement('option');
opt.value = dev.index || i;
opt.textContent = `Dev ${dev.index || i}`;
select.appendChild(opt);
});
}
})
.catch(() => {});
// Check if audio tools are available
fetch('/listening/tools')
.then(r => r.json())
.then(data => {
const missingTools = [];
if (!data.rtl_fm) missingTools.push('rtl_fm');
if (!data.ffmpeg) missingTools.push('ffmpeg (audio encoder)');
if (missingTools.length > 0) {
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
// Show warning banner
showAirbandWarning(missingTools);
}
})
.catch(() => {
// Endpoint not available, disable airband
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function showAirbandWarning(missingTools) {
const warning = document.createElement('div');
warning.id = 'airbandWarning';
warning.style.cssText = `
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 400px;
text-align: center;
`;
const toolList = missingTools.join(', ');
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">⚠️ Airband Listen Unavailable</div>
<div>Missing required tools: <strong>${toolList}</strong></div>
<div style="margin-top: 8px; font-size: 10px; opacity: 0.9;">
Install with: <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">sudo apt install rtl-sdr ffmpeg</code> (Debian) or <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">brew install librtlsdr ffmpeg</code> (macOS)
</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 5px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
document.body.appendChild(warning);
// Auto-dismiss after 15 seconds
setTimeout(() => {
if (warning.parentElement) {
warning.style.opacity = '0';
warning.style.transition = 'opacity 0.3s';
setTimeout(() => warning.remove(), 300);
}
}, 15000);
}
function updateAirbandFreq() {
const select = document.getElementById('airbandFreqSelect');
const customInput = document.getElementById('airbandCustomFreq');
if (select.value === 'custom') {
customInput.style.display = 'inline-block';
} else {
customInput.style.display = 'none';
// If audio is playing, restart on new frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
}
}
// Handle custom frequency input changes
document.addEventListener('DOMContentLoaded', () => {
const customInput = document.getElementById('airbandCustomFreq');
if (customInput) {
customInput.addEventListener('change', () => {
// If audio is playing, restart on new custom frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
});
}
});
function getAirbandFrequency() {
const select = document.getElementById('airbandFreqSelect');
if (select.value === 'custom') {
return parseFloat(document.getElementById('airbandCustomFreq').value) || 121.5;
}
return parseFloat(select.value);
}
function toggleAirband() {
if (isAirbandPlaying) {
stopAirband();
} else {
startAirband();
}
}
function startAirband() {
const frequency = getAirbandFrequency();
const device = parseInt(document.getElementById('airbandDeviceSelect').value);
const squelch = parseInt(document.getElementById('airbandSquelch').value);
document.getElementById('airbandStatus').textContent = 'STARTING...';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
fetch('/listening/audio/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
frequency: frequency,
modulation: 'am', // Airband uses AM
squelch: squelch,
gain: 40,
device: device
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAirbandPlaying = true;
// Start browser audio playback
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.src = '/listening/audio/stream?' + Date.now();
// Initialize visualizer before playing
initAirbandVisualizer();
audioPlayer.play().catch(e => {
console.warn('Audio autoplay blocked:', e);
});
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">⏹</span> STOP';
document.getElementById('airbandBtn').classList.add('active');
document.getElementById('airbandStatus').textContent = frequency.toFixed(3) + ' MHz';
document.getElementById('airbandStatus').style.color = 'var(--accent-green)';
} else {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
alert('Airband Error: ' + (data.message || 'Failed to start'));
}
})
.catch(err => {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function stopAirband() {
// Stop visualizer
stopAirbandVisualizer();
// Stop browser audio
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.pause();
audioPlayer.src = '';
fetch('/listening/audio/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAirbandPlaying = false;
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">▶</span> LISTEN';
document.getElementById('airbandBtn').classList.remove('active');
document.getElementById('airbandStatus').textContent = 'OFF';
document.getElementById('airbandStatus').style.color = 'var(--text-muted)';
})
.catch(() => {});
}
// Initialize airband on page load
document.addEventListener('DOMContentLoaded', initAirband);
</script>
</body>
</html>