@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
package/dist/index.js CHANGED
@@ -247,6 +247,30 @@ function unsupported(what) {
247
247
  return err;
248
248
  }
249
249
 
250
+ // src/device-state/events.ts
251
+ function stateEvent(domain, name, payload, timestamp = Date.now()) {
252
+ return payload === void 0 ? { domain, name, timestamp } : { domain, name, timestamp, payload };
253
+ }
254
+ function listenerSet() {
255
+ const listeners = /* @__PURE__ */ new Set();
256
+ return {
257
+ add(fn) {
258
+ listeners.add(fn);
259
+ return { unsubscribe: () => {
260
+ listeners.delete(fn);
261
+ } };
262
+ },
263
+ emit(event) {
264
+ for (const fn of listeners) {
265
+ try {
266
+ fn(event);
267
+ } catch {
268
+ }
269
+ }
270
+ }
271
+ };
272
+ }
273
+
250
274
  // src/device-state/domains/deviceContext.ts
251
275
  function createDeviceContextDomain() {
252
276
  return {
@@ -282,11 +306,12 @@ function createDeviceContextDomain() {
282
306
  },
283
307
  // Real events, not a placeholder: expo-battery exposes native listeners,
284
308
  // so a plugin that subscribes genuinely hears about a change rather than
285
- // sitting on a subscription that can never fire.
309
+ // sitting on a subscription that can never fire. Nudges without a payload:
310
+ // no Device Context payload schema is decided, so the subscriber re-reads.
286
311
  subscribe(listener) {
287
312
  const subs = [
288
- ExpoBattery.addBatteryLevelListener(() => listener({ type: "batteryLevelChanged" })),
289
- ExpoBattery.addBatteryStateListener(() => listener({ type: "chargingChanged" }))
313
+ ExpoBattery.addBatteryLevelListener(() => listener(stateEvent("deviceContext", "batteryLevelChanged"))),
314
+ ExpoBattery.addBatteryStateListener(() => listener(stateEvent("deviceContext", "chargingChanged")))
290
315
  ];
291
316
  return { unsubscribe: () => subs.forEach((x) => x?.remove?.()) };
292
317
  }
@@ -422,13 +447,523 @@ function createInteractionDomain() {
422
447
  // which is the one part of this domain that works.
423
448
  subscribe(listener) {
424
449
  const sub2 = import_react_native.AppState.addEventListener("change", (next) => {
425
- listener({ type: "appStateChanged", appState: mapState(next) });
450
+ listener(stateEvent("interaction", "appStateChanged", { appState: mapState(next) }));
426
451
  });
427
452
  return { unsubscribe: () => sub2.remove() };
428
453
  }
429
454
  };
430
455
  }
431
456
 
457
+ // src/device-state/headingSource.ts
458
+ var ExpoLocation = __toESM(require("expo-location"));
459
+
460
+ // src/device-state/turnDetector.ts
461
+ function signedDelta(a, b) {
462
+ return (b - a + 540) % 360 - 180;
463
+ }
464
+ var TurnDetector = class {
465
+ constructor(opts = {}) {
466
+ /** The heading currently treated as "straight ahead". */
467
+ this.refDeg = null;
468
+ this.stableSince = 0;
469
+ this.lastTurn = null;
470
+ this.minTurnDeg = opts.minTurnDeg ?? 35;
471
+ this.stableDeg = opts.stableDeg ?? 12;
472
+ this.stableMs = opts.stableMs ?? 2500;
473
+ }
474
+ /** Feed one compass reading. `null` degrees means the compass said nothing. */
475
+ push(deg, now) {
476
+ if (deg === null) return null;
477
+ if (this.refDeg === null) {
478
+ this.refDeg = deg;
479
+ this.stableSince = now;
480
+ return null;
481
+ }
482
+ const delta = signedDelta(this.refDeg, deg);
483
+ if (Math.abs(delta) >= this.minTurnDeg) {
484
+ this.lastTurn = { deg: Math.round(delta), timestamp: now };
485
+ this.refDeg = deg;
486
+ this.stableSince = now;
487
+ return this.lastTurn;
488
+ }
489
+ if (Math.abs(delta) > this.stableDeg) {
490
+ this.stableSince = now;
491
+ return null;
492
+ }
493
+ if (now - this.stableSince >= this.stableMs) {
494
+ this.refDeg = deg;
495
+ this.stableSince = now;
496
+ }
497
+ return null;
498
+ }
499
+ /**
500
+ * The most recent completed turn, or null if none this session.
501
+ *
502
+ * It does NOT expire on its own: the age is derivable from `timestamp`, and
503
+ * "recent enough to mention" is a product judgement this layer must not make
504
+ * on the caller's behalf.
505
+ */
506
+ last() {
507
+ return this.lastTurn;
508
+ }
509
+ reset() {
510
+ this.refDeg = null;
511
+ this.stableSince = 0;
512
+ this.lastTurn = null;
513
+ }
514
+ };
515
+
516
+ // src/device-state/headingSource.ts
517
+ var latest = null;
518
+ var sub = null;
519
+ var opening = null;
520
+ var owners = /* @__PURE__ */ new Set();
521
+ var turns = new TurnDetector();
522
+ var turnListeners = listenerSet();
523
+ function startHeading(owner) {
524
+ owners.add(owner);
525
+ if (sub) return Promise.resolve();
526
+ opening ?? (opening = open().finally(() => {
527
+ opening = null;
528
+ }));
529
+ return opening;
530
+ }
531
+ async function open() {
532
+ try {
533
+ const s = await ExpoLocation.watchHeadingAsync((h) => {
534
+ if (!owners.size) return;
535
+ const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
536
+ const deg = typeof raw === "number" && raw >= 0 ? raw : null;
537
+ const cal = typeof h.accuracy === "number" && h.accuracy >= 0 ? h.accuracy : null;
538
+ const now = Date.now();
539
+ latest = { headingDeg: deg, calibration: cal, timestamp: now };
540
+ const turn = turns.push(deg, now);
541
+ if (turn) turnListeners.emit(turn);
542
+ });
543
+ if (!owners.size) {
544
+ s.remove();
545
+ return;
546
+ }
547
+ sub = s;
548
+ } catch {
549
+ sub = null;
550
+ }
551
+ }
552
+ function stopHeading(owner) {
553
+ owners.delete(owner);
554
+ if (owners.size) return;
555
+ sub?.remove();
556
+ sub = null;
557
+ latest = null;
558
+ turns.reset();
559
+ }
560
+ function onTurn(fn) {
561
+ return turnListeners.add(fn);
562
+ }
563
+ function currentHeading() {
564
+ return latest ? { headingDeg: latest.headingDeg } : null;
565
+ }
566
+ function currentHeadingDetail() {
567
+ return latest;
568
+ }
569
+ function lastMeaningfulTurn() {
570
+ return turns.last();
571
+ }
572
+
573
+ // src/geo.ts
574
+ function distanceOf(path) {
575
+ const R = 6371e3;
576
+ let total = 0;
577
+ for (let i = 1; i < path.length; i++) {
578
+ const a = path[i - 1], b = path[i];
579
+ const dLat = (b.latitude - a.latitude) * Math.PI / 180;
580
+ const dLon = (b.longitude - a.longitude) * Math.PI / 180;
581
+ const la1 = a.latitude * Math.PI / 180, la2 = b.latitude * Math.PI / 180;
582
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
583
+ total += 2 * R * Math.asin(Math.sqrt(h));
584
+ }
585
+ return Math.round(total);
586
+ }
587
+
588
+ // src/device-state/spatialEvents.ts
589
+ var STATIONARY_MPS = 0.4;
590
+ function movementStateOf(speedMps) {
591
+ return speedMps === null ? "unknown" : speedMps < STATIONARY_MPS ? "stationary" : "moving";
592
+ }
593
+ function fixConfidence(accuracyM) {
594
+ return accuracyM === null ? "unknown" : accuracyM <= 10 ? "high" : accuracyM <= 50 ? "medium" : "low";
595
+ }
596
+ var SPATIAL_EVENT_PROFILES = {
597
+ navigation: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 3e3, minMoveM: 10 },
598
+ fitness: { movingHoldMs: 3e3, stoppedHoldMs: 5e3, confidenceHoldMs: 5e3, minMoveM: 10 },
599
+ preciseLocate: { movingHoldMs: 2e3, stoppedHoldMs: 3e3, confidenceHoldMs: 2e3, minMoveM: 3 },
600
+ geofence: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 25 },
601
+ lowPowerTracking: { movingHoldMs: 1e4, stoppedHoldMs: 3e4, confidenceHoldMs: 1e4, minMoveM: 50 }
602
+ };
603
+ function moveThresholdM(anchor, fix) {
604
+ const a = anchor.accuracyM ?? 0, b = fix.accuracyM ?? 0;
605
+ return 2 * Math.sqrt(a * a + b * b);
606
+ }
607
+ function spatialEventProfile(intent) {
608
+ return SPATIAL_EVENT_PROFILES[intent] ?? SPATIAL_EVENT_PROFILES.navigation;
609
+ }
610
+ var RANK = { high: 3, medium: 2, low: 1 };
611
+ var MIN_FIXES = 2;
612
+ function extend(run, state, fixAt2, now) {
613
+ if (!run || run.state !== state) return { state, since: now, fixAt: fixAt2, fixes: fixAt2 === null ? 0 : 1 };
614
+ if (fixAt2 !== null && fixAt2 !== run.fixAt) {
615
+ run.fixAt = fixAt2;
616
+ run.fixes++;
617
+ }
618
+ return run;
619
+ }
620
+ var settled = (run, now, holdMs) => run.fixes >= MIN_FIXES && now - run.since >= holdMs;
621
+ var fixAt = (s) => s.position?.timestamp ?? null;
622
+ var SpatialEventDetector = class {
623
+ constructor(profile = SPATIAL_EVENT_PROFILES.navigation) {
624
+ /** The movement state last settled on. Only MEASURED states settle. */
625
+ this.movement = "unknown";
626
+ this.run = null;
627
+ /** The completed turn already on record — reported turns, and the one the
628
+ * session opened onto. Only a turn with a new timestamp is news. */
629
+ this.lastTurnAt = null;
630
+ /** The GPS band last settled on; null until the session's first band. */
631
+ this.band = null;
632
+ this.bandRun = null;
633
+ /** The last position REPORTED — distance is measured from here, not from
634
+ * the previous fix, so a slow walk still adds up to a change. */
635
+ this.anchor = null;
636
+ this.staleArmed = true;
637
+ this.profile = profile;
638
+ }
639
+ /**
640
+ * Forget everything — a new session, or replay seeking backwards. Emits
641
+ * nothing. `turnOnRecordAt` is the timestamp of the turn already completed
642
+ * when the session opens (a visual session shares the compass): history, not
643
+ * news. Seeded from the turn itself rather than compared against a clock, so
644
+ * it holds whichever clock the turn was stamped with — epoch live, the
645
+ * recording's epoch in replay, where `now` is virtual.
646
+ */
647
+ reset(profile, turnOnRecordAt = null) {
648
+ if (profile) this.profile = profile;
649
+ this.movement = "unknown";
650
+ this.run = null;
651
+ this.lastTurnAt = turnOnRecordAt;
652
+ this.band = null;
653
+ this.bandRun = null;
654
+ this.anchor = null;
655
+ this.staleArmed = true;
656
+ }
657
+ /**
658
+ * Feed the newest snapshot. Returns what changed since the last one, in this
659
+ * order: movement, turn, position, confidence, staleness. Fields are read
660
+ * defensively — replay hands over recorded snapshots verbatim, and a recording
661
+ * may lack a block the live engine always fills.
662
+ */
663
+ push(snapshot, now) {
664
+ const out = [];
665
+ const emit = (name, payload) => {
666
+ out.push(stateEvent("spatial", name, payload, now));
667
+ };
668
+ this.movementFrom(snapshot, now, emit);
669
+ this.turnFrom(snapshot, emit);
670
+ this.positionFrom(snapshot, emit);
671
+ this.confidenceFrom(snapshot, now, emit);
672
+ this.staleFrom(snapshot, emit);
673
+ return out;
674
+ }
675
+ movementFrom(s, now, emit) {
676
+ const state = s.movement?.state ?? "unknown";
677
+ this.run = extend(this.run, state, fixAt(s), now);
678
+ if (state === this.movement) return;
679
+ if (state === "moving" && settled(this.run, now, this.profile.movingHoldMs)) {
680
+ emit("movementStarted", {
681
+ previous: this.movement === "stationary" ? "stationary" : "unknown",
682
+ speedMps: s.movement?.speedMps ?? null,
683
+ bearingDeg: s.movement?.bearingDeg ?? null,
684
+ since: this.run.since
685
+ });
686
+ this.movement = "moving";
687
+ } else if (state === "stationary" && settled(this.run, now, this.profile.stoppedHoldMs)) {
688
+ if (this.movement === "moving") emit("movementStopped", { previous: "moving", since: this.run.since });
689
+ this.movement = "stationary";
690
+ }
691
+ }
692
+ turnFrom(s, emit) {
693
+ const at = s.changes?.timestamp ?? null;
694
+ const deg = s.changes?.lastMeaningfulTurnDeg ?? null;
695
+ if (at === null || deg === null || at === this.lastTurnAt) return;
696
+ this.lastTurnAt = at;
697
+ emit("lastMeaningfulTurnChanged", { lastMeaningfulTurnDeg: deg, timestamp: at });
698
+ }
699
+ positionFrom(s, emit) {
700
+ const p = s.position ?? null;
701
+ if (!p) return;
702
+ if (!this.anchor) {
703
+ emit("positionChanged", { position: { ...p }, previous: null, distanceM: null });
704
+ this.anchor = p;
705
+ return;
706
+ }
707
+ const d = distanceOf([this.anchor, p]);
708
+ if (d < Math.max(this.profile.minMoveM, moveThresholdM(this.anchor, p))) {
709
+ const aNew = p.accuracyM ?? null, aOld = this.anchor.accuracyM ?? null;
710
+ if (aNew != null && aOld != null && aNew * 2 < aOld) this.anchor = p;
711
+ return;
712
+ }
713
+ emit("positionChanged", { position: { ...p }, previous: { ...this.anchor }, distanceM: d });
714
+ this.anchor = p;
715
+ }
716
+ confidenceFrom(s, now, emit) {
717
+ const p = s.position ?? null;
718
+ if (!p) return;
719
+ const level = fixConfidence(p.accuracyM ?? null);
720
+ if (level === "unknown") return;
721
+ if (this.band === null) {
722
+ this.band = level;
723
+ return;
724
+ }
725
+ if (level === this.band) {
726
+ this.bandRun = null;
727
+ return;
728
+ }
729
+ this.bandRun = extend(this.bandRun, level, fixAt(s), now);
730
+ if (!settled(this.bandRun, now, this.profile.confidenceHoldMs)) return;
731
+ emit("positionConfidenceChanged", {
732
+ level,
733
+ previous: this.band,
734
+ accuracyM: p.accuracyM ?? null,
735
+ direction: RANK[level] < RANK[this.band] ? "dropped" : "recovered"
736
+ });
737
+ this.band = level;
738
+ this.bandRun = null;
739
+ }
740
+ staleFrom(s, emit) {
741
+ const f = s.quality?.freshness;
742
+ if (!f) return;
743
+ if (f.state === "fresh") {
744
+ this.staleArmed = true;
745
+ return;
746
+ }
747
+ if (f.state !== "stale" || !this.staleArmed) return;
748
+ emit("spatialStateStale", { observedAt: f.observedAt, ageMs: f.ageMs });
749
+ this.staleArmed = false;
750
+ }
751
+ };
752
+
753
+ // src/device-state/domains/spatial.ts
754
+ var WATCH_ACCURACY = {
755
+ navigation: "navigation",
756
+ preciseLocate: "high",
757
+ fitness: "high",
758
+ geofence: "balanced",
759
+ lowPowerTracking: "low"
760
+ };
761
+ function createSpatialDomain(collectors, speedRef) {
762
+ const RECENT_PATH_MAX = 60;
763
+ let recentPath = [];
764
+ let latest2 = null;
765
+ let latestSpeed = null;
766
+ let latestCourse = null;
767
+ let stopWatch = null;
768
+ let session = null;
769
+ const detector = new SpatialEventDetector();
770
+ const listeners = listenerSet();
771
+ let ticker = null;
772
+ let turnSub = null;
773
+ const nonNegative = (v) => typeof v === "number" && v >= 0 ? v : null;
774
+ const derivedSpeed = () => {
775
+ if (recentPath.length < 2) return null;
776
+ const b = recentPath[recentPath.length - 1];
777
+ const a = recentPath[recentPath.length - 2];
778
+ const dtS = (b.timestamp - a.timestamp) / 1e3;
779
+ if (dtS <= 0 || dtS > 10) return null;
780
+ return distanceOf([a, b]) / dtS;
781
+ };
782
+ const derivedCourse = () => {
783
+ if (recentPath.length < 2) return null;
784
+ const b = recentPath[recentPath.length - 1];
785
+ const a = recentPath[recentPath.length - 2];
786
+ if (b.timestamp - a.timestamp > 1e4) return null;
787
+ if (distanceOf([a, b]) < 3) return null;
788
+ return bearingBetween(a, b);
789
+ };
790
+ const snapshotFrom = (point, measuredSpeed, measuredCourse) => {
791
+ let speedMps = measuredSpeed;
792
+ let courseDeg = measuredCourse;
793
+ let derived = false;
794
+ if (speedMps === null) {
795
+ const d = derivedSpeed();
796
+ if (d !== null) {
797
+ speedMps = d;
798
+ derived = true;
799
+ }
800
+ }
801
+ if (courseDeg === null) {
802
+ const d = derivedCourse();
803
+ if (d !== null) {
804
+ courseDeg = d;
805
+ derived = true;
806
+ }
807
+ }
808
+ const heading = currentHeadingDetail();
809
+ const turn = lastMeaningfulTurn();
810
+ return {
811
+ position: point,
812
+ movement: {
813
+ speedMps,
814
+ bearingDeg: courseDeg,
815
+ // Needs two points from the SAME session; without a stream there is
816
+ // nothing to measure against, and a distance of 0 would read as
817
+ // "the user has not moved".
818
+ distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
819
+ state: movementStateOf(speedMps)
820
+ },
821
+ orientation: {
822
+ headingDeg: heading?.headingDeg ?? null,
823
+ // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
824
+ // a number of degrees, and converting one to the other would be an
825
+ // invented error bar. The rank drives `confidence` below instead.
826
+ headingAccuracyDeg: null
827
+ },
828
+ changes: {
829
+ lastMeaningfulTurnDeg: turn ? turn.deg : null,
830
+ timestamp: turn ? turn.timestamp : null
831
+ },
832
+ recentPath: [...recentPath],
833
+ quality: qualityFrom(
834
+ point.timestamp,
835
+ spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived)
836
+ )
837
+ };
838
+ };
839
+ const noFix = () => ({
840
+ position: null,
841
+ movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" },
842
+ orientation: { headingDeg: currentHeadingDetail()?.headingDeg ?? null },
843
+ recentPath: [],
844
+ quality: qualityUnknown()
845
+ });
846
+ const warmSnapshot = () => latest2 ? snapshotFrom(latest2, latestSpeed, latestCourse) : null;
847
+ const observe = () => {
848
+ const snapshot = warmSnapshot();
849
+ if (!snapshot) return;
850
+ for (const event of detector.push(snapshot, Date.now())) listeners.emit(event);
851
+ };
852
+ const startTicking = () => {
853
+ ticker = setInterval(observe, 1e3);
854
+ };
855
+ const stopTicking = () => {
856
+ if (ticker) clearInterval(ticker);
857
+ ticker = null;
858
+ };
859
+ return {
860
+ async getSnapshot() {
861
+ const warm = warmSnapshot();
862
+ if (warm) return warm;
863
+ if (session) return noFix();
864
+ let point;
865
+ let speedMps;
866
+ let courseDeg;
867
+ try {
868
+ if (!collectors.location) throw unsupported("location collector");
869
+ const fix = await collectors.location.getPosition({ accuracy: "low" });
870
+ point = {
871
+ latitude: fix.latitude,
872
+ longitude: fix.longitude,
873
+ altitude: fix.altitude ?? null,
874
+ accuracyM: nonNegative(fix.accuracy),
875
+ timestamp: fix.timestamp ?? Date.now()
876
+ };
877
+ speedMps = nonNegative(fix.speed);
878
+ courseDeg = nonNegative(fix.course);
879
+ } catch {
880
+ return noFix();
881
+ }
882
+ return snapshotFrom(point, speedMps, courseDeg);
883
+ },
884
+ /**
885
+ * Open the warm window: position stream + compass + event feed.
886
+ *
887
+ * The intent selects how eager the EVENTS are (spatialEvents.ts profiles)
888
+ * and the GPS TIER the stream runs at (WATCH_ACCURACY): navigation pays the
889
+ * battery for BestForNavigation; low-power tracking does not.
890
+ *
891
+ * A second `start()` while one is open joins the running session and is
892
+ * told ITS intent, not handed back the one it asked for: answering with the
893
+ * caller's intent would claim a profile that is not in force.
894
+ */
895
+ async start(options) {
896
+ if (session) return { ...session };
897
+ if (!collectors.location) throw unsupported("location collector");
898
+ const startedAt = Date.now();
899
+ session = { id: `spatial_${startedAt}`, intent: options.intent, startedAt };
900
+ detector.reset(spatialEventProfile(options.intent), lastMeaningfulTurn()?.timestamp ?? null);
901
+ void startHeading("spatial");
902
+ turnSub = onTurn(observe);
903
+ stopWatch = collectors.location.watchPosition((pos) => {
904
+ const p = {
905
+ latitude: pos.latitude,
906
+ longitude: pos.longitude,
907
+ altitude: pos.altitude ?? null,
908
+ accuracyM: nonNegative(pos.accuracy),
909
+ timestamp: pos.timestamp ?? Date.now()
910
+ };
911
+ latest2 = p;
912
+ latestSpeed = nonNegative(pos.speed);
913
+ latestCourse = nonNegative(pos.course);
914
+ speedRef.mps = latestSpeed;
915
+ speedRef.at = p.timestamp;
916
+ recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
917
+ observe();
918
+ }, { accuracy: WATCH_ACCURACY[options.intent] ?? "balanced" });
919
+ startTicking();
920
+ return { ...session };
921
+ },
922
+ async stop() {
923
+ stopWatch?.();
924
+ stopWatch = null;
925
+ session = null;
926
+ stopTicking();
927
+ turnSub?.unsubscribe();
928
+ turnSub = null;
929
+ detector.reset();
930
+ recentPath = [];
931
+ latest2 = null;
932
+ latestSpeed = null;
933
+ latestCourse = null;
934
+ speedRef.mps = null;
935
+ speedRef.at = 0;
936
+ stopHeading("spatial");
937
+ },
938
+ /**
939
+ * The six spatial events (see `SpatialEvent`). Subscribing opens no sensor:
940
+ * events flow only while a session is open, and a subscription made before
941
+ * `start()` hears that session from its first fix.
942
+ */
943
+ subscribe(listener) {
944
+ return listeners.add(listener);
945
+ }
946
+ };
947
+ }
948
+ function spatialConfidence(accuracyM, calibration, derived) {
949
+ const rank = (v) => v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0;
950
+ const fromFix = fixConfidence(accuracyM);
951
+ const fromCompass = calibration === null ? "unknown" : calibration >= 3 ? "high" : calibration >= 2 ? "medium" : "low";
952
+ const parts = [fromFix, fromCompass, ...derived ? ["low"] : []];
953
+ const known = parts.filter((p) => p !== "unknown");
954
+ if (!known.length) return { level: "unknown" };
955
+ const worst = known.reduce((a, b) => rank(a) <= rank(b) ? a : b);
956
+ return { level: worst };
957
+ }
958
+ function bearingBetween(a, b) {
959
+ const toRad = (d) => d * Math.PI / 180;
960
+ const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
961
+ const dLon = toRad(b.longitude - a.longitude);
962
+ const y = Math.sin(dLon) * Math.cos(la2);
963
+ const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
964
+ return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
965
+ }
966
+
432
967
  // src/device-state/visualEngine.ts
433
968
  var PROFILES = {
434
969
  navigation: { intervalMs: 1e3, windowMs: 5e3 },
@@ -642,300 +1177,26 @@ function frameQuality(frame) {
642
1177
  };
643
1178
  }
644
1179
 
645
- // src/device-state/headingSource.ts
646
- var ExpoLocation = __toESM(require("expo-location"));
647
-
648
- // src/device-state/turnDetector.ts
649
- function signedDelta(a, b) {
650
- return (b - a + 540) % 360 - 180;
651
- }
652
- var TurnDetector = class {
653
- constructor(opts = {}) {
654
- /** The heading currently treated as "straight ahead". */
655
- this.refDeg = null;
656
- this.stableSince = 0;
657
- this.lastTurn = null;
658
- this.minTurnDeg = opts.minTurnDeg ?? 35;
659
- this.stableDeg = opts.stableDeg ?? 12;
660
- this.stableMs = opts.stableMs ?? 2500;
661
- }
662
- /** Feed one compass reading. `null` degrees means the compass said nothing. */
663
- push(deg, now) {
664
- if (deg === null) return null;
665
- if (this.refDeg === null) {
666
- this.refDeg = deg;
667
- this.stableSince = now;
668
- return null;
669
- }
670
- const delta = signedDelta(this.refDeg, deg);
671
- if (Math.abs(delta) >= this.minTurnDeg) {
672
- this.lastTurn = { deg: Math.round(delta), timestamp: now };
673
- this.refDeg = deg;
674
- this.stableSince = now;
675
- return this.lastTurn;
676
- }
677
- if (Math.abs(delta) > this.stableDeg) {
678
- this.stableSince = now;
679
- return null;
680
- }
681
- if (now - this.stableSince >= this.stableMs) {
682
- this.refDeg = deg;
683
- this.stableSince = now;
684
- }
685
- return null;
686
- }
687
- /**
688
- * The most recent completed turn, or null if none this session.
689
- *
690
- * It does NOT expire on its own: the age is derivable from `timestamp`, and
691
- * "recent enough to mention" is a product judgement this layer must not make
692
- * on the caller's behalf.
693
- */
694
- last() {
695
- return this.lastTurn;
696
- }
697
- reset() {
698
- this.refDeg = null;
699
- this.stableSince = 0;
700
- this.lastTurn = null;
701
- }
702
- };
703
-
704
- // src/device-state/headingSource.ts
705
- var latest = null;
706
- var sub = null;
707
- var turns = new TurnDetector();
708
- async function startHeading() {
709
- if (sub) return;
710
- try {
711
- sub = await ExpoLocation.watchHeadingAsync((h) => {
712
- const raw = h.trueHeading >= 0 ? h.trueHeading : h.magHeading;
713
- const deg = typeof raw === "number" && raw >= 0 ? raw : null;
714
- const cal = typeof h.accuracy === "number" && h.accuracy >= 0 ? h.accuracy : null;
715
- latest = { headingDeg: deg, calibration: cal, timestamp: Date.now() };
716
- turns.push(deg, Date.now());
717
- });
718
- } catch {
719
- sub = null;
720
- }
721
- }
722
- function stopHeading() {
723
- sub?.remove();
724
- sub = null;
725
- latest = null;
726
- turns.reset();
727
- }
728
- function currentHeading() {
729
- return latest ? { headingDeg: latest.headingDeg } : null;
730
- }
731
- function currentHeadingDetail() {
732
- return latest;
733
- }
734
- function lastMeaningfulTurn() {
735
- return turns.last();
736
- }
737
-
738
- // src/device-state/domains/spatial.ts
739
- function createSpatialDomain(collectors, speedRef) {
740
- const RECENT_PATH_MAX = 60;
741
- const STATIONARY_MPS = 0.4;
742
- let recentPath = [];
743
- let latest2 = null;
744
- let latestSpeed = null;
745
- let latestCourse = null;
746
- let stopWatch = null;
747
- let sessionId = null;
748
- const nonNegative = (v) => typeof v === "number" && v >= 0 ? v : null;
749
- const derivedSpeed = () => {
750
- if (recentPath.length < 2) return null;
751
- const b = recentPath[recentPath.length - 1];
752
- const a = recentPath[recentPath.length - 2];
753
- const dtS = (b.timestamp - a.timestamp) / 1e3;
754
- if (dtS <= 0 || dtS > 10) return null;
755
- return distanceOf([a, b]) / dtS;
756
- };
757
- const derivedCourse = () => {
758
- if (recentPath.length < 2) return null;
759
- const b = recentPath[recentPath.length - 1];
760
- const a = recentPath[recentPath.length - 2];
761
- if (b.timestamp - a.timestamp > 1e4) return null;
762
- if (distanceOf([a, b]) < 3) return null;
763
- return bearingBetween(a, b);
764
- };
1180
+ // src/device-state/domains/visual.ts
1181
+ function cameraState() {
1182
+ const session = visualEngine.getSession();
765
1183
  return {
766
- async getSnapshot() {
767
- let point = latest2;
768
- let speedMps = latestSpeed;
769
- let courseDeg = latestCourse;
770
- let derived = false;
771
- if (!point) {
772
- try {
773
- if (!collectors.location) throw unsupported("location collector");
774
- const fix = await collectors.location.getPosition({ accuracy: "low" });
775
- point = {
776
- latitude: fix.latitude,
777
- longitude: fix.longitude,
778
- altitude: fix.altitude ?? null,
779
- accuracyM: nonNegative(fix.accuracy),
780
- timestamp: fix.timestamp ?? Date.now()
781
- };
782
- speedMps = nonNegative(fix.speed);
783
- courseDeg = nonNegative(fix.course);
784
- } catch {
785
- return {
786
- position: null,
787
- movement: { speedMps: null, bearingDeg: null, distanceRecentM: null, state: "unknown" },
788
- orientation: { headingDeg: null },
789
- recentPath: [],
790
- quality: qualityUnknown()
791
- };
792
- }
793
- }
794
- if (speedMps === null) {
795
- const d = derivedSpeed();
796
- if (d !== null) {
797
- speedMps = d;
798
- derived = true;
799
- }
800
- }
801
- if (courseDeg === null) {
802
- const d = derivedCourse();
803
- if (d !== null) {
804
- courseDeg = d;
805
- derived = true;
806
- }
807
- }
808
- const heading = currentHeadingDetail();
809
- const turn = lastMeaningfulTurn();
810
- return {
811
- position: point,
812
- movement: {
813
- speedMps,
814
- bearingDeg: courseDeg,
815
- // Needs two points from the SAME session; without a stream there is
816
- // nothing to measure against, and a distance of 0 would read as
817
- // "the user has not moved".
818
- distanceRecentM: recentPath.length >= 2 ? distanceOf(recentPath) : null,
819
- // Only claim stationary/moving when a speed was actually obtained.
820
- // With no speed the truthful answer is `unknown`, not `stationary` —
821
- // "you have stopped" is a false statement, not a neutral default.
822
- state: speedMps === null ? "unknown" : speedMps < STATIONARY_MPS ? "stationary" : "moving"
823
- },
824
- orientation: {
825
- headingDeg: heading?.headingDeg ?? null,
826
- // NOT the calibration rank. iOS reports 0–3 (none…high), which is not
827
- // a number of degrees, and converting one to the other would be an
828
- // invented error bar. The rank drives `confidence` below instead.
829
- headingAccuracyDeg: null
830
- },
831
- changes: {
832
- lastMeaningfulTurnDeg: turn ? turn.deg : null,
833
- timestamp: turn ? turn.timestamp : null
834
- },
835
- recentPath: [...recentPath],
836
- quality: qualityFrom(
837
- point.timestamp,
838
- spatialConfidence(point.accuracyM ?? null, heading?.calibration ?? null, derived)
839
- )
840
- };
841
- },
842
- /**
843
- * Open the warm window: position stream + compass.
844
- *
845
- * The intent is recorded but does NOT yet change sampling — no native
846
- * strategy layer exists — so it is accepted rather than refused only
847
- * because the streams it opens are real and useful.
848
- */
849
- async start(options) {
850
- if (stopWatch) return { id: sessionId, intent: options.intent, startedAt: Date.now() };
851
- sessionId = `spatial_${Date.now()}`;
852
- if (!collectors.location) throw unsupported("location collector");
853
- void startHeading();
854
- stopWatch = collectors.location.watchPosition((pos) => {
855
- const p = {
856
- latitude: pos.latitude,
857
- longitude: pos.longitude,
858
- altitude: pos.altitude ?? null,
859
- accuracyM: nonNegative(pos.accuracy),
860
- timestamp: pos.timestamp ?? Date.now()
861
- };
862
- latest2 = p;
863
- latestSpeed = nonNegative(pos.speed);
864
- latestCourse = nonNegative(pos.course);
865
- speedRef.mps = latestSpeed;
866
- speedRef.at = p.timestamp;
867
- recentPath = [...recentPath, p].slice(-RECENT_PATH_MAX);
868
- });
869
- return { id: sessionId, intent: options.intent, startedAt: Date.now() };
870
- },
871
- async stop() {
872
- stopWatch?.();
873
- stopWatch = null;
874
- sessionId = null;
875
- recentPath = [];
876
- latest2 = null;
877
- latestSpeed = null;
878
- latestCourse = null;
879
- speedRef.mps = null;
880
- speedRef.at = 0;
881
- if (!visualEngine.getSession()) stopHeading();
882
- },
883
- // No event vocabulary is frozen for spatial, and inventing names here would
884
- // put them in plugins before the freeze can contradict them.
885
- subscribe() {
886
- throw unsupported("spatial.subscribe");
887
- }
1184
+ // `available` = the app could use a camera at all. `active` = one is
1185
+ // running RIGHT NOW. They differ exactly when it matters: a pocketed
1186
+ // phone is available but not active, and answering `true` to both
1187
+ // would let a guide speak about a scene nothing is watching.
1188
+ available: !!session,
1189
+ active: visualEngine.isCameraReady(),
1190
+ facing: session ? "back" : "unknown"
888
1191
  };
889
1192
  }
890
- function spatialConfidence(accuracyM, calibration, derived) {
891
- const rank = (v) => v === "high" ? 3 : v === "medium" ? 2 : v === "low" ? 1 : 0;
892
- const fromFix = accuracyM === null ? "unknown" : accuracyM <= 10 ? "high" : accuracyM <= 50 ? "medium" : "low";
893
- const fromCompass = calibration === null ? "unknown" : calibration >= 3 ? "high" : calibration >= 2 ? "medium" : "low";
894
- const parts = [fromFix, fromCompass, ...derived ? ["low"] : []];
895
- const known = parts.filter((p) => p !== "unknown");
896
- if (!known.length) return { level: "unknown" };
897
- const worst = known.reduce((a, b) => rank(a) <= rank(b) ? a : b);
898
- return { level: worst };
899
- }
900
- function bearingBetween(a, b) {
901
- const toRad = (d) => d * Math.PI / 180;
902
- const la1 = toRad(a.latitude), la2 = toRad(b.latitude);
903
- const dLon = toRad(b.longitude - a.longitude);
904
- const y = Math.sin(dLon) * Math.cos(la2);
905
- const x = Math.cos(la1) * Math.sin(la2) - Math.sin(la1) * Math.cos(la2) * Math.cos(dLon);
906
- return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
907
- }
908
- function distanceOf(path) {
909
- const R = 6371e3;
910
- let total = 0;
911
- for (let i = 1; i < path.length; i++) {
912
- const a = path[i - 1], b = path[i];
913
- const dLat = (b.latitude - a.latitude) * Math.PI / 180;
914
- const dLon = (b.longitude - a.longitude) * Math.PI / 180;
915
- const la1 = a.latitude * Math.PI / 180, la2 = b.latitude * Math.PI / 180;
916
- const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2;
917
- total += 2 * R * Math.asin(Math.sqrt(h));
918
- }
919
- return Math.round(total);
920
- }
921
-
922
- // src/device-state/domains/visual.ts
923
1193
  function createVisualDomain() {
924
1194
  return {
925
1195
  async getSnapshot() {
926
- const session = visualEngine.getSession();
927
1196
  const latest2 = visualEngine.latest();
928
1197
  const timeline = visualEngine.timeline();
929
1198
  return {
930
- camera: {
931
- // `available` = the app could use a camera at all. `active` = one is
932
- // running RIGHT NOW. They differ exactly when it matters: a pocketed
933
- // phone is available but not active, and answering `true` to both
934
- // would let a guide speak about a scene nothing is watching.
935
- available: !!session,
936
- active: visualEngine.isCameraReady(),
937
- facing: session ? "back" : "unknown"
938
- },
1199
+ camera: cameraState(),
939
1200
  // null, not a stale frame dressed as current. A caller with no usable
940
1201
  // frame must see nothing rather than something old.
941
1202
  latest: latest2,
@@ -946,13 +1207,13 @@ function createVisualDomain() {
946
1207
  async start(options) {
947
1208
  const s = visualEngine.startSession(options?.intent || "navigation");
948
1209
  visualEngine.setContextProvider({ heading: currentHeading });
949
- void startHeading();
1210
+ void startHeading("visual");
950
1211
  return { id: s.id, intent: s.intent, startedAt: s.startedAt };
951
1212
  },
952
1213
  async stop() {
953
1214
  visualEngine.stopSession();
954
1215
  visualEngine.setContextProvider(null);
955
- stopHeading();
1216
+ stopHeading("visual");
956
1217
  },
957
1218
  /**
958
1219
  * A deliberate one-shot. Distinct from getSnapshot because it is an
@@ -983,9 +1244,20 @@ function createVisualDomain() {
983
1244
  err.code = "TIMEOUT";
984
1245
  throw err;
985
1246
  },
1247
+ /**
1248
+ * Fires when the CAMERA block changes — a session opening or closing, the
1249
+ * camera coming up or going down. The engine notifies on every frame too,
1250
+ * and forwarding that made this a raw frame stream, which the freeze's core
1251
+ * rule 4 forbids. A frame arriving is what `getSnapshot()` is for.
1252
+ */
986
1253
  subscribe(listener) {
1254
+ let last = JSON.stringify(cameraState());
987
1255
  const off = visualEngine.subscribe(() => {
988
- listener({ type: "visualStateChanged" });
1256
+ const camera = cameraState();
1257
+ const key = JSON.stringify(camera);
1258
+ if (key === last) return;
1259
+ last = key;
1260
+ listener(stateEvent("visual", "visualStateChanged", { camera }));
989
1261
  });
990
1262
  return { unsubscribe: off };
991
1263
  }
@@ -1545,11 +1817,30 @@ function createReplayDeviceState(fixture) {
1545
1817
  const refusals = new Map((fixture.refusals || []).map((r) => [r.domain, r]));
1546
1818
  const openSessions = /* @__PURE__ */ new Set();
1547
1819
  let cursor = 0;
1820
+ const spatialDetector = new SpatialEventDetector();
1821
+ const spatialListeners = listenerSet();
1822
+ const moveTo = (target) => {
1823
+ const next = Math.min(duration, Math.max(0, target));
1824
+ if (!openSessions.has("spatial")) return cursor = next;
1825
+ if (next < cursor) {
1826
+ cursor = next;
1827
+ spatialDetector.reset(void 0, turnOnRecordAt());
1828
+ return cursor;
1829
+ }
1830
+ const from = cursor;
1831
+ for (const s of samples) {
1832
+ if (s.t <= from || !s.spatial) continue;
1833
+ if (s.t > next) break;
1834
+ cursor = s.t;
1835
+ for (const event of spatialDetector.push(s.spatial, s.t)) spatialListeners.emit(event);
1836
+ }
1837
+ return cursor = next;
1838
+ };
1548
1839
  const clock = {
1549
1840
  now: () => cursor,
1550
- advance: (ms) => cursor = Math.min(duration, Math.max(0, cursor + ms)),
1551
- seek: (ms) => cursor = Math.min(duration, Math.max(0, ms)),
1552
- end: () => cursor = duration,
1841
+ advance: (ms) => moveTo(cursor + ms),
1842
+ seek: (ms) => moveTo(ms),
1843
+ end: () => moveTo(duration),
1553
1844
  durationMs: () => duration
1554
1845
  };
1555
1846
  const sampleAt = () => {
@@ -1560,22 +1851,28 @@ function createReplayDeviceState(fixture) {
1560
1851
  }
1561
1852
  return found;
1562
1853
  };
1854
+ const turnOnRecordAt = () => sampleAt()?.spatial?.changes?.timestamp ?? null;
1563
1855
  const domainFor = (name) => ({
1564
1856
  async getSnapshot() {
1565
1857
  const s = sampleAt();
1566
1858
  const recorded = s ? s[name] : void 0;
1567
1859
  return recorded ?? unknownSnapshot(name);
1568
1860
  },
1569
- async start() {
1861
+ async start(options) {
1570
1862
  const r = refusals.get(name);
1571
1863
  if (r) throw refusal(name, r.code, r.message);
1864
+ if (name === "spatial" && !openSessions.has(name)) {
1865
+ spatialDetector.reset(spatialEventProfile(options?.intent), turnOnRecordAt());
1866
+ }
1572
1867
  openSessions.add(name);
1573
1868
  return { id: `replay_${name}`, intent: "replay", startedAt: 0 };
1574
1869
  },
1575
1870
  async stop() {
1576
1871
  openSessions.delete(name);
1872
+ if (name === "spatial") spatialDetector.reset();
1577
1873
  },
1578
- subscribe() {
1874
+ subscribe(listener) {
1875
+ if (name === "spatial") return spatialListeners.add(listener);
1579
1876
  throw refusal(name, "UNSUPPORTED", `deviceState.${name}.subscribe is not replayable \u2014 advance the clock and read.`);
1580
1877
  }
1581
1878
  });