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