3D map investigation — airspace volumes and an elevated flight track
Moved from vulture-ios/Docs/3d-map-investigation.md
Status: investigation only, nothing implemented. Written 2026-09-01, right
after the MapboxMaps v11 code swap landed on refactor/mapbox.
Question asked: now that the map runs on MapboxMaps 11.26, can we show airspace as real 3D volumes and the flight track at its true altitude — and if we do, does the barogram still work as the navigation control?
Short answer: yes to both, with three real caveats (translucent extrusion blending, view annotations that cannot leave the ground, and the globe projection we just switched on). The graph does not stop working — it stays the time axis — but it should shrink to a scrubber strip in the 3D mode, because a pitched camera needs the screen height the panel currently eats.
Contents: §§1–5 and §7 are the feasibility pass (what v11 gives us, and what breaks). §6 is the navigation design, decided after it. §8–9 are the plan and the open questions. §10 records the mockups.
1. What v11 actually gives us
Section titled “1. What v11 actually gives us”Verified against the resolved SDK checkout (mapbox-maps-ios 11.26.0), not
from memory. @_spi(Experimental) below means the property exists and is
usable but needs @_spi(Experimental) import MapboxMaps and can move between
minors — we already do that import in MapCompatMapView for the restricted
ornament options, so it is not new ground.
| Capability | API | Availability |
|---|---|---|
| Extruded polygons | FillExtrusionLayer + fillExtrusionBase / fillExtrusionHeight (meters, data-driven) | public |
| Walls only, no roof | fillExtrusionLineWidth — “wall rendering mode” | @_spi(Experimental) |
| AGL vs AMSL extrusion | fillExtrusionBaseAlignment / …HeightAlignment = .terrain / .flat | @_spi(Experimental) |
| Elevated lines | LineLayer.lineZOffset + lineElevationReference (.sea / .ground) | public |
| Per-vertex line altitude | Exp(.atInterpolated) + Exp(.lineProgress) over an array attribute | public (needs lineMetrics) |
| Terrain | RasterDemSource + StyleManager.setTerrain(Terrain(sourceId:)) | public |
| Elevated symbols | SymbolLayer.symbolZOffset / symbolElevationReference | @_spi(Experimental) |
| Elevated circles | CircleLayer.circleElevationReference | @_spi(Experimental) |
| 3D glider model | ModelLayer / ModelSource, or Puck3D | public / partly SPI |
| Camera pitch | CameraOptions.pitch, CameraBounds.maxPitch, FreeCameraOptions | public |
The migration also unblocked the spike itself. Since v11 the app links for the simulator, so all of this can be probed on a simulator before any device session — which was impossible on v6.
2. Airspace in 3D
Section titled “2. Airspace in 3D”The data is already there
Section titled “The data is already there”Both airspace sources already carry vertical limits:
- File airspace —
Airspace.lowerLimit/.upperLimitareAirspace.Altitudevalues with a readyvalueInMeters(Airspace+Altitude.swift).Airspace.feature(inAirspace+Feature.swift) already builds the polygon and attaches colour attributes; adding two numeric attributes is a two-line change. - OpenAIP tiles — the vector tiles carry
lower_limit_value,lower_limit_unit,lower_limit_reference_datum(and theupper_…trio) per feature;Airspace.initalready reads them. That means the extrusion can be driven straight off the tile source with a style expression, with no client-side preparation — amatchon the unit/datum plus an arithmetic expression for feet→meters and FL→meters.
So FillExtrusionLayer with fillExtrusionBase = ["get", "base"] and
fillExtrusionHeight = ["get", "top"] is the whole mechanism. No new data, no
new network calls.
The three catches
Section titled “The three catches”- Translucent extrusions do not stack well.
fill-extrusion-opacityis documented in the SDK as “rendered on a per-layer, not per-feature, basis, and data-driven styling is not available”, and overlapping translucent extrusions do not depth-sort correctly. A CTR inside a TMA inside a FIR — the normal case over Belgium — will look muddy. Two ways out, and the first is the better product anyway:- Wall rendering mode (
fillExtrusionLineWidth): draws only the vertical walls over the polygon outline, no roof, no filled interior. That is much closer to how a pilot reads an airspace chart, and it side-steps the blending problem entirely. This is the recommended default. - Or extrude only the selected / nearest airspace and leave the rest as today’s flat fills.
- Wall rendering mode (
- AGL and AMSL need separate layers.
fillExtrusionBaseAlignmentis a layer property, not a feature property. Airspace with a GND-referenced base wants.terrain; AMSL and FL want.flat. That is two extrusion layers with complementary filters — the layer factory pattern inComponents/Map/Layers/Airspace/handles this cleanly. Note this only matters once terrain is enabled; with terrain off, everything is flat anyway. unlimitedupper limits need clamping (today they areInt.max) — pick a display ceiling, FL195 or the top of the current view.
Query and interaction
Section titled “Query and interaction”queryRenderedFeatures works against fill-extrusion layers, so the existing
tap-to-identify path in MapViewDataSource+Gestures survives. What needs a
device check is hit-testing from a pitched camera: the invisible tap-target
fill is sized for a top-down view, and at 60° pitch distant airspace occupies
very few pixels.
3. The flight track in 3D
Section titled “3. The flight track in 3D”lineZOffset + lineElevationReference = .sea is public API in 11.26 — this
is the cleanest part of the whole feature.
There are two routes, and the current data shape favours the easy one:
- Per-segment constant (recommended first attempt).
Flight.flightPolylines()already emits one 2-point LineString per fix for the altitude-coloured track (that is how the colour gradient is done today). Each segment can carry its own altitude attribute and setlineZOffset = ["get", "altitude"]. Each segment is then flat, but at 1 Hz fixes a segment spans a few meters of climb — invisible. Needs proving: the SDK doc string does not state whetherlineZOffsetaccepts a data-driven (per-feature) expression. This is the single most important thing for a spike to answer. - Per-vertex array (fallback). One LineString for the whole flight with an
elevationarray attribute, indexed withExp(.atInterpolated) { Exp(.lineProgress) … }. This is Mapbox’s documented elevated-line pattern. It requireslineMetrics = trueon the GeoJSON source — and the compatMGLShapeSourcedoes not exposelineMetrics(it only capturesclustered/clusterRadius/maximumZoomLevelForClustering/simplificationTolerance). Either add an option key, or move the track sources to a nativeGeoJSONSource, which is the direction CLAUDE.md already points the compat layer.
Globe projection conflict. The SDK doc for lineZOffset says plainly:
“Not supported for globe projection at the moment.” We set
StyleProjection(name: .globe) on every style load (MapViewDataSource+Delegate,
commit 28799ee31). At the zooms where 3D is useful the map is already
Mercator, so this is probably academic — but it must be verified, and if the
track disappears when zoomed far out, 3D mode should force .mercator.
Also relevant: lineElevationGroundScale controls how the elevation reacts to
terrain exaggeration. Keep terrain exaggeration at 1.0 or airspace and track
stop agreeing with each other.
4. Terrain
Section titled “4. Terrain”RasterDemSource pointed at mapbox://mapbox.mapbox-terrain-dem-v1 plus
setTerrain. Cheap to add, and it is what makes a 3D flight actually read —
a track at 1800 m over the Alps means nothing without the ridge under it.
Two things to weigh:
- Extra tile traffic, on a device that is often on mobile data or in a glider. This should be a setting, off by default, and it should interact sensibly with whatever offline story we have.
- The graph already draws a terrain fill from
Fix.elevation(SRTM, cached byPackages/Flights/Elevation). Mapbox’s DEM and our SRTM cache will not agree to the meter. Two terrain surfaces disagreeing between the graph and the map is a small but real consistency wart.
5. The thing that actually breaks: annotations
Section titled “5. The thing that actually breaks: annotations”This is the sharpest limitation and it is worth being blunt about it.
Every marker on the flight map is a ViewAnnotation hosting a UIView —
FixPointAnnotation / GliderAnnotationView (the glider that follows the
graph), the turnpoint annotations, AirspaceQueryAnnotation. ViewAnnotation
anchors to a 2D coordinate. Its allowZElevate flag only snaps the view to
the top of terrain or a building underneath it — it cannot place a view at an
arbitrary altitude.
So in a 3D view, the track climbs to 2000 m and the glider marker stays on the ground. That is not a polish issue, it is the core of the feature.
Replacements, in order of appeal:
ModelLayerwith a real glider model for the current fix. Reads its position from a source, so an altitude-correct, bearing-correct glider is achievable — and this is the version that makes the feature feel special.SymbolLayerwithsymbolZOffset+symbolElevationReferencefor the simpler glyph markers. Caveat: the doc says “Specifies an uniform elevation from the ground” — whether it is per-feature data-driven needs verification. If it is uniform per layer, event symbols (takeoff, release, landing — all at different altitudes) cannot share one layer, and the honest answer is to leave event symbols on the ground with a thin vertical drop line up to the track.- Keep the pins 2D and ground-anchored deliberately, as “shadow” markers.
6. Navigation — the design
Section titled “6. Navigation — the design”Decided 2026-09-01 after the feasibility pass, and drawn up as an interactive mockup (§10). The shape is: one flight map with a 2D/3D toggle, not a separate replay screen. The earlier draft of this section recommended a separate screen to protect the 2D map’s gesture contract; the toggle was chosen instead, so §6.4 is where that contract gets paid for rather than avoided.
6.1 The graph stays
Section titled “6.1 The graph stays”It is not an altitude display that 3D makes redundant — it is the time
control. Everything hangs off it: currentFix → centerFix,
currentFixThermal, currentFixLeg, the playback loop with its speed menu,
the flight-comparison alignment. None of that has a 3D equivalent and none of
it needs to change.
What 3D changes is how much screen the graph may occupy. A pitched camera puts
the horizon high and the interesting ground in the lower half of the screen —
exactly where PanelContentViewController sits.
6.2 The toggle
Section titled “6.2 The toggle”A text 3D button in the existing bottom-trailing glass pill, beside the
satellite and show-panel buttons — the Apple Maps convention, and the pill is
already the map-appearance cluster. It tints to AccentColor while 3D is
active rather than swapping its label to 2D; a label that changes under the
finger reads as a different button.
It grows on the leading side of the pill, which is the direction
FlightMapView already grows it (the satellite button never changes place —
see invalidateBottomButtons()).
6.3 The panel’s fourth resting place
Section titled “6.3 The panel’s fourth resting place”3D collapses the panel to a 100 pt scrubber. This is a fourth resting place
added to the three FlightMapView.setupPanel() already defines — hidden (900),
tucked under the vario container (750), filled to the map bottom (800) — not a
new panel and not a new controller. It carries the same play button, the same
speed menu and the same handle, so every currentFix binding survives
untouched.
Its one row is: play/pause, a full-width scrub track with the altitude curve drawn behind it as a sparkline, the readout (time · altitude · vario · AGL) inline, and a chevron that restores the full barogram without leaving 3D.
On iPad nothing collapses. At 834 pt tall the 300 pt panel still leaves 534 pt of map, so 3D and the full barogram coexist. The scrubber is an iPhone accommodation — offered on iPad, not forced.
6.4 Camera modes
Section titled “6.4 Camera modes”A second glass pill appears above the map-controls pill, only in 3D. Three segments, icon plus label, active one filled with the accent:
| Mode | Camera | Scrub behaviour |
|---|---|---|
| Free | You fly it: pan, rotate, pitch by hand | Moves the glider, not the view |
| Chase (default) | Behind and above the fix, bearing follows the track | Camera follows the fix |
| Cockpit | Eye at the fix, looking along the heading | Camera follows the fix |
Chase is the default because it is the replay people picture. Cockpit needs the bearing smoothing the glider annotation already does; it also has no glider marker by definition, so it needs a canopy frame and wingtips or the view has no anchor at all.
6.5 The gesture collision — the one real cost of the toggle
Section titled “6.5 The gesture collision — the one real cost of the toggle”| Gesture | 2D today | 3D |
|---|---|---|
| One finger drag | Pan | Pan, ground-locked |
| Two finger drag | Measure tool | Pitch ← collides |
| Two finger rotate | Rotate | Rotate |
| Pinch | Zoom | Dolly along the view |
| Tap / long press | Identify airspace, query pin | Same — needs verifying at pitch |
| Scrub / play | Moves the fix | Fix and camera follow |
Two-finger drag is MapViewDataSource+Measure today and Mapbox’s pitch gesture
in 3D. Pitch is the primary 3D gesture and measure is rare, so pitch wins in
3D and measure becomes explicitly armed from the map menu with a banner while
active. A modifier gesture for pitch is the alternative and it is
undiscoverable.
Note that pitch is currently disabled for every map screen, in
MapView.mapView.didSet — so this is a per-screen decision to make, not a
global flip. The task and in-flight maps keep isPitchEnabled = false.
6.6 Two inventions, flagged as such
Section titled “6.6 Two inventions, flagged as such”Neither is in the app today; both are in the mockup and both need a call:
- The future track is dimmed ahead of the current fix. It reads well in 3D and it is a genuine navigation cue, but the 2D map draws the whole track at full strength and this would be a divergence.
- A drop line and ground shadow under the glider. This is partly forced —
§5 means the marker may have to be a
ModelLayer— but the dashed line to the terrain is what makes the altitude legible, and it has no 2D equivalent.
7. Also affected by a pitched camera
Section titled “7. Also affected by a pitched camera”Worth an explicit list, because these are the surprises:
- Two-finger measure gesture vs Mapbox’s two-finger pitch gesture.
- Camera framing —
showAnnotations/centerBounds/centerTurnpointall buildCameraOptionswithout a pitch; framing from a pitched camera needs pitch and padding passed through (MapCompatMapView). - Tap hit-testing at pitch, as noted above.
- The globe projection we just added.
- Mac (Designed for iPad) — pitch by trackpad, plus the existing pointer / scroll-wheel zoom gesture. CLAUDE.md requires a Mac pass for presentation changes.
- SkySight and satellite raster overlays drape onto terrain once it is on; the legend and opacity want a look.
- In-flight map — 3D here is a different feature (a live moving-map with terrain ahead). Out of scope for this investigation; the flight replay is where the value is.
- Track thumbnails are
TrackShape, not Mapbox — unaffected.
8. Suggested phasing
Section titled “8. Suggested phasing”Each phase is independently shippable and independently abandonable.
| Phase | Work | Proves |
|---|---|---|
| 0. Spike | Simulator, throwaway branch. Enable pitch + terrain, extrude one file airspace, elevate one track with per-feature lineZOffset. | Whether lineZOffset is data-driven; whether wall mode looks right; whether symbolZOffset is per-feature. Everything below depends on these three answers. |
| 1. Terrain + pitch | DEM source, setTerrain, pitch enabled on the flight map, setting to toggle. No data changes. | Immediate visible value, no new drawing code. Shakes out the gesture conflicts early. |
| 2. 3D track | Altitude attribute on the track features, lineZOffset, lineMetrics on the compat source if needed. | The headline feature. |
| 3. 3D airspace | Extrusion layer factories in wall mode; file airspace first, OpenAIP tiles second (the expression is the fiddly part). | The other headline feature. |
| 4. Navigation | The chrome from §6: the 3D pill button, the camera pill, the panel’s fourth resting place, the measure/pitch resolution, ModelLayer glider. | Turns “the map is tilted” into “I am flying my flight again”. |
Phases 1–3 are the honest scope of “see the airspace and the track in 3D”. Phase 4 is where it becomes a feature people talk about.
Phase 1 is where the §6.5 gesture collision has to be settled, because enabling pitch is what surfaces it — do not defer that to phase 4.
9. Open questions for the spike
Section titled “9. Open questions for the spike”- Does
lineZOffsetaccept a per-feature expression, or only a constant / zoom expression? (Decides the whole track approach.) - Does
symbolZOffsetsupport data-driven values? (Decides whether event and photo markers can be elevated at all.) - Does
lineZOffsetreally break under globe projection at the zooms we use? - How does wall-mode fill-extrusion read against the current flat fills — is it legible, or is it visual noise over a dense airspace region?
- Frame cost of extruding a full OpenAIP tile viewport on the oldest supported device, and of a 5-hour track as thousands of elevated segments.
- Does the DEM tile traffic hurt enough to need an offline story before this ships?
10. The mockups
Section titled “10. The mockups”An interactive design canvas, drawn 2026-09-01 against the real chrome
(UIGlassEffect pills and panel, AccentColor #EF703D, the Eagle airspace
palette, the panel geometry from FlightMapView.setupPanel() and
PanelContentViewController+Layout):
https://claude.ai/code/artifact/ec6d9294-1018-451a-9da9-dff250edbe4c (since 2026-10-09 also https://claude.ai/artifact/WCKT5QrPrSAeF1yJzfCXPZ)
Three artboards:
- iPhone (390×844) — live. The
3Dtoggle, the camera pill, the collapsed scrubber, playback and scrubbing all work. - iPad (1194×834) — live, opens in 3D with the full barogram still down, which is the §6.3 point.
- Navigation spec (940×720) — the four panel resting places with their constraint priorities, and the §6.5 gesture table.
Two tweak chips on each interactive artboard exist to settle open questions from §2 and §4: airspace style flips wall-mode against solid extrusion, and exaggeration runs terrain 1.0×–2.5×.
The map is a real pinhole projection over a synthetic 129 km task and a procedural terrain — the airspace prisms, the ground shadow and the drop line are genuinely projected, so the proportions are honest. It was verified numerically (no browser was available in that session), not by eye.
To change the canvas later: read the artifact and run the design skill’s
seed-canvas.mjs --extract to get the .dc.html working files back, edit,
re-seed, republish to the same URL. The original working files were built in a
session scratchpad and are not in this repo — extraction is the supported path.
11. Competition replay — many flights in 3D
Section titled “11. Competition replay — many flights in 3D”Added 2026-10-09, design only. The question: once the flight map has a 3D mode, can it replay a whole competition day, every glider of a class on one map at its true height, the way a TV broadcast of a race does?
Short answer: yes, and most of the 2D groundwork is already there. The
work that is new is the gliders (no ViewAnnotation can leave the ground,
§5, and a class is 30–50 of them), what to do with 50 full tracks, and a
race ranking (who leads at the moment being replayed), which the app
does not compute today in 2D or 3D.
11.1 What already exists in 2D
Section titled “11.1 What already exists in 2D”Facts from the code, so the plan starts from them:
- Several flights on one map with a shared timeline. Each
FlightViewModelhas atimeOffset(0 by default: every flight at its own time of day). The only other option isisTaskStartTimeAligned(FlightMapViewModel.alignTaskStartTimes()), which moves every flight onto the earliest task start. There is no alignment by take-off. - Getting a day onto the map. Soaring Spot and Charron days hand their
flights to
FlightMapViewController.add(items:externalTask:). Downloads run three at a time (Soaring Spot rate-limits), there is no cap on the count, ranked flights are sorted and coloured by rank, and the day’s task is applied. - Gliders. There is one
GliderAnnotationViewper flight in aViewAnnotation. The selected flight is opaque and in front, the rest are at half opacity, and tapping a glider selects it. - Playback. A
CADisplayLinkdrives it, at ×10 to ×200 (PlaySpeed). - Gating. More than one flight is Pro (
ProFeature.severalFlights), so a competition replay is Pro by construction. No new gate is needed for the multi-flight part. - Who followed whom (
CompareAnalyses,LeechingAnalysis) and the time flown alone exist, but only as totals for the whole flight on the compare screen. Nothing gives a value at a given moment, and the map has no leaderboard or gaps; its only ranking is the static Soaring Spotrank.
11.2 Time of day first, a ghost race second
Section titled “11.2 Time of day first, a ghost race second”The time of day is the default and the honest mode for a competition (a replay, not live tracking, which the app does not have): the gliders are in the same air at the same moment, and gaggles, leeching and the start-gate game are only visible that way. The existing task-start alignment stays as the second option, a ghost race where everyone starts together. It is useful to compare legs, but the gliders are then in air from different times of the day, so the 3D view must say so with a label on the map (“Aligned by task start”), not only a state in the menu.
11.3 Gliders: one model layer, not 50 views
Section titled “11.3 Gliders: one model layer, not 50 views”The §5 fix becomes mandatory here. All the gliders go in one GeoJSON
source with a feature per flight (position, altitude, heading, colour,
competition ID, selected), drawn by one ModelLayer. Each tick
replaces 30–50 point features, which is cheap. Tapping a glider in 3D
becomes a queryRenderedFeatures on that layer and replaces the annotation
tap.
The competition ID label (“LS”, “7B”) is what tells a pilot which glider
is which, and it is the weak spot: whether symbolZOffset can take a value
per feature is §9’s question 2. If it cannot:
- Option a: draw the labels on the ground under each glider, joined to it by a drop line, like §6.6’s shadow.
- Option b: draw them in a UIKit overlay at screen points, projected from
position and altitude each frame. Mapbox’s
point(for:)takes no altitude, so we would do the projection ourselves from the camera.
Spike option a first.
The selected glider stays as it is in 2D: full colour and in front, the others muted. Colours keep following the leaderboard (11.1).
11.4 Tracks: tails, not spaghetti
Section titled “11.4 Tracks: tails, not spaghetti”Fifty full tracks at altitude is unreadable. In competition replay:
- Every glider draws a tail, the last few minutes behind it at its true height and fading out. The length is a setting (decided 2026-10-09, the pilot), in Preferences beside the play speed; five minutes is the default to try.
- The selected flight’s whole track is drawn, past at full strength and future dimmed (§6.6), so the field is read against one line.
- The day’s task is drawn on the ground, as today.
A tail moves with every tick, so it cannot be a static layer. Two ways to build it:
- Rebuild the tails’ GeoJSON at a throttled rate. That is 50 × about 300 fixes, simplified.
- Use
lineTrimOffseton full per-flight lines. This is a layer property, so it means a layer per flight and needslineMetrics.
The spike measures the first one; it is simpler, and the second one’s 50 layers are a cost of their own.
11.5 The race ranking
Section titled “11.5 The race ranking”This is the one genuinely new analysis, and it is worth more than the 3D: it works in 2D too, and should ship there first.
At any time t, each flight has a place in the race:
- Racing task: distance flown along the task so far, measured as the task distance minus the distance still to go via the remaining turnpoints.
- AAT: the same, through the turnpoint chosen so far in each area. Proving the scoring there is the fiddly part.
- Finished: ranked by finish time.
- Landed out: frozen at their distance.
That gives a ranking at every moment, the gap to the leader in kilometres (and, for finishers, in time), and the climb and speed at that moment.
- Where the code goes. A precomputed
[TimeInSeconds: progress]per flight, built once off the main thread, likeCompareAnalyses. It belongs inPackages/Flights, is nonisolated and pure, and gets tested there. Looking up a time is a binary search, asfix(for:)already does. - Where it shows. In the panel’s flights list (
PanelFlightsViewController): place, ID, gap, climb at that moment, and the rows reorder as the ranking changes during playback. On the iPad and the Mac it has the room; on the iPhone in 3D it sits behind the scrubber’s chevron (§6.3). - Checking it. At the finish, the ranking should match the day’s official result for every finisher who flew the task without penalties. That is the test.
11.6 Camera
Section titled “11.6 Camera”§6.4’s three modes follow the selected glider. A competition adds one more:
| Mode | Camera |
|---|---|
| Field | Frames every glider still flying, from above and behind the field’s mean heading, re-fitting smoothly as the field spreads. The broadcast view, and the default when more than one flight is on the map. |
Selecting a glider in Field (tap, or a row in the ranking) switches to Chase on that glider; Field is one tap back on the camera pill.
11.7 What it adds to the open questions (§9)
Section titled “11.7 What it adds to the open questions (§9)”- How fast is the frame with 50
ModelLayerinstances moving, and with 50 elevated tails rebuilt each tick, on the oldest supported device? - Does a
ModelLayertake a colour per feature (modelColorfrom a data expression)? If not, the gliders are told apart only by their labels. - AAT progress at a given moment: is “distance via the turnpoints chosen so far” close enough to the official result to be trusted?
11.8 Phasing
Section titled “11.8 Phasing”These phases are added after §8’s. Phase 5 does not depend on 3D at all.
| Phase | Work | Proves |
|---|---|---|
| 5. Race ranking (2D) | The progress analysis in Flights, with tests against real Soaring Spot results; the panel’s flights list with place and gap, reordering as playback runs. | The ranking is right — and a competition replay is worth watching even flat. |
| 6. Field in 3D | All gliders in one ModelLayer, the labels, tails, the Field camera, the ghost-race label. Needs phases 2 and 4. | The broadcast view. |
11.9 Pro
Section titled “11.9 Pro”Decided 2026-10-09 (the pilot): one flight in 3D is free. Following the
Debrief’s pattern (score free, parts Pro): it is the screenshot that sells the
app, and the depth (a whole competition in 3D, the race ranking) is behind
severalFlights anyway, so 3D needs no gate of its own.
11.10 Not yet decided
Section titled “11.10 Not yet decided”- No mockup yet for the Field mode or the ranking list. The §10 canvas could take a fourth artboard.
11.11 The race ranking on every platform
Section titled “11.11 The race ranking on every platform”Agreed 2026-10-09 (the pilot): the race ranking ships first, on the flat map (phase 5, 11.8), and the 3D field picks it up later.
When it exists. Two or more flights on the map with one task they
share: the task a Soaring Spot or Charron day brought, the one the map
found, or one dropped on it. Without a task there is nothing to rank against,
and the button is not shown. A single flight has no ranking, and more than
one flight is Pro already (severalFlights).
Where it starts.
-
A competition day: Replay Day. The Soaring Spot and Charron day screens have Select All and Open today. One Replay Day action loads the whole class with the day’s task and opens the map with the ranking up. It is the front door, and its Pro check is the existing several-flights paywall.
-
The flight map, whenever the condition holds. A Ranking button (
trophy,trophy.fillwhile the card is up) goes beside Compare and the Debrief:- the bar on a regular width
- the overflow menu on a compact one
- View › Show Ranking on the Mac (check the shortcut against
Docs/hig-compliance.mdfirst)
A club day built from logbook flights gets it this way once a task is set.
-
A place badge on a flight card in the panel opens the full list.
The row. It follows the list conventions:
- Leading: the place, then the competition ID in a 32 pt circle badge in the flight’s colour.
- Title: the pilot.
- Caption: the glider and the climb at that moment.
- Trailing: the loud number, the gap to the leader in kilometres.
- A finisher shows the finish time and a flag.
- A pilot who landed out is grey: “Landed out · 212 km”.
- Pilots not yet started are grouped at the foot.
- The selected flight’s row is the grey picked row.
Per platform.
| Platform | Ranking | Notes |
|---|---|---|
| iPhone | The panel’s flight cards keep their order and gain a place badge and the gap. The full list is a card over the map, presented the way the thermal sheet is. | Cards reordering sideways during playback would be unreadable; only the card’s list reorders. |
| iPad | A floating card at the map’s leading edge (FloatingPanel.insets(in:)), up while playing and scrubbing; rows slide into their new places. | Not a popover: a popover goes on the first touch of the map. |
| Mac | A third MapInspector.Content, .ranking, beside .detail and .debrief, with a bar button that fills while it is up. | The same rows as the card: the pilot and the gap on top, the speed, handicapped speed and distance under them. |
Who leads, by time of day: average speed (decided 2026-10-10, the pilot, replacing “On the course” of 2026-10-09). With every flight at its own time of day, the glider furthest along the course is not always winning: a later starter level on distance is ahead on time (in the mockup’s day KX is fifth on the course at 14:00 and wins). A racing task is scored on speed, so the ranking is too: the finishers by their task speed and the flights still racing by their average speed since their own start, in one order, fastest first — a glider still racing faster than a finisher leads it. Until fifteen minutes after a start the distance is divided by fifteen minutes, so a high start does not read as a speed no glider holds. A flight landed out has no speed and ranks below, by its distance; one not started is at the foot. The row’s number is the leader’s speed and everyone else’s speed behind it (“−3.2 km/h”); a land-out’s is its distance. The header says “By average speed”, “By handicapped speed” where the class has handicaps (the speed times 100 over the glider’s index, from Soaring Spot’s pilots list, carried with each flight and through Handoff), or “Aligned by task start” with the alignment on, where the two orders are the same.
An assigned area task is scored per flight (2026-10-10). Each flight’s
course runs through its own credited point in each area, chosen for all
areas together so the course is as long as the flight allows
(AreaScoring, dynamic programming over the fixes inside each area, in
time order); the finish is the first fix in the finish zone after the last
area’s point, so a finish ring touched while the last area is still worked
does not end it; a start or finish ring is taken off the distance; and a
finish under the minimum time is scored over the minimum time. Checked on
OBGN 2026, 18m/Open, task 2 (RaceDayTests): distances within 0.2 % of
Soaring Spot’s, speeds within 0.3 km/h, and with handicaps the official
order 1–13 exactly. One difference: a glider already inside the finish
ring when it worked its last area (LE that day) is measured by Soaring Spot
without the ring taken off and finishes later, about 10 km longer; its
place is the same. Racing tasks still run through the turnpoints’ centres
(a cylinder’s shortening is the same for every flight). Alignment stays the pilot’s choice: at the time of day
the replay shows the day as it was (who shared a thermal, who followed whom),
and the speed already makes the order fair.
On every platform:
- With task-start alignment on, the header says “Aligned by task start” (11.2).
- A row’s tap selects that glider, as a tap on the glider does.
- The order animates; it does not jump.
Owed with it:
- Handoff carries whether the ranking is up, so
Docs/handoff.mdand the payload’s tests need an update. - The new strings need all 8 locales.
- The progress analysis is tested in Flights against real Soaring Spot results (11.5).
- The mockup is §10’s canvas, second row (2026-10-09): Ranking · iPhone,
· iPad and · Mac run the same synthetic day (8 gliders, a 308 km
racing task, one landing out) with play, scrub, speed and selection, and an
alignmenttweak for the ghost race. Ranking · where it starts draws the three ways in.
Built (2026-10-09, branch feature/race-ranking). Phase 5, on the flat
map:
- The analysis.
RaceProgressandRaceRankingare in Flights.- The distance runs centre to centre and never goes back; the order is by average speed since each flight’s own start (2026-10-10).
- An assigned area task is scored per flight, through its own points in the areas (2026-10-10), with handicaps from Soaring Spot. Charron.line days have no handicaps yet.
- It is tested on synthetic tracks, two logbook flights and three flights of a real Soaring Spot day against its official result (OBGN 2026, task 2).
- The screen.
RaceRankingViewand its view model are in FlightScreens.FlightMapViewModel+Rankingworks the progress out off the main thread and places the flights as the replay runs. - Where it goes up: a glass card of the map’s own (2026-10-10,
replacing the popover and the phone’s sheet; mockups in the “Ranking
Side Panel” canvas).
RankingCardView, placed byFlightMapViewfrom the map’s free width — never the device:- 600 pt and wider (iPad, a phone on its side, an iPhone Duo open):
down the trailing side from under the bar to the bottom margin, 320 or
340 pt (300 on a phone on its side, without column headings, plus the
device’s inset it reaches under). The graph card and the pills move
over for it, as for the Mac’s column (
rankingCoveredInset); a wide window hides the sidebar first, as the popover did. - Narrower (a phone upright, a Duo folded): a slim column of places and competition IDs down the trailing side, 64 pt, from under the bar to the bottom margin, the graph moving over for it as for the side card. Its button (‹‹) widens it to the whole ranking across the map, over the graph, from under the bar to the bottom margin; the graph fades under it, and the widened card’s button (››) narrows it back. It opens slim every time. A thermal’s or an airspace’s card puts it away there — one card at a time.
- The card has the title, the line saying how it ranks, and its button (the sidebar toggle down a wide window’s side); the bar’s Ranking button is its toggle on every width: in the bar’s pod with the others, its glyph in the accent while the card is up (2026-10-10; it was a prominent ring, which stood out on its own). A centring clears it.
- In a handicapped class each row has the flight’s own speed under its handicapped figure.
- Mac: the inspector column’s
.ranking. - The flight cards show the place and the gap where they showed the date.
- 600 pt and wider (iPad, a phone on its side, an iPhone Duo open):
down the trailing side from under the bar to the bottom margin, 320 or
340 pt (300 on a phone on its side, without column headings, plus the
device’s inset it reaches under). The graph card and the pills move
over for it, as for the Mac’s column (
- The ways in:
- Replay Day on the Soaring Spot and Charron.line days, also File › Replay Day.
- The Ranking button, on every width.
- Flight › Ranking, ⌥⌘R, beside Compare and Debrief, where the Debrief already sits, rather than View › Show Ranking.
- Handoff carries whether the ranking is up (
Docs/handoff.md). - Not built: a tap on a card’s place badge does not open the list yet.
