@ingissa/navcore-simulator 1.0.3 → 1.0.4

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