@ingissa/navcore-simulator 1.0.8 → 1.0.10

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 (170) hide show
  1. package/LICENSE +3 -3
  2. package/README.md +161 -356
  3. package/dist/index.d.mts +87 -0
  4. package/dist/{simulator/src/react-native/useSimulator.d.ts → index.d.ts} +37 -9
  5. package/dist/index.js +1393 -0
  6. package/dist/index.mjs +1383 -0
  7. package/dist/node.d.mts +527 -0
  8. package/dist/node.d.ts +527 -0
  9. package/dist/node.js +957 -0
  10. package/dist/node.mjs +949 -0
  11. package/dist/react-native.d.mts +4 -0
  12. package/dist/react-native.d.ts +4 -0
  13. package/dist/react-native.js +1393 -0
  14. package/dist/react-native.mjs +1383 -0
  15. package/package.json +61 -58
  16. package/dist/core/src/NavCore.d.ts +0 -142
  17. package/dist/core/src/NavCore.d.ts.map +0 -1
  18. package/dist/core/src/NavCore.js +0 -390
  19. package/dist/core/src/bearing/BearingEngine.d.ts +0 -24
  20. package/dist/core/src/bearing/BearingEngine.d.ts.map +0 -1
  21. package/dist/core/src/bearing/BearingEngine.js +0 -50
  22. package/dist/core/src/constants.d.ts +0 -34
  23. package/dist/core/src/constants.d.ts.map +0 -1
  24. package/dist/core/src/constants.js +0 -41
  25. package/dist/core/src/corridor/DeviationDetector.d.ts +0 -33
  26. package/dist/core/src/corridor/DeviationDetector.d.ts.map +0 -1
  27. package/dist/core/src/corridor/DeviationDetector.js +0 -83
  28. package/dist/core/src/corridor/DynamicCorridor.d.ts +0 -13
  29. package/dist/core/src/corridor/DynamicCorridor.d.ts.map +0 -1
  30. package/dist/core/src/corridor/DynamicCorridor.js +0 -26
  31. package/dist/core/src/dead-reckoning/DeadReckoning.d.ts +0 -35
  32. package/dist/core/src/dead-reckoning/DeadReckoning.d.ts.map +0 -1
  33. package/dist/core/src/dead-reckoning/DeadReckoning.js +0 -49
  34. package/dist/core/src/directions/DirectionsProvider.d.ts +0 -70
  35. package/dist/core/src/directions/DirectionsProvider.d.ts.map +0 -1
  36. package/dist/core/src/directions/DirectionsProvider.js +0 -7
  37. package/dist/core/src/directions/OSRMDirectionsProvider.d.ts +0 -26
  38. package/dist/core/src/directions/OSRMDirectionsProvider.d.ts.map +0 -1
  39. package/dist/core/src/directions/OSRMDirectionsProvider.js +0 -90
  40. package/dist/core/src/directions/OpenRouteServiceProvider.d.ts +0 -30
  41. package/dist/core/src/directions/OpenRouteServiceProvider.d.ts.map +0 -1
  42. package/dist/core/src/directions/OpenRouteServiceProvider.js +0 -111
  43. package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts +0 -33
  44. package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts.map +0 -1
  45. package/dist/core/src/directions/ValhallaDirectionsProvider.js +0 -155
  46. package/dist/core/src/eta/ETAEngine.d.ts +0 -61
  47. package/dist/core/src/eta/ETAEngine.d.ts.map +0 -1
  48. package/dist/core/src/eta/ETAEngine.js +0 -74
  49. package/dist/core/src/features.d.ts +0 -76
  50. package/dist/core/src/features.d.ts.map +0 -1
  51. package/dist/core/src/features.js +0 -76
  52. package/dist/core/src/geo/bearing.d.ts +0 -11
  53. package/dist/core/src/geo/bearing.d.ts.map +0 -1
  54. package/dist/core/src/geo/bearing.js +0 -25
  55. package/dist/core/src/geo/cumulative.d.ts +0 -9
  56. package/dist/core/src/geo/cumulative.d.ts.map +0 -1
  57. package/dist/core/src/geo/cumulative.js +0 -20
  58. package/dist/core/src/geo/haversine.d.ts +0 -13
  59. package/dist/core/src/geo/haversine.d.ts.map +0 -1
  60. package/dist/core/src/geo/haversine.js +0 -39
  61. package/dist/core/src/geo/projection.d.ts +0 -7
  62. package/dist/core/src/geo/projection.d.ts.map +0 -1
  63. package/dist/core/src/geo/projection.js +0 -17
  64. package/dist/core/src/geofencing/GeofencingEngine.d.ts +0 -93
  65. package/dist/core/src/geofencing/GeofencingEngine.d.ts.map +0 -1
  66. package/dist/core/src/geofencing/GeofencingEngine.js +0 -154
  67. package/dist/core/src/index.d.ts +0 -66
  68. package/dist/core/src/index.d.ts.map +0 -1
  69. package/dist/core/src/index.js +0 -62
  70. package/dist/core/src/kalman/KalmanFilter2D.d.ts +0 -46
  71. package/dist/core/src/kalman/KalmanFilter2D.d.ts.map +0 -1
  72. package/dist/core/src/kalman/KalmanFilter2D.js +0 -77
  73. package/dist/core/src/license/LicenseManager.d.ts +0 -40
  74. package/dist/core/src/license/LicenseManager.d.ts.map +0 -1
  75. package/dist/core/src/license/LicenseManager.js +0 -119
  76. package/dist/core/src/license/verifyP256Signature.d.ts +0 -2
  77. package/dist/core/src/license/verifyP256Signature.d.ts.map +0 -1
  78. package/dist/core/src/license/verifyP256Signature.js +0 -26
  79. package/dist/core/src/off-route/NavigationFSM.d.ts +0 -42
  80. package/dist/core/src/off-route/NavigationFSM.d.ts.map +0 -1
  81. package/dist/core/src/off-route/NavigationFSM.js +0 -56
  82. package/dist/core/src/off-route/OffRouteFSM.d.ts +0 -70
  83. package/dist/core/src/off-route/OffRouteFSM.d.ts.map +0 -1
  84. package/dist/core/src/off-route/OffRouteFSM.js +0 -151
  85. package/dist/core/src/predictor/RouteOffsetPredictor.d.ts +0 -24
  86. package/dist/core/src/predictor/RouteOffsetPredictor.d.ts.map +0 -1
  87. package/dist/core/src/predictor/RouteOffsetPredictor.js +0 -55
  88. package/dist/core/src/progress/InstructionEditor.d.ts +0 -77
  89. package/dist/core/src/progress/InstructionEditor.d.ts.map +0 -1
  90. package/dist/core/src/progress/InstructionEditor.js +0 -151
  91. package/dist/core/src/progress/InstructionResolver.d.ts +0 -25
  92. package/dist/core/src/progress/InstructionResolver.d.ts.map +0 -1
  93. package/dist/core/src/progress/InstructionResolver.js +0 -78
  94. package/dist/core/src/progress/ProgressTracker.d.ts +0 -12
  95. package/dist/core/src/progress/ProgressTracker.d.ts.map +0 -1
  96. package/dist/core/src/progress/ProgressTracker.js +0 -22
  97. package/dist/core/src/renderer/MapRendererAdapter.d.ts +0 -72
  98. package/dist/core/src/renderer/MapRendererAdapter.d.ts.map +0 -1
  99. package/dist/core/src/renderer/MapRendererAdapter.js +0 -19
  100. package/dist/core/src/route-builder/CustomRouteBuilder.d.ts +0 -83
  101. package/dist/core/src/route-builder/CustomRouteBuilder.d.ts.map +0 -1
  102. package/dist/core/src/route-builder/CustomRouteBuilder.js +0 -160
  103. package/dist/core/src/route-plan/RoutePlan.d.ts +0 -38
  104. package/dist/core/src/route-plan/RoutePlan.d.ts.map +0 -1
  105. package/dist/core/src/route-plan/RoutePlan.js +0 -102
  106. package/dist/core/src/session/NavigationSession.d.ts +0 -81
  107. package/dist/core/src/session/NavigationSession.d.ts.map +0 -1
  108. package/dist/core/src/session/NavigationSession.js +0 -115
  109. package/dist/core/src/snapper/ParallelRoadResolver.d.ts +0 -86
  110. package/dist/core/src/snapper/ParallelRoadResolver.d.ts.map +0 -1
  111. package/dist/core/src/snapper/ParallelRoadResolver.js +0 -83
  112. package/dist/core/src/snapper/RouteSnapper.d.ts +0 -38
  113. package/dist/core/src/snapper/RouteSnapper.d.ts.map +0 -1
  114. package/dist/core/src/snapper/RouteSnapper.js +0 -80
  115. package/dist/core/src/voice/VoiceTriggerEngine.d.ts +0 -80
  116. package/dist/core/src/voice/VoiceTriggerEngine.d.ts.map +0 -1
  117. package/dist/core/src/voice/VoiceTriggerEngine.js +0 -108
  118. package/dist/simulator/src/core/NoiseEngine.d.ts +0 -46
  119. package/dist/simulator/src/core/NoiseEngine.d.ts.map +0 -1
  120. package/dist/simulator/src/core/NoiseEngine.js +0 -106
  121. package/dist/simulator/src/core/RouteWalker.d.ts +0 -78
  122. package/dist/simulator/src/core/RouteWalker.d.ts.map +0 -1
  123. package/dist/simulator/src/core/RouteWalker.js +0 -180
  124. package/dist/simulator/src/core/SimulatorEngine.d.ts +0 -53
  125. package/dist/simulator/src/core/SimulatorEngine.d.ts.map +0 -1
  126. package/dist/simulator/src/core/SimulatorEngine.js +0 -298
  127. package/dist/simulator/src/core/types.d.ts +0 -157
  128. package/dist/simulator/src/core/types.d.ts.map +0 -1
  129. package/dist/simulator/src/core/types.js +0 -7
  130. package/dist/simulator/src/fleet/FleetSimulator.d.ts +0 -60
  131. package/dist/simulator/src/fleet/FleetSimulator.d.ts.map +0 -1
  132. package/dist/simulator/src/fleet/FleetSimulator.js +0 -114
  133. package/dist/simulator/src/index.d.ts +0 -28
  134. package/dist/simulator/src/index.d.ts.map +0 -1
  135. package/dist/simulator/src/index.js +0 -29
  136. package/dist/simulator/src/node.d.ts +0 -26
  137. package/dist/simulator/src/node.d.ts.map +0 -1
  138. package/dist/simulator/src/node.js +0 -25
  139. package/dist/simulator/src/react-native/SimulatorControls.d.ts +0 -35
  140. package/dist/simulator/src/react-native/SimulatorControls.d.ts.map +0 -1
  141. package/dist/simulator/src/react-native/SimulatorControls.js +0 -254
  142. package/dist/simulator/src/react-native/useSimulator.d.ts.map +0 -1
  143. package/dist/simulator/src/react-native/useSimulator.js +0 -100
  144. package/dist/simulator/src/replay/GpxParser.d.ts +0 -40
  145. package/dist/simulator/src/replay/GpxParser.d.ts.map +0 -1
  146. package/dist/simulator/src/replay/GpxParser.js +0 -95
  147. package/dist/simulator/src/replay/SessionRecorder.d.ts +0 -37
  148. package/dist/simulator/src/replay/SessionRecorder.d.ts.map +0 -1
  149. package/dist/simulator/src/replay/SessionRecorder.js +0 -120
  150. package/dist/simulator/src/scenarios/ScenarioInjector.d.ts +0 -55
  151. package/dist/simulator/src/scenarios/ScenarioInjector.d.ts.map +0 -1
  152. package/dist/simulator/src/scenarios/ScenarioInjector.js +0 -150
  153. package/src/__tests__/integration/simulator-pipeline.test.ts +0 -233
  154. package/src/core/NoiseEngine.ts +0 -137
  155. package/src/core/RouteWalker.ts +0 -227
  156. package/src/core/SimulatorEngine.ts +0 -364
  157. package/src/core/__tests__/NoiseEngine.test.ts +0 -131
  158. package/src/core/__tests__/RouteWalker.test.ts +0 -139
  159. package/src/core/__tests__/SimulatorEngine.test.ts +0 -317
  160. package/src/core/types.ts +0 -191
  161. package/src/fleet/FleetSimulator.ts +0 -162
  162. package/src/index.ts +0 -54
  163. package/src/node.ts +0 -48
  164. package/src/react-native/SimulatorControls.tsx +0 -473
  165. package/src/react-native/useSimulator.ts +0 -155
  166. package/src/replay/GpxParser.ts +0 -122
  167. package/src/replay/SessionRecorder.ts +0 -138
  168. package/src/replay/__tests__/GpxParser.test.ts +0 -136
  169. package/src/scenarios/ScenarioInjector.ts +0 -170
  170. package/src/scenarios/__tests__/ScenarioInjector.test.ts +0 -171
package/dist/node.mjs ADDED
@@ -0,0 +1,949 @@
1
+ import { haversineDistance, getBearingBetweenPoints } from '@ingissa/navcore-core';
2
+
3
+ // src/core/NoiseEngine.ts
4
+ var LAT_DEG_PER_M = 1 / 110540;
5
+ var LNG_DEG_PER_M = 1 / 111320;
6
+ var SeededRandom = class {
7
+ constructor(seed) {
8
+ this.state = seed >>> 0;
9
+ }
10
+ /** Returns a float in [0, 1) */
11
+ next() {
12
+ this.state = Math.imul(1664525, this.state) + 1013904223 >>> 0;
13
+ return this.state / 4294967296;
14
+ }
15
+ };
16
+ var NoiseEngine = class {
17
+ constructor(profile) {
18
+ this._tickIndex = 0;
19
+ this.profile = profile;
20
+ this.rng = profile.seed !== void 0 ? new SeededRandom(profile.seed) : null;
21
+ }
22
+ // ── Public API ─────────────────────────────────────────────────────────────
23
+ /**
24
+ * Apply noise to a coordinate.
25
+ * Returns a new coordinate — never mutates the input.
26
+ */
27
+ apply(coord) {
28
+ const idx = this._tickIndex++;
29
+ switch (this.profile.type) {
30
+ case "NONE":
31
+ return coord;
32
+ case "GAUSSIAN":
33
+ case "URBAN": {
34
+ const [dLng, dLat] = this._gaussianOffset(this.profile.stdDevM);
35
+ return [coord[0] + dLng, coord[1] + dLat];
36
+ }
37
+ case "TUNNEL":
38
+ return coord;
39
+ case "CUSTOM": {
40
+ if (!this.profile.customFn) return coord;
41
+ const [lngOff, latOff] = this.profile.customFn(idx);
42
+ return [coord[0] + lngOff, coord[1] + latOff];
43
+ }
44
+ default:
45
+ return coord;
46
+ }
47
+ }
48
+ /** Hot-swap the noise profile mid-simulation */
49
+ setProfile(profile) {
50
+ this.profile = profile;
51
+ this.rng = profile.seed !== void 0 ? new SeededRandom(profile.seed) : null;
52
+ this._tickIndex = 0;
53
+ }
54
+ get currentProfile() {
55
+ return this.profile;
56
+ }
57
+ // ── Private helpers ─────────────────────────────────────────────────────────
58
+ /**
59
+ * Box-Muller transform: produces Gaussian N(0, stdDev²) offset.
60
+ * Returns [lngOffset, latOffset] in degrees.
61
+ */
62
+ _gaussianOffset(stdDevM) {
63
+ if (stdDevM === 0) return [0, 0];
64
+ const u1 = this._rand();
65
+ const u2 = this._rand();
66
+ const mag = stdDevM * Math.sqrt(-2 * Math.log(Math.max(u1, 1e-10)));
67
+ const z0 = mag * Math.cos(2 * Math.PI * u2);
68
+ const z1 = mag * Math.sin(2 * Math.PI * u2);
69
+ return [z0 * LNG_DEG_PER_M, z1 * LAT_DEG_PER_M];
70
+ }
71
+ /** Returns a float in [0, 1) — uses seeded RNG if available, else Math.random */
72
+ _rand() {
73
+ return this.rng ? this.rng.next() : Math.random();
74
+ }
75
+ };
76
+ // ── Static presets ─────────────────────────────────────────────────────────
77
+ /** No noise — returns the exact coordinate */
78
+ NoiseEngine.NONE = { type: "NONE", stdDevM: 0 };
79
+ /** Good urban GPS — ~3m standard deviation */
80
+ NoiseEngine.CLEAN = { type: "GAUSSIAN", stdDevM: 3 };
81
+ /** Standard Go4Exams value — ~12m standard deviation (matches original ±0.00011°) */
82
+ NoiseEngine.STANDARD = { type: "GAUSSIAN", stdDevM: 12 };
83
+ /** Dense city canyon — ~25m standard deviation */
84
+ NoiseEngine.URBAN = { type: "URBAN", stdDevM: 25 };
85
+ /** Bad conditions — ~50m standard deviation */
86
+ NoiseEngine.DEGRADED = { type: "GAUSSIAN", stdDevM: 50 };
87
+ var RouteWalker = class {
88
+ constructor(geometry, startIndex = 0) {
89
+ if (geometry.length < 2) {
90
+ throw new Error("RouteWalker requires at least 2 geometry points");
91
+ }
92
+ this.geometry = geometry;
93
+ this._index = Math.max(0, Math.min(startIndex, geometry.length - 1));
94
+ this._isComplete = false;
95
+ this._cumulativeDistances = new Array(geometry.length).fill(0);
96
+ for (let i = 1; i < geometry.length; i++) {
97
+ const a = geometry[i - 1];
98
+ const b = geometry[i];
99
+ this._cumulativeDistances[i] = (this._cumulativeDistances[i - 1] ?? 0) + haversineDistance(a[1], a[0], b[1], b[0]);
100
+ }
101
+ this._distanceTravelled = this._cumulativeDistances[this._index] ?? 0;
102
+ }
103
+ // ── Public API ─────────────────────────────────────────────────────────────
104
+ /**
105
+ * Advance the walker by `steps` geometry points.
106
+ * Fractional steps are supported for sub-second precision.
107
+ */
108
+ advance(steps) {
109
+ if (this._isComplete) return this._currentPosition();
110
+ this._index += steps;
111
+ return this._finalizeAdvance();
112
+ }
113
+ /**
114
+ * Advance the walker by a physical distance in metres.
115
+ * This is the preferred way for smooth constant-speed simulation.
116
+ */
117
+ advanceByDistance(meters) {
118
+ if (this._isComplete) return this._currentPosition();
119
+ this._distanceTravelled += meters;
120
+ if (this._distanceTravelled >= this.totalDistanceM) {
121
+ this._distanceTravelled = this.totalDistanceM;
122
+ this._index = this.geometry.length - 1;
123
+ this._isComplete = true;
124
+ } else {
125
+ this._index = this._distanceToIndex(this._distanceTravelled);
126
+ }
127
+ return this._currentPosition();
128
+ }
129
+ _finalizeAdvance() {
130
+ if (this._index >= this.geometry.length - 1) {
131
+ this._index = this.geometry.length - 1;
132
+ this._isComplete = true;
133
+ }
134
+ this._distanceTravelled = this._interpolatedDistance(this._index);
135
+ return this._currentPosition();
136
+ }
137
+ /**
138
+ * Seek directly to a geometry index (integer or fractional).
139
+ * Recalculates bearing from the new position.
140
+ */
141
+ seek(index) {
142
+ this._index = Math.max(0, Math.min(index, this.geometry.length - 1));
143
+ this._isComplete = this._index >= this.geometry.length - 1;
144
+ this._distanceTravelled = this._interpolatedDistance(this._index);
145
+ return this._currentPosition();
146
+ }
147
+ /**
148
+ * Get an interpolated coordinate between geometry[index] and geometry[index+1].
149
+ * @param index - integer index
150
+ * @param fraction - 0..1 interpolation factor
151
+ */
152
+ interpolate(index, fraction) {
153
+ const clampedIdx = Math.max(0, Math.min(index, this.geometry.length - 2));
154
+ const a = this.geometry[clampedIdx];
155
+ const b = this.geometry[clampedIdx + 1];
156
+ const f = Math.max(0, Math.min(1, fraction));
157
+ return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
158
+ }
159
+ /**
160
+ * Compute the forward-looking bearing at a given integer index.
161
+ * At the last point, returns the bearing of the final segment.
162
+ */
163
+ bearingAt(index) {
164
+ const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 2));
165
+ const a = this.geometry[clampedIdx];
166
+ const b = this.geometry[clampedIdx + 1];
167
+ return getBearingBetweenPoints(a, b);
168
+ }
169
+ /**
170
+ * Total haversine distance from the route start to the given index.
171
+ * Uses the pre-computed cumulative distances (O(1)).
172
+ */
173
+ distanceAtIndex(index) {
174
+ const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 1));
175
+ return this._cumulativeDistances[clampedIdx] ?? 0;
176
+ }
177
+ /**
178
+ * Alias for distanceAtIndex.
179
+ */
180
+ distanceToIndex(index) {
181
+ return this.distanceAtIndex(index);
182
+ }
183
+ /**
184
+ * Convert a distance in metres back to a fractional index.
185
+ * Uses binary search for O(log N) performance.
186
+ */
187
+ _distanceToIndex(targetDistance) {
188
+ if (targetDistance <= 0) return 0;
189
+ if (targetDistance >= this.totalDistanceM) return this.geometry.length - 1;
190
+ let low = 0;
191
+ let high = this._cumulativeDistances.length - 1;
192
+ let i = 0;
193
+ while (low <= high) {
194
+ const mid = Math.floor((low + high) / 2);
195
+ if (this._cumulativeDistances[mid] <= targetDistance) {
196
+ i = mid;
197
+ low = mid + 1;
198
+ } else {
199
+ high = mid - 1;
200
+ }
201
+ }
202
+ const dStart = this._cumulativeDistances[i];
203
+ const dEnd = this._cumulativeDistances[i + 1];
204
+ const segmentDist = dEnd - dStart;
205
+ if (segmentDist <= 0) return i;
206
+ const fraction = (targetDistance - dStart) / segmentDist;
207
+ return i + fraction;
208
+ }
209
+ // ── Readonly state ──────────────────────────────────────────────────────────
210
+ get totalDistanceM() {
211
+ return this._cumulativeDistances[this.geometry.length - 1] ?? 0;
212
+ }
213
+ get totalPoints() {
214
+ return this.geometry.length;
215
+ }
216
+ get isComplete() {
217
+ return this._isComplete;
218
+ }
219
+ get currentIndex() {
220
+ return this._index;
221
+ }
222
+ // ── Private helpers ─────────────────────────────────────────────────────────
223
+ _currentPosition() {
224
+ const floorIdx = Math.min(Math.floor(this._index), this.geometry.length - 2);
225
+ const fraction = this._index - floorIdx;
226
+ const coord = this.interpolate(floorIdx, fraction);
227
+ const bearing = this.bearingAt(floorIdx);
228
+ return {
229
+ index: this._index,
230
+ coord,
231
+ bearing,
232
+ distanceTravelled: this._distanceTravelled,
233
+ isComplete: this._isComplete
234
+ };
235
+ }
236
+ /** Interpolate cumulative distance for fractional index */
237
+ _interpolatedDistance(index) {
238
+ const floor = Math.min(Math.floor(index), this.geometry.length - 1);
239
+ const ceil = Math.min(floor + 1, this.geometry.length - 1);
240
+ const fraction = index - floor;
241
+ const dFloor = this._cumulativeDistances[floor] ?? 0;
242
+ const dCeil = this._cumulativeDistances[ceil] ?? 0;
243
+ return dFloor + (dCeil - dFloor) * fraction;
244
+ }
245
+ };
246
+
247
+ // src/scenarios/ScenarioInjector.ts
248
+ var ScenarioInjector = class {
249
+ constructor(onStart, onEnd) {
250
+ /** Map from triggerIndex → Scenario */
251
+ this.scenarios = /* @__PURE__ */ new Map();
252
+ /** Tracks which scenarios have already fired (by name) to avoid double-fire */
253
+ this.fired = /* @__PURE__ */ new Set();
254
+ /** Currently active scenario, if any */
255
+ this._activeScenario = null;
256
+ this._activeEndTimer = null;
257
+ this.onStart = onStart ?? (() => void 0);
258
+ this.onEnd = onEnd ?? (() => void 0);
259
+ }
260
+ // ── Registration ────────────────────────────────────────────────────────────
261
+ register(scenario) {
262
+ this.scenarios.set(scenario.triggerIndex, scenario);
263
+ }
264
+ remove(name) {
265
+ for (const [idx, s] of this.scenarios) {
266
+ if (s.name === name) {
267
+ this.scenarios.delete(idx);
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ clear() {
273
+ this.scenarios.clear();
274
+ this.fired.clear();
275
+ this._clearActiveTimer();
276
+ this._activeScenario = null;
277
+ }
278
+ // ── Tick ────────────────────────────────────────────────────────────────────
279
+ /**
280
+ * Check and fire any scenario whose triggerIndex matches (or was passed) by
281
+ * currentIndex. Called by SimulatorEngine on every tick.
282
+ *
283
+ * Returns the name of the active scenario (if any), for metadata injection.
284
+ */
285
+ tick(currentIndex) {
286
+ const floorIdx = Math.floor(currentIndex);
287
+ for (const [triggerIdx, scenario] of this.scenarios) {
288
+ if (floorIdx >= triggerIdx && !this.fired.has(scenario.name)) {
289
+ this.fired.add(scenario.name);
290
+ this._fire(scenario);
291
+ }
292
+ }
293
+ return this._activeScenario?.name;
294
+ }
295
+ get activeScenario() {
296
+ return this._activeScenario;
297
+ }
298
+ /** Clean up any pending timers */
299
+ destroy() {
300
+ this._clearActiveTimer();
301
+ }
302
+ // ── Static factories ────────────────────────────────────────────────────────
303
+ /**
304
+ * GPS blackout: no position updates for `durationMs`.
305
+ * Engine handles signal loss; this scenario signals the blackout period.
306
+ */
307
+ static gpsBlackout(atIndex, durationMs) {
308
+ return {
309
+ name: `gps-blackout-@${atIndex}`,
310
+ triggerIndex: atIndex,
311
+ type: "GPS_BLACKOUT",
312
+ durationMs,
313
+ params: { durationMs }
314
+ };
315
+ }
316
+ /**
317
+ * Wrong-side road: offset the simulated position by `offsetM` metres
318
+ * perpendicular to the route to stress-test the parallel road resolver.
319
+ */
320
+ static wrongSideRoad(atIndex, offsetM) {
321
+ return {
322
+ name: `wrong-side-@${atIndex}`,
323
+ triggerIndex: atIndex,
324
+ type: "WRONG_SIDE_ROAD",
325
+ params: { offsetM }
326
+ };
327
+ }
328
+ /** U-turn at the given index — stress-tests U-turn protection. */
329
+ static uTurn(atIndex) {
330
+ return {
331
+ name: `uturn-@${atIndex}`,
332
+ triggerIndex: atIndex,
333
+ type: "UTURN",
334
+ params: {}
335
+ };
336
+ }
337
+ /** Speed spike: briefly report spikeKmh instead of the configured speed. */
338
+ static speedSpike(atIndex, spikeKmh, durationMs = 2e3) {
339
+ return {
340
+ name: `speed-spike-@${atIndex}`,
341
+ triggerIndex: atIndex,
342
+ type: "SPEED_SPIKE",
343
+ durationMs,
344
+ params: { spikeKmh }
345
+ };
346
+ }
347
+ /**
348
+ * Accuracy drop: GPS accuracy degrades to newAccuracyM for durationMs.
349
+ */
350
+ static accuracyDrop(atIndex, newAccuracyM, durationMs) {
351
+ return {
352
+ name: `accuracy-drop-@${atIndex}`,
353
+ triggerIndex: atIndex,
354
+ type: "ACCURACY_DROP",
355
+ durationMs,
356
+ params: { newAccuracyM }
357
+ };
358
+ }
359
+ // ── Private ─────────────────────────────────────────────────────────────────
360
+ _fire(scenario) {
361
+ this._activeScenario = scenario;
362
+ try {
363
+ this.onStart(scenario);
364
+ } catch (_) {
365
+ }
366
+ if (scenario.durationMs != null && scenario.durationMs > 0) {
367
+ this._clearActiveTimer();
368
+ this._activeEndTimer = setTimeout(() => {
369
+ this._activeScenario = null;
370
+ try {
371
+ this.onEnd(scenario);
372
+ } catch (_) {
373
+ }
374
+ }, scenario.durationMs);
375
+ } else {
376
+ this._activeScenario = null;
377
+ try {
378
+ this.onEnd(scenario);
379
+ } catch (_) {
380
+ }
381
+ }
382
+ }
383
+ _clearActiveTimer() {
384
+ if (this._activeEndTimer !== null) {
385
+ clearTimeout(this._activeEndTimer);
386
+ this._activeEndTimer = null;
387
+ }
388
+ }
389
+ };
390
+
391
+ // src/core/SimulatorEngine.ts
392
+ function uuidV4() {
393
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
394
+ const r = Math.random() * 16 | 0;
395
+ const v = c === "x" ? r : r & 3 | 8;
396
+ return v.toString(16);
397
+ });
398
+ }
399
+ var SimulatorEngine = class {
400
+ constructor(config) {
401
+ // ── State ────────────────────────────────────────────────────────────────────
402
+ this._state = "idle";
403
+ this._position = null;
404
+ this._timer = null;
405
+ this._lastTick = 0;
406
+ // 20 FPS for smooth movement
407
+ // ── Recording ────────────────────────────────────────────────────────────────
408
+ this._recording = false;
409
+ this._recordedPositions = [];
410
+ this._recordedScenarios = [];
411
+ this._sessionId = null;
412
+ this._sessionStart = null;
413
+ this._noiseSamplesAdded = 0;
414
+ const geometry = config.routeGeometry;
415
+ if (!geometry || geometry.length < 2) {
416
+ throw new Error("SimulatorEngine: routeGeometry must have at least 2 points");
417
+ }
418
+ this._speedMultiplier = config.speedMultiplier ?? 1;
419
+ this.config = {
420
+ routeGeometry: geometry,
421
+ speedKmh: config.speedKmh ?? 50,
422
+ speedMultiplier: this._speedMultiplier,
423
+ noiseProfile: config.noiseProfile ?? NoiseEngine.STANDARD,
424
+ loop: config.loop ?? false,
425
+ startIndex: config.startIndex ?? 0,
426
+ autoStart: config.autoStart ?? false,
427
+ onPositionUpdate: config.onPositionUpdate,
428
+ onRouteComplete: config.onRouteComplete,
429
+ onScenarioStart: config.onScenarioStart,
430
+ onScenarioEnd: config.onScenarioEnd
431
+ };
432
+ this.walker = new RouteWalker(geometry, this.config.startIndex);
433
+ this.noise = new NoiseEngine(this.config.noiseProfile);
434
+ this.injector = new ScenarioInjector(
435
+ (s) => {
436
+ if (this._recording) {
437
+ this._recordedScenarios.push({ scenario: s, firedAt: Date.now() });
438
+ }
439
+ try {
440
+ this.config.onScenarioStart?.(s);
441
+ } catch (_) {
442
+ }
443
+ },
444
+ (s) => {
445
+ try {
446
+ this.config.onScenarioEnd?.(s);
447
+ } catch (_) {
448
+ }
449
+ }
450
+ );
451
+ if (this.config.autoStart) {
452
+ this.start();
453
+ }
454
+ }
455
+ // ── Playback controls ────────────────────────────────────────────────────────
456
+ start() {
457
+ if (this._state === "running") return;
458
+ if (this._state === "complete") return;
459
+ this._state = "running";
460
+ this._startTimer();
461
+ }
462
+ stop() {
463
+ this._stopTimer();
464
+ this._state = "idle";
465
+ this._position = null;
466
+ }
467
+ pause() {
468
+ if (this._state !== "running") return;
469
+ this._stopTimer();
470
+ this._state = "paused";
471
+ }
472
+ resume() {
473
+ if (this._state !== "paused") return;
474
+ this._state = "running";
475
+ this._startTimer();
476
+ }
477
+ /**
478
+ * Jump to any geometry index.
479
+ * Bearing is recalculated from the new segment — seek() is always bearing-safe.
480
+ */
481
+ seek(geometryIndex) {
482
+ const wasRunning = this._state === "running";
483
+ if (wasRunning) this._stopTimer();
484
+ const pos = this.walker.seek(geometryIndex);
485
+ this._buildAndEmitPosition(pos.coord, pos.bearing, pos.distanceTravelled, false);
486
+ if (this._state === "complete" && !pos.isComplete) {
487
+ this._state = "idle";
488
+ }
489
+ if (wasRunning && !pos.isComplete) {
490
+ this._state = "running";
491
+ this._startTimer();
492
+ }
493
+ }
494
+ setSpeed(multiplier) {
495
+ this._speedMultiplier = multiplier;
496
+ if (this._state === "running") {
497
+ this._stopTimer();
498
+ this._startTimer();
499
+ }
500
+ }
501
+ // ── Scenario management ──────────────────────────────────────────────────────
502
+ addScenario(scenario) {
503
+ this.injector.register(scenario);
504
+ }
505
+ removeScenario(name) {
506
+ this.injector.remove(name);
507
+ }
508
+ clearScenarios() {
509
+ this.injector.clear();
510
+ }
511
+ // ── State accessors ──────────────────────────────────────────────────────────
512
+ get state() {
513
+ return this._state;
514
+ }
515
+ get position() {
516
+ return this._position;
517
+ }
518
+ get progress() {
519
+ if (this.walker.totalPoints <= 1) return 0;
520
+ return Math.min(1, this.walker.currentIndex / (this.walker.totalPoints - 1));
521
+ }
522
+ // ── Session recording ────────────────────────────────────────────────────────
523
+ startRecording(sessionId) {
524
+ this._recording = true;
525
+ this._recordedPositions = [];
526
+ this._recordedScenarios = [];
527
+ this._sessionId = sessionId ?? uuidV4();
528
+ this._sessionStart = Date.now();
529
+ this._noiseSamplesAdded = 0;
530
+ }
531
+ stopRecording() {
532
+ this._recording = false;
533
+ const endedAt = Date.now();
534
+ const startedAt = this._sessionStart ?? endedAt;
535
+ const positions = this._recordedPositions;
536
+ const durationMs = endedAt - startedAt;
537
+ let distanceTravelledM = 0;
538
+ if (positions.length > 0) {
539
+ distanceTravelledM = positions[positions.length - 1]?.metadata?.distanceTravelled ?? 0;
540
+ }
541
+ const avgSpeedKmh = durationMs > 0 ? distanceTravelledM / (durationMs / 1e3) * 3.6 : 0;
542
+ return {
543
+ id: this._sessionId ?? uuidV4(),
544
+ startedAt: new Date(startedAt).toISOString(),
545
+ endedAt: new Date(endedAt).toISOString(),
546
+ route: this.config.routeGeometry,
547
+ positions,
548
+ scenarios: this._recordedScenarios,
549
+ stats: {
550
+ durationMs,
551
+ distanceTravelledM,
552
+ avgSpeedKmh,
553
+ noiseSamplesAdded: this._noiseSamplesAdded,
554
+ scenariosFired: this._recordedScenarios.length
555
+ }
556
+ };
557
+ }
558
+ // ── Destroy ──────────────────────────────────────────────────────────────────
559
+ destroy() {
560
+ this._stopTimer();
561
+ this.injector.destroy();
562
+ this._state = "idle";
563
+ this._position = null;
564
+ this._recording = false;
565
+ this._recordedPositions = [];
566
+ }
567
+ // ── Private ──────────────────────────────────────────────────────────────────
568
+ _startTimer() {
569
+ this._lastTick = Date.now();
570
+ const intervalMs = Math.round(1e3 / this._speedMultiplier);
571
+ this._timer = setInterval(() => this._tick(), intervalMs);
572
+ }
573
+ _stopTimer() {
574
+ if (this._timer !== null) {
575
+ clearInterval(this._timer);
576
+ this._timer = null;
577
+ }
578
+ }
579
+ _tick() {
580
+ if (this._state !== "running") return;
581
+ const now = Date.now();
582
+ this._lastTick = now;
583
+ const speedMs = this.config.speedKmh / 3.6;
584
+ const walkerPos = this.walker.advance(1);
585
+ const activeScenarioName = this.injector.tick(walkerPos.index);
586
+ const activeScenario = this.injector.activeScenario;
587
+ let coord = walkerPos.coord;
588
+ let isNoisy = false;
589
+ if (activeScenario?.type !== "GPS_BLACKOUT") {
590
+ coord = this.noise.apply(walkerPos.coord);
591
+ isNoisy = this.noise.currentProfile.stdDevM > 0;
592
+ if (isNoisy) this._noiseSamplesAdded++;
593
+ }
594
+ if (activeScenario?.type === "SPEED_SPIKE") {
595
+ activeScenario.params?.["spikeKmh"] ?? this.config.speedKmh;
596
+ }
597
+ let accuracy = 8 + Math.random() * 6;
598
+ if (activeScenario?.type === "ACCURACY_DROP") {
599
+ accuracy = activeScenario.params?.["newAccuracyM"] ?? accuracy;
600
+ }
601
+ this._buildAndEmitPosition(coord, walkerPos.bearing, walkerPos.distanceTravelled, isNoisy, {
602
+ speedMs,
603
+ accuracy,
604
+ activeScenarioName,
605
+ segmentIndex: Math.floor(walkerPos.index)
606
+ });
607
+ if (walkerPos.isComplete) {
608
+ this._stopTimer();
609
+ if (this.config.loop) {
610
+ this.walker = new RouteWalker(this.config.routeGeometry, 0);
611
+ this._startTimer();
612
+ } else {
613
+ this._state = "complete";
614
+ }
615
+ try {
616
+ this.config.onRouteComplete?.();
617
+ } catch (_) {
618
+ }
619
+ }
620
+ }
621
+ _buildAndEmitPosition(coord, bearing, distanceTravelled, isNoisy, opts) {
622
+ const speedMs = opts?.speedMs ?? this.config.speedKmh / 3.6;
623
+ const accuracy = opts?.accuracy ?? 8;
624
+ const ts = Date.now();
625
+ const locationCoords = {
626
+ latitude: coord[1],
627
+ longitude: coord[0],
628
+ altitude: 0,
629
+ accuracy,
630
+ altitudeAccuracy: null,
631
+ heading: bearing,
632
+ speed: speedMs
633
+ };
634
+ const pos = {
635
+ coord,
636
+ bearing,
637
+ speed: speedMs,
638
+ accuracy,
639
+ timestamp: ts,
640
+ coords: locationCoords,
641
+ location: {
642
+ coords: locationCoords,
643
+ timestamp: ts
644
+ },
645
+ metadata: {
646
+ segmentIndex: opts?.segmentIndex ?? Math.floor(this.walker.currentIndex),
647
+ distanceTravelled,
648
+ isNoisy,
649
+ scenarioActive: opts?.activeScenarioName
650
+ }
651
+ };
652
+ this._position = pos;
653
+ if (this._recording) {
654
+ this._recordedPositions.push(pos);
655
+ }
656
+ try {
657
+ this.config.onPositionUpdate?.(pos);
658
+ } catch (_) {
659
+ }
660
+ }
661
+ };
662
+ SimulatorEngine.TICK_MS = 50;
663
+ var GpxParser = class _GpxParser {
664
+ /**
665
+ * Parse a GPX XML string into coordinates and metadata.
666
+ * Supports both <trkpt> and <wpt> elements.
667
+ */
668
+ static parse(gpxString) {
669
+ const coordinates = [];
670
+ const timestamps = [];
671
+ const pointRegex = /<(?:trkpt|wpt)\s+([^>]+)>([\s\S]*?)<\/(?:trkpt|wpt)>/g;
672
+ const latLonRegex = /lat="([^"]+)"/;
673
+ const lonRegex = /lon="([^"]+)"/;
674
+ const timeRegex = /<time>([^<]+)<\/time>/;
675
+ let match;
676
+ while ((match = pointRegex.exec(gpxString)) !== null) {
677
+ const attrs = match[1] ?? "";
678
+ const body = match[2] ?? "";
679
+ const latMatch = latLonRegex.exec(attrs);
680
+ const lonMatch = lonRegex.exec(attrs);
681
+ if (!latMatch || !lonMatch) continue;
682
+ const lat = parseFloat(latMatch[1] ?? "0");
683
+ const lon = parseFloat(lonMatch[1] ?? "0");
684
+ if (isNaN(lat) || isNaN(lon)) continue;
685
+ coordinates.push([lon, lat]);
686
+ const timeMatch = timeRegex.exec(body);
687
+ if (timeMatch) {
688
+ const ms = Date.parse(timeMatch[1] ?? "");
689
+ timestamps.push(isNaN(ms) ? 0 : ms);
690
+ } else {
691
+ timestamps.push(0);
692
+ }
693
+ }
694
+ const nameMatch = /<name>([^<]+)<\/name>/.exec(gpxString);
695
+ const name = nameMatch ? nameMatch[1]?.trim() : void 0;
696
+ let distanceM = 0;
697
+ for (let i = 1; i < coordinates.length; i++) {
698
+ const a = coordinates[i - 1];
699
+ const b = coordinates[i];
700
+ distanceM += haversineDistance(a[1], a[0], b[1], b[0]);
701
+ }
702
+ const validTs = timestamps.filter((t) => t > 0);
703
+ let durationMs = 0;
704
+ let avgSpeedKmh = 0;
705
+ if (validTs.length >= 2) {
706
+ durationMs = (validTs[validTs.length - 1] ?? 0) - (validTs[0] ?? 0);
707
+ if (durationMs > 0) {
708
+ const speedMs = distanceM / (durationMs / 1e3);
709
+ avgSpeedKmh = speedMs * 3.6;
710
+ }
711
+ }
712
+ return {
713
+ coordinates,
714
+ timestamps,
715
+ metadata: {
716
+ name,
717
+ totalPoints: coordinates.length,
718
+ durationMs,
719
+ distanceM,
720
+ avgSpeedKmh
721
+ }
722
+ };
723
+ }
724
+ /**
725
+ * Parse a base64-encoded GPX string.
726
+ * Works in React Native (uses atob) and Node.js (uses Buffer.from).
727
+ */
728
+ static fromBase64(base64) {
729
+ let decoded;
730
+ if (typeof atob !== "undefined") {
731
+ decoded = atob(base64);
732
+ } else {
733
+ decoded = Buffer.from(base64, "base64").toString("utf-8");
734
+ }
735
+ return _GpxParser.parse(decoded);
736
+ }
737
+ };
738
+
739
+ // src/replay/SessionRecorder.ts
740
+ function uuidV42() {
741
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
742
+ const r = Math.random() * 16 | 0;
743
+ const v = c === "x" ? r : r & 3 | 8;
744
+ return v.toString(16);
745
+ });
746
+ }
747
+ var SessionRecorder = class _SessionRecorder {
748
+ constructor() {
749
+ this._sessionId = null;
750
+ this._startedAt = null;
751
+ this._session = null;
752
+ }
753
+ /**
754
+ * Start a new recording.
755
+ * If the engine already has a session via startRecording(), you can pass
756
+ * the existing sessionId to keep IDs consistent.
757
+ */
758
+ start(sessionId) {
759
+ this._sessionId = sessionId ?? uuidV42();
760
+ this._startedAt = Date.now();
761
+ this._session = null;
762
+ }
763
+ /**
764
+ * Stop recording and return the captured session.
765
+ * If called with an existing SimSession (from SimulatorEngine.stopRecording()),
766
+ * it stores that session for subsequent export() calls.
767
+ */
768
+ stop(existing) {
769
+ if (existing) {
770
+ this._session = existing;
771
+ return existing;
772
+ }
773
+ const session = {
774
+ id: this._sessionId ?? uuidV42(),
775
+ startedAt: new Date(this._startedAt ?? Date.now()).toISOString(),
776
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
777
+ route: [],
778
+ positions: [],
779
+ scenarios: [],
780
+ stats: {
781
+ durationMs: Date.now() - (this._startedAt ?? Date.now()),
782
+ distanceTravelledM: 0,
783
+ avgSpeedKmh: 0,
784
+ noiseSamplesAdded: 0,
785
+ scenariosFired: 0
786
+ }
787
+ };
788
+ this._session = session;
789
+ return session;
790
+ }
791
+ /**
792
+ * Export the most recent session in the specified format.
793
+ * Must call stop() first.
794
+ */
795
+ export(format) {
796
+ const session = this._session;
797
+ if (!session) throw new Error("SessionRecorder: call stop() before export()");
798
+ switch (format) {
799
+ case "json":
800
+ return JSON.stringify(session, null, 2);
801
+ case "gpx":
802
+ return _SessionRecorder._toGpx(session);
803
+ case "csv":
804
+ return _SessionRecorder._toCsv(session);
805
+ default:
806
+ throw new Error(`SessionRecorder: unknown format "${format}"`);
807
+ }
808
+ }
809
+ // ── Static export helpers ───────────────────────────────────────────────────
810
+ static _toGpx(session) {
811
+ const trkpts = session.positions.map((p) => {
812
+ const time = new Date(p.timestamp).toISOString();
813
+ return ` <trkpt lat="${p.coord[1]}" lon="${p.coord[0]}">
814
+ <time>${time}</time>
815
+ <extensions>
816
+ <speed>${p.speed.toFixed(4)}</speed>
817
+ <bearing>${p.bearing.toFixed(4)}</bearing>
818
+ <accuracy>${p.accuracy.toFixed(2)}</accuracy>
819
+ </extensions>
820
+ </trkpt>`;
821
+ }).join("\n");
822
+ return `<?xml version="1.0" encoding="UTF-8"?>
823
+ <gpx version="1.1" creator="@ingissa/navcore-simulator"
824
+ xmlns="http://www.topografix.com/GPX/1/1"
825
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
826
+ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
827
+ <metadata>
828
+ <name>${session.id}</name>
829
+ <time>${session.startedAt}</time>
830
+ </metadata>
831
+ <trk>
832
+ <name>${session.id}</name>
833
+ <trkseg>
834
+ ` + trkpts + `
835
+ </trkseg>
836
+ </trk>
837
+ </gpx>`;
838
+ }
839
+ static _toCsv(session) {
840
+ const header = "timestamp,lng,lat,bearing,speed,accuracy";
841
+ const rows = session.positions.map(
842
+ (p) => `${p.timestamp},${p.coord[0]},${p.coord[1]},${p.bearing.toFixed(4)},${p.speed.toFixed(4)},${p.accuracy.toFixed(2)}`
843
+ );
844
+ return [header, ...rows].join("\n");
845
+ }
846
+ };
847
+
848
+ // src/fleet/FleetSimulator.ts
849
+ var FleetSimulator = class _FleetSimulator {
850
+ constructor(engines, profiles, baseConfig) {
851
+ this.engines = engines;
852
+ this.profiles = profiles;
853
+ this.baseConfig = baseConfig;
854
+ }
855
+ /**
856
+ * Spawn N SimulatorEngine instances.
857
+ *
858
+ * @param count - number of vehicles
859
+ * @param config - base config (onPositionUpdate is per-vehicle, excluded here)
860
+ */
861
+ static spawn(count, config) {
862
+ const { profiles = [], ...baseConfig } = config;
863
+ const engines = [];
864
+ const resolvedProfiles = [];
865
+ for (let i = 0; i < count; i++) {
866
+ const profile = profiles[i] ?? {};
867
+ resolvedProfiles.push(profile);
868
+ const engine = new SimulatorEngine({
869
+ ...baseConfig,
870
+ ...profile,
871
+ onPositionUpdate: void 0
872
+ });
873
+ engines.push(engine);
874
+ }
875
+ return new _FleetSimulator(engines, resolvedProfiles, baseConfig);
876
+ }
877
+ /** Start all engines simultaneously */
878
+ start() {
879
+ for (const engine of this.engines) {
880
+ engine.startRecording();
881
+ engine.start();
882
+ }
883
+ }
884
+ /** Stop all engines */
885
+ stop() {
886
+ for (const engine of this.engines) {
887
+ engine.stop();
888
+ }
889
+ }
890
+ /**
891
+ * Get the engine at the given index.
892
+ * Use this for per-vehicle control (seek, setSpeed, addScenario, etc.)
893
+ */
894
+ vehicle(index) {
895
+ const engine = this.engines[index];
896
+ if (!engine) throw new Error(`FleetSimulator: no vehicle at index ${index}`);
897
+ return engine;
898
+ }
899
+ /**
900
+ * Stop all engines, collect sessions, and return an aggregate report.
901
+ */
902
+ report() {
903
+ const vehicleReports = this.engines.map((engine, i) => {
904
+ const session = engine.stopRecording();
905
+ const profile = this.profiles[i] ?? {};
906
+ return {
907
+ id: i,
908
+ label: profile.label ?? `vehicle-${i}`,
909
+ session,
910
+ profile
911
+ };
912
+ });
913
+ const avgScoreByProfile = {};
914
+ for (const v of vehicleReports) {
915
+ const key = v.label;
916
+ const existing = avgScoreByProfile[key];
917
+ if (existing === void 0) {
918
+ avgScoreByProfile[key] = v.session.stats.avgSpeedKmh;
919
+ } else {
920
+ avgScoreByProfile[key] = (existing + v.session.stats.avgSpeedKmh) / 2;
921
+ }
922
+ }
923
+ const segmentCount = this.baseConfig.routeGeometry.length;
924
+ const deviationsBySegment = new Array(segmentCount).fill(0);
925
+ for (const v of vehicleReports) {
926
+ for (const pos of v.session.positions) {
927
+ const segIdx = pos.metadata?.segmentIndex ?? 0;
928
+ if (segIdx < segmentCount) {
929
+ deviationsBySegment[segIdx] = (deviationsBySegment[segIdx] ?? 0) + pos.accuracy;
930
+ }
931
+ }
932
+ }
933
+ for (let i = 0; i < deviationsBySegment.length; i++) {
934
+ deviationsBySegment[i] = (deviationsBySegment[i] ?? 0) / Math.max(1, vehicleReports.length);
935
+ }
936
+ return {
937
+ vehicles: vehicleReports,
938
+ comparison: { avgScoreByProfile, deviationsBySegment }
939
+ };
940
+ }
941
+ /** Destroy all engines and clean up */
942
+ destroy() {
943
+ for (const engine of this.engines) {
944
+ engine.destroy();
945
+ }
946
+ }
947
+ };
948
+
949
+ export { FleetSimulator, GpxParser, NoiseEngine, RouteWalker, ScenarioInjector, SessionRecorder, SimulatorEngine };