@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.
Files changed (71) hide show
  1. package/dist/bootstrap.js +841 -323
  2. package/dist/bootstrap.js.map +1 -1
  3. package/dist/bootstrap.mjs +9 -7
  4. package/dist/bootstrap.mjs.map +1 -1
  5. package/dist/chunk-7FWYEHN5.mjs +203 -0
  6. package/dist/chunk-7FWYEHN5.mjs.map +1 -0
  7. package/dist/{chunk-4XYXJYN5.mjs → chunk-C7M5WAHA.mjs} +583 -298
  8. package/dist/chunk-C7M5WAHA.mjs.map +1 -0
  9. package/dist/{chunk-5HM6UA2K.mjs → chunk-GF2MPTAB.mjs} +83 -7
  10. package/dist/chunk-GF2MPTAB.mjs.map +1 -0
  11. package/dist/chunk-J6XRNX7P.mjs +19 -0
  12. package/dist/chunk-J6XRNX7P.mjs.map +1 -0
  13. package/dist/{chunk-TZXWVYYR.mjs → chunk-PCAEUX2N.mjs} +1 -1
  14. package/dist/chunk-PCAEUX2N.mjs.map +1 -0
  15. package/dist/{chunk-VQJPSLHP.mjs → chunk-Z5AI2SXX.mjs} +13 -4
  16. package/dist/chunk-Z5AI2SXX.mjs.map +1 -0
  17. package/dist/createBridge.js +41 -10
  18. package/dist/createBridge.js.map +1 -1
  19. package/dist/createBridge.mjs +5 -1
  20. package/dist/createBridge.mjs.map +1 -1
  21. package/dist/device/createDeviceProvider.js +278 -17
  22. package/dist/device/createDeviceProvider.js.map +1 -1
  23. package/dist/device/createDeviceProvider.mjs +5 -3
  24. package/dist/device-bridge/index.d.mts +13 -1
  25. package/dist/device-bridge/index.d.ts +13 -1
  26. package/dist/device-bridge/index.js +49 -12
  27. package/dist/device-bridge/index.js.map +1 -1
  28. package/dist/device-bridge/index.mjs +3 -1
  29. package/dist/index.d.mts +19 -3
  30. package/dist/index.d.ts +19 -3
  31. package/dist/index.js +594 -297
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +2 -1
  34. package/dist/maps/index.d.mts +56 -0
  35. package/dist/maps/index.d.ts +56 -0
  36. package/dist/maps/index.js +95 -0
  37. package/dist/maps/index.js.map +1 -0
  38. package/dist/maps/index.mjs +17 -0
  39. package/dist/maps/index.mjs.map +1 -0
  40. package/dist/vision/index.d.mts +4 -2
  41. package/dist/vision/index.d.ts +4 -2
  42. package/dist/vision/index.js.map +1 -1
  43. package/dist/vision/index.mjs +1 -1
  44. package/expo-module.config.json +1 -1
  45. package/ios/AdaMaps/AdaMapsModule.swift +180 -0
  46. package/ios/AdaMaps.podspec +25 -0
  47. package/ios/AdaVision.podspec +4 -0
  48. package/ios/AdaVisionModule.swift +63 -9
  49. package/package.json +10 -4
  50. package/src/args.ts +56 -0
  51. package/src/createBridge.ts +3 -1
  52. package/src/device/createDeviceProvider.ts +88 -3
  53. package/src/device-bridge/collectors/location.ts +21 -9
  54. package/src/device-state/collectors.ts +5 -1
  55. package/src/device-state/domains/deviceContext.ts +5 -3
  56. package/src/device-state/domains/interaction.ts +2 -1
  57. package/src/device-state/domains/spatial.ts +200 -116
  58. package/src/device-state/domains/visual.ts +34 -13
  59. package/src/device-state/events.ts +49 -0
  60. package/src/device-state/headingSource.ts +43 -8
  61. package/src/device-state/index.ts +3 -2
  62. package/src/device-state/replay.ts +76 -11
  63. package/src/device-state/spatialEvents.ts +300 -0
  64. package/src/geo.ts +23 -0
  65. package/src/maps/index.ts +81 -0
  66. package/src/maps/normalize.ts +225 -0
  67. package/src/vision/index.ts +4 -2
  68. package/dist/chunk-4XYXJYN5.mjs.map +0 -1
  69. package/dist/chunk-5HM6UA2K.mjs.map +0 -1
  70. package/dist/chunk-TZXWVYYR.mjs.map +0 -1
  71. 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 = Math.min(duration, Math.max(0, cursor + ms))),
144
- seek: ms => (cursor = Math.min(duration, Math.max(0, ms))),
145
- end: () => (cursor = duration),
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() { openSessions.delete(name); },
184
- subscribe() {
185
- // Deliberately unsupported: a fixture is a timeline a test steps through,
186
- // so events would have to fire on `advance()`, and a subscription that
187
- // silently never fires reads as "nothing has changed" — the failure this
188
- // whole layer exists to prevent. Step the clock and read instead.
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
+ }