@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,1445 @@
|
|
|
1
|
+
package com.mentra.crust.navigation
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.mapbox.api.directions.v5.DirectionsCriteria
|
|
7
|
+
import com.mapbox.api.directions.v5.models.DirectionsRoute
|
|
8
|
+
import com.mapbox.api.directions.v5.models.ManeuverModifier
|
|
9
|
+
import com.mapbox.api.directions.v5.models.RouteOptions
|
|
10
|
+
import com.mapbox.api.directions.v5.models.StepManeuver
|
|
11
|
+
import com.mapbox.common.MapboxOptions
|
|
12
|
+
import com.mapbox.common.location.Location
|
|
13
|
+
import com.mapbox.geojson.Point
|
|
14
|
+
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
|
|
15
|
+
import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
|
|
16
|
+
import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
|
|
17
|
+
import com.mapbox.navigation.base.options.NavigationOptions
|
|
18
|
+
import com.mapbox.navigation.base.route.NavigationRoute
|
|
19
|
+
import com.mapbox.navigation.base.route.NavigationRouterCallback
|
|
20
|
+
import com.mapbox.navigation.base.route.RouterFailure
|
|
21
|
+
import com.mapbox.navigation.base.trip.model.RouteProgress
|
|
22
|
+
import com.mapbox.navigation.base.trip.model.RouteProgressState
|
|
23
|
+
import com.mapbox.navigation.core.MapboxNavigation
|
|
24
|
+
import com.mapbox.navigation.core.MapboxNavigationProvider
|
|
25
|
+
import com.mapbox.navigation.core.directions.session.RoutesExtra
|
|
26
|
+
import com.mapbox.navigation.core.directions.session.RoutesObserver
|
|
27
|
+
import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult
|
|
28
|
+
import com.mapbox.navigation.core.reroute.RerouteController
|
|
29
|
+
import com.mapbox.navigation.core.reroute.RerouteState
|
|
30
|
+
import com.mapbox.navigation.core.replay.MapboxReplayer
|
|
31
|
+
import com.mapbox.navigation.core.replay.history.ReplayEventBase
|
|
32
|
+
import com.mapbox.navigation.core.replay.history.ReplayEventLocation
|
|
33
|
+
import com.mapbox.navigation.core.replay.history.ReplayEventUpdateLocation
|
|
34
|
+
import com.mapbox.navigation.core.replay.route.ReplayRouteMapper
|
|
35
|
+
import com.mapbox.navigation.core.replay.route.ReplayRouteOptions
|
|
36
|
+
import com.mapbox.navigation.core.replay.route.ReplayRouteSession
|
|
37
|
+
import com.mapbox.navigation.core.replay.route.ReplayRouteSessionOptions
|
|
38
|
+
import com.mapbox.navigation.core.trip.session.LocationMatcherResult
|
|
39
|
+
import com.mapbox.navigation.core.trip.session.LocationObserver
|
|
40
|
+
import com.mapbox.navigation.core.trip.session.OffRouteObserver
|
|
41
|
+
import com.mapbox.navigation.core.trip.session.RouteProgressObserver
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* NavigationManager
|
|
45
|
+
*
|
|
46
|
+
* Singleton wrapper around the **Mapbox Navigation SDK v3** for Android
|
|
47
|
+
* (migrated from the Google Navigation SDK). Owns the MapboxNavigation
|
|
48
|
+
* lifecycle, attaches observers, and exposes coarse callbacks consumed by
|
|
49
|
+
* CrustModule (which forwards them as events to JS).
|
|
50
|
+
*
|
|
51
|
+
* Headless: never mounts a MapView. The mobile app does not render a
|
|
52
|
+
* map natively (the miniapp WebView owns the visible map).
|
|
53
|
+
*
|
|
54
|
+
* ## Contract (UNCHANGED from the Google implementation)
|
|
55
|
+
*
|
|
56
|
+
* The public surface — `start` / `stop` / `ensureTermsAccepted` /
|
|
57
|
+
* `resetTermsAccepted` / `simulateDeviation` / `setWrongSidewalkOffset` /
|
|
58
|
+
* `setSkipCrossings`, plus the `StartOptions` / `Callbacks` /
|
|
59
|
+
* `ManeuverPayload` / `LocationPayload` / `RoutePoint` / `RouteStep` types —
|
|
60
|
+
* is byte-identical to the Google version. CrustModule and everything above
|
|
61
|
+
* the bridge (the `@mentra/miniapp` SDK, the PivotEngine) are unaffected by
|
|
62
|
+
* this migration. See issues/mapbox-navigation-migration.md.
|
|
63
|
+
*
|
|
64
|
+
* ## How Mapbox differs from Google (mapped onto the same contract)
|
|
65
|
+
*
|
|
66
|
+
* - **Off-route + reroute is automatic.** Mapbox detects off-route and
|
|
67
|
+
* re-requests a route by default. We surface `OffRouteObserver` →
|
|
68
|
+
* `onOffRoute`, `RerouteState` transitions → `onRerouting`, and
|
|
69
|
+
* `RoutesObserver` (new route) → `onRoute`. No hand-rolled 30m
|
|
70
|
+
* perpendicular detector for the live trip.
|
|
71
|
+
* - **Steps arrive inline** on each `RouteProgress` / off the
|
|
72
|
+
* `NavigationRoute` directlegs — no Messenger-IPC NavInfo service
|
|
73
|
+
* (NavInfoReceiverService is deleted). Road names + maneuver come from
|
|
74
|
+
* the Directions `LegStep` / `BannerInstructions`.
|
|
75
|
+
* - **No T&C dialog.** Mapbox has no runtime terms screen, so
|
|
76
|
+
* `ensureTermsAccepted` resolves immediately `true` and
|
|
77
|
+
* `resetTermsAccepted` is a no-op — keeping the callback shape the
|
|
78
|
+
* miniapp's `requestPermission()` expects.
|
|
79
|
+
* - **Enhanced (map-matched) location** replaces RoadSnappedLocationProvider
|
|
80
|
+
* via `LocationObserver.onNewLocationMatcherResult`.
|
|
81
|
+
* - **Simulation** uses `MapboxReplayer` + `ReplayRouteMapper`; the three
|
|
82
|
+
* dev walkers (deviate / wrong-sidewalk / skip-crossings) are
|
|
83
|
+
* re-implemented by pushing synthetic `ReplayEventUpdateLocation`s.
|
|
84
|
+
*/
|
|
85
|
+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
|
|
86
|
+
object NavigationManager {
|
|
87
|
+
private const val TAG = "NavigationManager"
|
|
88
|
+
|
|
89
|
+
private var mapboxNavigation: MapboxNavigation? = null
|
|
90
|
+
private var appContext: Context? = null
|
|
91
|
+
|
|
92
|
+
private var activeCallbacks: Callbacks? = null
|
|
93
|
+
private var lastEmittedKey: String? = null
|
|
94
|
+
|
|
95
|
+
/** True iff the active trip is using simulated locations. Drives reroute-restart logic. */
|
|
96
|
+
private var simulating: Boolean = false
|
|
97
|
+
/** Saved speed multiplier so deviation/reroute can resume at the same pace. */
|
|
98
|
+
private var simulationSpeed: Float = 1f
|
|
99
|
+
/**
|
|
100
|
+
* Set once when an arrival is handled so the repeated COMPLETE
|
|
101
|
+
* RouteProgress ticks the SDK keeps emitting after arrival don't
|
|
102
|
+
* re-trigger the sim→real-GPS handoff. Cleared on start()/stop().
|
|
103
|
+
*/
|
|
104
|
+
private var arrivedHandled: Boolean = false
|
|
105
|
+
|
|
106
|
+
/** Most recent (map-matched) fix. Used to compute distance-to-maneuver and dev walkers. */
|
|
107
|
+
private var lastFixLat: Double = Double.NaN
|
|
108
|
+
private var lastFixLng: Double = Double.NaN
|
|
109
|
+
/**
|
|
110
|
+
* The sample BEFORE lastFix. Used by the Deviate dev button to derive
|
|
111
|
+
* the user's actual direction of travel (prev → last) so the
|
|
112
|
+
* straight-walk uses *their* bearing, not the route's local bearing.
|
|
113
|
+
*/
|
|
114
|
+
private var prevFixLat: Double = Double.NaN
|
|
115
|
+
private var prevFixLng: Double = Double.NaN
|
|
116
|
+
/** Most recent speed in m/s, off the enhanced location. Null until first sample. */
|
|
117
|
+
private var lastSpeedMps: Float? = null
|
|
118
|
+
|
|
119
|
+
/** Edge-debounce for Mapbox's OffRouteObserver: true between an off-route
|
|
120
|
+
* signal and the next fresh route, so we emit onOffRoute once per
|
|
121
|
+
* divergence. Cleared when a (rerouted) route lands. */
|
|
122
|
+
private var offRouteFired: Boolean = false
|
|
123
|
+
|
|
124
|
+
/** Latest flattened active-route polyline + step list (for dev walkers + maneuver math). */
|
|
125
|
+
private var activePolyline: List<Pair<Double, Double>>? = null
|
|
126
|
+
private var activeSteps: List<RouteStep>? = null
|
|
127
|
+
/** Trip totals freshened on every RouteProgress tick. -1 = unknown. */
|
|
128
|
+
private var distanceToDestinationMeters: Int = -1
|
|
129
|
+
private var timeToDestinationSeconds: Int = -1
|
|
130
|
+
/** Current step maneuver + distance-to-next-maneuver from RouteProgress. Null until first tick. */
|
|
131
|
+
private var currentManeuverType: String? = null
|
|
132
|
+
private var distanceToManeuverMeters: Int? = null
|
|
133
|
+
private var currentRoad: String? = null
|
|
134
|
+
private var nextStepRoad: String? = null
|
|
135
|
+
|
|
136
|
+
// ---- Dev toggles (parity with the Google implementation) ----
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Dev toggle: when true, every emitted location is shifted ~8m to the
|
|
140
|
+
* right of the local route bearing before being reported. Simulates a
|
|
141
|
+
* pedestrian walking on the wrong sidewalk so we can verify the
|
|
142
|
+
* along-path pivot trigger fires even when they're never within the
|
|
143
|
+
* 7m point radius of a pivot. Only meaningful in simulate mode.
|
|
144
|
+
*/
|
|
145
|
+
private var wrongSidewalkOffsetEnabled: Boolean = false
|
|
146
|
+
private val WRONG_SIDEWALK_OFFSET_M = 8.0
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Dev toggle: when true, takes over from the Mapbox replayer and walks
|
|
150
|
+
* the user along a *modified* polyline that omits crossing micro-steps,
|
|
151
|
+
* reproducing the wrong-sidewalk-then-missed-the-turn scenario. Only
|
|
152
|
+
* takes effect with simulate=true.
|
|
153
|
+
*/
|
|
154
|
+
private var skipCrossingsEnabled: Boolean = false
|
|
155
|
+
private var skipCrossingsTimer: java.util.Timer? = null
|
|
156
|
+
private val SKIP_CROSSINGS_BASE_M_PER_TICK = 0.56
|
|
157
|
+
private val SKIP_CROSSINGS_TICK_MS = 400L
|
|
158
|
+
private val CROSSING_MAX_LEG_METERS = 25.0
|
|
159
|
+
private val CROSSING_MIN_BEND_DEG = 60.0
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Baseline walking speed (m/s) stamped onto synthetic replay locations
|
|
163
|
+
* pushed by the dev helpers (deviate jump, skip-crossings walker).
|
|
164
|
+
*/
|
|
165
|
+
private val DEVIATE_BASE_MPS = 1.4
|
|
166
|
+
/**
|
|
167
|
+
* How far (meters) the Deviate dev button jumps the simulated puck off
|
|
168
|
+
* the active route. Far enough that Mapbox's off-route detector fires
|
|
169
|
+
* reliably and triggers an automatic reroute.
|
|
170
|
+
*/
|
|
171
|
+
private val DEVIATE_OFFSET_M = 60.0
|
|
172
|
+
/** Cached options so a re-request to the same final stop is possible. */
|
|
173
|
+
private var activeOptions: StartOptions? = null
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One-shot gate set in `start()` and fired by `handleLocation` on the
|
|
177
|
+
* FIRST location fix after the trip session starts. Carries (originLat,
|
|
178
|
+
* originLng) — the real device position — into the deferred route
|
|
179
|
+
* request. Cleared after firing (and on stop()) so it runs exactly once
|
|
180
|
+
* per trip. This is how we get a valid origin without a synchronous
|
|
181
|
+
* Mapbox location getter (there is none).
|
|
182
|
+
*/
|
|
183
|
+
private var pendingRouteRequest: ((originLat: Double, originLng: Double) -> Unit)? = null
|
|
184
|
+
|
|
185
|
+
// ---- Mapbox replay/sim plumbing ----
|
|
186
|
+
// ReplayRouteSession is Mapbox's purpose-built, all-in-one simulation
|
|
187
|
+
// driver (a MapboxNavigationObserver). It internally observes routes +
|
|
188
|
+
// route progress, pushes the real first device location, generates replay
|
|
189
|
+
// events from the active route, and resets on reroute — the entire sim
|
|
190
|
+
// lifecycle, in the correct order. We attach/detach it instead of hand-
|
|
191
|
+
// rolling MapboxReplayer + ReplayProgressObserver + manual pushEvents
|
|
192
|
+
// (which fought each other and left the puck frozen).
|
|
193
|
+
private var replaySession: ReplayRouteSession? = null
|
|
194
|
+
// The built-in MapboxReplayer is still used by the dev walkers
|
|
195
|
+
// (deviate / skip-crossings), which push synthetic locations directly.
|
|
196
|
+
private var replayer: MapboxReplayer? = null
|
|
197
|
+
private val replayRouteMapper = ReplayRouteMapper()
|
|
198
|
+
|
|
199
|
+
// ---- Mapbox observers (held so we can detach on stop) ----
|
|
200
|
+
private var routesObserver: RoutesObserver? = null
|
|
201
|
+
private var routeProgressObserver: RouteProgressObserver? = null
|
|
202
|
+
private var locationObserver: LocationObserver? = null
|
|
203
|
+
private var offRouteObserver: OffRouteObserver? = null
|
|
204
|
+
private var rerouteStateObserver: RerouteController.RerouteStateObserver? = null
|
|
205
|
+
|
|
206
|
+
// =====================================================================
|
|
207
|
+
// Wire payloads — IDENTICAL to the Google implementation. Do not change
|
|
208
|
+
// field names/types: CrustModule + the bridge depend on this shape.
|
|
209
|
+
// =====================================================================
|
|
210
|
+
|
|
211
|
+
data class ManeuverPayload(
|
|
212
|
+
/**
|
|
213
|
+
* Categorical type of the upcoming maneuver. One of: STRAIGHT,
|
|
214
|
+
* CONTINUE, SLIGHT_LEFT, SLIGHT_RIGHT, TURN_LEFT, TURN_RIGHT,
|
|
215
|
+
* SHARP_LEFT, SHARP_RIGHT, U_TURN, NAME_CHANGE, DEPART, ARRIVE.
|
|
216
|
+
*/
|
|
217
|
+
val maneuverType: String,
|
|
218
|
+
/** Meters from the user's current position to that maneuver. -1 if unknown. */
|
|
219
|
+
val distanceMeters: Int,
|
|
220
|
+
/** Road the user is currently on. Null if unavailable. */
|
|
221
|
+
val fromRoad: String?,
|
|
222
|
+
/** Legacy "next road" field (== fromRoad historically). Kept for back-compat. */
|
|
223
|
+
val toRoad: String?,
|
|
224
|
+
/** Road the user will be on AFTER the upcoming maneuver. Null until known. */
|
|
225
|
+
val nextStepRoad: String?,
|
|
226
|
+
/** Total remaining distance to final destination, meters. -1 if unknown. */
|
|
227
|
+
val distanceToDestinationMeters: Int = -1,
|
|
228
|
+
/** Total remaining travel time, seconds. -1 if unknown. */
|
|
229
|
+
val timeToDestinationSeconds: Int = -1,
|
|
230
|
+
/** Current speed in m/s. Null if unavailable. */
|
|
231
|
+
val currentSpeedMps: Float? = null,
|
|
232
|
+
/** Speed limit on the current road segment in m/s. Null if unknown. */
|
|
233
|
+
val speedLimitMps: Float? = null,
|
|
234
|
+
/** Bearing along the route at the user's current position, 0–360. Null if unknown. */
|
|
235
|
+
val routeHeadingDeg: Float? = null,
|
|
236
|
+
/**
|
|
237
|
+
* Mapbox's verbatim turn-by-turn instruction for the step the user is
|
|
238
|
+
* approaching (e.g. "Keep right at the fork.", "Turn left onto Waller
|
|
239
|
+
* Street.", "Your destination is on the right."). Taken from the
|
|
240
|
+
* upcoming step's StepManeuver.instruction(); the maneuver card shows
|
|
241
|
+
* it as-is rather than reconstructing "Turn left onto X". Null when the
|
|
242
|
+
* SDK hasn't supplied one yet.
|
|
243
|
+
*/
|
|
244
|
+
val instruction: String? = null,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
data class LocationPayload(
|
|
248
|
+
val lat: Double,
|
|
249
|
+
val lng: Double,
|
|
250
|
+
val accuracy: Float?,
|
|
251
|
+
val timestamp: Long,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
data class RoutePoint(val lat: Double, val lng: Double)
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* One step along the active route — used by the SDK's pivot module to
|
|
258
|
+
* enrich pivots with `fromRoad` / `toRoad` metadata. Lat/lng is the
|
|
259
|
+
* step's START coordinate.
|
|
260
|
+
*/
|
|
261
|
+
data class RouteStep(
|
|
262
|
+
val lat: Double,
|
|
263
|
+
val lng: Double,
|
|
264
|
+
val routeIndex: Int,
|
|
265
|
+
val road: String?,
|
|
266
|
+
val maneuver: String,
|
|
267
|
+
val distanceMeters: Int,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
interface Callbacks {
|
|
271
|
+
fun onManeuver(payload: ManeuverPayload)
|
|
272
|
+
fun onRerouting()
|
|
273
|
+
fun onArrived()
|
|
274
|
+
fun onError(message: String)
|
|
275
|
+
fun onLocation(payload: LocationPayload)
|
|
276
|
+
fun onRoute(points: List<RoutePoint>, steps: List<RouteStep>?)
|
|
277
|
+
/**
|
|
278
|
+
* Fires once when the user crosses the off-route threshold. Always
|
|
279
|
+
* arrives before `onRerouting` for the same deviation.
|
|
280
|
+
*/
|
|
281
|
+
fun onOffRoute(perpendicularDistanceMeters: Double)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Trip configuration. `stops` is the canonical destination list (last is final). */
|
|
285
|
+
data class StartOptions(
|
|
286
|
+
val stops: List<Pair<Double, Double>>,
|
|
287
|
+
val mode: String = "driving",
|
|
288
|
+
val avoidHighways: Boolean = false,
|
|
289
|
+
val avoidTolls: Boolean = false,
|
|
290
|
+
val avoidFerries: Boolean = false,
|
|
291
|
+
val simulate: Boolean = false,
|
|
292
|
+
// Real-time (1×) by default. Mapbox's ReplayRouteMapper already bakes
|
|
293
|
+
// realistic per-segment speeds into the replay events; `playbackSpeed`
|
|
294
|
+
// is a raw time-scale ON TOP of that, so the old 5× default ran a
|
|
295
|
+
// walking sim at ~7 m/s (a sprint). 1× walks/drives the route at true
|
|
296
|
+
// speed; callers can still pass a higher multiplier to fast-forward.
|
|
297
|
+
val speedMultiplier: Float = 1f,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
// =====================================================================
|
|
301
|
+
// T&C — Mapbox has no runtime terms dialog. Preserve the callback shape
|
|
302
|
+
// so the miniapp's requestPermission() keeps resolving {accepted:true}.
|
|
303
|
+
// =====================================================================
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Mapbox has no Terms & Conditions dialog (acceptance is by
|
|
307
|
+
* account/usage, not a runtime screen). Resolves immediately `true`.
|
|
308
|
+
* Kept so callers that pre-trigger T&C don't break.
|
|
309
|
+
*/
|
|
310
|
+
fun ensureTermsAccepted(activity: Activity, onResult: (accepted: Boolean) -> Unit) {
|
|
311
|
+
onResult(true)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** No-op under Mapbox — there is no terms cache to clear. */
|
|
315
|
+
fun resetTermsAccepted(activity: Activity) {
|
|
316
|
+
Log.d(TAG, "resetTermsAccepted — no-op (Mapbox has no T&C dialog)")
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// =====================================================================
|
|
320
|
+
// Lifecycle
|
|
321
|
+
// =====================================================================
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Lazily create (or reuse) the process-wide MapboxNavigation. The token
|
|
325
|
+
* is read from the AndroidManifest meta-data `com.mapbox.token` (injected
|
|
326
|
+
* by mobile/plugins/android.ts), mirroring how the Google geo key was
|
|
327
|
+
* provisioned. MapboxNavigationProvider keeps a singleton; we attach our
|
|
328
|
+
* own observers per trip and detach in stop().
|
|
329
|
+
*/
|
|
330
|
+
private fun ensureNavigation(activity: Activity): MapboxNavigation {
|
|
331
|
+
appContext = activity.applicationContext
|
|
332
|
+
val existing = if (MapboxNavigationProvider.isCreated()) MapboxNavigationProvider.retrieve() else null
|
|
333
|
+
if (existing != null) {
|
|
334
|
+
mapboxNavigation = existing
|
|
335
|
+
return existing
|
|
336
|
+
}
|
|
337
|
+
// v3 sets the token globally via MapboxOptions, not on NavigationOptions.
|
|
338
|
+
val token = readMapboxToken(activity)
|
|
339
|
+
if (token.isNotBlank()) {
|
|
340
|
+
MapboxOptions.accessToken = token
|
|
341
|
+
} else {
|
|
342
|
+
Log.e(TAG, "com.mapbox.token meta-data is empty — navigation will fail to authenticate")
|
|
343
|
+
}
|
|
344
|
+
val nav = MapboxNavigationProvider.create(
|
|
345
|
+
NavigationOptions.Builder(activity.applicationContext).build(),
|
|
346
|
+
)
|
|
347
|
+
mapboxNavigation = nav
|
|
348
|
+
return nav
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Read the Mapbox runtime token from AndroidManifest meta-data com.mapbox.token. */
|
|
352
|
+
private fun readMapboxToken(activity: Activity): String {
|
|
353
|
+
return try {
|
|
354
|
+
val ai = activity.packageManager.getApplicationInfo(
|
|
355
|
+
activity.packageName,
|
|
356
|
+
android.content.pm.PackageManager.GET_META_DATA,
|
|
357
|
+
)
|
|
358
|
+
ai.metaData?.getString("com.mapbox.token") ?: ""
|
|
359
|
+
} catch (e: Throwable) {
|
|
360
|
+
Log.e(TAG, "failed to read com.mapbox.token meta-data", e)
|
|
361
|
+
""
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Start a navigation session. Initializes MapboxNavigation on first
|
|
367
|
+
* call. Because Mapbox has no T&C gate, this goes straight into route
|
|
368
|
+
* request + trip start (the `ensureTermsAccepted` wrapper resolves
|
|
369
|
+
* immediately for callers that still invoke it up front).
|
|
370
|
+
*/
|
|
371
|
+
fun start(activity: Activity, options: StartOptions, callbacks: Callbacks) {
|
|
372
|
+
Log.d(TAG, "start stops=${options.stops.size} mode=${options.mode} simulate=${options.simulate} speed=${options.speedMultiplier}")
|
|
373
|
+
if (options.stops.isEmpty()) {
|
|
374
|
+
callbacks.onError("at least one stop is required")
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
val nav = try {
|
|
378
|
+
ensureNavigation(activity)
|
|
379
|
+
} catch (e: Throwable) {
|
|
380
|
+
Log.e(TAG, "MapboxNavigation init failed", e)
|
|
381
|
+
callbacks.onError("navigation init failed: ${e.message}")
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
activeCallbacks = callbacks
|
|
386
|
+
activeOptions = options
|
|
387
|
+
simulating = options.simulate
|
|
388
|
+
simulationSpeed = options.speedMultiplier.coerceIn(0.5f, 50f)
|
|
389
|
+
offRouteFired = false
|
|
390
|
+
arrivedHandled = false
|
|
391
|
+
|
|
392
|
+
attachObservers(nav, callbacks)
|
|
393
|
+
|
|
394
|
+
// Origin handling — the crux of a correct trip start.
|
|
395
|
+
//
|
|
396
|
+
// Mapbox has NO synchronous "current location" getter: the device
|
|
397
|
+
// position is only delivered asynchronously through LocationObserver,
|
|
398
|
+
// and only AFTER the trip session has started. So we cannot build the
|
|
399
|
+
// route's origin up front. Requesting with `stops.first()` as origin
|
|
400
|
+
// would be a bug — for a single-destination trip that first stop IS the
|
|
401
|
+
// destination, producing a degenerate origin==destination route (the
|
|
402
|
+
// exact trap the SDK's navigation.ts documents avoiding).
|
|
403
|
+
//
|
|
404
|
+
// Correct flow: start the trip session FIRST so location starts
|
|
405
|
+
// flowing, capture the first fix via a one-shot gate, then request the
|
|
406
|
+
// route from that real origin. `pendingRouteRequest` holds the trip
|
|
407
|
+
// params until the gate fires; `handleLocation` invokes it on the first
|
|
408
|
+
// location and clears it.
|
|
409
|
+
pendingRouteRequest = { originLat, originLng ->
|
|
410
|
+
requestAndStartRoute(nav, activity, options, originLat, originLng, callbacks)
|
|
411
|
+
}
|
|
412
|
+
startTripSession(nav, options)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the RouteOptions from a resolved origin + the trip stops, request
|
|
417
|
+
* the route, set it on the navigator, and emit it. Called once the first
|
|
418
|
+
* location fix has arrived (via `pendingRouteRequest`).
|
|
419
|
+
*/
|
|
420
|
+
private fun requestAndStartRoute(
|
|
421
|
+
nav: MapboxNavigation,
|
|
422
|
+
activity: Activity,
|
|
423
|
+
options: StartOptions,
|
|
424
|
+
originLat: Double,
|
|
425
|
+
originLng: Double,
|
|
426
|
+
callbacks: Callbacks,
|
|
427
|
+
) {
|
|
428
|
+
val coordinates = ArrayList<Point>()
|
|
429
|
+
coordinates.add(Point.fromLngLat(originLng, originLat))
|
|
430
|
+
for ((lat, lng) in options.stops) coordinates.add(Point.fromLngLat(lng, lat))
|
|
431
|
+
|
|
432
|
+
val routeOptions = RouteOptions.builder()
|
|
433
|
+
.applyDefaultNavigationOptions(profileFor(options.mode))
|
|
434
|
+
.applyLanguageAndVoiceUnitOptions(activity)
|
|
435
|
+
.coordinatesList(coordinates)
|
|
436
|
+
.alternatives(false)
|
|
437
|
+
.steps(true)
|
|
438
|
+
.bannerInstructions(true)
|
|
439
|
+
.voiceInstructions(false)
|
|
440
|
+
.exclude(buildExclude(options))
|
|
441
|
+
.build()
|
|
442
|
+
|
|
443
|
+
nav.requestRoutes(
|
|
444
|
+
routeOptions,
|
|
445
|
+
object : NavigationRouterCallback {
|
|
446
|
+
override fun onRoutesReady(routes: List<NavigationRoute>, routerOrigin: String) {
|
|
447
|
+
if (routes.isEmpty()) {
|
|
448
|
+
callbacks.onError("no route found")
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
nav.setNavigationRoutes(routes)
|
|
452
|
+
// In sim mode the ReplayRouteSession observes setNavigationRoutes
|
|
453
|
+
// and starts driving the puck automatically — no manual replay
|
|
454
|
+
// kick needed here.
|
|
455
|
+
emitRoute(routes.first(), callbacks)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
override fun onFailure(reasons: List<RouterFailure>, routeOptions: RouteOptions) {
|
|
459
|
+
val msg = reasons.firstOrNull()?.message ?: "route request failed"
|
|
460
|
+
Log.e(TAG, "requestRoutes failed: $msg")
|
|
461
|
+
callbacks.onError(msg)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
|
|
465
|
+
Log.w(TAG, "route request canceled")
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Comma-joined Directions `exclude` list from the avoid flags. Null when none. */
|
|
472
|
+
private fun buildExclude(options: StartOptions): String? {
|
|
473
|
+
val ex = mutableListOf<String>()
|
|
474
|
+
if (options.avoidTolls) ex.add(DirectionsCriteria.EXCLUDE_TOLL)
|
|
475
|
+
if (options.avoidFerries) ex.add(DirectionsCriteria.EXCLUDE_FERRY)
|
|
476
|
+
if (options.avoidHighways) ex.add(DirectionsCriteria.EXCLUDE_MOTORWAY)
|
|
477
|
+
return if (ex.isEmpty()) null else ex.joinToString(",")
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Map the SDK-agnostic mode string to a Mapbox Directions profile.
|
|
482
|
+
* Google's `two_wheeler` has no Mapbox equivalent → driving (see the
|
|
483
|
+
* migration doc's open decision #3).
|
|
484
|
+
*/
|
|
485
|
+
private fun profileFor(mode: String): String = when (mode.lowercase()) {
|
|
486
|
+
"walking" -> DirectionsCriteria.PROFILE_WALKING
|
|
487
|
+
"cycling" -> DirectionsCriteria.PROFILE_CYCLING
|
|
488
|
+
"two_wheeler" -> DirectionsCriteria.PROFILE_DRIVING
|
|
489
|
+
"driving" -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC
|
|
490
|
+
else -> DirectionsCriteria.PROFILE_DRIVING_TRAFFIC
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Begin the trip session — either live GPS or the replay session for simulate mode. */
|
|
494
|
+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
|
|
495
|
+
private fun startTripSession(nav: MapboxNavigation, options: StartOptions) {
|
|
496
|
+
if (options.simulate) {
|
|
497
|
+
// Start the replay trip session, then attach Mapbox's ReplayRouteSession
|
|
498
|
+
// which owns the whole sim: it pushes the real first device location
|
|
499
|
+
// (firing LocationObserver → satisfies our first-fix gate so the route
|
|
500
|
+
// can be requested), then once the route lands it auto-generates replay
|
|
501
|
+
// events and drives the puck — and resets cleanly on every reroute. No
|
|
502
|
+
// manual pushRealLocation / pushEvents / restart needed.
|
|
503
|
+
val session = ReplayRouteSession().setOptions(
|
|
504
|
+
ReplayRouteSessionOptions.Builder()
|
|
505
|
+
.replayRouteOptions(replayRouteOptionsFor(options.mode, simulationSpeed))
|
|
506
|
+
// Snap the replay puck to the freshly-set route after a reroute
|
|
507
|
+
// so re-attaching the session (e.g. after the Deviate dev button
|
|
508
|
+
// forces a divergence) resumes cleanly on the NEW route.
|
|
509
|
+
.locationResetEnabled(true)
|
|
510
|
+
.build(),
|
|
511
|
+
)
|
|
512
|
+
replaySession = session
|
|
513
|
+
replayer = nav.mapboxReplayer
|
|
514
|
+
nav.startReplayTripSession()
|
|
515
|
+
session.onAttached(nav)
|
|
516
|
+
} else {
|
|
517
|
+
nav.startTripSession()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* After a SIMULATED trip arrives, the replay puck is parked at the sim
|
|
523
|
+
* destination — so the location stream is reporting the fake endpoint,
|
|
524
|
+
* not the device's real position. Hand the location source back to real
|
|
525
|
+
* GPS: detach the replay session (its onDetached pushes the real device
|
|
526
|
+
* location) and switch from the replay trip session to a live one, so
|
|
527
|
+
* `onLocation` resumes emitting the user's actual position and the idle
|
|
528
|
+
* map snaps back to reality.
|
|
529
|
+
*
|
|
530
|
+
* No-op on real (non-sim) trips. Best-effort — failures are logged and
|
|
531
|
+
* leave the trip stopped rather than crashing.
|
|
532
|
+
*/
|
|
533
|
+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
|
|
534
|
+
private fun returnToRealLocationAfterSimArrival() {
|
|
535
|
+
val nav = mapboxNavigation ?: return
|
|
536
|
+
try {
|
|
537
|
+
replaySession?.onDetached(nav)
|
|
538
|
+
replaySession = null
|
|
539
|
+
// Drop the finished route and flip the session from replay → live
|
|
540
|
+
// GPS. startTripSession() begins consuming the real device location
|
|
541
|
+
// provider again, so the next onLocation fix is the user's true spot.
|
|
542
|
+
nav.setNavigationRoutes(emptyList())
|
|
543
|
+
nav.stopTripSession()
|
|
544
|
+
nav.startTripSession()
|
|
545
|
+
Log.d(TAG, "sim arrival — handed location source back to real GPS")
|
|
546
|
+
} catch (e: Throwable) {
|
|
547
|
+
Log.w(TAG, "returnToRealLocationAfterSimArrival failed: ${e.message}")
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build ReplayRouteOptions tuned to the travel MODE and a speed
|
|
553
|
+
* multiplier.
|
|
554
|
+
*
|
|
555
|
+
* CRITICAL: Mapbox's default `ReplayRouteOptions` is tuned for DRIVING —
|
|
556
|
+
* `maxSpeedMps = 30` (108 km/h). Using it for a walking trip makes the
|
|
557
|
+
* sim sprint at highway speed (~10 m per tick), which is the "goes much
|
|
558
|
+
* faster than 1×" bug. So we set a realistic cruise speed per mode:
|
|
559
|
+
* walking ≈ 1.4 m/s, cycling ≈ 5 m/s, driving = Mapbox default.
|
|
560
|
+
* `multiplier` scales that cruise (and turn) speed for fast-forward;
|
|
561
|
+
* 1× = real time.
|
|
562
|
+
*/
|
|
563
|
+
private fun replayRouteOptionsFor(mode: String, multiplier: Float): ReplayRouteOptions {
|
|
564
|
+
val m = multiplier.toDouble().coerceIn(0.25, 50.0)
|
|
565
|
+
val base = ReplayRouteOptions.Builder().build()
|
|
566
|
+
// Realistic cruise speed (m/s) for the mode. driving keeps Mapbox's
|
|
567
|
+
// default (already realistic for roads).
|
|
568
|
+
val cruiseMps = when (mode.lowercase()) {
|
|
569
|
+
"walking" -> 1.4
|
|
570
|
+
"cycling" -> 5.0
|
|
571
|
+
"two_wheeler" -> base.maxSpeedMps
|
|
572
|
+
"driving" -> base.maxSpeedMps
|
|
573
|
+
else -> base.maxSpeedMps
|
|
574
|
+
}
|
|
575
|
+
// Turn/u-turn speeds: keep below cruise so corners slow down, but never
|
|
576
|
+
// above the mode cruise (else a walking sim still rounds corners at the
|
|
577
|
+
// driving default of 3 m/s — faster than its 1.4 m/s straightaways).
|
|
578
|
+
val turnMps = minOf(base.turnSpeedMps, cruiseMps)
|
|
579
|
+
val uTurnMps = minOf(base.uTurnSpeedMps, cruiseMps)
|
|
580
|
+
return base.toBuilder()
|
|
581
|
+
.maxSpeedMps(cruiseMps * m)
|
|
582
|
+
.turnSpeedMps(turnMps * m)
|
|
583
|
+
.uTurnSpeedMps(uTurnMps * m)
|
|
584
|
+
.build()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fun stop() {
|
|
588
|
+
Log.d(TAG, "stop")
|
|
589
|
+
val nav = mapboxNavigation
|
|
590
|
+
activeCallbacks = null
|
|
591
|
+
lastEmittedKey = null
|
|
592
|
+
simulating = false
|
|
593
|
+
simulationSpeed = 1f
|
|
594
|
+
|
|
595
|
+
// Tear down dev walkers first.
|
|
596
|
+
skipCrossingsEnabled = false
|
|
597
|
+
skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
|
|
598
|
+
wrongSidewalkOffsetEnabled = false
|
|
599
|
+
|
|
600
|
+
if (nav != null) {
|
|
601
|
+
try {
|
|
602
|
+
detachObservers(nav)
|
|
603
|
+
// Detach the replay session (stops the sim, unregisters its
|
|
604
|
+
// internal observers, resets the replayer).
|
|
605
|
+
replaySession?.onDetached(nav)
|
|
606
|
+
replayer?.finish()
|
|
607
|
+
nav.stopTripSession()
|
|
608
|
+
nav.setNavigationRoutes(emptyList())
|
|
609
|
+
} catch (e: Exception) {
|
|
610
|
+
Log.e(TAG, "stop failed", e)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
replaySession = null
|
|
614
|
+
replayer = null
|
|
615
|
+
|
|
616
|
+
activePolyline = null
|
|
617
|
+
activeSteps = null
|
|
618
|
+
distanceToDestinationMeters = -1
|
|
619
|
+
timeToDestinationSeconds = -1
|
|
620
|
+
currentManeuverType = null
|
|
621
|
+
distanceToManeuverMeters = null
|
|
622
|
+
currentRoad = null
|
|
623
|
+
nextStepRoad = null
|
|
624
|
+
lastSpeedMps = null
|
|
625
|
+
offRouteFired = false
|
|
626
|
+
activeOptions = null
|
|
627
|
+
pendingRouteRequest = null
|
|
628
|
+
prevFixLat = Double.NaN
|
|
629
|
+
prevFixLng = Double.NaN
|
|
630
|
+
lastFixLat = Double.NaN
|
|
631
|
+
lastFixLng = Double.NaN
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// =====================================================================
|
|
635
|
+
// Observers — translate Mapbox events into the existing Callbacks shape.
|
|
636
|
+
// =====================================================================
|
|
637
|
+
|
|
638
|
+
private fun attachObservers(nav: MapboxNavigation, callbacks: Callbacks) {
|
|
639
|
+
// REROUTING IS FULLY OWNED BY MAPBOX. Auto-reroute is ON by default in
|
|
640
|
+
// v3 — when the user diverges, the SDK detects off-route AND fetches a
|
|
641
|
+
// new route on its own. We do NOT hand-roll perpendicular-distance
|
|
642
|
+
// detection, and we do NOT re-request the route ourselves; we just
|
|
643
|
+
// observe and forward the SDK's signals:
|
|
644
|
+
// - OffRouteObserver → onOffRoute (purely observational)
|
|
645
|
+
// - RerouteStateObserver → onRerouting (FetchingRoute state)
|
|
646
|
+
// - RoutesObserver(REROUTE) → onRoute (the new route, already active)
|
|
647
|
+
|
|
648
|
+
// RoutesObserver — fires on initial route, every reroute, refresh, and
|
|
649
|
+
// alternatives. The new route is ALREADY set active by the SDK; we just
|
|
650
|
+
// flatten + emit it. We branch on `reason` so a reroute emit can also
|
|
651
|
+
// clear our off-route flag and (for the host) reads as a fresh route.
|
|
652
|
+
val routesObs = RoutesObserver { result: RoutesUpdatedResult ->
|
|
653
|
+
val route = result.navigationRoutes.firstOrNull() ?: return@RoutesObserver
|
|
654
|
+
val reason = result.reason
|
|
655
|
+
// Ignore refresh (same geometry, just live traffic) — it would
|
|
656
|
+
// needlessly re-emit the whole polyline + rebuild pivots.
|
|
657
|
+
if (reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH) return@RoutesObserver
|
|
658
|
+
if (reason == RoutesExtra.ROUTES_UPDATE_REASON_REROUTE) {
|
|
659
|
+
Log.d(TAG, "routes updated: REROUTE — emitting new route")
|
|
660
|
+
offRouteFired = false // back on (a fresh) route
|
|
661
|
+
}
|
|
662
|
+
emitRoute(route, callbacks)
|
|
663
|
+
}
|
|
664
|
+
routesObserver = routesObs
|
|
665
|
+
nav.registerRoutesObserver(routesObs)
|
|
666
|
+
|
|
667
|
+
// RouteProgressObserver — every location tick. Carries current step,
|
|
668
|
+
// upcoming maneuver, distance-to-maneuver, and trip totals.
|
|
669
|
+
val progressObs = RouteProgressObserver { progress: RouteProgress ->
|
|
670
|
+
handleRouteProgress(progress, callbacks)
|
|
671
|
+
}
|
|
672
|
+
routeProgressObserver = progressObs
|
|
673
|
+
nav.registerRouteProgressObserver(progressObs)
|
|
674
|
+
|
|
675
|
+
// LocationObserver — enhanced (map-matched) location replaces Google's
|
|
676
|
+
// RoadSnappedLocationProvider.
|
|
677
|
+
val locObs = object : LocationObserver {
|
|
678
|
+
override fun onNewRawLocation(rawLocation: Location) { /* prefer matched */ }
|
|
679
|
+
override fun onNewLocationMatcherResult(result: LocationMatcherResult) {
|
|
680
|
+
handleLocation(result, callbacks)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
locationObserver = locObs
|
|
684
|
+
nav.registerLocationObserver(locObs)
|
|
685
|
+
|
|
686
|
+
// OffRouteObserver — Mapbox's automatic off-route edge signal. PURELY
|
|
687
|
+
// OBSERVATIONAL: registering it does NOT disable auto-reroute. We
|
|
688
|
+
// surface onOffRoute for the UI's "off route" banner; the SDK handles
|
|
689
|
+
// the actual reroute and the new route arrives via RoutesObserver.
|
|
690
|
+
val offRouteObs = OffRouteObserver { offRoute: Boolean ->
|
|
691
|
+
if (offRoute && !offRouteFired) {
|
|
692
|
+
offRouteFired = true
|
|
693
|
+
// Distance from the route is best-effort context for the banner;
|
|
694
|
+
// not used to decide anything (the SDK already decided).
|
|
695
|
+
val perp = currentPerpDistanceMeters() ?: 0.0
|
|
696
|
+
Log.d(TAG, "off-route (Mapbox) — perp≈${perp}m; SDK will auto-reroute")
|
|
697
|
+
callbacks.onOffRoute(perp)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
offRouteObserver = offRouteObs
|
|
701
|
+
nav.registerOffRouteObserver(offRouteObs)
|
|
702
|
+
|
|
703
|
+
// RerouteStateObserver — the authoritative "we are rerouting" signal.
|
|
704
|
+
// FetchingRoute → onRerouting (UI shows the rerouting state); the new
|
|
705
|
+
// route then lands via RoutesObserver(REROUTE). Available only while
|
|
706
|
+
// auto-reroute is enabled (getRerouteController() is null otherwise).
|
|
707
|
+
val rerouteObs = RerouteController.RerouteStateObserver { state: RerouteState ->
|
|
708
|
+
when (state) {
|
|
709
|
+
is RerouteState.FetchingRoute -> {
|
|
710
|
+
Log.d(TAG, "reroute state: FetchingRoute → onRerouting")
|
|
711
|
+
callbacks.onRerouting()
|
|
712
|
+
}
|
|
713
|
+
is RerouteState.Failed -> {
|
|
714
|
+
Log.w(TAG, "reroute failed: ${state.message}")
|
|
715
|
+
// Don't surface as a hard error — the user is still on the old
|
|
716
|
+
// route and the SDK may retry; just log.
|
|
717
|
+
}
|
|
718
|
+
else -> { /* Idle / RouteFetched / Interrupted — no UI change */ }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
rerouteStateObserver = rerouteObs
|
|
722
|
+
nav.getRerouteController()?.registerRerouteStateObserver(rerouteObs)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private fun detachObservers(nav: MapboxNavigation) {
|
|
726
|
+
routesObserver?.let { nav.unregisterRoutesObserver(it) }
|
|
727
|
+
routeProgressObserver?.let { nav.unregisterRouteProgressObserver(it) }
|
|
728
|
+
locationObserver?.let { nav.unregisterLocationObserver(it) }
|
|
729
|
+
offRouteObserver?.let { nav.unregisterOffRouteObserver(it) }
|
|
730
|
+
rerouteStateObserver?.let { nav.getRerouteController()?.unregisterRerouteStateObserver(it) }
|
|
731
|
+
routesObserver = null
|
|
732
|
+
routeProgressObserver = null
|
|
733
|
+
locationObserver = null
|
|
734
|
+
offRouteObserver = null
|
|
735
|
+
rerouteStateObserver = null
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Translate a RouteProgress tick into trip-state + an emitted maneuver.
|
|
740
|
+
* Mapbox gives us current step / upcoming maneuver / distance-remaining
|
|
741
|
+
* directly, so no polyline bearing-scanning is needed for the live trip
|
|
742
|
+
* (the bearing helpers remain for the dev walkers).
|
|
743
|
+
*/
|
|
744
|
+
private fun handleRouteProgress(progress: RouteProgress, callbacks: Callbacks) {
|
|
745
|
+
distanceToDestinationMeters = progress.distanceRemaining.toInt().coerceAtLeast(-1)
|
|
746
|
+
timeToDestinationSeconds = progress.durationRemaining.toInt().coerceAtLeast(-1)
|
|
747
|
+
|
|
748
|
+
// Arrival.
|
|
749
|
+
if (progress.currentState == RouteProgressState.COMPLETE) {
|
|
750
|
+
callbacks.onArrived()
|
|
751
|
+
// In sim mode the puck is now parked at the SIMULATED destination, so
|
|
752
|
+
// the app's idea of "where I am" is the sim endpoint, not reality.
|
|
753
|
+
// Hand the location source back to real GPS so the idle map snaps to
|
|
754
|
+
// the user's actual position. Guarded by `arrivedHandled` so the
|
|
755
|
+
// repeated COMPLETE ticks the SDK emits after arrival don't reset the
|
|
756
|
+
// session over and over.
|
|
757
|
+
if (simulating && !arrivedHandled) {
|
|
758
|
+
arrivedHandled = true
|
|
759
|
+
returnToRealLocationAfterSimArrival()
|
|
760
|
+
}
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
val legProgress = progress.currentLegProgress
|
|
765
|
+
val stepProgress = legProgress?.currentStepProgress
|
|
766
|
+
val upcomingStep = legProgress?.upcomingStep
|
|
767
|
+
val currentStep = stepProgress?.step
|
|
768
|
+
|
|
769
|
+
// Show the maneuver the user is WALKING TOWARD — i.e. the UPCOMING step's
|
|
770
|
+
// maneuver — together with the distance to it.
|
|
771
|
+
//
|
|
772
|
+
// In Mapbox, `currentStep.maneuver` is the maneuver that BEGAN the
|
|
773
|
+
// current step (the depart, or the turn you just took), so showing it
|
|
774
|
+
// means the card says "Walk on Market St" for the whole approach and
|
|
775
|
+
// only flips to "Turn left" once you're already at the corner — one
|
|
776
|
+
// step behind. The `upcomingStep.maneuver` is the NEXT turn (the one
|
|
777
|
+
// ahead), and `stepProgress.distanceRemaining` is the distance to the
|
|
778
|
+
// END of the current step == where that upcoming maneuver happens. So
|
|
779
|
+
// upcomingStep.maneuver + distanceRemaining is the matched pair that
|
|
780
|
+
// reads "In 120 m, turn left onto the walkway" the whole way in, then
|
|
781
|
+
// "Now" inside the turn radius.
|
|
782
|
+
//
|
|
783
|
+
// The turn-display step (what we show) and its road:
|
|
784
|
+
val turnStep = upcomingStep ?: currentStep // upcoming normally; current only on the final/arrival leg
|
|
785
|
+
val distToManeuver = stepProgress?.distanceRemaining?.toInt() ?: -1
|
|
786
|
+
distanceToManeuverMeters = distToManeuver
|
|
787
|
+
|
|
788
|
+
val maneuver = turnStep?.maneuver()?.let { mapManeuver(it) } ?: "STRAIGHT"
|
|
789
|
+
currentManeuverType = maneuver
|
|
790
|
+
// Road the user is currently on = the current step's road; the road
|
|
791
|
+
// being entered at the turn = the upcoming step's road.
|
|
792
|
+
currentRoad = currentStep?.name()?.takeIf { it.isNotBlank() }
|
|
793
|
+
nextStepRoad = upcomingStep?.name()?.takeIf { it.isNotBlank() }
|
|
794
|
+
|
|
795
|
+
// Verbatim instruction for the maneuver we're showing (the upcoming
|
|
796
|
+
// turn). Same step as `maneuver`/`distToManeuver` so they stay
|
|
797
|
+
// consistent — the instruction describes the turn ahead, not the road
|
|
798
|
+
// we're currently walking.
|
|
799
|
+
val instruction = turnStep?.maneuver()?.instruction()?.takeIf { it.isNotBlank() }
|
|
800
|
+
|
|
801
|
+
val payload = ManeuverPayload(
|
|
802
|
+
maneuverType = maneuver,
|
|
803
|
+
distanceMeters = distToManeuver,
|
|
804
|
+
fromRoad = currentRoad,
|
|
805
|
+
toRoad = currentRoad, // legacy field == fromRoad, matching Google behavior
|
|
806
|
+
nextStepRoad = nextStepRoad,
|
|
807
|
+
distanceToDestinationMeters = distanceToDestinationMeters,
|
|
808
|
+
timeToDestinationSeconds = timeToDestinationSeconds,
|
|
809
|
+
currentSpeedMps = lastSpeedMps,
|
|
810
|
+
routeHeadingDeg = null,
|
|
811
|
+
instruction = instruction,
|
|
812
|
+
)
|
|
813
|
+
emitManeuverIfChanged(payload, callbacks)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Per-METRE dedup. ReplayRouteSession/RouteProgress ticks ~1 Hz while
|
|
818
|
+
* moving, so a 1 m bucket means the maneuver "In X m" countdown updates
|
|
819
|
+
* roughly once a second and stays in lockstep with the trip
|
|
820
|
+
* distance-to-destination shown on the HUD. (The old 5 m / 10 m buckets
|
|
821
|
+
* made "In 200 m" look frozen for several seconds and let the maneuver
|
|
822
|
+
* distance drift out of agreement with the overall distance on the final
|
|
823
|
+
* leg — e.g. card "In 70 m" while the HUD already said 65 m.)
|
|
824
|
+
*
|
|
825
|
+
* Both distance fields go into the key at 1 m granularity so a change in
|
|
826
|
+
* EITHER re-emits — the card's "In X m" and the HUD's "Arriving in X m"
|
|
827
|
+
* track the same value, to the metre, all the way to the door.
|
|
828
|
+
*/
|
|
829
|
+
private fun emitManeuverIfChanged(payload: ManeuverPayload, callbacks: Callbacks) {
|
|
830
|
+
val distBucket = if (payload.distanceMeters >= 0) payload.distanceMeters else -1
|
|
831
|
+
val tripBucket = if (payload.distanceToDestinationMeters >= 0) payload.distanceToDestinationMeters else -1
|
|
832
|
+
val key = "${payload.maneuverType}|$distBucket|$tripBucket"
|
|
833
|
+
// Diagnostic: log EVERY tick (before the dedup early-return) so we can see
|
|
834
|
+
// whether the step distance (top box) is actually moving in lockstep with
|
|
835
|
+
// the trip distance (bottom box), and whether the dedup is suppressing it.
|
|
836
|
+
android.util.Log.d(
|
|
837
|
+
"NavManeuverDist",
|
|
838
|
+
"step=${payload.distanceMeters}m trip=${payload.distanceToDestinationMeters}m type=${payload.maneuverType} key=$key emit=${key != lastEmittedKey}",
|
|
839
|
+
)
|
|
840
|
+
if (key == lastEmittedKey) return
|
|
841
|
+
lastEmittedKey = key
|
|
842
|
+
callbacks.onManeuver(payload)
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private fun handleLocation(result: LocationMatcherResult, callbacks: Callbacks) {
|
|
846
|
+
val loc = result.enhancedLocation
|
|
847
|
+
|
|
848
|
+
// First-fix gate: if a route request is pending (start() deferred it
|
|
849
|
+
// until we had a real origin), fire it now with the raw device
|
|
850
|
+
// position — NOT any dev-shifted variant. Cleared so it runs once.
|
|
851
|
+
val pending = pendingRouteRequest
|
|
852
|
+
if (pending != null) {
|
|
853
|
+
pendingRouteRequest = null
|
|
854
|
+
pending(loc.latitude, loc.longitude)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
var effectiveLat = loc.latitude
|
|
858
|
+
var effectiveLng = loc.longitude
|
|
859
|
+
|
|
860
|
+
if (wrongSidewalkOffsetEnabled) {
|
|
861
|
+
val flat = activePolyline
|
|
862
|
+
if (flat != null && flat.size >= 2) {
|
|
863
|
+
val (idx, _) = closestSegmentIndex(flat, effectiveLat, effectiveLng)
|
|
864
|
+
val a = flat[idx]
|
|
865
|
+
val b = flat[(idx + 1).coerceAtMost(flat.size - 1)]
|
|
866
|
+
val routeBearing = bearing(a.first, a.second, b.first, b.second)
|
|
867
|
+
val perpBearing = (routeBearing + 90.0) % 360.0
|
|
868
|
+
val (offLat, offLng) = movePoint(effectiveLat, effectiveLng, WRONG_SIDEWALK_OFFSET_M, perpBearing)
|
|
869
|
+
effectiveLat = offLat
|
|
870
|
+
effectiveLng = offLng
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
prevFixLat = lastFixLat
|
|
875
|
+
prevFixLng = lastFixLng
|
|
876
|
+
lastFixLat = effectiveLat
|
|
877
|
+
lastFixLng = effectiveLng
|
|
878
|
+
lastSpeedMps = loc.speed?.toFloat()
|
|
879
|
+
|
|
880
|
+
callbacks.onLocation(
|
|
881
|
+
LocationPayload(
|
|
882
|
+
lat = effectiveLat,
|
|
883
|
+
lng = effectiveLng,
|
|
884
|
+
accuracy = loc.horizontalAccuracy?.toFloat(),
|
|
885
|
+
timestamp = System.currentTimeMillis(),
|
|
886
|
+
),
|
|
887
|
+
)
|
|
888
|
+
// Off-route detection is now FULLY owned by Mapbox (OffRouteObserver +
|
|
889
|
+
// automatic reroute). No hand-rolled perpendicular-distance check here.
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Perp distance from the last fix to the active polyline, or null. */
|
|
893
|
+
private fun currentPerpDistanceMeters(): Double? {
|
|
894
|
+
val flat = activePolyline ?: return null
|
|
895
|
+
if (flat.size < 2 || lastFixLat.isNaN() || lastFixLng.isNaN()) return null
|
|
896
|
+
val (_, perp) = closestSegmentIndex(flat, lastFixLat, lastFixLng)
|
|
897
|
+
return perp
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// =====================================================================
|
|
901
|
+
// Route emit — flatten a NavigationRoute into the wire RoutePoint/RouteStep
|
|
902
|
+
// shape. Mapbox carries steps inline, so unlike Google there's no deferred
|
|
903
|
+
// re-emit waiting on a separate NavInfo service.
|
|
904
|
+
// =====================================================================
|
|
905
|
+
|
|
906
|
+
private fun emitRoute(route: NavigationRoute, callbacks: Callbacks) {
|
|
907
|
+
try {
|
|
908
|
+
val directionsRoute = route.directionsRoute
|
|
909
|
+
val geometry = directionsRoute.geometry()
|
|
910
|
+
val points: List<RoutePoint> = decodeGeometry(route)
|
|
911
|
+
if (points.isEmpty()) {
|
|
912
|
+
Log.w(TAG, "route geometry empty, nothing to emit")
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
val steps = buildRouteSteps(directionsRoute, points)
|
|
916
|
+
activePolyline = points.map { it.lat to it.lng }
|
|
917
|
+
activeSteps = steps
|
|
918
|
+
Log.d(TAG, "emit route — ${points.size} points, steps=${steps?.size ?: "null"}")
|
|
919
|
+
callbacks.onRoute(points, steps)
|
|
920
|
+
} catch (e: Exception) {
|
|
921
|
+
Log.e(TAG, "emitRoute failed", e)
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Decode the route polyline. NavigationRoute exposes the full geometry. */
|
|
926
|
+
private fun decodeGeometry(route: NavigationRoute): List<RoutePoint> {
|
|
927
|
+
val out = ArrayList<RoutePoint>()
|
|
928
|
+
// The directions route geometry honors the request's geometry
|
|
929
|
+
// precision; NavigationRoute also exposes per-step geometry. Walk the
|
|
930
|
+
// legs' steps' geometry to assemble the full polyline in order.
|
|
931
|
+
val legs = route.directionsRoute.legs() ?: return out
|
|
932
|
+
for (leg in legs) {
|
|
933
|
+
val steps = leg.steps() ?: continue
|
|
934
|
+
for (step in steps) {
|
|
935
|
+
val geom = step.geometry() ?: continue
|
|
936
|
+
val pts = com.mapbox.geojson.utils.PolylineUtils.decode(geom, 6)
|
|
937
|
+
for (p in pts) out.add(RoutePoint(p.latitude(), p.longitude()))
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
return out
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Build the wire-shape step list from the Directions legs/steps. Unlike
|
|
945
|
+
* Google's StepInfo (no coordinates), Mapbox `LegStep` carries its own
|
|
946
|
+
* geometry, so each step's start vertex is exact — no polyline-walking
|
|
947
|
+
* by cumulative distance needed.
|
|
948
|
+
*/
|
|
949
|
+
private fun buildRouteSteps(route: DirectionsRoute, points: List<RoutePoint>): List<RouteStep>? {
|
|
950
|
+
val legs = route.legs() ?: return null
|
|
951
|
+
val out = ArrayList<RouteStep>()
|
|
952
|
+
var runningIndex = 0
|
|
953
|
+
for (leg in legs) {
|
|
954
|
+
val steps = leg.steps() ?: continue
|
|
955
|
+
for (step in steps) {
|
|
956
|
+
val maneuver = step.maneuver()?.let { mapManeuver(it) } ?: "STRAIGHT"
|
|
957
|
+
val road = step.name()?.takeIf { it.isNotBlank() }
|
|
958
|
+
val loc = step.maneuver()?.location()
|
|
959
|
+
val lat = loc?.latitude() ?: continue
|
|
960
|
+
val lng = loc.longitude()
|
|
961
|
+
// routeIndex: nearest polyline vertex to this step's maneuver point.
|
|
962
|
+
val idx = nearestPointIndex(points, lat, lng, runningIndex)
|
|
963
|
+
runningIndex = idx
|
|
964
|
+
out.add(
|
|
965
|
+
RouteStep(
|
|
966
|
+
lat = lat,
|
|
967
|
+
lng = lng,
|
|
968
|
+
routeIndex = idx,
|
|
969
|
+
road = road,
|
|
970
|
+
maneuver = maneuver,
|
|
971
|
+
distanceMeters = step.distance().toInt(),
|
|
972
|
+
),
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return if (out.isEmpty()) null else out
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Nearest polyline vertex to (lat,lng), scanning forward from `from`. */
|
|
980
|
+
private fun nearestPointIndex(points: List<RoutePoint>, lat: Double, lng: Double, from: Int): Int {
|
|
981
|
+
var best = from.coerceIn(0, points.size - 1)
|
|
982
|
+
var bestD = Double.MAX_VALUE
|
|
983
|
+
for (i in from until points.size) {
|
|
984
|
+
val dLat = points[i].lat - lat
|
|
985
|
+
val dLng = points[i].lng - lng
|
|
986
|
+
val d = dLat * dLat + dLng * dLng
|
|
987
|
+
if (d < bestD) { bestD = d; best = i }
|
|
988
|
+
}
|
|
989
|
+
return best
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// =====================================================================
|
|
993
|
+
// Maneuver mapping — Mapbox `type` + `modifier` → our ManeuverKind union.
|
|
994
|
+
// (Replaces NavInfoReceiverService.mapManeuver's Google-enum table.)
|
|
995
|
+
// =====================================================================
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Reduce a Mapbox StepManeuver (type + modifier) to the categorical
|
|
999
|
+
* string vocabulary the bridge already uses: STRAIGHT, CONTINUE,
|
|
1000
|
+
* SLIGHT_LEFT, SLIGHT_RIGHT, TURN_LEFT, TURN_RIGHT, SHARP_LEFT,
|
|
1001
|
+
* SHARP_RIGHT, U_TURN, NAME_CHANGE, DEPART, ARRIVE.
|
|
1002
|
+
*
|
|
1003
|
+
* Per the migration doc §7. The PivotEngine trusts polyline geometry
|
|
1004
|
+
* over this string for left/right, so roundabout approximations degrade
|
|
1005
|
+
* gracefully.
|
|
1006
|
+
*/
|
|
1007
|
+
@JvmStatic
|
|
1008
|
+
fun mapManeuver(maneuver: StepManeuver): String {
|
|
1009
|
+
val type = maneuver.type()
|
|
1010
|
+
val modifier = maneuver.modifier()
|
|
1011
|
+
return when (type) {
|
|
1012
|
+
StepManeuver.DEPART -> "DEPART"
|
|
1013
|
+
StepManeuver.ARRIVE -> "ARRIVE"
|
|
1014
|
+
StepManeuver.NEW_NAME -> "NAME_CHANGE"
|
|
1015
|
+
StepManeuver.CONTINUE, StepManeuver.MERGE -> "CONTINUE"
|
|
1016
|
+
StepManeuver.NOTIFICATION -> "STRAIGHT"
|
|
1017
|
+
// turn / end of road / fork / on ramp / off ramp / roundabout* →
|
|
1018
|
+
// classify by modifier.
|
|
1019
|
+
else -> classifyModifier(modifier)
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private fun classifyModifier(modifier: String?): String = when (modifier) {
|
|
1024
|
+
ManeuverModifier.UTURN -> "U_TURN"
|
|
1025
|
+
ManeuverModifier.SHARP_LEFT -> "SHARP_LEFT"
|
|
1026
|
+
ManeuverModifier.LEFT -> "TURN_LEFT"
|
|
1027
|
+
ManeuverModifier.SLIGHT_LEFT -> "SLIGHT_LEFT"
|
|
1028
|
+
ManeuverModifier.STRAIGHT -> "STRAIGHT"
|
|
1029
|
+
ManeuverModifier.SLIGHT_RIGHT -> "SLIGHT_RIGHT"
|
|
1030
|
+
ManeuverModifier.RIGHT -> "TURN_RIGHT"
|
|
1031
|
+
ManeuverModifier.SHARP_RIGHT -> "SHARP_RIGHT"
|
|
1032
|
+
else -> "STRAIGHT"
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// =====================================================================
|
|
1036
|
+
// Dev simulation — full parity with the Google implementation, driven by
|
|
1037
|
+
// MapboxReplayer instead of Google's Navigator.simulator.
|
|
1038
|
+
// =====================================================================
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Dev-only: shove the simulated puck OFF the active route so Mapbox's
|
|
1042
|
+
* built-in off-route detection + automatic rerouting kick in — exactly
|
|
1043
|
+
* like a real GPS divergence. We no longer hand-roll the off-route math
|
|
1044
|
+
* or call requestRoutes ourselves; we just teleport the replay puck
|
|
1045
|
+
* `DEVIATE_OFFSET_M` perpendicular to the route and let the SDK do the
|
|
1046
|
+
* rest (OffRouteObserver → onOffRoute, RerouteState → onRerouting,
|
|
1047
|
+
* RoutesObserver(REROUTE) → onRoute).
|
|
1048
|
+
*
|
|
1049
|
+
* Requires simulate mode (a replayer to inject into). On a real-GPS trip
|
|
1050
|
+
* there's nothing to push, so it no-ops with a log. `offsetMeters` from
|
|
1051
|
+
* the legacy protocol overrides the default jump distance when > 0.
|
|
1052
|
+
*/
|
|
1053
|
+
@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
|
|
1054
|
+
fun simulateDeviation(offsetMeters: Double = 0.0) {
|
|
1055
|
+
val replayer = replayer ?: run {
|
|
1056
|
+
Log.w(TAG, "simulateDeviation: not in simulate mode — nothing to push off-route")
|
|
1057
|
+
return
|
|
1058
|
+
}
|
|
1059
|
+
if (lastFixLat.isNaN() || lastFixLng.isNaN()) {
|
|
1060
|
+
Log.w(TAG, "simulateDeviation: no last fix yet")
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
val nav = mapboxNavigation ?: run { Log.w(TAG, "simulateDeviation: no navigation"); return }
|
|
1065
|
+
|
|
1066
|
+
// Perpendicular bearing to walk along. Use the route's local bearing at
|
|
1067
|
+
// the puck so we step SIDEWAYS off the road; fall back to the user's
|
|
1068
|
+
// recent travel bearing + 90°.
|
|
1069
|
+
val flat = activePolyline
|
|
1070
|
+
val sideBearing: Double = if (flat != null && flat.size >= 2) {
|
|
1071
|
+
val (idx, _) = closestSegmentIndex(flat, lastFixLat, lastFixLng)
|
|
1072
|
+
val a = flat[idx]
|
|
1073
|
+
val b = flat[(idx + 1).coerceAtMost(flat.size - 1)]
|
|
1074
|
+
(bearing(a.first, a.second, b.first, b.second) + 90.0) % 360.0
|
|
1075
|
+
} else if (!prevFixLat.isNaN() && haversine(prevFixLat, prevFixLng, lastFixLat, lastFixLng) > 0.5) {
|
|
1076
|
+
(bearing(prevFixLat, prevFixLng, lastFixLat, lastFixLng) + 90.0) % 360.0
|
|
1077
|
+
} else {
|
|
1078
|
+
90.0 // arbitrary — due east
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
val maxJump = if (offsetMeters > 0.0) offsetMeters else DEVIATE_OFFSET_M
|
|
1082
|
+
|
|
1083
|
+
// THE KEY: ReplayRouteSession keeps pushing ON-ROUTE locations every
|
|
1084
|
+
// RouteProgress tick (its internal RouteProgressObserver → pushEvents),
|
|
1085
|
+
// so a one-off off-route blip is immediately buried and the puck never
|
|
1086
|
+
// actually leaves the route. To force a real divergence we must:
|
|
1087
|
+
// 1. detach the session so it stops feeding on-route points,
|
|
1088
|
+
// 2. clear its queued on-route events,
|
|
1089
|
+
// 3. push a SUSTAINED stream walking progressively off-route (so the
|
|
1090
|
+
// divergence persists long enough for Mapbox to detect it),
|
|
1091
|
+
// 4. once the reroute lands (RoutesObserver REROUTE), the session is
|
|
1092
|
+
// re-attached so the puck resumes on the NEW route.
|
|
1093
|
+
try {
|
|
1094
|
+
replaySession?.onDetached(nav)
|
|
1095
|
+
replayer.clearEvents()
|
|
1096
|
+
|
|
1097
|
+
// Walk from the puck outward to maxJump in ~10m steps, each a
|
|
1098
|
+
// separate replay location so the off-route position is held across
|
|
1099
|
+
// several ticks rather than a single frame.
|
|
1100
|
+
//
|
|
1101
|
+
// CRITICAL: the replayer plays events ordered/paced by eventTimestamp.
|
|
1102
|
+
// All-zero timestamps would collapse into a single instant and never
|
|
1103
|
+
// play as a sequence — so we stamp each event with a monotonically
|
|
1104
|
+
// increasing time (1s apart) to hold the off-route position over
|
|
1105
|
+
// several real seconds, which is what lets the detector latch.
|
|
1106
|
+
val baseLat = lastFixLat
|
|
1107
|
+
val baseLng = lastFixLng
|
|
1108
|
+
val events = ArrayList<ReplayEventBase>()
|
|
1109
|
+
var d = 10.0
|
|
1110
|
+
var ts = 0.0
|
|
1111
|
+
while (d <= maxJump) {
|
|
1112
|
+
val (oLat, oLng) = movePoint(baseLat, baseLng, d, sideBearing)
|
|
1113
|
+
events.add(ReplayRouteMapper.mapToUpdateLocation(ts, Point.fromLngLat(oLng, oLat)))
|
|
1114
|
+
d += 10.0
|
|
1115
|
+
ts += 1.0
|
|
1116
|
+
}
|
|
1117
|
+
// Hold at the farthest point for a few extra seconds so the off-route
|
|
1118
|
+
// condition is unambiguous and the detector latches.
|
|
1119
|
+
val (farLat, farLng) = movePoint(baseLat, baseLng, maxJump, sideBearing)
|
|
1120
|
+
val farPoint = Point.fromLngLat(farLng, farLat)
|
|
1121
|
+
repeat(6) {
|
|
1122
|
+
events.add(ReplayRouteMapper.mapToUpdateLocation(ts, farPoint))
|
|
1123
|
+
ts += 1.0
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
Log.d(TAG, "simulateDeviation: detached session, walking ${maxJump}m off-route in ${events.size} events")
|
|
1127
|
+
replayer.pushEvents(events)
|
|
1128
|
+
replayer.play()
|
|
1129
|
+
|
|
1130
|
+
// The divergence above moves the puck off-route (which exercises the
|
|
1131
|
+
// OffRouteObserver / banner path). But to GUARANTEE a reroute even if
|
|
1132
|
+
// the replayed divergence is too brief for the native detector, also
|
|
1133
|
+
// ask the RerouteController to reroute directly — this is the reliable
|
|
1134
|
+
// trigger. It fetches a new route from the current (now off-route)
|
|
1135
|
+
// position and the result flows through RoutesObserver(REROUTE) →
|
|
1136
|
+
// onRoute exactly like an automatic reroute. Fire it slightly after
|
|
1137
|
+
// the off-route events start playing so the controller reroutes from
|
|
1138
|
+
// the diverged position, not the on-route one.
|
|
1139
|
+
val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
1140
|
+
mainHandler.postDelayed({
|
|
1141
|
+
try {
|
|
1142
|
+
val controller = mapboxNavigation?.getRerouteController()
|
|
1143
|
+
if (controller != null) {
|
|
1144
|
+
// onRerouting fires automatically via our RerouteStateObserver
|
|
1145
|
+
// (FetchingRoute) when reroute() runs — no manual call needed.
|
|
1146
|
+
Log.d(TAG, "simulateDeviation: requesting reroute() directly")
|
|
1147
|
+
controller.reroute(
|
|
1148
|
+
RerouteController.RoutesCallback { routes: List<NavigationRoute>, _: String ->
|
|
1149
|
+
// RoutesObserver(REROUTE) will also fire and emit the route;
|
|
1150
|
+
// we don't setNavigationRoutes here (the controller does).
|
|
1151
|
+
Log.d(TAG, "simulateDeviation: reroute() returned ${routes.size} route(s)")
|
|
1152
|
+
},
|
|
1153
|
+
)
|
|
1154
|
+
} else {
|
|
1155
|
+
Log.w(TAG, "simulateDeviation: no reroute controller (auto-reroute disabled?)")
|
|
1156
|
+
}
|
|
1157
|
+
} catch (e: Throwable) {
|
|
1158
|
+
Log.e(TAG, "simulateDeviation reroute() failed", e)
|
|
1159
|
+
}
|
|
1160
|
+
}, 1500L)
|
|
1161
|
+
|
|
1162
|
+
// Re-attach the session so it resumes driving the puck once a route is
|
|
1163
|
+
// active again (locationResetEnabled snaps it onto the new route).
|
|
1164
|
+
// Delay until AFTER the off-route events + reroute fetch.
|
|
1165
|
+
val playSeconds = (ts / simulationSpeed.coerceAtLeast(0.1f)).toDouble()
|
|
1166
|
+
val reattachMs = ((playSeconds + 4.0) * 1000.0).toLong().coerceIn(5000L, 20000L)
|
|
1167
|
+
mainHandler.postDelayed({
|
|
1168
|
+
try {
|
|
1169
|
+
if (simulating) replaySession?.onAttached(nav)
|
|
1170
|
+
} catch (e: Throwable) {
|
|
1171
|
+
Log.w(TAG, "simulateDeviation: re-attach session failed: ${e.message}")
|
|
1172
|
+
}
|
|
1173
|
+
}, reattachMs)
|
|
1174
|
+
} catch (e: Throwable) {
|
|
1175
|
+
Log.e(TAG, "simulateDeviation push failed", e)
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Dev toggle. Shift every emitted location ~8m perpendicular-right of the
|
|
1181
|
+
* route bearing, simulating a wrong-sidewalk pedestrian.
|
|
1182
|
+
*/
|
|
1183
|
+
fun setWrongSidewalkOffset(enabled: Boolean) {
|
|
1184
|
+
Log.d(TAG, "setWrongSidewalkOffset($enabled)")
|
|
1185
|
+
wrongSidewalkOffsetEnabled = enabled
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Dev toggle. Take over from the replayer and walk a polyline with
|
|
1190
|
+
* crossing micro-steps removed. When disabled, hand control back to the
|
|
1191
|
+
* replayer on the active route.
|
|
1192
|
+
*/
|
|
1193
|
+
fun setSkipCrossings(enabled: Boolean) {
|
|
1194
|
+
Log.d(TAG, "setSkipCrossings($enabled)")
|
|
1195
|
+
skipCrossingsEnabled = enabled
|
|
1196
|
+
if (enabled) startSkipCrossingsWalker() else stopSkipCrossingsWalker(resumeReplay = true)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private fun startSkipCrossingsWalker() {
|
|
1200
|
+
val flat = activePolyline
|
|
1201
|
+
if (flat == null || flat.size < 2) { Log.w(TAG, "startSkipCrossingsWalker: no route polyline"); return }
|
|
1202
|
+
try { replayer?.stop() } catch (_: Throwable) {}
|
|
1203
|
+
val modified = stripCrossings(flat)
|
|
1204
|
+
Log.d(TAG, "skip-crossings walker: original=${flat.size} pts, modified=${modified.size} pts")
|
|
1205
|
+
stopSkipCrossingsWalker(resumeReplay = false)
|
|
1206
|
+
val startIdx = if (!lastFixLat.isNaN() && !lastFixLng.isNaN()) {
|
|
1207
|
+
closestPolylineIndex(modified, lastFixLat, lastFixLng)
|
|
1208
|
+
} else 0
|
|
1209
|
+
val timer = java.util.Timer("skipCrossingsWalker", true)
|
|
1210
|
+
val stepMeters = SKIP_CROSSINGS_BASE_M_PER_TICK * simulationSpeed
|
|
1211
|
+
var cursor = startIdx.toDouble()
|
|
1212
|
+
val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
1213
|
+
timer.scheduleAtFixedRate(object : java.util.TimerTask() {
|
|
1214
|
+
override fun run() {
|
|
1215
|
+
mainHandler.post {
|
|
1216
|
+
try {
|
|
1217
|
+
if (!skipCrossingsEnabled) { cancel(); return@post }
|
|
1218
|
+
cursor = advanceCursor(modified, cursor, stepMeters)
|
|
1219
|
+
if (cursor >= modified.size - 1) { cancel(); return@post }
|
|
1220
|
+
val (lat, lng) = pointAt(modified, cursor)
|
|
1221
|
+
val brng = run {
|
|
1222
|
+
val (nlat, nlng) = pointAt(modified, (cursor + 0.5).coerceAtMost(modified.size - 1.0))
|
|
1223
|
+
bearing(lat, lng, nlat, nlng)
|
|
1224
|
+
}
|
|
1225
|
+
pushReplayLocation(lat, lng, brng)
|
|
1226
|
+
activeCallbacks?.onLocation(LocationPayload(lat, lng, null, System.currentTimeMillis()))
|
|
1227
|
+
prevFixLat = lastFixLat; prevFixLng = lastFixLng
|
|
1228
|
+
lastFixLat = lat; lastFixLng = lng
|
|
1229
|
+
} catch (e: Throwable) {
|
|
1230
|
+
Log.e(TAG, "skip-crossings walker tick failed", e)
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}, 0L, SKIP_CROSSINGS_TICK_MS)
|
|
1235
|
+
skipCrossingsTimer = timer
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private fun stopSkipCrossingsWalker(resumeReplay: Boolean) {
|
|
1239
|
+
skipCrossingsTimer?.cancel(); skipCrossingsTimer = null
|
|
1240
|
+
if (resumeReplay && simulating) restartReplayOnActiveRoute()
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* (Re)start the replayer driving the current active route at
|
|
1245
|
+
* simulationSpeed. Called once the route lands (start) and on every
|
|
1246
|
+
* reroute.
|
|
1247
|
+
*
|
|
1248
|
+
* Ordering matters and was the source of the "sim sometimes doesn't go
|
|
1249
|
+
* off" bug:
|
|
1250
|
+
* 1. stop() the player first — if it's mid-playback on the old buffer
|
|
1251
|
+
* (or the bootstrap seed location), pushing/clearing under it raced
|
|
1252
|
+
* and play() could no-op.
|
|
1253
|
+
* 2. clearEvents() wipes the seed + any prior route events so the new
|
|
1254
|
+
* route plays from its true start.
|
|
1255
|
+
* 3. set playbackSpeed BEFORE play so the first tick is already at the
|
|
1256
|
+
* right rate.
|
|
1257
|
+
* 4. play() — starts from the head of the freshly-pushed buffer. We do
|
|
1258
|
+
* NOT seekTo(firstEvent): seeking to a specific event object after a
|
|
1259
|
+
* clear was unreliable (it occasionally landed past the start or on
|
|
1260
|
+
* a stale reference), leaving the puck frozen.
|
|
1261
|
+
*/
|
|
1262
|
+
private fun restartReplayOnActiveRoute() {
|
|
1263
|
+
val nav = mapboxNavigation ?: return
|
|
1264
|
+
val replayer = replayer ?: return
|
|
1265
|
+
val route = nav.getNavigationRoutes().firstOrNull() ?: return
|
|
1266
|
+
try {
|
|
1267
|
+
val events = replayRouteMapper.mapDirectionsRouteGeometry(route.directionsRoute)
|
|
1268
|
+
if (events.isEmpty()) {
|
|
1269
|
+
Log.w(TAG, "restartReplayOnActiveRoute: no replay events from route geometry")
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
replayer.stop()
|
|
1273
|
+
replayer.clearEvents()
|
|
1274
|
+
replayer.pushEvents(events)
|
|
1275
|
+
replayer.playbackSpeed(simulationSpeed.toDouble())
|
|
1276
|
+
replayer.play()
|
|
1277
|
+
Log.d(TAG, "replay restarted — ${events.size} events @ ${simulationSpeed}×")
|
|
1278
|
+
} catch (e: Throwable) {
|
|
1279
|
+
Log.w(TAG, "restartReplayOnActiveRoute failed: ${e.message}")
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Push one synthetic location into the replayer (used by the dev walkers). */
|
|
1284
|
+
private fun pushReplayLocation(lat: Double, lng: Double, bearingDeg: Double) {
|
|
1285
|
+
val replayer = replayer ?: return
|
|
1286
|
+
// ReplayEventLocation(lon, lat, provider, time, altitude,
|
|
1287
|
+
// accuracyHorizontal, bearing, speed) — positional; nullable fields
|
|
1288
|
+
// are boxed Double?.
|
|
1289
|
+
val location = ReplayEventLocation(
|
|
1290
|
+
lng,
|
|
1291
|
+
lat,
|
|
1292
|
+
"mentra-dev-walker",
|
|
1293
|
+
null,
|
|
1294
|
+
null,
|
|
1295
|
+
null,
|
|
1296
|
+
bearingDeg,
|
|
1297
|
+
DEVIATE_BASE_MPS * simulationSpeed,
|
|
1298
|
+
)
|
|
1299
|
+
val event = ReplayEventUpdateLocation(0.0, location)
|
|
1300
|
+
try {
|
|
1301
|
+
replayer.pushEvents(listOf<ReplayEventBase>(event))
|
|
1302
|
+
replayer.play()
|
|
1303
|
+
} catch (e: Throwable) {
|
|
1304
|
+
Log.w(TAG, "pushReplayLocation failed: ${e.message}")
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
// ---- crossing strip (unchanged math from the Google implementation) ----
|
|
1309
|
+
|
|
1310
|
+
private fun stripCrossings(points: List<Pair<Double, Double>>): List<Pair<Double, Double>> {
|
|
1311
|
+
if (points.size < 4) return points
|
|
1312
|
+
val out = ArrayList<Pair<Double, Double>>(points.size)
|
|
1313
|
+
out.add(points[0])
|
|
1314
|
+
var i = 1
|
|
1315
|
+
while (i < points.size - 1) {
|
|
1316
|
+
val prev = points[i - 1]; val here = points[i]; val next = points[i + 1]
|
|
1317
|
+
val legOut = haversine(here.first, here.second, next.first, next.second)
|
|
1318
|
+
val bearIn = bearing(prev.first, prev.second, here.first, here.second)
|
|
1319
|
+
val bearOut = bearing(here.first, here.second, next.first, next.second)
|
|
1320
|
+
val bend = bearingDiffAbs(bearIn, bearOut)
|
|
1321
|
+
val looksLikeCrossingStart = legOut < CROSSING_MAX_LEG_METERS && bend > CROSSING_MIN_BEND_DEG
|
|
1322
|
+
if (looksLikeCrossingStart && i + 2 < points.size) {
|
|
1323
|
+
val afterNext = points[i + 2]
|
|
1324
|
+
val bearAfter = bearing(next.first, next.second, afterNext.first, afterNext.second)
|
|
1325
|
+
val bendBack = bearingDiffAbs(bearOut, bearAfter)
|
|
1326
|
+
val isCrossing = bendBack > CROSSING_MIN_BEND_DEG &&
|
|
1327
|
+
bearingDiffAbs(bearIn, bearAfter) < CROSSING_MIN_BEND_DEG
|
|
1328
|
+
if (isCrossing) {
|
|
1329
|
+
val legAfter = haversine(next.first, next.second, afterNext.first, afterNext.second)
|
|
1330
|
+
val skipDist = legOut + legAfter
|
|
1331
|
+
val (newLat, newLng) = movePoint(here.first, here.second, skipDist, bearIn)
|
|
1332
|
+
out.add(Pair(newLat, newLng))
|
|
1333
|
+
i += 3
|
|
1334
|
+
continue
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
out.add(here)
|
|
1338
|
+
i++
|
|
1339
|
+
}
|
|
1340
|
+
if (out.last() != points.last()) out.add(points.last())
|
|
1341
|
+
return out
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private fun bearingDiffAbs(a: Double, b: Double): Double {
|
|
1345
|
+
val d = (a - b + 540.0) % 360.0 - 180.0
|
|
1346
|
+
return kotlin.math.abs(d)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
private fun closestPolylineIndex(points: List<Pair<Double, Double>>, lat: Double, lng: Double): Int {
|
|
1350
|
+
var best = 0; var bestD = Double.MAX_VALUE
|
|
1351
|
+
for (i in points.indices) {
|
|
1352
|
+
val (plat, plng) = points[i]
|
|
1353
|
+
val dx = plat - lat; val dy = plng - lng
|
|
1354
|
+
val d = dx * dx + dy * dy
|
|
1355
|
+
if (d < bestD) { bestD = d; best = i }
|
|
1356
|
+
}
|
|
1357
|
+
return best
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
private fun advanceCursor(points: List<Pair<Double, Double>>, cursor: Double, meters: Double): Double {
|
|
1361
|
+
if (points.size < 2) return cursor.coerceAtMost(0.0)
|
|
1362
|
+
var idx = cursor.toInt(); var frac = cursor - idx; var remaining = meters
|
|
1363
|
+
while (remaining > 0 && idx < points.size - 1) {
|
|
1364
|
+
val a = points[idx]; val b = points[idx + 1]
|
|
1365
|
+
val segLen = haversine(a.first, a.second, b.first, b.second)
|
|
1366
|
+
val segLeft = (1.0 - frac) * segLen
|
|
1367
|
+
if (remaining < segLeft) { frac += remaining / segLen; remaining = 0.0 }
|
|
1368
|
+
else { remaining -= segLeft; idx++; frac = 0.0 }
|
|
1369
|
+
}
|
|
1370
|
+
return idx.toDouble() + frac
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
private fun pointAt(points: List<Pair<Double, Double>>, cursor: Double): Pair<Double, Double> {
|
|
1374
|
+
val idx = cursor.toInt().coerceIn(0, points.size - 1)
|
|
1375
|
+
val frac = (cursor - idx).coerceIn(0.0, 1.0)
|
|
1376
|
+
if (idx >= points.size - 1) return points.last()
|
|
1377
|
+
val a = points[idx]; val b = points[idx + 1]
|
|
1378
|
+
return Pair(a.first + (b.first - a.first) * frac, a.second + (b.second - a.second) * frac)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
private fun movePoint(lat: Double, lng: Double, meters: Double, bearingDeg: Double): Pair<Double, Double> {
|
|
1382
|
+
val r = 6_371_000.0
|
|
1383
|
+
val brng = Math.toRadians(bearingDeg)
|
|
1384
|
+
val lat1 = Math.toRadians(lat); val lng1 = Math.toRadians(lng)
|
|
1385
|
+
val ad = meters / r
|
|
1386
|
+
val lat2 = kotlin.math.asin(
|
|
1387
|
+
kotlin.math.sin(lat1) * kotlin.math.cos(ad) +
|
|
1388
|
+
kotlin.math.cos(lat1) * kotlin.math.sin(ad) * kotlin.math.cos(brng),
|
|
1389
|
+
)
|
|
1390
|
+
val lng2 = lng1 + kotlin.math.atan2(
|
|
1391
|
+
kotlin.math.sin(brng) * kotlin.math.sin(ad) * kotlin.math.cos(lat1),
|
|
1392
|
+
kotlin.math.cos(ad) - kotlin.math.sin(lat1) * kotlin.math.sin(lat2),
|
|
1393
|
+
)
|
|
1394
|
+
return Math.toDegrees(lat2) to Math.toDegrees(lng2)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// ---- math helpers (unchanged) ----
|
|
1398
|
+
|
|
1399
|
+
private fun haversine(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
|
|
1400
|
+
val r = 6_371_000.0
|
|
1401
|
+
val dLat = Math.toRadians(lat2 - lat1)
|
|
1402
|
+
val dLng = Math.toRadians(lng2 - lng1)
|
|
1403
|
+
val a = kotlin.math.sin(dLat / 2).let { it * it } +
|
|
1404
|
+
kotlin.math.cos(Math.toRadians(lat1)) *
|
|
1405
|
+
kotlin.math.cos(Math.toRadians(lat2)) *
|
|
1406
|
+
kotlin.math.sin(dLng / 2).let { it * it }
|
|
1407
|
+
return 2 * r * kotlin.math.asin(kotlin.math.sqrt(a))
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
private fun bearing(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
|
|
1411
|
+
val phi1 = Math.toRadians(lat1); val phi2 = Math.toRadians(lat2)
|
|
1412
|
+
val lam1 = Math.toRadians(lng1); val lam2 = Math.toRadians(lng2)
|
|
1413
|
+
val y = kotlin.math.sin(lam2 - lam1) * kotlin.math.cos(phi2)
|
|
1414
|
+
val x = kotlin.math.cos(phi1) * kotlin.math.sin(phi2) -
|
|
1415
|
+
kotlin.math.sin(phi1) * kotlin.math.cos(phi2) * kotlin.math.cos(lam2 - lam1)
|
|
1416
|
+
val deg = Math.toDegrees(kotlin.math.atan2(y, x))
|
|
1417
|
+
return (deg + 360.0) % 360.0
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
private fun closestSegmentIndex(
|
|
1421
|
+
pts: List<Pair<Double, Double>>, lat: Double, lng: Double,
|
|
1422
|
+
): Pair<Int, Double> {
|
|
1423
|
+
var bestIdx = 0; var bestDist = Double.POSITIVE_INFINITY
|
|
1424
|
+
for (i in 0 until pts.size - 1) {
|
|
1425
|
+
val d = perpDistanceMeters(lat, lng, pts[i].first, pts[i].second, pts[i + 1].first, pts[i + 1].second)
|
|
1426
|
+
if (d < bestDist) { bestDist = d; bestIdx = i }
|
|
1427
|
+
}
|
|
1428
|
+
return Pair(bestIdx, bestDist)
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
private fun perpDistanceMeters(
|
|
1432
|
+
pLat: Double, pLng: Double, aLat: Double, aLng: Double, bLat: Double, bLng: Double,
|
|
1433
|
+
): Double {
|
|
1434
|
+
val mPerDegLat = 111_320.0
|
|
1435
|
+
val mPerDegLng = 111_320.0 * kotlin.math.cos(Math.toRadians(aLat))
|
|
1436
|
+
val px = (pLng - aLng) * mPerDegLng; val py = (pLat - aLat) * mPerDegLat
|
|
1437
|
+
val bx = (bLng - aLng) * mPerDegLng; val by = (bLat - aLat) * mPerDegLat
|
|
1438
|
+
val len2 = bx * bx + by * by
|
|
1439
|
+
if (len2 == 0.0) return kotlin.math.sqrt(px * px + py * py)
|
|
1440
|
+
var t = (px * bx + py * by) / len2
|
|
1441
|
+
t = t.coerceIn(0.0, 1.0)
|
|
1442
|
+
val projx = t * bx; val projy = t * by
|
|
1443
|
+
return kotlin.math.sqrt((px - projx).let { it * it } + (py - projy).let { it * it })
|
|
1444
|
+
}
|
|
1445
|
+
}
|