What is this? An interface for current and historical traffic at Billy Bishop (YTZ), plus other aircraft flying nearby — YYZ approaches, high-altitude overflights, the occasional float plane.
Where does the data come from? An antenna on my windowsill, across the harbor from YTZ. Aircraft broadcast their position, altitude, speed, and callsign on 1090 MHz; a $25 RTL-SDR USB receiver feeds a Raspberry Pi that decodes the signals and forwards them to the backend. No subscriptions, no flight-data APIs — just radio from the window.
Photos and route info come from planespotters.net and adsbdb.com.
Questions or feedback? Email [email protected]
Built by Adam. An ADS-B antenna on the rooftop picks up aircraft radio signals and streams them live to this site — no flight data subscriptions, just radio waves.
Aircraft broadcast their position, altitude, speed, and flight number on 1090 MHz. A Nooelec RTL-SDR dongle + Raspberry Pi decodes these signals using dump1090 and serves them to this page in real time. Coverage is roughly 200 km around YTZ.
Colours apply to the live trail of a selected aircraft. Frozen trails from history use a single blue line.
altitude=null), filterAircraft would drop it from the active list, the marker would be immediately deleted, then the very next tick the plane would pass the filter again and the marker would be recreated. Result: visible flashing every second for any plane near a filter threshold. Added a 5-second grace period — the marker stays on the map for 5 seconds of continuous filtering before actually being removed. Crossings under 5 seconds are completely invisible to the user.LOST 12s counter, fading opacity). The old code called setIcon on every tick for the entire 120-second window — 120 full DOM replacements per disappearance per plane. setIcon detaches and reattaches the icon, which is itself a flash source. Now: setIcon is called once on the transition to ghost; subsequent ticks just patch the LOST Ns counter text and the wrapper’s opacity directly via DOM. Cuts ghost-mode rendering cost ~95% and removes the second flash source on the map.flights table on the backend doesn’t store last_lat/last_lon as separate columns — only a JSON trail array. The frontend was checking entry.last_lat && entry.last_lon and bailing out for past entries because those fields were undefined. Fix: showFrozenTrail() now derives the last position from trail[trail.length-1] when last_lat/last_lon aren’t explicitly set. One-line fix, applies to every past-day click.trail (briefly seen, signal too weak to resolve a CPR position fix). Clicking those cards used to silently no-op — map didn’t move, no message, nothing. Now the map flies to YTZ and a small popup explains “no position track recorded for this flight.”flights table but its signal expired before our 2-minute “previously-seen” promotion fired, the card was rendered (good) but clicking it silently no-op’d (bad). Two bugs: (1) loadTodayFromPi wasn’t parsing the trail JSON column from /history/today, so the data was on the page but unparseable; (2) onPrevSeenClick only searched recentlySeen, not flightHistory, so it couldn’t find the entry to render. Fixed both: loadTodayFromPi now parses + carries the trail through, and onPrevSeenClick falls back to flightHistory when not in recentlySeen.will-change: transform + translateZ(0) on .aircraft-icon-wrapper already does that via GPU compositing. With both the snap AND the will-change in place, slow planes (60-100 kt at zoom 12, where 1 pixel ≈ 250-500 ms of travel) appeared to advance in jumps instead of moving fluidly. Removed the snap; will-change handles the shimmer and motion is back to fully smooth.transform: translate3d() updates with sub-pixel values 60 times a second, which made small antialiased label text re-rasterise at every fractional offset — visible as a constant text shimmer even on stationary planes; (2) the rAF loop was correcting toward the raw last-received ADS-B target every frame, but ADS-B positions wobble ±5-15m frame to frame even for a plane flying perfectly straight, so the correction term would yank back and forth every sample; (3) heading flipped instantly between rounded-to-5° values, snapping the SVG rotation; (4) the first frame after tab unhide / dragend / zoomend used real frameDt from a bursty rAF callback, jerking every plane forward by half a second of dead-reckoning. Fixed all four: server target updates are now low-pass filtered with a 1-second cubic ease (the rAF loop sees a smoothly transitioning target instead of a noisy one), heading interpolates over 500ms with shortest-path angular lerp, marker positions are integer-pixel snapped on the way out (kills the text shimmer entirely), and rAF clamps to 16.67ms for the first 3 frames after any resume.zoomanim handler writes --marker-counter-scale on the markerPane so all aircraft icons stay at constant pixel size for the entire transition. Second, restored wheelDebounceTime to 40ms (was 10ms). On a trackpad pinch, 10ms fires multiple zoom animations back-to-back, each interrupting the previous mid-flight. 40ms coalesces a fast wheel/trackpad burst into one clean animation per gesture. Third, tuned easeLinearity to 0.25 for a softer cubic landing and disabled bounceAtZoomLimits so hitting min/max zoom doesn’t jitter.will-change: transform; backface-visibility: hidden; transform: translateZ(0) to .aircraft-icon-wrapper so each marker gets its own GPU compositor layer. The browser keeps a pre-rasterised bitmap of the SVG + label and just re-transforms it on each frame instead of repainting. Heading-only changes now update the SVG’s inline transform directly via the existing icon element rather than rebuilding the entire divIcon HTML — saves ~10 DOM replacements/sec when a plane is turning.BackgroundTracker._check_route_warning: for any flight whose route doesn’t include a local airport, compute the cross-track distance from the plane’s position to the great-circle path between the route’s origin and destination. If it’s >150 km off — well beyond what ATC vectoring or weather deviation can explain — flag the route. Real ATC deviation is rarely >50 km, so 150 km has comfortable margin against false positives. Both checks are sticky and persist via the routeWarning column added in v3.2.7.version, process_start_ts, and process_uptime_s fields so it’s easy to verify whether a deploy actually rolled out and which version is live. Lets us distinguish “the rule is broken” from “the new rule isn’t deployed yet.”routeWarning was computed per-tick in the browser and attached only to the live aircraft object. The seen_today and flights tables had no column for it, so the next /seen/today poll (or any page refresh) silently dropped any flag the live tick had set.BackgroundTracker._check_route_warning. It classifies observed_op from telemetry (descending = arr, climbing = dep, otherwise ovr) and route_implies from which endpoint is local (origin local = dep, dest local = arr, neither = ovr), and fires the warning when they conflict. Sticky per icao for the tracking session — once flagged, doesn’t downgrade. Persisted to seen_today and flights via a new routeWarning column, returned by the existing endpoints, and surfaced on /aircraft.json for live cards too. Frontend stops computing it locally; server is the single source of truth. Backfill: existing rows default to 0; new rows get the right value going forward.renderAnalytics(rows, true) (called from updateHistoryView after fetching today’s rows from the Pi) reclassified ovr-direction rows whose reported origin or destination was YTZ as arrivals/departures, while renderAnalytics() (live, called from refresh()) used ytzArrivals + ytzDepartures — just direction='arr'|'dep' from the tracker. The reclassification was meant for past-date views, but it was firing for today too. With route data being unreliable (stale callsign→route lookups; see v3.2.5), tons of true overflights have routes pointing at YTZ, and reclassifying them inflated today’s number by ~120. Fix: skip reclassification when isToday — trust the Pi tracker’s direction call for today, since it’s based on observed flight behavior, not stale route metadata. Past-date views unchanged.nearYTZ/nearYYZ gate in routeWarning required the plane to be within 15 km, and our first contact on a fast climb-out can easily land at 20-30 km out. Bumped the radius gate to 30 km so we catch planes earlier in the climb when first contact is already past 12k feet. Altitude gate (<15,000 ft) and vrate gate (±200 fpm) unchanged.total field from the /history rollup — but that field is COUNT(*) over the flights table, which also includes direction='ovr' rows (overhead/passby aircraft that never touched YTZ). So today’s number was YTZ ops only, while yesterday’s number bundled YTZ ops and overhead, and the delta read systematically negative by however many flyovers we logged the day before. Same bug inflated the 7-day average. Both now use arrivals + departures from the rollup so the comparison is the metric the card label promises.recently_gone. After the 30-min rejoin window expires — which can land at 00:01-00:30 ET tomorrow — _save_seen finalizes the row, stamping date from today_et() (today, post-midnight) but last_seen from the actual radar timestamp (yesterday 23:xx). The row gets returned by /seen/today because the date filter matches, and the UI shows a 23:xx timestamp from yesterday. Same bug applies to the periodic flush of active aircraft for the first ~2 min after midnight. Fix: derive the row’s date from the last_seen Unix timestamp (interpreted in ET), not from today_et(). Now late-night drop-offs are correctly filed against yesterday and removed at the next midnight cleanup. Also added a client-side filter that drops any row whose last_seen falls on a different local date than today — cleans up phantom rows already in the cloud DB without needing a database migration.updateWhenZooming: true option fetched new tiles mid-animation; as those tiles arrived, the visual reference frame shifted enough that markers (anchored to lat/lon, not screen pixels) appeared to drift across the map during zooms. Mid-zoom blur is the lesser evil. Vector tiles + WebGL (MapLibre) remains the only real fix for both blur and drift.summary.all_seen - (arrivals + departures), but arrivals+departures double-counts round trips, so the result was clamped to 0 on most days. Now derived from the same distinct-ICAO computation as today, via summary.all_seen - distinct YTZ aircraft count.L.tileLayer options to mask it: keepBuffer 2→8 keeps a wider ring of off-screen tiles in the DOM so zoom-outs land on already-loaded tiles instead of blank white; updateWhenZooming: true requests new tiles mid-animation rather than after it settles; updateWhenIdle: false never defers fetches to idle time; crossOrigin: true lets the browser cache & reuse tiles across zoom levels. Net effect ~30% smoother — about as far as Leaflet’s raster pipeline can go. Reverted in v3.2.2 — tile-frame shifts during zoom made markers appear to drift.wheelDebounceTime kept resetting while you were actively scrolling, so a continuous wheel/trackpad gesture wouldn’t even start zooming until you stopped scrolling for 40 ms — on a long pinch that’s ~half a second of "nothing happening". The 60 px-per-zoom-level threshold also meant trackpad pinches easily over-shot the target zoom by 2-3 levels, forcing the animation to cover more ground than you actually wanted. Tuned the four wheel-zoom knobs: wheelDebounceTime 40→10 ms, wheelPxPerZoomLevel 60→100 px, zoomSnap 1→0.25, zoomDelta 1→0.5. Net effect: zoom tracks your wheel/pinch in near-real-time, lands at fractional zoom levels close to where you stopped scrolling instead of yanking to the next integer level, and the +/− buttons step in half-zooms for finer control.setLatLng calls fight Leaflet’s zoom transform and the map feels frozen. But when zoom ended, the loop resumed from the plane’s frozen pre-zoom position and corrected toward the latest server position at only ~5% per frame for airborne planes — about a second of visible slide after every zoom. Now the interpolation state snaps to the latest server target on zoomend / dragend, and the marker is pushed to that position immediately rather than waiting for the next rAF tick. Planes appear glued to their actual location the instant your zoom completes. Same fix applies after panning. The first-contact "green dot" never actually moved, but it appeared to lag because the plane next to it was sliding — now everything stays put.min to the capture-start date so pre-capture days are greyed out in the native calendar UI.trailHistory, frozenTrailCache, firstContactData) were keyed only by icao24 — which is the airframe's hardware ID, not the flight. So every cache thought "I've seen this aircraft before, here's its trail" and merrily replayed the wrong flight's data. Now every cached trail / first-contact entry is tagged with the callsign it belongs to, and gets reset when the same airframe shows up under a different flight number. Same fix applies to JZA7952→JZA7957 and any other multi-leg-per-day aircraft.zoomanim event and applies a CSS transform that scales/translates with the rest of the map. Previously the canvas sat at the old zoom level for the full 250 ms zoom animation and then snapped to the new zoom at the end — now it eases through the zoom along with everything else. Same trick Leaflet’s built-in renderers use.flyTo at the current zoom level — never zooms in or out.setLatLng on every aircraft 60 times per second — with ~30 visible markers that’s 1,800 pixel-position recomputations per second, all against the map origin which is itself shifting during a drag. Leaflet’s drag handler was competing with that loop for main-thread time. The animation loop now pauses while the map is being dragged or zoomed (markers stay glued to position because they live in the overlay pane, which the drag transform already moves as a group) and resumes on dragend/zoomend.~ in the V/S stat so the source is obvious.trail != "" was being interpreted as a reference to a column with an empty name (because "" is identifier syntax, not string syntax, in standard SQL). Switched to single quotes; the heatmap and trail corridors render again.aircraft.json to the cloud roughly once a second — nothing else.Storage=volatile), apt-daily auto-update timers are disabled, no backups, no DB. If the card ever dies again, the recovery is reflash + run install.sh — data lives in Turso, not on the card.api.ytzradar.com now points at Render via a Cloudflare CNAME. Cloudflare correctly classifies the live feed as DYNAMIC, so it’s never cached. The frontend at ytzradar.com on Netlify is unchanged — same hostname, same code, just a different machine answering.sqlite3-style code works against Turso, with a multi-VALUES INSERT rewrite to mask libsql’s per-row sync overhead (50-row insert: 38s → 0.18s). New gzip /ingest endpoint authenticated with a shared HMAC token. The old /backup endpoint and backup_db are gone — Turso has continuous point-in-time recovery built in.direction, so YTZ flights that got tagged ovr (seen above the arr/dep altitude gate) were excluded even when their route clearly terminates at YTZ. The filter now also accepts rows where origin or destination is YTZ — matching what _build_flight_filter_sql returns.origin/destination fields, which the adsbdb callsign lookup sometimes left blank or stale — so up to 30% of real YTZ flights were excluded from the Viz tab’s YTZ view. The direction column (set by our own detector when an aircraft touches YTZ airspace) is now the source of truth.seen_today that double-counted in-flight overheads when the same aircraft got written with both upper and lower ICAO24 casing by different code paths. Today’s count drops by ~25% as the duplicates are collapsed.all = YTZ because yesterday had no overheads; for today, all = YTZ + overheads with no duplicates./history/add POST, so every logged flight ends up with real ping history (previously only backend-logged flights had trails, leaving most past rows empty)/history/add now deduplicates on (date, icao24, direction, time) and upgrades the stored trail to the longer of what it already has and what the new POST brings — so multiple open tabs all contribute without creating duplicates/viz/heat (in addition to the legacy density points, kept for back-compat)