@mentra/crust 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/mentra/crust/CrustModule.kt +1042 -0
- package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
- package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
- package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +148 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +182 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +34 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +63 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +7 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +4 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +7 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +781 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +814 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +673 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +720 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +19 -0
- package/plugin/build/index.js +23 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +78 -0
- package/src/Crust.types.ts +157 -0
- package/src/CrustModule.ts +191 -0
- package/src/CrustModule.web.ts +66 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +11 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
import Combine
|
|
2
|
+
import CoreLocation
|
|
3
|
+
import Foundation
|
|
4
|
+
import MapboxDirections
|
|
5
|
+
import MapboxNavigationCore
|
|
6
|
+
|
|
7
|
+
/// NavigationManager (iOS)
|
|
8
|
+
///
|
|
9
|
+
/// Singleton wrapper around the **Mapbox Navigation SDK v3 for iOS** (migrated
|
|
10
|
+
/// from the Google Navigation SDK). Mirrors the Android
|
|
11
|
+
/// `NavigationManager.kt`: owns the `MapboxNavigation` lifecycle, subscribes to
|
|
12
|
+
/// the Combine publishers (route progress, location, arrival, rerouting), and
|
|
13
|
+
/// fans out the SAME coarse callbacks the Google version exposed to
|
|
14
|
+
/// `CrustModule`.
|
|
15
|
+
///
|
|
16
|
+
/// ## Contract (UNCHANGED from the Google implementation)
|
|
17
|
+
///
|
|
18
|
+
/// The public surface — `start` / `stop` / `requestPermission` /
|
|
19
|
+
/// `simulateDeviation`, plus the `onEvent` / `onLocation` / `onRoute` payload
|
|
20
|
+
/// shapes — is identical to the Google version, so `CrustModule.swift` and
|
|
21
|
+
/// everything above it (the `@mentra/miniapp` SDK) does not change.
|
|
22
|
+
///
|
|
23
|
+
/// ## iOS-vs-Android API differences (why this isn't a line-for-line port)
|
|
24
|
+
///
|
|
25
|
+
/// - iOS v3 is **Combine publisher-based**, not observer-based. We hold
|
|
26
|
+
/// `AnyCancellable`s instead of registering observer objects.
|
|
27
|
+
/// - Routing is `async`: `routingProvider().calculateRoutes(options:)`.
|
|
28
|
+
/// - Simulation is a **CoreConfig location source** (`.simulation`), chosen at
|
|
29
|
+
/// provider-construction time — not a separate replay session object. Because
|
|
30
|
+
/// the source is fixed when the provider is built, switching sim on/off
|
|
31
|
+
/// rebuilds the provider (see `makeProvider`).
|
|
32
|
+
/// - Mapbox owns off-route detection + auto-reroute on iOS too, same as
|
|
33
|
+
/// Android — we observe, we don't re-derive.
|
|
34
|
+
///
|
|
35
|
+
/// VERIFY-IN-XCODE markers flag the few exact field/case/method names that the
|
|
36
|
+
/// (auth-gated) v3 docs didn't let me confirm. Everything else is from the
|
|
37
|
+
/// official v3 examples.
|
|
38
|
+
// @MainActor: the Mapbox Navigation v3 API surface (MapboxNavigation,
|
|
39
|
+
// NavigationController, SessionController, the routeProgress/locationMatching
|
|
40
|
+
// publishers) is main-actor-isolated. All our Mapbox access already happens on
|
|
41
|
+
// the main thread (we dispatch to DispatchQueue.main in start/stop), so marking
|
|
42
|
+
// the whole manager @MainActor aligns the compiler with the actual runtime
|
|
43
|
+
// threading and removes the "main actor-isolated … from a nonisolated context"
|
|
44
|
+
// errors. CrustModule calls these from AsyncFunctions, which await across the
|
|
45
|
+
// boundary cleanly.
|
|
46
|
+
@MainActor
|
|
47
|
+
final class NavigationManager: NSObject {
|
|
48
|
+
static let shared = NavigationManager()
|
|
49
|
+
|
|
50
|
+
typealias EventCallback = ([String: Any]) -> Void
|
|
51
|
+
typealias LocationCallback = ([String: Any]) -> Void
|
|
52
|
+
typealias RouteCallback = ([String: Any]) -> Void
|
|
53
|
+
typealias StartCompletion = (Bool, String?) -> Void
|
|
54
|
+
|
|
55
|
+
// Callbacks wired up by CrustModule and cleared on stop().
|
|
56
|
+
private var onEvent: EventCallback?
|
|
57
|
+
private var onLocation: LocationCallback?
|
|
58
|
+
private var onRoute: RouteCallback?
|
|
59
|
+
|
|
60
|
+
// The Mapbox engine. Rebuilt per-trip because the location source
|
|
61
|
+
// (live vs simulation) is baked into CoreConfig at construction time.
|
|
62
|
+
private var provider: MapboxNavigationProvider?
|
|
63
|
+
private var mapboxNavigation: MapboxNavigation? { provider?.mapboxNavigation }
|
|
64
|
+
|
|
65
|
+
// Combine subscriptions — torn down on stop().
|
|
66
|
+
private var cancellables = Set<AnyCancellable>()
|
|
67
|
+
|
|
68
|
+
// First-fix gate. Mapbox has no synchronous "current location" getter; the
|
|
69
|
+
// device position arrives only via the locationMatching publisher AFTER the
|
|
70
|
+
// session starts. So we start the session, capture the first fix, THEN
|
|
71
|
+
// request the route from that real origin. Mirrors Android's
|
|
72
|
+
// `pendingRouteRequest`. nil once fired (one-shot).
|
|
73
|
+
private var pendingRouteRequest: ((_ originLat: Double, _ originLng: Double) -> Void)?
|
|
74
|
+
|
|
75
|
+
// Per-trip config captured from start().
|
|
76
|
+
private var tripStops: [(lat: Double, lng: Double)] = []
|
|
77
|
+
private var travelMode: String = "driving"
|
|
78
|
+
private var simulating: Bool = false
|
|
79
|
+
private var simulationSpeed: Double = 1.0
|
|
80
|
+
|
|
81
|
+
// Dedup the maneuver emission to ~1m granularity (matches Android's
|
|
82
|
+
// emitManeuverIfChanged so the "In X m" countdown updates ~1 Hz and stays in
|
|
83
|
+
// lockstep with the trip distance). Key = "type|distM|tripM".
|
|
84
|
+
private var lastEmittedManeuverKey: String?
|
|
85
|
+
|
|
86
|
+
// Arrival is reported once; the SDK keeps ticking COMPLETE afterwards.
|
|
87
|
+
private var arrivedHandled = false
|
|
88
|
+
// Off-route emitted once per episode (reset on reroute), same as Android.
|
|
89
|
+
private var offRouteEmitted = false
|
|
90
|
+
// The navigationRoutes publisher fires once with the INITIAL route (already
|
|
91
|
+
// emitted by requestAndStartRoute). We skip that first emission and treat
|
|
92
|
+
// every subsequent one as a reroute redraw. false until the first fires.
|
|
93
|
+
private var didEmitInitialRoute = false
|
|
94
|
+
|
|
95
|
+
// Last two forwarded coords — the Deviate dev walker derives its bearing
|
|
96
|
+
// from prev→last so the user keeps moving in *their* direction of travel.
|
|
97
|
+
private var lastReportedCoord: CLLocationCoordinate2D?
|
|
98
|
+
private var prevReportedCoord: CLLocationCoordinate2D?
|
|
99
|
+
private var deviateTimer: Timer?
|
|
100
|
+
private let DEVIATE_DURATION_S: Double = 10.0
|
|
101
|
+
|
|
102
|
+
// MARK: - Permissions
|
|
103
|
+
|
|
104
|
+
/// Mapbox needs no Terms & Conditions dialog (that was Google-specific).
|
|
105
|
+
/// Resolve immediately with `true` so the JS permission gate passes —
|
|
106
|
+
/// mirrors Android `ensureTermsAccepted`, which is a no-op on Mapbox.
|
|
107
|
+
/// (Location authorization itself is handled by the standard iOS prompts
|
|
108
|
+
/// driven from the NSLocation* Info.plist usage strings + CoreLocation.)
|
|
109
|
+
func requestPermission(completion: @escaping (Bool) -> Void) {
|
|
110
|
+
completion(true)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// MARK: - Start
|
|
114
|
+
|
|
115
|
+
func start(
|
|
116
|
+
stops: [(lat: Double, lng: Double)],
|
|
117
|
+
mode: String,
|
|
118
|
+
simulate: Bool,
|
|
119
|
+
speedMultiplier: Double,
|
|
120
|
+
missedTurnRerouteMeters: Double? = nil,
|
|
121
|
+
onEvent: @escaping EventCallback,
|
|
122
|
+
onLocation: @escaping LocationCallback,
|
|
123
|
+
onRoute: @escaping RouteCallback,
|
|
124
|
+
completion: @escaping StartCompletion
|
|
125
|
+
) {
|
|
126
|
+
// Clean baseline — like Android's start() calling stop() first.
|
|
127
|
+
stopInternal()
|
|
128
|
+
|
|
129
|
+
guard !stops.isEmpty else {
|
|
130
|
+
completion(false, "at least one stop is required")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
self.onEvent = onEvent
|
|
135
|
+
self.onLocation = onLocation
|
|
136
|
+
self.onRoute = onRoute
|
|
137
|
+
self.tripStops = stops
|
|
138
|
+
self.travelMode = mode
|
|
139
|
+
self.simulating = simulate
|
|
140
|
+
self.simulationSpeed = max(0.5, min(speedMultiplier, 50))
|
|
141
|
+
self.arrivedHandled = false
|
|
142
|
+
self.offRouteEmitted = false
|
|
143
|
+
self.didEmitInitialRoute = false
|
|
144
|
+
self.lastEmittedManeuverKey = nil
|
|
145
|
+
// (missedTurnRerouteMeters is intentionally unused on the Mapbox path —
|
|
146
|
+
// Mapbox owns reroute natively, same decision as Android. Kept in the
|
|
147
|
+
// signature for contract parity with CrustModule.)
|
|
148
|
+
_ = missedTurnRerouteMeters
|
|
149
|
+
|
|
150
|
+
DispatchQueue.main.async { [weak self] in
|
|
151
|
+
guard let self else { return }
|
|
152
|
+
|
|
153
|
+
// Build the provider with the right location source. Simulation is a
|
|
154
|
+
// CoreConfig choice; `.simulation` drives a synthetic puck along the
|
|
155
|
+
// active route once one is set.
|
|
156
|
+
let provider = self.makeProvider(simulate: simulate)
|
|
157
|
+
self.provider = provider
|
|
158
|
+
|
|
159
|
+
// `mapboxNavigation` is non-optional on the provider.
|
|
160
|
+
let nav = provider.mapboxNavigation
|
|
161
|
+
|
|
162
|
+
// Subscribe to the publishers BEFORE starting the session so we don't
|
|
163
|
+
// miss the first ticks.
|
|
164
|
+
self.subscribe(nav)
|
|
165
|
+
|
|
166
|
+
// Start a free-drive (passive) session so location starts flowing; the
|
|
167
|
+
// first fix satisfies the gate below, then we request the route and
|
|
168
|
+
// switch to active guidance.
|
|
169
|
+
// VERIFY-IN-XCODE: free-drive start method name. v3 examples show
|
|
170
|
+
// `nav.tripSession().startFreeDrive()`.
|
|
171
|
+
nav.tripSession().startFreeDrive()
|
|
172
|
+
|
|
173
|
+
// First-fix gate: request the route from the device's real origin.
|
|
174
|
+
self.pendingRouteRequest = { [weak self] originLat, originLng in
|
|
175
|
+
self?.requestAndStartRoute(
|
|
176
|
+
nav: nav,
|
|
177
|
+
originLat: originLat,
|
|
178
|
+
originLng: originLng,
|
|
179
|
+
completion: completion
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// First-fix TIMEOUT (live AND sim). The gate above waits for Mapbox's
|
|
184
|
+
// locationMatching publisher to emit the first fix before requesting the
|
|
185
|
+
// route. On a COLD APP LAUNCH that first fix can be slow or never arrive
|
|
186
|
+
// until the CLLocationManager fully spins up — which is exactly the
|
|
187
|
+
// "first nav after launch hangs at Starting…, works on the 2nd try" bug
|
|
188
|
+
// (the 2nd try has a warm location manager). So we ALWAYS arm a timeout:
|
|
189
|
+
// if no fix satisfies the gate in time, request the route from a one-shot
|
|
190
|
+
// CoreLocation fix / last known location so we never hang.
|
|
191
|
+
self.armFirstFixTimeout(nav: nav, completion: completion)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Build a fresh MapboxNavigationProvider. `.simulation` vs `.live` is fixed
|
|
196
|
+
/// at construction, which is why a sim toggle rebuilds the provider.
|
|
197
|
+
private func makeProvider(simulate: Bool) -> MapboxNavigationProvider {
|
|
198
|
+
// Explicit reroute config: native off-route detection + auto-reroute ON
|
|
199
|
+
// (this is the SDK default — detectsReroute defaults to true — but we set it
|
|
200
|
+
// explicitly so the intent is visible and can't silently regress). The
|
|
201
|
+
// navigator runs this off-route check continuously while in active guidance.
|
|
202
|
+
let routingConfig = RoutingConfig(
|
|
203
|
+
rerouteConfig: RerouteConfig(detectsReroute: true)
|
|
204
|
+
)
|
|
205
|
+
let coreConfig = CoreConfig(
|
|
206
|
+
routingConfig: routingConfig,
|
|
207
|
+
locationSource: simulate ? .simulation(initialLocation: nil) : .live
|
|
208
|
+
)
|
|
209
|
+
return MapboxNavigationProvider(coreConfig: coreConfig)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Subscribe to route progress, location, arrival, and reroute publishers.
|
|
213
|
+
private func subscribe(_ nav: MapboxNavigation) {
|
|
214
|
+
let navigation = nav.navigation()
|
|
215
|
+
|
|
216
|
+
// Location stream. `MapMatchingState` carries BOTH the raw GPS fix
|
|
217
|
+
// (`.location`) and the snapped/map-matched fix (`.enhancedLocation`). We
|
|
218
|
+
// forward the RAW location for the phone puck so that when the user
|
|
219
|
+
// physically diverges from the route, the puck visibly leaves the line
|
|
220
|
+
// (matching Android, which reports raw GPS). Using `.enhancedLocation` here
|
|
221
|
+
// was the bug: it snaps the puck onto the route, hiding all divergence.
|
|
222
|
+
// This is also our first-fix gate trigger.
|
|
223
|
+
navigation.locationMatching
|
|
224
|
+
.sink { [weak self] matched in
|
|
225
|
+
let r = matched.mapMatchingResult
|
|
226
|
+
print(String(format: "[NavMgr] loc raw=(%.6f,%.6f) offRoad=%@ offRoadProb=%.2f",
|
|
227
|
+
matched.location.coordinate.latitude,
|
|
228
|
+
matched.location.coordinate.longitude,
|
|
229
|
+
r.isOffRoad ? "YES" : "no",
|
|
230
|
+
r.offRoadProbability))
|
|
231
|
+
self?.handleLocation(matched.location, isOffRoad: r.isOffRoad)
|
|
232
|
+
}
|
|
233
|
+
.store(in: &cancellables)
|
|
234
|
+
|
|
235
|
+
// Route progress — drives the maneuver card + arrival.
|
|
236
|
+
// VERIFY-IN-XCODE: `.routeProgress` publisher emits `RouteProgress?`
|
|
237
|
+
// (v3 examples map `\.?.routeProgress`). Unwrap before use.
|
|
238
|
+
navigation.routeProgress
|
|
239
|
+
.sink { [weak self] progressState in
|
|
240
|
+
guard let progress = progressState?.routeProgress else { return }
|
|
241
|
+
self?.handleRouteProgress(progress)
|
|
242
|
+
}
|
|
243
|
+
.store(in: &cancellables)
|
|
244
|
+
|
|
245
|
+
// Rerouting — Mapbox detects off-route and fetches a new route NATIVELY
|
|
246
|
+
// (same as Android; we observe, we don't re-derive). `rerouting` is an
|
|
247
|
+
// event publisher of `ReroutingStatus`, whose `.event` is one of
|
|
248
|
+
// `.FetchingRoute` / `.Fetched` / `.Failed` / `.Interrupted`. We emit the
|
|
249
|
+
// "rerouting" event when the fetch begins so the glasses show the
|
|
250
|
+
// "Rerouting…" HUD + the phone shows the toast.
|
|
251
|
+
navigation.rerouting
|
|
252
|
+
.sink { [weak self] status in
|
|
253
|
+
guard let self else { return }
|
|
254
|
+
print("[NavMgr] rerouting event: \(type(of: status.event))")
|
|
255
|
+
switch status.event {
|
|
256
|
+
case is ReroutingStatus.Events.FetchingRoute:
|
|
257
|
+
// A new route fetch has started — tell JS we're rerouting. The actual
|
|
258
|
+
// new polyline arrives via the navigationRoutes publisher below.
|
|
259
|
+
self.lastEmittedManeuverKey = nil
|
|
260
|
+
self.onEvent?(["kind": "rerouting"])
|
|
261
|
+
case is ReroutingStatus.Events.Failed:
|
|
262
|
+
self.onEvent?(["kind": "error", "message": "reroute failed"])
|
|
263
|
+
default:
|
|
264
|
+
// .Fetched / .Interrupted — the route update is delivered separately.
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
.store(in: &cancellables)
|
|
269
|
+
|
|
270
|
+
// Arrival — the authoritative signal is the waypoints-arrival publisher
|
|
271
|
+
// (final destination), not a distance threshold. Emit `arrived` once.
|
|
272
|
+
navigation.waypointsArrival
|
|
273
|
+
.sink { [weak self] status in
|
|
274
|
+
guard let self else { return }
|
|
275
|
+
if status.event is WaypointArrivalStatus.Events.ToFinalDestination {
|
|
276
|
+
if !self.arrivedHandled {
|
|
277
|
+
self.arrivedHandled = true
|
|
278
|
+
self.onEvent?(["kind": "arrived"])
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
.store(in: &cancellables)
|
|
283
|
+
|
|
284
|
+
// Route updates — `navigationRoutes` emits the active NavigationRoutes
|
|
285
|
+
// whenever they change: the initial route, AND every reroute. We skip the
|
|
286
|
+
// very first emission (that's the initial route, already emitted by
|
|
287
|
+
// requestAndStartRoute) and treat every subsequent non-nil emission as a
|
|
288
|
+
// reroute: reset the maneuver dedup and re-emit the new polyline so the
|
|
289
|
+
// phone map + glasses redraw it. This is the RoutesObserver-equivalent and
|
|
290
|
+
// is the reliable redraw signal.
|
|
291
|
+
nav.tripSession().navigationRoutes
|
|
292
|
+
.sink { [weak self] routes in
|
|
293
|
+
guard let self, let routes else { return }
|
|
294
|
+
if !self.didEmitInitialRoute {
|
|
295
|
+
// First emission is the initial route — requestAndStartRoute already
|
|
296
|
+
// emitted it; just record that we've seen it.
|
|
297
|
+
self.didEmitInitialRoute = true
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
// Subsequent emission = reroute. Redraw.
|
|
301
|
+
self.lastEmittedManeuverKey = nil
|
|
302
|
+
self.emitRoute(routes)
|
|
303
|
+
}
|
|
304
|
+
.store(in: &cancellables)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/// Safety net for the first-fix gate. The gate fires when Mapbox's
|
|
308
|
+
/// locationMatching emits the first fix; this fallback fires if that's slow
|
|
309
|
+
/// (cold-launch) or never comes. It retries a few times — on a cold start the
|
|
310
|
+
/// CLLocationManager populates `.location` within a second or two of starting
|
|
311
|
+
/// updates — and once it has any origin (Mapbox fix, our keep-alive manager,
|
|
312
|
+
/// or a fresh CLLocationManager) it requests the route from it. Only bails
|
|
313
|
+
/// with an error after exhausting all retries with no location at all.
|
|
314
|
+
private func armFirstFixTimeout(
|
|
315
|
+
nav: MapboxNavigation,
|
|
316
|
+
completion: @escaping StartCompletion,
|
|
317
|
+
attempt: Int = 0
|
|
318
|
+
) {
|
|
319
|
+
let maxAttempts = 8 // ~8 × 0.75s ≈ 6s total before giving up
|
|
320
|
+
let interval: TimeInterval = 0.75
|
|
321
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
|
|
322
|
+
guard let self else { return }
|
|
323
|
+
// Gate already fired (a real fix arrived) — nothing to do.
|
|
324
|
+
guard let pending = self.pendingRouteRequest else { return }
|
|
325
|
+
|
|
326
|
+
// Try every origin source we have, freshest first.
|
|
327
|
+
let origin = self.lastReportedCoord
|
|
328
|
+
?? CLLocationManager().location?.coordinate
|
|
329
|
+
|
|
330
|
+
if let origin {
|
|
331
|
+
print("[NavMgr] first-fix timeout fallback fired (attempt \(attempt)) — requesting route from last-known origin")
|
|
332
|
+
self.pendingRouteRequest = nil
|
|
333
|
+
pending(origin.latitude, origin.longitude)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// No location yet — keep retrying until we run out of attempts.
|
|
338
|
+
if attempt + 1 < maxAttempts {
|
|
339
|
+
self.armFirstFixTimeout(nav: nav, completion: completion, attempt: attempt + 1)
|
|
340
|
+
} else {
|
|
341
|
+
print("[NavMgr] first-fix timeout — no location after \(maxAttempts) attempts, giving up")
|
|
342
|
+
self.pendingRouteRequest = nil
|
|
343
|
+
completion(false, "no location fix to start navigation")
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// Build NavigationRouteOptions from origin + stops, request the route,
|
|
349
|
+
/// set it, switch to active guidance, and emit it. Mirrors Android's
|
|
350
|
+
/// `requestAndStartRoute`.
|
|
351
|
+
private func requestAndStartRoute(
|
|
352
|
+
nav: MapboxNavigation,
|
|
353
|
+
originLat: Double,
|
|
354
|
+
originLng: Double,
|
|
355
|
+
completion: @escaping StartCompletion
|
|
356
|
+
) {
|
|
357
|
+
var waypoints: [Waypoint] = []
|
|
358
|
+
waypoints.append(Waypoint(coordinate: CLLocationCoordinate2D(latitude: originLat, longitude: originLng)))
|
|
359
|
+
for stop in tripStops {
|
|
360
|
+
waypoints.append(Waypoint(coordinate: CLLocationCoordinate2D(latitude: stop.lat, longitude: stop.lng)))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// VERIFY-IN-XCODE: NavigationRouteOptions init + profileIdentifier param.
|
|
364
|
+
let options = NavigationRouteOptions(
|
|
365
|
+
waypoints: waypoints,
|
|
366
|
+
profileIdentifier: profileFor(travelMode)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
// VERIFY-IN-XCODE: `routingProvider().calculateRoutes(options:)` returns a
|
|
370
|
+
// Task whose `.result` is `Result<NavigationRoutes, Error>` (v3 example).
|
|
371
|
+
let task = nav.routingProvider().calculateRoutes(options: options)
|
|
372
|
+
Task { [weak self] in
|
|
373
|
+
guard let self else { return }
|
|
374
|
+
switch await task.result {
|
|
375
|
+
case .success(let navigationRoutes):
|
|
376
|
+
await MainActor.run {
|
|
377
|
+
// v3 SessionController: startActiveGuidance(with:startLegIndex:).
|
|
378
|
+
// Off-route detection + auto-reroute only run in ACTIVE GUIDANCE — so
|
|
379
|
+
// this call (not the earlier free-drive) is what arms rerouting.
|
|
380
|
+
print("[NavMgr] startActiveGuidance — \(navigationRoutes.mainRoute.route.legs.count) legs, profile=\(self.travelMode)")
|
|
381
|
+
nav.tripSession().startActiveGuidance(with: navigationRoutes, startLegIndex: 0)
|
|
382
|
+
self.emitRoute(navigationRoutes)
|
|
383
|
+
completion(true, nil)
|
|
384
|
+
}
|
|
385
|
+
case .failure(let error):
|
|
386
|
+
await MainActor.run {
|
|
387
|
+
completion(false, "route request failed: \(error.localizedDescription)")
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// SDK-agnostic mode string → Mapbox Directions profile. Google's
|
|
394
|
+
/// `two_wheeler` has no Mapbox equivalent → driving (migration doc #3).
|
|
395
|
+
private func profileFor(_ mode: String) -> ProfileIdentifier {
|
|
396
|
+
switch mode.lowercased() {
|
|
397
|
+
case "walking": return .walking
|
|
398
|
+
case "cycling": return .cycling
|
|
399
|
+
case "two_wheeler": return .automobile
|
|
400
|
+
case "driving": return .automobileAvoidingTraffic
|
|
401
|
+
default: return .automobileAvoidingTraffic
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// MARK: - Stop
|
|
406
|
+
|
|
407
|
+
func stop() {
|
|
408
|
+
DispatchQueue.main.async { [weak self] in
|
|
409
|
+
self?.stopInternal()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private func stopInternal() {
|
|
414
|
+
deviateTimer?.invalidate()
|
|
415
|
+
deviateTimer = nil
|
|
416
|
+
cancellables.forEach { $0.cancel() }
|
|
417
|
+
cancellables.removeAll()
|
|
418
|
+
// Trip-session stop: SessionController.setToIdle(). `mapboxNavigation` is
|
|
419
|
+
// non-optional, so only `provider?` carries the optional chain.
|
|
420
|
+
provider?.mapboxNavigation.tripSession().setToIdle()
|
|
421
|
+
provider = nil
|
|
422
|
+
onEvent = nil
|
|
423
|
+
onLocation = nil
|
|
424
|
+
onRoute = nil
|
|
425
|
+
pendingRouteRequest = nil
|
|
426
|
+
tripStops = []
|
|
427
|
+
arrivedHandled = false
|
|
428
|
+
offRouteEmitted = false
|
|
429
|
+
didEmitInitialRoute = false
|
|
430
|
+
lastEmittedManeuverKey = nil
|
|
431
|
+
lastReportedCoord = nil
|
|
432
|
+
prevReportedCoord = nil
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// MARK: - Location handling
|
|
436
|
+
|
|
437
|
+
private func handleLocation(_ location: CLLocation, isOffRoad: Bool = false) {
|
|
438
|
+
let coord = location.coordinate
|
|
439
|
+
|
|
440
|
+
// First-fix gate: fire the deferred route request once.
|
|
441
|
+
if let pending = pendingRouteRequest {
|
|
442
|
+
pendingRouteRequest = nil
|
|
443
|
+
pending(coord.latitude, coord.longitude)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
prevReportedCoord = lastReportedCoord
|
|
447
|
+
lastReportedCoord = coord
|
|
448
|
+
|
|
449
|
+
onLocation?([
|
|
450
|
+
"lat": coord.latitude,
|
|
451
|
+
"lng": coord.longitude,
|
|
452
|
+
"accuracy": location.horizontalAccuracy,
|
|
453
|
+
"timestamp": location.timestamp.timeIntervalSince1970 * 1000,
|
|
454
|
+
])
|
|
455
|
+
// Off-route DETECTION + auto-reroute is fully owned by Mapbox (see the
|
|
456
|
+
// `rerouting` publisher in subscribe()) — no hand-rolled perpendicular
|
|
457
|
+
// distance check here, matching Android. `isOffRoad` is the map-matcher's
|
|
458
|
+
// own opinion; currently informational (the reroute publisher drives the
|
|
459
|
+
// HUD), kept available for future advisory use.
|
|
460
|
+
_ = isOffRoad
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// MARK: - Route progress → maneuver
|
|
464
|
+
|
|
465
|
+
private func handleRouteProgress(_ progress: RouteProgress) {
|
|
466
|
+
// Arrival. VERIFY-IN-XCODE: completion check. v3 RouteProgress exposes a
|
|
467
|
+
// session/route state; candidates: `progress.currentState == .complete`
|
|
468
|
+
// or `progress.fractionTraveled >= 1`. Android uses
|
|
469
|
+
// `RouteProgressState.COMPLETE`.
|
|
470
|
+
if isArrived(progress) {
|
|
471
|
+
if !arrivedHandled {
|
|
472
|
+
arrivedHandled = true
|
|
473
|
+
onEvent?(["kind": "arrived"])
|
|
474
|
+
if simulating {
|
|
475
|
+
// In sim the puck is parked at the destination; nothing more to do
|
|
476
|
+
// on iOS (no replay session to tear back to live). The next start()
|
|
477
|
+
// rebuilds the provider cleanly.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Distance to final destination + ETA.
|
|
484
|
+
// VERIFY-IN-XCODE: `progress.distanceRemaining` (whole route, meters) and
|
|
485
|
+
// `progress.durationRemaining` (seconds).
|
|
486
|
+
let distToDest = Int(progress.distanceRemaining.rounded())
|
|
487
|
+
let timeToDest = Int(progress.durationRemaining.rounded())
|
|
488
|
+
|
|
489
|
+
// The UPCOMING step is what the user is walking toward (matches Android:
|
|
490
|
+
// upcomingStep.maneuver + currentStepProgress.distanceRemaining).
|
|
491
|
+
// VERIFY-IN-XCODE: leg/step accessors:
|
|
492
|
+
// progress.currentLegProgress.currentStepProgress.distanceRemaining
|
|
493
|
+
// progress.currentLegProgress.upcomingStep (RouteStep?)
|
|
494
|
+
// progress.currentLegProgress.currentStep (RouteStep)
|
|
495
|
+
let legProgress = progress.currentLegProgress
|
|
496
|
+
let stepProgress = legProgress.currentStepProgress
|
|
497
|
+
let upcomingStep = legProgress.upcomingStep
|
|
498
|
+
let currentStep = stepProgress.step
|
|
499
|
+
|
|
500
|
+
// turnStep = upcoming normally; current only on the final/arrival leg.
|
|
501
|
+
let turnStep = upcomingStep ?? currentStep
|
|
502
|
+
let distToManeuver = Int(stepProgress.distanceRemaining.rounded())
|
|
503
|
+
|
|
504
|
+
// Maneuver kind from the turn step.
|
|
505
|
+
// VERIFY-IN-XCODE: `turnStep.maneuverType` / `.maneuverDirection`.
|
|
506
|
+
let maneuver = maneuverString(type: turnStep.maneuverType, direction: turnStep.maneuverDirection)
|
|
507
|
+
|
|
508
|
+
// Roads: current step's name = road we're on; upcoming step's name = the
|
|
509
|
+
// road being entered at the turn.
|
|
510
|
+
// VERIFY-IN-XCODE: `RouteStep.names?.first` / `.instructionsDisplayedAlongStep`
|
|
511
|
+
// / `.name`. MapboxDirections exposes `names: [String]?`.
|
|
512
|
+
let currentRoad = currentStep.names?.first?.nonBlank
|
|
513
|
+
let nextStepRoad = upcomingStep?.names?.first?.nonBlank
|
|
514
|
+
|
|
515
|
+
// Verbatim instruction for the turn we're showing (the upcoming turn).
|
|
516
|
+
// VERIFY-IN-XCODE: the instruction text. Candidates:
|
|
517
|
+
// turnStep.instructions (String)
|
|
518
|
+
// turnStep.instructionsDisplayedAlongStep?.first?.primaryInstruction.text
|
|
519
|
+
let instruction = turnStep.instructions.nonBlank
|
|
520
|
+
|
|
521
|
+
emitManeuverIfChanged(
|
|
522
|
+
maneuverType: maneuver,
|
|
523
|
+
distanceMeters: distToManeuver,
|
|
524
|
+
fromRoad: currentRoad,
|
|
525
|
+
nextStepRoad: nextStepRoad,
|
|
526
|
+
distanceToDestinationMeters: distToDest,
|
|
527
|
+
timeToDestinationSeconds: timeToDest,
|
|
528
|
+
instruction: instruction
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// Per-metre dedup, identical policy to Android's emitManeuverIfChanged, so
|
|
533
|
+
/// the "In X m" countdown updates ~1 Hz and stays in lockstep with the trip
|
|
534
|
+
/// distance shown on the HUD.
|
|
535
|
+
private func emitManeuverIfChanged(
|
|
536
|
+
maneuverType: String,
|
|
537
|
+
distanceMeters: Int,
|
|
538
|
+
fromRoad: String?,
|
|
539
|
+
nextStepRoad: String?,
|
|
540
|
+
distanceToDestinationMeters: Int,
|
|
541
|
+
timeToDestinationSeconds: Int,
|
|
542
|
+
instruction: String?
|
|
543
|
+
) {
|
|
544
|
+
let distBucket = distanceMeters >= 0 ? distanceMeters : -1
|
|
545
|
+
let tripBucket = distanceToDestinationMeters >= 0 ? distanceToDestinationMeters : -1
|
|
546
|
+
let key = "\(maneuverType)|\(distBucket)|\(tripBucket)"
|
|
547
|
+
if key == lastEmittedManeuverKey { return }
|
|
548
|
+
lastEmittedManeuverKey = key
|
|
549
|
+
|
|
550
|
+
var payload: [String: Any] = [
|
|
551
|
+
"kind": "maneuver",
|
|
552
|
+
"maneuverType": maneuverType,
|
|
553
|
+
"distanceMeters": distanceMeters,
|
|
554
|
+
"distanceToDestinationMeters": distanceToDestinationMeters,
|
|
555
|
+
"timeToDestinationSeconds": timeToDestinationSeconds,
|
|
556
|
+
]
|
|
557
|
+
if let fromRoad { payload["fromRoad"] = fromRoad; payload["toRoad"] = fromRoad }
|
|
558
|
+
if let nextStepRoad { payload["nextStepRoad"] = nextStepRoad }
|
|
559
|
+
if let instruction { payload["instruction"] = instruction }
|
|
560
|
+
onEvent?(payload)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/// VERIFY-IN-XCODE: arrival predicate for v3 RouteProgress.
|
|
564
|
+
private func isArrived(_ progress: RouteProgress) -> Bool {
|
|
565
|
+
// Prefer an explicit completion state if the SDK exposes one; else fall
|
|
566
|
+
// back to "essentially no distance left".
|
|
567
|
+
// return progress.currentState == .complete
|
|
568
|
+
return progress.distanceRemaining <= 1.0
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// MARK: - Route emission
|
|
572
|
+
|
|
573
|
+
private func emitRoute(_ navigationRoutes: NavigationRoutes) {
|
|
574
|
+
// VERIFY-IN-XCODE: how to read the chosen route's geometry + steps.
|
|
575
|
+
// navigationRoutes.mainRoute.route (Route)
|
|
576
|
+
// route.shape?.coordinates ([CLLocationCoordinate2D])
|
|
577
|
+
// route.legs.flatMap(\.steps) ([RouteStep])
|
|
578
|
+
// mainRoute is non-optional; `.route` is the underlying Route.
|
|
579
|
+
let route = navigationRoutes.mainRoute.route
|
|
580
|
+
let coords = route.shape?.coordinates ?? []
|
|
581
|
+
let points = coordinatesToPoints(coords)
|
|
582
|
+
|
|
583
|
+
var payload: [String: Any] = ["points": points]
|
|
584
|
+
|
|
585
|
+
// Build steps with polyline index + road + maneuver + distance, matching
|
|
586
|
+
// Android's RouteStep shape (lat, lng, routeIndex, road, maneuver,
|
|
587
|
+
// distanceMeters). Each step is anchored to its maneuver location.
|
|
588
|
+
var steps: [[String: Any]] = []
|
|
589
|
+
for leg in route.legs {
|
|
590
|
+
for step in leg.steps {
|
|
591
|
+
// VERIFY-IN-XCODE: `step.maneuverLocation` (CLLocationCoordinate2D),
|
|
592
|
+
// `step.distance` (meters), `step.names`, maneuver type/direction.
|
|
593
|
+
let loc = step.maneuverLocation
|
|
594
|
+
let idx = closestPolylineIndex(in: points, lat: loc.latitude, lng: loc.longitude)
|
|
595
|
+
var entry: [String: Any] = [
|
|
596
|
+
"lat": loc.latitude,
|
|
597
|
+
"lng": loc.longitude,
|
|
598
|
+
"routeIndex": idx,
|
|
599
|
+
"maneuver": maneuverString(type: step.maneuverType, direction: step.maneuverDirection),
|
|
600
|
+
"distanceMeters": Int(step.distance.rounded()),
|
|
601
|
+
]
|
|
602
|
+
if let road = step.names?.first?.nonBlank { entry["road"] = road }
|
|
603
|
+
steps.append(entry)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if !steps.isEmpty { payload["steps"] = steps }
|
|
607
|
+
|
|
608
|
+
onRoute?(payload)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private func closestPolylineIndex(in points: [[String: Double]], lat: Double, lng: Double) -> Int {
|
|
612
|
+
var best = 0
|
|
613
|
+
var bestD = Double.greatestFiniteMagnitude
|
|
614
|
+
for (i, p) in points.enumerated() {
|
|
615
|
+
let dx = (p["lat"] ?? 0) - lat
|
|
616
|
+
let dy = (p["lng"] ?? 0) - lng
|
|
617
|
+
let d = dx * dx + dy * dy
|
|
618
|
+
if d < bestD { bestD = d; best = i }
|
|
619
|
+
}
|
|
620
|
+
return best
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// MARK: - Simulate deviation (dev only)
|
|
624
|
+
|
|
625
|
+
/// Walk the user STRAIGHT FORWARD in their current direction of travel for
|
|
626
|
+
/// DEVIATE_DURATION_S, pushing synthesized coords through `onLocation` so
|
|
627
|
+
/// they go off-route and Mapbox's native reroute kicks in. Bearing comes
|
|
628
|
+
/// from prev→last forwarded coords (their real direction of travel).
|
|
629
|
+
/// `offsetMeters` is legacy/ignored. Mirrors Android `simulateDeviation`.
|
|
630
|
+
func simulateDeviation(offsetMeters: Double) {
|
|
631
|
+
DispatchQueue.main.async { [weak self] in
|
|
632
|
+
guard let self else { return }
|
|
633
|
+
let origin = self.lastReportedCoord
|
|
634
|
+
?? CLLocationManager().location?.coordinate
|
|
635
|
+
?? CLLocationCoordinate2D(latitude: 0, longitude: 0)
|
|
636
|
+
|
|
637
|
+
let bearing: Double = {
|
|
638
|
+
if let prev = self.prevReportedCoord, let last = self.lastReportedCoord {
|
|
639
|
+
let d = self.haversineMeters(prev, last)
|
|
640
|
+
if d > 0.5 { return self.bearingDegrees(from: prev, to: last) }
|
|
641
|
+
}
|
|
642
|
+
return .nan
|
|
643
|
+
}()
|
|
644
|
+
guard !bearing.isNaN else {
|
|
645
|
+
print("[NavigationManager] simulateDeviation: no recent movement to infer bearing")
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
_ = offsetMeters
|
|
649
|
+
|
|
650
|
+
self.deviateTimer?.invalidate()
|
|
651
|
+
let baselineMps = 1.4
|
|
652
|
+
let stepMeters = max(0.1, baselineMps * self.simulationSpeed * 0.4)
|
|
653
|
+
let interval: TimeInterval = 0.4
|
|
654
|
+
let totalTicks = Int((self.DEVIATE_DURATION_S / interval).rounded())
|
|
655
|
+
var cursor = origin
|
|
656
|
+
var ticksRemaining = totalTicks
|
|
657
|
+
// The Timer fires on a @Sendable closure, but every property it touches is
|
|
658
|
+
// @MainActor-isolated. Hop onto the main actor inside the tick so the
|
|
659
|
+
// mutation is actor-safe (clears the Swift-6 concurrency warnings and
|
|
660
|
+
// matches the rest of this class's main-actor model).
|
|
661
|
+
self.deviateTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { t in
|
|
662
|
+
Task { @MainActor [weak self] in
|
|
663
|
+
guard let self else { t.invalidate(); return }
|
|
664
|
+
if ticksRemaining <= 0 {
|
|
665
|
+
t.invalidate(); self.deviateTimer = nil
|
|
666
|
+
return
|
|
667
|
+
}
|
|
668
|
+
cursor = self.projectCoordinate(from: cursor, distanceMeters: stepMeters, bearingDegrees: bearing)
|
|
669
|
+
self.prevReportedCoord = self.lastReportedCoord
|
|
670
|
+
self.lastReportedCoord = cursor
|
|
671
|
+
self.onLocation?([
|
|
672
|
+
"lat": cursor.latitude,
|
|
673
|
+
"lng": cursor.longitude,
|
|
674
|
+
"accuracy": 5.0,
|
|
675
|
+
"timestamp": Date().timeIntervalSince1970 * 1000,
|
|
676
|
+
])
|
|
677
|
+
ticksRemaining -= 1
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// MARK: - Geo helpers
|
|
684
|
+
|
|
685
|
+
private func bearingDegrees(from a: CLLocationCoordinate2D, to b: CLLocationCoordinate2D) -> Double {
|
|
686
|
+
let toRad = Double.pi / 180, toDeg = 180 / Double.pi
|
|
687
|
+
let lat1 = a.latitude * toRad, lat2 = b.latitude * toRad
|
|
688
|
+
let dLng = (b.longitude - a.longitude) * toRad
|
|
689
|
+
let y = sin(dLng) * cos(lat2)
|
|
690
|
+
let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLng)
|
|
691
|
+
return (atan2(y, x) * toDeg + 360).truncatingRemainder(dividingBy: 360)
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private func projectCoordinate(from origin: CLLocationCoordinate2D, distanceMeters: Double, bearingDegrees: Double) -> CLLocationCoordinate2D {
|
|
695
|
+
let earthRadius = 6_371_000.0
|
|
696
|
+
let angular = distanceMeters / earthRadius
|
|
697
|
+
let bearing = bearingDegrees * .pi / 180
|
|
698
|
+
let lat1 = origin.latitude * .pi / 180, lng1 = origin.longitude * .pi / 180
|
|
699
|
+
let lat2 = asin(sin(lat1) * cos(angular) + cos(lat1) * sin(angular) * cos(bearing))
|
|
700
|
+
let lng2 = lng1 + atan2(sin(bearing) * sin(angular) * cos(lat1), cos(angular) - sin(lat1) * sin(lat2))
|
|
701
|
+
return CLLocationCoordinate2D(latitude: lat2 * 180 / .pi, longitude: lng2 * 180 / .pi)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private func haversineMeters(_ a: CLLocationCoordinate2D, _ b: CLLocationCoordinate2D) -> Double {
|
|
705
|
+
let R = 6_371_000.0, toRad = Double.pi / 180
|
|
706
|
+
let dLat = (b.latitude - a.latitude) * toRad
|
|
707
|
+
let dLng = (b.longitude - a.longitude) * toRad
|
|
708
|
+
let lat1 = a.latitude * toRad, lat2 = b.latitude * toRad
|
|
709
|
+
let s1 = sin(dLat / 2), s2 = sin(dLng / 2)
|
|
710
|
+
let x = s1 * s1 + s2 * s2 * cos(lat1) * cos(lat2)
|
|
711
|
+
return 2 * R * asin(min(1.0, sqrt(x)))
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private extension String {
|
|
716
|
+
var nonBlank: String? {
|
|
717
|
+
let t = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
718
|
+
return t.isEmpty ? nil : t
|
|
719
|
+
}
|
|
720
|
+
}
|