@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
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
// ── spatial ──────────────────────────────────────────────────────────────
|
|
2
|
-
// position ✓ path ✓ (while a session is open)
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
// position ✓ path ✓ heading ✓ turns ✓ events ✓ (while a session is open) speed ~derived
|
|
3
|
+
import type {
|
|
4
|
+
DeviceState,
|
|
5
|
+
SpatialIntent,
|
|
6
|
+
SpatialSnapshot,
|
|
7
|
+
SpatialStateEvent,
|
|
8
|
+
SpatialPoint,
|
|
9
|
+
StateQuality,
|
|
10
|
+
Subscription,
|
|
11
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
12
|
+
import { startHeading, stopHeading, currentHeadingDetail, lastMeaningfulTurn, onTurn } from '../headingSource';
|
|
13
|
+
import type { DeviceStateCollectors, SpeedRef, WatchAccuracy } from '../collectors';
|
|
7
14
|
import { qualityFrom, qualityUnknown, unsupported } from '../quality';
|
|
15
|
+
import { distanceOf } from '../../geo';
|
|
16
|
+
import { listenerSet } from '../events';
|
|
17
|
+
import {
|
|
18
|
+
SpatialEventDetector,
|
|
19
|
+
fixConfidence,
|
|
20
|
+
movementStateOf,
|
|
21
|
+
spatialEventProfile,
|
|
22
|
+
} from '../spatialEvents';
|
|
8
23
|
|
|
9
24
|
/**
|
|
10
25
|
* Position, movement, facing and a short recent path.
|
|
@@ -31,7 +46,23 @@ import { qualityFrom, qualityUnknown, unsupported } from '../quality';
|
|
|
31
46
|
* (right) and negative for anticlockwise (left), so a guide can say which way
|
|
32
47
|
* the user just went without a second field to consult. The freeze names the
|
|
33
48
|
* field but states no convention; this is a decision, recorded here.
|
|
49
|
+
*
|
|
50
|
+
* EVENTS ARE THE SNAPSHOT, DIFFED. `subscribe` publishes what changed between
|
|
51
|
+
* successive warm snapshots — the same ones `getSnapshot` returns — via the
|
|
52
|
+
* pure detector in spatialEvents.ts. It is fed on every fix, the moment a turn
|
|
53
|
+
* completes, and once a second, so a transition that happens between fixes
|
|
54
|
+
* (fixes stopping is itself one: the state goes stale) is still noticed.
|
|
34
55
|
*/
|
|
56
|
+
/** The GPS tier each intent's stream runs at. Navigation is the one that needs
|
|
57
|
+
* "which side of the street"; everything else trades precision for battery. */
|
|
58
|
+
const WATCH_ACCURACY: Record<SpatialIntent, WatchAccuracy> = {
|
|
59
|
+
navigation: 'navigation',
|
|
60
|
+
preciseLocate: 'high',
|
|
61
|
+
fitness: 'high',
|
|
62
|
+
geofence: 'balanced',
|
|
63
|
+
lowPowerTracking: 'low',
|
|
64
|
+
};
|
|
65
|
+
|
|
35
66
|
export function createSpatialDomain(
|
|
36
67
|
collectors: DeviceStateCollectors,
|
|
37
68
|
speedRef: SpeedRef,
|
|
@@ -39,14 +70,6 @@ export function createSpatialDomain(
|
|
|
39
70
|
// The warm cache, when a session is open. Newest last, bounded — an unbounded
|
|
40
71
|
// path on a walking user grows without limit.
|
|
41
72
|
const RECENT_PATH_MAX = 60;
|
|
42
|
-
/**
|
|
43
|
-
* Below this the user is standing still.
|
|
44
|
-
*
|
|
45
|
-
* 0.4 m/s is well under a slow walk (~1.2 m/s) and above the jitter a
|
|
46
|
-
* stationary phone's GPS produces. The point of the band is that a guide
|
|
47
|
-
* must not say "keep going straight" to someone who has stopped.
|
|
48
|
-
*/
|
|
49
|
-
const STATIONARY_MPS = 0.4;
|
|
50
73
|
|
|
51
74
|
let recentPath: SpatialPoint[] = [];
|
|
52
75
|
let latest: SpatialPoint | null = null;
|
|
@@ -54,7 +77,13 @@ export function createSpatialDomain(
|
|
|
54
77
|
let latestSpeed: number | null = null;
|
|
55
78
|
let latestCourse: number | null = null;
|
|
56
79
|
let stopWatch: (() => void) | null = null;
|
|
57
|
-
|
|
80
|
+
/** The running session. A second `start()` joins it, and is told so. */
|
|
81
|
+
let session: { id: string; intent: SpatialIntent; startedAt: number } | null = null;
|
|
82
|
+
|
|
83
|
+
const detector = new SpatialEventDetector();
|
|
84
|
+
const listeners = listenerSet<SpatialStateEvent>();
|
|
85
|
+
let ticker: ReturnType<typeof setInterval> | null = null;
|
|
86
|
+
let turnSub: Subscription | null = null;
|
|
58
87
|
|
|
59
88
|
const nonNegative = (v: unknown): number | null =>
|
|
60
89
|
typeof v === 'number' && v >= 0 ? v : null;
|
|
@@ -87,95 +116,155 @@ export function createSpatialDomain(
|
|
|
87
116
|
return bearingBetween(a, b);
|
|
88
117
|
};
|
|
89
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The snapshot for a fix — the ONE builder, used by the read and by the
|
|
121
|
+
* event detector alike. Synchronous: everything it needs is already in hand.
|
|
122
|
+
*/
|
|
123
|
+
const snapshotFrom = (
|
|
124
|
+
point: SpatialPoint,
|
|
125
|
+
measuredSpeed: number | null,
|
|
126
|
+
measuredCourse: number | null,
|
|
127
|
+
): SpatialSnapshot => {
|
|
128
|
+
// Fall back to the path only where the OS said nothing.
|
|
129
|
+
let speedMps = measuredSpeed;
|
|
130
|
+
let courseDeg = measuredCourse;
|
|
131
|
+
let derived = false;
|
|
132
|
+
if (speedMps === null) { const d = derivedSpeed(); if (d !== null) { speedMps = d; derived = true; } }
|
|
133
|
+
if (courseDeg === null) { const d = derivedCourse(); if (d !== null) { courseDeg = d; derived = true; } }
|
|
134
|
+
|
|
135
|
+
const heading = currentHeadingDetail();
|
|
136
|
+
const turn = lastMeaningfulTurn();
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
position: point,
|
|
140
|
+
movement: {
|
|
141
|
+
speedMps,
|
|
142
|
+
bearingDeg: courseDeg,
|
|
143
|
+
// Needs two points from the SAME session; without a stream there is
|
|
144
|
+
// nothing to measure against, and a distance of 0 would read as
|
|
145
|
+
// "the user has not moved".
|
|
146
|
+
distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
|
|
147
|
+
state: movementStateOf(speedMps),
|
|
148
|
+
},
|
|
149
|
+
orientation: {
|
|
150
|
+
headingDeg: heading?.headingDeg ?? null,
|
|
151
|
+
// NOT the calibration rank. iOS reports 0–3 (none…high), which is not
|
|
152
|
+
// a number of degrees, and converting one to the other would be an
|
|
153
|
+
// invented error bar. The rank drives `confidence` below instead.
|
|
154
|
+
headingAccuracyDeg: null,
|
|
155
|
+
},
|
|
156
|
+
changes: {
|
|
157
|
+
lastMeaningfulTurnDeg: turn ? turn.deg : null,
|
|
158
|
+
timestamp: turn ? turn.timestamp : null,
|
|
159
|
+
},
|
|
160
|
+
recentPath: [...recentPath],
|
|
161
|
+
quality: qualityFrom(
|
|
162
|
+
point.timestamp,
|
|
163
|
+
spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived),
|
|
164
|
+
),
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** "No position" — honest nulls, never a (0,0). The compass may still know
|
|
169
|
+
* which way the phone points, so heading is kept when it has one. */
|
|
170
|
+
const noFix = (): SpatialSnapshot => ({
|
|
171
|
+
position: null,
|
|
172
|
+
movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: 'unknown' },
|
|
173
|
+
orientation: { headingDeg: currentHeadingDetail()?.headingDeg ?? null },
|
|
174
|
+
recentPath: [],
|
|
175
|
+
quality: qualityUnknown(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** The warm snapshot, or null before the session's first streamed fix. */
|
|
179
|
+
const warmSnapshot = (): SpatialSnapshot | null =>
|
|
180
|
+
latest ? snapshotFrom(latest, latestSpeed, latestCourse) : null;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Diff the snapshot a read would get RIGHT NOW against the last one, and
|
|
184
|
+
* publish what changed. Before the first fix there is nothing to diff; a
|
|
185
|
+
* turn completed meanwhile is not lost, it is reported with the first fix.
|
|
186
|
+
*/
|
|
187
|
+
const observe = () => {
|
|
188
|
+
const snapshot = warmSnapshot();
|
|
189
|
+
if (!snapshot) return;
|
|
190
|
+
for (const event of detector.push(snapshot, Date.now())) listeners.emit(event);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Re-diff once a second while a session is open. Fixes alone are not enough:
|
|
195
|
+
* the OS may pause them while the phone is still, and "the state has gone
|
|
196
|
+
* stale" or "you have been stationary for five seconds" are exactly the
|
|
197
|
+
* transitions that complete while nothing arrives. A tick re-reads the same
|
|
198
|
+
* fix, so it only completes a state the fixes already showed — it never
|
|
199
|
+
* turns one outlier fix into an event (spatialEvents.ts, MIN_FIXES).
|
|
200
|
+
*/
|
|
201
|
+
const startTicking = () => {
|
|
202
|
+
ticker = setInterval(observe, 1000);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const stopTicking = () => {
|
|
206
|
+
if (ticker) clearInterval(ticker);
|
|
207
|
+
ticker = null;
|
|
208
|
+
};
|
|
209
|
+
|
|
90
210
|
return {
|
|
91
211
|
async getSnapshot(): Promise<SpatialSnapshot> {
|
|
92
212
|
// Serve the warm fix when a session is streaming; otherwise take one.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
213
|
+
const warm = warmSnapshot();
|
|
214
|
+
if (warm) return warm;
|
|
215
|
+
// A session is open but its stream has not delivered yet: say so NOW.
|
|
216
|
+
// A one-shot fix here would make every read wait on a cold GPS (seconds),
|
|
217
|
+
// stalling whatever loop reads us — and the stream answers soon anyway.
|
|
218
|
+
if (session) return noFix();
|
|
97
219
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
orientation: { headingDeg: null },
|
|
117
|
-
recentPath: [],
|
|
118
|
-
quality: qualityUnknown(),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
220
|
+
let point: SpatialPoint;
|
|
221
|
+
let speedMps: number | null;
|
|
222
|
+
let courseDeg: number | null;
|
|
223
|
+
try {
|
|
224
|
+
if (!collectors.location) throw unsupported('location collector');
|
|
225
|
+
const fix: any = await collectors.location.getPosition({ accuracy: 'low' });
|
|
226
|
+
point = {
|
|
227
|
+
latitude: fix.latitude,
|
|
228
|
+
longitude: fix.longitude,
|
|
229
|
+
altitude: fix.altitude ?? null,
|
|
230
|
+
accuracyM: nonNegative(fix.accuracy),
|
|
231
|
+
timestamp: fix.timestamp ?? Date.now(),
|
|
232
|
+
};
|
|
233
|
+
speedMps = nonNegative(fix.speed);
|
|
234
|
+
courseDeg = nonNegative(fix.course);
|
|
235
|
+
} catch {
|
|
236
|
+
// Permission refused, or no fix. Not a position at (0,0).
|
|
237
|
+
return noFix();
|
|
121
238
|
}
|
|
122
|
-
|
|
123
|
-
// Fall back to the path only where the OS said nothing.
|
|
124
|
-
if (speedMps === null) { const d = derivedSpeed(); if (d !== null) { speedMps = d; derived = true; } }
|
|
125
|
-
if (courseDeg === null) { const d = derivedCourse(); if (d !== null) { courseDeg = d; derived = true; } }
|
|
126
|
-
|
|
127
|
-
const heading = currentHeadingDetail();
|
|
128
|
-
const turn = lastMeaningfulTurn();
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
position: point,
|
|
132
|
-
movement: {
|
|
133
|
-
speedMps,
|
|
134
|
-
bearingDeg: courseDeg,
|
|
135
|
-
// Needs two points from the SAME session; without a stream there is
|
|
136
|
-
// nothing to measure against, and a distance of 0 would read as
|
|
137
|
-
// "the user has not moved".
|
|
138
|
-
distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
|
|
139
|
-
// Only claim stationary/moving when a speed was actually obtained.
|
|
140
|
-
// With no speed the truthful answer is `unknown`, not `stationary` —
|
|
141
|
-
// "you have stopped" is a false statement, not a neutral default.
|
|
142
|
-
state: speedMps === null ? 'unknown' : speedMps < STATIONARY_MPS ? 'stationary' : 'moving',
|
|
143
|
-
},
|
|
144
|
-
orientation: {
|
|
145
|
-
headingDeg: heading?.headingDeg ?? null,
|
|
146
|
-
// NOT the calibration rank. iOS reports 0–3 (none…high), which is not
|
|
147
|
-
// a number of degrees, and converting one to the other would be an
|
|
148
|
-
// invented error bar. The rank drives `confidence` below instead.
|
|
149
|
-
headingAccuracyDeg: null,
|
|
150
|
-
},
|
|
151
|
-
changes: {
|
|
152
|
-
lastMeaningfulTurnDeg: turn ? turn.deg : null,
|
|
153
|
-
timestamp: turn ? turn.timestamp : null,
|
|
154
|
-
},
|
|
155
|
-
recentPath: [...recentPath],
|
|
156
|
-
quality: qualityFrom(
|
|
157
|
-
point.timestamp,
|
|
158
|
-
spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived),
|
|
159
|
-
),
|
|
160
|
-
};
|
|
239
|
+
return snapshotFrom(point, speedMps, courseDeg);
|
|
161
240
|
},
|
|
162
241
|
|
|
163
242
|
/**
|
|
164
|
-
* Open the warm window: position stream + compass.
|
|
243
|
+
* Open the warm window: position stream + compass + event feed.
|
|
165
244
|
*
|
|
166
|
-
* The intent
|
|
167
|
-
*
|
|
168
|
-
*
|
|
245
|
+
* The intent selects how eager the EVENTS are (spatialEvents.ts profiles)
|
|
246
|
+
* and the GPS TIER the stream runs at (WATCH_ACCURACY): navigation pays the
|
|
247
|
+
* battery for BestForNavigation; low-power tracking does not.
|
|
248
|
+
*
|
|
249
|
+
* A second `start()` while one is open joins the running session and is
|
|
250
|
+
* told ITS intent, not handed back the one it asked for: answering with the
|
|
251
|
+
* caller's intent would claim a profile that is not in force.
|
|
169
252
|
*/
|
|
170
253
|
async start(options) {
|
|
171
|
-
if (
|
|
172
|
-
sessionId = `spatial_${Date.now()}`;
|
|
254
|
+
if (session) return { ...session };
|
|
173
255
|
if (!collectors.location) throw unsupported('location collector');
|
|
256
|
+
const startedAt = Date.now();
|
|
257
|
+
session = { id: `spatial_${startedAt}`, intent: options.intent, startedAt };
|
|
258
|
+
// A turn the compass completed before this session (a visual session
|
|
259
|
+
// shares it) is already on record, not news.
|
|
260
|
+
detector.reset(spatialEventProfile(options.intent), lastMeaningfulTurn()?.timestamp ?? null);
|
|
174
261
|
// The compass is what makes heading and turn detection possible, and it
|
|
175
262
|
// has to run for the session's whole life: a turn happens between reads,
|
|
176
263
|
// so a compass started on demand would miss exactly the event a guide
|
|
177
|
-
// most needs.
|
|
178
|
-
void startHeading();
|
|
264
|
+
// most needs. Shared with a visual session if one is open.
|
|
265
|
+
void startHeading('spatial');
|
|
266
|
+
// A turn is published the moment it completes, not on the next tick.
|
|
267
|
+
turnSub = onTurn(observe);
|
|
179
268
|
stopWatch = collectors.location.watchPosition((pos: any) => {
|
|
180
269
|
const p: SpatialPoint = {
|
|
181
270
|
latitude: pos.latitude,
|
|
@@ -190,14 +279,22 @@ export function createSpatialDomain(
|
|
|
190
279
|
speedRef.mps = latestSpeed;
|
|
191
280
|
speedRef.at = p.timestamp;
|
|
192
281
|
recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
|
|
193
|
-
|
|
194
|
-
|
|
282
|
+
observe();
|
|
283
|
+
}, { accuracy: WATCH_ACCURACY[options.intent] ?? 'balanced' });
|
|
284
|
+
startTicking();
|
|
285
|
+
return { ...session };
|
|
195
286
|
},
|
|
196
287
|
|
|
197
288
|
async stop() {
|
|
198
289
|
stopWatch?.();
|
|
199
290
|
stopWatch = null;
|
|
200
|
-
|
|
291
|
+
session = null;
|
|
292
|
+
stopTicking();
|
|
293
|
+
turnSub?.unsubscribe();
|
|
294
|
+
turnSub = null;
|
|
295
|
+
// Listeners are KEPT: a subscription outlives a session, and hears the
|
|
296
|
+
// next one. Only what this session observed is forgotten.
|
|
297
|
+
detector.reset();
|
|
201
298
|
// The path belongs to the session that recorded it. Keeping it would let
|
|
202
299
|
// a later snapshot report movement from a walk that ended hours ago.
|
|
203
300
|
recentPath = [];
|
|
@@ -206,14 +303,18 @@ export function createSpatialDomain(
|
|
|
206
303
|
latestCourse = null;
|
|
207
304
|
speedRef.mps = null;
|
|
208
305
|
speedRef.at = 0;
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
if (!visualEngine.getSession()) stopHeading();
|
|
306
|
+
// Releases only spatial's hold — a visual session keeps the compass.
|
|
307
|
+
stopHeading('spatial');
|
|
212
308
|
},
|
|
213
309
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
310
|
+
/**
|
|
311
|
+
* The six spatial events (see `SpatialEvent`). Subscribing opens no sensor:
|
|
312
|
+
* events flow only while a session is open, and a subscription made before
|
|
313
|
+
* `start()` hears that session from its first fix.
|
|
314
|
+
*/
|
|
315
|
+
subscribe(listener) {
|
|
316
|
+
return listeners.add(listener);
|
|
317
|
+
},
|
|
217
318
|
};
|
|
218
319
|
}
|
|
219
320
|
|
|
@@ -238,10 +339,8 @@ function spatialConfidence(
|
|
|
238
339
|
const rank = (v: 'high' | 'medium' | 'low' | 'unknown') =>
|
|
239
340
|
v === 'high' ? 3 : v === 'medium' ? 2 : v === 'low' ? 1 : 0;
|
|
240
341
|
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
const fromFix: 'high' | 'medium' | 'low' | 'unknown' =
|
|
244
|
-
accuracyM === null ? 'unknown' : accuracyM <= 10 ? 'high' : accuracyM <= 50 ? 'medium' : 'low';
|
|
342
|
+
// The fix band is shared with positionConfidenceChanged — one set of numbers.
|
|
343
|
+
const fromFix = fixConfidence(accuracyM);
|
|
245
344
|
|
|
246
345
|
// iOS calibration rank: 3 high, 2 medium, 1 low, 0 none.
|
|
247
346
|
const fromCompass: 'high' | 'medium' | 'low' | 'unknown' =
|
|
@@ -266,18 +365,3 @@ function bearingBetween(a: SpatialPoint, b: SpatialPoint): number {
|
|
|
266
365
|
const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
|
|
267
366
|
return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
|
|
268
367
|
}
|
|
269
|
-
|
|
270
|
-
/** Metres along a path, great-circle per leg. */
|
|
271
|
-
function distanceOf(path: SpatialPoint[]): number {
|
|
272
|
-
const R = 6_371_000;
|
|
273
|
-
let total = 0;
|
|
274
|
-
for (let i = 1; i < path.length; i++) {
|
|
275
|
-
const a = path[i - 1], b = path[i];
|
|
276
|
-
const dLat = ((b.latitude - a.latitude) * Math.PI) / 180;
|
|
277
|
-
const dLon = ((b.longitude - a.longitude) * Math.PI) / 180;
|
|
278
|
-
const la1 = (a.latitude * Math.PI) / 180, la2 = (b.latitude * Math.PI) / 180;
|
|
279
|
-
const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
|
|
280
|
-
total += 2 * R * Math.asin(Math.sqrt(h));
|
|
281
|
-
}
|
|
282
|
-
return Math.round(total);
|
|
283
|
-
}
|
|
@@ -8,6 +8,24 @@ import type { DeviceState, VisualSnapshot } from '@ateam-ai-mobile/plugin-runtim
|
|
|
8
8
|
import { visualEngine, frameQuality } from '../visualEngine';
|
|
9
9
|
import { currentHeading, startHeading, stopHeading } from '../headingSource';
|
|
10
10
|
import { unsupported } from '../quality';
|
|
11
|
+
import { stateEvent } from '../events';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The camera block of a visual snapshot — one builder, so the read and the
|
|
15
|
+
* change event cannot describe the camera two ways.
|
|
16
|
+
*/
|
|
17
|
+
function cameraState(): VisualSnapshot['camera'] {
|
|
18
|
+
const session = visualEngine.getSession();
|
|
19
|
+
return {
|
|
20
|
+
// `available` = the app could use a camera at all. `active` = one is
|
|
21
|
+
// running RIGHT NOW. They differ exactly when it matters: a pocketed
|
|
22
|
+
// phone is available but not active, and answering `true` to both
|
|
23
|
+
// would let a guide speak about a scene nothing is watching.
|
|
24
|
+
available: !!session,
|
|
25
|
+
active: visualEngine.isCameraReady(),
|
|
26
|
+
facing: session ? 'back' : 'unknown',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
11
29
|
|
|
12
30
|
/**
|
|
13
31
|
* Reads the rolling timeline the engine maintains. See visualEngine.ts for why
|
|
@@ -20,20 +38,11 @@ import { unsupported } from '../quality';
|
|
|
20
38
|
export function createVisualDomain(): DeviceState['visual'] {
|
|
21
39
|
return {
|
|
22
40
|
async getSnapshot(): Promise<VisualSnapshot> {
|
|
23
|
-
const session = visualEngine.getSession();
|
|
24
41
|
const latest = visualEngine.latest();
|
|
25
42
|
const timeline = visualEngine.timeline();
|
|
26
43
|
|
|
27
44
|
return {
|
|
28
|
-
camera:
|
|
29
|
-
// `available` = the app could use a camera at all. `active` = one is
|
|
30
|
-
// running RIGHT NOW. They differ exactly when it matters: a pocketed
|
|
31
|
-
// phone is available but not active, and answering `true` to both
|
|
32
|
-
// would let a guide speak about a scene nothing is watching.
|
|
33
|
-
available: !!session,
|
|
34
|
-
active: visualEngine.isCameraReady(),
|
|
35
|
-
facing: session ? 'back' : 'unknown',
|
|
36
|
-
},
|
|
45
|
+
camera: cameraState(),
|
|
37
46
|
// null, not a stale frame dressed as current. A caller with no usable
|
|
38
47
|
// frame must see nothing rather than something old.
|
|
39
48
|
latest: latest as any,
|
|
@@ -48,14 +57,15 @@ export function createVisualDomain(): DeviceState['visual'] {
|
|
|
48
57
|
// launch: a magnetometer subscription running all day for a feature
|
|
49
58
|
// nobody opened is exactly the kind of cost this layer is meant to avoid.
|
|
50
59
|
visualEngine.setContextProvider({ heading: currentHeading });
|
|
51
|
-
void startHeading();
|
|
60
|
+
void startHeading('visual');
|
|
52
61
|
return { id: s.id, intent: s.intent, startedAt: s.startedAt } as any;
|
|
53
62
|
},
|
|
54
63
|
|
|
55
64
|
async stop() {
|
|
56
65
|
visualEngine.stopSession();
|
|
57
66
|
visualEngine.setContextProvider(null);
|
|
58
|
-
|
|
67
|
+
// Releases only visual's hold — a spatial session keeps the compass.
|
|
68
|
+
stopHeading('visual');
|
|
59
69
|
},
|
|
60
70
|
|
|
61
71
|
/**
|
|
@@ -90,9 +100,20 @@ export function createVisualDomain(): DeviceState['visual'] {
|
|
|
90
100
|
throw err;
|
|
91
101
|
},
|
|
92
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Fires when the CAMERA block changes — a session opening or closing, the
|
|
105
|
+
* camera coming up or going down. The engine notifies on every frame too,
|
|
106
|
+
* and forwarding that made this a raw frame stream, which the freeze's core
|
|
107
|
+
* rule 4 forbids. A frame arriving is what `getSnapshot()` is for.
|
|
108
|
+
*/
|
|
93
109
|
subscribe(listener) {
|
|
110
|
+
let last = JSON.stringify(cameraState());
|
|
94
111
|
const off = visualEngine.subscribe(() => {
|
|
95
|
-
|
|
112
|
+
const camera = cameraState();
|
|
113
|
+
const key = JSON.stringify(camera);
|
|
114
|
+
if (key === last) return;
|
|
115
|
+
last = key;
|
|
116
|
+
listener(stateEvent('visual', 'visualStateChanged', { camera }));
|
|
96
117
|
});
|
|
97
118
|
return { unsubscribe: off };
|
|
98
119
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE event envelope every domain emits, and the one way listeners are held.
|
|
3
|
+
*
|
|
4
|
+
* The frozen shape is `StateEvent { domain, name, timestamp, payload }`. Three
|
|
5
|
+
* domains were born emitting `{ type: '…' }` instead, so a plugin written
|
|
6
|
+
* against the contract — `e.name === 'appStateChanged'` — never matched, and
|
|
7
|
+
* `e.name.endsWith(…)` threw on `undefined`. Building every event here is what
|
|
8
|
+
* keeps a second shape from growing back.
|
|
9
|
+
*
|
|
10
|
+
* Type-only imports, so the pure modules that use this (the spatial event
|
|
11
|
+
* detector) stay testable under plain `node --test`.
|
|
12
|
+
*/
|
|
13
|
+
import type { DeviceBridgeDomain, StateEvent, Subscription } from '@ateam-ai-mobile/plugin-runtime';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build an event. `payload` is omitted, not `undefined`, when there is none —
|
|
17
|
+
* an event that is only a nudge ("re-read the snapshot") says so by shape.
|
|
18
|
+
* `timestamp` is when the change was recognised; it defaults to now, and replay
|
|
19
|
+
* passes its virtual clock.
|
|
20
|
+
*/
|
|
21
|
+
export function stateEvent<TDomain extends DeviceBridgeDomain, TName extends string, TPayload = never>(
|
|
22
|
+
domain: TDomain,
|
|
23
|
+
name: TName,
|
|
24
|
+
payload?: TPayload,
|
|
25
|
+
timestamp: number = Date.now(),
|
|
26
|
+
): StateEvent<TName, TPayload> & { domain: TDomain } {
|
|
27
|
+
return payload === undefined
|
|
28
|
+
? { domain, name, timestamp }
|
|
29
|
+
: { domain, name, timestamp, payload };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Listeners, each isolated from the others. A listener that throws must not
|
|
34
|
+
* stop the next one hearing the event, nor the engine that emitted it.
|
|
35
|
+
*/
|
|
36
|
+
export function listenerSet<E>() {
|
|
37
|
+
const listeners = new Set<(event: E) => void>();
|
|
38
|
+
return {
|
|
39
|
+
add(fn: (event: E) => void): Subscription {
|
|
40
|
+
listeners.add(fn);
|
|
41
|
+
return { unsubscribe: () => { listeners.delete(fn); } };
|
|
42
|
+
},
|
|
43
|
+
emit(event: E): void {
|
|
44
|
+
for (const fn of listeners) {
|
|
45
|
+
try { fn(event); } catch { /* a bad listener is not the engine's problem */ }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -13,15 +13,25 @@
|
|
|
13
13
|
* event, not a state — so a detector driven by sampled snapshots would miss
|
|
14
14
|
* exactly the moment a guide needs. It runs on the compass callback instead.
|
|
15
15
|
* The logic itself lives in `turnDetector.ts`, free of any Expo import, so it
|
|
16
|
-
* can be tested against a scripted walk.
|
|
16
|
+
* can be tested against a scripted walk. A completed turn is also PUSHED to
|
|
17
|
+
* `onTurn` listeners — spatial publishes it as an event the moment it lands.
|
|
18
|
+
*
|
|
19
|
+
* SHARED, SO COUNTED. Spatial and visual both keep the compass warm, and each
|
|
20
|
+
* used to stop it on its own `stop()`: visual's stop killed the compass — and
|
|
21
|
+
* wiped the turn history — under a live spatial session. Each caller now names
|
|
22
|
+
* itself, and the watch stops only when the last one leaves.
|
|
17
23
|
*/
|
|
18
24
|
import * as ExpoLocation from 'expo-location';
|
|
19
25
|
|
|
20
26
|
import { TurnDetector, type TurnEvent } from './turnDetector';
|
|
27
|
+
import { listenerSet } from './events';
|
|
21
28
|
|
|
22
29
|
export type { TurnEvent };
|
|
23
30
|
export { signedDelta } from './turnDetector';
|
|
24
31
|
|
|
32
|
+
/** Who is holding the compass open. */
|
|
33
|
+
export type HeadingOwner = 'spatial' | 'visual';
|
|
34
|
+
|
|
25
35
|
interface HeadingReading {
|
|
26
36
|
headingDeg: number | null;
|
|
27
37
|
/**
|
|
@@ -36,21 +46,38 @@ interface HeadingReading {
|
|
|
36
46
|
|
|
37
47
|
let latest: HeadingReading | null = null;
|
|
38
48
|
let sub: { remove(): void } | null = null;
|
|
49
|
+
/** The watch being opened, so two owners starting at once open ONE. */
|
|
50
|
+
let opening: Promise<void> | null = null;
|
|
51
|
+
const owners = new Set<HeadingOwner>();
|
|
39
52
|
const turns = new TurnDetector();
|
|
53
|
+
const turnListeners = listenerSet<TurnEvent>();
|
|
54
|
+
|
|
55
|
+
/** Start watching on behalf of `owner`. Idempotent per owner. */
|
|
56
|
+
export function startHeading(owner: HeadingOwner): Promise<void> {
|
|
57
|
+
owners.add(owner);
|
|
58
|
+
if (sub) return Promise.resolve();
|
|
59
|
+
opening ??= open().finally(() => { opening = null; });
|
|
60
|
+
return opening;
|
|
61
|
+
}
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
export async function startHeading(): Promise<void> {
|
|
43
|
-
if (sub) return;
|
|
63
|
+
async function open(): Promise<void> {
|
|
44
64
|
try {
|
|
45
|
-
|
|
65
|
+
const s = await ExpoLocation.watchHeadingAsync(h => {
|
|
66
|
+
// A reading landing after the last owner left is not kept.
|
|
67
|
+
if (!owners.size) return;
|
|
46
68
|
// trueHeading is -1 until the compass calibrates; magHeading is the
|
|
47
69
|
// fallback the OS itself recommends.
|
|
48
70
|
const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
|
|
49
71
|
const deg = typeof raw === 'number' && raw >= 0 ? raw : null;
|
|
50
72
|
const cal = typeof h.accuracy === 'number' && h.accuracy >= 0 ? h.accuracy : null;
|
|
51
|
-
|
|
52
|
-
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
latest = { headingDeg: deg, calibration: cal, timestamp: now };
|
|
75
|
+
const turn = turns.push(deg, now);
|
|
76
|
+
if (turn) turnListeners.emit(turn);
|
|
53
77
|
});
|
|
78
|
+
// Every owner may have left while the OS was starting the watch.
|
|
79
|
+
if (!owners.size) { s.remove(); return; }
|
|
80
|
+
sub = s;
|
|
54
81
|
} catch {
|
|
55
82
|
// No compass, or permission refused. Callers get headingDeg: null, which
|
|
56
83
|
// is the honest answer and does not stop anything else working.
|
|
@@ -58,7 +85,10 @@ export async function startHeading(): Promise<void> {
|
|
|
58
85
|
}
|
|
59
86
|
}
|
|
60
87
|
|
|
61
|
-
|
|
88
|
+
/** Release `owner`'s hold. The watch stops only when no owner is left. */
|
|
89
|
+
export function stopHeading(owner: HeadingOwner): void {
|
|
90
|
+
owners.delete(owner);
|
|
91
|
+
if (owners.size) return;
|
|
62
92
|
sub?.remove();
|
|
63
93
|
sub = null;
|
|
64
94
|
// Clear everything: a heading from a finished session describes a direction
|
|
@@ -68,6 +98,11 @@ export function stopHeading(): void {
|
|
|
68
98
|
turns.reset();
|
|
69
99
|
}
|
|
70
100
|
|
|
101
|
+
/** Hear each completed turn as it completes. Signed: positive = right. */
|
|
102
|
+
export function onTurn(fn: (turn: TurnEvent) => void) {
|
|
103
|
+
return turnListeners.add(fn);
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
/** The newest reading, or null if the compass has said nothing yet. */
|
|
72
107
|
export function currentHeading(): { headingDeg: number | null } | null {
|
|
73
108
|
return latest ? { headingDeg: latest.headingDeg } : null;
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* connectivity reachability ✓ transport ✓ quality/stability/metered ✗
|
|
15
15
|
* capability permission ✓ availability ✗ restriction ✗
|
|
16
16
|
* interaction app lifecycle ✓ screenOn ✗ audio ✗
|
|
17
|
-
* spatial position ✓ path ✓ (while a session is open)
|
|
17
|
+
* spatial position ✓ heading ✓ turns ✓ path/events ✓ (while a session is open) speed ~derived
|
|
18
18
|
* visual frames ✓ heading ✓ freshness ✓ blur/exposure ✗
|
|
19
|
-
* activity
|
|
19
|
+
* activity walking/running/stationary ✓ pose ✓ cycling/driving ✗ events ✗
|
|
20
|
+
* environment NOT IMPLEMENTED AT ALL
|
|
20
21
|
*
|
|
21
22
|
* Everything marked ✗ reports `unknown`, which is a real answer in this design
|
|
22
23
|
* and the only honest one. It never reports a plausible default: an invented
|