Files
wiki/tasks/flightradar24/frontend/static/index.html
2026-04-19 23:30:01 +03:00

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>