gps-plus-slam-app-framework 1.0.6 → 1.1.0

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 (198) hide show
  1. package/LICENSE +4 -4
  2. package/README.md +310 -115
  3. package/dist/accuracy-circles-DEKr0Hoh.d.ts +43 -0
  4. package/dist/{app-selectors-CHt7DLuq.d.ts → app-selectors-CR9OWodf.d.ts} +8 -10
  5. package/dist/ar/camera-blit-capture.d.ts +1 -1
  6. package/dist/ar/capability-checker.d.ts +2 -0
  7. package/dist/ar/capability-checker.js +25 -0
  8. package/dist/ar/capture-failure-tracker.d.ts +1 -1
  9. package/dist/ar/chromium-camera-access-workaround.d.ts +2 -2
  10. package/dist/ar/chromium-camera-access-workaround.js +197 -8
  11. package/dist/ar/depth-sampler.d.ts +2 -2
  12. package/dist/ar/enable-gps-ar.d.ts +2 -0
  13. package/dist/ar/enable-gps-ar.js +137 -0
  14. package/dist/ar/frame-loop.d.ts +2 -0
  15. package/dist/ar/frame-loop.js +63 -0
  16. package/dist/ar/image-capture.d.ts +1 -1
  17. package/dist/ar/image-capture.js +1 -1
  18. package/dist/ar/index.d.ts +16 -12
  19. package/dist/ar/index.js +8 -4
  20. package/dist/ar/replay-scene.d.ts +1 -1
  21. package/dist/ar/scene-node-names.d.ts +1 -1
  22. package/dist/ar/webxr-session.d.ts +4 -4
  23. package/dist/ar/webxr-session.js +173 -93
  24. package/dist/ar/xr-camera-texture.d.ts +1 -1
  25. package/dist/ar/xr-error-handler.d.ts +1 -1
  26. package/dist/ar/xr-frame-loop.d.ts +2 -0
  27. package/dist/ar/xr-frame-loop.js +69 -0
  28. package/dist/ar-world-group-alignment-Dn4rQk_c.d.ts +43 -0
  29. package/dist/capability-checker-BTCmZRL4.d.ts +43 -0
  30. package/dist/chromium-camera-access-workaround-MifIwK9x.d.ts +137 -0
  31. package/dist/core/index.d.ts +2 -2
  32. package/dist/core/index.js +2 -2
  33. package/dist/create-slam-app-store-B76AGaI0.d.ts +371 -0
  34. package/dist/{depth-sampler-BNCtnEoP.d.ts → depth-sampler-CqrkRr2N.d.ts} +1 -1
  35. package/dist/enable-gps-ar-DNbO0zbg.d.ts +95 -0
  36. package/dist/{file-system-DD5TczSO.d.ts → file-system-TIsDfamK.d.ts} +32 -2
  37. package/dist/frame-conversions-D2EYjeNa.d.ts +54 -0
  38. package/dist/frame-loop-BTeRpDm4.d.ts +47 -0
  39. package/dist/frustum-visibility-DGnJqls0.d.ts +58 -0
  40. package/dist/{fused-path-DtqCSqJh.d.ts → fused-path-BdVHmDW9.d.ts} +1 -1
  41. package/dist/geo/h3-proximity.d.ts +1 -1
  42. package/dist/geo/index.d.ts +1 -1
  43. package/dist/{geo-types-Xx-LVcnw.d.ts → geo-types-clgzl8b5.d.ts} +16 -1
  44. package/dist/{gps-GKc6_Cly.d.ts → gps-B7AlMPz5.d.ts} +1 -1
  45. package/dist/gps-anchor-CISLPQIb.d.ts +59 -0
  46. package/dist/{gps-ar-pose-sampler-D_Zso40V.d.ts → gps-ar-pose-sampler-BypXZUqC.d.ts} +1 -1
  47. package/dist/{gps-event-coordinator-fwvmVfku.d.ts → gps-event-coordinator-BKVZbS1M.d.ts} +3 -3
  48. package/dist/gps-event-markers-BlHq3jYV.d.ts +103 -0
  49. package/dist/hit-test-reticle-BGXOxrUh.d.ts +45 -0
  50. package/dist/{image-capture-2pKjYUS6.d.ts → image-capture-BXZUmj7j.d.ts} +1 -1
  51. package/dist/index--ldLph4V.d.ts +2 -0
  52. package/dist/index-22v8MXJX.d.ts +23 -0
  53. package/dist/index-BB2KEWbN2.d.ts +37 -0
  54. package/dist/index.d.ts +64 -52
  55. package/dist/index.js +21 -8
  56. package/dist/{leaflet-map-overlay-CQZcnxbc.d.ts → leaflet-map-overlay-jNt-kcUr.d.ts} +22 -11
  57. package/dist/{logger-B81iwxx0.d.ts → logger-BZ44Dhd3.d.ts} +7 -1
  58. package/dist/map-data-BGIBM5rv.d.ts +49 -0
  59. package/dist/map-overlay-draw-DcGs9UV3.d.ts +42 -0
  60. package/dist/{null-storage-backend-C0TiwwzQ.d.ts → null-storage-backend-BPQJSaMw.d.ts} +2 -2
  61. package/dist/{opfs-storage-backend-Gt3rd76D.d.ts → opfs-storage-backend-u4QKvgvP.d.ts} +2 -2
  62. package/dist/{permission-checker-Cu3nbH1S.d.ts → permission-checker-CfV7INCa.d.ts} +41 -1
  63. package/dist/persistence-middleware-xB5sTllJ.d.ts +55 -0
  64. package/dist/{recording-options-BYUFX8iU.d.ts → recording-options-BSFpBSZH.d.ts} +10 -4
  65. package/dist/{recording-replayer-BFhFs7Mc.d.ts → recording-replayer-CTL2cUE0.d.ts} +2 -2
  66. package/dist/{recording-slice-BiktUIWF.d.ts → recording-slice-Cb888P6d.d.ts} +2 -2
  67. package/dist/{replay-engine-B88Va62x.d.ts → replay-engine-B-5GIZtc.d.ts} +1 -1
  68. package/dist/{replay-scene-DtUm6bsr.d.ts → replay-scene-DkNfldPh.d.ts} +3 -3
  69. package/dist/sensors/gps-error-handler.d.ts +1 -1
  70. package/dist/sensors/gps.d.ts +1 -1
  71. package/dist/sensors/gps.js +2 -2
  72. package/dist/sensors/index.d.ts +3 -3
  73. package/dist/sensors/index.js +1 -1
  74. package/dist/sensors/permission-checker.d.ts +2 -2
  75. package/dist/sensors/permission-checker.js +79 -1
  76. package/dist/session-disposers-M-oashRH.js +57 -0
  77. package/dist/state/app-selectors.d.ts +2 -2
  78. package/dist/state/app-selectors.js +33 -3
  79. package/dist/state/combined-root-state.d.ts +2 -0
  80. package/dist/state/combined-root-state.js +1 -0
  81. package/dist/state/create-slam-app-store.d.ts +1 -1
  82. package/dist/state/create-slam-app-store.js +27 -13
  83. package/dist/state/gps-ar-pose-sampler.d.ts +3 -3
  84. package/dist/state/gps-event-coordinator.d.ts +1 -1
  85. package/dist/state/index.d.ts +17 -16
  86. package/dist/state/index.js +6 -4
  87. package/dist/state/persistence-middleware.d.ts +2 -2
  88. package/dist/state/persistence-middleware.js +43 -9
  89. package/dist/state/recording-options.d.ts +1 -1
  90. package/dist/state/recording-replayer.d.ts +2 -2
  91. package/dist/state/recording-slice.d.ts +1 -1
  92. package/dist/state/replay-engine.d.ts +1 -1
  93. package/dist/state/store-subscribers.d.ts +2 -2
  94. package/dist/state/store-subscribers.js +38 -12
  95. package/dist/state/subscribe-to-selector.d.ts +1 -1
  96. package/dist/state/tracking-quality.d.ts +2 -0
  97. package/dist/state/tracking-quality.js +723 -0
  98. package/dist/state/tracking-slice.d.ts +2 -0
  99. package/dist/state/tracking-slice.js +147 -0
  100. package/dist/state-BkIfpiyA.js +87 -0
  101. package/dist/storage/file-system-utils.d.ts +1 -1
  102. package/dist/storage/file-system.d.ts +2 -2
  103. package/dist/storage/file-system.js +52 -1
  104. package/dist/storage/index.d.ts +8 -8
  105. package/dist/storage/null-storage-backend.d.ts +1 -1
  106. package/dist/storage/opfs-storage-backend.d.ts +1 -1
  107. package/dist/storage/opfs-storage.d.ts +1 -1
  108. package/dist/storage/storage-backend.d.ts +1 -1
  109. package/dist/storage/zip-export.d.ts +1 -1
  110. package/dist/storage/zip-reader.d.ts +1 -1
  111. package/dist/storage/zip-reader.js +8 -4
  112. package/dist/{storage-backend-yVnuWGTr.d.ts → storage-backend-yDSKafAQ.d.ts} +1 -1
  113. package/dist/{store-subscribers-DKKHld49.d.ts → store-subscribers-swlbtg_z.d.ts} +31 -6
  114. package/dist/{subscribe-to-selector-ghDKYtap.d.ts → subscribe-to-selector--KQpgLXG.d.ts} +1 -1
  115. package/dist/test-utils/browser-mocks.d.ts +1 -1
  116. package/dist/test-utils/zip-round-trip-helpers.d.ts +7 -1
  117. package/dist/test-utils/zip-round-trip-helpers.js +2 -1
  118. package/dist/tracking-slice-CDREeoZ_.d.ts +86 -0
  119. package/dist/types/ar-types.d.ts +1 -1
  120. package/dist/types/geo-types.d.ts +2 -2
  121. package/dist/types/index.d.ts +3 -3
  122. package/dist/utils/concurrency.d.ts +1 -1
  123. package/dist/utils/failure-tracker.d.ts +1 -1
  124. package/dist/utils/format-file-size.d.ts +1 -1
  125. package/dist/utils/fused-path.d.ts +1 -1
  126. package/dist/utils/index.d.ts +6 -6
  127. package/dist/utils/list-formatter.d.ts +1 -1
  128. package/dist/utils/logger.d.ts +1 -1
  129. package/dist/utils/logger.js +68 -8
  130. package/dist/visualization/accuracy-circles.d.ts +2 -0
  131. package/dist/visualization/accuracy-circles.js +57 -0
  132. package/dist/visualization/alignment-lerper.d.ts +1 -1
  133. package/dist/visualization/ar-world-group-alignment.d.ts +2 -0
  134. package/dist/visualization/ar-world-group-alignment.js +51 -0
  135. package/dist/visualization/camera-follower.d.ts +1 -1
  136. package/dist/visualization/css3d-renderer-manager.d.ts +1 -1
  137. package/dist/visualization/frame-conversions.d.ts +2 -0
  138. package/dist/visualization/frame-conversions.js +88 -0
  139. package/dist/visualization/frustum-visibility.d.ts +2 -0
  140. package/dist/visualization/frustum-visibility.js +139 -0
  141. package/dist/visualization/gps-anchor.d.ts +2 -0
  142. package/dist/visualization/gps-anchor.js +217 -0
  143. package/dist/visualization/gps-compass-cubes.d.ts +1 -1
  144. package/dist/visualization/gps-event-markers.d.ts +2 -2
  145. package/dist/visualization/gps-event-markers.js +69 -2
  146. package/dist/visualization/hit-test-reticle.d.ts +2 -0
  147. package/dist/visualization/hit-test-reticle.js +81 -0
  148. package/dist/visualization/index.d.ts +19 -12
  149. package/dist/visualization/index.js +9 -2
  150. package/dist/visualization/leaflet-map-overlay.d.ts +1 -1
  151. package/dist/visualization/leaflet-map-overlay.js +33 -79
  152. package/dist/visualization/lerp-utils.d.ts +1 -1
  153. package/dist/visualization/map-data.d.ts +2 -0
  154. package/dist/visualization/map-data.js +33 -0
  155. package/dist/visualization/map-overlay-draw.d.ts +2 -0
  156. package/dist/visualization/map-overlay-draw.js +107 -0
  157. package/dist/visualization/map-overlay.d.ts +1 -1
  158. package/dist/visualization/three-dispose.d.ts +1 -1
  159. package/dist/visualization/vis-colors.d.ts +1 -1
  160. package/dist/webxr-nue-basis-BbdqhqY6.js +54 -0
  161. package/dist/{webxr-session-COlSrXdL.d.ts → webxr-session-BnArFCew.d.ts} +76 -14
  162. package/dist/xr-frame-loop-BKckC7xC.d.ts +70 -0
  163. package/dist/{zip-reader-KgJEXbca.d.ts → zip-reader-B2lzN8F5.d.ts} +5 -0
  164. package/package.json +16 -16
  165. package/dist/ar/tracking-state.d.ts +0 -2
  166. package/dist/ar/tracking-state.js +0 -164
  167. package/dist/chromium-camera-access-workaround-CU5zSKNr.d.ts +0 -61
  168. package/dist/combined-root-state-B3SvPNjU.d.ts +0 -6
  169. package/dist/create-slam-app-store-DBm7g2Zd.d.ts +0 -85
  170. package/dist/gps-anchored-mesh-manager-CovAFK4g.d.ts +0 -56
  171. package/dist/gps-event-markers-sVyO9utS.d.ts +0 -67
  172. package/dist/persistence-middleware-z-75iisb.d.ts +0 -31
  173. package/dist/tracking-state-B9QfuZCg.d.ts +0 -101
  174. package/dist/visualization/gps-anchored-mesh-manager.d.ts +0 -2
  175. package/dist/visualization/gps-anchored-mesh-manager.js +0 -128
  176. /package/dist/{alignment-lerper-D9BeDwZX.d.ts → alignment-lerper-CCZJf-Xb.d.ts} +0 -0
  177. /package/dist/{ar-types-B-ORgk6Z.d.ts → ar-types-isPsQptb.d.ts} +0 -0
  178. /package/dist/{camera-blit-capture-DHuyrrxr.d.ts → camera-blit-capture-75WWa9Xb.d.ts} +0 -0
  179. /package/dist/{camera-follower-BMqPddxx.d.ts → camera-follower-B-nS1sr6.d.ts} +0 -0
  180. /package/dist/{capture-failure-tracker-2Q6oI2uQ.d.ts → capture-failure-tracker-D4HYJNOj.d.ts} +0 -0
  181. /package/dist/{concurrency-p6gB30fT.d.ts → concurrency-Bsmv53qw.d.ts} +0 -0
  182. /package/dist/{css3d-renderer-manager-rgLMVAvd.d.ts → css3d-renderer-manager-EDED3nvg.d.ts} +0 -0
  183. /package/dist/{failure-tracker-1sQ_Cgyw.d.ts → failure-tracker-D7ELffm0.d.ts} +0 -0
  184. /package/dist/{file-system-utils-BH8FdyIu.d.ts → file-system-utils-BH6uIs2i.d.ts} +0 -0
  185. /package/dist/{format-file-size-onGq0k1Q.d.ts → format-file-size-B7gJb3Md.d.ts} +0 -0
  186. /package/dist/{gps-compass-cubes-jRIJMOuh.d.ts → gps-compass-cubes-Ys6Hf1nc.d.ts} +0 -0
  187. /package/dist/{gps-error-handler-CH1S528z.d.ts → gps-error-handler-BLDhUlw5.d.ts} +0 -0
  188. /package/dist/{h3-proximity-GwesPTFT.d.ts → h3-proximity-DJnghtir.d.ts} +0 -0
  189. /package/dist/{lerp-utils-CZgQlrmu.d.ts → lerp-utils-DNcSmKVX.d.ts} +0 -0
  190. /package/dist/{list-formatter-DKQQVLcl.d.ts → list-formatter-CsqcU4v5.d.ts} +0 -0
  191. /package/dist/{map-overlay-C9ifl_4-.d.ts → map-overlay-yrFsUtGv.d.ts} +0 -0
  192. /package/dist/{opfs-storage-Dlq1I_Gb.d.ts → opfs-storage-LsLY6VZV.d.ts} +0 -0
  193. /package/dist/{scene-node-names-DPsaE2z3.d.ts → scene-node-names-IHW7HR4S.d.ts} +0 -0
  194. /package/dist/{three-dispose-qt_r5wR1.d.ts → three-dispose-WTBAfaWU.d.ts} +0 -0
  195. /package/dist/{vis-colors-BKkdTIKb.d.ts → vis-colors-Djnyg_UH.d.ts} +0 -0
  196. /package/dist/{xr-camera-texture-BbukNE79.d.ts → xr-camera-texture-BU5YvKAM.d.ts} +0 -0
  197. /package/dist/{xr-error-handler-DTHfmvrl.d.ts → xr-error-handler-DcUFshNF.d.ts} +0 -0
  198. /package/dist/{zip-export-BjrQPv0X.d.ts → zip-export-C0_ErAqT.d.ts} +0 -0
@@ -0,0 +1,723 @@
1
+ import { selectLastSensorOrientation, selectLastValidPose, selectTrackingPhase } from "./tracking-slice.js";
2
+ import { selectAlignmentMatrix, selectGpsPositions, selectOdometryPositions, selectZeroReference } from "./app-selectors.js";
3
+ import { calcGpsCoords, distanceInMeters } from "gps-plus-slam-js";
4
+ import { createListenerMiddleware, createSlice } from "@reduxjs/toolkit";
5
+ import { mat4, quat, vec3 } from "gl-matrix";
6
+ //#region ../src/state/tracking-quality.ts
7
+ const DEFAULT_TRACKING_QUALITY_OPTIONS = {
8
+ matrixHistorySize: 5,
9
+ residualWindowSize: 16,
10
+ degradedThreshold: .5,
11
+ gpsAccuracyWindowSize: 30,
12
+ coverageWalkedDistanceM: 15,
13
+ coverageDirectionSpreadDeg: 90,
14
+ convergenceRotationWarnDeg: 6,
15
+ convergenceTranslationWarnM: 2,
16
+ compassWarnDeg: 15,
17
+ compassFailDeg: 35,
18
+ warmupMinObservations: 10,
19
+ warmupMinCoverage: .5,
20
+ residualConfidenceTargetM: 3,
21
+ gpsAccuracyFloorM: 1,
22
+ firstAgreementMinStreak: 3,
23
+ degradedHoldoff: 3,
24
+ convergenceEmaAlpha: .3
25
+ };
26
+ const initialState = {
27
+ recentAlignments: [],
28
+ firstAgreementObservationIndex: null,
29
+ report: null,
30
+ degradedConsecutiveCount: 0,
31
+ smoothedConvergence: null
32
+ };
33
+ function clamp01(x) {
34
+ if (!Number.isFinite(x)) return 0;
35
+ if (x < 0) return 0;
36
+ if (x > 1) return 1;
37
+ return x;
38
+ }
39
+ /**
40
+ * §4.8b (Finding 4) — exponential moving-average blend. When `prev` is
41
+ * `null` the filter has no history yet and we seed it to `raw`.
42
+ * `alpha = 1` disables smoothing (returns `raw`); `alpha = 0` freezes
43
+ * the filter at `prev`. Non-finite `alpha` is treated as 1 (no
44
+ * smoothing) to fail safe — never silently swallow a valid raw signal.
45
+ */
46
+ function emaBlend(prev, raw, alpha) {
47
+ if (prev === null) return raw;
48
+ return prev + (Number.isFinite(alpha) ? alpha : 1) * (raw - prev);
49
+ }
50
+ function wrapToHalfCircle(deg) {
51
+ let d = (deg + 180) % 360 - 180;
52
+ if (d < -180) d += 360;
53
+ return d;
54
+ }
55
+ function bearingDeg(north, east) {
56
+ let deg = Math.atan2(east, north) * 180 / Math.PI;
57
+ if (deg < 0) deg += 360;
58
+ return deg;
59
+ }
60
+ function median(values) {
61
+ if (values.length === 0) return 0;
62
+ const sorted = [...values].sort((a, b) => a - b);
63
+ const mid = Math.floor(sorted.length / 2);
64
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
65
+ }
66
+ /**
67
+ * Linear ramp: `value ≤ low` → 1, `value ≥ high` → 0, linear between.
68
+ * Used by sub-scores whose underlying physical quantity grows as
69
+ * quality drops (e.g. residuals, heading delta).
70
+ */
71
+ function rampDown(value, low, high) {
72
+ if (!Number.isFinite(value)) return 0;
73
+ if (high <= low) return value <= low ? 1 : 0;
74
+ if (value <= low) return 1;
75
+ if (value >= high) return 0;
76
+ return 1 - (value - low) / (high - low);
77
+ }
78
+ /**
79
+ * Linear ramp: `value ≤ low` → 0, `value ≥ high` → 1, linear between.
80
+ * Used by sub-scores whose underlying quantity grows as quality
81
+ * improves (e.g. walked distance, direction spread).
82
+ */
83
+ function rampUp(value, low, high) {
84
+ if (!Number.isFinite(value)) return 0;
85
+ if (high <= low) return value >= high ? 1 : 0;
86
+ if (value <= low) return 0;
87
+ if (value >= high) return 1;
88
+ return (value - low) / (high - low);
89
+ }
90
+ /**
91
+ * §4.1 matrix-history convergence over the buffered snapshots.
92
+ *
93
+ * Reuses the same kernel idea as `computeStabilityDelta` from the
94
+ * Investigation helpers (rotation + translation delta between two 4×4
95
+ * matrices). With 0 or 1 snapshot we return score 0 — convergence is
96
+ * undefined until at least one consecutive pair exists.
97
+ *
98
+ * Finding 6 (2026-05-23 field test): the per-pair `max` aggregation was
99
+ * replaced with a **sum** across the window. `max` is a burst detector
100
+ * — dominated by one bad pair, blind to slow drift; `sum` answers
101
+ * "how much alignment motion accumulated over the window?" and exposes
102
+ * slow creeping drift in user-readable units (°, m). The HUD now shows
103
+ * the raw sums (`ΣΔrot:`, `ΣΔpos:`) alongside the EMA-smoothed `Conv:`
104
+ * sub-score (Finding 4) so the user can debug an unstable reading.
105
+ */
106
+ function computeConvergence(snapshots, options = {}) {
107
+ const rotWarn = options.rotationWarnDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.convergenceRotationWarnDeg;
108
+ const transWarn = options.translationWarnM ?? DEFAULT_TRACKING_QUALITY_OPTIONS.convergenceTranslationWarnM;
109
+ const rotFail = rotWarn * 4;
110
+ const transFail = transWarn * 4;
111
+ if (snapshots.length < 2) return {
112
+ score: 0,
113
+ recentSumRotationDeltaDeg: 0,
114
+ recentSumTranslationDeltaM: 0,
115
+ pairCount: 0
116
+ };
117
+ let sumRotDeg = 0;
118
+ let sumTransM = 0;
119
+ let pairCount = 0;
120
+ for (let i = 1; i < snapshots.length; i++) {
121
+ const { rotationDeltaDeg, translationDeltaM } = matrixDelta(snapshots[i - 1].matrix, snapshots[i].matrix);
122
+ sumRotDeg += Math.abs(rotationDeltaDeg);
123
+ sumTransM += Math.abs(translationDeltaM);
124
+ pairCount += 1;
125
+ }
126
+ const rotScore = rampDown(sumRotDeg, rotWarn, rotFail);
127
+ const transScore = rampDown(sumTransM, transWarn, transFail);
128
+ return {
129
+ score: Math.min(rotScore, transScore),
130
+ recentSumRotationDeltaDeg: sumRotDeg,
131
+ recentSumTranslationDeltaM: sumTransM,
132
+ pairCount
133
+ };
134
+ }
135
+ /**
136
+ * Rotation and translation delta between two 4×4 alignment matrices in
137
+ * the column-major layout that gl-matrix and the framework use.
138
+ *
139
+ * Rotation: extract the orientation quaternion with `mat4.getRotation`
140
+ * and compute the relative angle via `quat.getAngle` (degrees).
141
+ * Translation: extract the origin column with `mat4.getTranslation` and
142
+ * compute the Euclidean distance.
143
+ *
144
+ * This is the **single shared kernel** for the AppFramework reporter and
145
+ * the Investigation harness (see §11 (a) of the tracking-quality plan).
146
+ * `GpsPlusSlamJs_Investigation/src/investigation-helpers.ts` re-exports
147
+ * `computeStabilityDelta` as a thin wrapper around this function so the
148
+ * §6.1 corpus sweep and the runtime convergence score share one numeric
149
+ * definition.
150
+ */
151
+ function matrixDelta(prev, curr) {
152
+ if (prev.length !== 16 || curr.length !== 16) return {
153
+ rotationDeltaDeg: 0,
154
+ translationDeltaM: 0
155
+ };
156
+ const prevMat = prev;
157
+ const currMat = curr;
158
+ const prevQuat = quat.create();
159
+ const currQuat = quat.create();
160
+ mat4.getRotation(prevQuat, prevMat);
161
+ mat4.getRotation(currQuat, currMat);
162
+ quat.normalize(prevQuat, prevQuat);
163
+ quat.normalize(currQuat, currQuat);
164
+ const angleRad = quat.getAngle(prevQuat, currQuat);
165
+ const rotationDeltaDeg = Number.isNaN(angleRad) ? 0 : angleRad * 180 / Math.PI;
166
+ const prevT = vec3.create();
167
+ const currT = vec3.create();
168
+ mat4.getTranslation(prevT, prevMat);
169
+ mat4.getTranslation(currT, currMat);
170
+ return {
171
+ rotationDeltaDeg,
172
+ translationDeltaM: vec3.distance(prevT, currT)
173
+ };
174
+ }
175
+ /**
176
+ * §4.2 — Median residual between the last `K` GPS observations and the
177
+ * positions you would have predicted by transforming the matching
178
+ * odometry positions through the current alignment matrix.
179
+ *
180
+ * Returns `score = 0`, `medianResidualM = 0` when we have fewer than
181
+ * 2 paired observations or when the alignment matrix / zero ref is
182
+ * not yet set.
183
+ */
184
+ function computeResidualConsensus(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, options = {
185
+ residualWindowSize: DEFAULT_TRACKING_QUALITY_OPTIONS.residualWindowSize,
186
+ residualConfidenceTargetM: DEFAULT_TRACKING_QUALITY_OPTIONS.residualConfidenceTargetM,
187
+ gpsAccuracyFloorM: DEFAULT_TRACKING_QUALITY_OPTIONS.gpsAccuracyFloorM
188
+ }) {
189
+ if (!alignmentMatrix || !zeroRef) return {
190
+ score: 0,
191
+ medianResidualM: 0,
192
+ count: 0
193
+ };
194
+ const n = Math.min(gpsPositions.length, odometryPositions.length);
195
+ if (n < 2) return {
196
+ score: 0,
197
+ medianResidualM: 0,
198
+ count: 0
199
+ };
200
+ const start = Math.max(0, n - options.residualWindowSize);
201
+ const glMat = alignmentMatrix;
202
+ const tmp = vec3.create();
203
+ const normalised = [];
204
+ let rawResidualSum = 0;
205
+ let rawResidualCount = 0;
206
+ for (let i = start; i < n; i++) {
207
+ const odom = odometryPositions[i];
208
+ const gps = gpsPositions[i];
209
+ vec3.set(tmp, odom[0], odom[1], odom[2]);
210
+ vec3.transformMat4(tmp, tmp, glMat);
211
+ const meters = distanceInMeters(calcGpsCoords(zeroRef, tmp), {
212
+ lat: gps.latitude,
213
+ lon: gps.longitude
214
+ });
215
+ if (!Number.isFinite(meters)) continue;
216
+ rawResidualSum += meters;
217
+ rawResidualCount += 1;
218
+ const acc = Math.max(options.gpsAccuracyFloorM, gps.latLongAccuracy ?? options.gpsAccuracyFloorM);
219
+ normalised.push(meters / acc);
220
+ }
221
+ if (normalised.length < 2) return {
222
+ score: 0,
223
+ medianResidualM: rawResidualCount > 0 ? rawResidualSum / rawResidualCount : 0,
224
+ count: normalised.length
225
+ };
226
+ const medianNorm = median(normalised);
227
+ const medianResidualM = rawResidualSum / rawResidualCount;
228
+ return {
229
+ score: clamp01(1 / (1 + medianNorm / options.residualConfidenceTargetM)),
230
+ medianResidualM,
231
+ count: normalised.length
232
+ };
233
+ }
234
+ /**
235
+ * §4.4 — Median `latLongAccuracy` of the last `K` GPS points, with
236
+ * an "accurate-fix count" diagnostic. Cheap, always available.
237
+ *
238
+ * `score` ramps from 1.0 at median ≤ 3 m to 0.0 at median ≥ 25 m
239
+ * (linear), matching the plan's "≤ 5 m is good" heuristic with a
240
+ * generous fail tail.
241
+ */
242
+ function computeGpsAccuracy(gpsPositions, options = {}) {
243
+ const windowSize = options.windowSize ?? DEFAULT_TRACKING_QUALITY_OPTIONS.gpsAccuracyWindowSize;
244
+ const goodMedianM = options.goodMedianM ?? 3;
245
+ const badMedianM = options.badMedianM ?? 25;
246
+ if (gpsPositions.length === 0) return {
247
+ score: 0,
248
+ medianM: 0,
249
+ countAccurate: 0,
250
+ countTotal: 0
251
+ };
252
+ const start = Math.max(0, gpsPositions.length - windowSize);
253
+ const accs = [];
254
+ let countAccurate = 0;
255
+ for (let i = start; i < gpsPositions.length; i++) {
256
+ const acc = gpsPositions[i].latLongAccuracy;
257
+ if (typeof acc === "number" && Number.isFinite(acc)) {
258
+ accs.push(acc);
259
+ if (acc <= 5) countAccurate += 1;
260
+ }
261
+ }
262
+ if (accs.length === 0) return {
263
+ score: 0,
264
+ medianM: 0,
265
+ countAccurate: 0,
266
+ countTotal: gpsPositions.length - start
267
+ };
268
+ const medianM = median(accs);
269
+ return {
270
+ score: rampDown(medianM, goodMedianM, badMedianM),
271
+ medianM,
272
+ countAccurate,
273
+ countTotal: accs.length
274
+ };
275
+ }
276
+ /**
277
+ * §4.5 — Coverage = how far the user has walked AND how diverse the
278
+ * walking direction has been. Both required: a long but unidirectional
279
+ * walk leaves the rotation about the walking axis under-determined.
280
+ */
281
+ function computeCoverage(odometryPositions, options = {}) {
282
+ const wThr = options.walkedDistanceM ?? DEFAULT_TRACKING_QUALITY_OPTIONS.coverageWalkedDistanceM;
283
+ const dThr = options.directionSpreadDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.coverageDirectionSpreadDeg;
284
+ if (odometryPositions.length < 2) return {
285
+ score: 0,
286
+ walkedDistanceM: 0,
287
+ directionSpreadDeg: 0
288
+ };
289
+ let walked = 0;
290
+ const bearings = [];
291
+ for (let i = 1; i < odometryPositions.length; i++) {
292
+ const a = odometryPositions[i - 1];
293
+ const b = odometryPositions[i];
294
+ const dN = b[0] - a[0];
295
+ const dE = b[2] - a[2];
296
+ const segLen = Math.hypot(dN, dE);
297
+ if (!Number.isFinite(segLen) || segLen < 1e-4) continue;
298
+ walked += segLen;
299
+ bearings.push(bearingDeg(dN, dE));
300
+ }
301
+ let spread = 0;
302
+ if (bearings.length >= 2) {
303
+ const sorted = [...bearings].sort((a, b) => a - b);
304
+ let maxGap = 0;
305
+ for (let i = 1; i < sorted.length; i++) {
306
+ const gap = sorted[i] - sorted[i - 1];
307
+ if (gap > maxGap) maxGap = gap;
308
+ }
309
+ const wrapGap = 360 - sorted[sorted.length - 1] + sorted[0];
310
+ if (wrapGap > maxGap) maxGap = wrapGap;
311
+ spread = Math.max(0, 360 - maxGap);
312
+ }
313
+ const walkScore = rampUp(walked, wThr * .25, wThr);
314
+ const spreadScore = rampUp(spread, dThr * .25, dThr);
315
+ return {
316
+ score: Math.min(walkScore, spreadScore),
317
+ walkedDistanceM: walked,
318
+ directionSpreadDeg: spread
319
+ };
320
+ }
321
+ /**
322
+ * §4.3 — Compare the alignment matrix's mapping of AR-forward to ENU
323
+ * against the device compass heading. Returns `score = null` whenever
324
+ * the sensor's `absolute !== true` so the aggregate excludes the
325
+ * sub-score (per Q2 in the plan).
326
+ *
327
+ * AR-forward in our `ARPose` convention is `(0, 0, -1)` (right-handed,
328
+ * Y-up). We rotate that by the alignment's 3×3 rotation, take its
329
+ * North/East components, and compare against the compass alpha.
330
+ */
331
+ function computeCompassAgreement(alignmentMatrix, sensorOrientation, arPose, options = {}) {
332
+ const warn = options.warnDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.compassWarnDeg;
333
+ const fail = options.failDeg ?? DEFAULT_TRACKING_QUALITY_OPTIONS.compassFailDeg;
334
+ if (!alignmentMatrix || !sensorOrientation || !arPose) return {
335
+ score: null,
336
+ headingDeltaDeg: null
337
+ };
338
+ if (sensorOrientation.absolute !== true) return {
339
+ score: null,
340
+ headingDeltaDeg: null
341
+ };
342
+ const q = arPose.orientation;
343
+ const fx = 0;
344
+ const fy = 0;
345
+ const fz = -1;
346
+ const x = q.x, y = q.y, z = q.z, w = q.w;
347
+ const tx = 2 * (y * fz - z * fy);
348
+ const ty = 2 * (z * fx - x * fz);
349
+ const tz = 2 * (x * fy - y * fx);
350
+ const arForwardLocal = [
351
+ fx + w * tx + (y * tz - z * ty),
352
+ fy + w * ty + (z * tx - x * tz),
353
+ fz + w * tz + (x * ty - y * tx)
354
+ ];
355
+ const m = alignmentMatrix;
356
+ const enuN = m[0] * arForwardLocal[0] + m[4] * arForwardLocal[1] + m[8] * arForwardLocal[2];
357
+ m[1] * arForwardLocal[0] + m[5] * arForwardLocal[1] + m[9] * arForwardLocal[2];
358
+ const enuE = m[2] * arForwardLocal[0] + m[6] * arForwardLocal[1] + m[10] * arForwardLocal[2];
359
+ if (Math.hypot(enuN, enuE) < 1e-6) return {
360
+ score: null,
361
+ headingDeltaDeg: null
362
+ };
363
+ const alignmentHeadingDeg = bearingDeg(enuN, enuE);
364
+ const compassHeadingDeg = sensorOrientation.alpha;
365
+ const delta = Math.abs(wrapToHalfCircle(alignmentHeadingDeg - compassHeadingDeg));
366
+ return {
367
+ score: rampDown(delta, warn, fail),
368
+ headingDeltaDeg: delta
369
+ };
370
+ }
371
+ /**
372
+ * §4.6 — Maximum lateral distance between raw GPS positions and the
373
+ * positions you would predict by transforming the matching odometry
374
+ * positions through the current alignment. Diagnostic only — never
375
+ * feeds the aggregate confidence per the plan.
376
+ */
377
+ function computeGpsVsFusedDivergence(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, windowSize = 16) {
378
+ if (!alignmentMatrix || !zeroRef) return 0;
379
+ const n = Math.min(gpsPositions.length, odometryPositions.length);
380
+ if (n < 2) return 0;
381
+ const start = Math.max(0, n - windowSize);
382
+ const glMat = alignmentMatrix;
383
+ const tmp = vec3.create();
384
+ let maxDiv = 0;
385
+ for (let i = start; i < n; i++) {
386
+ const odom = odometryPositions[i];
387
+ vec3.set(tmp, odom[0], odom[1], odom[2]);
388
+ vec3.transformMat4(tmp, tmp, glMat);
389
+ const d = distanceInMeters(calcGpsCoords(zeroRef, tmp), {
390
+ lat: gpsPositions[i].latitude,
391
+ lon: gpsPositions[i].longitude
392
+ });
393
+ if (Number.isFinite(d) && d > maxDiv) maxDiv = d;
394
+ }
395
+ return maxDiv;
396
+ }
397
+ const trackingQualitySlice = createSlice({
398
+ name: "trackingQuality",
399
+ initialState,
400
+ reducers: {
401
+ /**
402
+ * Push a new alignment snapshot. Idempotent: callers must only
403
+ * dispatch when the matrix has actually changed (the listener
404
+ * middleware enforces this).
405
+ */
406
+ snapshotPushed(state, action) {
407
+ state.recentAlignments.push(action.payload);
408
+ },
409
+ /**
410
+ * Drop oldest snapshots until `length ≤ size`. Separate action so
411
+ * the buffer cap can be applied after `snapshotPushed` without
412
+ * the reducer needing to know the option value.
413
+ */
414
+ snapshotsTrimmed(state, action) {
415
+ const size = Math.max(1, action.payload.size | 0);
416
+ while (state.recentAlignments.length > size) state.recentAlignments.shift();
417
+ },
418
+ reportUpdated(state, action) {
419
+ state.report = action.payload;
420
+ },
421
+ firstAgreementReached(state, action) {
422
+ state.firstAgreementObservationIndex = action.payload;
423
+ },
424
+ /** §4.8 — update the hysteresis counter for ok → degraded holdoff. */
425
+ degradedCountUpdated(state, action) {
426
+ state.degradedConsecutiveCount = action.payload;
427
+ },
428
+ /**
429
+ * §4.8b (Finding 4) — persist the latest EMA-smoothed convergence so
430
+ * the next aggregator pass can blend against it. `null` resets the
431
+ * filter (next pass seeds from raw).
432
+ */
433
+ smoothedConvergenceUpdated(state, action) {
434
+ state.smoothedConvergence = action.payload;
435
+ },
436
+ /** Full reset — used on new session start / tracking reset. */
437
+ resetTrackingQuality() {
438
+ return initialState;
439
+ }
440
+ }
441
+ });
442
+ const { snapshotPushed, snapshotsTrimmed, reportUpdated, firstAgreementReached, degradedCountUpdated, resetTrackingQuality } = trackingQualitySlice.actions;
443
+ const { smoothedConvergenceUpdated } = trackingQualitySlice.actions;
444
+ const trackingQualityReducer = trackingQualitySlice.reducer;
445
+ function selectTrackingQuality(state) {
446
+ return state.trackingQuality?.report ?? null;
447
+ }
448
+ function selectRecentAlignments(state) {
449
+ return state.trackingQuality?.recentAlignments ?? EMPTY_SNAPSHOTS;
450
+ }
451
+ function selectFirstAgreementObservationIndex(state) {
452
+ return state.trackingQuality?.firstAgreementObservationIndex ?? null;
453
+ }
454
+ const EMPTY_SNAPSHOTS = Object.freeze([]);
455
+ /**
456
+ * Pure function — produces a {@link TrackingQualityReport} from a root
457
+ * state and options. The Investigation harness imports this directly to
458
+ * sweep `(N, K)` without going through the Redux listener middleware.
459
+ *
460
+ * The report's `state` enum follows §5 of the plan:
461
+ * - `ar-lost` whenever `tracking.phase !== 'tracking'`
462
+ * - `warming-up` until `coverage ≥ warmupMinCoverage` AND
463
+ * `observationsSeen ≥ warmupMinObservations`
464
+ * - then `ok` / `degraded` from `min(subScores)` vs.
465
+ * `degradedThreshold`.
466
+ */
467
+ function computeTrackingQualityReport(rootState, options = {}) {
468
+ const opts = {
469
+ ...DEFAULT_TRACKING_QUALITY_OPTIONS,
470
+ ...options
471
+ };
472
+ const alignmentMatrix = selectAlignmentMatrix(rootState);
473
+ const gpsPositions = selectGpsPositions(rootState);
474
+ const odometryPositions = selectOdometryPositions(rootState);
475
+ const zeroRef = selectZeroReference(rootState);
476
+ const trackingPhase = selectTrackingPhase(rootState);
477
+ const sensorOrientation = selectLastSensorOrientation(rootState);
478
+ const lastPose = selectLastValidPose(rootState);
479
+ const snapshots = selectRecentAlignments(rootState);
480
+ const firstAgreementIdx = selectFirstAgreementObservationIndex(rootState);
481
+ const coverage = computeCoverage(odometryPositions, {
482
+ walkedDistanceM: opts.coverageWalkedDistanceM,
483
+ directionSpreadDeg: opts.coverageDirectionSpreadDeg
484
+ });
485
+ const gpsAccuracy = computeGpsAccuracy(gpsPositions, { windowSize: opts.gpsAccuracyWindowSize });
486
+ const residual = computeResidualConsensus(alignmentMatrix, gpsPositions, odometryPositions, zeroRef, {
487
+ residualWindowSize: opts.residualWindowSize,
488
+ residualConfidenceTargetM: opts.residualConfidenceTargetM,
489
+ gpsAccuracyFloorM: opts.gpsAccuracyFloorM
490
+ });
491
+ const convergence = computeConvergence(snapshots, {
492
+ rotationWarnDeg: opts.convergenceRotationWarnDeg,
493
+ translationWarnM: opts.convergenceTranslationWarnM
494
+ });
495
+ const compass = computeCompassAgreement(alignmentMatrix, sensorOrientation, lastPose, {
496
+ warnDeg: opts.compassWarnDeg,
497
+ failDeg: opts.compassFailDeg
498
+ });
499
+ const gpsVsFusedMaxDivergenceM = computeGpsVsFusedDivergence(alignmentMatrix, gpsPositions, odometryPositions, zeroRef);
500
+ const subScores = {
501
+ convergence: clamp01(emaBlend(rootState.trackingQuality?.smoothedConvergence ?? null, convergence.score, opts.convergenceEmaAlpha)),
502
+ residualConsensus: clamp01(residual.score),
503
+ compassAgreement: compass.score === null ? null : clamp01(compass.score),
504
+ gpsAccuracy: clamp01(gpsAccuracy.score),
505
+ coverage: clamp01(coverage.score)
506
+ };
507
+ const aggregateInputs = [
508
+ subScores.convergence,
509
+ subScores.residualConsensus,
510
+ subScores.gpsAccuracy,
511
+ subScores.coverage
512
+ ];
513
+ if (subScores.compassAgreement !== null) aggregateInputs.push(subScores.compassAgreement);
514
+ const confidence = clamp01(Math.min(...aggregateInputs));
515
+ const observationsSeen = gpsPositions.length;
516
+ let state;
517
+ if (trackingPhase !== "tracking") state = "ar-lost";
518
+ else if (subScores.coverage < opts.warmupMinCoverage || observationsSeen < opts.warmupMinObservations) state = "warming-up";
519
+ else if (confidence < opts.degradedThreshold) state = "degraded";
520
+ else state = "ok";
521
+ return {
522
+ state,
523
+ confidence: state === "ar-lost" ? 0 : confidence,
524
+ subScores,
525
+ diagnostics: {
526
+ recentSumRotationDeltaDeg: convergence.recentSumRotationDeltaDeg,
527
+ recentSumTranslationDeltaM: convergence.recentSumTranslationDeltaM,
528
+ medianResidualM: residual.medianResidualM,
529
+ medianRecentGpsAccuracyM: gpsAccuracy.medianM,
530
+ walkedDistanceM: coverage.walkedDistanceM,
531
+ directionSpreadDeg: coverage.directionSpreadDeg,
532
+ headingDeltaDeg: compass.headingDeltaDeg,
533
+ compassDriftDetected: firstAgreementIdx !== null && compass.headingDeltaDeg !== null && compass.headingDeltaDeg > opts.compassWarnDeg,
534
+ observationsSeen,
535
+ gpsVsFusedMaxDivergenceM
536
+ }
537
+ };
538
+ }
539
+ /**
540
+ * Per-field tolerances for the {@link reportsEqual} dispatch gate.
541
+ *
542
+ * Why this exists: `tracking/poseReceived` fires on *every* XR frame
543
+ * (30–60 fps). The §4.3 compass cross-check consumes the live AR pose
544
+ * and sensor heading, so `subScores.compassAgreement` and
545
+ * `diagnostics.headingDeltaDeg` (and, when compass is the minimum,
546
+ * `confidence`) jitter by imperceptible floating-point amounts on a
547
+ * held-still device. With a strict `!==` comparison every such frame
548
+ * produced a fresh `reportUpdated` dispatch → high Redux churn and a
549
+ * HUD re-render at frame rate. The tolerances below quantise those
550
+ * sub-perceptual changes so the gate only fires when the user-visible
551
+ * quality actually moved.
552
+ *
553
+ * The gate compares the freshly computed report against the *last
554
+ * dispatched* report (the stored `prev`), not against the previous
555
+ * frame, so slow real drift cannot accumulate indefinitely: once it
556
+ * crosses a tolerance a dispatch fires and re-baselines. Tolerances are
557
+ * chosen below the smallest user-meaningful step in the HUD:
558
+ * - scores/confidence live in 0..1 → 1e-3 (0.1 %).
559
+ * - angle diagnostics are shown in whole/tenths of a degree → 0.01°.
560
+ * - metre diagnostics are shown in centimetres at best → 1 mm.
561
+ */
562
+ const REPORT_SCORE_EPSILON = .001;
563
+ const REPORT_ANGLE_EPSILON_DEG = .01;
564
+ const REPORT_METRE_EPSILON_M = .001;
565
+ /**
566
+ * Tolerant scalar comparison that treats `null` as a distinct value
567
+ * (both `null` ⇒ equal; exactly one `null` ⇒ different) and otherwise
568
+ * compares with an absolute epsilon. Non-finite values are compared with
569
+ * {@link Object.is}, so a genuine finite↔NaN/Infinity transition still
570
+ * fires a dispatch, but a *persistently* NaN diagnostic (the only
571
+ * unguarded path is a NaN compass `alpha` leaking into
572
+ * `diagnostics.headingDeltaDeg`) does NOT churn the gate every frame —
573
+ * `Object.is(NaN, NaN)` is `true` whereas `NaN === NaN` is `false`.
574
+ */
575
+ function nearlyEqual(a, b, eps) {
576
+ if (a === null || b === null) return a === b;
577
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return Object.is(a, b);
578
+ return Math.abs(a - b) <= eps;
579
+ }
580
+ /**
581
+ * Deep-equal that only inspects the public shape of a
582
+ * {@link TrackingQualityReport}. Cheaper than a generic deep-equal
583
+ * because it knows the schema; gates `reportUpdated` dispatches so
584
+ * subscribers wake up exactly when the user-visible quality changes.
585
+ *
586
+ * Float fields use the per-field tolerances above
587
+ * ({@link REPORT_SCORE_EPSILON} et al.) so imperceptible per-frame
588
+ * compass jitter does not churn the store — see the constants' doc
589
+ * comment for the rationale.
590
+ */
591
+ function reportsEqual(a, b) {
592
+ if (a === b) return true;
593
+ if (!a || !b) return false;
594
+ if (a.state !== b.state) return false;
595
+ if (!nearlyEqual(a.confidence, b.confidence, REPORT_SCORE_EPSILON)) return false;
596
+ const sa = a.subScores;
597
+ const sb = b.subScores;
598
+ if (!nearlyEqual(sa.convergence, sb.convergence, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.residualConsensus, sb.residualConsensus, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.compassAgreement, sb.compassAgreement, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.gpsAccuracy, sb.gpsAccuracy, REPORT_SCORE_EPSILON) || !nearlyEqual(sa.coverage, sb.coverage, REPORT_SCORE_EPSILON)) return false;
599
+ const da = a.diagnostics;
600
+ const db = b.diagnostics;
601
+ return da.compassDriftDetected === db.compassDriftDetected && da.observationsSeen === db.observationsSeen && nearlyEqual(da.recentSumRotationDeltaDeg, db.recentSumRotationDeltaDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.directionSpreadDeg, db.directionSpreadDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.headingDeltaDeg, db.headingDeltaDeg, REPORT_ANGLE_EPSILON_DEG) && nearlyEqual(da.recentSumTranslationDeltaM, db.recentSumTranslationDeltaM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.medianResidualM, db.medianResidualM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.medianRecentGpsAccuracyM, db.medianRecentGpsAccuracyM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.walkedDistanceM, db.walkedDistanceM, REPORT_METRE_EPSILON_M) && nearlyEqual(da.gpsVsFusedMaxDivergenceM, db.gpsVsFusedMaxDivergenceM, REPORT_METRE_EPSILON_M);
602
+ }
603
+ function matricesNearlyEqual(a, b) {
604
+ if (a.length !== b.length) return false;
605
+ for (let i = 0; i < a.length; i++) if (Math.abs(a[i] - b[i]) > 1e-9) return false;
606
+ return true;
607
+ }
608
+ /**
609
+ * Action types the listener reacts to. Module-private: consumed only by the
610
+ * listener predicate and middleware below. The strings are the canonical
611
+ * RTK auto-generated action types.
612
+ */
613
+ const TRACKING_QUALITY_INPUT_ACTIONS = {
614
+ gpsRecorded: "gpsData/recordGpsEvent",
615
+ setZeroPos: "gpsData/setZeroPos",
616
+ poseReceived: "tracking/poseReceived",
617
+ poseLost: "tracking/poseLost",
618
+ resetTracking: "tracking/resetTracking",
619
+ startSession: "recording/startSession"
620
+ };
621
+ function inputActionPredicate(action) {
622
+ switch (action.type) {
623
+ case TRACKING_QUALITY_INPUT_ACTIONS.gpsRecorded:
624
+ case TRACKING_QUALITY_INPUT_ACTIONS.setZeroPos:
625
+ case TRACKING_QUALITY_INPUT_ACTIONS.poseReceived:
626
+ case TRACKING_QUALITY_INPUT_ACTIONS.poseLost:
627
+ case TRACKING_QUALITY_INPUT_ACTIONS.resetTracking:
628
+ case TRACKING_QUALITY_INPUT_ACTIONS.startSession: return true;
629
+ default: return false;
630
+ }
631
+ }
632
+ /**
633
+ * Build the listener middleware that drives the `trackingQuality`
634
+ * slice. Register the returned middleware in `createSlamAppStore`.
635
+ *
636
+ * Behaviour:
637
+ * - On `gpsData/recordGpsEvent` and `gpsData/setZeroPos`: if the
638
+ * alignment matrix changed, dispatch `snapshotPushed` +
639
+ * `snapshotsTrimmed`. Then recompute the report and dispatch
640
+ * `reportUpdated` if it changed.
641
+ * - On `recording/startSession` and `tracking/resetTracking`:
642
+ * dispatch `resetTrackingQuality` and recompute the report.
643
+ * - On `tracking/poseReceived` and `tracking/poseLost`: only
644
+ * recompute the report (no buffer change).
645
+ * - All dispatches are idempotency-gated so quiet motion does not
646
+ * churn the store.
647
+ */
648
+ function createTrackingQualityListenerMiddleware(options = {}) {
649
+ const opts = {
650
+ ...DEFAULT_TRACKING_QUALITY_OPTIONS,
651
+ ...options
652
+ };
653
+ let compassStreak = 0;
654
+ const listenerMiddleware = createListenerMiddleware();
655
+ listenerMiddleware.startListening({
656
+ predicate: (action) => inputActionPredicate(action),
657
+ effect: (action, api) => {
658
+ const state = api.getState();
659
+ if (action.type === TRACKING_QUALITY_INPUT_ACTIONS.startSession || action.type === TRACKING_QUALITY_INPUT_ACTIONS.resetTracking) {
660
+ compassStreak = 0;
661
+ const tq = state.trackingQuality;
662
+ if (tq && (tq.recentAlignments.length > 0 || tq.firstAgreementObservationIndex !== null || tq.report !== null || tq.degradedConsecutiveCount > 0)) api.dispatch(resetTrackingQuality());
663
+ return;
664
+ }
665
+ if (action.type === TRACKING_QUALITY_INPUT_ACTIONS.gpsRecorded || action.type === TRACKING_QUALITY_INPUT_ACTIONS.setZeroPos) {
666
+ const alignment = selectAlignmentMatrix(state);
667
+ if (alignment) {
668
+ const buf = state.trackingQuality?.recentAlignments ?? [];
669
+ const newMatrix = alignment;
670
+ const obsIndex = selectGpsPositions(state).length;
671
+ const last = buf.length > 0 ? buf[buf.length - 1] : null;
672
+ if (!last || !matricesNearlyEqual(last.matrix, newMatrix)) {
673
+ api.dispatch(snapshotPushed({
674
+ observationIndex: obsIndex,
675
+ matrix: [...newMatrix]
676
+ }));
677
+ api.dispatch(snapshotsTrimmed({ size: opts.matrixHistorySize }));
678
+ }
679
+ }
680
+ }
681
+ const midState = api.getState();
682
+ const midTq = midState.trackingQuality;
683
+ if (midTq && midTq.firstAgreementObservationIndex === null) {
684
+ const snapshots = selectRecentAlignments(midState);
685
+ if (snapshots.length >= 2) {
686
+ const conv = computeConvergence(snapshots, {
687
+ rotationWarnDeg: opts.convergenceRotationWarnDeg,
688
+ translationWarnM: opts.convergenceTranslationWarnM
689
+ });
690
+ const sensorOr = selectLastSensorOrientation(midState);
691
+ const pose = selectLastValidPose(midState);
692
+ const compass = computeCompassAgreement(selectAlignmentMatrix(midState), sensorOr, pose, {
693
+ warnDeg: opts.compassWarnDeg,
694
+ failDeg: opts.compassFailDeg
695
+ });
696
+ if (conv.score >= .7 && compass.headingDeltaDeg !== null && compass.headingDeltaDeg <= opts.compassWarnDeg) {
697
+ compassStreak += 1;
698
+ if (compassStreak >= opts.firstAgreementMinStreak) {
699
+ const obsCount = selectGpsPositions(midState).length;
700
+ api.dispatch(firstAgreementReached(obsCount));
701
+ }
702
+ } else compassStreak = 0;
703
+ }
704
+ }
705
+ const next = api.getState();
706
+ const report = computeTrackingQualityReport(next, opts);
707
+ const prev = next.trackingQuality?.report ?? null;
708
+ const tqNext = next.trackingQuality;
709
+ let dcc = tqNext?.degradedConsecutiveCount ?? 0;
710
+ if (report.state === "degraded") {
711
+ dcc += 1;
712
+ if (prev?.state === "ok" && dcc < opts.degradedHoldoff) report.state = "ok";
713
+ } else dcc = 0;
714
+ if (dcc !== (tqNext?.degradedConsecutiveCount ?? 0)) api.dispatch(degradedCountUpdated(dcc));
715
+ const newSmoothed = report.subScores.convergence;
716
+ if (newSmoothed !== (tqNext?.smoothedConvergence ?? null)) api.dispatch(smoothedConvergenceUpdated(newSmoothed));
717
+ if (!reportsEqual(prev, report)) api.dispatch(reportUpdated(report));
718
+ }
719
+ });
720
+ return listenerMiddleware.middleware;
721
+ }
722
+ //#endregion
723
+ export { DEFAULT_TRACKING_QUALITY_OPTIONS, computeCompassAgreement, computeConvergence, computeCoverage, computeGpsAccuracy, computeGpsVsFusedDivergence, computeResidualConsensus, computeTrackingQualityReport, createTrackingQualityListenerMiddleware, degradedCountUpdated, firstAgreementReached, matrixDelta, reportUpdated, resetTrackingQuality, selectFirstAgreementObservationIndex, selectRecentAlignments, selectTrackingQuality, snapshotPushed, snapshotsTrimmed, trackingQualityReducer };