@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.
- package/README.md +259 -0
- package/dist/background/index.d.ts +60 -0
- package/dist/background/index.d.ts.map +1 -0
- package/dist/background/index.js +25 -0
- package/dist/background/index.js.map +1 -0
- package/dist/background/register.d.ts +38 -0
- package/dist/background/register.d.ts.map +1 -0
- package/dist/background/register.js +75 -0
- package/dist/background/register.js.map +1 -0
- package/dist/dev-reload.d.ts +21 -0
- package/dist/dev-reload.d.ts.map +1 -0
- package/dist/dev-reload.js +62 -0
- package/dist/dev-reload.js.map +1 -0
- package/dist/envelope.d.ts +23 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +47 -0
- package/dist/envelope.js.map +1 -0
- package/dist/globals.d.ts +54 -0
- package/dist/globals.d.ts.map +1 -0
- package/dist/globals.js +12 -0
- package/dist/globals.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/modules/actions.d.ts +54 -0
- package/dist/modules/actions.d.ts.map +1 -0
- package/dist/modules/actions.js +122 -0
- package/dist/modules/actions.js.map +1 -0
- package/dist/modules/auth.d.ts +25 -0
- package/dist/modules/auth.d.ts.map +1 -0
- package/dist/modules/auth.js +52 -0
- package/dist/modules/auth.js.map +1 -0
- package/dist/modules/base64.d.ts +16 -0
- package/dist/modules/base64.d.ts.map +1 -0
- package/dist/modules/base64.js +92 -0
- package/dist/modules/base64.js.map +1 -0
- package/dist/modules/blob.d.ts +161 -0
- package/dist/modules/blob.d.ts.map +1 -0
- package/dist/modules/blob.js +277 -0
- package/dist/modules/blob.js.map +1 -0
- package/dist/modules/camera.d.ts +170 -0
- package/dist/modules/camera.d.ts.map +1 -0
- package/dist/modules/camera.js +116 -0
- package/dist/modules/camera.js.map +1 -0
- package/dist/modules/cloud.d.ts +34 -0
- package/dist/modules/cloud.d.ts.map +1 -0
- package/dist/modules/cloud.js +70 -0
- package/dist/modules/cloud.js.map +1 -0
- package/dist/modules/dashboard.d.ts +16 -0
- package/dist/modules/dashboard.d.ts.map +1 -0
- package/dist/modules/dashboard.js +27 -0
- package/dist/modules/dashboard.js.map +1 -0
- package/dist/modules/display.d.ts +106 -0
- package/dist/modules/display.d.ts.map +1 -0
- package/dist/modules/display.js +57 -0
- package/dist/modules/display.js.map +1 -0
- package/dist/modules/events.d.ts +160 -0
- package/dist/modules/events.d.ts.map +1 -0
- package/dist/modules/events.js +168 -0
- package/dist/modules/events.js.map +1 -0
- package/dist/modules/glasses.d.ts +28 -0
- package/dist/modules/glasses.d.ts.map +1 -0
- package/dist/modules/glasses.js +39 -0
- package/dist/modules/glasses.js.map +1 -0
- package/dist/modules/heading.d.ts +16 -0
- package/dist/modules/heading.d.ts.map +1 -0
- package/dist/modules/heading.js +20 -0
- package/dist/modules/heading.js.map +1 -0
- package/dist/modules/imu.d.ts +31 -0
- package/dist/modules/imu.d.ts.map +1 -0
- package/dist/modules/imu.js +40 -0
- package/dist/modules/imu.js.map +1 -0
- package/dist/modules/input.d.ts +34 -0
- package/dist/modules/input.d.ts.map +1 -0
- package/dist/modules/input.js +50 -0
- package/dist/modules/input.js.map +1 -0
- package/dist/modules/led.d.ts +35 -0
- package/dist/modules/led.d.ts.map +1 -0
- package/dist/modules/led.js +39 -0
- package/dist/modules/led.js.map +1 -0
- package/dist/modules/location.d.ts +27 -0
- package/dist/modules/location.d.ts.map +1 -0
- package/dist/modules/location.js +35 -0
- package/dist/modules/location.js.map +1 -0
- package/dist/modules/mic.d.ts +44 -0
- package/dist/modules/mic.d.ts.map +1 -0
- package/dist/modules/mic.js +67 -0
- package/dist/modules/mic.js.map +1 -0
- package/dist/modules/miniapps.d.ts +72 -0
- package/dist/modules/miniapps.d.ts.map +1 -0
- package/dist/modules/miniapps.js +48 -0
- package/dist/modules/miniapps.js.map +1 -0
- package/dist/modules/navigation.d.ts +326 -0
- package/dist/modules/navigation.d.ts.map +1 -0
- package/dist/modules/navigation.js +386 -0
- package/dist/modules/navigation.js.map +1 -0
- package/dist/modules/permissions.d.ts +58 -0
- package/dist/modules/permissions.d.ts.map +1 -0
- package/dist/modules/permissions.js +66 -0
- package/dist/modules/permissions.js.map +1 -0
- package/dist/modules/phone.d.ts +95 -0
- package/dist/modules/phone.d.ts.map +1 -0
- package/dist/modules/phone.js +110 -0
- package/dist/modules/phone.js.map +1 -0
- package/dist/modules/pivots/engine.d.ts +169 -0
- package/dist/modules/pivots/engine.d.ts.map +1 -0
- package/dist/modules/pivots/engine.js +779 -0
- package/dist/modules/pivots/engine.js.map +1 -0
- package/dist/modules/pivots/geometry.d.ts +84 -0
- package/dist/modules/pivots/geometry.d.ts.map +1 -0
- package/dist/modules/pivots/geometry.js +371 -0
- package/dist/modules/pivots/geometry.js.map +1 -0
- package/dist/modules/pivots/instructions.d.ts +139 -0
- package/dist/modules/pivots/instructions.d.ts.map +1 -0
- package/dist/modules/pivots/instructions.js +280 -0
- package/dist/modules/pivots/instructions.js.map +1 -0
- package/dist/modules/speaker.d.ts +169 -0
- package/dist/modules/speaker.d.ts.map +1 -0
- package/dist/modules/speaker.js +251 -0
- package/dist/modules/speaker.js.map +1 -0
- package/dist/modules/storage.d.ts +50 -0
- package/dist/modules/storage.d.ts.map +1 -0
- package/dist/modules/storage.js +99 -0
- package/dist/modules/storage.js.map +1 -0
- package/dist/modules/stream.d.ts +129 -0
- package/dist/modules/stream.d.ts.map +1 -0
- package/dist/modules/stream.js +52 -0
- package/dist/modules/stream.js.map +1 -0
- package/dist/modules/system.d.ts +45 -0
- package/dist/modules/system.d.ts.map +1 -0
- package/dist/modules/system.js +42 -0
- package/dist/modules/system.js.map +1 -0
- package/dist/modules/transcription.d.ts +75 -0
- package/dist/modules/transcription.d.ts.map +1 -0
- package/dist/modules/transcription.js +106 -0
- package/dist/modules/transcription.js.map +1 -0
- package/dist/modules/translation.d.ts +59 -0
- package/dist/modules/translation.d.ts.map +1 -0
- package/dist/modules/translation.js +119 -0
- package/dist/modules/translation.js.map +1 -0
- package/dist/modules/ui.d.ts +186 -0
- package/dist/modules/ui.d.ts.map +1 -0
- package/dist/modules/ui.js +293 -0
- package/dist/modules/ui.js.map +1 -0
- package/dist/protocol.d.ts +299 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +325 -0
- package/dist/protocol.js.map +1 -0
- package/dist/react/MentraProvider.d.ts +33 -0
- package/dist/react/MentraProvider.d.ts.map +1 -0
- package/dist/react/MentraProvider.js +15 -0
- package/dist/react/MentraProvider.js.map +1 -0
- package/dist/react/MiniappHeader.d.ts +44 -0
- package/dist/react/MiniappHeader.d.ts.map +1 -0
- package/dist/react/MiniappHeader.js +32 -0
- package/dist/react/MiniappHeader.js.map +1 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +9 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useCapabilities.d.ts +26 -0
- package/dist/react/useCapabilities.d.ts.map +1 -0
- package/dist/react/useCapabilities.js +27 -0
- package/dist/react/useCapabilities.js.map +1 -0
- package/dist/react/useCapsuleHeaderStyle.d.ts +27 -0
- package/dist/react/useCapsuleHeaderStyle.d.ts.map +1 -0
- package/dist/react/useCapsuleHeaderStyle.js +36 -0
- package/dist/react/useCapsuleHeaderStyle.js.map +1 -0
- package/dist/react/useColorScheme.d.ts +18 -0
- package/dist/react/useColorScheme.d.ts.map +1 -0
- package/dist/react/useColorScheme.js +24 -0
- package/dist/react/useColorScheme.js.map +1 -0
- package/dist/react/useConnected.d.ts +17 -0
- package/dist/react/useConnected.d.ts.map +1 -0
- package/dist/react/useConnected.js +39 -0
- package/dist/react/useConnected.js.map +1 -0
- package/dist/react/useRpc.d.ts +29 -0
- package/dist/react/useRpc.d.ts.map +1 -0
- package/dist/react/useRpc.js +82 -0
- package/dist/react/useRpc.js.map +1 -0
- package/dist/react/useSafeArea.d.ts +21 -0
- package/dist/react/useSafeArea.d.ts.map +1 -0
- package/dist/react/useSafeArea.js +22 -0
- package/dist/react/useSafeArea.js.map +1 -0
- package/dist/session.d.ts +312 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +653 -0
- package/dist/session.js.map +1 -0
- package/dist/sub-path-types.test-d.d.ts +28 -0
- package/dist/sub-path-types.test-d.d.ts.map +1 -0
- package/dist/sub-path-types.test-d.js +37 -0
- package/dist/sub-path-types.test-d.js.map +1 -0
- package/dist/transport/auto.d.ts +35 -0
- package/dist/transport/auto.d.ts.map +1 -0
- package/dist/transport/auto.js +125 -0
- package/dist/transport/auto.js.map +1 -0
- package/dist/transport/dispatch.d.ts +43 -0
- package/dist/transport/dispatch.d.ts.map +1 -0
- package/dist/transport/dispatch.js +91 -0
- package/dist/transport/dispatch.js.map +1 -0
- package/dist/transport/local-socket.d.ts +28 -0
- package/dist/transport/local-socket.d.ts.map +1 -0
- package/dist/transport/local-socket.js +80 -0
- package/dist/transport/local-socket.js.map +1 -0
- package/dist/transport/mock.d.ts +54 -0
- package/dist/transport/mock.d.ts.map +1 -0
- package/dist/transport/mock.js +207 -0
- package/dist/transport/mock.js.map +1 -0
- package/dist/transport/postmessage.d.ts +36 -0
- package/dist/transport/postmessage.d.ts.map +1 -0
- package/dist/transport/postmessage.js +78 -0
- package/dist/transport/postmessage.js.map +1 -0
- package/dist/transport/types.d.ts +25 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +11 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/ui/index.d.ts +98 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +32 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +84 -0
- package/src/background/index.ts +152 -0
- package/src/background/register.ts +85 -0
- package/src/dev-reload.ts +60 -0
- package/src/envelope.ts +54 -0
- package/src/globals.ts +62 -0
- package/src/index.ts +171 -0
- package/src/modules/actions.ts +156 -0
- package/src/modules/auth.ts +68 -0
- package/src/modules/base64.ts +87 -0
- package/src/modules/blob.ts +341 -0
- package/src/modules/camera.ts +242 -0
- package/src/modules/cloud.ts +94 -0
- package/src/modules/dashboard.ts +31 -0
- package/src/modules/display.ts +122 -0
- package/src/modules/events.ts +291 -0
- package/src/modules/glasses.ts +44 -0
- package/src/modules/heading.ts +23 -0
- package/src/modules/imu.ts +44 -0
- package/src/modules/input.ts +71 -0
- package/src/modules/led.ts +61 -0
- package/src/modules/location.ts +39 -0
- package/src/modules/mic.ts +74 -0
- package/src/modules/miniapps.ts +90 -0
- package/src/modules/navigation.ts +670 -0
- package/src/modules/permissions.ts +84 -0
- package/src/modules/phone.ts +155 -0
- package/src/modules/pivots/engine.ts +863 -0
- package/src/modules/pivots/geometry.ts +409 -0
- package/src/modules/pivots/instructions.ts +343 -0
- package/src/modules/speaker.ts +335 -0
- package/src/modules/storage.ts +109 -0
- package/src/modules/stream.ts +163 -0
- package/src/modules/system.ts +76 -0
- package/src/modules/transcription.ts +145 -0
- package/src/modules/translation.ts +149 -0
- package/src/modules/ui.ts +498 -0
- package/src/protocol.ts +378 -0
- package/src/react/MentraProvider.tsx +50 -0
- package/src/react/MiniappHeader.tsx +122 -0
- package/src/react/index.ts +12 -0
- package/src/react/useCapabilities.ts +29 -0
- package/src/react/useCapsuleHeaderStyle.ts +54 -0
- package/src/react/useColorScheme.ts +26 -0
- package/src/react/useConnected.ts +47 -0
- package/src/react/useRpc.ts +106 -0
- package/src/react/useSafeArea.ts +40 -0
- package/src/session.ts +886 -0
- package/src/sub-path-types.test-d.ts +49 -0
- package/src/transport/auto.ts +143 -0
- package/src/transport/dispatch.ts +106 -0
- package/src/transport/local-socket.ts +93 -0
- package/src/transport/mock.ts +251 -0
- package/src/transport/postmessage.ts +95 -0
- package/src/transport/types.ts +26 -0
- 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}
|