SkyRecon

Visual aircraft tracking — live ADS-B on a dark map. Browser port of the skyrecon CLI.

[osint] [aviation] [active]
Last updated: 2026-05-23

Abstract

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.

Scope vs. the CLI

The CLI ships eight async subcommands. The browser port maps them as follows:

CLI subcommandVisual equivalentNotes
bbox + watchWatch tabPolls the visible map bounds on a configurable interval (5–60 s)
trackTrack tabLookup by ICAO24 hex or callsign; "Follow live" re-polls every 10 s
geoGeo tabClick the map to set centre, sweep within N nm
fleetFleet tabICAO operator code (callsign prefix) filter applied to current contacts
idSelected panelInline ICAO24 country + callsign operator decoding
historyTrail polylineClient-side rolling trail (200 positions / 30 min TTL) for the selected aircraft
report (PDF)not portedExport panel offers JSON / CSV / GeoJSON instead
Vessel tracking (AISHub)not portedAISHub requires server-side authentication and IP whitelisting — not feasible from a static page

Data Sources

ProviderEndpoint shapeAuthCORSRole
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

Why the chain is inverted vs. the CLI

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.

Data freshness

Known limitations

Methodology

1. Five-module separation

The browser app is split into five vanilla-JS modules loaded in dependency order via <script defer>:

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.

2. Provider normalisation

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.

3. Fallback orchestrator & abort handling

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:

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;
}

4. Mercator vs. Leaflet

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).

5. Rotation by CSS transform

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.

6. Bbox ↔ point conversion

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.

7. Identity tables

Two compact tables live at the top of lookup.js:

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.

8. Marker reconciliation

The renderer maintains parallel Map instances of icao24 → aircraft and icao24 → L.Marker. On each sweep, replace(list):

  1. Computes the set of incoming ICAO24s.
  2. For each, upserts the marker (create or move + re-icon).
  3. Removes any pre-existing marker whose ICAO24 is no longer in the set — except the currently-selected aircraft, which is kept on the map even after it leaves the bbox so the user does not lose their selection mid-investigation.

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.

9. Map state persistence

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.

Stack & Tooling

Key Observations & Limitations

What works

Known gaps

Possible future directions

Responsible Use

ADS-B feeds aggregate publicly-broadcast position data from volunteer ground receivers. The data is real-time location of identified vehicles — treat it accordingly.