221 lines
7.8 KiB
HTML
221 lines
7.8 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>FR24 Live Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: monospace; background: #0d1117; color: #c9d1d9; }
|
|
#map { width: 100vw; height: 100vh; }
|
|
#hud {
|
|
position: fixed; top: 10px; left: 10px; z-index: 1000;
|
|
background: rgba(13,17,23,0.85); border: 1px solid #30363d;
|
|
border-radius: 6px; padding: 10px 14px; min-width: 180px;
|
|
}
|
|
#hud h2 { font-size: 13px; color: #58a6ff; margin-bottom: 6px; }
|
|
#hud .stat { font-size: 12px; color: #8b949e; }
|
|
#hud .stat span { color: #c9d1d9; }
|
|
#hud a { font-size: 12px; color: #58a6ff; text-decoration: none; display: block; margin-top: 8px; }
|
|
#hud a:hover { text-decoration: underline; }
|
|
#popup-box {
|
|
position: fixed; bottom: 20px; right: 20px; z-index: 1000;
|
|
background: rgba(13,17,23,0.92); border: 1px solid #30363d;
|
|
border-radius: 6px; padding: 12px 16px; min-width: 220px;
|
|
display: none;
|
|
}
|
|
#popup-box h3 { font-size: 14px; color: #58a6ff; margin-bottom: 8px; }
|
|
#popup-box .row { font-size: 12px; color: #8b949e; margin: 3px 0; }
|
|
#popup-box .row span { color: #c9d1d9; }
|
|
#popup-close {
|
|
float: right; cursor: pointer; color: #8b949e; font-size: 16px; line-height: 1;
|
|
}
|
|
#popup-close:hover { color: #c9d1d9; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="hud">
|
|
<h2>✈ FR24 Live</h2>
|
|
<div class="stat">Aircraft: <span id="ac-count">—</span></div>
|
|
<div class="stat">Updated: <span id="last-update">—</span></div>
|
|
<div class="stat" style="margin-top:6px;">
|
|
Period: <select id="period-select" style="background:#161b22;color:#c9d1d9;border:1px solid #30363d;border-radius:4px;font-size:12px;padding:2px 4px;">
|
|
<option value="60">1 hour</option>
|
|
<option value="360">6 hours</option>
|
|
<option value="720">12 hours</option>
|
|
<option value="1440">1 day</option>
|
|
<option value="4320" selected>3 days</option>
|
|
</select>
|
|
</div>
|
|
<div class="stat" id="status-line"></div>
|
|
<a href="/monitoring">📊 Monitoring</a>
|
|
</div>
|
|
|
|
<div id="popup-box">
|
|
<span id="popup-close" onclick="closePopup()">✕</span>
|
|
<h3 id="pop-callsign">—</h3>
|
|
<div class="row">ICAO24: <span id="pop-icao">—</span></div>
|
|
<div class="row">Altitude: <span id="pop-alt">—</span></div>
|
|
<div class="row">Speed: <span id="pop-spd">—</span></div>
|
|
<div class="row">Heading: <span id="pop-hdg">—</span></div>
|
|
<div class="row">Vert rate: <span id="pop-vr">—</span></div>
|
|
<div class="row">Seen: <span id="pop-ts">—</span></div>
|
|
</div>
|
|
|
|
<div id="map"></div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
function getMinutes() {
|
|
const sel = document.getElementById('period-select');
|
|
return sel ? parseInt(sel.value) : 4320;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const sel = document.getElementById('period-select');
|
|
if (sel) sel.addEventListener('change', () => refresh());
|
|
});
|
|
|
|
const map = L.map('map').setView([55.75, 37.62], 7);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 18,
|
|
}).addTo(map);
|
|
|
|
// plane icon rotated by heading
|
|
function planeIcon(heading) {
|
|
const deg = heading || 0;
|
|
return L.divIcon({
|
|
html: `<div style="font-size:28px;color:#58a6ff;transform:rotate(${deg}deg);transform-origin:center;line-height:1;text-shadow:0 0 3px #000;">✈</div>`,
|
|
className: '',
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
});
|
|
}
|
|
|
|
const markers = {}; // icao24 -> L.marker
|
|
let trackLayers = []; // active track polylines
|
|
|
|
function fmt(v, unit, decimals=0) {
|
|
if (v == null) return '—';
|
|
return Number(v).toFixed(decimals) + ' ' + unit;
|
|
}
|
|
|
|
function showPopup(props) {
|
|
document.getElementById('pop-callsign').textContent = props.callsign || props.icao24;
|
|
document.getElementById('pop-icao').textContent = props.icao24;
|
|
// altitude: m → ft
|
|
const altFt = props.altitude_m != null ? Math.round(props.altitude_m * 3.281) : null;
|
|
document.getElementById('pop-alt').textContent = altFt != null ? altFt + ' ft' : '—';
|
|
// speed: kt → km/h
|
|
const spdKmh = props.ground_speed_kt != null ? Math.round(props.ground_speed_kt * 1.852) : null;
|
|
document.getElementById('pop-spd').textContent = spdKmh != null ? spdKmh + ' km/h' : '—';
|
|
document.getElementById('pop-hdg').textContent = fmt(props.heading_deg, '°');
|
|
document.getElementById('pop-vr').textContent = fmt(props.vertical_rate_fpm, 'fpm');
|
|
document.getElementById('pop-ts').textContent = props.observed_at
|
|
? new Date(props.observed_at).toLocaleTimeString() : '—';
|
|
document.getElementById('popup-box').style.display = 'block';
|
|
}
|
|
|
|
function closePopup() {
|
|
document.getElementById('popup-box').style.display = 'none';
|
|
}
|
|
|
|
async function refreshTracks() {
|
|
try {
|
|
const res = await fetch('/api/tracks?minutes=' + getMinutes() + '&limit=200');
|
|
if (!res.ok) return;
|
|
const geojson = await res.json();
|
|
let features = geojson.features || [];
|
|
|
|
// cap at 200 tracks — keep the most recent (already ordered by last_point_at DESC)
|
|
if (features.length > 200) features = features.slice(0, 200);
|
|
|
|
// remove old track layers
|
|
for (const layer of trackLayers) map.removeLayer(layer);
|
|
trackLayers = [];
|
|
|
|
for (const f of features) {
|
|
const pts = f.geometry.coordinates; // [[lon, lat], ...]
|
|
const times = (f.properties.times || []); // ISO strings, may be empty
|
|
|
|
// Split into segments where time gap > 5 min (300s)
|
|
const segments = [];
|
|
let seg = [];
|
|
for (let i = 0; i < pts.length; i++) {
|
|
if (i > 0 && times.length === pts.length) {
|
|
const dt = (new Date(times[i]) - new Date(times[i-1])) / 1000;
|
|
if (dt > 300) { segments.push(seg); seg = []; }
|
|
}
|
|
seg.push([pts[i][1], pts[i][0]]); // [lat, lon] for Leaflet
|
|
}
|
|
if (seg.length) segments.push(seg);
|
|
|
|
const callsign = f.properties.callsign || f.properties.icao24;
|
|
for (const segment of segments) {
|
|
if (segment.length < 2) continue;
|
|
const line = L.polyline(segment, {
|
|
color: '#58a6ff',
|
|
weight: 3,
|
|
opacity: 0.7,
|
|
}).addTo(map);
|
|
line.bindPopup(callsign);
|
|
trackLayers.push(line);
|
|
}
|
|
}
|
|
} catch (_) { /* silent — tracks are non-critical */ }
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [acRes] = await Promise.all([
|
|
fetch('/api/aircraft/live?minutes=' + getMinutes()),
|
|
refreshTracks(),
|
|
]);
|
|
if (!acRes.ok) throw new Error('HTTP ' + acRes.status);
|
|
const geojson = await acRes.json();
|
|
const features = geojson.features || [];
|
|
const seen = new Set();
|
|
|
|
for (const f of features) {
|
|
const p = f.properties;
|
|
const [lon, lat] = f.geometry.coordinates;
|
|
seen.add(p.icao24);
|
|
|
|
if (markers[p.icao24]) {
|
|
markers[p.icao24].setLatLng([lat, lon]);
|
|
markers[p.icao24].setIcon(planeIcon(p.heading_deg));
|
|
markers[p.icao24]._props = p;
|
|
} else {
|
|
const m = L.marker([lat, lon], { icon: planeIcon(p.heading_deg) })
|
|
.addTo(map)
|
|
.on('click', () => showPopup(m._props));
|
|
m._props = p;
|
|
markers[p.icao24] = m;
|
|
}
|
|
}
|
|
|
|
// remove stale markers
|
|
for (const icao of Object.keys(markers)) {
|
|
if (!seen.has(icao)) {
|
|
map.removeLayer(markers[icao]);
|
|
delete markers[icao];
|
|
}
|
|
}
|
|
|
|
document.getElementById('ac-count').textContent = features.length;
|
|
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
|
document.getElementById('status-line').textContent = '';
|
|
} catch (e) {
|
|
document.getElementById('status-line').textContent = '⚠ ' + e.message;
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|