Behind every plane the map draws the way it came. The receiver hears it every few seconds, so the obvious line joins the dots. Here is a real 14-minute descent with two loops, and a close-up of its tightest turn.
Companion to how it predicts, which is about the line in front of the plane. This page is about the line behind it. Every figure is a flight the receiver recorded on 11 or 12 October 2026, fitted by the benchmark code; every rate is from the benchmark over all 8,139 recorded flights (about 90,000 flight-minutes, 597,249 fresh fixes).
Most curve fitting sees only points and guesses the direction between them. An aircraft tells us its direction: every ADS-B position comes with ground speed and track. So the velocity at each fix is measured, not inferred, and the trail is a curve in time, not just a shape.
Two findings changed the problem before any curve was tried. Each chart below is the speed implied by consecutive dots (distance over time between stamps) on a 28-minute Heathrow arrival, against the speed the plane reported.
Every row as recorded. A repeated row has moved 0 m, so its speed is 0; the next real fix then seems to fly at twice the speed. Clipped at 450 kt.
Repeats dropped, each fix on the time it was polled. Still ragged: the recorder stamps when it asked, not when the plane sent, and the receiver software keeps no fix age.
Retimed: walk the fixes at the reported ground speed to get a smooth clock, then pull it back to the recorder's with a ±60 s moving average so it never drifts. Plain Python, one pass.
Retiming moves a fix by 0.7 s at the median. Without it, a curve asked to keep time to 1 s mostly fits noise: on this flight the same fitter needs 77 pieces on the poll clock and 45 retimed.
All on the same descent, all held to the same promise: within 30 m sideways and 1 s in time of every fix. The faint blue is the curve that won. The numbers in each tab are over all 8,139 flights.
What the map drew until 21 October: walk the poll rows and keep one as a corner whenever the straight line past it would miss it by more than 30 m, or its height by 100 ft. Rings are rows that only repeat the last position.
Dropped: 4.4% of fixes end up more than 30 m off, the line has a corner about once a minute, and repeated rows become zero-length pieces that turn into steps when the trail is lifted by height. On this flight: 77 pieces, worst miss 40 m.
Throw away the rows that repeat the last position and the rare glitch, then run the same 30 m rule.
Fewer pieces and the timing is sane, but the rule only checks the fix it drops, not the ones dropped before it, so the line still strays 5.2% of the time. Still corners. On this flight: 71 pieces, worst miss 40 m.
Grow each straight piece until any fix it covers would be more than 30 m off sideways or 1 s off in time. The honest polyline.
Accurate, but a turn is still a row of corners, and between corners the plane moves at one chord speed, so a marker riding it jerks. It stores no velocity. On this flight: 69 pieces.
Planes fly straight legs and constant-rate turns, so join arcs and lines with no corners (G1), and run speed along them as a cubic.
Dropped: a turn is rarely a perfect circle, so it needs 3.7 times the pieces of the curve that won, and 1.7 times the bytes. The map would also have to turn every arc into Béziers before drawing it. On this flight: 63 pieces.
Each piece holds a turn rate and an acceleration fitted to its fixes, and starts from where the last one ended (CTRA).
Dropped: errors add up along the chain, so it drifts and must restart from a fix (a keyframe) whenever it cannot catch up, which leaves a jump. Seven times the Pi time of the winner and 24.6 bytes per flight-minute. On this flight: 91 pieces.
The textbook answer: one C2 spline through the whole flight, with a new knot wherever a fix is missed.
Dropped: numerically fragile on real ADS-B data. Where it fails it blows up rather than missing by a little, and it needs numpy and scipy, which the Pi does not have. Drawn here from a re-run of the same method in plain Python (Gaussian elimination where the bench used scipy): it strays up to 16.5 km from the track, so the view is clipped.
The three that met the promise cheaply enough to ship, on the Heathrow arrival: vectors, a base turn and the final approach, with 4 radio silences (shaded). Under each, its speed against the plane's report.
Chosen. Fewest pieces of anything that runs on the Pi, and time is built in. Its speed can wobble between knots far apart.
The same curve, but a piece also ends where its speed would stray 20 kt from the report. Costs 6% more pieces. Kept as an option, not shipped.
The best shape for the fewest Béziers, but the shape has no clock: a second curve says how far along it the plane is, and the map would have to invert arc length to place the marker. With no clock it cannot tell a radio silence either, so here it curves smoothly through all four. Needs numpy.
Every finalist meets 30 m and 1 s on this flight (worst 29.2, 29.2 and 29.8 m). The Hermite's speed wobbles where its knots are far apart (here around minutes 15 to 17): between knots the curve may do anything the fixes do not forbid. Over all traffic its cross-track 99th percentile is 28.6 to 29.3 m in every flight phase; the old line's is 37 to 68 m.
The old line stored height only at its corners, so height changed only where the track bent. Height now has its own knots, on its own clock, with the climb rate as the slope.
The map raises each trail by its height. Turn this one by dragging sideways; tilt it with the slider; stretch the height to see the steps. Then switch the line.
Join every row the recorder stored. 86 of 273 rows (32%) only repeat the last position while the height changes, so the line climbs straight up and down in place: the staircase (red rings).
Arcs, turn-and-acceleration and straight pieces have no height channel of their own in the benchmark, so here they borrow the shipped cubic height. Schneider and the B-spline are left out: one has no clock to look the height up by, the other leaves the frame.
On the Pi each new fix extends the open piece or closes it and starts another. Closed pieces are frozen and sent once; the map turns knots back into Béziers. Bytes are measured on 20 real 15-minute windows (101 planes on average), delta-coded integers, brotli.
The curve is 2.1 times smaller than sending every fix, and about the size of the old lossy line while meeting 30 m, 1 s and 100 ft. An honest polyline is 12% smaller, but has corners and no velocity.
So the map draws Béziers and never samples them. On the Pi Zero 2 W, at about 80 planes, fitting costs about 0.3% of one core.
Shipped on 21 October (trails v3). Every 5 s the Pi sends the knots the server has not stored, and each live plane's tail: from its newest knot at least 45 s old to the open end. A knot is frozen once committed, at most 60 s behind the newest fix, and its time is its key, so a re-sent push changes nothing and history already drawn never shifts. A unit test replays the Pi's own pushes and checks both: re-sending every push leaves the log unchanged, and a viewer polling every 5 s gets tails that join the log with no holes.
| Idea | Status | Evidence |
|---|---|---|
| Fit, simplify, send, reconstruct | Tested | Benchmarked end to end on 20 windows (bytes after brotli); shipped as trails v3, with a test that Pi bytes decode to its knots and re-encode identically. |
| Curves clearly smaller than raw points | Tested | 12.7 against 26.6 bytes per flight-minute: 2.1 times smaller. Against the old lossy line it is about even (12.4). |
| Append and pop: sent history never shifts | Partly tested | Built into v3 and unit-tested on the Pi's recorded pushes (re-sends change nothing, tails join without holes). Not yet measured on live traffic over hours. |
| Open tail sized by confidence (seconds on straights, longer in turns) | Not tested yet | Not tested yet. Today the tail is set by age alone: from the newest knot at least 45 s old. |
| 6D state (east, north, height and their rates on one clock) | Partly tested | Only the two-fits arm exists: lateral Hermite with reported velocity, plus a separate height cubic with climb rate, scored on position, timing, height and bytes. Velocity is scored only as speed (with the 20 kt bound, p99 within 15 kt of reported). A joint 6D fit, and heading and climb-rate error, are not tested yet. |
Each run, what it tried, what it measured and what it decided. Raw outputs are kept with the benchmark scripts.
All seven representations on the raw recorder timestamps.
Hermite 3.58 pieces/min, timing p99 0.97 s · arcs 5.56 · CTRA 4.19 with 2.02 restarts/min · B-spline miss p99 1,050 m
Verdict: The curves were spending their pieces on clock noise. Led to the jitter finding.
Loosen timing to 3 s instead of fixing the clock.
Hermite 1.95 pieces/min, timing p99 2.92 s · CTRA 0.79% of fixes over 30 m · B-spline still blows up
Verdict: Cheaper, but gives up the 1 s promise. Rejected for retiming.
The same seven, after retiming fixes from reported ground speed.
Hermite 1.18 pieces/min, miss p99 29.1 m, timing p99 0.81 s, 0 corners · old line 2.72, 52.6 m, 39.8 s
Verdict: Hermite in time wins on pieces, smoothness and Pi cost.
Straight pieces held to the same 30 m and 1 s.
1.58 pieces/min, 1.03 corners over 2° a minute
Verdict: A fair straight-line rival: fewer bytes, but corners and no velocity.
Encode 20 real 15-minute windows (101 planes on average) as JSON and binary, brotli q11.
every fix 26.6 · old line 12.4 · Hermite 12.7 (+cubic height 14.1) · arcs 21.9 · CTRA 24.6 · B/flight-min · about 80 live planes with the snapshot: ≈ 12.5–13.2 KiB br per poll
Verdict: The curve costs about what the old line did and half of every fix.
Measured the live endpoints, then weighed client-side fixes, per-viewer deltas, and the Pi pushing the trail with its 5 s snapshot.
/api/adsb ≈ 6.3 KiB br · trails ≈ 16.2 KiB br · 6.5% of old segments height-only (the steps) · per-viewer deltas: origin ≈ 12/min → ≈ 600/min at 50 viewers
Verdict: Per-viewer deltas rejected (they break the shared cache). The Pi pushes; shipped as curve knots rather than a raw ring.
Close a piece where its speed strays 20 kt from the report.
1.25 pieces/min · 13.1 B/flight-min (+cubic height 14.5) · speed p99 within 15 kt
Verdict: Fixes the speed wobble for 6% more pieces. Kept in reserve.
Height tied to lateral corners, straight constant-climb pieces, and a cubic with climb rate.
tied p99 200 ft (9.7% over 100 ft) · straight 99 ft, 1.08 knots/min · cubic 97 ft, worst 330 ft, 1.10 knots/min
Verdict: Cubic: about 1.5 B/flight-min more than straight, and smooth.
The fitters as a standalone script on the receiver, at lowest priority; no services touched.
19–20× slower than the desktop · Hermite + cubic height ≈ 175 µs/fix ≈ 0.3% of a core at 80 planes · CTRA ≈ 1.2% · B-spline or Schneider refits ≈ 60%
Verdict: Only the greedy fitters belong on the Pi.
134 planes, 15 minutes each, on a skia canvas.
old lines 0.58 · every fix 0.98 · native Béziers 0.54–0.78 · sampled ×8 1.82 · ms/frame
Verdict: Draw native Béziers; never sample.
Pi webhook carries Hermite knots, appended to a per-minute log, plus a live tail; the map draws native Béziers.
tests: v3 round-trips to the Pi's bytes · re-sent pushes change nothing · tails join with no holes
Verdict: Live.
Cap each Bézier arm at half its chord; cut curves into pieces on the ground at 0.5 m, never in pixels; keep the Pi's pen flags.
replay of a London City landing and taxi: same line at every zoom and tilt, no stretch overshoots its ends, one break (the 110 s silence)
Verdict: A slowing plane no longer draws a loop it never flew.
Replay of the same landing with the feed's own clock: hold the tip, then ease back.
overshoot in the air 4,933 → 1,901 m · creep after hold 3,037 → 0 m · ground target off the fix 1,993 → 34 m · largest one-frame step 3,233 → 23 m
Verdict: The line behind and the marker in front now agree.
Tolerance 30 m sideways, 1 s in time (on retimed fixes), 100 ft. 597,249 fresh fixes, 90,000 flight-minutes.
| Method | Pieces/min | B/flight-min | Miss p99 | Timing p99 | Smoothness | Pi µs/fix |
|---|---|---|---|---|---|---|
| Old line (poll rows) | 2.72 | 12.4 | 52.6 m | 39.8 s | corners 0.93/min, to 180° | ≈ 20 |
| Same, fresh fixes | 1.74 | 10.1 | 54.6 m | 2.3 s | corners 1.00/min | 20 |
| Straight, held to 30 m | 1.58 | 11.1 | ≤ 30 m | ≤ 1 s | corners 1.03/min | 50 |
| Cubic Hermite in time | 1.18 | 12.7 | 29.1 m | 0.81 s | C1, no corners | 90–109 |
| Hermite + 20 kt bound | 1.25 | 13.1 | 29.1 m | 0.80 s | C1 | ≈ 95 |
| Arcs and lines | 4.37 | 21.9 | 29.3 m | 0.59 s | G1, curvature jumps | 106 |
| Turn rate + acceleration | 4.85 | 24.6 | 29.2 m (max 317) | 0.83 s | jumps at restarts | 650–770 |
| B-spline, adaptive knots | 1.84 | 15.2 | unstable | 0.81 s | C2 | ≈ 1,200 (est.) |
| Schneider + speed | 1.10 + 0.61 | 17.6 | 28.2 m | 0.96 s | G1 | ≈ 2,000 (est.) |
| Every fresh fix | – | 26.6 | 0 | 0 | corners | – |
The old line spends most where planes turn and hold; the curve spends 2 to 3 times less there, and its 99th-percentile miss stays under 30 m in every phase. What is left: spans across silences over 45 s lift the pen rather than guess, and the speed between sparse knots can wobble.
See also how it predicts and the receiver status.