@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
@@ -2,6 +2,9 @@ import {
2
2
  cameraError,
3
3
  cameraTasks
4
4
  } from "./chunk-6LXF3JVX.mjs";
5
+ import {
6
+ distanceOf
7
+ } from "./chunk-J6XRNX7P.mjs";
5
8
  import {
6
9
  __require
7
10
  } from "./chunk-WMJKH4XE.mjs";
@@ -216,6 +219,30 @@ function unsupported(what) {
216
219
  return err;
217
220
  }
218
221
 
222
+ // src/device-state/events.ts
223
+ function stateEvent(domain, name, payload, timestamp = Date.now()) {
224
+ return payload === void 0 ? { domain, name, timestamp } : { domain, name, timestamp, payload };
225
+ }
226
+ function listenerSet() {
227
+ const listeners = /* @__PURE__ */ new Set();
228
+ return {
229
+ add(fn) {
230
+ listeners.add(fn);
231
+ return { unsubscribe: () => {
232
+ listeners.delete(fn);
233
+ } };
234
+ },
235
+ emit(event) {
236
+ for (const fn of listeners) {
237
+ try {
238
+ fn(event);
239
+ } catch {
240
+ }
241
+ }
242
+ }
243
+ };
244
+ }
245
+
219
246
  // src/device-state/domains/deviceContext.ts
220
247
  function createDeviceContextDomain() {
221
248
  return {
@@ -251,11 +278,12 @@ function createDeviceContextDomain() {
251
278
  },
252
279
  // Real events, not a placeholder: expo-battery exposes native listeners,
253
280
  // so a plugin that subscribes genuinely hears about a change rather than
254
- // sitting on a subscription that can never fire.
281
+ // sitting on a subscription that can never fire. Nudges without a payload:
282
+ // no Device Context payload schema is decided, so the subscriber re-reads.
255
283
  subscribe(listener) {
256
284
  const subs = [
257
- ExpoBattery.addBatteryLevelListener(() => listener({ type: "batteryLevelChanged" })),
258
- ExpoBattery.addBatteryStateListener(() => listener({ type: "chargingChanged" }))
285
+ ExpoBattery.addBatteryLevelListener(() => listener(stateEvent("deviceContext", "batteryLevelChanged"))),
286
+ ExpoBattery.addBatteryStateListener(() => listener(stateEvent("deviceContext", "chargingChanged")))
259
287
  ];
260
288
  return { unsubscribe: () => subs.forEach((x) => x?.remove?.()) };
261
289
  }
@@ -391,13 +419,508 @@ function createInteractionDomain() {
391
419
  // which is the one part of this domain that works.
392
420
  subscribe(listener) {
393
421
  const sub2 = AppState.addEventListener("change", (next) => {
394
- listener({ type: "appStateChanged", appState: mapState(next) });
422
+ listener(stateEvent("interaction", "appStateChanged", { appState: mapState(next) }));
395
423
  });
396
424
  return { unsubscribe: () => sub2.remove() };
397
425
  }
398
426
  };
399
427
  }
400
428
 
429
+ // src/device-state/headingSource.ts
430
+ import * as ExpoLocation from "expo-location";
431
+
432
+ // src/device-state/turnDetector.ts
433
+ function signedDelta(a, b) {
434
+ return (b - a + 540) % 360 - 180;
435
+ }
436
+ var TurnDetector = class {
437
+ constructor(opts = {}) {
438
+ /** The heading currently treated as "straight ahead". */
439
+ this.refDeg = null;
440
+ this.stableSince = 0;
441
+ this.lastTurn = null;
442
+ this.minTurnDeg = opts.minTurnDeg ?? 35;
443
+ this.stableDeg = opts.stableDeg ?? 12;
444
+ this.stableMs = opts.stableMs ?? 2500;
445
+ }
446
+ /** Feed one compass reading. `null` degrees means the compass said nothing. */
447
+ push(deg, now) {
448
+ if (deg === null) return null;
449
+ if (this.refDeg === null) {
450
+ this.refDeg = deg;
451
+ this.stableSince = now;
452
+ return null;
453
+ }
454
+ const delta = signedDelta(this.refDeg, deg);
455
+ if (Math.abs(delta) >= this.minTurnDeg) {
456
+ this.lastTurn = { deg: Math.round(delta), timestamp: now };
457
+ this.refDeg = deg;
458
+ this.stableSince = now;
459
+ return this.lastTurn;
460
+ }
461
+ if (Math.abs(delta) > this.stableDeg) {
462
+ this.stableSince = now;
463
+ return null;
464
+ }
465
+ if (now - this.stableSince >= this.stableMs) {
466
+ this.refDeg = deg;
467
+ this.stableSince = now;
468
+ }
469
+ return null;
470
+ }
471
+ /**
472
+ * The most recent completed turn, or null if none this session.
473
+ *
474
+ * It does NOT expire on its own: the age is derivable from `timestamp`, and
475
+ * "recent enough to mention" is a product judgement this layer must not make
476
+ * on the caller's behalf.
477
+ */
478
+ last() {
479
+ return this.lastTurn;
480
+ }
481
+ reset() {
482
+ this.refDeg = null;
483
+ this.stableSince = 0;
484
+ this.lastTurn = null;
485
+ }
486
+ };
487
+
488
+ // src/device-state/headingSource.ts
489
+ var latest = null;
490
+ var sub = null;
491
+ var opening = null;
492
+ var owners = /* @__PURE__ */ new Set();
493
+ var turns = new TurnDetector();
494
+ var turnListeners = listenerSet();
495
+ function startHeading(owner) {
496
+ owners.add(owner);
497
+ if (sub) return Promise.resolve();
498
+ opening ?? (opening = open().finally(() => {
499
+ opening = null;
500
+ }));
501
+ return opening;
502
+ }
503
+ async function open() {
504
+ try {
505
+ const s = await ExpoLocation.watchHeadingAsync((h) => {
506
+ if (!owners.size) return;
507
+ const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
508
+ const deg = typeof raw === "number" && raw >= 0 ? raw : null;
509
+ const cal = typeof h.accuracy === "number" && h.accuracy >= 0 ? h.accuracy : null;
510
+ const now = Date.now();
511
+ latest = { headingDeg: deg, calibration: cal, timestamp: now };
512
+ const turn = turns.push(deg, now);
513
+ if (turn) turnListeners.emit(turn);
514
+ });
515
+ if (!owners.size) {
516
+ s.remove();
517
+ return;
518
+ }
519
+ sub = s;
520
+ } catch {
521
+ sub = null;
522
+ }
523
+ }
524
+ function stopHeading(owner) {
525
+ owners.delete(owner);
526
+ if (owners.size) return;
527
+ sub?.remove();
528
+ sub = null;
529
+ latest = null;
530
+ turns.reset();
531
+ }
532
+ function onTurn(fn) {
533
+ return turnListeners.add(fn);
534
+ }
535
+ function currentHeading() {
536
+ return latest ? { headingDeg: latest.headingDeg } : null;
537
+ }
538
+ function currentHeadingDetail() {
539
+ return latest;
540
+ }
541
+ function lastMeaningfulTurn() {
542
+ return turns.last();
543
+ }
544
+
545
+ // src/device-state/spatialEvents.ts
546
+ var STATIONARY_MPS = 0.4;
547
+ function movementStateOf(speedMps) {
548
+ return speedMps === null ? "unknown" : speedMps < STATIONARY_MPS ? "stationary" : "moving";
549
+ }
550
+ function fixConfidence(accuracyM) {
551
+ return accuracyM === null ? "unknown" : accuracyM <= 10 ? "high" : accuracyM <= 50 ? "medium" : "low";
552
+ }
553
+ var SPATIAL_EVENT_PROFILES = {
554
+ navigation: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 3e3, minMoveM: 10 },
555
+ fitness: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 5e3, minMoveM: 10 },
556
+ preciseLocate: { movingHoldMs: 2e3, stoppedHoldMs: 3e3, confidenceHoldMs: 2e3, minMoveM: 3 },
557
+ geofence: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 25 },
558
+ lowPowerTracking: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 50 }
559
+ };
560
+ function moveThresholdM(anchor, fix) {
561
+ const a = anchor.accuracyM ?? 0, b = fix.accuracyM ?? 0;
562
+ return 2 * Math.sqrt(a * a + b * b);
563
+ }
564
+ function spatialEventProfile(intent) {
565
+ return SPATIAL_EVENT_PROFILES[intent] ?? SPATIAL_EVENT_PROFILES.navigation;
566
+ }
567
+ var RANK = { high: 3, medium: 2, low: 1 };
568
+ var MIN_FIXES = 2;
569
+ function extend(run, state, fixAt2, now) {
570
+ if (!run || run.state !== state) return { state, since: now, fixAt: fixAt2, fixes: fixAt2 === null ? 0 : 1 };
571
+ if (fixAt2 !== null && fixAt2 !== run.fixAt) {
572
+ run.fixAt = fixAt2;
573
+ run.fixes++;
574
+ }
575
+ return run;
576
+ }
577
+ var settled = (run, now, holdMs) => run.fixes >= MIN_FIXES && now - run.since >= holdMs;
578
+ var fixAt = (s) => s.position?.timestamp ?? null;
579
+ var SpatialEventDetector = class {
580
+ constructor(profile = SPATIAL_EVENT_PROFILES.navigation) {
581
+ /** The movement state last settled on. Only MEASURED states settle. */
582
+ this.movement = "unknown";
583
+ this.run = null;
584
+ /** The completed turn already on record — reported turns, and the one the
585
+ * session opened onto. Only a turn with a new timestamp is news. */
586
+ this.lastTurnAt = null;
587
+ /** The GPS band last settled on; null until the session's first band. */
588
+ this.band = null;
589
+ this.bandRun = null;
590
+ /** The last position REPORTED — distance is measured from here, not from
591
+ * the previous fix, so a slow walk still adds up to a change. */
592
+ this.anchor = null;
593
+ this.staleArmed = true;
594
+ this.profile = profile;
595
+ }
596
+ /**
597
+ * Forget everything — a new session, or replay seeking backwards. Emits
598
+ * nothing. `turnOnRecordAt` is the timestamp of the turn already completed
599
+ * when the session opens (a visual session shares the compass): history, not
600
+ * news. Seeded from the turn itself rather than compared against a clock, so
601
+ * it holds whichever clock the turn was stamped with — epoch live, the
602
+ * recording's epoch in replay, where `now` is virtual.
603
+ */
604
+ reset(profile, turnOnRecordAt = null) {
605
+ if (profile) this.profile = profile;
606
+ this.movement = "unknown";
607
+ this.run = null;
608
+ this.lastTurnAt = turnOnRecordAt;
609
+ this.band = null;
610
+ this.bandRun = null;
611
+ this.anchor = null;
612
+ this.staleArmed = true;
613
+ }
614
+ /**
615
+ * Feed the newest snapshot. Returns what changed since the last one, in this
616
+ * order: movement, turn, position, confidence, staleness. Fields are read
617
+ * defensively — replay hands over recorded snapshots verbatim, and a recording
618
+ * may lack a block the live engine always fills.
619
+ */
620
+ push(snapshot, now) {
621
+ const out = [];
622
+ const emit = (name, payload) => {
623
+ out.push(stateEvent("spatial", name, payload, now));
624
+ };
625
+ this.movementFrom(snapshot, now, emit);
626
+ this.turnFrom(snapshot, emit);
627
+ this.positionFrom(snapshot, emit);
628
+ this.confidenceFrom(snapshot, now, emit);
629
+ this.staleFrom(snapshot, emit);
630
+ return out;
631
+ }
632
+ movementFrom(s, now, emit) {
633
+ const state = s.movement?.state ?? "unknown";
634
+ this.run = extend(this.run, state, fixAt(s), now);
635
+ if (state === this.movement) return;
636
+ if (state === "moving" && settled(this.run, now, this.profile.movingHoldMs)) {
637
+ emit("movementStarted", {
638
+ previous: this.movement === "stationary" ? "stationary" : "unknown",
639
+ speedMps: s.movement?.speedMps ?? null,
640
+ bearingDeg: s.movement?.bearingDeg ?? null,
641
+ since: this.run.since
642
+ });
643
+ this.movement = "moving";
644
+ } else if (state === "stationary" && settled(this.run, now, this.profile.stoppedHoldMs)) {
645
+ if (this.movement === "moving") emit("movementStopped", { previous: "moving", since: this.run.since });
646
+ this.movement = "stationary";
647
+ }
648
+ }
649
+ turnFrom(s, emit) {
650
+ const at = s.changes?.timestamp ?? null;
651
+ const deg = s.changes?.lastMeaningfulTurnDeg ?? null;
652
+ if (at === null || deg === null || at === this.lastTurnAt) return;
653
+ this.lastTurnAt = at;
654
+ emit("lastMeaningfulTurnChanged", { lastMeaningfulTurnDeg: deg, timestamp: at });
655
+ }
656
+ positionFrom(s, emit) {
657
+ const p = s.position ?? null;
658
+ if (!p) return;
659
+ if (!this.anchor) {
660
+ emit("positionChanged", { position: { ...p }, previous: null, distanceM: null });
661
+ this.anchor = p;
662
+ return;
663
+ }
664
+ const d = distanceOf([this.anchor, p]);
665
+ if (d < Math.max(this.profile.minMoveM, moveThresholdM(this.anchor, p))) {
666
+ const aNew = p.accuracyM ?? null, aOld = this.anchor.accuracyM ?? null;
667
+ if (aNew != null && aOld != null && aNew * 2 < aOld) this.anchor = p;
668
+ return;
669
+ }
670
+ emit("positionChanged", { position: { ...p }, previous: { ...this.anchor }, distanceM: d });
671
+ this.anchor = p;
672
+ }
673
+ confidenceFrom(s, now, emit) {
674
+ const p = s.position ?? null;
675
+ if (!p) return;
676
+ const level = fixConfidence(p.accuracyM ?? null);
677
+ if (level === "unknown") return;
678
+ if (this.band === null) {
679
+ this.band = level;
680
+ return;
681
+ }
682
+ if (level === this.band) {
683
+ this.bandRun = null;
684
+ return;
685
+ }
686
+ this.bandRun = extend(this.bandRun, level, fixAt(s), now);
687
+ if (!settled(this.bandRun, now, this.profile.confidenceHoldMs)) return;
688
+ emit("positionConfidenceChanged", {
689
+ level,
690
+ previous: this.band,
691
+ accuracyM: p.accuracyM ?? null,
692
+ direction: RANK[level] < RANK[this.band] ? "dropped" : "recovered"
693
+ });
694
+ this.band = level;
695
+ this.bandRun = null;
696
+ }
697
+ staleFrom(s, emit) {
698
+ const f = s.quality?.freshness;
699
+ if (!f) return;
700
+ if (f.state === "fresh") {
701
+ this.staleArmed = true;
702
+ return;
703
+ }
704
+ if (f.state !== "stale" || !this.staleArmed) return;
705
+ emit("spatialStateStale", { observedAt: f.observedAt, ageMs: f.ageMs });
706
+ this.staleArmed = false;
707
+ }
708
+ };
709
+
710
+ // src/device-state/domains/spatial.ts
711
+ var WATCH_ACCURACY = {
712
+ navigation: "navigation",
713
+ preciseLocate: "high",
714
+ fitness: "high",
715
+ geofence: "balanced",
716
+ lowPowerTracking: "low"
717
+ };
718
+ function createSpatialDomain(collectors, speedRef) {
719
+ const RECENT_PATH_MAX = 60;
720
+ let recentPath = [];
721
+ let latest2 = null;
722
+ let latestSpeed = null;
723
+ let latestCourse = null;
724
+ let stopWatch = null;
725
+ let session = null;
726
+ const detector = new SpatialEventDetector();
727
+ const listeners = listenerSet();
728
+ let ticker = null;
729
+ let turnSub = null;
730
+ const nonNegative = (v) => typeof v === "number" && v >= 0 ? v : null;
731
+ const derivedSpeed = () => {
732
+ if (recentPath.length < 2) return null;
733
+ const b = recentPath[recentPath.length - 1];
734
+ const a = recentPath[recentPath.length - 2];
735
+ const dtS = (b.timestamp - a.timestamp) / 1e3;
736
+ if (dtS <= 0 || dtS > 10) return null;
737
+ return distanceOf([a, b]) / dtS;
738
+ };
739
+ const derivedCourse = () => {
740
+ if (recentPath.length < 2) return null;
741
+ const b = recentPath[recentPath.length - 1];
742
+ const a = recentPath[recentPath.length - 2];
743
+ if (b.timestamp - a.timestamp > 1e4) return null;
744
+ if (distanceOf([a, b]) < 3) return null;
745
+ return bearingBetween(a, b);
746
+ };
747
+ const snapshotFrom = (point, measuredSpeed, measuredCourse) => {
748
+ let speedMps = measuredSpeed;
749
+ let courseDeg = measuredCourse;
750
+ let derived = false;
751
+ if (speedMps === null) {
752
+ const d = derivedSpeed();
753
+ if (d !== null) {
754
+ speedMps = d;
755
+ derived = true;
756
+ }
757
+ }
758
+ if (courseDeg === null) {
759
+ const d = derivedCourse();
760
+ if (d !== null) {
761
+ courseDeg = d;
762
+ derived = true;
763
+ }
764
+ }
765
+ const heading = currentHeadingDetail();
766
+ const turn = lastMeaningfulTurn();
767
+ return {
768
+ position: point,
769
+ movement: {
770
+ speedMps,
771
+ bearingDeg: courseDeg,
772
+ // Needs two points from the SAME session; without a stream there is
773
+ // nothing to measure against, and a distance of 0 would read as
774
+ // "the user has not moved".
775
+ distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
776
+ state: movementStateOf(speedMps)
777
+ },
778
+ orientation: {
779
+ headingDeg: heading?.headingDeg ?? null,
780
+ // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
781
+ // a number of degrees, and converting one to the other would be an
782
+ // invented error bar. The rank drives `confidence` below instead.
783
+ headingAccuracyDeg: null
784
+ },
785
+ changes: {
786
+ lastMeaningfulTurnDeg: turn ? turn.deg : null,
787
+ timestamp: turn ? turn.timestamp : null
788
+ },
789
+ recentPath: [...recentPath],
790
+ quality: qualityFrom(
791
+ point.timestamp,
792
+ spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived)
793
+ )
794
+ };
795
+ };
796
+ const noFix = () => ({
797
+ position: null,
798
+ movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" },
799
+ orientation: { headingDeg: currentHeadingDetail()?.headingDeg ?? null },
800
+ recentPath: [],
801
+ quality: qualityUnknown()
802
+ });
803
+ const warmSnapshot = () => latest2 ? snapshotFrom(latest2, latestSpeed, latestCourse) : null;
804
+ const observe = () => {
805
+ const snapshot = warmSnapshot();
806
+ if (!snapshot) return;
807
+ for (const event of detector.push(snapshot, Date.now())) listeners.emit(event);
808
+ };
809
+ const startTicking = () => {
810
+ ticker = setInterval(observe, 1e3);
811
+ };
812
+ const stopTicking = () => {
813
+ if (ticker) clearInterval(ticker);
814
+ ticker = null;
815
+ };
816
+ return {
817
+ async getSnapshot() {
818
+ const warm = warmSnapshot();
819
+ if (warm) return warm;
820
+ if (session) return noFix();
821
+ let point;
822
+ let speedMps;
823
+ let courseDeg;
824
+ try {
825
+ if (!collectors.location) throw unsupported("location collector");
826
+ const fix = await collectors.location.getPosition({ accuracy: "low" });
827
+ point = {
828
+ latitude: fix.latitude,
829
+ longitude: fix.longitude,
830
+ altitude: fix.altitude ?? null,
831
+ accuracyM: nonNegative(fix.accuracy),
832
+ timestamp: fix.timestamp ?? Date.now()
833
+ };
834
+ speedMps = nonNegative(fix.speed);
835
+ courseDeg = nonNegative(fix.course);
836
+ } catch {
837
+ return noFix();
838
+ }
839
+ return snapshotFrom(point, speedMps, courseDeg);
840
+ },
841
+ /**
842
+ * Open the warm window: position stream + compass + event feed.
843
+ *
844
+ * The intent selects how eager the EVENTS are (spatialEvents.ts profiles)
845
+ * and the GPS TIER the stream runs at (WATCH_ACCURACY): navigation pays the
846
+ * battery for BestForNavigation; low-power tracking does not.
847
+ *
848
+ * A second `start()` while one is open joins the running session and is
849
+ * told ITS intent, not handed back the one it asked for: answering with the
850
+ * caller's intent would claim a profile that is not in force.
851
+ */
852
+ async start(options) {
853
+ if (session) return { ...session };
854
+ if (!collectors.location) throw unsupported("location collector");
855
+ const startedAt = Date.now();
856
+ session = { id: `spatial_${startedAt}`, intent: options.intent, startedAt };
857
+ detector.reset(spatialEventProfile(options.intent), lastMeaningfulTurn()?.timestamp ?? null);
858
+ void startHeading("spatial");
859
+ turnSub = onTurn(observe);
860
+ stopWatch = collectors.location.watchPosition((pos) => {
861
+ const p = {
862
+ latitude: pos.latitude,
863
+ longitude: pos.longitude,
864
+ altitude: pos.altitude ?? null,
865
+ accuracyM: nonNegative(pos.accuracy),
866
+ timestamp: pos.timestamp ?? Date.now()
867
+ };
868
+ latest2 = p;
869
+ latestSpeed = nonNegative(pos.speed);
870
+ latestCourse = nonNegative(pos.course);
871
+ speedRef.mps = latestSpeed;
872
+ speedRef.at = p.timestamp;
873
+ recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
874
+ observe();
875
+ }, { accuracy: WATCH_ACCURACY[options.intent] ?? "balanced" });
876
+ startTicking();
877
+ return { ...session };
878
+ },
879
+ async stop() {
880
+ stopWatch?.();
881
+ stopWatch = null;
882
+ session = null;
883
+ stopTicking();
884
+ turnSub?.unsubscribe();
885
+ turnSub = null;
886
+ detector.reset();
887
+ recentPath = [];
888
+ latest2 = null;
889
+ latestSpeed = null;
890
+ latestCourse = null;
891
+ speedRef.mps = null;
892
+ speedRef.at = 0;
893
+ stopHeading("spatial");
894
+ },
895
+ /**
896
+ * The six spatial events (see `SpatialEvent`). Subscribing opens no sensor:
897
+ * events flow only while a session is open, and a subscription made before
898
+ * `start()` hears that session from its first fix.
899
+ */
900
+ subscribe(listener) {
901
+ return listeners.add(listener);
902
+ }
903
+ };
904
+ }
905
+ function spatialConfidence(accuracyM, calibration, derived) {
906
+ const rank = (v) => v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0;
907
+ const fromFix = fixConfidence(accuracyM);
908
+ const fromCompass = calibration === null ? "unknown" : calibration >= 3 ? "high" : calibration >= 2 ? "medium" : "low";
909
+ const parts = [fromFix, fromCompass, ...derived ? ["low"] : []];
910
+ const known = parts.filter((p) => p !== "unknown");
911
+ if (!known.length) return { level: "unknown" };
912
+ const worst = known.reduce((a, b) => rank(a) <= rank(b) ? a : b);
913
+ return { level: worst };
914
+ }
915
+ function bearingBetween(a, b) {
916
+ const toRad = (d) => d * Math.PI / 180;
917
+ const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
918
+ const dLon = toRad(b.longitude - a.longitude);
919
+ const y = Math.sin(dLon) * Math.cos(la2);
920
+ const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
921
+ return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
922
+ }
923
+
401
924
  // src/device-state/visualEngine.ts
402
925
  var PROFILES = {
403
926
  navigation: { intervalMs: 1e3, windowMs: 5e3 },
@@ -611,300 +1134,26 @@ function frameQuality(frame) {
611
1134
  };
612
1135
  }
613
1136
 
614
- // src/device-state/headingSource.ts
615
- import * as ExpoLocation from "expo-location";
616
-
617
- // src/device-state/turnDetector.ts
618
- function signedDelta(a, b) {
619
- return (b - a + 540) % 360 - 180;
620
- }
621
- var TurnDetector = class {
622
- constructor(opts = {}) {
623
- /** The heading currently treated as "straight ahead". */
624
- this.refDeg = null;
625
- this.stableSince = 0;
626
- this.lastTurn = null;
627
- this.minTurnDeg = opts.minTurnDeg ?? 35;
628
- this.stableDeg = opts.stableDeg ?? 12;
629
- this.stableMs = opts.stableMs ?? 2500;
630
- }
631
- /** Feed one compass reading. `null` degrees means the compass said nothing. */
632
- push(deg, now) {
633
- if (deg === null) return null;
634
- if (this.refDeg === null) {
635
- this.refDeg = deg;
636
- this.stableSince = now;
637
- return null;
638
- }
639
- const delta = signedDelta(this.refDeg, deg);
640
- if (Math.abs(delta) >= this.minTurnDeg) {
641
- this.lastTurn = { deg: Math.round(delta), timestamp: now };
642
- this.refDeg = deg;
643
- this.stableSince = now;
644
- return this.lastTurn;
645
- }
646
- if (Math.abs(delta) > this.stableDeg) {
647
- this.stableSince = now;
648
- return null;
649
- }
650
- if (now - this.stableSince >= this.stableMs) {
651
- this.refDeg = deg;
652
- this.stableSince = now;
653
- }
654
- return null;
655
- }
656
- /**
657
- * The most recent completed turn, or null if none this session.
658
- *
659
- * It does NOT expire on its own: the age is derivable from `timestamp`, and
660
- * "recent enough to mention" is a product judgement this layer must not make
661
- * on the caller's behalf.
662
- */
663
- last() {
664
- return this.lastTurn;
665
- }
666
- reset() {
667
- this.refDeg = null;
668
- this.stableSince = 0;
669
- this.lastTurn = null;
670
- }
671
- };
672
-
673
- // src/device-state/headingSource.ts
674
- var latest = null;
675
- var sub = null;
676
- var turns = new TurnDetector();
677
- async function startHeading() {
678
- if (sub) return;
679
- try {
680
- sub = await ExpoLocation.watchHeadingAsync((h) => {
681
- const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
682
- const deg = typeof raw === "number" && raw >= 0 ? raw : null;
683
- const cal = typeof h.accuracy === "number" && h.accuracy >= 0 ? h.accuracy : null;
684
- latest = { headingDeg: deg, calibration: cal, timestamp: Date.now() };
685
- turns.push(deg, Date.now());
686
- });
687
- } catch {
688
- sub = null;
689
- }
690
- }
691
- function stopHeading() {
692
- sub?.remove();
693
- sub = null;
694
- latest = null;
695
- turns.reset();
696
- }
697
- function currentHeading() {
698
- return latest ? { headingDeg: latest.headingDeg } : null;
699
- }
700
- function currentHeadingDetail() {
701
- return latest;
702
- }
703
- function lastMeaningfulTurn() {
704
- return turns.last();
705
- }
706
-
707
- // src/device-state/domains/spatial.ts
708
- function createSpatialDomain(collectors, speedRef) {
709
- const RECENT_PATH_MAX = 60;
710
- const STATIONARY_MPS = 0.4;
711
- let recentPath = [];
712
- let latest2 = null;
713
- let latestSpeed = null;
714
- let latestCourse = null;
715
- let stopWatch = null;
716
- let sessionId = null;
717
- const nonNegative = (v) => typeof v === "number" && v >= 0 ? v : null;
718
- const derivedSpeed = () => {
719
- if (recentPath.length < 2) return null;
720
- const b = recentPath[recentPath.length - 1];
721
- const a = recentPath[recentPath.length - 2];
722
- const dtS = (b.timestamp - a.timestamp) / 1e3;
723
- if (dtS <= 0 || dtS > 10) return null;
724
- return distanceOf([a, b]) / dtS;
725
- };
726
- const derivedCourse = () => {
727
- if (recentPath.length < 2) return null;
728
- const b = recentPath[recentPath.length - 1];
729
- const a = recentPath[recentPath.length - 2];
730
- if (b.timestamp - a.timestamp > 1e4) return null;
731
- if (distanceOf([a, b]) < 3) return null;
732
- return bearingBetween(a, b);
733
- };
1137
+ // src/device-state/domains/visual.ts
1138
+ function cameraState() {
1139
+ const session = visualEngine.getSession();
734
1140
  return {
735
- async getSnapshot() {
736
- let point = latest2;
737
- let speedMps = latestSpeed;
738
- let courseDeg = latestCourse;
739
- let derived = false;
740
- if (!point) {
741
- try {
742
- if (!collectors.location) throw unsupported("location collector");
743
- const fix = await collectors.location.getPosition({ accuracy: "low" });
744
- point = {
745
- latitude: fix.latitude,
746
- longitude: fix.longitude,
747
- altitude: fix.altitude ?? null,
748
- accuracyM: nonNegative(fix.accuracy),
749
- timestamp: fix.timestamp ?? Date.now()
750
- };
751
- speedMps = nonNegative(fix.speed);
752
- courseDeg = nonNegative(fix.course);
753
- } catch {
754
- return {
755
- position: null,
756
- movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" },
757
- orientation: { headingDeg: null },
758
- recentPath: [],
759
- quality: qualityUnknown()
760
- };
761
- }
762
- }
763
- if (speedMps === null) {
764
- const d = derivedSpeed();
765
- if (d !== null) {
766
- speedMps = d;
767
- derived = true;
768
- }
769
- }
770
- if (courseDeg === null) {
771
- const d = derivedCourse();
772
- if (d !== null) {
773
- courseDeg = d;
774
- derived = true;
775
- }
776
- }
777
- const heading = currentHeadingDetail();
778
- const turn = lastMeaningfulTurn();
779
- return {
780
- position: point,
781
- movement: {
782
- speedMps,
783
- bearingDeg: courseDeg,
784
- // Needs two points from the SAME session; without a stream there is
785
- // nothing to measure against, and a distance of 0 would read as
786
- // "the user has not moved".
787
- distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
788
- // Only claim stationary/moving when a speed was actually obtained.
789
- // With no speed the truthful answer is `unknown`, not `stationary` —
790
- // "you have stopped" is a false statement, not a neutral default.
791
- state: speedMps === null ? "unknown" : speedMps < STATIONARY_MPS ? "stationary" : "moving"
792
- },
793
- orientation: {
794
- headingDeg: heading?.headingDeg ?? null,
795
- // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
796
- // a number of degrees, and converting one to the other would be an
797
- // invented error bar. The rank drives `confidence` below instead.
798
- headingAccuracyDeg: null
799
- },
800
- changes: {
801
- lastMeaningfulTurnDeg: turn ? turn.deg : null,
802
- timestamp: turn ? turn.timestamp : null
803
- },
804
- recentPath: [...recentPath],
805
- quality: qualityFrom(
806
- point.timestamp,
807
- spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived)
808
- )
809
- };
810
- },
811
- /**
812
- * Open the warm window: position stream + compass.
813
- *
814
- * The intent is recorded but does NOT yet change sampling — no native
815
- * strategy layer exists — so it is accepted rather than refused only
816
- * because the streams it opens are real and useful.
817
- */
818
- async start(options) {
819
- if (stopWatch) return { id: sessionId, intent: options.intent, startedAt: Date.now() };
820
- sessionId = `spatial_${Date.now()}`;
821
- if (!collectors.location) throw unsupported("location collector");
822
- void startHeading();
823
- stopWatch = collectors.location.watchPosition((pos) => {
824
- const p = {
825
- latitude: pos.latitude,
826
- longitude: pos.longitude,
827
- altitude: pos.altitude ?? null,
828
- accuracyM: nonNegative(pos.accuracy),
829
- timestamp: pos.timestamp ?? Date.now()
830
- };
831
- latest2 = p;
832
- latestSpeed = nonNegative(pos.speed);
833
- latestCourse = nonNegative(pos.course);
834
- speedRef.mps = latestSpeed;
835
- speedRef.at = p.timestamp;
836
- recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
837
- });
838
- return { id: sessionId, intent: options.intent, startedAt: Date.now() };
839
- },
840
- async stop() {
841
- stopWatch?.();
842
- stopWatch = null;
843
- sessionId = null;
844
- recentPath = [];
845
- latest2 = null;
846
- latestSpeed = null;
847
- latestCourse = null;
848
- speedRef.mps = null;
849
- speedRef.at = 0;
850
- if (!visualEngine.getSession()) stopHeading();
851
- },
852
- // No event vocabulary is frozen for spatial, and inventing names here would
853
- // put them in plugins before the freeze can contradict them.
854
- subscribe() {
855
- throw unsupported("spatial.subscribe");
856
- }
1141
+ // `available` = the app could use a camera at all. `active` = one is
1142
+ // running RIGHT NOW. They differ exactly when it matters: a pocketed
1143
+ // phone is available but not active, and answering `true` to both
1144
+ // would let a guide speak about a scene nothing is watching.
1145
+ available: !!session,
1146
+ active: visualEngine.isCameraReady(),
1147
+ facing: session ? "back" : "unknown"
857
1148
  };
858
1149
  }
859
- function spatialConfidence(accuracyM, calibration, derived) {
860
- const rank = (v) => v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0;
861
- const fromFix = accuracyM === null ? "unknown" : accuracyM <= 10 ? "high" : accuracyM <= 50 ? "medium" : "low";
862
- const fromCompass = calibration === null ? "unknown" : calibration >= 3 ? "high" : calibration >= 2 ? "medium" : "low";
863
- const parts = [fromFix, fromCompass, ...derived ? ["low"] : []];
864
- const known = parts.filter((p) => p !== "unknown");
865
- if (!known.length) return { level: "unknown" };
866
- const worst = known.reduce((a, b) => rank(a) <= rank(b) ? a : b);
867
- return { level: worst };
868
- }
869
- function bearingBetween(a, b) {
870
- const toRad = (d) => d * Math.PI / 180;
871
- const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
872
- const dLon = toRad(b.longitude - a.longitude);
873
- const y = Math.sin(dLon) * Math.cos(la2);
874
- const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
875
- return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
876
- }
877
- function distanceOf(path) {
878
- const R = 6371e3;
879
- let total = 0;
880
- for (let i = 1; i < path.length; i++) {
881
- const a = path[i - 1], b = path[i];
882
- const dLat = (b.latitude - a.latitude) * Math.PI / 180;
883
- const dLon = (b.longitude - a.longitude) * Math.PI / 180;
884
- const la1 = a.latitude * Math.PI / 180, la2 = b.latitude * Math.PI / 180;
885
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
886
- total += 2 * R * Math.asin(Math.sqrt(h));
887
- }
888
- return Math.round(total);
889
- }
890
-
891
- // src/device-state/domains/visual.ts
892
1150
  function createVisualDomain() {
893
1151
  return {
894
1152
  async getSnapshot() {
895
- const session = visualEngine.getSession();
896
1153
  const latest2 = visualEngine.latest();
897
1154
  const timeline = visualEngine.timeline();
898
1155
  return {
899
- camera: {
900
- // `available` = the app could use a camera at all. `active` = one is
901
- // running RIGHT NOW. They differ exactly when it matters: a pocketed
902
- // phone is available but not active, and answering `true` to both
903
- // would let a guide speak about a scene nothing is watching.
904
- available: !!session,
905
- active: visualEngine.isCameraReady(),
906
- facing: session ? "back" : "unknown"
907
- },
1156
+ camera: cameraState(),
908
1157
  // null, not a stale frame dressed as current. A caller with no usable
909
1158
  // frame must see nothing rather than something old.
910
1159
  latest: latest2,
@@ -915,13 +1164,13 @@ function createVisualDomain() {
915
1164
  async start(options) {
916
1165
  const s = visualEngine.startSession(options?.intent || "navigation");
917
1166
  visualEngine.setContextProvider({ heading: currentHeading });
918
- void startHeading();
1167
+ void startHeading("visual");
919
1168
  return { id: s.id, intent: s.intent, startedAt: s.startedAt };
920
1169
  },
921
1170
  async stop() {
922
1171
  visualEngine.stopSession();
923
1172
  visualEngine.setContextProvider(null);
924
- stopHeading();
1173
+ stopHeading("visual");
925
1174
  },
926
1175
  /**
927
1176
  * A deliberate one-shot. Distinct from getSnapshot because it is an
@@ -952,9 +1201,20 @@ function createVisualDomain() {
952
1201
  err.code = "TIMEOUT";
953
1202
  throw err;
954
1203
  },
1204
+ /**
1205
+ * Fires when the CAMERA block changes — a session opening or closing, the
1206
+ * camera coming up or going down. The engine notifies on every frame too,
1207
+ * and forwarding that made this a raw frame stream, which the freeze's core
1208
+ * rule 4 forbids. A frame arriving is what `getSnapshot()` is for.
1209
+ */
955
1210
  subscribe(listener) {
1211
+ let last = JSON.stringify(cameraState());
956
1212
  const off = visualEngine.subscribe(() => {
957
- listener({ type: "visualStateChanged" });
1213
+ const camera = cameraState();
1214
+ const key = JSON.stringify(camera);
1215
+ if (key === last) return;
1216
+ last = key;
1217
+ listener(stateEvent("visual", "visualStateChanged", { camera }));
958
1218
  });
959
1219
  return { unsubscribe: off };
960
1220
  }
@@ -1421,11 +1681,30 @@ function createReplayDeviceState(fixture) {
1421
1681
  const refusals = new Map((fixture.refusals || []).map((r) => [r.domain, r]));
1422
1682
  const openSessions = /* @__PURE__ */ new Set();
1423
1683
  let cursor = 0;
1684
+ const spatialDetector = new SpatialEventDetector();
1685
+ const spatialListeners = listenerSet();
1686
+ const moveTo = (target) => {
1687
+ const next = Math.min(duration, Math.max(0, target));
1688
+ if (!openSessions.has("spatial")) return cursor = next;
1689
+ if (next < cursor) {
1690
+ cursor = next;
1691
+ spatialDetector.reset(void 0, turnOnRecordAt());
1692
+ return cursor;
1693
+ }
1694
+ const from = cursor;
1695
+ for (const s of samples) {
1696
+ if (s.t <= from || !s.spatial) continue;
1697
+ if (s.t > next) break;
1698
+ cursor = s.t;
1699
+ for (const event of spatialDetector.push(s.spatial, s.t)) spatialListeners.emit(event);
1700
+ }
1701
+ return cursor = next;
1702
+ };
1424
1703
  const clock = {
1425
1704
  now: () => cursor,
1426
- advance: (ms) => cursor = Math.min(duration, Math.max(0, cursor + ms)),
1427
- seek: (ms) => cursor = Math.min(duration, Math.max(0, ms)),
1428
- end: () => cursor = duration,
1705
+ advance: (ms) => moveTo(cursor + ms),
1706
+ seek: (ms) => moveTo(ms),
1707
+ end: () => moveTo(duration),
1429
1708
  durationMs: () => duration
1430
1709
  };
1431
1710
  const sampleAt = () => {
@@ -1436,22 +1715,28 @@ function createReplayDeviceState(fixture) {
1436
1715
  }
1437
1716
  return found;
1438
1717
  };
1718
+ const turnOnRecordAt = () => sampleAt()?.spatial?.changes?.timestamp ?? null;
1439
1719
  const domainFor = (name) => ({
1440
1720
  async getSnapshot() {
1441
1721
  const s = sampleAt();
1442
1722
  const recorded = s ? s[name] : void 0;
1443
1723
  return recorded ?? unknownSnapshot(name);
1444
1724
  },
1445
- async start() {
1725
+ async start(options) {
1446
1726
  const r = refusals.get(name);
1447
1727
  if (r) throw refusal(name, r.code, r.message);
1728
+ if (name === "spatial" && !openSessions.has(name)) {
1729
+ spatialDetector.reset(spatialEventProfile(options?.intent), turnOnRecordAt());
1730
+ }
1448
1731
  openSessions.add(name);
1449
1732
  return { id: `replay_${name}`, intent: "replay", startedAt: 0 };
1450
1733
  },
1451
1734
  async stop() {
1452
1735
  openSessions.delete(name);
1736
+ if (name === "spatial") spatialDetector.reset();
1453
1737
  },
1454
- subscribe() {
1738
+ subscribe(listener) {
1739
+ if (name === "spatial") return spatialListeners.add(listener);
1455
1740
  throw refusal(name, "UNSUPPORTED", `deviceState.${name}.subscribe is not replayable \u2014 advance the clock and read.`);
1456
1741
  }
1457
1742
  });
@@ -1510,4 +1795,4 @@ export {
1510
1795
  parseWalkJsonl,
1511
1796
  setupExpoProviders
1512
1797
  };
1513
- //# sourceMappingURL=chunk-4XYXJYN5.mjs.map
1798
+ //# sourceMappingURL=chunk-C7M5WAHA.mjs.map