@mentra/miniapp 0.3.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.
Files changed (277) hide show
  1. package/README.md +259 -0
  2. package/dist/background/index.d.ts +60 -0
  3. package/dist/background/index.d.ts.map +1 -0
  4. package/dist/background/index.js +25 -0
  5. package/dist/background/index.js.map +1 -0
  6. package/dist/background/register.d.ts +38 -0
  7. package/dist/background/register.d.ts.map +1 -0
  8. package/dist/background/register.js +75 -0
  9. package/dist/background/register.js.map +1 -0
  10. package/dist/dev-reload.d.ts +21 -0
  11. package/dist/dev-reload.d.ts.map +1 -0
  12. package/dist/dev-reload.js +62 -0
  13. package/dist/dev-reload.js.map +1 -0
  14. package/dist/envelope.d.ts +23 -0
  15. package/dist/envelope.d.ts.map +1 -0
  16. package/dist/envelope.js +47 -0
  17. package/dist/envelope.js.map +1 -0
  18. package/dist/globals.d.ts +54 -0
  19. package/dist/globals.d.ts.map +1 -0
  20. package/dist/globals.js +12 -0
  21. package/dist/globals.js.map +1 -0
  22. package/dist/index.d.ts +59 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +31 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/actions.d.ts +54 -0
  27. package/dist/modules/actions.d.ts.map +1 -0
  28. package/dist/modules/actions.js +122 -0
  29. package/dist/modules/actions.js.map +1 -0
  30. package/dist/modules/auth.d.ts +25 -0
  31. package/dist/modules/auth.d.ts.map +1 -0
  32. package/dist/modules/auth.js +52 -0
  33. package/dist/modules/auth.js.map +1 -0
  34. package/dist/modules/base64.d.ts +16 -0
  35. package/dist/modules/base64.d.ts.map +1 -0
  36. package/dist/modules/base64.js +92 -0
  37. package/dist/modules/base64.js.map +1 -0
  38. package/dist/modules/blob.d.ts +161 -0
  39. package/dist/modules/blob.d.ts.map +1 -0
  40. package/dist/modules/blob.js +277 -0
  41. package/dist/modules/blob.js.map +1 -0
  42. package/dist/modules/camera.d.ts +170 -0
  43. package/dist/modules/camera.d.ts.map +1 -0
  44. package/dist/modules/camera.js +116 -0
  45. package/dist/modules/camera.js.map +1 -0
  46. package/dist/modules/cloud.d.ts +34 -0
  47. package/dist/modules/cloud.d.ts.map +1 -0
  48. package/dist/modules/cloud.js +70 -0
  49. package/dist/modules/cloud.js.map +1 -0
  50. package/dist/modules/dashboard.d.ts +16 -0
  51. package/dist/modules/dashboard.d.ts.map +1 -0
  52. package/dist/modules/dashboard.js +27 -0
  53. package/dist/modules/dashboard.js.map +1 -0
  54. package/dist/modules/display.d.ts +106 -0
  55. package/dist/modules/display.d.ts.map +1 -0
  56. package/dist/modules/display.js +57 -0
  57. package/dist/modules/display.js.map +1 -0
  58. package/dist/modules/events.d.ts +160 -0
  59. package/dist/modules/events.d.ts.map +1 -0
  60. package/dist/modules/events.js +168 -0
  61. package/dist/modules/events.js.map +1 -0
  62. package/dist/modules/glasses.d.ts +28 -0
  63. package/dist/modules/glasses.d.ts.map +1 -0
  64. package/dist/modules/glasses.js +39 -0
  65. package/dist/modules/glasses.js.map +1 -0
  66. package/dist/modules/heading.d.ts +16 -0
  67. package/dist/modules/heading.d.ts.map +1 -0
  68. package/dist/modules/heading.js +20 -0
  69. package/dist/modules/heading.js.map +1 -0
  70. package/dist/modules/imu.d.ts +31 -0
  71. package/dist/modules/imu.d.ts.map +1 -0
  72. package/dist/modules/imu.js +40 -0
  73. package/dist/modules/imu.js.map +1 -0
  74. package/dist/modules/input.d.ts +34 -0
  75. package/dist/modules/input.d.ts.map +1 -0
  76. package/dist/modules/input.js +50 -0
  77. package/dist/modules/input.js.map +1 -0
  78. package/dist/modules/led.d.ts +35 -0
  79. package/dist/modules/led.d.ts.map +1 -0
  80. package/dist/modules/led.js +39 -0
  81. package/dist/modules/led.js.map +1 -0
  82. package/dist/modules/location.d.ts +27 -0
  83. package/dist/modules/location.d.ts.map +1 -0
  84. package/dist/modules/location.js +35 -0
  85. package/dist/modules/location.js.map +1 -0
  86. package/dist/modules/mic.d.ts +44 -0
  87. package/dist/modules/mic.d.ts.map +1 -0
  88. package/dist/modules/mic.js +67 -0
  89. package/dist/modules/mic.js.map +1 -0
  90. package/dist/modules/miniapps.d.ts +72 -0
  91. package/dist/modules/miniapps.d.ts.map +1 -0
  92. package/dist/modules/miniapps.js +48 -0
  93. package/dist/modules/miniapps.js.map +1 -0
  94. package/dist/modules/navigation.d.ts +326 -0
  95. package/dist/modules/navigation.d.ts.map +1 -0
  96. package/dist/modules/navigation.js +386 -0
  97. package/dist/modules/navigation.js.map +1 -0
  98. package/dist/modules/permissions.d.ts +58 -0
  99. package/dist/modules/permissions.d.ts.map +1 -0
  100. package/dist/modules/permissions.js +66 -0
  101. package/dist/modules/permissions.js.map +1 -0
  102. package/dist/modules/phone.d.ts +95 -0
  103. package/dist/modules/phone.d.ts.map +1 -0
  104. package/dist/modules/phone.js +110 -0
  105. package/dist/modules/phone.js.map +1 -0
  106. package/dist/modules/pivots/engine.d.ts +169 -0
  107. package/dist/modules/pivots/engine.d.ts.map +1 -0
  108. package/dist/modules/pivots/engine.js +779 -0
  109. package/dist/modules/pivots/engine.js.map +1 -0
  110. package/dist/modules/pivots/geometry.d.ts +84 -0
  111. package/dist/modules/pivots/geometry.d.ts.map +1 -0
  112. package/dist/modules/pivots/geometry.js +371 -0
  113. package/dist/modules/pivots/geometry.js.map +1 -0
  114. package/dist/modules/pivots/instructions.d.ts +139 -0
  115. package/dist/modules/pivots/instructions.d.ts.map +1 -0
  116. package/dist/modules/pivots/instructions.js +280 -0
  117. package/dist/modules/pivots/instructions.js.map +1 -0
  118. package/dist/modules/speaker.d.ts +169 -0
  119. package/dist/modules/speaker.d.ts.map +1 -0
  120. package/dist/modules/speaker.js +251 -0
  121. package/dist/modules/speaker.js.map +1 -0
  122. package/dist/modules/storage.d.ts +50 -0
  123. package/dist/modules/storage.d.ts.map +1 -0
  124. package/dist/modules/storage.js +99 -0
  125. package/dist/modules/storage.js.map +1 -0
  126. package/dist/modules/stream.d.ts +129 -0
  127. package/dist/modules/stream.d.ts.map +1 -0
  128. package/dist/modules/stream.js +52 -0
  129. package/dist/modules/stream.js.map +1 -0
  130. package/dist/modules/system.d.ts +45 -0
  131. package/dist/modules/system.d.ts.map +1 -0
  132. package/dist/modules/system.js +42 -0
  133. package/dist/modules/system.js.map +1 -0
  134. package/dist/modules/transcription.d.ts +75 -0
  135. package/dist/modules/transcription.d.ts.map +1 -0
  136. package/dist/modules/transcription.js +106 -0
  137. package/dist/modules/transcription.js.map +1 -0
  138. package/dist/modules/translation.d.ts +59 -0
  139. package/dist/modules/translation.d.ts.map +1 -0
  140. package/dist/modules/translation.js +119 -0
  141. package/dist/modules/translation.js.map +1 -0
  142. package/dist/modules/ui.d.ts +186 -0
  143. package/dist/modules/ui.d.ts.map +1 -0
  144. package/dist/modules/ui.js +293 -0
  145. package/dist/modules/ui.js.map +1 -0
  146. package/dist/protocol.d.ts +299 -0
  147. package/dist/protocol.d.ts.map +1 -0
  148. package/dist/protocol.js +325 -0
  149. package/dist/protocol.js.map +1 -0
  150. package/dist/react/MentraProvider.d.ts +33 -0
  151. package/dist/react/MentraProvider.d.ts.map +1 -0
  152. package/dist/react/MentraProvider.js +15 -0
  153. package/dist/react/MentraProvider.js.map +1 -0
  154. package/dist/react/MiniappHeader.d.ts +44 -0
  155. package/dist/react/MiniappHeader.d.ts.map +1 -0
  156. package/dist/react/MiniappHeader.js +32 -0
  157. package/dist/react/MiniappHeader.js.map +1 -0
  158. package/dist/react/index.d.ts +13 -0
  159. package/dist/react/index.d.ts.map +1 -0
  160. package/dist/react/index.js +9 -0
  161. package/dist/react/index.js.map +1 -0
  162. package/dist/react/useCapabilities.d.ts +26 -0
  163. package/dist/react/useCapabilities.d.ts.map +1 -0
  164. package/dist/react/useCapabilities.js +27 -0
  165. package/dist/react/useCapabilities.js.map +1 -0
  166. package/dist/react/useCapsuleHeaderStyle.d.ts +27 -0
  167. package/dist/react/useCapsuleHeaderStyle.d.ts.map +1 -0
  168. package/dist/react/useCapsuleHeaderStyle.js +36 -0
  169. package/dist/react/useCapsuleHeaderStyle.js.map +1 -0
  170. package/dist/react/useColorScheme.d.ts +18 -0
  171. package/dist/react/useColorScheme.d.ts.map +1 -0
  172. package/dist/react/useColorScheme.js +24 -0
  173. package/dist/react/useColorScheme.js.map +1 -0
  174. package/dist/react/useConnected.d.ts +17 -0
  175. package/dist/react/useConnected.d.ts.map +1 -0
  176. package/dist/react/useConnected.js +39 -0
  177. package/dist/react/useConnected.js.map +1 -0
  178. package/dist/react/useRpc.d.ts +29 -0
  179. package/dist/react/useRpc.d.ts.map +1 -0
  180. package/dist/react/useRpc.js +82 -0
  181. package/dist/react/useRpc.js.map +1 -0
  182. package/dist/react/useSafeArea.d.ts +21 -0
  183. package/dist/react/useSafeArea.d.ts.map +1 -0
  184. package/dist/react/useSafeArea.js +22 -0
  185. package/dist/react/useSafeArea.js.map +1 -0
  186. package/dist/session.d.ts +312 -0
  187. package/dist/session.d.ts.map +1 -0
  188. package/dist/session.js +653 -0
  189. package/dist/session.js.map +1 -0
  190. package/dist/sub-path-types.test-d.d.ts +28 -0
  191. package/dist/sub-path-types.test-d.d.ts.map +1 -0
  192. package/dist/sub-path-types.test-d.js +37 -0
  193. package/dist/sub-path-types.test-d.js.map +1 -0
  194. package/dist/transport/auto.d.ts +35 -0
  195. package/dist/transport/auto.d.ts.map +1 -0
  196. package/dist/transport/auto.js +125 -0
  197. package/dist/transport/auto.js.map +1 -0
  198. package/dist/transport/dispatch.d.ts +43 -0
  199. package/dist/transport/dispatch.d.ts.map +1 -0
  200. package/dist/transport/dispatch.js +91 -0
  201. package/dist/transport/dispatch.js.map +1 -0
  202. package/dist/transport/local-socket.d.ts +28 -0
  203. package/dist/transport/local-socket.d.ts.map +1 -0
  204. package/dist/transport/local-socket.js +80 -0
  205. package/dist/transport/local-socket.js.map +1 -0
  206. package/dist/transport/mock.d.ts +54 -0
  207. package/dist/transport/mock.d.ts.map +1 -0
  208. package/dist/transport/mock.js +207 -0
  209. package/dist/transport/mock.js.map +1 -0
  210. package/dist/transport/postmessage.d.ts +36 -0
  211. package/dist/transport/postmessage.d.ts.map +1 -0
  212. package/dist/transport/postmessage.js +78 -0
  213. package/dist/transport/postmessage.js.map +1 -0
  214. package/dist/transport/types.d.ts +25 -0
  215. package/dist/transport/types.d.ts.map +1 -0
  216. package/dist/transport/types.js +11 -0
  217. package/dist/transport/types.js.map +1 -0
  218. package/dist/ui/index.d.ts +98 -0
  219. package/dist/ui/index.d.ts.map +1 -0
  220. package/dist/ui/index.js +32 -0
  221. package/dist/ui/index.js.map +1 -0
  222. package/package.json +84 -0
  223. package/src/background/index.ts +152 -0
  224. package/src/background/register.ts +85 -0
  225. package/src/dev-reload.ts +60 -0
  226. package/src/envelope.ts +54 -0
  227. package/src/globals.ts +62 -0
  228. package/src/index.ts +171 -0
  229. package/src/modules/actions.ts +156 -0
  230. package/src/modules/auth.ts +68 -0
  231. package/src/modules/base64.ts +87 -0
  232. package/src/modules/blob.ts +341 -0
  233. package/src/modules/camera.ts +242 -0
  234. package/src/modules/cloud.ts +94 -0
  235. package/src/modules/dashboard.ts +31 -0
  236. package/src/modules/display.ts +122 -0
  237. package/src/modules/events.ts +291 -0
  238. package/src/modules/glasses.ts +44 -0
  239. package/src/modules/heading.ts +23 -0
  240. package/src/modules/imu.ts +44 -0
  241. package/src/modules/input.ts +71 -0
  242. package/src/modules/led.ts +61 -0
  243. package/src/modules/location.ts +39 -0
  244. package/src/modules/mic.ts +74 -0
  245. package/src/modules/miniapps.ts +90 -0
  246. package/src/modules/navigation.ts +670 -0
  247. package/src/modules/permissions.ts +84 -0
  248. package/src/modules/phone.ts +155 -0
  249. package/src/modules/pivots/engine.ts +863 -0
  250. package/src/modules/pivots/geometry.ts +409 -0
  251. package/src/modules/pivots/instructions.ts +343 -0
  252. package/src/modules/speaker.ts +335 -0
  253. package/src/modules/storage.ts +109 -0
  254. package/src/modules/stream.ts +163 -0
  255. package/src/modules/system.ts +76 -0
  256. package/src/modules/transcription.ts +145 -0
  257. package/src/modules/translation.ts +149 -0
  258. package/src/modules/ui.ts +498 -0
  259. package/src/protocol.ts +378 -0
  260. package/src/react/MentraProvider.tsx +50 -0
  261. package/src/react/MiniappHeader.tsx +122 -0
  262. package/src/react/index.ts +12 -0
  263. package/src/react/useCapabilities.ts +29 -0
  264. package/src/react/useCapsuleHeaderStyle.ts +54 -0
  265. package/src/react/useColorScheme.ts +26 -0
  266. package/src/react/useConnected.ts +47 -0
  267. package/src/react/useRpc.ts +106 -0
  268. package/src/react/useSafeArea.ts +40 -0
  269. package/src/session.ts +886 -0
  270. package/src/sub-path-types.test-d.ts +49 -0
  271. package/src/transport/auto.ts +143 -0
  272. package/src/transport/dispatch.ts +106 -0
  273. package/src/transport/local-socket.ts +93 -0
  274. package/src/transport/mock.ts +251 -0
  275. package/src/transport/postmessage.ts +95 -0
  276. package/src/transport/types.ts +26 -0
  277. package/src/ui/index.ts +122 -0
@@ -0,0 +1,863 @@
1
+ /**
2
+ * @fileoverview Pivot engine — internal to NavigationModule.
3
+ *
4
+ * Owns the per-trip pivot list and the cursor that walks it as GPS
5
+ * ticks come in. Public consumers of the SDK never touch this class
6
+ * directly — they go through `navigation.onPivot()` / `getPivots()` /
7
+ * `getActivePivot()` / `getUpcomingPivot()`.
8
+ *
9
+ * Lifecycle:
10
+ * - `reset()` on `navigation.start()`. Clears pivot list, cursor,
11
+ * all subscribers stay attached.
12
+ * - `setRoute(route)` on the first `onRoute` after start, and on
13
+ * every subsequent reroute. Rebuilds the pivot list from
14
+ * scratch.
15
+ * - `onLocationUpdate(coords)` on every GPS fix. Computes which
16
+ * events to fire and emits them to subscribers.
17
+ * - `reset()` on `stop()` / `arrived`.
18
+ *
19
+ * The engine doesn't subscribe to GPS itself — `NavigationModule`
20
+ * owns that, calling `onLocationUpdate(coords)` from its own GPS
21
+ * subscription. Keeps this class pure-state with no side effects.
22
+ */
23
+
24
+ import type {LatLng, ManeuverKind, NavRoute, NavStep, Pivot, PivotEvent, PivotOptions, TravelMode} from "../navigation"
25
+ import {
26
+ bearingDeg,
27
+ cumulativeDistances,
28
+ extractPivots,
29
+ haversineMeters,
30
+ signedAngleDiff,
31
+ type RawPivot,
32
+ } from "./geometry"
33
+ import {extractPivotsFromComputedSteps} from "./instructions"
34
+
35
+ /**
36
+ * Mode-aware defaults for `PivotOptions`. Tuned for typical
37
+ * speeds — walking gets a tight radius (you have time to react),
38
+ * driving gets a wider one (you need earlier warning).
39
+ */
40
+ const RADIUS_DEFAULTS_M: Record<TravelMode, number> = {
41
+ walking: 7,
42
+ cycling: 15,
43
+ driving: 40,
44
+ two_wheeler: 25,
45
+ }
46
+
47
+ const APPROACH_DEFAULTS_M: Record<TravelMode, number> = {
48
+ walking: 100,
49
+ cycling: 300,
50
+ driving: 800,
51
+ two_wheeler: 500,
52
+ }
53
+
54
+ /**
55
+ * SDK-internal maneuver categories that don't constitute a real
56
+ * "turn" the UI should announce. Filtered out at pivot construction.
57
+ */
58
+ const NON_TURN_MANEUVERS = new Set(["STRAIGHT", "NAME_CHANGE", "DEPART", "ARRIVE"])
59
+
60
+ /**
61
+ * Maximum routeIndex delta when matching a geometry-derived pivot
62
+ * against the SDK's step list. Beyond this, we treat the SDK as
63
+ * having no matching step and leave fromRoad/toRoad null rather than
64
+ * guessing.
65
+ */
66
+ const STEP_MATCH_MAX_INDEX_DELTA = 8
67
+
68
+ /**
69
+ * Realized PivotOptions with all fields resolved (no undefined).
70
+ */
71
+ type ResolvedOptions = {
72
+ radiusMeters: number
73
+ approachThresholdMeters: number
74
+ }
75
+
76
+ type PivotState = {
77
+ approachingFired: boolean
78
+ entered: boolean
79
+ exited: boolean
80
+ }
81
+
82
+ type Subscriber = (event: PivotEvent) => void
83
+
84
+ /**
85
+ * Threshold (meters) for perpendicular distance to the route polyline.
86
+ * Beyond this, the user is treated as "not on the route" — we fall back
87
+ * to straight-line distance to the pivot point so pivots on a parallel
88
+ * street don't fire by accident. Within this, we use along-path
89
+ * projection (the user is on/near the planned path, just maybe on the
90
+ * wrong sidewalk).
91
+ */
92
+ const ON_ROUTE_PERP_TOLERANCE_M = 50
93
+
94
+ export class PivotEngine {
95
+ private opts: ResolvedOptions
96
+ private pivots: Pivot[] = []
97
+ private states: PivotState[] = []
98
+ /** Index of the next pivot we expect the user to encounter. */
99
+ private cursor = 0
100
+ /** Pivot currently between `entered` and `exited`, or null. */
101
+ private activePivotIndex: number | null = null
102
+ /**
103
+ * Latest route polyline + its cumulative-distance array. Retained
104
+ * after `setRoute()` so `onLocationUpdate` can project the user onto
105
+ * the path and compare along-path distance to each pivot's
106
+ * `distanceAlongRouteMeters`. This is what makes a pedestrian on the
107
+ * wrong sidewalk still trigger the upcoming turn — straight-line
108
+ * distance to the pivot point would miss it.
109
+ */
110
+ private points: LatLng[] = []
111
+ private cumulative: number[] = []
112
+ /**
113
+ * Async road-name resolver injected by NavigationModule. Backs the
114
+ * engine's last-resort fallback: when a pivot's `fromRoad` or
115
+ * `toRoad` is null after Routes-API instruction parsing, the
116
+ * engine samples a coordinate ~SAMPLE_OFFSET_M behind/ahead along
117
+ * the polyline and asks the host to reverse-geocode it. Patched
118
+ * onto the pivot in place when the response lands. Null when no
119
+ * resolver is wired — the engine simply leaves the field null.
120
+ */
121
+ private roadNameResolver: ((coord: LatLng) => Promise<string | null>) | null = null
122
+ /**
123
+ * Generation counter bumped on every route rebuild. Geocode
124
+ * responses from a stale route are discarded by comparing the
125
+ * generation captured at request time against the current value.
126
+ * Without this, a slow geocode reply from a previous route can
127
+ * stamp a fresh route's pivot with the wrong road name.
128
+ */
129
+ private routeGeneration = 0
130
+
131
+ private subscribers = new Set<Subscriber>()
132
+
133
+ constructor(mode: TravelMode, opts: PivotOptions | undefined) {
134
+ this.opts = resolveOptions(mode, opts)
135
+ }
136
+
137
+ /** Replace the trip-level options. Used if `start()` is called
138
+ * with new options without a full reset. */
139
+ updateOptions(mode: TravelMode, opts: PivotOptions | undefined): void {
140
+ this.opts = resolveOptions(mode, opts)
141
+ // Re-stamp radiusMeters on every pivot.
142
+ for (const p of this.pivots) {
143
+ p.radiusMeters = this.opts.radiusMeters
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Install the host-backed reverse-geocode resolver. NavigationModule
149
+ * calls this once after constructing the engine, wiring through to
150
+ * the host's Geocoding REST adapter. When unset, the engine simply
151
+ * leaves null road names in place — geocoding is a best-effort
152
+ * enhancement, not a requirement.
153
+ */
154
+ setRoadNameResolver(resolver: ((coord: LatLng) => Promise<string | null>) | null): void {
155
+ this.roadNameResolver = resolver
156
+ }
157
+
158
+ /**
159
+ * Clear all pivot state. Pivots = []. Cursor reset. Active pivot
160
+ * cleared. Subscribers stay attached so the next `setRoute` can
161
+ * fire events.
162
+ */
163
+ reset(): void {
164
+ if (this.activePivotIndex !== null) {
165
+ // Surface an exited event for the active pivot so consumers
166
+ // don't see it stuck in "in-progress" state after a stop().
167
+ const active = this.pivots[this.activePivotIndex]
168
+ if (active) this.emit({kind: "exited", pivot: active})
169
+ }
170
+ this.pivots = []
171
+ this.states = []
172
+ this.cursor = 0
173
+ this.activePivotIndex = null
174
+ this.points = []
175
+ this.cumulative = []
176
+ }
177
+
178
+ /**
179
+ * Rebuild the pivot list from a Routes-API computed step list. This
180
+ * is the high-accuracy path used during live trips: `instruction`
181
+ * strings on each step name the road being entered unambiguously
182
+ * ("Turn left onto Octavia Blvd"), and the explicit `endLat/endLng`
183
+ * gives us the exact corner location without polyline-walking math.
184
+ *
185
+ * NavigationModule calls this once per route lifecycle — at trip
186
+ * start (after firing NAVIGATION_COMPUTE_ROUTE), and again on every
187
+ * reroute. The cursor + state machinery (approaching / entered /
188
+ * exited) is identical to `setRoute` — only the pivot construction
189
+ * differs.
190
+ *
191
+ * If the computed-step list is empty or no pivots survive the
192
+ * filters in `extractPivotsFromComputedSteps`, this falls back to
193
+ * `setRoute(route)` so the geometry-derived pivots are still
194
+ * available. That keeps the engine working on platforms that don't
195
+ * yet have Routes API plumbing.
196
+ */
197
+ setRouteFromComputedSteps(
198
+ route: NavRoute,
199
+ computedSteps:
200
+ | Array<{
201
+ lat: number
202
+ lng: number
203
+ endLat: number
204
+ endLng: number
205
+ distanceMeters: number
206
+ maneuver?: ManeuverKind
207
+ instruction?: string
208
+ /** Host-resolved road name (Phase 1). Optional for backward
209
+ * compat with callers that only supply `instruction`. */
210
+ road?: string | null
211
+ }>
212
+ | undefined,
213
+ ): void {
214
+ const points = route.points ?? []
215
+
216
+ // Close out an in-flight pivot before rebuilding so subscribers
217
+ // see a clean state transition.
218
+ if (this.activePivotIndex !== null) {
219
+ const active = this.pivots[this.activePivotIndex]
220
+ if (active) this.emit({kind: "exited", pivot: active})
221
+ }
222
+
223
+ if (points.length < 3 || !computedSteps || computedSteps.length < 2) {
224
+ // Fall back to the geometry-derived path. setRoute also handles
225
+ // the points<3 early-exit cleanup.
226
+ this.setRoute(route, null)
227
+ return
228
+ }
229
+
230
+ const cumulative = cumulativeDistances(points)
231
+ const instructionPivots = extractPivotsFromComputedSteps(computedSteps, points)
232
+ console.log(
233
+ `[PivotEngine] setRouteFromComputedSteps: steps=${computedSteps.length} instructionPivots=${instructionPivots.length}` +
234
+ (instructionPivots.length > 0
235
+ ? "\n" +
236
+ instructionPivots
237
+ .map(
238
+ (p, i) =>
239
+ ` pivot[${i}] ${p.fromRoad ?? "—"} → ${p.toRoad ?? "—"} dir=${p.direction} @ (${p.lat.toFixed(5)}, ${p.lng.toFixed(5)})`,
240
+ )
241
+ .join("\n")
242
+ : ""),
243
+ )
244
+ if (instructionPivots.length === 0) {
245
+ // No survived turns from the instruction path — defer to geometry.
246
+ // eslint-disable-next-line no-console
247
+ console.log(
248
+ `[PivotEngine] falling back to setRoute (geometry) — input steps:\n` +
249
+ computedSteps
250
+ .map(
251
+ (s, i) =>
252
+ ` step[${i}] road=${s.road ?? "—"} maneuver=${s.maneuver ?? "—"} @ (${s.lat.toFixed(5)}, ${s.lng.toFixed(5)}) → (${s.endLat.toFixed(5)}, ${s.endLng.toFixed(5)})`,
253
+ )
254
+ .join("\n"),
255
+ )
256
+ this.setRoute(route, null)
257
+ return
258
+ }
259
+
260
+ const pivots: Pivot[] = instructionPivots.map((p, i) => ({
261
+ index: i,
262
+ lat: p.lat,
263
+ lng: p.lng,
264
+ direction: p.direction === "left" ? "left" : "right",
265
+ fromRoad: p.fromRoad,
266
+ toRoad: p.toRoad,
267
+ maneuver: p.maneuver,
268
+ distanceAlongRouteMeters: alongRouteAtCoord(points, cumulative, {lat: p.lat, lng: p.lng}),
269
+ radiusMeters: this.opts.radiusMeters,
270
+ }))
271
+
272
+ // Crosswalk pivots intentionally NOT injected. Earlier the engine
273
+ // surfaced CROSS_STREET pivots from extractCrossings(points) so a
274
+ // "Cross the street" prompt would fire alongside turns — but in
275
+ // practice every block on a city walk has 1-4 crosswalks and the
276
+ // banner ended up flickering "Onto Gough St in 100m / 80m / 60m"
277
+ // across each one, even though Gough was still hundreds of meters
278
+ // away. The user perceives this as the destination jumping back
279
+ // each time they pass a crosswalk. Removing the injection means
280
+ // the only pivots in the list are real road→road turns from the
281
+ // Routes API, which gives a monotonic countdown to each turn —
282
+ // matching how Google Maps behaves. extractCrossings + CROSS_MERGE_M
283
+ // are kept in the codebase in case crossings get reintroduced as a
284
+ // separate (non-banner) signal later.
285
+
286
+ pivots.sort((a, b) => a.distanceAlongRouteMeters - b.distanceAlongRouteMeters)
287
+ for (let i = 0; i < pivots.length; i++) pivots[i].index = i
288
+
289
+ this.pivots = pivots
290
+ this.states = pivots.map(() => ({approachingFired: false, entered: false, exited: false}))
291
+ this.cursor = 0
292
+ this.activePivotIndex = null
293
+ this.points = points
294
+ this.cumulative = cumulative
295
+ this.routeGeneration++
296
+
297
+ // Kick off async reverse-geocode lookups for any pivot whose
298
+ // instruction-parse didn't yield a clean road name. Also fills
299
+ // toRoad for CROSS_STREET pivots so the label can read
300
+ // "Cross to X".
301
+ void this._resolveMissingRoadNames(this.routeGeneration)
302
+ }
303
+
304
+ /**
305
+ * Rebuild the pivot list from a fresh route. Called on every
306
+ * `onRoute` event. Any prior pivot list is discarded; cursor
307
+ * resets to 0.
308
+ */
309
+ setRoute(route: NavRoute, _userPosition: LatLng | null): void {
310
+ const points = route.points ?? []
311
+ const steps = route.steps ?? []
312
+
313
+ // If an active pivot was in flight, close it out cleanly before
314
+ // wiping the list.
315
+ if (this.activePivotIndex !== null) {
316
+ const active = this.pivots[this.activePivotIndex]
317
+ if (active) this.emit({kind: "exited", pivot: active})
318
+ }
319
+
320
+ if (points.length < 3) {
321
+ this.pivots = []
322
+ this.states = []
323
+ this.cursor = 0
324
+ this.activePivotIndex = null
325
+ this.points = []
326
+ this.cumulative = []
327
+ return
328
+ }
329
+
330
+ const cumulative = cumulativeDistances(points)
331
+ const raw = extractPivots(points)
332
+ const stepIndex = buildStepIndex(steps)
333
+
334
+ const pivots: Pivot[] = []
335
+ for (let i = 0; i < raw.length; i++) {
336
+ const r = raw[i]
337
+ const matched = matchStep(r, stepIndex)
338
+ // Filter out non-turn maneuvers per the SDK's categorical types.
339
+ // If the matched step's maneuver says STRAIGHT / NAME_CHANGE /
340
+ // DEPART / ARRIVE, this isn't a turn the UI should announce —
341
+ // even if our geometry detected a bend.
342
+ if (matched && NON_TURN_MANEUVERS.has(matched.maneuver)) {
343
+ continue
344
+ }
345
+ pivots.push({
346
+ index: pivots.length,
347
+ lat: r.lat,
348
+ lng: r.lng,
349
+ direction: r.direction,
350
+ fromRoad: matched?.fromRoad ?? null,
351
+ toRoad: matched?.toRoad ?? null,
352
+ maneuver: matched?.maneuver ?? (r.direction === "left" ? "TURN_LEFT" : "TURN_RIGHT"),
353
+ distanceAlongRouteMeters: distanceAtIndex(cumulative, r.rawRouteIndex),
354
+ radiusMeters: this.opts.radiusMeters,
355
+ })
356
+ }
357
+
358
+ // Crosswalk pivots intentionally NOT injected — see the matching
359
+ // comment in setRouteFromComputedSteps. Crossings created banner
360
+ // flicker on city walks where every block has multiple crosswalks
361
+ // labeled "Onto <next turn road>", making the destination appear
362
+ // to jump back each time the user crossed a side street.
363
+
364
+ // Sort by along-route distance so the cursor walks them in
365
+ // geographic order, then re-assign indices.
366
+ pivots.sort((a, b) => a.distanceAlongRouteMeters - b.distanceAlongRouteMeters)
367
+ for (let i = 0; i < pivots.length; i++) pivots[i].index = i
368
+
369
+ this.pivots = pivots
370
+ this.states = pivots.map(() => ({approachingFired: false, entered: false, exited: false}))
371
+ this.cursor = 0
372
+ this.activePivotIndex = null
373
+ this.points = points
374
+ this.cumulative = cumulative
375
+ this.routeGeneration++
376
+
377
+ // Geometry-fallback path: matchStep often leaves road names null
378
+ // for unmatched pivots. Try the reverse-geocode resolver as a
379
+ // last resort, same as setRouteFromComputedSteps.
380
+ void this._resolveMissingRoadNames(this.routeGeneration)
381
+ }
382
+
383
+ /**
384
+ * For every pivot in the current list that has a missing road
385
+ * label, fill it in. Two-stage strategy:
386
+ *
387
+ * 1. **Inherit from neighbors.** Adjacent pivots on the route
388
+ * share roads by definition: between pivot N and pivot N+1
389
+ * there are no turns, so pivot N's toRoad == pivot N+1's
390
+ * fromRoad. If one side is known, copy it. This is free
391
+ * (no network) and avoids the geocode-mismatch class of bug
392
+ * where two different sample points return two different
393
+ * strings for what is conceptually the same road.
394
+ *
395
+ * 2. **Reverse-geocode the remainder.** For any field still
396
+ * null after inheritance, sample a coordinate ~18m behind
397
+ * (fromRoad) or ahead (toRoad) along the polyline and ask
398
+ * the host to reverse-geocode it. Best-effort — failures
399
+ * leave the field null.
400
+ *
401
+ * The `generation` guard rejects geocode replies arriving after
402
+ * a fresh route rebuild superseded them.
403
+ */
404
+ private async _resolveMissingRoadNames(generation: number): Promise<void> {
405
+ const pivots = this.pivots
406
+ if (pivots.length === 0) return
407
+
408
+ // Stage 1: neighbor inheritance. Walk forward then backward so
409
+ // both directions of propagation happen in one pass. Same-road
410
+ // dedupe is also implicit here — if N.toRoad == N+1.fromRoad
411
+ // they already agree; if only one is known the other inherits.
412
+ for (let i = 0; i < pivots.length - 1; i++) {
413
+ const here = pivots[i]
414
+ const next = pivots[i + 1]
415
+ if (!here.toRoad && next.fromRoad) here.toRoad = next.fromRoad
416
+ if (!next.fromRoad && here.toRoad) next.fromRoad = here.toRoad
417
+ }
418
+ for (let i = pivots.length - 1; i > 0; i--) {
419
+ const here = pivots[i]
420
+ const prev = pivots[i - 1]
421
+ if (!prev.toRoad && here.fromRoad) prev.toRoad = here.fromRoad
422
+ if (!here.fromRoad && prev.toRoad) here.fromRoad = prev.toRoad
423
+ }
424
+
425
+ // Stage 2: geocode whatever's still null. The rule is strict:
426
+ // every pivot MUST end up with both fromRoad and toRoad. If a
427
+ // single geocode attempt fails (returns null), expand the sample
428
+ // distance and retry along the polyline until we either hit a
429
+ // road name or run out of route in that direction.
430
+ //
431
+ // The expansion sequence is tuned to typical urban geometry:
432
+ // 18m clears a sidewalk + crosswalk; 30m is mid-block; 50m is
433
+ // well past the next building entrance; 80m approaches the
434
+ // following intersection. Beyond ~80m we'd risk crossing into
435
+ // the next street segment, which would return the wrong road.
436
+ const resolver = this.roadNameResolver
437
+ if (!resolver) return
438
+ if (this.points.length < 2) return
439
+ const SAMPLE_OFFSETS_M = [18, 30, 50, 80]
440
+ const points = this.points
441
+ const cumulative = this.cumulative
442
+
443
+ /**
444
+ * Try each offset in sequence until one returns a road name.
445
+ * Direction is +1 for toRoad (sample ahead of pivot) or -1 for
446
+ * fromRoad (sample behind pivot). Returns null only when every
447
+ * offset failed.
448
+ */
449
+ const geocodeWithExpansion = async (pivotAlong: number, direction: 1 | -1): Promise<string | null> => {
450
+ for (const offset of SAMPLE_OFFSETS_M) {
451
+ const sample = sampleAlongRoute(points, cumulative, pivotAlong + direction * offset)
452
+ if (!sample) continue
453
+ try {
454
+ const road = await resolver(sample)
455
+ if (generation !== this.routeGeneration) return null
456
+ if (road) return road
457
+ } catch {
458
+ /* try the next offset */
459
+ }
460
+ }
461
+ return null
462
+ }
463
+
464
+ const tasks: Array<Promise<void>> = []
465
+ for (const pivot of pivots) {
466
+ if (pivot.fromRoad && pivot.toRoad) continue
467
+ const along = pivot.distanceAlongRouteMeters
468
+ if (!pivot.fromRoad) {
469
+ tasks.push(
470
+ geocodeWithExpansion(along, -1).then((road) => {
471
+ if (generation !== this.routeGeneration) return
472
+ if (road && !pivot.fromRoad) pivot.fromRoad = road
473
+ }),
474
+ )
475
+ }
476
+ if (!pivot.toRoad) {
477
+ tasks.push(
478
+ geocodeWithExpansion(along, 1).then((road) => {
479
+ if (generation !== this.routeGeneration) return
480
+ if (road && !pivot.toRoad) pivot.toRoad = road
481
+ }),
482
+ )
483
+ }
484
+ }
485
+ await Promise.all(tasks)
486
+ if (generation !== this.routeGeneration) return
487
+
488
+ // After geocoding lands, run one more pass of neighbor
489
+ // inheritance so a freshly-geocoded road can propagate to an
490
+ // adjacent pivot that's still null. Cheap and resolves the case
491
+ // where geocode succeeded on N.toRoad but failed on N+1.fromRoad
492
+ // (or vice versa).
493
+ for (let i = 0; i < pivots.length - 1; i++) {
494
+ const here = pivots[i]
495
+ const next = pivots[i + 1]
496
+ if (!here.toRoad && next.fromRoad) here.toRoad = next.fromRoad
497
+ if (!next.fromRoad && here.toRoad) next.fromRoad = here.toRoad
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Drive the cursor with a new GPS fix. Fires `approaching` /
503
+ * `entered` / `exited` as thresholds are crossed.
504
+ *
505
+ * Two distance metrics are used:
506
+ *
507
+ * 1. **Along-path distance** — the user's projected position on the
508
+ * route polyline gives `userAlong`; each pivot's
509
+ * `distanceAlongRouteMeters` is its `pivotAlong`. The signed
510
+ * delta `pivotAlong - userAlong` says how far the user still has
511
+ * to walk to reach the pivot's perpendicular line (positive =
512
+ * ahead, negative = past). This is the primary metric for
513
+ * pedestrians, because it doesn't care which sidewalk they're on
514
+ * — only that they've crossed the pivot's latitude/longitude
515
+ * band.
516
+ *
517
+ * 2. **Straight-line distance** — fallback when the user is more
518
+ * than ON_ROUTE_PERP_TOLERANCE_M from the polyline (they've
519
+ * really wandered off, not just onto the wrong sidewalk). Keeps
520
+ * the legacy behavior for that case.
521
+ */
522
+ onLocationUpdate(coords: LatLng): void {
523
+ if (this.pivots.length === 0) return
524
+
525
+ // Project the user onto the polyline. perpMeters tells us how far
526
+ // they are from the route; userAlong tells us how far along the
527
+ // route their projected position sits. Both are needed to choose
528
+ // between the along-path and straight-line trigger metrics.
529
+ const projection = projectOntoPolyline(this.points, this.cumulative, coords)
530
+ const useAlongPath = projection !== null && projection.perpMeters <= ON_ROUTE_PERP_TOLERANCE_M
531
+ const userAlong = projection?.alongMeters ?? 0
532
+
533
+ // Walk forward from cursor — only consider the upcoming pivot
534
+ // and any not-yet-finalized pivots ahead of it. We never
535
+ // re-evaluate a pivot whose `exited` event has already fired.
536
+ for (let i = this.cursor; i < this.pivots.length; i++) {
537
+ const pivot = this.pivots[i]
538
+ const state = this.states[i]
539
+ if (state.exited) continue
540
+
541
+ // `distanceToPivot` is the metric that decides approaching/entered/exited.
542
+ // When the user is on the route polyline, it's how far they still
543
+ // have to walk along the path; when they've wandered off, it
544
+ // falls back to straight-line so we don't fire pivots they can't
545
+ // reasonably reach.
546
+ // `aheadDelta` is the signed along-path distance: positive means
547
+ // pivot is ahead of the user, negative means they've already
548
+ // crossed it. Only meaningful when useAlongPath is true.
549
+ const aheadDelta = pivot.distanceAlongRouteMeters - userAlong
550
+ const distanceToPivot = useAlongPath
551
+ ? Math.abs(aheadDelta)
552
+ : haversineMeters(coords, {lat: pivot.lat, lng: pivot.lng})
553
+
554
+ // Approaching — first time inside the approach threshold. Only
555
+ // counts if the pivot is still ahead of the user; we never
556
+ // announce "approaching" for a pivot they've already crossed.
557
+ const approaching =
558
+ !state.approachingFired &&
559
+ distanceToPivot <= this.opts.approachThresholdMeters &&
560
+ (!useAlongPath || aheadDelta >= -this.opts.radiusMeters)
561
+ if (approaching) {
562
+ state.approachingFired = true
563
+ this.emit({kind: "approaching", pivot, distanceMeters: distanceToPivot})
564
+ }
565
+
566
+ // Entered — user is within radiusMeters of the pivot. On-route
567
+ // this fires the moment they cross the pivot's perpendicular
568
+ // band, regardless of which sidewalk they're on.
569
+ if (!state.entered && distanceToPivot <= pivot.radiusMeters) {
570
+ state.entered = true
571
+ this.activePivotIndex = i
572
+ this.emit({kind: "entered", pivot})
573
+ }
574
+
575
+ // Exited — was entered and is now past the radius. With
576
+ // along-path projection we additionally exit as soon as the user
577
+ // is past the pivot by more than radiusMeters in the forward
578
+ // direction — even if they never tripped `entered` (e.g. they
579
+ // walked straight through the pivot band on a wide intersection
580
+ // without ever being within radiusMeters of the point itself).
581
+ const movedPastOnPath = useAlongPath && aheadDelta < -pivot.radiusMeters
582
+ if (
583
+ ((state.entered && distanceToPivot > pivot.radiusMeters) || (!state.entered && movedPastOnPath)) &&
584
+ !state.exited
585
+ ) {
586
+ // If we're exiting without ever entering (skipped the band),
587
+ // still emit `entered` first so subscribers can pair entered/exited.
588
+ if (!state.entered) {
589
+ state.entered = true
590
+ this.activePivotIndex = i
591
+ this.emit({kind: "entered", pivot})
592
+ }
593
+ state.exited = true
594
+ if (this.activePivotIndex === i) this.activePivotIndex = null
595
+ this.emit({kind: "exited", pivot})
596
+ // Advance the cursor past this pivot — we don't re-evaluate it.
597
+ if (this.cursor <= i) this.cursor = i + 1
598
+ }
599
+
600
+ // Stop scanning when we hit a pivot that hasn't fired
601
+ // `approaching` yet AND is far enough to be in the future.
602
+ // Specifically: if it's >2× approach threshold, don't bother
603
+ // checking pivots beyond it this tick. Avoids O(N) work per
604
+ // GPS fix on long routes.
605
+ if (!state.approachingFired && distanceToPivot > this.opts.approachThresholdMeters * 2) {
606
+ break
607
+ }
608
+ }
609
+ }
610
+
611
+ // Public accessors used by NavigationModule.
612
+
613
+ subscribe(fn: Subscriber): () => void {
614
+ this.subscribers.add(fn)
615
+ return () => {
616
+ this.subscribers.delete(fn)
617
+ }
618
+ }
619
+
620
+ getPivots(): Pivot[] {
621
+ return this.pivots.slice()
622
+ }
623
+
624
+ getActivePivot(): Pivot | null {
625
+ if (this.activePivotIndex === null) return null
626
+ return this.pivots[this.activePivotIndex] ?? null
627
+ }
628
+
629
+ getUpcomingPivot(): Pivot | null {
630
+ // First pivot whose `exited` hasn't fired. Equivalent to
631
+ // `pivots[cursor]` most of the time; using state directly is
632
+ // safer if the cursor ever lags behind.
633
+ for (let i = this.cursor; i < this.pivots.length; i++) {
634
+ if (!this.states[i].exited) return this.pivots[i]
635
+ }
636
+ return null
637
+ }
638
+
639
+ private emit(event: PivotEvent): void {
640
+ for (const fn of this.subscribers) {
641
+ try {
642
+ fn(event)
643
+ } catch (err) {
644
+ console.error("[PivotEngine] subscriber threw:", err)
645
+ }
646
+ }
647
+ }
648
+ }
649
+
650
+ /** -------------------------------------------------------------- */
651
+ /* Helpers */
652
+
653
+ function resolveOptions(mode: TravelMode, opts: PivotOptions | undefined): ResolvedOptions {
654
+ return {
655
+ radiusMeters: opts?.radiusMeters ?? RADIUS_DEFAULTS_M[mode] ?? RADIUS_DEFAULTS_M.walking,
656
+ approachThresholdMeters: opts?.approachThresholdMeters ?? APPROACH_DEFAULTS_M[mode] ?? APPROACH_DEFAULTS_M.walking,
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Project `user` onto the polyline. Returns:
662
+ * - `perpMeters`: shortest distance from the user to the polyline (any segment).
663
+ * - `alongMeters`: cumulative distance from the polyline's start to
664
+ * the projection point, measured along the polyline.
665
+ *
666
+ * Used by `onLocationUpdate` to decide whether the user is "on the
667
+ * route" (eligible for along-path pivot triggering) and how far they
668
+ * have walked along it. Returns null when the polyline has fewer than
669
+ * two points and projection is undefined.
670
+ *
671
+ * Uses a flat-earth approximation (lat/lng → meters via per-degree
672
+ * scale at the local latitude). City-scale routes don't see meaningful
673
+ * error from this; the alternative is per-segment spherical math which
674
+ * doesn't pay for itself here.
675
+ */
676
+ function projectOntoPolyline(
677
+ points: LatLng[],
678
+ cumulative: number[],
679
+ user: LatLng,
680
+ ): {perpMeters: number; alongMeters: number} | null {
681
+ if (points.length < 2 || cumulative.length !== points.length) return null
682
+ const mPerDegLat = 111_320
683
+ const mPerDegLng = 111_320 * Math.cos((user.lat * Math.PI) / 180)
684
+ const ux = user.lng * mPerDegLng
685
+ const uy = user.lat * mPerDegLat
686
+
687
+ let bestPerp = Number.POSITIVE_INFINITY
688
+ let bestAlong = 0
689
+ for (let i = 0; i < points.length - 1; i++) {
690
+ const a = points[i]
691
+ const b = points[i + 1]
692
+ const ax = a.lng * mPerDegLng
693
+ const ay = a.lat * mPerDegLat
694
+ const bx = b.lng * mPerDegLng
695
+ const by = b.lat * mPerDegLat
696
+ const dx = bx - ax
697
+ const dy = by - ay
698
+ const segLen2 = dx * dx + dy * dy
699
+ // Parameter t along segment [0..1] of the closest point to user.
700
+ // Degenerate zero-length segments (duplicate vertices) get t=0.
701
+ const t = segLen2 > 0 ? Math.max(0, Math.min(1, ((ux - ax) * dx + (uy - ay) * dy) / segLen2)) : 0
702
+ const px = ax + t * dx
703
+ const py = ay + t * dy
704
+ const perp = Math.hypot(ux - px, uy - py)
705
+ if (perp < bestPerp) {
706
+ bestPerp = perp
707
+ const segLen = Math.sqrt(segLen2)
708
+ bestAlong = cumulative[i] + t * segLen
709
+ }
710
+ }
711
+ if (!Number.isFinite(bestPerp)) return null
712
+ return {perpMeters: bestPerp, alongMeters: bestAlong}
713
+ }
714
+
715
+ /**
716
+ * Project a coordinate onto the route polyline and return the
717
+ * along-route distance in meters. Used for instruction-derived
718
+ * pivots whose anchor is a Routes-API `endLat/endLng` rather than a
719
+ * polyline-vertex index — we still need their `distanceAlongRouteMeters`
720
+ * for the cursor's along-path metric. Falls back to 0 when projection
721
+ * is undefined (degenerate polyline).
722
+ */
723
+ function alongRouteAtCoord(points: LatLng[], cumulative: number[], coord: LatLng): number {
724
+ const projection = projectOntoPolyline(points, cumulative, coord)
725
+ return projection?.alongMeters ?? 0
726
+ }
727
+
728
+ /**
729
+ * Interpolate the LatLng at a given along-route distance. Walks the
730
+ * polyline segment-by-segment via the cumulative-distance array,
731
+ * linearly interpolating within whichever segment contains the
732
+ * target. Used by the reverse-geocode fallback to sample a
733
+ * coordinate slightly behind or ahead of a pivot — far enough to be
734
+ * cleanly on one road rather than at the ambiguous corner itself.
735
+ *
736
+ * Returns null when the target is outside [0, total] or the polyline
737
+ * has fewer than two points. Callers treat null as "no sample
738
+ * available" and skip the geocode lookup for that side.
739
+ */
740
+ function sampleAlongRoute(points: LatLng[], cumulative: number[], targetMeters: number): LatLng | null {
741
+ if (points.length < 2 || cumulative.length !== points.length) return null
742
+ const total = cumulative[cumulative.length - 1]
743
+ if (!Number.isFinite(targetMeters)) return null
744
+ if (targetMeters <= 0) return {lat: points[0].lat, lng: points[0].lng}
745
+ if (targetMeters >= total) {
746
+ const last = points[points.length - 1]
747
+ return {lat: last.lat, lng: last.lng}
748
+ }
749
+ // Binary search the segment containing targetMeters. cumulative is
750
+ // monotonically increasing so we can find the segment in O(log n).
751
+ let lo = 0
752
+ let hi = cumulative.length - 1
753
+ while (lo + 1 < hi) {
754
+ const mid = (lo + hi) >>> 1
755
+ if (cumulative[mid] <= targetMeters) lo = mid
756
+ else hi = mid
757
+ }
758
+ const segStart = cumulative[lo]
759
+ const segEnd = cumulative[hi]
760
+ const segLen = segEnd - segStart
761
+ const t = segLen > 0 ? (targetMeters - segStart) / segLen : 0
762
+ const a = points[lo]
763
+ const b = points[hi]
764
+ return {lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t}
765
+ }
766
+
767
+ /** Cumulative-distance lookup. Clamps out-of-range indices. */
768
+ function distanceAtIndex(cumulative: number[], idx: number): number {
769
+ if (cumulative.length === 0) return 0
770
+ if (idx < 0) return 0
771
+ if (idx >= cumulative.length) return cumulative[cumulative.length - 1]
772
+ return cumulative[idx]
773
+ }
774
+
775
+ /**
776
+ * Sort steps by `routeIndex` ascending so we can scan for the step
777
+ * whose start lines up with a given pivot's polyline index.
778
+ */
779
+ function buildStepIndex(steps: NavStep[]): NavStep[] {
780
+ if (!steps.length) return []
781
+ return steps.slice().sort((a, b) => a.routeIndex - b.routeIndex)
782
+ }
783
+
784
+ type MatchedStep = {
785
+ fromRoad: string | null
786
+ toRoad: string | null
787
+ maneuver: ManeuverKind
788
+ }
789
+
790
+ /**
791
+ * Bind a raw geometry pivot to its corresponding SDK step.
792
+ *
793
+ * SDK step convention: `step[i].road` is the road traversed during
794
+ * step i, and `step[i].routeIndex` is the polyline vertex where step
795
+ * i STARTS. The turn at the boundary between step i-1 and step i
796
+ * lives at `step[i].routeIndex`.
797
+ *
798
+ * A geometric pivot at polyline index K is a step boundary. We find
799
+ * the step `j >= 1` whose `routeIndex` is closest to K, and label the
800
+ * pivot as the boundary at the end of step j-1:
801
+ *
802
+ * fromRoad = step[j-1].road (road we were on)
803
+ * toRoad = step[j].road (road we turn onto)
804
+ * maneuver = step[j-1].maneuver (turn type at the end of step j-1)
805
+ *
806
+ * We start matching from `j=1` (skipping the trip-start step[0])
807
+ * because step[0] doesn't represent a turn — its `routeIndex=0`
808
+ * collides with later "depart" pseudo-steps that the SDK sometimes
809
+ * emits before the first real turn.
810
+ */
811
+ function matchStep(raw: RawPivot, stepIndex: NavStep[]): MatchedStep | null {
812
+ if (stepIndex.length < 2) return null
813
+
814
+ // Scan with `<=` so that when multiple steps share the same
815
+ // routeIndex (the SDK sometimes emits duplicate trip-start
816
+ // pseudo-steps at routeIndex=0), we land on the LAST one in the
817
+ // group — the meaningful step boundary, not the depart filler.
818
+ let bestJ = -1
819
+ let bestDelta = Number.POSITIVE_INFINITY
820
+ for (let i = 1; i < stepIndex.length; i++) {
821
+ const delta = Math.abs(stepIndex[i].routeIndex - raw.rawRouteIndex)
822
+ if (delta <= bestDelta) {
823
+ bestDelta = delta
824
+ bestJ = i
825
+ }
826
+ }
827
+ if (bestJ < 1) return null
828
+ if (bestDelta > STEP_MATCH_MAX_INDEX_DELTA) return null
829
+
830
+ // Collapse "crossing" sub-steps. When a single geometric pivot spans
831
+ // an intersection where the SDK emits multiple tiny steps (e.g.
832
+ // Guerrero → Market(9m) → Gough), the user perceives one turn onto
833
+ // the destination road. Advance past short transitional steps so
834
+ // `toRoad` is the meaningful destination, not the crossing in
835
+ // between.
836
+ //
837
+ // Stop one short of the FINAL step. The last step in the SDK's list
838
+ // is the arrival leg — its `road` is typically a destination-anchor
839
+ // string (the placename, an empty string, or literally "Destination"
840
+ // on some hosts), not a real street name. Collapsing into it
841
+ // produces labels like "15th St → Destination" instead of the
842
+ // intended "Dolores St → 15th St". Keeping `j` strictly less than
843
+ // the final index preserves the real street the user is turning
844
+ // onto.
845
+ const SHORT_TRANSIT_METERS = 25
846
+ const finalIndex = stepIndex.length - 1
847
+ let j = bestJ
848
+ while (j < finalIndex - 1 && stepIndex[j].distanceMeters > 0 && stepIndex[j].distanceMeters < SHORT_TRANSIT_METERS) {
849
+ j++
850
+ }
851
+ if (j >= finalIndex) j = finalIndex - 1
852
+
853
+ const fromStep = stepIndex[bestJ - 1]
854
+ const toStep = stepIndex[j]
855
+ return {
856
+ fromRoad: fromStep.road ?? null,
857
+ toRoad: toStep.road ?? null,
858
+ maneuver: fromStep.maneuver,
859
+ }
860
+ }
861
+
862
+ // Re-export for unit tests / Navigation miniapp post-migration.
863
+ export {bearingDeg, haversineMeters, signedAngleDiff}