Skip to content

05 — Map, track rendering, barogram and airspace (iOS → Android)

Moved from vulture-android/docs/analysis/05-map-baro-airspace.md

Source analysed: /Users/fousa/Projects/vulture-ios (read-only), as of 2026-10-10. Paths below are relative to that repo. Map/ = Modules/App/Sources/Map/. No secrets are reproduced; where a key/token is configured, only its location is given.

Contents

  1. Mapbox setup (SDK, styles, projection, chrome, events, gestures, camera, offline, 3D status)
  2. Style layers: full IDs and z-order
  3. Flight track rendering (styles, colouring, simplification, thermals, events, glider, task/OZ, multi-flight, highlight)
  4. Barogram (implementation, series, axes, gestures, scrubber, sync, playback, panel)
  5. Airspace (sources, parsers, model, settings, styling, selection, detail sheet, infringements)
  6. Other map data (waypoints, airports, terrain/elevation, satellite clouds, measure tool)
  7. Colour reference (all tokens)
  8. Android mapping (Mapbox Android v11 + Compose equivalents, barogram recommendation, token handling)
  9. Quirks to replicate or fix deliberately
  10. Risks and size estimate

ItemiOS valueSource
SDKMapboxMaps 11.32.0, pinned exactVersion via SPMXcodeGen/Packages/MapboxMaps.yml
Migrationv6 → v11 swapped 2026-09-01; per-feature verification trackerDocs/mapbox-v11-feature-checklist.md
Access tokenMBXAccessToken key in Modules/App/Resources/Info.plist (public pk. token, hard-coded)Info.plist:107
SPI imports@_spi(Restricted) (hide logo/attribution), @_spi(Experimental)Map/Rendering/Compat/MapCompatMapView.swift
ArchitectureControllers are Mapbox-free; MapViewDataSource (base) + Flight/Task/InFlight subclasses own the map, sources, layers, annotations. One factory enum per layer under Map/Rendering/Layers/. A “compat” layer keeps v6-shaped types (MGLShapeSource, MGL*Feature) bridging to v11.CLAUDE.md “Map integration (MapboxMaps v11)”

Rules from CLAUDE.md worth carrying over verbatim to Android:

  • Layers are values; never hold one to mutate later. For per-frame/gesture-driven changes set one property (setStyleLayerProperty(id, "line-opacity", …)) instead of a whole updateLayer (measured 0.3–0.55 ms per updateLayer vs ~300× cheaper single-property set).
  • Feature queries are async-only (never block the main thread to fake a sync query).
  • Camera-changed fires per frame in v11 → the “did change” hook is debounced 200 ms; “is changing” stays immediate (MapViewDataSource.swift setupBindings, +Delegate.swift).
  • GeoJSON numbers come back as Double; read feature attributes tolerantly.
  • Shape pushes to GeoJSON sources are coalesced to ~30 Hz (1/30 s with trailing push) (Map/Rendering/Compat/MapCompatSources.swift:124-141).

Packages/Preferences/.../Models/MapStyle.swift (Int raw values persisted, key VultureMapStyle):

MapStyle (raw)Style URLNotes
adaptive (0, default when unset)mapbox://styles/mapbox/light-v11 / mapbox://styles/mapbox/dark-v11 by UI appearanceRe-applies style URL on light/dark change (MapView.swift invalidateTraits)
outdoor (1)mapbox://styles/mapbox/outdoors-v12
satellite (2)mapbox://styles/mapbox/satellite-v9
streets (3)mapbox://styles/mapbox/streets-v12
standard (4)mapbox://styles/mapbox/standardAfter every style load and on appearance change: `setStyleImportConfigProperty(“basemap”, “lightPreset”, “day”

Sources: Map/Rendering/Compat/MapCompatMapView.swift:22-31, Map/Extensions/MapStyle+URL.swift, Map/Extensions/UITraitCollection+Style.swift, Map/MapView.swift.

Map-style-dependent colours: overlay label/glider colours resolve light on outdoor/streets, dark/white on satellite, dynamic on adaptive/standard (Map/Extensions/UIColor+Theme.swift:28-39, 93-106). Android must reproduce this: pick colour by (mapStyle, isSystemDark).

Picker thumbnails: debug launch arg -VultureProbeStyleThumbnail frames Terlet (52.0567, 5.9192) zoom 11, crop 800×500 (MapViewDataSource+Delegate.swift). Reuse the same images on Android.

1.3 Per-style-load setup (MapViewDataSource+Delegate.swift styleDidLoad)

Section titled “1.3 Per-style-load setup (MapViewDataSource+Delegate.swift styleDidLoad)”

On every style load (style-scoped things are lost on style switch):

  1. setProjection(globe) — globe when zoomed out, Mercator at flying zooms.
  2. Add file-airspace source + layers; waypoint + home-waypoint sources + layers.
  3. If OpenAIP airspace active: add OpenAIP tile source + layers, hide file-airspace layers.
  4. Re-render parsed files only if shapes are nil (otherwise re-add from held shapes).
  5. Re-apply highlighted airspace, weather overlay.
  6. Register style images: legend numerals "0","5",…,"100", photo (SF photo, template), photos (SF photo.on.rectangle.fill), question, direction, pill, bs_0..bs_2, generated significant-weather patterns.
  7. Fade the map in: alpha 0 → 1, 0.35 s easeInOut (MapViewDataSource.presentMap).

Map chrome: logo and attribution hidden (hideMapboxChrome; attribution is shown in About instead, telemetry opt-out in Settings). Compass margins (16,16). isPitchEnabled = false on every map. Background while loading: systemGroupedBackground. A loading pill (“Loading map…”) scales 0.7↔1 and fades, spring 0.5 s damping 0.7 velocity 0.4.

Android note: hiding the Mapbox logo/attribution is a ToS matter; on iOS it relies on restricted SPI. On Android logo.enabled=false / attribution.enabled=false are public but the Mapbox ToS still requires attribution somewhere — keep it in About as iOS does and confirm with the account terms.

BehaviourValueSource
Initial centreHomebase, else fallback 48.1049991, 8.7470783, zoom 9MapViewDataSource.swift, +Center.swift
Animated cameracamera.ease(duration: 0.3)MapCompatMapView.swift ease()
Fit to shapesmapboxMap.camera(for: coords, camera: CameraOptions(), coordinatesPadding: edgePadding, maxZoom: nil, offset: nil) then ease 0.3 sMapCompatMapView.swift showAnnotations
Edge paddingtop max(safeTop, 56) + 16, left safeLeft + 16, bottom max(safeBottom,0) + bottomPadding + 16, right safeRight + 16MapViewDataSource+Center.swift
bottomPaddingheight of open sheet (thermal/chat/airspace) + 8, else the graph panel heightFlightMapViewController.swift:506-520
Centre on point radiusbuilds a 5°-sampled circle polygon of radius r and fits it+Center.swift center(coordinate:radius:)
Radii usedglider 5000 m; fix/thermal 2000 m; pinned-follow 10000 mFlightMapViewDataSource.swift:141-143, +Center.swift:47-61
Airspace centrepadding 60 pt each side + sheet height at bottomMapViewDataSource+Airspace.swift:145-175
Zoom keys⌘+/⌘− = zoom ±1 animated+Center.swift zoom(by:)
Resize/rotationkeepVisibleRegion: if map still where a centring left it, re-frame framedShape into the new free area; else keep zoom and the point at the middle of the free area (Web-Mercator math in Packages/Map/.../Viewport/MapViewport+Reframing.swift)CLAUDE.md
Viewport handoffMapViewport{center, zoom, bearing, size}; zoom(fitting:) = zoom + log2(min(w'/w, h'/h))Packages/Map/.../Viewport/MapViewport.swift

Follow during replay (“Pinned”, pref VulturePinned, default off): no continuous follow. On each position update, if the selected glider’s screen point leaves the map rect inset by edgePadding, the camera frames a 10 km radius around it (animated). Scrubbing otherwise never moves the camera.

1.5 Gestures (MapViewDataSource+Gestures.swift)

Section titled “1.5 Gestures (MapViewDataSource+Gestures.swift)”
GestureAction
Pan / pinch / rotateMapbox defaults; pitch disabled
Long-press (1 finger)Airspace query at point (default)
TapAirspace query only while the airspace sheet is open (airspaceGestureType = .tap)
Two-finger long-press (+drag)Measure tool (line + end dots + rotated distance pill). Cancelled by any camera movement
Tap on thermal / photo (flight map)44×44 pt rect query: photos first (photos.layer, photos.labels), then thermals.fill
Tap on glider annotationSelect that flight (light impact haptic)
Right-click / scroll-wheel / trackpad zoomDesktop only — skip on Android (or map to mouse wheel on ChromeOS)
Left edge stripGuard so swipe-back wins over map pan — Android equivalent: predictive back gesture handles this automatically
Weather tap halvesLeft/right half steps SkySight period (task/in-flight maps only)

App recognisers are prioritised over Mapbox’s own tap/long-press recognisers. Android: use mapView.gestures.addOnMapLongClickListener / addOnMapClickListener and return true to consume; measure tool requires a custom two-pointer long-press detector on top of the MapView (Compose pointerInput overlay that passes through otherwise).

  • No offline map support (no OfflineManager, TileStore, style packs anywhere). Only the satellite-cloud raster tiles are pre-downloaded and served from a disk cache via an HTTP interceptor (§6.4).
  • No terrain / DEM / pitch in the map today. Docs/3d-map-investigation.md is a design (2D/3D toggle, chase/cockpit/free camera, lineZOffset elevated track, fill-extrusion wall-mode airspace, ModelLayer glider, 100 pt scrubber panel state, competition “Field” camera). Not implemented — Android should not build it in v1 but should keep the track data shaped so it can be added (per-segment features with an altitude property).
  • Race ranking (RaceProgress/RaceRanking in Flights, RaceRankingView in FlightScreens) is built on branch feature/race-ranking (2026-10-09) on the flat map; ranks by average speed since own start (min divisor 15 min). Out of scope here but it hooks into the same replay clock.

Suffix helpers (Map/Extensions/String+Identifiers.swift:129-152): .layer, .labels, .fill, .border, .border.dashed, .border.inner.

Flight map, bottom → top (all addLayer on top unless noted):

#Layer IDTypeSource
1airspace.fillfillairspace (GeoJSON)
2airspace.border.innerlineairspace
3airspace.border.dashedlineairspace
4airspace.borderlineairspace
5waypoints.layer, waypoints.labelscircle, symbolwaypoints
6homeWaypoint.layer, homeWaypoint.labelscircle, symbolhomeWaypoint
7openAIPAirspace.fill.tap, .fill, .fill.blue, .border.inner, .border.dashed, .border (only when OpenAIP active)fill/lineopenAIPAirspace (vector)
8airspace.highlight.fill, airspace.highlight.border (OpenAIP highlight only)fill, lineopenAIPAirspace
—Weather/SkySight layersrasterinserted below airspace.border.dashed (MGLStyle+Layers.swift:34-43)
9thermals.fillfillthermals
10task.border.dashed, task.fill, task.borderline, fill, linetask
—satellite.<unix>.layer (cloud frames)rasterinserted below the first layer whose id contains "flight"
11flight.<hash>.layer (one per loaded flight)lineflight.<hash>; each inserted below selectedFlight
12selectedFlight (layer id = source id)lineselectedFlight
13coloredFlight.layerlinecoloredFlight
14track.layer (distance optimisation)line dottedtrack
15optimizedTask.layer (best task attempt)line dottedoptimizedTask
16events.layer, events.labelscircle, symbolevents
17photos.fill, photos.layer, photos.labelscircle, symbolphotos (clustered)
18measure, measure.layer, measureMeta.labelsline, circle, symbolmeasure sources
19highlight.layer then highlight.border (added on demand, on top)linehighlight
topView annotations: gliders, airspace query markerAndroid ViewAnnotationManager

Task map: task.fill, task.border, task.layer, taskMetadata.labels, taskMetadata.layer, measure. In-flight map: flight.layer (.inflight style; features chunked per 200 fixes, ids track.<i>).

GeoJSON source options (MapCompatSources.swift):

Option setmaxzoombufferlineMetricstolerance
.track1664falsedefault 0.375
.task16default (128)falsedefault
.airspace1464falsedefault
coloredFlighttrack optsfalse0.0
photoscluster true, clusterRadius 35

3.1 Line layer factory (Map/Rendering/Layers/LineStyleLayer.swift)

Section titled “3.1 Line layer factory (Map/Rendering/Layers/LineStyleLayer.swift)”

All: line-join round, line-cap round (except .inflight). Width = ["interpolate", ["linear"], ["zoom"], 9, low, 14, high]. Data-driven colour key "color".

LineTypeWidth z9 → z14ColourOtherUsed for
.line0.8 → 1.5flight colour (constant)every loaded flight (thin)
.border1.6 → 3.0flightColor.darken(0.2) set after creationselected flight, plain mode
.color1.6 → 3.0["get","color"]altitude-coloured selected flight
.inflight2.4 → 4.5Flight 1 #4A6FA0join miter, cap butt, opacity 0.5live in-flight track
.highlight1.6 → 3.0#D5A359graph/leg highlight
.highlightBorder2.6 → 4.0#D5A359 darkened
.infrigement (sic)1.6 → 3.0#ED5656infringement highlight
.infrigementBorder2.6 → 4.0#ED5656 darkened (≈#BA2323)

Dotted layers (FlightTrackLineStyleLayer.swift:15-31): round join/cap, width interpolate linear zoom 8 → 1.6, 14 → 3.0 (stop at zoom 8), line-dasharray [1, 2], constant colour:

  • track.layer → brand Tint #DF774A (distance optimisation polyline, chosen by VultureTrackOptimization: out / outAndReturn / triangle / threeTurnpoints / fiveTurnpoints(=free), default fiveTurnpoints).
  • optimizedTask.layer → Success #519672 (best task attempt fixes).

darken(ratio: 0.2): subtract 0.2 from each of R,G,B (0…1), floor 0, keep alpha (Packages/DesignSystem/.../UIColor+Helpers.swift:34-46).

Only two modes exist. There is no vario, speed or phase colouring on the map.

ModePrefRendering
Plain (default)VultureShowColoredTrack = falseSource selectedFlight: one LineString of all fixes; colour flightColor.darken(), width 1.6→3.0
By altitudeVultureShowColoredTrack = trueSource coloredFlight (tolerance 0): per-segment LineString features each with color property; layer coloredFlight.layer uses ["get","color"]. Not a line-gradient

Switch: FlightMapViewDataSource+Highlight.swift:40-56. Any pref change re-runs select(flightViewModel:).

Altitude ramp (Map/Extensions/Flight+Polylines.swift:87-142) — index 0 = min pressure altitude, 10 = max:

idxRGB (0–1)Hex
01.00, 0.00, 0.00#FF0000
11.00, 0.39, 0.00#FF6300
21.00, 0.78, 0.00#FFC700
30.80, 1.00, 0.00#CCFF00
40.41, 1.00, 0.00#69FF00
50.01, 1.00, 0.00#03FF00
60.00, 1.00, 0.40#00FF66
70.00, 1.00, 0.79#00FFC9
80.00, 0.80, 1.00#00CCFF
90.00, 0.41, 1.00#0069FF
100.00, 0.00, 1.00#0000FF

Algorithm (replicate exactly):

ratio = 100 / (maxPressureAlt - minPressureAlt)
for each fix i>0:
pct = clamp(ratio * (fix.pressureAlt - minPressureAlt), 0, 100) // non-finite -> 0
step = round(pct * 2.0) // 0..200, 0.5 % buckets
segment prev->fix takes the step of the END fix
consecutive equal steps merge into one LineString; a new run starts with the previous fix (continuity)
runs need >= 2 coords
feature.properties.color = ramp.color(step / 2.0)
ramp.color(pct): p = clamp(pct,0,100)/100; x = p * (count-1); lerp RGBA between floor(x) and ceil(x)

Encoded as rgba(r,g,b,a) strings (sRGB 0–255 clamped) — MapCompatShapes.swift:283-317.

  • Map: no client-side Douglas–Peucker; relies on GeoJSON source tolerance (default 0.375 for all track sources, 0.0 for coloredFlight). No per-zoom logic. Android: same tolerance option on geoJsonSource.
  • Packages/TrackShape is only for thumbnails (list rows, widget, detail popup), not Mapbox. TrackSimplifier.simplify(coords, maxPointCount = 200): project to Web Mercator, normalise per-axis to unit square (zero-extent axis → 0.5), iterative Douglas–Peucker ranking (endpoints ∞, child rank clamped to parent), drop rank ≤ 0.001, keep top-N by rank, re-sort by index; perpendicular distance to the clamped segment. SimplifiedTrack.encodedData(): [version u8 = 1][aspect f32 LE][x,y f32 LE…] (persisted — keep the byte format if thumbnails sync). TrackShapeView: line 1.6, inset 8, round cap/join, start dot r = 1.6×lineWidth, aspect clamped [2/3, 1.5]. Task outline: closed loop, max 32 pts. Android: port to a pure Kotlin module + Compose Canvas.
  • Layer thermals.fill (ThermalFillStyleLayer.swift): FillLayer, minzoom 8, fill-color ["get","fillColor"], fill-opacity 0.4, no outline.
  • Geometry: circle around thermal.centerCoordinate, radius totalRadius (encloses all circling fixes), 0…360° in 5° steps (73 pts), spherical R = 6 371 000 m.
  • Colour: vario < 0 → Thermal Sink #F61D24; else lerp [Thermal Bad #F6B91E, Thermal Good #98F61E] at 100 * vario / maxVario.
  • Properties: fillColor, id (UUID string), isAttempt (altitudeGained ≤ 0).
  • Filter: thermals+attempts → none; attempts only ["==",["get","isAttempt"],true]; thermals only ["==",["get","isAttempt"],false]; both off → source data nil. Prefs VultureShowThermals, VultureShowThermalAttempts (default off).
  • Tap → thermal sheet (Pro) + scrubber jumps to thermal centre fix.

No glide/phase colouring on the map — phases only in the barogram.

3.5 Events (takeoff, release, landing, low/high, furthest, IGC events)

Section titled “3.5 Events (takeoff, release, landing, low/high, furthest, IGC events)”

No dedicated start/finish icons. Points for all fixesWithEvents, property name:

  • events.layer CircleLayer: minzoom 9, circle-color #FFFFFF, circle-stroke-color #5F5F5F, circle-radius and circle-stroke-width both interpolate linear zoom 9 → 1.6, 14 → 3.0.
  • events.labels SymbolLayer: minzoom 9, text-field ["get","name"], text-size 12, text-translate [8,0], text-justify left, text-anchor left, text-opacity interpolate linear zoom 7 → 0, 8 → 1, colour = waypoint label colour (secondaryLabel; light-resolved on outdoor/streets, white on satellite).
  • Event names: VultureTakeoff, VultureRelease, VultureLanding, VultureAltitudeGainLowPoint, VultureAltitudeGainHighPoint, VultureFurthestPointFromHome + IGC events (localised for display).

3.6 Glider marker during replay (Map/Rendering/Views/GliderAnnotationView.swift)

Section titled “3.6 Glider marker during replay (Map/Rendering/Views/GliderAnnotationView.swift)”
AspectValue
Hostv11 ViewAnnotation, annotatedFeature = .geometry(Point), allowOverlap = true; updated by reassigning the geometry
ImageTemplate PDF by sport: Glider 40×19 pt (scale 1.0); Parapente 41×15 × 0.7; Delta 40×15 × 0.7. Flight’s detected sport overrides pref VultureAnnotationStyle
Tintlabel colour, resolved light (black) on outdoor/streets, white on satellite, dynamic otherwise
Rotationradians(fix.bearing − map.bearing), no animation; re-applied on camera change only if bearing changed
Label pillRegistration, caption1, insets 2/6/2/6, corner = h/2, 10 pt above image, centred
Selectedimage alpha 1.0; pill bg = flight colour, text = inverse colour; brought to front
Unselectedimage alpha 0.5; pill bg clear, text = flight colour
Inverse colourbrightness=(r*299+g*587+b*114)/1000; < 0.6 → systemGray6, else label
LifetimeExists only while the flight has a fix at the current time
Taplight impact haptic, select flight

Android: ViewAnnotationManager + AndroidView/Compose ViewAnnotation works for ≤ ~10 gliders. For competition days (30–50 flights) prefer a SymbolLayer with icon-rotate ["get","bearing"], icon-rotation-alignment map, icon-image SDF glider (tinted via icon-color), and a text pill — fed by one GeoJSON source updated per tick. This also matches the 3D plan (ModelLayer later). Decide early; a mixed approach (symbols for others, view annotation for selected) is acceptable.

3.7 Task / turnpoint / observation-zone drawing

Section titled “3.7 Task / turnpoint / observation-zone drawing”

Flight map (FlightMapViewDataSource+Layers.swift:79-97), source task:

LayerStyle
task.border.dashedround; width interpolate linear zoom 8 → 1.6, 14 → 3.0; dash [1,2]; line-color ["get","lineColorKey"]
task.fillminzoom 6; fill-color ["get","fillColor"]; fill-opacity 0.2
task.borderminzoom 6; round; line-color ["get","borderColor"]; line-opacity 0.8; width 9 → 1.5, 14 → 3.0

Task map adds task.layer (solid, width 2.0 constant), taskMetadata.labels (text-field ["get","text"], colour #5C7EA1, size 14, text-rotate ["get","rotation"], rotation-alignment map, anchor bottom, line-height 2.3, allow overlap) and taskMetadata.layer (icon-image ["get","icon"] = direction, icon-rotate ["get","iconRotation"], icon-color #5C7EA1, allow overlap).

Features (Map/Extensions/Task+Features.swift, Task/Task+Feature.swift, Waypoint/Waypoint+Features.swift). Property keys: literal "lineColorKey", "fillColor", "borderColor".

  • Leg line: turnpoints with a great-circle midpoint inserted before each point; lineColorKey = #5C7EA1, fill/border clear.
  • Leg label (task map): midpoint point; text = distance 0 decimals + " FAI" if count == 4 && leg/total >= 0.28; rotation = (bearing>180 ? bearing-180 : bearing) - 90; iconRotation = bearing.
  • Zone with 2 coords (line) → LineString, border #ED5656, others clear.
  • Zone polygon → fill #D5A359, border #ED5656 if showBorder (task map) else clear (flight map).

Zone geometry (fields R1, A1, R2, A2, A12, isLine, type; metres/degrees) — Packages/Map/.../CLLocationCoordinate2D+ObservationZone.swift, already tested in WaypointsTests (port those tests):

CaseGeometry
isLine2 points at R1 on bearing ± 90°
A1>0, A2>0, R2>0, A1 ≥ 180circle R1
…, A2 ≥ 180 (keyhole)arc R2 end → start (start = bearing − A1, end = start + 2·A1), repeat last, arc R1 start → end, close
…, elsesector arc at R2: bearing − A2 … + 2·A2
simple, A1 ≥ 180cylinder R1
simple, else[centre] + arc(R1, bearing − A1 → +2·A1) + [centre]

Arc sampling: count = ceil(range/5), step = range/count; from == to → 360°; to < from → +360. Spherical destination with R = 6 371 000 m. Bisector bearing (points away from the course): fixed with A12>0 → A12+180; next → bearing(next→wp); previous → bearing(prev→wp); start → bearing(start→wp); symmetric with both neighbours: bp,bn = Int bearings wp→prev/next, mid = (max−min)/2 + min, result mid if max−min ≥ 180 else mid−180.

AspectBehaviour
Palette20 colours Flight 1…20 (DesignSystem, display-p3, no dark variant): #4A6FA0 #5B5B99 #6E528F #884C89 #9C4880 #A64F66 #A65252 #AA6548 #AA7648 #AA8842 #AA9842 #A2A148 #88A14C #689A50 #50935E #469378 #46938A #469393 #46869E #467A9E
AssignmentRound-robin shared index 0…19 (FlightMapViewModel.swift:603-610); if all flights have a competition rank → re-sort by rank and recolour all[i % 20]
PrimaryFirst added (or selecting) when none selected; tap glider / panel card re-selects
Selected-only overlaysthick/coloured track, task, optimisation, events, thermals, photos, graph extras
Othersthin .line in their colour + glider at 0.5 alpha
Labelsglider pill with registration
Time syncEach flight has timeOffset (s). Default 0 = real time of day. “Align task start times”: offset = earliestTaskStart − thisTaskStart (best task attempt first fix). Readout becomes T+hh:mm:ss. Other flights at time t: fix(for: t − offset) binary search → exact or next later fix; nil outside range
Gate>1 flight = Pro (ProFeature.severalFlights)
DownloadsSoaring Spot / Charron: 3 concurrent, day task applied

Source highlight, layers highlight.layer (.highlight or .infrigement) then highlight.border added on top — note the wider darker border ends up above the line (looks unintended; on Android put the border below unless pixel parity is required). Infringement uses MultiLineString. Camera fits the highlight afterwards.

Clustered GeoJSON (clusterRadius 35), photos.fill circle + photos.layer icon (photo / photos) + photos.labels (count). Tap opens the photo pager. Android equivalent: geoJsonSource { cluster(true); clusterRadius(35) } with point_count expressions.


Custom UIKit + CoreGraphics, no chart library (no Swift Charts/DGCharts). Paths built off-main, rasterised into bitmaps covering only the visible window; axes/handle/badges via CAShapeLayer/CATextLayer.

TypeFile (Map/Flight/Panel/…)Role
PanelContentViewController (+Layout/+Graph/+Playback)Panel/panel content: dragger, flights row, graph, controls
PanelGraphViewControllerPanel/Graph/binds view model streams
PanelGraphView (+Gestures, +Accessibility)Panel/Graph/Y-axis, horizontal UIScrollView (zoom), handle, one data view per flight
GraphDataSourcePanel/Graph/GraphDataSource.swiftranges, intervals, unit conversion, time↔progress, fix lookup, offsets
GraphFlightDataViewPanel/Graph/Views/per-flight paths, dots, badges, AAT, pills
GraphBitmapRendererPanel/Graph/Views/rasterises 3 bitmaps: under, over, highlight
GraphMetadataViewY grid + labels
GraphHandleView, GraphHandleMetadataView, GraphValueViewscrubber, dot, readout bubble

X domain: progress 0…100 over [min startRecordingTime, max endRecordingTime] of all loaded flights (GraphDataSource.swift:406-410, 515-522).

GraphDataUnit (Packages/Preferences/.../GraphDataUnit.swift), persisted Defaults[.graphDataUnit], default pressureAltitude: pressureAltitude, gpsAltitude, groundspeed, vario, windSpeed, averageSpeed, averageTaskSpeed, totalEnergy, trueAirspeed, outsideAirTemperature, gForce, verticalAcceleration, verticalAirMovement, attitudeRollAngle, attitudePitchAngle, electricalVolts, electricalCurrent, fuelLevel, fuelFlow, temperatureExhaustGas, temperatureMotor, temperatureController, temperatureJetPipe, batteryLeft, flapPosition, engineRPM, meansOfPropulsionLevel, heartRate, bloodOxygenLevel.

Menu (FlightMapViewModel+GraphMenu.swift:40-73): inline [averageTaskSpeed (if task attempted), averageSpeed]; [windSpeed, vario, groundspeed]; an “Altitude” group [extension extras e.g. totalEnergy…, gpsAltitude, pressureAltitude]; one group per IGC-extension category present. The groups are titled and inline, not submenus (UIMenu(title:options: .displayInline), since 4b24851f4, 2026-10-16; Android the same since build 4). GPS altitude skips 2D fixes; wind uses last sample ≤ t. Pressure altitude with terrain shows readout as AGL "%0.f %@ AGL".

SeriesConditionColourStyleBitmap
Main trace, selectedalwaysflight colourstroke 2.0, α 1.0over (top)
Main trace, unselectedalwayssecondaryLabelstroke 1.0, α 0.3over
Terrain (ground elevation)selected, elevation available, altitude unitfill #C3AC93 / dark #644E35; stroke #AD8F6C / dark #8A6C49closed polygon to bottom, line 1.0over (first)
No-terrain baselineelevation nil#AD8F6C/#8A6C491 pt line at bottom
GPS altitude backdropselected, !showAltitudeGainedtertiaryLabelfill α 0.3over
Thermalsselected, VultureShowThermals#69AB8Bfull-height rect entry→exit, α 0.3under
Thermal attemptsselected, VultureShowThermalAttempts#FF4139full-height rect, α 0.3under
Engine (ENL/MOP)selected#F6B91Efilled curve, α 0.3under
Highlight (leg/infringement)selected, highlight setleg #D5A359 / infringement #ED5656stroke 2.5highlight (top)

Engine curve: drawn where fix.engineLevel > 200 + flight.engineLevelThreshold (else at bottom), y = h − engineLevel/1000 · h — absolute 0–1000 scale independent of the Y axis. engineLevel = max(ENL, MOP); engineLevelThreshold = 200 + ΣENL / fixCount (so effective threshold = 400 + mean ENL).

Terrain source: not Mapbox — SRTM 3″ .hgt tiles (1201×1201 Int16, name like N50E005) from https://vulture.aero/api/elevations/<tile> (Authorization header; token in Packages/Flights/.../ElevationServiceConfiguration.swift), cached Caches/elevations/<tile>.hgt, 10 s timeout; nearest-sample lookup (row = Int(frac(-lat)·1201), col = Int(frac(lon)·1201)), stored per fix as fix.elevation; flight.isElevationAvailable.

Point markers (selected, only when altitude unit and pref showAltitudeGained): circle r 6, stroke 2, fill same colour α 0.2 — Lowest #F61D24, Highest #4C9172, Release systemGray, PEV #D5A359, Furthest #5C7EA1. Event pills (white caption2 text, capsule height 16, width 12+text+8, with an even-odd hole r 6 at the dot) fade in 0.2 s when the handle/hover x is within 10 pt; flip left if overflowing or overlapping the readout.

Task badges above the plot (36 pt top inset): DotTextLayer circles “S”, “1…n”, “F” (completed) / “X”; size = caption2 + 4; task-attempt badges + dashed vertical lines #519672 width 1.5 dash [3,4]; distance-optimisation badges/lines #DF774A width 1.5 dash [4,3]; lines sit below the over bitmap. AAT minimum time: horizontal line at y=0 from task start for the duration, #D5A359 width 2, label "AAT: 1h30m" caption2. Prefs: showTask && showOptimizedTask, showTrack, showTask && showAssignedTaskTime.

No airspace vertical profile in the graph (only the red infringement highlight stroke). No glide shading (unshaded = glide).

  • Y axis column 54 pt (label 50 + gap 4); labels "\(value) \(unit)" caption2, right-aligned, secondaryLabel; grid lines tertiaryLabel 1 pt full width; label thinning stride 1,2,4… until spacing ≥ 22 pt counted from the bottom (grid lines stay).
  • Mapping: yRatio = h / (max − min + valueOffset), y = h − (v − min)·yRatio − valueOffset·yRatio; valueOffset = −rawMin only for altitude when raw min < 0.
  • Interval tables (GraphDataSource.swift:624-709), small (phone landscape) / large: altitude 1000 m / 3000 ft vs 500 m / 1500 ft; speeds 100 / 50; vario/TE 10 / 5; wind 20 / 10; OAT 20/10; g 2/1; flaps 1/1; RPM/MOP 1000/500; HR 10/40; SpO2 5/20 (some pairs look inverted, see §9). Density: range/interval > 14 → ×4; > 10 → ×2.
  • Altitude with terrain & min>0: if min < interval → interval 100; min = minV − minV % interval, max = maxV + (interval − maxV % interval); recheck >14 → ((range/7 + 99)/100)·100, >10 → ×2. Without terrain: min = max(0, …).
  • Other units: min = minV − (interval + minV % interval), max as above. Clamps: vario [max(−10,floor min), min(14, ceil max)]; TE min ≤ −5; speeds ≥ 0; battery ≤ 99; HR 0–200; SpO2 0–100.
  • Readout formats: "%0.f %@" altitude/speed/wind, "%0.2f %@" vario/TE, "%0.1f %@" g, "%0.f °%@" temperature.
  • No X/time axis is drawn. Time only in the readout: local takeoff-timezone HH:mm:ss, or T+hh:mm:ss when task-aligned.
ItemValue
Zoomhorizontal only, scale 1.0…10.0; content width = width·scale − 16; pinch anchored at finger; wheel scale *= 1 + dy·0.01
Pannative scroll when zoomed, no indicator
Reset zoom buttonvisible when scale ≠ 1, fades 0.15 s
Handlefull-height line #DF774A 2 pt; tap jumps; 1-finger pan drags; when zoomed a pan only grabs the handle if it starts within ±40 pt (80 pt hit rect), else scrolls
Clamp<0 → 1; > width → width−1 and emits reachedEnd (pauses playback)
Readoutdot r 6 #DF774A on the selected trace; bubble bg #DF774A, radius 8, padding 4, title footnote white, subtitle caption2 white α 0.8; top 4, at x+8 or flipped to x−8
Hapticsselection tick whenever the handle moves > 5 progress units (5 % of timeline)
KeyboardCtrl+←/→ = 2 pt; ←/→ = ±1 s flight time; Space = play/pause
Panel dragdisabled while the handle is active

Performance model: one sample per point of content width (for x in 0..<width: progress → fix by exact key or binary search), Y truncated to Int; paths rebuilt off-main with a generation counter; during pinch only an X-scale transform of existing paths, full rebuild at gesture end; bitmap window = viewport ±0.5 viewport width (or whole content if ≤ 2× viewport), re-render when the viewport leaves the window inset by 0.25 width; first appearance fade 0.35 s. Accessibility audio-graph resamples to 60 points.

Graph → map: handle progress → [flight: fix] for every flight (with offsets) + time → FlightMapViewModel.updatePositions → move all gliders, pinned-follow check, satellite cloud frame update. Camera never follows a scrub otherwise.

Map/app → graph: timeSubject → positionHandle(time) (hidden when out of range). goTo(fix, shouldCenter) is called from: detail popup “centre fix”, thermal tap/selection (thermal centre fix), thermal-detail scrub, next/previous/nearest thermal, phase pill. Task legs/infringements → highlight(fixes:) on both graph and map.

Tapping the track line on the map does NOT move the scrubber (only thermals/photos are hit-tested). Android may add this as an enhancement but it is not parity.

Display-link driven (Android: withFrameNanos loop / Choreographer): acc += realElapsed · multiplier; consume whole seconds → go(to: t + n); pause at endRecordingTime, on background, on handle reaching end. Speeds ×10, ×25 (default), ×50, ×100, ×200 (Defaults[.playSpeed]). Play button: stopped “Play” play.circle, tap opens speed menu with subtitles “Duration: %d min %02d sec” (= flight duration / multiplier); playing “Stop (25x)” stop.circle, tap pauses, menu changes speed live.

Custom floating glass panel (FlightMapView.swift, Support/Views/FloatingPanel.swift): margin 8 from window edges, corner radius container-concentric min 16; height = 40 % of map height (45 % in compact vertical / phone landscape); states .all (0) and .none (2) persisted Defaults[.panelState]; spring 0.5 s damping 0.7 velocity 0.4; release decides by |velocity| > 500 else midpoint; round “show panel” button when hidden. Content: dragger (+3), flights row (h 70; 35 phone landscape; 30 with one flight, no scroll), graph container (inset 8, top corners 16, overlaps controls by 8), control row h 34 (play, reset zoom icon arrow.down.right.and.arrow.up.left, unit picker ≤ 50 % width); control row hidden in compact height. Graph insets: top 36 (badges), bottom 16, leading/trailing 8 + 54 pt label column. Every card over the map takes its side insets from FloatingPanel.insets(in:).

Android: BottomSheetScaffold is a poor fit (it is not a floating card with margins). Use a custom Box + AnchoredDraggableState with two anchors (shown/hidden), Material 3 Expressive surface (surfaceContainer with tonal elevation, 16–28 dp shape). Feed its height into map padding.


SourceDetailsWhere
OpenAIP vector tiles (default; forced without Pro)https://api.tiles.openaip.net/api/data/openaip/{z}/{x}/{y}.pbf?apiKey=<KEY>, source minzoom 5, maxzoom 14, source-layer airspaces. API key hard-coded inline (Map/MapViewDataSource+Airspace.swift:240-242)online only
User files (Pro “Custom Airspace”)OpenAir .txt, OpenAIP XML .aip, SeeYou .cub; imported from Files or downloaded from catalogueCore Data File
Cataloguehttps://vulture.aero/api/ + xcsoar/airspace, xcsoar/waypoints, legacy openaip/airspace; JSON {"records":[{name, uri, type, area?, update "yyyy-MM-dd"}]}; Authorization JWT (location Packages/Backend/.../VultureServiceConfiguration.swift:41-47)Packages/RemoteContent
File downloadplain GET of record.uri, no authFileDownloadManager.swift
UpdatescheckUpdates() at app start: refetch catalogue, flag .xcsoar files where record.update > file.contentUpdatedAt (match by name); auto re-download if VultureAutomaticUpdateEnabled, else promptFileDownloadManager.swift:94-208

Which one shows: showsOpenAIP = VultureOpenAIPAirspaceEnabled || !Pro(customAirspace). Only one set is drawn at a time; file geometry is held but not pushed while hidden.

Persistence (File entity): version, importedAt, filename, type (0 waypoints, 1 airspace), itemCount, content (text; CUB as Base64), isVisible, source (0 user, 1 xcsoar, 2 openaip), contentUpdatedAt, contentArea. Parsed data is not cached: every visible file is re-parsed on launch/change in background operations; renders coalesced 50 ms.

OpenAIP tile features become domain objects via a rendered-feature query of openAIPAirspace.fill.tap on every map idle (only if bounds changed) → merged into visibleAirspaces (dedupe by id, file wins) → feeds infringement detection.

Encoding fallbacks: utf8, ascii, isoLatin1, isoLatin2, macOSRoman. Detection: extension (txt/aip/cub) or content (<OPENAIP in first 4096 chars; a line starting AC ; Base64 bytes 1..<4 == CUB).

OpenAir (Decoder/AirspaceOpenAirDecoder.swift): split on LF/CR, trim, skip empty and * comments; command = text before first whitespace.

CmdHandling
ACfinish previous; new airspace; reset arc centre nil, direction clockwise
AN / AL / AHname / floor / ceiling
DPadd point
V X= / V D=arc centre / direction (+ cw, - ccw)
DA r,a1,a2arc radius NM, a1→a2 in current direction
DB c1,c2arc around X, radius = average of both distances
DC rfull circle NM
AF / AXfrequency / transponder
AY AT SP SB TC TO AG AAignored

Coordinates: DD:MM:SS N, DD:MM.MMM, decimal; flexible whitespace/case. Arcs sampled every ~5° (ceil(range/5)), R = 6 371 000 m. Valid only with name+floor+ceiling; ring closed. Frequency = AF else regex 1\d{2}\.\d{1,3} in name.

Class (AC): A,B,C,E,F,G → same; D and Q → D; RESTRICTED/R → restricted; DANGER → danger; PROHIBITED/P/GAW → prohibited; CTR; TMA; WAVE/W → waveWindow; GLIDING/GSEC → gliding; TMZ; RMZ; FIR; else nil.

Altitude (AL/AH), in order: lowercase, strip msl; unl/unlimited → unlimited; exact gnd/sfc → ground; extract integer (none → invalid); contains fl → FL(n); contains m → metres else feet; contains agl → AGL else AMSL.

OpenAIP XML: AIRSPACES/ASP@CATEGORY (A–G, RESTRICTED, DANGER, PROHIBITED, CTR, TMA, WAVE, GLIDING, TMZ, RMZ, FIR, UIR→FIR), NAME, ALTLIMIT_BOTTOM/TOP@REFERENCE + ALT@UNIT (M = metres else ft; STD → FL; GND 0 → ground; GND → AGL; MSL → AMSL), GEOMETRY/POLYGON "lon lat, …".

CUB binary: little-endian header (ident 0x425543C2, title 112 B, …, sizeOfItem, sizeOfPoint, hdrItems, maxPoints, bounds f32, loLaScale f32, headerOffset, dataOffset, alignment); items ≤42 B (bounds f32, style u8 → class (b&0x70)>>4, cubStyle (b&0x0F)|((b&0x80)>>3); altStyle u8 low=min high=max: 0 unknown,1 agl,2 msl,3 fl,4 unl,5 notam; minAlt/maxAlt i16 metres; pointOffset i32 …); points (flag u8, x i16, y i16): 0x81 move origin by (x,y)·scale, 0x01 add point at origin+(x,y)·scale (radians→degrees), 0x40 name (len = flag&0x3F) optionally followed by frequency (flag&0xC0==0xC0, u32). Altitudes metres → ft rounded to 10; AGL 0 → ground; FL = ft/100. Full details: Packages/Airspace/.../Decoder/AirspaceCUBDecoder.swift, Models/CUB/*.

OpenAIP tile attributes: name (required), type, icao_class, lower_/upper_limit_value|unit|reference_datum, source_id. Class: icao_class a–g wins; else type: ctr→CTR; tma/cta/atz/lta/awy→TMA; restricted/tra/tsa→restricted; danger; prohibited; rmz; tmz; gliding_sector/aerial_sporting_recreational→gliding; fir/fis_sector→FIR. Altitude: unit FL or datum STD → FL; datum GND → ground if 0 else AGL ft; else AMSL ft; missing floor → ground, missing ceiling → unlimited. Id = djb2-style hash of source_id ?? name.

Airspace { id: UInt64, name, class: Class?, floor, ceiling: Altitude, coordinates (closed ring), radioFrequency?, transponderCode?, openAIPSourceId? }.

  • File id: uniqueHash(name + class.rawValue + filename + coords "lon:lat" joined "-"), hash h = 5381; for byte in utf8: h = 127 * (h & 0x00ffffffffffffff) + byte (UInt64 wrapping). Persisted (hidden sectors) — must match byte-exactly incl. Swift Double.description formatting of coordinates (Kotlin Double.toString differs for some values → write a Swift-compatible formatter + golden tests).
  • Class raw: a “A” … g “G”, restricted “R”, danger “Q”, prohibited “P”, gliderProhibited “GP”, ctr “CTR”, tma “TMA”, waveWindow “W”, gliding “GLIDING”, TMZ, RMZ, FIR.
  • Altitude: ground | agl(len) | amsl(len) | fl(Int) | unlimited. Ordering by feet (FL×100, unlimited ∞), AGL ranks higher on ties. valueInMeters: ground 0; FL Int(level·100 / 3.2808399); unlimited Int.max.
  • Display: “Ground”, ”%@ AGL”, ”%@ AMSL”, “FL %d”, “UNL”; class labels “Class A”… “CTR”, “TMA”, “Restricted”, “Danger”, “Prohibited”, “Glider prohibited”, “Wave window”, “Gliding”, “TMZ”, “RMZ”, “FIR”, “Unknown”.

There is no class filter and no altitude-ceiling filter. Settings has two toggles:

UIKeyDefault
“Custom Airspace” (inverse of OpenAIP)VultureOpenAIPAirspaceEnabledfalse (but OpenAIP forced without Pro)
“Show hidden sectors”VultureShowHiddenAirspacesfalse

Per-sector hiding (file airspaces only): JSON in Documents <yyyyMMdd>-hidden-airspace.cache, [{"id":UInt64,"type":"permanent"|"today"}] (legacy [UInt64] = permanent); old day files deleted carrying permanent entries forward; “today” expires on date change (checked on scene activation). Hidden airspaces are filtered before render; tap lookup of hidden ones by point-in-polygon. Screens: hidden list (Packages/AirspaceScreens/.../HiddenAirspaces), per-file sectors with search (name or class raw).

Palette (Packages/Theme, single appearance): Blue #5C7EA1 (p3), Red #ED5656, Green #4C9172, Gray #5F5F5F, Light Gray #A9A9A9, Highlight #D5A359 (p3), Infringement #ED5656.

Per class (Packages/Theme/.../UIColor+Airspace.swift:44-93, Airspace.Class+Borders.swift, Airspace+Crossing.swift):

ClassFillBorderDashedInnerisRestricted
A–red–✓✓
B, C–blue–✓✓
D–blue––✓
E–green✓✓–
F, G–green–✓–
CTRblueblue✓–✓
TMA–blue––✓
Restrictedredred–✓✓
Danger–red✓✓✓
Prohibited, GliderProhibitedredred–✓✓
WaveWindow–blue–✓✓
Gliding–green✓✓✓
TMZ, RMZ–gray✓✓✓
FIR–light gray–––
nilbluered✓––

File-airspace layers (source airspace; feature props id (decimal string), borderColor, fillColor, hasDashedBorder, hasInnerBorder; GeoJSON feature id = id & 0x1F_FFFF_FFFF_FFFF for feature-state):

LayerPaintFilter
airspace.fillfill-color = case(feature-state highlighted, #D5A359, get fillColor); fill-opacity = case(highlighted, 0.4, 0.1)–
airspace.border.innerwidth interpolate linear zoom 5→0, 6→4, 8→4, 9→10, 14→20; line-offset 5→0, 6→2, 8→2, 9→5, 14→10; colour get borderColor; opacity 0.1hasInnerBorder == true
airspace.border.dashedround; width 6→0, 7→1, 8→1, 9→2, 14→4; dash [4,3]hasDashedBorder == true
airspace.bordersame width, solidhasDashedBorder == false

OpenAIP tile layers (source-layer airspaces). Border colour match(get type): restricted/tra/tsa/danger/prohibited → red; ctr/tma/cta/atz/lta/awy → blue; rmz/tmz → gray; gliding_sector/aerial_sporting_recreational → green; fir/fis_sector → light gray; default red. dashedTypes = ctr, danger, gliding_sector, aerial_sporting_recreational, rmz, tmz. innerBorderTypes = restricted, tra, tsa, danger, prohibited, gliding_sector, aerial_sporting_recreational, rmz, tmz.

LayerPaintFilter
openAIPAirspace.fill.taptransparent, opacity 0.01 (hit target)–
openAIPAirspace.fillred, 0.1type in [restricted, tra, tsa, prohibited]
openAIPAirspace.fill.blueblue, 0.1type == ctr
openAIPAirspace.border.innerround; width 6→0, 7→2, 8→4, 9→6, 14→12; offset 6→0, 7→-1, 8→-2, 9→-3, 14→-6; opacity 0.1innerBorderTypes
openAIPAirspace.border.dashedround; width 6→0, 7→1, 8→1, 9→2, 14→4; dash [4,3]dashedTypes
openAIPAirspace.bordersame width, solidnot dashedTypes

(Inner-border ramp and offset sign differ between file and tile layers — keep as is.) No airspace labels on the map.

  • Query: long-press (tap while sheet open), right-click; layers openAIPAirspace.fill.tap or airspace.fill. Marker: 12 pt dot (label colour, 1.5 pt background stroke) with 40 pt pulse scaling 1→3, opacity 0.7→0, 1.5 s easeOut, repeat.
  • Highlight: file → feature-state highlighted=true; OpenAIP → extra layers filtered name == <name>: airspace.highlight.fill (#D5A359, 0.4) and airspace.highlight.border (#D5A359, width 4, opacity 0.6). Centre: OpenAIP unions all visible tile features with the same name.
  • Sheet (Map/Airspace/*, Features/Airspace/Airspace/AirspaceSheetViewController.swift, Packages/AirspaceScreens/.../AirspaceDetail/*): items = tapped + (hidden at point if pref) sorted by floor descending, initial selection = last (lowest). Fields: name (+ “XPDR code”), class label, floor, ceiling, frequency, swatch, hide (permanent / today) or show; Center action. Subtitle "<class> · <floor> – <ceiling>".
  • Compact layout = vertical cross-section profile (AirspaceDetailProfileView/Layout): ordinal axis (one step per distinct limit), lane packing by interval partitioning, axis 92, lane spacing 8, min segment 26, insets 8/14, corner 8, border 2 (1.5 hidden), dash [4,3] if dashed/hidden, tint class colour 0.15, hidden α 0.55; glider altitude marker = dashed brand line + pill (hollow α 0.45 when outside), position badge “INSIDE” (red) or gap chips (”%@ below the floor”/”%@ above the ceiling”). Regular width: profile 55 % + AirspaceDetailCard (CEILING/FLOOR/FREQUENCY, Center, Hide menu). Accessibility sizes: list with swipe actions.
  • Position logic: outside polygon → outside; floor < alt && ceiling > alt → inside; floor > alt → belowFloor(diff); else aboveCeiling(diff). Comparisons in each bound’s frame (AGL vs alt.agl, AMSL vs msl, FL vs fl). Flight-analysis altitude from the scrubbed fix: agl = pressureAlt − elevation, fl = Int(pressureAlt·3.2808399/100), msl = pressureAlt.
  • Swatch AirspaceIndicator: circle 20 pt (16 in summary), fill 10 %, border 2 solid or dashed [4,3], optional 4 pt inner ring 10 % inset 2.

5.7 Infringement detection (recorded flight)

Section titled “5.7 Infringement detection (recorded flight)”

Map/Flight/Flight/FlightViewModel.swift:552-653, re-run on every visibleAirspaces emission, background queue:

  1. Keep airspaces with class.isRestricted and floor.valueInMeters <= flight.maximumPressureAltitude; drop polygons < 2 coords; precompute bbox + floor/ceiling metres.
  2. Iterate flight.phases; only .cruise(leg) and .thermal.
  3. Candidates per phase: floor ≤ phase max alt and bbox intersects.
  4. Per fix: floor <= pressureAlt && ceiling >= pressureAlt && pointInPolygon(coord) (planar even-odd ray cast, Packages/Flights/.../CLLocationCoordinate2D+Calculations.swift:12-29). Integer metres, no tolerance, AGL limits compared raw against pressure altitude (no terrain), FL via 3.2808399, unlimited Int.max.
  5. Contiguous fix index ranges → [[Fix]] per airspace. AirspaceInfringement { airspace, fixes }, duration = Σ(last−first rawTime), single-fix group = 1 s.
  6. With OpenAIP only on-screen airspace is known → footer “Infringements are shown for airspace visible on the map. Pan or zoom the map to check additional areas.”
  7. UI: flight detail row “Airspace usage” (“Processing…” → “N Infringements”), list (indicator + class + clock duration; name caption), select → map highlight (MultiLineString #ED5656 + darker border) and graph highlight; actions “center infringements”, “clear”.

No along-track vertical airspace profile and no live in-flight warning exist.


6.1 Waypoints (Packages/Waypoints, Map/MapViewDataSource+Waypoints.swift)

Section titled “6.1 Waypoints (Packages/Waypoints, Map/MapViewDataSource+Waypoints.swift)”

CUP: split on -----Related Tasks-----; header normalised to name,code,country,lat,lon,elev,style,rwdir,rwlen,freq,desc (or prepended); lat DDMM.mmmN|S (≥9 chars), lon DDDMM.mmmE|W (≥10 chars); styles 0 unknown … 17 intersection (2 grass airfield, 3 outlanding, 4 gliding airfield, 5 solid airfield, …). Tasks: name + waypoint names, Options/ObsZone rows apply to previous task; takeoff/landing stripped. Waypoints from all files merged order-preserving unique.

Rendering (no clustering): circle <src>.layer minzoom 5, radius interpolate linear zoom 6→0, 7→r7, 8→r8; symbol <src>.labels minzoom 7, text-field get name, text-translate [8,0], left justify/anchor, text-opacity 8→0, 9→1.

TypeCircleText colourSizer7r8
waypoint#D5A359secondaryLabel (light on outdoor/streets, white on satellite)1023
home#DF774A#DF774A1234
event#5F5F5Fas waypoint1023

(Flight-map events use their own layers, §3.5.)

Bundled Packages/Airports/.../airports.json (4.8 MB, 47 991 entries; keys a name, b lat, c lon, d country, e ICAO, f [{s station, r MHz}], g elevation ft). Not drawn on the map — used for nearest airport within 3000 m (takeoff/landing naming), METAR, nearest list. Android: ship as asset, load lazily; consider a spatial index (grid) instead of linear scan.

SRTM tiles from the Vulture API (§4.3) for barogram terrain and AGL. No Mapbox DEM.

6.4 Satellite cloud overlay (flight map, Pro)

Section titled “6.4 Satellite cloud overlay (flight map, Pro)”

FlightMapViewDataSource+Satellite.swift, Packages/Flights/.../Satellite/*. Tiles https://satellite.skysight.io/tiles/{z}/{x}/{y}@2x?date=<formatted>&mtg=true, 10-minute frames. One raster source per frame (tileSize 512, maxzoom 7), layer satellite.<unix>.layer with raster-opacity 0, raster-fade-duration 0, inserted below the lowest flight* layer. Sliding window: shown frame 0.5, ±2 neighbours 0.01 (keep decoded), others 0; cold jump keeps previous frame until map idle (timeout 1.0 s). Scrub → fix rawTime (UTC sec since midnight) + flight date → floor to 10 min. Tiles pre-downloaded and served from disk by an HTTP interceptor (also adds the SkySight Origin header). Android: HttpServiceFactory.setHttpServiceInterceptor (Mapbox common) or a custom HttpServiceInterface to serve cached tiles and add headers.

SkySight forecast overlays are task/in-flight map only (out of scope for this doc; see Docs/skysight-*).

Two-finger long-press: line measure (glider colour, width 3, round, opacity transition 0.35 s), end circles r 4, white stroke 2; distance label symbol with pill icon (icon-text-fit both, padding [4,12,4,12]), white text 13, rotated along line (text-rotation-alignment map). Shown/hidden via opacity only.


Display-p3 values are given as p3 component×255; convert to sRGB for Android (Color(0.876f,0.467f,0.291f, colorSpace = ColorSpaces.DisplayP3) in Compose keeps them exact on wide-gamut screens; for Mapbox expressions convert to sRGB hex).

TokenLightDarkSpace
Brand / Tint / AccentColor / Handle#DF774Asamep3
Success (task attempt)#519672samep3
Baro Highlight / AAT / Task waypoint / Airspace Highlight#D5A359samep3
Task leg / Turnpoint / Airspace Blue#5C7EA1samep3
Task zone / Airspace Red / Infringement / Destructive#ED5656samesRGB
Airspace Green#4C9172samesRGB
Airspace Gray / Event#5F5F5FsamesRGB
Airspace Light Gray#A9A9A9samesRGB
Elevation fill#C3AC93#644E35sRGB
Elevation border#AD8F6C#8A6C49sRGB
Engine / Thermal Bad#F6B91EsamesRGB
Thermal Good#98F61EsamesRGB
Thermal Sink / Low#F61D24samesRGB
Baro Thermal#69AB8Bsamep3
Baro Thermal Attempt#FF4139samep3
Flight 1–20see §3.8samep3
Baro lines / altitude backdropiOS tertiaryLabel → M3 outlineVariant
Baro labels / unselected traceiOS secondaryLabel → M3 onSurfaceVariant
Field colours (Theme Color.*Field)blue #32769E, yellow #EBAC57, red #ED5656, green #0B8E65, magenta #B052C8

  • Mapbox Maps SDK for Android v11 (com.mapbox.maps:android:11.x + com.mapbox.extension:maps-compose:11.x), pin an exact version in the version catalog (same “never float” rule). Pick the 11.x matching iOS 11.32 if available, else latest 11.x at start.
  • Compose: MapboxMap(modifier, mapViewportState, style = { MapStyle(styleUri) }, compass/scaleBar/logo/attribution = {}). For the volume of custom layers and frequent updates, use MapEffect(Unit) { mapView -> … } and drive layers imperatively through mapView.mapboxMap.style with the Style DSL (com.mapbox.maps.extension.style.*). The declarative Compose layer API (LineLayer(sourceState = …)) is fine for static layers but costs recomposition per update; keep high-frequency things (glider positions, satellite opacity) imperative.
  • Keep the iOS layering: Compose screens/ViewModels are Mapbox-free; a FlightMapController (Android analogue of MapViewDataSource) owns the MapView, sources, layers, annotations.
iOS (v11 Swift)Android (v11 Kotlin DSL)
LineLayer(id:source:) + lineWidth = .expression(interpolate linear zoom 9 a 14 b)lineLayer(id, src) { lineWidth(interpolate { linear(); zoom(); stop { literal(9); literal(a) }; stop { literal(14); literal(b) } }) }
lineColor = Exp(.get){"color"}lineColor(get("color"))
lineDasharray [1,2]lineDasharray(listOf(1.0, 2.0))
FillLayer + feature-state highlightfillColor(switchCase { boolean { featureState { literal("highlighted") }; literal(false) }; literal("#D5A359"); get("fillColor") }); mapboxMap.setFeatureState(sourceId, null, featureId, Value(mapOf("highlighted" to true)))
Filter ["==",["get","hasDashedBorder"],true]filter(eq { get("hasDashedBorder"); literal(true) })
OpenAIP match on typelineColor(match { get("type"); literal(listOf("restricted","tra",…)); color(red); …; color(red) })
Vector sourcevectorSource("openAIPAirspace") { tiles(listOf(url)); minzoom(5); maxzoom(14) }, layers with sourceLayer("airspaces")
GeoJSON source optionsgeoJsonSource(id) { maxzoom(16); buffer(64); tolerance(0.0); lineMetrics(false); cluster(true); clusterRadius(35) }
Push shapesource.featureCollection(fc) (async in v11; throttle to 30 Hz as iOS)
RasterSource + rasterOpacity, rasterFadeDuration 0rasterSource(id){ tiles(...); tileSize(512); maxzoom(7) }, rasterLayer(id, src){ rasterOpacity(0.0); rasterFadeDuration(0.0) }
setOpacity(of:identifier:) single-propertystyle.setStyleLayerProperty(layerId, "raster-opacity", Value(0.5))
addLayer(_, layerPosition: .below(id))style.addLayerBelow(layer, belowId) / addLayerAbove / layerAtPosition
setProjection(globe)style.setProjection(projection(ProjectionName.GLOBE))
Standard lightPreset import configstyle.setStyleImportConfigProperty("basemap", "lightPreset", Value("night")); in Compose MapboxStandardStyle { lightPreset = LightPresetValue.NIGHT }
setImage(_:forName:)style.addImage(name, bitmap, sdf = true) (SDF for tinted template images)
ViewAnnotationViewAnnotationManager.addViewAnnotation(view, viewAnnotationOptions { geometry(point); allowOverlap(true) }) or Compose ViewAnnotation(options) { … }
Async queryRenderedFeaturesmapboxMap.queryRenderedFeatures(RenderedQueryGeometry(screenBox), RenderedQueryOptions(layerIds, null)) { result -> }
camera(for: coords, padding:) + ease 0.3 smapboxMap.cameraForCoordinates(coords, CameraOptions.Builder().build(), EdgeInsets(...), null, null) { cam -> mapView.camera.easeTo(cam, mapAnimationOptions { duration(300) }) }
onStyleLoaded, onMapIdle, onCameraChangedsubscribeStyleLoaded, subscribeMapIdle, subscribeCameraChanged (debounce 200 ms with a coroutine debounce)
Pitch offgestures.pitchEnabled = false
Hide logo/attributionlogo.enabled = false, attribution.enabled = false (attribution in About; check ToS)
Satellite tile cache + Origin headerHttpServiceFactory.setHttpServiceInterceptor(...) (common SDK)

Colours in expressions: Android Mapbox accepts "rgba(r,g,b,a)" strings and color(Int). Keep the per-segment altitude colour property as an rgba() string (or an int packed colour with toColor).

  • Per-segment altitude features (≤ 201 colour buckets, merged runs) exactly as iOS. Alternative line-gradient + line-progress (needs lineMetrics=true) would look smoother but differs visually from iOS and interpolates by distance not fix — keep segments for parity; add an altitude property per segment now so the 3D line-z-offset plan stays open.
  • Build features on Dispatchers.Default; push on main.

8.4 Barogram recommendation: custom Compose Canvas

Section titled “8.4 Barogram recommendation: custom Compose Canvas”

Recommend a custom Compose Canvas (with an android.graphics.Picture/ImageBitmap cache), not Vico or MPAndroidChart:

CriterionCustom CanvasVico 2.xMPAndroidChart
Parity with iOS visuals (terrain fill under trace, full-height thermal bands, engine curve on independent scale, badges above plot, AAT line, event pills with holes, multi-flight alpha)exactpartial; decorations/markers possible but layered composition is fighting the libpartial; View-based, dated styling
10k–50k points, 60 fps scrubyes: pre-decimate to 1 sample per pixel column (as iOS), cache paths/bitmap, scrub draws only the handle layerOK with own decimation, but its model re-measures on updatesdecent but View/invalidate-based, no Compose
Horizontal-only zoom 1–10× anchored at pinch + pan + handle hit-rect arbitrationtrivial with pointerInput/transformablebuilt-in zoom is both axes or custom; handle arbitration hardbuilt-in, but gesture arbitration with a draggable handle awkward
Two-way sync with map + playback at 60 Hzstate hoisted in ViewModel (progress: Float), handle in its own draw layermarker API not designed for external drivingpossible via highlightValue but heavy
Maintenance / dependencynoneactive lib, API churn 1.x→2.xunmaintained since ~2020

Implementation sketch:

  1. BarogramModel (pure Kotlin, testable): port GraphDataSource ranges/interval tables/format strings and time↔progress mapping verbatim.
  2. Path builder off-main (Dispatchers.Default): for each flight, sample widthPx·scale columns → FloatArray y-values; build Paths for trace, terrain, backdrop, thermals, attempts, engine, highlight. Cancel stale builds (coroutine collectLatest = iOS generation counter).
  3. Draw: three cached layers (under, over, highlight) via Modifier.drawWithCache or recorded GraphicsLayer (Compose 1.7+ rememberGraphicsLayer), rendered only for the visible window ±0.5 viewport; during pinch apply scale(scaleX, 1f) transform to cached layers and rebuild on gesture end — same strategy as iOS.
  4. Handle + readout + pills in a separate lightweight Canvas/composables on top so scrubbing never invalidates the data layers.
  5. Haptics: HapticFeedbackType.SegmentFrequentTick (or TextHandleMove) every 5 progress units.
  6. Accessibility: semantics with progressBarRangeInfo/custom actions to step ±1 s; content description of current readout.
  • Public token (pk.…) goes in res/values/mapbox_access_token.xml as <string name="mapbox_access_token" translatable="false"> — generated at build time from local.properties / CI secret (MAPBOX_ACCESS_TOKEN) via a Gradle resValue, not committed. Or set MapboxOptions.accessToken = BuildConfig.MAPBOX_ACCESS_TOKEN in Application.onCreate before any map.
  • Downloads token (sk.… with DOWNLOADS:READ) is needed only if pulling from Mapbox’s Maven repo with auth; store in ~/.gradle/gradle.properties (MAPBOX_DOWNLOADS_TOKEN) / CI secret, never in the repo.
  • Restrict the public token by Android package name + signing cert SHA in the Mapbox account (iOS uses bundle ID restriction equivalently).
  • Same treatment for the OpenAIP API key and the Vulture backend JWT: currently hard-coded in iOS source; on Android inject via BuildConfig from secrets and ideally move behind the backend.

9. Quirks to replicate or fix deliberately

Section titled “9. Quirks to replicate or fix deliberately”
#QuirkWhere
1highlight.border (wider, darker) drawn above highlight.layerFlightMapViewDataSource.swift:162-217
2OpenAir AC Q → class D (not danger)Airspace+Class.swift:43-45
3AL 1000ft GND/SFC with a number parsed as AMSL (only “agl” detected)Airspace+Altitude.swift:26-64
4Infringement: AGL limits vs raw pressure altitude, no tolerance, only cruise/thermal phasesFlightViewModel.swift:569-639
5Unlimited ceiling never reports “inside” in sheetSupport/Extensions/Airspace+Formatter.swift:17-30
6OpenAIP tile AGL/AMSL always treated as feetAirspace+Altitude.swift:86-100
7Persisted airspace id hash depends on Swift Double string formattingAirspace.swift:50, String+Hash.swift
8Baro interval tables with inverted pairs (vario ft/s 500/1000, current 2/50)GraphDataSource.swift:637-691
9Baro valueOffset from raw (pre-conversion) minGraphDataSource.swift:83-89
10Baro markers’ Y always converted with altitude unitGraphDataSource.swift:537
11Haptic threshold in progress units, not pxPanelGraphView.swift:219
12Dotted/task-dashed width stops start at zoom 8, solid at 9FlightTrackLineStyleLayer.swift, LineStyleLayer.swift
13Tapping the track on the map doesn’t scrubFlightMapViewDataSource+Gestures.swift
14Infringements with OpenAIP only cover airspace currently rendered on screenqueryOpenAIPAirspaces

Risks

  • Glider markers at scale: View annotations are fine for a few flights; competition days (30–50) need a SymbolLayer approach — decide up front.
  • Barogram performance & gesture arbitration (handle vs scroll vs sheet drag vs map) in Compose — the hardest UI piece; prototype first.
  • Hash compatibility for hidden airspaces (only matters if data migrates/syncs between platforms; otherwise can use a new scheme).
  • OpenAIP infringement coverage depends on rendered tiles — same limitation on Android; a better approach (fetch OpenAIP features via REST for the flight bbox) is a possible improvement.
  • Secrets: OpenAIP key and backend JWT are hard-coded on iOS; Android needs a secrets plan.
  • Display-p3 colours look slightly different unless converted properly.
  • CUB binary parser and OpenAir edge cases need golden-file tests against iOS output.
  • Mapbox ToS for hidden attribution.

Rough size (one experienced Android dev, incl. tests):

AreaEstimate
Mapbox setup, styles, light/dark/standard preset, camera/padding, gestures, measure1.5–2 wk
Track layers, altitude colouring, events, thermals, photos clusters, task/OZ geometry (+tests)2–2.5 wk
Gliders, multi-flight colours/time alignment, playback clock, pinned follow1.5 wk
Barogram (custom Canvas, all series, axes, zoom/pan, handle, readout, badges, pills, perf)3–4 wk
Floating panel + controls (unit menu, play/speed, reset zoom)1 wk
Airspace parsers (OpenAir, OpenAIP XML, CUB) + model + tests1.5–2 wk
Airspace rendering (file + OpenAIP tiles), query, highlight, hidden sectors, catalogue download/update2 wk
Airspace sheet with cross-section profile + infringement detection & list2 wk
Satellite cloud overlay with interceptor cache1 wk
SRTM elevation service + enricher0.5 wk
Total~17–21 dev-weeks (≈ 9–11 k LOC Kotlin)