Skip to content

feat(map): migrate from MapKit to MapLibre Native#253

Merged
Avi0n merged 56 commits into
devfrom
feature/maplibre-migration
Mar 28, 2026
Merged

feat(map): migrate from MapKit to MapLibre Native#253
Avi0n merged 56 commits into
devfrom
feature/maplibre-migration

Conversation

@Avi0n
Copy link
Copy Markdown
Owner

@Avi0n Avi0n commented Mar 14, 2026

Summary

Replaces MapKit with MapLibre Native across all map views (main map, Line of Sight, Trace Path, LocationPicker).

  • New MC1MapView (UIViewRepresentable) using data-driven GeoJSON layers, sprite-based pins, and raster tile overlays for satellite/topo styles
  • New OfflineMapService with settings UI for downloading and managing offline map packs
  • New PinSpriteRenderer for rendering map pin sprites without a network connection
  • Removed all MapKit representables and annotation views
  • Added MapLibre Native SPM dependency (maplibre-gl-native-distribution 6.23+)

Test plan

  • Main map tab shows contacts with correct pin styles (chat/repeater/room)
  • Pin clustering and cluster expansion on tap
  • Contact callout popover on pin tap
  • Map style switching (standard, satellite, topo) and dark mode
  • Line of Sight map with point selection, repeater pins, and path lines
  • Trace Path map with path building, signal quality lines, and stats badges
  • ContactDetail mini-map renders correctly
  • LocationPicker drop-pin and save flow
  • Offline map download, progress display, and deletion in Settings
  • Offline badge when network is unavailable
  • Label toggle shows/hides contact name labels
  • Full test suite (907 tests passing)

@Avi0n Avi0n marked this pull request as draft March 14, 2026 01:40
@Avi0n Avi0n force-pushed the feature/maplibre-migration branch 2 times, most recently from 6b3df34 to a8ac913 Compare March 20, 2026 06:58
@Avi0n Avi0n force-pushed the feature/maplibre-migration branch from 7404325 to ebda046 Compare March 26, 2026 04:18
Avi0n added 26 commits March 26, 2026 18:44
Replace MapKit with MapLibre Native for all map views (main map, Line of
Sight, Trace Path, LocationPicker, offline maps). Introduces a unified
MC1MapView (UIViewRepresentable) with data-driven GeoJSON layers, sprite-
based pin rendering, and raster tile overlays for satellite/topo styles.

Key changes:
- Add MC1MapView with clustered/fixed point layers and line style layers
- Add PinSpriteRenderer for offline-capable map pin sprites
- Add OfflineMapService and settings UI for offline map pack management
- Migrate LOS, TracePath, ContactDetail, and LocationPicker to MC1MapView
- Remove all legacy MapKit representables and annotation views
- Split ChatConversationView into ChatView and ChannelChatView
- Add MapLibre Native SPM dependency (maplibre-gl-native-distribution 6.23+)
Extract 10 computed view properties from LineOfSightView.swift into
separate View structs, reducing it from 1335 to 711 lines (~47%).
Also collapse analysisSheetContent/analysisSheetVStack into a single
property. Pure structural refactor with no behavior changes.
- Work around MapLibre bug where MLNEffectiveScaleFactorForView uses
  nativeBounds.width / bounds.width, which gives wrong scale in landscape
  because nativeBounds is fixed while bounds rotates with the device
- Isa-swizzle the internal Metal UIView's setDrawableSize: to compute
  bounds × nativeScale instead of the buggy ratio
- Use non-zero initial frame to avoid zero-size Metal init (issue #67)
- Fix mapStyleChanged always false when style URL changes
- Remove empty setupPointLayers stub, pass mapView param to setupRasterSources
- Use UIGraphicsImageRendererFormat.preferred() for all four renderers
  so sprites are rendered at the device's native screen scale instead
  of defaulting to 1x
- Debounce OfflineMapService pack reloads on per-tile progress notifications
- Guard TracePathMapViewModel overlay rebuilds against GPS no-ops
- Cache PinSpriteRenderer sprites across style reloads
- Consolidate glass button style helpers into View+LiquidGlass, delete GlassButtonStyles.swift
- Extract bounding-box calculation into setCameraRegion(fitting:) in TracePathMapViewModel
- Add MapLayerID/MapSourceID constants, remove stringly-typed layer identifiers
- Extract ContactType display properties to ContactType+Display.swift
- Move CLLocationCoordinate2D.formattedString to Extensions/
- Remove unused openTopoMapB/C URL constants
- Intercept setContentScaleFactor: on MapLibre's Metal UIView alongside
  setDrawableSize: to prevent wrong contentsScale from being stored
- Extract findMapView(from:) helper to deduplicate parent-walking logic
- Track lastAppliedStyleURL instead of reading mapView.styleURL which
  MapLibre transiently nils during rotation
- Guard data layer updates against mid-gesture state (isUserInteracting)
- Guard label visibility updates against no-op changes
- Guard against duplicate source/layer setup on style reload
- Build and cache mapPoints in rebuildMapPoints() to avoid reallocation
  on every SwiftUI body evaluation
- Remove callout dismissal on camera region change
- Regenerated L10n.swift
- Add OfflineMapLayer enum with base, satellite, and topo cases
- Add offline style JSON files for satellite and topo layers
- Extend OfflineMapService with multi-layer download, disk space checks, download speed tracking, error reporting, and database size
- Update OfflineMapSettingsView with layer picker, error alerts, and storage footer
- Add offlineMapLayer property to MapStyleSelection
- Add localized strings for offline map layers across all languages
- Extract CLLocationCoordinate2D+BoundingRegion and ContactDTO+Coordinate extensions
- Add pinStyle to ContactType+Display, removing duplicate logic from view models
- Extract LabelsToggleButton, NorthLockButton, MapRefreshButton, and CenterAllButton into standalone views
- Add isNorthLocked and setCameraRegion to MapViewModel
- Remove colorScheme and mapPoints params from MapCanvasView
- Delete GlassButtonModifier in favor of liquidGlassSecondaryButtonStyle
- Add north-lock button localization strings
- Move showLabels, mapPoints, and mapLines to LineOfSightViewModel
- Add didSet observers to rebuild map state on point/repeater changes
- Extract AnalyzeButton into a standalone view
- Extract HeightEditorGrid to deduplicate height editor layouts
- Simplify PointHeightEditorView, RepeaterHeightEditorView, and RepeaterRowView
- Convert pathState from computed property to stored, rebuilt via rebuildPathState()
- Add stored mapPoints rebuilt via rebuildMapPoints()
- Make cameraRegionVersion private(set) with increment method
- Trigger selective rebuilds via didSet observers on showLabels and repeatersWithLocation
- Remove "Downloaded" section header from packs list
- Change pack status label from "Complete" to "Downloaded"
- Remove unused offlineMaps.downloaded localization key
- Remove if/else that replaced the map with ContentUnavailableView when no contacts had location
- Remove emptyState computed property and unused MapKit import
- Remove map.emptyState and map.common.refresh L10n keys across all 9 languages
- Remove corresponding EmptyState enum and Common.refresh from L10n.swift
@Avi0n Avi0n force-pushed the feature/maplibre-migration branch from ebda046 to 3eab6cb Compare March 27, 2026 01:46
Avi0n added 8 commits March 26, 2026 18:56
- Added renderLabelSprite(text:) to PinSpriteRenderer, rendering pill
  background + text as a single UIImage
- Extended renderOnDemand to handle "label-" prefix sprites on-demand,
  matching the existing hop-ring pattern
- Updated configureNameLabelLayer to use per-feature pre-rendered sprites
  instead of MapLibre text rendering, eliminating the separate icon/text
  render pass that caused text from underlying labels to bleed through
- Removed dead nameLabel feature attribute; predicates now use labelSpriteName
- Extracted labelSpritePrefix constant shared by builder and recognizer
- Keep all trace path repeaters in the fixed (non-clustered) source so
  pins never migrate between MapLibre sources on tap, eliminating the
  async re-cluster gap that caused the blink
- Return the rendered image from didFailToLoadImage so MapLibre uses it
  as an immediate fallback rather than skipping the frame
- MapLibre has no API for this, so we find its internal UILongPressGestureRecognizer (numberOfTapsRequired == 1, minimumPressDuration == 0) and disable it
- White casing layer behind each trace line style outlines each dash
- Dash pattern values are scaled by the width ratio so casing and line stay in sync
- Resolve conflicts in AppState, TracePathMapView/ViewModel,
  TraceResultsSheet, and MapView
- Delete MapKit PathLineOverlay/PathLineRenderer (replaced by MapLibre)
- Adopt bestAvailableLocation in TracePathMapView onChange handler
@Avi0n Avi0n marked this pull request as ready for review March 27, 2026 21:29
Avi0n added 11 commits March 27, 2026 14:37
…opRow

- Replace removed TraceHop.signalLevel/signalColor static methods with
  SNRQuality computed properties to match dev's refactored API
…nSpriteRenderer

Five offline map settings keys were English in all non-English locales.
Translated for de, es, fr, nl, pl, ru, uk, zh-Hans. Added @mainactor to
PinSpriteRenderer so the compiler enforces thread safety rather than
trusting nonisolated(unsafe).
Swipe-to-delete was causing the row to disappear, reappear when the
confirmation alert rendered, then disappear again after confirming.
Delete directly on swipe instead.
…yles, guard download bounds

Track last-applied points/lines separately from current coordinator state so data arriving mid-gesture gets pushed to MapLibre once the gesture ends. Disable all map styles when offline without a matching pack, not just satellite. Disable the download button until debounced bounds exist.
- Fix callout popover not dismissing when tapping badge text layers
- Add name label layers to tap hit-test so tapping a name pill selects the contact
- Re-render pin sprites on each style reload to fix stale colors after dark/light switch
- Reset label visibility flag on style reload so user preference is reapplied
- Derive mini-map camera version from coordinates so it recenters on refresh
- Preserve user-paused offline downloads across app foreground cycles
- Throw on nil style URL in offline download instead of silently skipping
- Add NWPathMonitor onTermination handler for proper teardown
- Add VoiceOver announcement and top padding on offline badge
- Remove .controlSize(.small) from callout buttons for HIG tap targets
- Allow re-analysis in Line of Sight after results exist
- Use Measurement formatter for elevation display in LOS views
- Add accessibility labels on comparison row triangle indicators
- Fix refresh button layout shift and add accessibility label
- Add safe area padding on trace path results banner
- Extract computed property sub-views to View structs
- Add OpenTopoMap b/c subdomain round-robin
- Add comparison row increased/decreased labels for VoiceOver
- Add refresh button accessibility label
- Add default path name fallback
- Translations for all 9 languages
- Regenerate L10n.swift via SwiftGen
- Add .gesturePinch to userGestureReasons so onCameraRegionChange
  fires during pinch-to-zoom, not just pan or double-tap zoom
Previous constants (2-12 KB/tile) reflected global averages skewed by
empty ocean tiles. Populated land regions average 30-150 KB/tile at
z10-14. Also add 500 KB overhead for non-tile resources (style JSON,
sprites, glyph PBFs) that were previously excluded from the estimate.
- Use bestAvailableLocation in MapView and TracePathMapToolbarView
- Recenter was silently failing when phone GPS denied but radio had a fix
- Fall back to Liberty style URL when offline (packs only cache that style, so dark mode went blank)
- Thread isOffline through MC1MapView to field-use callers
- Store pack bounds in OfflinePack, check viewport overlap in LayersMenu
- Add MKCoordinateRegion → MLNCoordinateBounds conversion
- Add L10n strings for disabled style hints (all 9 locales)
- Group callout content for VoiceOver, add hints to action buttons
- Bump PointRowButtons icon from 16pt to 22pt for gloved use
- Add isolated deinit to LineOfSightViewModel
- Dismiss callout on camera move (stale anchor after rotation)
- Use canonical SNRQuality instead of private 3-tier enum
- Drop C-style %012d for Swift string padding
- Use Measurement formatter in HeightEditorGrid instead of hardcoded "m"
- Make TracePathEmptyState a card overlay instead of full-screen material
- Document nonisolated(unsafe) NSExpression safety
- Fix OfflineBadge trait, bump label sprite font to 12pt
@Avi0n Avi0n force-pushed the feature/maplibre-migration branch from b39584f to a722d1a Compare March 28, 2026 01:01
Avi0n added 6 commits March 27, 2026 18:24
- Apply .radioDisabled to telemetry, management, and room join buttons
- Previously only the chat message button had the connection guard
- Add pathIndex property to MapLine instead of parsing from string ID
- Replace hand-rolled UUID construction with UUID(hopIndex:) initializer
- Use withThrowingTaskGroup so multiple layers start simultaneously
- Pre-compute regions and contexts before spawning tasks
- Dismiss overlay buttons on the map and LOS views had no accessibility
  label, making them invisible to VoiceOver
- Added map.common.dismissOverlay key across all 9 languages
- Replace Text + Text("/s") with single interpolated Text in OfflinePackRow
- Add nonisolated(unsafe) to context capture in task group to fix a
  sending-parameter error in Xcode 26.2's stricter region isolation checks
- Xcode 26.2's stricter sending-parameter checks reject captures from
  @mainactor context in group.addTask closures, even with nonisolated(unsafe)
- Sequential download is sufficient for the typical 1-2 offline map layers
@Avi0n Avi0n merged commit 296c614 into dev Mar 28, 2026
2 checks passed
@Avi0n Avi0n deleted the feature/maplibre-migration branch March 28, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant