@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,409 @@
1
+ /**
2
+ * @fileoverview Private geometry helpers for the pivot engine.
3
+ *
4
+ * Lifted (with light edits) from the Navigation miniapp's
5
+ * `src/client/lib/geometry/{geometry,pivots}.ts`. Lives inside the
6
+ * SDK now so the public `navigation.getPivots()` / `onPivot()` APIs
7
+ * are fully self-contained — no consumer has to ship its own
8
+ * polyline-simplification + pivot-extraction code.
9
+ *
10
+ * Not exported from the SDK barrel. Internal only.
11
+ */
12
+
13
+ import type {LatLng} from "../navigation"
14
+
15
+ export type PivotDirection = "left" | "right"
16
+
17
+ /** Raw pivot returned by `extractPivots` — pre-enrichment, geometry only. */
18
+ export type RawPivot = {
19
+ lat: number
20
+ lng: number
21
+ direction: PivotDirection
22
+ /** Index of this point in the *simplified* polyline (post-RDP). */
23
+ simplifiedRouteIndex: number
24
+ /** Approximate index in the ORIGINAL polyline (pre-RDP). Best-effort lookup. */
25
+ rawRouteIndex: number
26
+ /** Signed heading delta in degrees: +right, -left. */
27
+ headingDelta: number
28
+ }
29
+
30
+ const TURN_THRESHOLD_DEG = 15
31
+ const SAME_DIR_MERGE_M = 25
32
+ const RDP_EPSILON_M = 5
33
+ const MIN_BEND_PER_POINT_DEG = 10
34
+ const STRAIGHT_SEGMENT_BREAK_M = 1
35
+ /**
36
+ * Pivots within this radius of each other are treated as one
37
+ * intersection and collapsed into a single net-direction pivot. The
38
+ * Routes API + walking polylines often split a single perceived turn
39
+ * (e.g. crossing a 6-way intersection diagonally) into multiple tight
40
+ * left/right/left zigzag pivots over ~10-20m. Merging them keeps the
41
+ * UI announcing one turn per real-world intersection.
42
+ */
43
+ const INTERSECTION_CLUSTER_M = 30
44
+
45
+ /** Great-circle distance in meters (haversine). */
46
+ export function haversineMeters(a: LatLng, b: LatLng): number {
47
+ const R = 6_371_000
48
+ const toRad = (d: number) => (d * Math.PI) / 180
49
+ const dLat = toRad(b.lat - a.lat)
50
+ const dLng = toRad(b.lng - a.lng)
51
+ const lat1 = toRad(a.lat)
52
+ const lat2 = toRad(b.lat)
53
+ const x = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
54
+ return 2 * R * Math.asin(Math.sqrt(x))
55
+ }
56
+
57
+ /** Compass bearing from `a` to `b`, in degrees [0, 360). */
58
+ export function bearingDeg(a: LatLng, b: LatLng): number {
59
+ const toRad = (d: number) => (d * Math.PI) / 180
60
+ const toDeg = (r: number) => (r * 180) / Math.PI
61
+ const lat1 = toRad(a.lat)
62
+ const lat2 = toRad(b.lat)
63
+ const dLng = toRad(b.lng - a.lng)
64
+ const y = Math.sin(dLng) * Math.cos(lat2)
65
+ const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng)
66
+ return (toDeg(Math.atan2(y, x)) + 360) % 360
67
+ }
68
+
69
+ /** Smallest signed difference: target - actual, in [-180, 180]. */
70
+ export function signedAngleDiff(target: number, actual: number): number {
71
+ return ((target - actual + 540) % 360) - 180
72
+ }
73
+
74
+ /**
75
+ * Ramer-Douglas-Peucker polyline simplification. Returns indices into
76
+ * the ORIGINAL `points` array that survive the pass.
77
+ */
78
+ function rdpSimplify(points: LatLng[], epsilonMeters: number): number[] {
79
+ if (points.length < 3) return points.map((_, i) => i)
80
+
81
+ const keep = new Array(points.length).fill(false)
82
+ keep[0] = true
83
+ keep[points.length - 1] = true
84
+
85
+ const stack: Array<[number, number]> = [[0, points.length - 1]]
86
+ while (stack.length) {
87
+ const [lo, hi] = stack.pop()!
88
+ let maxDist = 0
89
+ let maxIdx = -1
90
+ for (let i = lo + 1; i < hi; i++) {
91
+ const d = perpDistMeters(points[i], points[lo], points[hi])
92
+ if (d > maxDist) {
93
+ maxDist = d
94
+ maxIdx = i
95
+ }
96
+ }
97
+ if (maxIdx !== -1 && maxDist > epsilonMeters) {
98
+ keep[maxIdx] = true
99
+ stack.push([lo, maxIdx], [maxIdx, hi])
100
+ }
101
+ }
102
+
103
+ const out: number[] = []
104
+ for (let i = 0; i < keep.length; i++) if (keep[i]) out.push(i)
105
+ return out
106
+ }
107
+
108
+ /** Perpendicular distance from `p` to the infinite line a–b, in meters. */
109
+ function perpDistMeters(p: LatLng, a: LatLng, b: LatLng): number {
110
+ const mPerDegLat = 111_320
111
+ const mPerDegLng = 111_320 * Math.cos((a.lat * Math.PI) / 180)
112
+ const bx = (b.lng - a.lng) * mPerDegLng
113
+ const by = (b.lat - a.lat) * mPerDegLat
114
+ const px = (p.lng - a.lng) * mPerDegLng
115
+ const py = (p.lat - a.lat) * mPerDegLat
116
+ const dx = bx
117
+ const dy = by
118
+ const len = Math.sqrt(dx * dx + dy * dy)
119
+ if (len === 0) return Math.sqrt(px * px + py * py)
120
+ return Math.abs(dx * -py - -px * dy) / len
121
+ }
122
+
123
+ /**
124
+ * Extract raw turn pivots from a polyline. Returns a sparse list of
125
+ * meaningful turns — STRAIGHT segments, polyline noise, and tiny
126
+ * wobbles are filtered out.
127
+ *
128
+ * Algorithm:
129
+ * 1. RDP-simplify with epsilon = 5m
130
+ * 2. Accumulate consecutive same-direction bends into runs
131
+ * 3. Emit one pivot per run whose summed heading delta clears 25°
132
+ * 4. Merge same-direction pivots within 25m of each other
133
+ */
134
+ export function extractPivots(rawPoints: LatLng[]): RawPivot[] {
135
+ if (rawPoints.length < 3) return []
136
+
137
+ const keptIdx = rdpSimplify(rawPoints, RDP_EPSILON_M)
138
+ const simplified: LatLng[] = keptIdx.map((i) => rawPoints[i])
139
+ if (simplified.length < 3) return []
140
+
141
+ // Per-point signed deltas
142
+ const deltas: number[] = []
143
+ for (let i = 1; i < simplified.length - 1; i++) {
144
+ const inBearing = bearingDeg(simplified[i - 1], simplified[i])
145
+ const outBearing = bearingDeg(simplified[i], simplified[i + 1])
146
+ deltas.push(signedAngleDiff(outBearing, inBearing))
147
+ }
148
+
149
+ const candidates: RawPivot[] = []
150
+ let runStart = -1
151
+ let runSum = 0
152
+ let runSign = 0
153
+ const flushRun = (endExclusive: number) => {
154
+ if (runStart === -1) return
155
+ if (Math.abs(runSum) >= TURN_THRESHOLD_DEG) {
156
+ let bestIdx = runStart
157
+ let bestAbs = 0
158
+ for (let k = runStart; k < endExclusive; k++) {
159
+ const a = Math.abs(deltas[k])
160
+ if (a > bestAbs) {
161
+ bestAbs = a
162
+ bestIdx = k
163
+ }
164
+ }
165
+ const simplifiedIdx = bestIdx + 1
166
+ candidates.push({
167
+ lat: simplified[simplifiedIdx].lat,
168
+ lng: simplified[simplifiedIdx].lng,
169
+ direction: runSum > 0 ? "right" : "left",
170
+ simplifiedRouteIndex: simplifiedIdx,
171
+ rawRouteIndex: keptIdx[simplifiedIdx],
172
+ headingDelta: runSum,
173
+ })
174
+ }
175
+ runStart = -1
176
+ runSum = 0
177
+ runSign = 0
178
+ }
179
+
180
+ let metersSinceLastBend = 0
181
+ for (let k = 0; k < deltas.length; k++) {
182
+ const d = deltas[k]
183
+ const sign = d > 0 ? 1 : d < 0 ? -1 : 0
184
+ const segLen = haversineMeters(simplified[k + 1], simplified[k + 2])
185
+
186
+ if (sign === 0 || Math.abs(d) < MIN_BEND_PER_POINT_DEG) {
187
+ if (runStart !== -1) {
188
+ if (sign === runSign || sign === 0) runSum += d
189
+ metersSinceLastBend += segLen
190
+ if (metersSinceLastBend >= STRAIGHT_SEGMENT_BREAK_M) {
191
+ flushRun(k + 1)
192
+ }
193
+ }
194
+ continue
195
+ }
196
+
197
+ if (runStart === -1) {
198
+ runStart = k
199
+ runSum = d
200
+ runSign = sign
201
+ metersSinceLastBend = segLen
202
+ } else if (sign === runSign) {
203
+ if (metersSinceLastBend >= STRAIGHT_SEGMENT_BREAK_M) {
204
+ flushRun(k)
205
+ runStart = k
206
+ runSum = d
207
+ runSign = sign
208
+ } else {
209
+ runSum += d
210
+ }
211
+ metersSinceLastBend = segLen
212
+ } else {
213
+ flushRun(k)
214
+ runStart = k
215
+ runSum = d
216
+ runSign = sign
217
+ metersSinceLastBend = segLen
218
+ }
219
+ }
220
+ flushRun(deltas.length)
221
+
222
+ // Merge same-direction pivots that are close together.
223
+ const merged: RawPivot[] = []
224
+ for (const p of candidates) {
225
+ const last = merged[merged.length - 1]
226
+ if (
227
+ last &&
228
+ last.direction === p.direction &&
229
+ haversineMeters({lat: last.lat, lng: last.lng}, {lat: p.lat, lng: p.lng}) < SAME_DIR_MERGE_M
230
+ ) {
231
+ if (Math.abs(p.headingDelta) > Math.abs(last.headingDelta)) {
232
+ merged[merged.length - 1] = p
233
+ }
234
+ continue
235
+ }
236
+ merged.push(p)
237
+ }
238
+
239
+ // Intersection-cluster merge: collapse pivots within
240
+ // INTERSECTION_CLUSTER_M of each other regardless of direction. The
241
+ // resulting pivot inherits the net heading delta — if a tight
242
+ // left→right→left zigzag at one intersection nets out to a right
243
+ // turn, the merged pivot is a right. The anchor lat/lng is the pivot
244
+ // with the largest individual heading delta in the cluster (most
245
+ // likely to be the "real" turn vertex). We tag the merged pivot with
246
+ // the SUMMED `headingDelta` so consumers can still see how sharp the
247
+ // net turn was.
248
+ const clustered: RawPivot[] = []
249
+ for (const p of merged) {
250
+ const last = clustered[clustered.length - 1]
251
+ if (last && haversineMeters({lat: last.lat, lng: last.lng}, {lat: p.lat, lng: p.lng}) < INTERSECTION_CLUSTER_M) {
252
+ const netDelta = last.headingDelta + p.headingDelta
253
+ // Anchor on whichever pivot had the larger absolute bend — that's
254
+ // typically the geometric vertex of the actual turn.
255
+ const anchor = Math.abs(p.headingDelta) > Math.abs(last.headingDelta) ? p : last
256
+ clustered[clustered.length - 1] = {
257
+ ...anchor,
258
+ direction: netDelta > 0 ? "right" : "left",
259
+ headingDelta: netDelta,
260
+ }
261
+ continue
262
+ }
263
+ clustered.push(p)
264
+ }
265
+
266
+ // Discard clusters whose NET turn fell back below the threshold
267
+ // (e.g. a left followed by an equal-magnitude right that cancels out
268
+ // — a pure jog with no real direction change).
269
+ return clustered.filter((p) => Math.abs(p.headingDelta) >= TURN_THRESHOLD_DEG)
270
+ }
271
+
272
+ /** Raw crossing leg returned by `extractCrossings`. */
273
+ export type RawCrossing = {
274
+ /** Polyline index of the curb on the user's current sidewalk (start of leg). */
275
+ startIndex: number
276
+ /** Polyline index of the curb on the far sidewalk (end of leg). */
277
+ endIndex: number
278
+ /** Coords at startIndex, surfaced for convenience. */
279
+ lat: number
280
+ lng: number
281
+ }
282
+
283
+ /**
284
+ * Detect crosswalk-shaped micro-legs in a route polyline. A crossing
285
+ * is a short leg (<maxLegMeters) that turns sharply (>minBendDeg) off
286
+ * the previous leg and is followed by a sharp turn *back* to roughly
287
+ * the original direction — the classic "step off the sidewalk → walk
288
+ * across → step back onto the sidewalk" shape.
289
+ *
290
+ * The shape check alone isn't enough though: at every intersection
291
+ * Google's polyline traces small perpendicular curb-cut bends even when
292
+ * the user is walking straight through on the SAME sidewalk. To filter
293
+ * those out we also require a meaningful perpendicular displacement —
294
+ * the route after the crossing must continue on the OTHER side of the
295
+ * pre-crossing path, not curve back onto it.
296
+ *
297
+ * Returns each crossing as the polyline indices that bracket the leg
298
+ * we'd skip. The engine wraps each into a synthetic `CROSS_STREET`
299
+ * pivot so glasses HUDs can announce "Cross the road" alongside the
300
+ * usual turn maneuvers.
301
+ */
302
+ export function extractCrossings(
303
+ points: LatLng[],
304
+ opts: {maxLegMeters?: number; minBendDeg?: number; minSidewalkSwitchMeters?: number} = {},
305
+ ): RawCrossing[] {
306
+ const maxLeg = opts.maxLegMeters ?? 25
307
+ const minBend = opts.minBendDeg ?? 60
308
+ // Minimum perpendicular distance from the post-crossing line back to
309
+ // the pre-crossing line. ~4m is wider than any sidewalk + GPS noise
310
+ // and narrower than a typical road. A real sidewalk-switch easily
311
+ // clears this; a curb-cut zigzag at a crosswalk you DON'T take stays
312
+ // well under it.
313
+ const minSwitch = opts.minSidewalkSwitchMeters ?? 4
314
+ // How far past the crossing's end to sample for the displacement
315
+ // check. ~10m gives the polyline room to resume its post-crossing
316
+ // direction without being influenced by an immediate follow-on turn.
317
+ const sampleMeters = 10
318
+ const out: RawCrossing[] = []
319
+ if (points.length < 4) return out
320
+ for (let i = 1; i < points.length - 2; i++) {
321
+ const prev = points[i - 1]
322
+ const here = points[i]
323
+ const next = points[i + 1]
324
+ const afterNext = points[i + 2]
325
+ const legOut = haversineMeters(here, next)
326
+ if (legOut >= maxLeg) continue
327
+ const bearIn = bearingDeg(prev, here)
328
+ const bearOut = bearingDeg(here, next)
329
+ const bend = Math.abs(signedAngleDiff(bearIn, bearOut))
330
+ if (bend <= minBend) continue
331
+ const bearAfter = bearingDeg(next, afterNext)
332
+ const bendBack = Math.abs(signedAngleDiff(bearOut, bearAfter))
333
+ const inOutDelta = Math.abs(signedAngleDiff(bearIn, bearAfter))
334
+ if (!(bendBack > minBend && inOutDelta < minBend)) continue
335
+
336
+ // Displacement gate. Walk ~sampleMeters along the post-crossing
337
+ // polyline from `next` and ask: how far is that point from the
338
+ // pre-crossing line (extended forward through `here`)? Pass-through
339
+ // crosswalk traces have the route curving back to the same side
340
+ // (displacement ≈ 0). Real sidewalk-switches leave the route on
341
+ // the opposite side (displacement ≈ road width).
342
+ const sample = walkAlongPolyline(points, i + 1, sampleMeters)
343
+ if (!sample) continue
344
+ const displacement = perpDistanceMeters(sample, prev, here)
345
+ if (displacement < minSwitch) continue
346
+
347
+ out.push({startIndex: i, endIndex: i + 1, lat: here.lat, lng: here.lng})
348
+ }
349
+ return out
350
+ }
351
+
352
+ /**
353
+ * Walk forward from polyline index `startIdx` by `meters` and return
354
+ * the interpolated LatLng. Returns null when the remaining polyline
355
+ * is shorter than `meters` (we'd be measuring noise at the tail).
356
+ */
357
+ function walkAlongPolyline(points: LatLng[], startIdx: number, meters: number): LatLng | null {
358
+ if (startIdx >= points.length - 1) return null
359
+ let remaining = meters
360
+ let idx = startIdx
361
+ while (idx < points.length - 1) {
362
+ const a = points[idx]
363
+ const b = points[idx + 1]
364
+ const segLen = haversineMeters(a, b)
365
+ if (remaining <= segLen) {
366
+ const t = segLen > 0 ? remaining / segLen : 0
367
+ return {lat: a.lat + (b.lat - a.lat) * t, lng: a.lng + (b.lng - a.lng) * t}
368
+ }
369
+ remaining -= segLen
370
+ idx++
371
+ }
372
+ return null
373
+ }
374
+
375
+ /**
376
+ * Perpendicular distance in meters from `p` to the infinite line
377
+ * through `a → b`. Local-flat-earth projection — fine for the scales
378
+ * we're checking (a single intersection's worth of polyline).
379
+ */
380
+ function perpDistanceMeters(p: LatLng, a: LatLng, b: LatLng): number {
381
+ const mPerDegLat = 111_320
382
+ const mPerDegLng = 111_320 * Math.cos((a.lat * Math.PI) / 180)
383
+ const ax = a.lng * mPerDegLng
384
+ const ay = a.lat * mPerDegLat
385
+ const bx = b.lng * mPerDegLng
386
+ const by = b.lat * mPerDegLat
387
+ const px = p.lng * mPerDegLng
388
+ const py = p.lat * mPerDegLat
389
+ const dx = bx - ax
390
+ const dy = by - ay
391
+ const len = Math.sqrt(dx * dx + dy * dy)
392
+ if (len === 0) return Math.hypot(px - ax, py - ay)
393
+ // |cross product| / |line vector| = perpendicular distance.
394
+ return Math.abs(dx * (py - ay) - dy * (px - ax)) / len
395
+ }
396
+
397
+ /**
398
+ * Cumulative haversine distance along a polyline from index 0 to
399
+ * each point. Returned array is the same length as `points` —
400
+ * `cumulative[i]` is meters from start to `points[i]`.
401
+ */
402
+ export function cumulativeDistances(points: LatLng[]): number[] {
403
+ const out: number[] = new Array(points.length)
404
+ out[0] = 0
405
+ for (let i = 1; i < points.length; i++) {
406
+ out[i] = out[i - 1] + haversineMeters(points[i - 1], points[i])
407
+ }
408
+ return out
409
+ }