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
- Mapbox setup (SDK, styles, projection, chrome, events, gestures, camera, offline, 3D status)
- Style layers: full IDs and z-order
- Flight track rendering (styles, colouring, simplification, thermals, events, glider, task/OZ, multi-flight, highlight)
- Barogram (implementation, series, axes, gestures, scrubber, sync, playback, panel)
- Airspace (sources, parsers, model, settings, styling, selection, detail sheet, infringements)
- Other map data (waypoints, airports, terrain/elevation, satellite clouds, measure tool)
- Colour reference (all tokens)
- Android mapping (Mapbox Android v11 + Compose equivalents, barogram recommendation, token handling)
- Quirks to replicate or fix deliberately
- Risks and size estimate
1. Mapbox setup
Section titled “1. Mapbox setup”1.1 SDK
Section titled “1.1 SDK”| Item | iOS value | Source |
|---|---|---|
| SDK | MapboxMaps 11.32.0, pinned exactVersion via SPM | XcodeGen/Packages/MapboxMaps.yml |
| Migration | v6 → v11 swapped 2026-09-01; per-feature verification tracker | Docs/mapbox-v11-feature-checklist.md |
| Access token | MBXAccessToken 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 |
| Architecture | Controllers 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 wholeupdateLayer(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.swiftsetupBindings,+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).
1.2 Styles
Section titled “1.2 Styles”Packages/Preferences/.../Models/MapStyle.swift (Int raw values persisted, key VultureMapStyle):
| MapStyle (raw) | Style URL | Notes |
|---|---|---|
adaptive (0, default when unset) | mapbox://styles/mapbox/light-v11 / mapbox://styles/mapbox/dark-v11 by UI appearance | Re-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/standard | After 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):
setProjection(globe)— globe when zoomed out, Mercator at flying zooms.- Add file-airspace source + layers; waypoint + home-waypoint sources + layers.
- If OpenAIP airspace active: add OpenAIP tile source + layers, hide file-airspace layers.
- Re-render parsed files only if shapes are nil (otherwise re-add from held shapes).
- Re-apply highlighted airspace, weather overlay.
- Register style images: legend numerals
"0","5",…,"100",photo(SFphoto, template),photos(SFphoto.on.rectangle.fill),question,direction,pill,bs_0..bs_2, generated significant-weather patterns. - 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=falseare public but the Mapbox ToS still requires attribution somewhere — keep it in About as iOS does and confirm with the account terms.
1.4 Initial camera, centring and padding
Section titled “1.4 Initial camera, centring and padding”| Behaviour | Value | Source |
|---|---|---|
| Initial centre | Homebase, else fallback 48.1049991, 8.7470783, zoom 9 | MapViewDataSource.swift, +Center.swift |
| Animated camera | camera.ease(duration: 0.3) | MapCompatMapView.swift ease() |
| Fit to shapes | mapboxMap.camera(for: coords, camera: CameraOptions(), coordinatesPadding: edgePadding, maxZoom: nil, offset: nil) then ease 0.3 s | MapCompatMapView.swift showAnnotations |
| Edge padding | top max(safeTop, 56) + 16, left safeLeft + 16, bottom max(safeBottom,0) + bottomPadding + 16, right safeRight + 16 | MapViewDataSource+Center.swift |
bottomPadding | height of open sheet (thermal/chat/airspace) + 8, else the graph panel height | FlightMapViewController.swift:506-520 |
| Centre on point radius | builds a 5°-sampled circle polygon of radius r and fits it | +Center.swift center(coordinate:radius:) |
| Radii used | glider 5000 m; fix/thermal 2000 m; pinned-follow 10000 m | FlightMapViewDataSource.swift:141-143, +Center.swift:47-61 |
| Airspace centre | padding 60 pt each side + sheet height at bottom | MapViewDataSource+Airspace.swift:145-175 |
| Zoom keys | ⌘+/⌘− = zoom ±1 animated | +Center.swift zoom(by:) |
| Resize/rotation | keepVisibleRegion: 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 handoff | MapViewport{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)”| Gesture | Action |
|---|---|
| Pan / pinch / rotate | Mapbox defaults; pitch disabled |
| Long-press (1 finger) | Airspace query at point (default) |
| Tap | Airspace 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 annotation | Select that flight (light impact haptic) |
| Right-click / scroll-wheel / trackpad zoom | Desktop only — skip on Android (or map to mouse wheel on ChromeOS) |
| Left edge strip | Guard so swipe-back wins over map pan — Android equivalent: predictive back gesture handles this automatically |
| Weather tap halves | Left/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).
1.6 Offline, terrain, 3D
Section titled “1.6 Offline, terrain, 3D”- 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.mdis a design (2D/3D toggle, chase/cockpit/free camera,lineZOffsetelevated track, fill-extrusion wall-mode airspace,ModelLayerglider, 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 analtitudeproperty). - Race ranking (
RaceProgress/RaceRankingin Flights,RaceRankingViewin FlightScreens) is built on branchfeature/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.
2. Style layers — IDs and z-order
Section titled “2. Style layers — IDs and z-order”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 ID | Type | Source |
|---|---|---|---|
| 1 | airspace.fill | fill | airspace (GeoJSON) |
| 2 | airspace.border.inner | line | airspace |
| 3 | airspace.border.dashed | line | airspace |
| 4 | airspace.border | line | airspace |
| 5 | waypoints.layer, waypoints.labels | circle, symbol | waypoints |
| 6 | homeWaypoint.layer, homeWaypoint.labels | circle, symbol | homeWaypoint |
| 7 | openAIPAirspace.fill.tap, .fill, .fill.blue, .border.inner, .border.dashed, .border (only when OpenAIP active) | fill/line | openAIPAirspace (vector) |
| 8 | airspace.highlight.fill, airspace.highlight.border (OpenAIP highlight only) | fill, line | openAIPAirspace |
| — | Weather/SkySight layers | raster | inserted below airspace.border.dashed (MGLStyle+Layers.swift:34-43) |
| 9 | thermals.fill | fill | thermals |
| 10 | task.border.dashed, task.fill, task.border | line, fill, line | task |
| — | satellite.<unix>.layer (cloud frames) | raster | inserted below the first layer whose id contains "flight" |
| 11 | flight.<hash>.layer (one per loaded flight) | line | flight.<hash>; each inserted below selectedFlight |
| 12 | selectedFlight (layer id = source id) | line | selectedFlight |
| 13 | coloredFlight.layer | line | coloredFlight |
| 14 | track.layer (distance optimisation) | line dotted | track |
| 15 | optimizedTask.layer (best task attempt) | line dotted | optimizedTask |
| 16 | events.layer, events.labels | circle, symbol | events |
| 17 | photos.fill, photos.layer, photos.labels | circle, symbol | photos (clustered) |
| 18 | measure, measure.layer, measureMeta.labels | line, circle, symbol | measure sources |
| 19 | highlight.layer then highlight.border (added on demand, on top) | line | highlight |
| top | View annotations: gliders, airspace query marker | Android 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 set | maxzoom | buffer | lineMetrics | tolerance |
|---|---|---|---|---|
.track | 16 | 64 | false | default 0.375 |
.task | 16 | default (128) | false | default |
.airspace | 14 | 64 | false | default |
coloredFlight | track opts | false | 0.0 | |
photos | cluster true, clusterRadius 35 |
3. Flight track rendering
Section titled “3. Flight track rendering”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".
| LineType | Width z9 → z14 | Colour | Other | Used for |
|---|---|---|---|---|
.line | 0.8 → 1.5 | flight colour (constant) | every loaded flight (thin) | |
.border | 1.6 → 3.0 | flightColor.darken(0.2) set after creation | selected flight, plain mode | |
.color | 1.6 → 3.0 | ["get","color"] | altitude-coloured selected flight | |
.inflight | 2.4 → 4.5 | Flight 1 #4A6FA0 | join miter, cap butt, opacity 0.5 | live in-flight track |
.highlight | 1.6 → 3.0 | #D5A359 | graph/leg highlight | |
.highlightBorder | 2.6 → 4.0 | #D5A359 darkened | ||
.infrigement (sic) | 1.6 → 3.0 | #ED5656 | infringement highlight | |
.infrigementBorder | 2.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 byVultureTrackOptimization: out / outAndReturn / triangle / threeTurnpoints / fiveTurnpoints(=free), defaultfiveTurnpoints).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).
3.2 Colouring modes
Section titled “3.2 Colouring modes”Only two modes exist. There is no vario, speed or phase colouring on the map.
| Mode | Pref | Rendering |
|---|---|---|
| Plain (default) | VultureShowColoredTrack = false | Source selectedFlight: one LineString of all fixes; colour flightColor.darken(), width 1.6→3.0 |
| By altitude | VultureShowColoredTrack = true | Source 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:
| idx | RGB (0–1) | Hex |
|---|---|---|
| 0 | 1.00, 0.00, 0.00 | #FF0000 |
| 1 | 1.00, 0.39, 0.00 | #FF6300 |
| 2 | 1.00, 0.78, 0.00 | #FFC700 |
| 3 | 0.80, 1.00, 0.00 | #CCFF00 |
| 4 | 0.41, 1.00, 0.00 | #69FF00 |
| 5 | 0.01, 1.00, 0.00 | #03FF00 |
| 6 | 0.00, 1.00, 0.40 | #00FF66 |
| 7 | 0.00, 1.00, 0.79 | #00FFC9 |
| 8 | 0.00, 0.80, 1.00 | #00CCFF |
| 9 | 0.00, 0.41, 1.00 | #0069FF |
| 10 | 0.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.
3.3 Simplification
Section titled “3.3 Simplification”- Map: no client-side Douglas–Peucker; relies on GeoJSON source
tolerance(default 0.375 for all track sources, 0.0 forcoloredFlight). No per-zoom logic. Android: sametoleranceoption ongeoJsonSource. Packages/TrackShapeis 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 + ComposeCanvas.
3.4 Thermals on the map
Section titled “3.4 Thermals on the map”- Layer
thermals.fill(ThermalFillStyleLayer.swift): FillLayer,minzoom 8,fill-color ["get","fillColor"],fill-opacity 0.4, no outline. - Geometry: circle around
thermal.centerCoordinate, radiustotalRadius(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]at100 * 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. PrefsVultureShowThermals,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.layerCircleLayer:minzoom 9,circle-color #FFFFFF,circle-stroke-color #5F5F5F,circle-radiusandcircle-stroke-widthbothinterpolate linear zoom 9 → 1.6, 14 → 3.0.events.labelsSymbolLayer: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)”| Aspect | Value |
|---|---|
| Host | v11 ViewAnnotation, annotatedFeature = .geometry(Point), allowOverlap = true; updated by reassigning the geometry |
| Image | Template 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 |
| Tint | label colour, resolved light (black) on outdoor/streets, white on satellite, dynamic otherwise |
| Rotation | radians(fix.bearing − map.bearing), no animation; re-applied on camera change only if bearing changed |
| Label pill | Registration, caption1, insets 2/6/2/6, corner = h/2, 10 pt above image, centred |
| Selected | image alpha 1.0; pill bg = flight colour, text = inverse colour; brought to front |
| Unselected | image alpha 0.5; pill bg clear, text = flight colour |
| Inverse colour | brightness=(r*299+g*587+b*114)/1000; < 0.6 → systemGray6, else label |
| Lifetime | Exists only while the flight has a fix at the current time |
| Tap | light 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:
| Layer | Style |
|---|---|
task.border.dashed | round; width interpolate linear zoom 8 → 1.6, 14 → 3.0; dash [1,2]; line-color ["get","lineColorKey"] |
task.fill | minzoom 6; fill-color ["get","fillColor"]; fill-opacity 0.2 |
task.border | minzoom 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"ifcount == 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#ED5656ifshowBorder(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):
| Case | Geometry |
|---|---|
isLine | 2 points at R1 on bearing ± 90° |
| A1>0, A2>0, R2>0, A1 ≥ 180 | circle R1 |
| …, A2 ≥ 180 (keyhole) | arc R2 end → start (start = bearing − A1, end = start + 2·A1), repeat last, arc R1 start → end, close |
| …, else | sector arc at R2: bearing − A2 … + 2·A2 |
| simple, A1 ≥ 180 | cylinder 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.
3.8 Multi-flight comparison
Section titled “3.8 Multi-flight comparison”| Aspect | Behaviour |
|---|---|
| Palette | 20 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 |
| Assignment | Round-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] |
| Primary | First added (or selecting) when none selected; tap glider / panel card re-selects |
| Selected-only overlays | thick/coloured track, task, optimisation, events, thermals, photos, graph extras |
| Others | thin .line in their colour + glider at 0.5 alpha |
| Labels | glider pill with registration |
| Time sync | Each 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) |
| Downloads | Soaring Spot / Charron: 3 concurrent, day task applied |
3.9 Highlight (task leg / infringement)
Section titled “3.9 Highlight (task leg / infringement)”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.
3.10 Photos
Section titled “3.10 Photos”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.
4. Barogram
Section titled “4. Barogram”4.1 Implementation
Section titled “4.1 Implementation”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.
| Type | File (Map/Flight/Panel/…) | Role |
|---|---|---|
PanelContentViewController (+Layout/+Graph/+Playback) | Panel/ | panel content: dragger, flights row, graph, controls |
PanelGraphViewController | Panel/Graph/ | binds view model streams |
PanelGraphView (+Gestures, +Accessibility) | Panel/Graph/ | Y-axis, horizontal UIScrollView (zoom), handle, one data view per flight |
GraphDataSource | Panel/Graph/GraphDataSource.swift | ranges, intervals, unit conversion, time↔progress, fix lookup, offsets |
GraphFlightDataView | Panel/Graph/Views/ | per-flight paths, dots, badges, AAT, pills |
GraphBitmapRenderer | Panel/Graph/Views/ | rasterises 3 bitmaps: under, over, highlight |
GraphMetadataView | Y grid + labels | |
GraphHandleView, GraphHandleMetadataView, GraphValueView | scrubber, dot, readout bubble |
X domain: progress 0…100 over [min startRecordingTime, max endRecordingTime] of all loaded flights (GraphDataSource.swift:406-410, 515-522).
4.2 Main series (one at a time)
Section titled “4.2 Main series (one at a time)”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".
4.3 Layers drawn per flight
Section titled “4.3 Layers drawn per flight”| Series | Condition | Colour | Style | Bitmap |
|---|---|---|---|---|
| Main trace, selected | always | flight colour | stroke 2.0, α 1.0 | over (top) |
| Main trace, unselected | always | secondaryLabel | stroke 1.0, α 0.3 | over |
| Terrain (ground elevation) | selected, elevation available, altitude unit | fill #C3AC93 / dark #644E35; stroke #AD8F6C / dark #8A6C49 | closed polygon to bottom, line 1.0 | over (first) |
| No-terrain baseline | elevation nil | #AD8F6C/#8A6C49 | 1 pt line at bottom | |
| GPS altitude backdrop | selected, !showAltitudeGained | tertiaryLabel | fill α 0.3 | over |
| Thermals | selected, VultureShowThermals | #69AB8B | full-height rect entry→exit, α 0.3 | under |
| Thermal attempts | selected, VultureShowThermalAttempts | #FF4139 | full-height rect, α 0.3 | under |
| Engine (ENL/MOP) | selected | #F6B91E | filled curve, α 0.3 | under |
| Highlight (leg/infringement) | selected, highlight set | leg #D5A359 / infringement #ED5656 | stroke 2.5 | highlight (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).
4.4 Axes
Section titled “4.4 Axes”- Y axis column 54 pt (label 50 + gap 4); labels
"\(value) \(unit)"caption2, right-aligned,secondaryLabel; grid linestertiaryLabel1 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 = −rawMinonly 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),maxas 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, orT+hh:mm:sswhen task-aligned.
4.5 Gestures, scrubber, haptics
Section titled “4.5 Gestures, scrubber, haptics”| Item | Value |
|---|---|
| Zoom | horizontal only, scale 1.0…10.0; content width = width·scale − 16; pinch anchored at finger; wheel scale *= 1 + dy·0.01 |
| Pan | native scroll when zoomed, no indicator |
| Reset zoom button | visible when scale ≠ 1, fades 0.15 s |
| Handle | full-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) |
| Readout | dot 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 |
| Haptics | selection tick whenever the handle moves > 5 progress units (5 % of timeline) |
| Keyboard | Ctrl+←/→ = 2 pt; ←/→ = ±1 s flight time; Space = play/pause |
| Panel drag | disabled 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.
4.6 Sync with the map
Section titled “4.6 Sync with the map”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.
4.7 Playback
Section titled “4.7 Playback”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.
4.8 Panel
Section titled “4.8 Panel”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.
5. Airspace
Section titled “5. Airspace”5.1 Sources
Section titled “5.1 Sources”| Source | Details | Where |
|---|---|---|
| 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 catalogue | Core Data File |
| Catalogue | https://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 download | plain GET of record.uri, no auth | FileDownloadManager.swift |
| Updates | checkUpdates() at app start: refetch catalogue, flag .xcsoar files where record.update > file.contentUpdatedAt (match by name); auto re-download if VultureAutomaticUpdateEnabled, else prompt | FileDownloadManager.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.
5.2 Parsers (Packages/Airspace)
Section titled “5.2 Parsers (Packages/Airspace)”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.
| Cmd | Handling |
|---|---|
AC | finish previous; new airspace; reset arc centre nil, direction clockwise |
AN / AL / AH | name / floor / ceiling |
DP | add point |
V X= / V D= | arc centre / direction (+ cw, - ccw) |
DA r,a1,a2 | arc radius NM, a1→a2 in current direction |
DB c1,c2 | arc around X, radius = average of both distances |
DC r | full circle NM |
AF / AX | frequency / transponder |
AY AT SP SB TC TO AG AA | ignored |
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.
5.3 Model
Section titled “5.3 Model”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 "-"), hashh = 5381; for byte in utf8: h = 127 * (h & 0x00ffffffffffffff) + byte(UInt64 wrapping). Persisted (hidden sectors) — must match byte-exactly incl. SwiftDouble.descriptionformatting of coordinates (KotlinDouble.toStringdiffers for some values → write a Swift-compatible formatter + golden tests). Classraw: 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; FLInt(level·100 / 3.2808399); unlimitedInt.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”.
5.4 Settings / filtering
Section titled “5.4 Settings / filtering”There is no class filter and no altitude-ceiling filter. Settings has two toggles:
| UI | Key | Default |
|---|---|---|
| “Custom Airspace” (inverse of OpenAIP) | VultureOpenAIPAirspaceEnabled | false (but OpenAIP forced without Pro) |
| “Show hidden sectors” | VultureShowHiddenAirspaces | false |
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).
5.5 Styling
Section titled “5.5 Styling”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):
| Class | Fill | Border | Dashed | Inner | isRestricted |
|---|---|---|---|---|---|
| A | – | red | – | ✓ | ✓ |
| B, C | – | blue | – | ✓ | ✓ |
| D | – | blue | – | – | ✓ |
| E | – | green | ✓ | ✓ | – |
| F, G | – | green | – | ✓ | – |
| CTR | blue | blue | ✓ | – | ✓ |
| TMA | – | blue | – | – | ✓ |
| Restricted | red | red | – | ✓ | ✓ |
| Danger | – | red | ✓ | ✓ | ✓ |
| Prohibited, GliderProhibited | red | red | – | ✓ | ✓ |
| WaveWindow | – | blue | – | ✓ | ✓ |
| Gliding | – | green | ✓ | ✓ | ✓ |
| TMZ, RMZ | – | gray | ✓ | ✓ | ✓ |
| FIR | – | light gray | – | – | – |
| nil | blue | red | ✓ | – | – |
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):
| Layer | Paint | Filter |
|---|---|---|
airspace.fill | fill-color = case(feature-state highlighted, #D5A359, get fillColor); fill-opacity = case(highlighted, 0.4, 0.1) | – |
airspace.border.inner | width 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.1 | hasInnerBorder == true |
airspace.border.dashed | round; width 6→0, 7→1, 8→1, 9→2, 14→4; dash [4,3] | hasDashedBorder == true |
airspace.border | same width, solid | hasDashedBorder == 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.
| Layer | Paint | Filter |
|---|---|---|
openAIPAirspace.fill.tap | transparent, opacity 0.01 (hit target) | – |
openAIPAirspace.fill | red, 0.1 | type in [restricted, tra, tsa, prohibited] |
openAIPAirspace.fill.blue | blue, 0.1 | type == ctr |
openAIPAirspace.border.inner | round; width 6→0, 7→2, 8→4, 9→6, 14→12; offset 6→0, 7→-1, 8→-2, 9→-3, 14→-6; opacity 0.1 | innerBorderTypes |
openAIPAirspace.border.dashed | round; width 6→0, 7→1, 8→1, 9→2, 14→4; dash [4,3] | dashedTypes |
openAIPAirspace.border | same width, solid | not dashedTypes |
(Inner-border ramp and offset sign differ between file and tile layers — keep as is.) No airspace labels on the map.
5.6 Selection, query, detail UI
Section titled “5.6 Selection, query, detail UI”- Query: long-press (tap while sheet open), right-click; layers
openAIPAirspace.fill.taporairspace.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 filteredname == <name>:airspace.highlight.fill(#D5A359, 0.4) andairspace.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:
- Keep airspaces with
class.isRestrictedandfloor.valueInMeters <= flight.maximumPressureAltitude; drop polygons < 2 coords; precompute bbox + floor/ceiling metres. - Iterate
flight.phases; only.cruise(leg)and.thermal. - Candidates per phase: floor ≤ phase max alt and bbox intersects.
- 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. - Contiguous fix index ranges →
[[Fix]]per airspace.AirspaceInfringement { airspace, fixes }, duration = Σ(last−first rawTime), single-fix group = 1 s. - 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.”
- 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. Other map data
Section titled “6. Other map data”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.
| Type | Circle | Text colour | Size | r7 | r8 |
|---|---|---|---|---|---|
| waypoint | #D5A359 | secondaryLabel (light on outdoor/streets, white on satellite) | 10 | 2 | 3 |
| home | #DF774A | #DF774A | 12 | 3 | 4 |
| event | #5F5F5F | as waypoint | 10 | 2 | 3 |
(Flight-map events use their own layers, §3.5.)
6.2 Airports
Section titled “6.2 Airports”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.
6.3 Terrain
Section titled “6.3 Terrain”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-*).
6.5 Measure tool
Section titled “6.5 Measure tool”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.
7. Colour reference
Section titled “7. Colour reference”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).
| Token | Light | Dark | Space |
|---|---|---|---|
| Brand / Tint / AccentColor / Handle | #DF774A | same | p3 |
| Success (task attempt) | #519672 | same | p3 |
| Baro Highlight / AAT / Task waypoint / Airspace Highlight | #D5A359 | same | p3 |
| Task leg / Turnpoint / Airspace Blue | #5C7EA1 | same | p3 |
| Task zone / Airspace Red / Infringement / Destructive | #ED5656 | same | sRGB |
| Airspace Green | #4C9172 | same | sRGB |
| Airspace Gray / Event | #5F5F5F | same | sRGB |
| Airspace Light Gray | #A9A9A9 | same | sRGB |
| Elevation fill | #C3AC93 | #644E35 | sRGB |
| Elevation border | #AD8F6C | #8A6C49 | sRGB |
| Engine / Thermal Bad | #F6B91E | same | sRGB |
| Thermal Good | #98F61E | same | sRGB |
| Thermal Sink / Low | #F61D24 | same | sRGB |
| Baro Thermal | #69AB8B | same | p3 |
| Baro Thermal Attempt | #FF4139 | same | p3 |
| Flight 1–20 | see §3.8 | same | p3 |
| Baro lines / altitude backdrop | iOS tertiaryLabel → M3 outlineVariant | ||
| Baro labels / unselected trace | iOS secondaryLabel → M3 onSurfaceVariant | ||
Field colours (Theme Color.*Field) | blue #32769E, yellow #EBAC57, red #ED5656, green #0B8E65, magenta #B052C8 |
8. Android mapping
Section titled “8. Android mapping”8.1 SDK and Compose
Section titled “8.1 SDK and Compose”- 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, useMapEffect(Unit) { mapView -> … }and drive layers imperatively throughmapView.mapboxMap.stylewith 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 ofMapViewDataSource) owns theMapView, sources, layers, annotations.
8.2 Layer/expression equivalents
Section titled “8.2 Layer/expression equivalents”| 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 highlight | fillColor(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 type | lineColor(match { get("type"); literal(listOf("restricted","tra",…)); color(red); …; color(red) }) |
| Vector source | vectorSource("openAIPAirspace") { tiles(listOf(url)); minzoom(5); maxzoom(14) }, layers with sourceLayer("airspaces") |
| GeoJSON source options | geoJsonSource(id) { maxzoom(16); buffer(64); tolerance(0.0); lineMetrics(false); cluster(true); clusterRadius(35) } |
| Push shape | source.featureCollection(fc) (async in v11; throttle to 30 Hz as iOS) |
RasterSource + rasterOpacity, rasterFadeDuration 0 | rasterSource(id){ tiles(...); tileSize(512); maxzoom(7) }, rasterLayer(id, src){ rasterOpacity(0.0); rasterFadeDuration(0.0) } |
setOpacity(of:identifier:) single-property | style.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 config | style.setStyleImportConfigProperty("basemap", "lightPreset", Value("night")); in Compose MapboxStandardStyle { lightPreset = LightPresetValue.NIGHT } |
setImage(_:forName:) | style.addImage(name, bitmap, sdf = true) (SDF for tinted template images) |
ViewAnnotation | ViewAnnotationManager.addViewAnnotation(view, viewAnnotationOptions { geometry(point); allowOverlap(true) }) or Compose ViewAnnotation(options) { … } |
Async queryRenderedFeatures | mapboxMap.queryRenderedFeatures(RenderedQueryGeometry(screenBox), RenderedQueryOptions(layerIds, null)) { result -> } |
camera(for: coords, padding:) + ease 0.3 s | mapboxMap.cameraForCoordinates(coords, CameraOptions.Builder().build(), EdgeInsets(...), null, null) { cam -> mapView.camera.easeTo(cam, mapAnimationOptions { duration(300) }) } |
onStyleLoaded, onMapIdle, onCameraChanged | subscribeStyleLoaded, subscribeMapIdle, subscribeCameraChanged (debounce 200 ms with a coroutine debounce) |
| Pitch off | gestures.pitchEnabled = false |
| Hide logo/attribution | logo.enabled = false, attribution.enabled = false (attribution in About; check ToS) |
| Satellite tile cache + Origin header | HttpServiceFactory.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).
8.3 Track data shape for Android
Section titled “8.3 Track data shape for Android”- Per-segment altitude features (≤ 201 colour buckets, merged runs) exactly as iOS. Alternative
line-gradient+line-progress(needslineMetrics=true) would look smoother but differs visually from iOS and interpolates by distance not fix — keep segments for parity; add analtitudeproperty per segment now so the 3Dline-z-offsetplan 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:
| Criterion | Custom Canvas | Vico 2.x | MPAndroidChart |
|---|---|---|---|
| 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) | exact | partial; decorations/markers possible but layered composition is fighting the lib | partial; View-based, dated styling |
| 10k–50k points, 60 fps scrub | yes: pre-decimate to 1 sample per pixel column (as iOS), cache paths/bitmap, scrub draws only the handle layer | OK with own decimation, but its model re-measures on updates | decent but View/invalidate-based, no Compose |
| Horizontal-only zoom 1–10× anchored at pinch + pan + handle hit-rect arbitration | trivial with pointerInput/transformable | built-in zoom is both axes or custom; handle arbitration hard | built-in, but gesture arbitration with a draggable handle awkward |
| Two-way sync with map + playback at 60 Hz | state hoisted in ViewModel (progress: Float), handle in its own draw layer | marker API not designed for external driving | possible via highlightValue but heavy |
| Maintenance / dependency | none | active lib, API churn 1.x→2.x | unmaintained since ~2020 |
Implementation sketch:
BarogramModel(pure Kotlin, testable): portGraphDataSourceranges/interval tables/format strings and time↔progress mapping verbatim.- Path builder off-main (
Dispatchers.Default): for each flight, samplewidthPx·scalecolumns →FloatArrayy-values; buildPaths for trace, terrain, backdrop, thermals, attempts, engine, highlight. Cancel stale builds (coroutinecollectLatest= iOS generation counter). - Draw: three cached layers (
under,over,highlight) viaModifier.drawWithCacheor recordedGraphicsLayer(Compose 1.7+rememberGraphicsLayer), rendered only for the visible window ±0.5 viewport; during pinch applyscale(scaleX, 1f)transform to cached layers and rebuild on gesture end — same strategy as iOS. - Handle + readout + pills in a separate lightweight
Canvas/composables on top so scrubbing never invalidates the data layers. - Haptics:
HapticFeedbackType.SegmentFrequentTick(orTextHandleMove) every 5 progress units. - Accessibility: semantics with
progressBarRangeInfo/custom actions to step ±1 s; content description of current readout.
8.5 Mapbox token on Android
Section titled “8.5 Mapbox token on Android”- Public token (
pk.…) goes inres/values/mapbox_access_token.xmlas<string name="mapbox_access_token" translatable="false">— generated at build time fromlocal.properties/ CI secret (MAPBOX_ACCESS_TOKEN) via a GradleresValue, not committed. Or setMapboxOptions.accessToken = BuildConfig.MAPBOX_ACCESS_TOKENinApplication.onCreatebefore any map. - Downloads token (
sk.…withDOWNLOADS: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
BuildConfigfrom secrets and ideally move behind the backend.
9. Quirks to replicate or fix deliberately
Section titled “9. Quirks to replicate or fix deliberately”| # | Quirk | Where |
|---|---|---|
| 1 | highlight.border (wider, darker) drawn above highlight.layer | FlightMapViewDataSource.swift:162-217 |
| 2 | OpenAir AC Q → class D (not danger) | Airspace+Class.swift:43-45 |
| 3 | AL 1000ft GND/SFC with a number parsed as AMSL (only “agl” detected) | Airspace+Altitude.swift:26-64 |
| 4 | Infringement: AGL limits vs raw pressure altitude, no tolerance, only cruise/thermal phases | FlightViewModel.swift:569-639 |
| 5 | Unlimited ceiling never reports “inside” in sheet | Support/Extensions/Airspace+Formatter.swift:17-30 |
| 6 | OpenAIP tile AGL/AMSL always treated as feet | Airspace+Altitude.swift:86-100 |
| 7 | Persisted airspace id hash depends on Swift Double string formatting | Airspace.swift:50, String+Hash.swift |
| 8 | Baro interval tables with inverted pairs (vario ft/s 500/1000, current 2/50) | GraphDataSource.swift:637-691 |
| 9 | Baro valueOffset from raw (pre-conversion) min | GraphDataSource.swift:83-89 |
| 10 | Baro markers’ Y always converted with altitude unit | GraphDataSource.swift:537 |
| 11 | Haptic threshold in progress units, not px | PanelGraphView.swift:219 |
| 12 | Dotted/task-dashed width stops start at zoom 8, solid at 9 | FlightTrackLineStyleLayer.swift, LineStyleLayer.swift |
| 13 | Tapping the track on the map doesn’t scrub | FlightMapViewDataSource+Gestures.swift |
| 14 | Infringements with OpenAIP only cover airspace currently rendered on screen | queryOpenAIPAirspaces |
10. Risks and size estimate
Section titled “10. Risks and size estimate”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):
| Area | Estimate |
|---|---|
| Mapbox setup, styles, light/dark/standard preset, camera/padding, gestures, measure | 1.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 follow | 1.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 + tests | 1.5–2 wk |
| Airspace rendering (file + OpenAIP tiles), query, highlight, hidden sectors, catalogue download/update | 2 wk |
| Airspace sheet with cross-section profile + infringement detection & list | 2 wk |
| Satellite cloud overlay with interceptor cache | 1 wk |
| SRTM elevation service + enricher | 0.5 wk |
| Total | ~17–21 dev-weeks (≈ 9–11 k LOC Kotlin) |
