Visual aircraft tracking — live ADS-B on a dark map. Browser port of the skyrecon CLI.
SkyRecon is a single-page browser tool that consumes public Automatic Dependent Surveillance – Broadcast (ADS-B) feeds and renders aircraft positions on a Leaflet map. It is a feature subset of the upstream Python CLI of the same name, restructured around a four-mode UI (Watch, Track, Geo, Fleet) that maps onto the CLI's bbox, track, geo, and fleet subcommands. Identity decoding (ICAO24 → country, callsign → operator) runs entirely client-side from embedded lookup tables, mirroring the CLI's id subcommand. The tool is intended for OSINT analysts, aviation enthusiasts, and anyone exploring the public ADS-B record without committing to a Python install.
The CLI ships eight async subcommands. The browser port maps them as follows:
| CLI subcommand | Visual equivalent | Notes |
|---|---|---|
bbox + watch | Watch tab | Polls the visible map bounds on a configurable interval (5–60 s) |
track | Track tab | Lookup by ICAO24 hex or callsign; "Follow live" re-polls every 10 s |
geo | Geo tab | Click the map to set centre, sweep within N nm |
fleet | Fleet tab | ICAO operator code (callsign prefix) filter applied to current contacts |
id | Selected panel | Inline ICAO24 country + callsign operator decoding |
history | Trail polyline | Client-side rolling trail (200 positions / 30 min TTL) for the selected aircraft |
report (PDF) | not ported | Export panel offers JSON / CSV / GeoJSON instead |
| Vessel tracking (AISHub) | not ported | AISHub requires server-side authentication and IP whitelisting — not feasible from a static page |
| Provider | Endpoint shape | Auth | CORS | Role |
|---|---|---|---|---|
| ADSB.lol | /v2/lat/{lat}/lon/{lon}/dist/{nm}, /v2/icao/{hex}, /v2/callsign/{cs} |
none | yes | primary |
| airplanes.live | same readsb-style shape (/v2/point/…, /v2/icao/…, /v2/callsign/…) |
none | yes | first fallback |
| OpenSky Network | /api/states/all?lamin=… (bbox) or ?icao24=… (single) |
optional (anonymous quota tight) | yes | last resort |
The upstream CLI documents the chain as OpenSky → ADSB.lol → Airplanes.live. That order makes sense in a long-running server-side process with OpenSky credentials. From an anonymous browser the order is wrong: OpenSky's free-tier anonymous bbox queries now return 429 Too Many Requests during peak hours, while ADSB.lol and airplanes.live serve the same JSON shape without authentication. The visual tool therefore tries adsblol → airplaneslive → opensky and exposes a Source dropdown so the order can be pinned manually for testing.
seen field is converted to a wall-clock lastSeen timestamp during normalisation. Positions older than a few seconds are common at the edge of coverage.last_contact seconds-since-epoch is multiplied to milliseconds; the top-level time field is the upper bound for the dataset.The browser app is split into five vanilla-JS modules loaded in dependency order via <script defer>:
lookup.js — static identity tables (ICAO24 country ranges, ICAO airline codes).providers.js — the three data providers, their normalisation, and the fallback orchestrator.aircraft.js — Leaflet marker management, rotation, trails, selection.export.js — JSON / CSV / GeoJSON serialisers and download triggers.app.js — controller (state, modes, watch loop, UI wiring).Each module attaches a single object to window (SkyReconLookup, SkyReconProviders, …). No bundler, no ES module loader, no build step. This matches the rest of the lab.
The two readsb-style feeds (ADSB.lol, airplanes.live) and the column-positional OpenSky response are reduced to a single normalised aircraft shape. Everything downstream consumes only this shape:
{
icao24, // hex string, lower-case
callsign, // trimmed, may be null
country, // string or null (from provider; lookup.js fills gaps)
lat, lon,
altBaro, // feet, may be null
altGeo, // feet, may be null
speed, // knots, may be null
heading, // degrees true, may be null
vRate, // feet/minute, may be null
squawk, // 4-digit string, may be null
onGround, // bool
category, // ADS-B Cat (A1..C4), may be null
registration, // tail number, may be null
type, // type code (B738, etc.), may be null
source, // provider id
lastSeen, // ms epoch
}
OpenSky's metric units (m, m/s) are converted into the readsb defaults (ft, kt, fpm) inside the normaliser so downstream code never branches on provider.
The orchestrator iterates the provider list and stops at the first success. Errors are aggregated for diagnostics. The subtle case is distinguishing a caller-initiated abort from an internal timeout:
AbortSignal so it can cancel an in-flight sweep when the interval ticks again.setTimeout that aborts a hung request after 12 s.AbortError on the fetch promise. If we treat them identically, a hung provider would bubble the abort to the caller and never try the fallback.The fix is a timedOut flag plus a check on the caller's signal:
try {
const r = await fetch(url, { signal: ctrl.signal, … });
…
} catch (e) {
if (e.name === 'AbortError') {
if (signal && signal.aborted) throw e; // caller cancelled — bubble
if (timedOut) throw new Error(url + ' → timeout'); // try next provider
}
throw e;
}
This project does not reproduce the manual Mercator rendering used in /gpxeditor. SkyRecon needs rotated markers that move every few seconds, popups, polylines for trails, and tile-aware pan/zoom. Hand-rolling all of that on a canvas would duplicate Leaflet for no benefit. The previous brutalist black/red design carries over via heavy CSS overrides on Leaflet's default DOM (zoom buttons, attribution, popups).
Leaflet markers cannot rotate natively. Instead of pulling in a plugin (Leaflet.RotatedMarker), each aircraft uses a divIcon whose root element carries an inline transform: rotate(°) derived from the aircraft's track angle. The plane silhouette is a single inline SVG <path> (no <use>, no external file). The icon class also encodes climb / descent / ground / selected state, which CSS uses to swap the SVG fill colour.
ADSB.lol and airplanes.live expose a radius-from-point endpoint but no native bbox endpoint. The watch loop converts the map's bounds to {lat, lon, radiusNm} using the diagonal great-circle distance plus a 10% pad, then client-side clips the response back to the exact bbox. OpenSky's bbox query is used directly when that provider is selected.
Two compact tables live at the top of lookup.js:
(military).RCH, SAM, RFR, GAF, …).Decoding is a linear scan on the country table (sub-microsecond on ~140 entries) and a hash lookup on the operator table. Both are extended by editing the literals; no build step, no JSON loader.
The renderer maintains parallel Map instances of icao24 → aircraft and icao24 → L.Marker. On each sweep, replace(list):
Trail positions accumulate in a separate Map bounded by TRAIL_MAX (200) entries and a TRAIL_TTL (30 minutes). Only the selected aircraft's trail is rendered, as a dashed red polyline. Trails outlive marker removal so re-selecting a previously-seen aircraft restores its history.
The current centre and zoom are saved to localStorage on every moveend / zoomend. On next load, the saved view is restored; if absent, the default is Luxembourg City (49.611, 6.131) at zoom 8 — the lab's home airport area.
/fonts/fonts.csswatch subcommand has no browser equivalent.localStorage with a tiny proxy snippet documented in the README.