@ateam-ai-mobile/expo 0.1.7 → 0.1.8
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/dist/bootstrap.js +841 -323
- package/dist/bootstrap.js.map +1 -1
- package/dist/bootstrap.mjs +9 -7
- package/dist/bootstrap.mjs.map +1 -1
- package/dist/chunk-7FWYEHN5.mjs +203 -0
- package/dist/chunk-7FWYEHN5.mjs.map +1 -0
- package/dist/{chunk-4XYXJYN5.mjs → chunk-C7M5WAHA.mjs} +583 -298
- package/dist/chunk-C7M5WAHA.mjs.map +1 -0
- package/dist/{chunk-5HM6UA2K.mjs → chunk-GF2MPTAB.mjs} +83 -7
- package/dist/chunk-GF2MPTAB.mjs.map +1 -0
- package/dist/chunk-J6XRNX7P.mjs +19 -0
- package/dist/chunk-J6XRNX7P.mjs.map +1 -0
- package/dist/{chunk-TZXWVYYR.mjs → chunk-PCAEUX2N.mjs} +1 -1
- package/dist/chunk-PCAEUX2N.mjs.map +1 -0
- package/dist/{chunk-VQJPSLHP.mjs → chunk-Z5AI2SXX.mjs} +13 -4
- package/dist/chunk-Z5AI2SXX.mjs.map +1 -0
- package/dist/createBridge.js +41 -10
- package/dist/createBridge.js.map +1 -1
- package/dist/createBridge.mjs +5 -1
- package/dist/createBridge.mjs.map +1 -1
- package/dist/device/createDeviceProvider.js +278 -17
- package/dist/device/createDeviceProvider.js.map +1 -1
- package/dist/device/createDeviceProvider.mjs +5 -3
- package/dist/device-bridge/index.d.mts +13 -1
- package/dist/device-bridge/index.d.ts +13 -1
- package/dist/device-bridge/index.js +49 -12
- package/dist/device-bridge/index.js.map +1 -1
- package/dist/device-bridge/index.mjs +3 -1
- package/dist/index.d.mts +19 -3
- package/dist/index.d.ts +19 -3
- package/dist/index.js +594 -297
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/maps/index.d.mts +56 -0
- package/dist/maps/index.d.ts +56 -0
- package/dist/maps/index.js +95 -0
- package/dist/maps/index.js.map +1 -0
- package/dist/maps/index.mjs +17 -0
- package/dist/maps/index.mjs.map +1 -0
- package/dist/vision/index.d.mts +4 -2
- package/dist/vision/index.d.ts +4 -2
- package/dist/vision/index.js.map +1 -1
- package/dist/vision/index.mjs +1 -1
- package/expo-module.config.json +1 -1
- package/ios/AdaMaps/AdaMapsModule.swift +180 -0
- package/ios/AdaMaps.podspec +25 -0
- package/ios/AdaVision.podspec +4 -0
- package/ios/AdaVisionModule.swift +63 -9
- package/package.json +10 -4
- package/src/args.ts +56 -0
- package/src/createBridge.ts +3 -1
- package/src/device/createDeviceProvider.ts +88 -3
- package/src/device-bridge/collectors/location.ts +21 -9
- package/src/device-state/collectors.ts +5 -1
- package/src/device-state/domains/deviceContext.ts +5 -3
- package/src/device-state/domains/interaction.ts +2 -1
- package/src/device-state/domains/spatial.ts +200 -116
- package/src/device-state/domains/visual.ts +34 -13
- package/src/device-state/events.ts +49 -0
- package/src/device-state/headingSource.ts +43 -8
- package/src/device-state/index.ts +3 -2
- package/src/device-state/replay.ts +76 -11
- package/src/device-state/spatialEvents.ts +300 -0
- package/src/geo.ts +23 -0
- package/src/maps/index.ts +81 -0
- package/src/maps/normalize.ts +225 -0
- package/src/vision/index.ts +4 -2
- package/dist/chunk-4XYXJYN5.mjs.map +0 -1
- package/dist/chunk-5HM6UA2K.mjs.map +0 -1
- package/dist/chunk-TZXWVYYR.mjs.map +0 -1
- package/dist/chunk-VQJPSLHP.mjs.map +0 -1
|
@@ -26,8 +26,20 @@
|
|
|
26
26
|
* are replayed exactly as recorded. They are the most valuable records in the
|
|
27
27
|
* file — they are what a guide must handle correctly, and precisely what a
|
|
28
28
|
* hand-written fixture would never contain.
|
|
29
|
+
*
|
|
30
|
+
* SPATIAL EVENTS REPLAY; THE OTHERS REFUSE. Spatial events are derived from
|
|
31
|
+
* snapshots by the same detector the live engine runs, so feeding it the
|
|
32
|
+
* recorded snapshots in time order gives the events the walk produced — at the
|
|
33
|
+
* virtual time of the sample that completed each one. While a listener runs,
|
|
34
|
+
* the clock stands AT that sample, so a listener that re-reads gets the
|
|
35
|
+
* snapshot its event came from, as it would live. Two limits, inherited from
|
|
36
|
+
* recording snapshots: two turns inside one recorded tick collapse to the later
|
|
37
|
+
* one, because a snapshot carries only the last turn; and a hold completes at
|
|
38
|
+
* the recording's tick rather than the live engine's one-second one.
|
|
29
39
|
*/
|
|
30
|
-
import type { DeviceState } from '@ateam-ai-mobile/plugin-runtime';
|
|
40
|
+
import type { DeviceState, SpatialStateEvent } from '@ateam-ai-mobile/plugin-runtime';
|
|
41
|
+
import { SpatialEventDetector, spatialEventProfile } from './spatialEvents';
|
|
42
|
+
import { listenerSet } from './events';
|
|
31
43
|
|
|
32
44
|
/** One tick of a recorded walk. Domains are stored verbatim, never normalised. */
|
|
33
45
|
export interface WalkSample {
|
|
@@ -138,11 +150,46 @@ export function createReplayDeviceState(fixture: WalkFixture): ReplaySession {
|
|
|
138
150
|
|
|
139
151
|
let cursor = 0;
|
|
140
152
|
|
|
153
|
+
// Spatial events: the live engine's detector, fed recorded snapshots.
|
|
154
|
+
const spatialDetector = new SpatialEventDetector();
|
|
155
|
+
const spatialListeners = listenerSet<SpatialStateEvent>();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Move the cursor, feeding every spatial sample crossed on the way to the
|
|
159
|
+
* detector, oldest first, with the sample's own `t` as "now". Only while a
|
|
160
|
+
* spatial session is open — the live engine has no event feed without one.
|
|
161
|
+
*
|
|
162
|
+
* The cursor steps THROUGH each fed sample rather than jumping to the target
|
|
163
|
+
* first: an event and a read taken inside its listener must describe the same
|
|
164
|
+
* instant, or the event says "you turned" while the read still shows the
|
|
165
|
+
* sample before the corner.
|
|
166
|
+
*
|
|
167
|
+
* Backwards is not a walk: the detector forgets and says nothing, and the
|
|
168
|
+
* next step forward starts from the new position as a fresh session would.
|
|
169
|
+
*/
|
|
170
|
+
const moveTo = (target: number): number => {
|
|
171
|
+
const next = Math.min(duration, Math.max(0, target));
|
|
172
|
+
if (!openSessions.has('spatial')) return (cursor = next);
|
|
173
|
+
if (next < cursor) {
|
|
174
|
+
cursor = next;
|
|
175
|
+
spatialDetector.reset(undefined, turnOnRecordAt());
|
|
176
|
+
return cursor;
|
|
177
|
+
}
|
|
178
|
+
const from = cursor;
|
|
179
|
+
for (const s of samples) {
|
|
180
|
+
if (s.t <= from || !s.spatial) continue;
|
|
181
|
+
if (s.t > next) break;
|
|
182
|
+
cursor = s.t;
|
|
183
|
+
for (const event of spatialDetector.push(s.spatial, s.t)) spatialListeners.emit(event);
|
|
184
|
+
}
|
|
185
|
+
return (cursor = next);
|
|
186
|
+
};
|
|
187
|
+
|
|
141
188
|
const clock: ReplayClock = {
|
|
142
189
|
now: () => cursor,
|
|
143
|
-
advance: ms => (cursor
|
|
144
|
-
seek: ms => (
|
|
145
|
-
end: () => (
|
|
190
|
+
advance: ms => moveTo(cursor + ms),
|
|
191
|
+
seek: ms => moveTo(ms),
|
|
192
|
+
end: () => moveTo(duration),
|
|
146
193
|
durationMs: () => duration,
|
|
147
194
|
};
|
|
148
195
|
|
|
@@ -163,6 +210,13 @@ export function createReplayDeviceState(fixture: WalkFixture): ReplaySession {
|
|
|
163
210
|
return found;
|
|
164
211
|
};
|
|
165
212
|
|
|
213
|
+
/**
|
|
214
|
+
* The turn the recording already holds at the cursor. Taken from the sample,
|
|
215
|
+
* never compared with the cursor: a recording stamps turns in the phone's
|
|
216
|
+
* epoch ms, while `t` is ms since the recording started.
|
|
217
|
+
*/
|
|
218
|
+
const turnOnRecordAt = (): number | null => sampleAt()?.spatial?.changes?.timestamp ?? null;
|
|
219
|
+
|
|
166
220
|
const domainFor = (name: string): any => ({
|
|
167
221
|
async getSnapshot() {
|
|
168
222
|
const s = sampleAt();
|
|
@@ -171,21 +225,32 @@ export function createReplayDeviceState(fixture: WalkFixture): ReplaySession {
|
|
|
171
225
|
// answer the real gate gives for a domain it cannot serve.
|
|
172
226
|
return recorded ?? unknownSnapshot(name);
|
|
173
227
|
},
|
|
174
|
-
async start() {
|
|
228
|
+
async start(options?: { intent?: string }) {
|
|
175
229
|
const r = refusals.get(name);
|
|
176
230
|
// Replay the refusal the real walk hit. A fixture recorded with the
|
|
177
231
|
// pedometer denied must fail here, or a test proves the guide works in
|
|
178
232
|
// conditions the user never had.
|
|
179
233
|
if (r) throw refusal(name, r.code, r.message);
|
|
234
|
+
// A session opened at `t` hears samples recorded AFTER `t`: the reading
|
|
235
|
+
// at `t` already existed before it asked, as a live fix would have.
|
|
236
|
+
if (name === 'spatial' && !openSessions.has(name)) {
|
|
237
|
+
spatialDetector.reset(spatialEventProfile(options?.intent), turnOnRecordAt());
|
|
238
|
+
}
|
|
180
239
|
openSessions.add(name);
|
|
181
240
|
return { id: `replay_${name}`, intent: 'replay', startedAt: 0 } as any;
|
|
182
241
|
},
|
|
183
|
-
async stop() {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
242
|
+
async stop() {
|
|
243
|
+
openSessions.delete(name);
|
|
244
|
+
if (name === 'spatial') spatialDetector.reset();
|
|
245
|
+
},
|
|
246
|
+
subscribe(listener: (event: SpatialStateEvent) => void) {
|
|
247
|
+
// Spatial events are derived from snapshots, and the snapshots are what
|
|
248
|
+
// this file replays — so they fire as the clock crosses each sample.
|
|
249
|
+
if (name === 'spatial') return spatialListeners.add(listener);
|
|
250
|
+
// The rest are deliberately unsupported: their events come from engine
|
|
251
|
+
// callbacks a fixture does not record, and a subscription that silently
|
|
252
|
+
// never fires reads as "nothing has changed" — the failure this whole
|
|
253
|
+
// layer exists to prevent. Step the clock and read instead.
|
|
189
254
|
throw refusal(name, 'UNSUPPORTED', `deviceState.${name}.subscribe is not replayable — advance the clock and read.`);
|
|
190
255
|
},
|
|
191
256
|
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial events, derived from spatial snapshots.
|
|
3
|
+
*
|
|
4
|
+
* ONE MODEL, NOT TWO. An event is what changed between two snapshots — the
|
|
5
|
+
* same snapshots `getSnapshot()` returns — so an event can never say "moving"
|
|
6
|
+
* while a read says "stationary", and no threshold here has a second copy
|
|
7
|
+
* somewhere else. The thresholds a read uses (the stationary speed, the GPS
|
|
8
|
+
* confidence bands, distance along a path) live in this file and the domain
|
|
9
|
+
* imports them, rather than the other way round, so the detector can be tested
|
|
10
|
+
* without an Expo import: the live engine feeds it on every fix, every turn and
|
|
11
|
+
* a one-second tick; replay feeds it recorded samples on a virtual clock.
|
|
12
|
+
*
|
|
13
|
+
* THE HARD PART IS NOT DETECTING CHANGES, IT IS NOT INVENTING THEM (the turn
|
|
14
|
+
* detector's lesson, again). GPS speed wobbles around any threshold, and a fix
|
|
15
|
+
* band flickers when accuracy sits near a boundary. So every state change must
|
|
16
|
+
* HOLD before it is reported, and position changes must beat the fix's own
|
|
17
|
+
* error bar. Holds delay an event on purpose; `since` in the payload carries
|
|
18
|
+
* the real onset.
|
|
19
|
+
*
|
|
20
|
+
* A HOLD IS HELD BY FIXES, NOT BY THE CLOCK ALONE. The one-second tick re-reads
|
|
21
|
+
* the SAME fix, so a hold measured only in time would let one outlier fix
|
|
22
|
+
* followed by a gap — sparse fixes at Balanced accuracy, a wifi jump read as
|
|
23
|
+
* speed — become "you started moving". A state settles only once two distinct
|
|
24
|
+
* fixes have shown it; after that the tick may complete the hold, which is what
|
|
25
|
+
* it is for ("stationary for five seconds" completes while nothing arrives).
|
|
26
|
+
*
|
|
27
|
+
* Type-only imports from plugin-runtime, so `node --test` can load this file.
|
|
28
|
+
*/
|
|
29
|
+
import type {
|
|
30
|
+
Confidence,
|
|
31
|
+
SpatialEvent,
|
|
32
|
+
SpatialIntent,
|
|
33
|
+
SpatialPoint,
|
|
34
|
+
SpatialSnapshot,
|
|
35
|
+
SpatialStateEvent,
|
|
36
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
37
|
+
import { stateEvent } from './events';
|
|
38
|
+
import { distanceOf } from '../geo';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Below this the user is standing still.
|
|
42
|
+
*
|
|
43
|
+
* 0.4 m/s is well under a slow walk (~1.2 m/s) and above the jitter a
|
|
44
|
+
* stationary phone's GPS produces. The point of the band is that a guide
|
|
45
|
+
* must not say "keep going straight" to someone who has stopped.
|
|
46
|
+
*/
|
|
47
|
+
export const STATIONARY_MPS = 0.4;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Only claim stationary/moving when a speed was actually obtained. With no
|
|
51
|
+
* speed the truthful answer is `unknown`, not `stationary` — "you have
|
|
52
|
+
* stopped" is a false statement, not a neutral default.
|
|
53
|
+
*/
|
|
54
|
+
export function movementStateOf(speedMps: number | null): 'stationary' | 'moving' | 'unknown' {
|
|
55
|
+
return speedMps === null ? 'unknown' : speedMps < STATIONARY_MPS ? 'stationary' : 'moving';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The GPS fix's confidence band, from metres of horizontal error. ~10m is a
|
|
60
|
+
* good open-sky fix; beyond ~50m the position is street-level at best and
|
|
61
|
+
* useless for "turn at the next corner".
|
|
62
|
+
*/
|
|
63
|
+
export function fixConfidence(accuracyM: number | null): Confidence['level'] {
|
|
64
|
+
return accuracyM === null ? 'unknown' : accuracyM <= 10 ? 'high' : accuracyM <= 50 ? 'medium' : 'low';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
export interface SpatialEventProfile {
|
|
69
|
+
/** How long `moving` must hold before `movementStarted`. */
|
|
70
|
+
movingHoldMs: number;
|
|
71
|
+
/** How long `stationary` must hold, after moving, before `movementStopped`. */
|
|
72
|
+
stoppedHoldMs: number;
|
|
73
|
+
/** How long a new GPS confidence band must hold before it is reported. */
|
|
74
|
+
confidenceHoldMs: number;
|
|
75
|
+
/** Floor for `positionChanged`, metres. The real threshold is
|
|
76
|
+
* max(this, moveThresholdM(anchor, fix)) — both fixes' uncertainty — so a
|
|
77
|
+
* loose fix on EITHER end needs a bigger move. */
|
|
78
|
+
minMoveM: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Intent → how eager the events are.
|
|
83
|
+
*
|
|
84
|
+
* These are the engine's judgement, not the freeze's — the architecture doc
|
|
85
|
+
* says only "thresholds are profile-dependent". Recorded here so the reasoning
|
|
86
|
+
* is visible and arguable rather than buried. The intent changes ONLY these
|
|
87
|
+
* holds, not sampling: there is no native strategy layer for it to reach.
|
|
88
|
+
*
|
|
89
|
+
* - `navigation`: a guide needs "you've stopped" soon enough to act on, but a
|
|
90
|
+
* pause at a crossing is not a stop worth announcing — 3 s to start, 5 s to
|
|
91
|
+
* stop. 10 m is about one building frontage.
|
|
92
|
+
* - `fitness`: same movement holds; a slower confidence hold, because a runner
|
|
93
|
+
* under trees dips in and out of a band constantly and nobody needs to hear it.
|
|
94
|
+
* - `preciseLocate`: the caller is deliberately pinning a position, so the
|
|
95
|
+
* holds are shortest and a few metres counts.
|
|
96
|
+
* - `geofence` / `lowPowerTracking`: coarse by nature — tens of metres, and a
|
|
97
|
+
* state must hold for a while before it is worth waking anyone for.
|
|
98
|
+
*/
|
|
99
|
+
export const SPATIAL_EVENT_PROFILES: Record<SpatialIntent, SpatialEventProfile> = {
|
|
100
|
+
navigation: { movingHoldMs: 3000, stoppedHoldMs: 5000, confidenceHoldMs: 3000, minMoveM: 10 },
|
|
101
|
+
fitness: { movingHoldMs: 3000, stoppedHoldMs: 5000, confidenceHoldMs: 5000, minMoveM: 10 },
|
|
102
|
+
preciseLocate: { movingHoldMs: 2000, stoppedHoldMs: 3000, confidenceHoldMs: 2000, minMoveM: 3 },
|
|
103
|
+
geofence: { movingHoldMs: 10000, stoppedHoldMs: 30000, confidenceHoldMs: 10000, minMoveM: 25 },
|
|
104
|
+
lowPowerTracking: { movingHoldMs: 10000, stoppedHoldMs: 30000, confidenceHoldMs: 10000, minMoveM: 50 },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** The profile for an intent; an unrecognised one gets `navigation`'s. */
|
|
108
|
+
/**
|
|
109
|
+
* The move two fixes can vouch for. A fix is a circle, not a point, and so is
|
|
110
|
+
* the anchor it is compared with: two fixes of a phone lying still differ by up
|
|
111
|
+
* to BOTH their errors. Combined (root-sum-square, each accuracyM ~ one radius)
|
|
112
|
+
* and doubled, like a single fix's 2× before. No estimate = 0 (the floor rules).
|
|
113
|
+
* Anchor ±100 m → fix ±5 m: a 20 m difference is noise, not a walk.
|
|
114
|
+
*/
|
|
115
|
+
export function moveThresholdM(anchor: { accuracyM?: number | null }, fix: { accuracyM?: number | null }): number {
|
|
116
|
+
const a = anchor.accuracyM ?? 0, b = fix.accuracyM ?? 0;
|
|
117
|
+
return 2 * Math.sqrt(a * a + b * b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function spatialEventProfile(intent: string | null | undefined): SpatialEventProfile {
|
|
121
|
+
return SPATIAL_EVENT_PROFILES[intent as SpatialIntent] ?? SPATIAL_EVENT_PROFILES.navigation;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type Band = 'high' | 'medium' | 'low';
|
|
125
|
+
const RANK: Record<Band, number> = { high: 3, medium: 2, low: 1 };
|
|
126
|
+
type PayloadOf<N extends SpatialEvent> = Extract<SpatialStateEvent, { name: N }>['payload'];
|
|
127
|
+
|
|
128
|
+
/** Distinct fixes a state must be seen on before its hold can complete. */
|
|
129
|
+
const MIN_FIXES = 2;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An unbroken run of one raw state: when it began, and how many distinct fixes
|
|
133
|
+
* (by `position.timestamp`) have shown it. A re-read of the same fix extends
|
|
134
|
+
* the run's time, never its evidence.
|
|
135
|
+
*/
|
|
136
|
+
interface Run<S> { state: S; since: number; fixAt: number | null; fixes: number }
|
|
137
|
+
|
|
138
|
+
function extend<S>(run: Run<S> | null, state: S, fixAt: number | null, now: number): Run<S> {
|
|
139
|
+
if (!run || run.state !== state) return { state, since: now, fixAt, fixes: fixAt === null ? 0 : 1 };
|
|
140
|
+
if (fixAt !== null && fixAt !== run.fixAt) { run.fixAt = fixAt; run.fixes++; }
|
|
141
|
+
return run;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const settled = (run: Run<unknown>, now: number, holdMs: number): boolean =>
|
|
145
|
+
run.fixes >= MIN_FIXES && now - run.since >= holdMs;
|
|
146
|
+
|
|
147
|
+
/** Which fix a snapshot was built from — its identity when counting evidence. */
|
|
148
|
+
const fixAt = (s: SpatialSnapshot): number | null => s.position?.timestamp ?? null;
|
|
149
|
+
|
|
150
|
+
export class SpatialEventDetector {
|
|
151
|
+
private profile: SpatialEventProfile;
|
|
152
|
+
|
|
153
|
+
/** The movement state last settled on. Only MEASURED states settle. */
|
|
154
|
+
private movement: 'stationary' | 'moving' | 'unknown' = 'unknown';
|
|
155
|
+
private run: Run<'stationary' | 'moving' | 'unknown'> | null = null;
|
|
156
|
+
|
|
157
|
+
/** The completed turn already on record — reported turns, and the one the
|
|
158
|
+
* session opened onto. Only a turn with a new timestamp is news. */
|
|
159
|
+
private lastTurnAt: number | null = null;
|
|
160
|
+
|
|
161
|
+
/** The GPS band last settled on; null until the session's first band. */
|
|
162
|
+
private band: Band | null = null;
|
|
163
|
+
private bandRun: Run<Band> | null = null;
|
|
164
|
+
|
|
165
|
+
/** The last position REPORTED — distance is measured from here, not from
|
|
166
|
+
* the previous fix, so a slow walk still adds up to a change. */
|
|
167
|
+
private anchor: SpatialPoint | null = null;
|
|
168
|
+
|
|
169
|
+
private staleArmed = true;
|
|
170
|
+
|
|
171
|
+
constructor(profile: SpatialEventProfile = SPATIAL_EVENT_PROFILES.navigation) {
|
|
172
|
+
this.profile = profile;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Forget everything — a new session, or replay seeking backwards. Emits
|
|
177
|
+
* nothing. `turnOnRecordAt` is the timestamp of the turn already completed
|
|
178
|
+
* when the session opens (a visual session shares the compass): history, not
|
|
179
|
+
* news. Seeded from the turn itself rather than compared against a clock, so
|
|
180
|
+
* it holds whichever clock the turn was stamped with — epoch live, the
|
|
181
|
+
* recording's epoch in replay, where `now` is virtual.
|
|
182
|
+
*/
|
|
183
|
+
reset(profile?: SpatialEventProfile, turnOnRecordAt: number | null = null): void {
|
|
184
|
+
if (profile) this.profile = profile;
|
|
185
|
+
this.movement = 'unknown';
|
|
186
|
+
this.run = null;
|
|
187
|
+
this.lastTurnAt = turnOnRecordAt;
|
|
188
|
+
this.band = null;
|
|
189
|
+
this.bandRun = null;
|
|
190
|
+
this.anchor = null;
|
|
191
|
+
this.staleArmed = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Feed the newest snapshot. Returns what changed since the last one, in this
|
|
196
|
+
* order: movement, turn, position, confidence, staleness. Fields are read
|
|
197
|
+
* defensively — replay hands over recorded snapshots verbatim, and a recording
|
|
198
|
+
* may lack a block the live engine always fills.
|
|
199
|
+
*/
|
|
200
|
+
push(snapshot: SpatialSnapshot, now: number): SpatialStateEvent[] {
|
|
201
|
+
const out: SpatialStateEvent[] = [];
|
|
202
|
+
const emit: Emit = (name, payload) => {
|
|
203
|
+
// The payload is checked against its name by `Emit`; the union cannot
|
|
204
|
+
// follow a generic name through stateEvent's return, hence the assertion.
|
|
205
|
+
out.push(stateEvent('spatial', name, payload as unknown, now) as SpatialStateEvent);
|
|
206
|
+
};
|
|
207
|
+
this.movementFrom(snapshot, now, emit);
|
|
208
|
+
this.turnFrom(snapshot, emit);
|
|
209
|
+
this.positionFrom(snapshot, emit);
|
|
210
|
+
this.confidenceFrom(snapshot, now, emit);
|
|
211
|
+
this.staleFrom(snapshot, emit);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private movementFrom(s: SpatialSnapshot, now: number, emit: Emit): void {
|
|
216
|
+
const state = s.movement?.state ?? 'unknown';
|
|
217
|
+
this.run = extend(this.run, state, fixAt(s), now);
|
|
218
|
+
if (state === this.movement) return;
|
|
219
|
+
|
|
220
|
+
if (state === 'moving' && settled(this.run, now, this.profile.movingHoldMs)) {
|
|
221
|
+
emit('movementStarted', {
|
|
222
|
+
previous: this.movement === 'stationary' ? 'stationary' : 'unknown',
|
|
223
|
+
speedMps: s.movement?.speedMps ?? null,
|
|
224
|
+
bearingDeg: s.movement?.bearingDeg ?? null,
|
|
225
|
+
since: this.run.since,
|
|
226
|
+
});
|
|
227
|
+
this.movement = 'moving';
|
|
228
|
+
} else if (state === 'stationary' && settled(this.run, now, this.profile.stoppedHoldMs)) {
|
|
229
|
+
if (this.movement === 'moving') emit('movementStopped', { previous: 'moving', since: this.run.since });
|
|
230
|
+
this.movement = 'stationary';
|
|
231
|
+
}
|
|
232
|
+
// `unknown` never settles. Losing the speed is not evidence of stopping, so
|
|
233
|
+
// moving → unknown says nothing, and a later measured `stationary` is still
|
|
234
|
+
// reported as the stop it is.
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private turnFrom(s: SpatialSnapshot, emit: Emit): void {
|
|
238
|
+
const at = s.changes?.timestamp ?? null;
|
|
239
|
+
const deg = s.changes?.lastMeaningfulTurnDeg ?? null;
|
|
240
|
+
// A compass reset clears the turn to null. That is not a new turn.
|
|
241
|
+
if (at === null || deg === null || at === this.lastTurnAt) return;
|
|
242
|
+
this.lastTurnAt = at;
|
|
243
|
+
emit('lastMeaningfulTurnChanged', { lastMeaningfulTurnDeg: deg, timestamp: at });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private positionFrom(s: SpatialSnapshot, emit: Emit): void {
|
|
247
|
+
const p = s.position ?? null;
|
|
248
|
+
if (!p) return;
|
|
249
|
+
if (!this.anchor) {
|
|
250
|
+
emit('positionChanged', { position: { ...p }, previous: null, distanceM: null });
|
|
251
|
+
this.anchor = p;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const d = distanceOf([this.anchor, p]);
|
|
255
|
+
if (d < Math.max(this.profile.minMoveM, moveThresholdM(this.anchor, p))) {
|
|
256
|
+
// Not a move — but if this fix is far more precise than the anchor, keep
|
|
257
|
+
// IT as the reference (silently): measuring later moves from a ±100 m
|
|
258
|
+
// anchor would hide real ones behind the old fix's error.
|
|
259
|
+
const aNew = p.accuracyM ?? null, aOld = this.anchor.accuracyM ?? null;
|
|
260
|
+
if (aNew != null && aOld != null && aNew * 2 < aOld) this.anchor = p;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
emit('positionChanged', { position: { ...p }, previous: { ...this.anchor }, distanceM: d });
|
|
264
|
+
this.anchor = p;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private confidenceFrom(s: SpatialSnapshot, now: number, emit: Emit): void {
|
|
268
|
+
const p = s.position ?? null;
|
|
269
|
+
if (!p) return;
|
|
270
|
+
const level = fixConfidence(p.accuracyM ?? null);
|
|
271
|
+
// A fix with no accuracy estimate says nothing about the band — the same
|
|
272
|
+
// way the snapshot's confidence ignores an `unknown` part.
|
|
273
|
+
if (level === 'unknown') return;
|
|
274
|
+
// The first band is where the session starts, not a change.
|
|
275
|
+
if (this.band === null) { this.band = level; return; }
|
|
276
|
+
if (level === this.band) { this.bandRun = null; return; }
|
|
277
|
+
this.bandRun = extend(this.bandRun, level, fixAt(s), now);
|
|
278
|
+
if (!settled(this.bandRun, now, this.profile.confidenceHoldMs)) return;
|
|
279
|
+
emit('positionConfidenceChanged', {
|
|
280
|
+
level,
|
|
281
|
+
previous: this.band,
|
|
282
|
+
accuracyM: p.accuracyM ?? null,
|
|
283
|
+
direction: RANK[level] < RANK[this.band] ? 'dropped' : 'recovered',
|
|
284
|
+
});
|
|
285
|
+
this.band = level;
|
|
286
|
+
this.bandRun = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private staleFrom(s: SpatialSnapshot, emit: Emit): void {
|
|
290
|
+
const f = s.quality?.freshness;
|
|
291
|
+
if (!f) return;
|
|
292
|
+
// Re-armed only by a FRESH read: `aging` on the way back is not recovery.
|
|
293
|
+
if (f.state === 'fresh') { this.staleArmed = true; return; }
|
|
294
|
+
if (f.state !== 'stale' || !this.staleArmed) return;
|
|
295
|
+
emit('spatialStateStale', { observedAt: f.observedAt, ageMs: f.ageMs });
|
|
296
|
+
this.staleArmed = false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
type Emit = <N extends SpatialEvent>(name: N, payload: PayloadOf<N>) => void;
|
package/src/geo.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Great-circle distance — the SDK's one answer to "how far apart are these".
|
|
2
|
+
//
|
|
3
|
+
// Moved out of device-state/domains/spatial.ts when native.maps needed the same
|
|
4
|
+
// question answered (a search result's distance from `near`). Two haversines
|
|
5
|
+
// would be two answers that could drift; both callers import this one.
|
|
6
|
+
//
|
|
7
|
+
// No imports on purpose: it runs under plain `node --test` with the maps
|
|
8
|
+
// normaliser that uses it.
|
|
9
|
+
|
|
10
|
+
/** Metres along a path, great-circle per leg, rounded to the metre. */
|
|
11
|
+
export function distanceOf(path: ReadonlyArray<{ latitude: number; longitude: number }>): number {
|
|
12
|
+
const R = 6_371_000;
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (let i = 1; i < path.length; i++) {
|
|
15
|
+
const a = path[i - 1], b = path[i];
|
|
16
|
+
const dLat = ((b.latitude - a.latitude) * Math.PI) / 180;
|
|
17
|
+
const dLon = ((b.longitude - a.longitude) * Math.PI) / 180;
|
|
18
|
+
const la1 = (a.latitude * Math.PI) / 180, la2 = (b.latitude * Math.PI) / 180;
|
|
19
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
|
20
|
+
total += 2 * R * Math.asin(Math.sqrt(h));
|
|
21
|
+
}
|
|
22
|
+
return Math.round(total);
|
|
23
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ada-maps — place search + walking directions (Apple MapKit), our own native
|
|
2
|
+
// code, plus the SDK's one reverse geocode.
|
|
3
|
+
//
|
|
4
|
+
// NOT on-device — the opposite of ada-vision. Every call sends the query and the
|
|
5
|
+
// coordinates it is given to a map service over the network. None of it reads
|
|
6
|
+
// the device's location: the caller supplies every coordinate.
|
|
7
|
+
//
|
|
8
|
+
// search(...) — MKLocalSearch (AdaMaps module, iOS only)
|
|
9
|
+
// route(...) — MKDirections (AdaMaps module, iOS only)
|
|
10
|
+
// reverseGeocode(at) — expo-location's reverseGeocodeAsync, which IS CLGeocoder
|
|
11
|
+
// on iOS and the platform Geocoder on Android. No Swift for
|
|
12
|
+
// it, and the ONLY reverse geocode in the SDK: the location
|
|
13
|
+
// collector and the connector bridge call this too.
|
|
14
|
+
//
|
|
15
|
+
// requireOptionalNativeModule: on a binary WITHOUT the module the import is null,
|
|
16
|
+
// isSupported() is false, and search/route refuse cleanly (no crash). Android has
|
|
17
|
+
// no AdaMaps at all, so it refuses by name the same way.
|
|
18
|
+
|
|
19
|
+
import { Platform } from 'react-native';
|
|
20
|
+
import { requireOptionalNativeModule } from 'expo-modules-core';
|
|
21
|
+
import * as ExpoLocation from 'expo-location';
|
|
22
|
+
import type { MapsAddress, MapsCoordinate } from '@ateam-ai-mobile/plugin-runtime';
|
|
23
|
+
import { toMapsAddress } from './normalize';
|
|
24
|
+
import type { RawMapsPlace, RawMapsRoute } from './normalize';
|
|
25
|
+
|
|
26
|
+
interface AdaMapsNative {
|
|
27
|
+
isSupported(): boolean;
|
|
28
|
+
search(query: string, lat: number, lng: number, hasNear: boolean, radiusM: number, limit: number): Promise<RawMapsPlace[]>;
|
|
29
|
+
route(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<RawMapsRoute | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const native = requireOptionalNativeModule<AdaMapsNative>('AdaMaps');
|
|
33
|
+
|
|
34
|
+
/** What search/route are missing in THIS build, as the clause after "needs" —
|
|
35
|
+
* or null when they can run. A voice assistant reads it out, so it names the
|
|
36
|
+
* missing thing rather than giving a code. */
|
|
37
|
+
export function missingReason(): string | null {
|
|
38
|
+
if (Platform.OS !== 'ios') return `iOS (Apple MapKit) — ${Platform.OS} has no implementation yet`;
|
|
39
|
+
if (!native) return 'the app build with the AdaMaps module (this build predates it — a native rebuild is needed)';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Is the native maps module present in this binary (and on a platform it runs on)? */
|
|
44
|
+
export function isSupported(): boolean {
|
|
45
|
+
try { return missingReason() === null && (native?.isSupported() ?? false); } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// search/route hand their arguments to native AS GIVEN: a NaN or Infinity
|
|
49
|
+
// `limit` traps Swift's Int conversion and kills the app. createMapsAPI checks
|
|
50
|
+
// every argument first (searchArgs / coordinateArg in ./normalize); a host
|
|
51
|
+
// calling these directly must pass what those checks would accept.
|
|
52
|
+
|
|
53
|
+
/** MKLocalSearch around `near` (when given). [] = looked, found nothing; throws =
|
|
54
|
+
* couldn't look, with a string code OFFLINE / THROTTLED / UNAVAILABLE. */
|
|
55
|
+
export async function search(
|
|
56
|
+
query: string,
|
|
57
|
+
near: MapsCoordinate | null,
|
|
58
|
+
radiusM: number,
|
|
59
|
+
limit: number,
|
|
60
|
+
): Promise<RawMapsPlace[]> {
|
|
61
|
+
if (!native) throw new Error('ada-maps native module not present in this build');
|
|
62
|
+
// Without `near`, hasNear is false and Swift never reads lat/lng — the zeros
|
|
63
|
+
// are placeholders for a primitive argument, not a place.
|
|
64
|
+
return near
|
|
65
|
+
? native.search(query, near.latitude, near.longitude, true, radiusM, limit)
|
|
66
|
+
: native.search(query, 0, 0, false, radiusM, limit);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** MKDirections, walking, between two caller-supplied points. null = no route. */
|
|
70
|
+
export async function route(from: MapsCoordinate, to: MapsCoordinate): Promise<RawMapsRoute | null> {
|
|
71
|
+
if (!native) throw new Error('ada-maps native module not present in this build');
|
|
72
|
+
return native.route(from.latitude, from.longitude, to.latitude, to.longitude);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The address at a coordinate, or null when the geocoder has nothing there.
|
|
76
|
+
* Throws when it could not look (network, rate limit, Android without location
|
|
77
|
+
* permission) — callers decide whether that is fatal. */
|
|
78
|
+
export async function reverseGeocode(at: MapsCoordinate): Promise<MapsAddress | null> {
|
|
79
|
+
const [first] = await ExpoLocation.reverseGeocodeAsync({ latitude: at.latitude, longitude: at.longitude });
|
|
80
|
+
return first ? toMapsAddress(first) : null;
|
|
81
|
+
}
|