@ingissa/navcore-simulator 1.0.3
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.
- package/README.md +426 -0
- package/dist/core/NoiseEngine.d.ts +46 -0
- package/dist/core/NoiseEngine.d.ts.map +1 -0
- package/dist/core/NoiseEngine.js +106 -0
- package/dist/core/RouteWalker.d.ts +75 -0
- package/dist/core/RouteWalker.d.ts.map +1 -0
- package/dist/core/RouteWalker.js +177 -0
- package/dist/core/SimulatorEngine.d.ts +53 -0
- package/dist/core/SimulatorEngine.d.ts.map +1 -0
- package/dist/core/SimulatorEngine.js +283 -0
- package/dist/core/types.d.ts +129 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +7 -0
- package/dist/fleet/FleetSimulator.d.ts +60 -0
- package/dist/fleet/FleetSimulator.d.ts.map +1 -0
- package/dist/fleet/FleetSimulator.js +114 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/node.d.ts +26 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +25 -0
- package/dist/react-native/SimulatorControls.d.ts +35 -0
- package/dist/react-native/SimulatorControls.d.ts.map +1 -0
- package/dist/react-native/SimulatorControls.js +254 -0
- package/dist/react-native/useSimulator.d.ts +55 -0
- package/dist/react-native/useSimulator.d.ts.map +1 -0
- package/dist/react-native/useSimulator.js +97 -0
- package/dist/replay/GpxParser.d.ts +40 -0
- package/dist/replay/GpxParser.d.ts.map +1 -0
- package/dist/replay/GpxParser.js +95 -0
- package/dist/replay/SessionRecorder.d.ts +37 -0
- package/dist/replay/SessionRecorder.d.ts.map +1 -0
- package/dist/replay/SessionRecorder.js +120 -0
- package/dist/scenarios/ScenarioInjector.d.ts +55 -0
- package/dist/scenarios/ScenarioInjector.d.ts.map +1 -0
- package/dist/scenarios/ScenarioInjector.js +150 -0
- package/package.json +48 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouteWalker — Manages traversal of a route geometry array.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the inline useGpsSimulator hook so the traversal logic
|
|
5
|
+
* is testable independently of React, timers, or noise.
|
|
6
|
+
*
|
|
7
|
+
* ZERO dependencies beyond @navcore/core types.
|
|
8
|
+
*/
|
|
9
|
+
import { getBearingBetweenPoints, haversineDistance, } from '@ingissa/navcore-core';
|
|
10
|
+
export class RouteWalker {
|
|
11
|
+
constructor(geometry, startIndex = 0) {
|
|
12
|
+
if (geometry.length < 2) {
|
|
13
|
+
throw new Error('RouteWalker requires at least 2 geometry points');
|
|
14
|
+
}
|
|
15
|
+
this.geometry = geometry;
|
|
16
|
+
this._index = Math.max(0, Math.min(startIndex, geometry.length - 1));
|
|
17
|
+
this._isComplete = false;
|
|
18
|
+
// Pre-compute cumulative distances for O(1) distanceToIndex()
|
|
19
|
+
this._cumulativeDistances = new Array(geometry.length).fill(0);
|
|
20
|
+
for (let i = 1; i < geometry.length; i++) {
|
|
21
|
+
const a = geometry[i - 1];
|
|
22
|
+
const b = geometry[i];
|
|
23
|
+
this._cumulativeDistances[i] =
|
|
24
|
+
(this._cumulativeDistances[i - 1] ?? 0) +
|
|
25
|
+
haversineDistance(a[1], a[0], b[1], b[0]);
|
|
26
|
+
}
|
|
27
|
+
this._distanceTravelled = this._cumulativeDistances[this._index] ?? 0;
|
|
28
|
+
}
|
|
29
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Advance the walker by `steps` geometry points.
|
|
32
|
+
* Fractional steps are supported for sub-second precision.
|
|
33
|
+
*/
|
|
34
|
+
advance(steps) {
|
|
35
|
+
if (this._isComplete)
|
|
36
|
+
return this._currentPosition();
|
|
37
|
+
this._index += steps;
|
|
38
|
+
return this._finalizeAdvance();
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Advance the walker by a physical distance in metres.
|
|
42
|
+
* This is the preferred way for smooth constant-speed simulation.
|
|
43
|
+
*/
|
|
44
|
+
advanceByDistance(meters) {
|
|
45
|
+
if (this._isComplete)
|
|
46
|
+
return this._currentPosition();
|
|
47
|
+
this._distanceTravelled += meters;
|
|
48
|
+
if (this._distanceTravelled >= this.totalDistanceM) {
|
|
49
|
+
this._distanceTravelled = this.totalDistanceM;
|
|
50
|
+
this._index = this.geometry.length - 1;
|
|
51
|
+
this._isComplete = true;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
this._index = this._distanceToIndex(this._distanceTravelled);
|
|
55
|
+
}
|
|
56
|
+
return this._currentPosition();
|
|
57
|
+
}
|
|
58
|
+
_finalizeAdvance() {
|
|
59
|
+
if (this._index >= this.geometry.length - 1) {
|
|
60
|
+
this._index = this.geometry.length - 1;
|
|
61
|
+
this._isComplete = true;
|
|
62
|
+
}
|
|
63
|
+
this._distanceTravelled = this._interpolatedDistance(this._index);
|
|
64
|
+
return this._currentPosition();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Seek directly to a geometry index (integer or fractional).
|
|
68
|
+
* Recalculates bearing from the new position.
|
|
69
|
+
*/
|
|
70
|
+
seek(index) {
|
|
71
|
+
this._index = Math.max(0, Math.min(index, this.geometry.length - 1));
|
|
72
|
+
this._isComplete = this._index >= this.geometry.length - 1;
|
|
73
|
+
this._distanceTravelled = this._interpolatedDistance(this._index);
|
|
74
|
+
return this._currentPosition();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get an interpolated coordinate between geometry[index] and geometry[index+1].
|
|
78
|
+
* @param index - integer index
|
|
79
|
+
* @param fraction - 0..1 interpolation factor
|
|
80
|
+
*/
|
|
81
|
+
interpolate(index, fraction) {
|
|
82
|
+
const clampedIdx = Math.max(0, Math.min(index, this.geometry.length - 2));
|
|
83
|
+
const a = this.geometry[clampedIdx];
|
|
84
|
+
const b = this.geometry[clampedIdx + 1];
|
|
85
|
+
const f = Math.max(0, Math.min(1, fraction));
|
|
86
|
+
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Compute the forward-looking bearing at a given integer index.
|
|
90
|
+
* At the last point, returns the bearing of the final segment.
|
|
91
|
+
*/
|
|
92
|
+
bearingAt(index) {
|
|
93
|
+
const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 2));
|
|
94
|
+
const a = this.geometry[clampedIdx];
|
|
95
|
+
const b = this.geometry[clampedIdx + 1];
|
|
96
|
+
return getBearingBetweenPoints(a, b);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Total haversine distance from the route start to the given index.
|
|
100
|
+
* Uses the pre-computed cumulative distances (O(1)).
|
|
101
|
+
*/
|
|
102
|
+
distanceAtIndex(index) {
|
|
103
|
+
const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 1));
|
|
104
|
+
return this._cumulativeDistances[clampedIdx] ?? 0;
|
|
105
|
+
}
|
|
106
|
+
distanceToIndex(index) {
|
|
107
|
+
return this.distanceAtIndex(index);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Convert a distance in metres back to a fractional index.
|
|
111
|
+
* Uses binary search for O(log N) performance.
|
|
112
|
+
*/
|
|
113
|
+
_distanceToIndex(targetDistance) {
|
|
114
|
+
if (targetDistance <= 0)
|
|
115
|
+
return 0;
|
|
116
|
+
if (targetDistance >= this.totalDistanceM)
|
|
117
|
+
return this.geometry.length - 1;
|
|
118
|
+
// Binary search for the segment [i, i+1] containing targetDistance
|
|
119
|
+
let low = 0;
|
|
120
|
+
let high = this._cumulativeDistances.length - 1;
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (low <= high) {
|
|
123
|
+
const mid = Math.floor((low + high) / 2);
|
|
124
|
+
if (this._cumulativeDistances[mid] <= targetDistance) {
|
|
125
|
+
i = mid;
|
|
126
|
+
low = mid + 1;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
high = mid - 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Now targetDistance is between i and i+1
|
|
133
|
+
const dStart = this._cumulativeDistances[i];
|
|
134
|
+
const dEnd = this._cumulativeDistances[i + 1];
|
|
135
|
+
const segmentDist = dEnd - dStart;
|
|
136
|
+
if (segmentDist <= 0)
|
|
137
|
+
return i;
|
|
138
|
+
const fraction = (targetDistance - dStart) / segmentDist;
|
|
139
|
+
return i + fraction;
|
|
140
|
+
}
|
|
141
|
+
// ── Readonly state ──────────────────────────────────────────────────────────
|
|
142
|
+
get totalDistanceM() {
|
|
143
|
+
return this._cumulativeDistances[this.geometry.length - 1] ?? 0;
|
|
144
|
+
}
|
|
145
|
+
get totalPoints() {
|
|
146
|
+
return this.geometry.length;
|
|
147
|
+
}
|
|
148
|
+
get isComplete() {
|
|
149
|
+
return this._isComplete;
|
|
150
|
+
}
|
|
151
|
+
get currentIndex() {
|
|
152
|
+
return this._index;
|
|
153
|
+
}
|
|
154
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
155
|
+
_currentPosition() {
|
|
156
|
+
const floorIdx = Math.min(Math.floor(this._index), this.geometry.length - 2);
|
|
157
|
+
const fraction = this._index - floorIdx;
|
|
158
|
+
const coord = this.interpolate(floorIdx, fraction);
|
|
159
|
+
const bearing = this.bearingAt(floorIdx);
|
|
160
|
+
return {
|
|
161
|
+
index: this._index,
|
|
162
|
+
coord,
|
|
163
|
+
bearing,
|
|
164
|
+
distanceTravelled: this._distanceTravelled,
|
|
165
|
+
isComplete: this._isComplete,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Interpolate cumulative distance for fractional index */
|
|
169
|
+
_interpolatedDistance(index) {
|
|
170
|
+
const floor = Math.min(Math.floor(index), this.geometry.length - 1);
|
|
171
|
+
const ceil = Math.min(floor + 1, this.geometry.length - 1);
|
|
172
|
+
const fraction = index - floor;
|
|
173
|
+
const dFloor = this._cumulativeDistances[floor] ?? 0;
|
|
174
|
+
const dCeil = this._cumulativeDistances[ceil] ?? 0;
|
|
175
|
+
return dFloor + (dCeil - dFloor) * fraction;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SimulatorEngine — The headless GPS simulation core.
|
|
3
|
+
*
|
|
4
|
+
* Works in Node.js for CI tests. NO React, NO Expo APIs.
|
|
5
|
+
* All timers are cleaned up on destroy(). No memory leaks.
|
|
6
|
+
*
|
|
7
|
+
* State machine: idle → running ⇄ paused → complete
|
|
8
|
+
*/
|
|
9
|
+
import type { SimPosition, SimulatorConfig, SimSession, Scenario } from './types';
|
|
10
|
+
export type EngineState = 'idle' | 'running' | 'paused' | 'complete';
|
|
11
|
+
export declare class SimulatorEngine {
|
|
12
|
+
private readonly config;
|
|
13
|
+
private walker;
|
|
14
|
+
private noise;
|
|
15
|
+
private injector;
|
|
16
|
+
private _state;
|
|
17
|
+
private _position;
|
|
18
|
+
private _speedMultiplier;
|
|
19
|
+
private _timer;
|
|
20
|
+
private _lastTick;
|
|
21
|
+
private static readonly TICK_MS;
|
|
22
|
+
private _recording;
|
|
23
|
+
private _recordedPositions;
|
|
24
|
+
private _recordedScenarios;
|
|
25
|
+
private _sessionId;
|
|
26
|
+
private _sessionStart;
|
|
27
|
+
private _noiseSamplesAdded;
|
|
28
|
+
constructor(config: SimulatorConfig);
|
|
29
|
+
start(): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
pause(): void;
|
|
32
|
+
resume(): void;
|
|
33
|
+
/**
|
|
34
|
+
* Jump to any geometry index.
|
|
35
|
+
* Bearing is recalculated from the new segment — seek() is always bearing-safe.
|
|
36
|
+
*/
|
|
37
|
+
seek(geometryIndex: number): void;
|
|
38
|
+
setSpeed(multiplier: 1 | 2 | 4 | 8): void;
|
|
39
|
+
addScenario(scenario: Scenario): void;
|
|
40
|
+
removeScenario(name: string): void;
|
|
41
|
+
clearScenarios(): void;
|
|
42
|
+
get state(): EngineState;
|
|
43
|
+
get position(): SimPosition | null;
|
|
44
|
+
get progress(): number;
|
|
45
|
+
startRecording(sessionId?: string): void;
|
|
46
|
+
stopRecording(): SimSession;
|
|
47
|
+
destroy(): void;
|
|
48
|
+
private _startTimer;
|
|
49
|
+
private _stopTimer;
|
|
50
|
+
private _tick;
|
|
51
|
+
private _buildAndEmitPosition;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=SimulatorEngine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SimulatorEngine.d.ts","sourceRoot":"","sources":["../../src/core/SimulatorEngine.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAWlF,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,UAAU,CAAC;AAErE,qBAAa,eAAe;IAE1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAEiF;IAGxG,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,QAAQ,CAAmB;IAGnC,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,gBAAgB,CAAgB;IACxC,OAAO,CAAC,MAAM,CAA+C;IAC7D,OAAO,CAAC,SAAS,CAAa;IAE9B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAM;IAGrC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,kBAAkB,CAAsD;IAChF,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,aAAa,CAAuB;IAC5C,OAAO,CAAC,kBAAkB,CAAK;gBAEnB,MAAM,EAAE,eAAe;IA4CnC,KAAK,IAAI,IAAI;IAOb,IAAI,IAAI,IAAI;IAMZ,KAAK,IAAI,IAAI;IAMb,MAAM,IAAI,IAAI;IAMd;;;OAGG;IACH,IAAI,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI;IAkBjC,QAAQ,CAAC,UAAU,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI;IAOzC,WAAW,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI;IAIrC,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIlC,cAAc,IAAI,IAAI;IAMtB,IAAI,KAAK,IAAI,WAAW,CAEvB;IAED,IAAI,QAAQ,IAAI,WAAW,GAAG,IAAI,CAEjC;IAED,IAAI,QAAQ,IAAI,MAAM,CAGrB;IAID,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI;IASxC,aAAa,IAAI,UAAU;IAqC3B,OAAO,IAAI,IAAI;IAWf,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,KAAK;IA+Db,OAAO,CAAC,qBAAqB;CAqC9B"}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SimulatorEngine — The headless GPS simulation core.
|
|
3
|
+
*
|
|
4
|
+
* Works in Node.js for CI tests. NO React, NO Expo APIs.
|
|
5
|
+
* All timers are cleaned up on destroy(). No memory leaks.
|
|
6
|
+
*
|
|
7
|
+
* State machine: idle → running ⇄ paused → complete
|
|
8
|
+
*/
|
|
9
|
+
import { NoiseEngine } from './NoiseEngine';
|
|
10
|
+
import { RouteWalker } from './RouteWalker';
|
|
11
|
+
import { ScenarioInjector } from '../scenarios/ScenarioInjector';
|
|
12
|
+
/** Generate a UUID v4 without external dependencies */
|
|
13
|
+
function uuidV4() {
|
|
14
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
15
|
+
const r = (Math.random() * 16) | 0;
|
|
16
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
17
|
+
return v.toString(16);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export class SimulatorEngine {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
23
|
+
this._state = 'idle';
|
|
24
|
+
this._position = null;
|
|
25
|
+
this._timer = null;
|
|
26
|
+
this._lastTick = 0;
|
|
27
|
+
// ── Recording ────────────────────────────────────────────────────────────────
|
|
28
|
+
this._recording = false;
|
|
29
|
+
this._recordedPositions = [];
|
|
30
|
+
this._recordedScenarios = [];
|
|
31
|
+
this._sessionId = null;
|
|
32
|
+
this._sessionStart = null;
|
|
33
|
+
this._noiseSamplesAdded = 0;
|
|
34
|
+
const geometry = config.routeGeometry;
|
|
35
|
+
if (!geometry || geometry.length < 2) {
|
|
36
|
+
throw new Error('SimulatorEngine: routeGeometry must have at least 2 points');
|
|
37
|
+
}
|
|
38
|
+
this._speedMultiplier = config.speedMultiplier ?? 1;
|
|
39
|
+
this.config = {
|
|
40
|
+
routeGeometry: geometry,
|
|
41
|
+
speedKmh: config.speedKmh ?? 50,
|
|
42
|
+
speedMultiplier: this._speedMultiplier,
|
|
43
|
+
noiseProfile: config.noiseProfile ?? NoiseEngine.STANDARD,
|
|
44
|
+
loop: config.loop ?? false,
|
|
45
|
+
startIndex: config.startIndex ?? 0,
|
|
46
|
+
autoStart: config.autoStart ?? false,
|
|
47
|
+
onPositionUpdate: config.onPositionUpdate,
|
|
48
|
+
onRouteComplete: config.onRouteComplete,
|
|
49
|
+
onScenarioStart: config.onScenarioStart,
|
|
50
|
+
onScenarioEnd: config.onScenarioEnd,
|
|
51
|
+
};
|
|
52
|
+
this.walker = new RouteWalker(geometry, this.config.startIndex);
|
|
53
|
+
this.noise = new NoiseEngine(this.config.noiseProfile);
|
|
54
|
+
this.injector = new ScenarioInjector((s) => {
|
|
55
|
+
if (this._recording) {
|
|
56
|
+
this._recordedScenarios.push({ scenario: s, firedAt: Date.now() });
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
this.config.onScenarioStart?.(s);
|
|
60
|
+
}
|
|
61
|
+
catch (_) { /* never throw */ }
|
|
62
|
+
}, (s) => {
|
|
63
|
+
try {
|
|
64
|
+
this.config.onScenarioEnd?.(s);
|
|
65
|
+
}
|
|
66
|
+
catch (_) { /* never throw */ }
|
|
67
|
+
});
|
|
68
|
+
if (this.config.autoStart) {
|
|
69
|
+
this.start();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// ── Playback controls ────────────────────────────────────────────────────────
|
|
73
|
+
start() {
|
|
74
|
+
if (this._state === 'running')
|
|
75
|
+
return;
|
|
76
|
+
if (this._state === 'complete')
|
|
77
|
+
return;
|
|
78
|
+
this._state = 'running';
|
|
79
|
+
this._startTimer();
|
|
80
|
+
}
|
|
81
|
+
stop() {
|
|
82
|
+
this._stopTimer();
|
|
83
|
+
this._state = 'idle';
|
|
84
|
+
this._position = null;
|
|
85
|
+
}
|
|
86
|
+
pause() {
|
|
87
|
+
if (this._state !== 'running')
|
|
88
|
+
return;
|
|
89
|
+
this._stopTimer();
|
|
90
|
+
this._state = 'paused';
|
|
91
|
+
}
|
|
92
|
+
resume() {
|
|
93
|
+
if (this._state !== 'paused')
|
|
94
|
+
return;
|
|
95
|
+
this._state = 'running';
|
|
96
|
+
this._startTimer();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Jump to any geometry index.
|
|
100
|
+
* Bearing is recalculated from the new segment — seek() is always bearing-safe.
|
|
101
|
+
*/
|
|
102
|
+
seek(geometryIndex) {
|
|
103
|
+
const wasRunning = this._state === 'running';
|
|
104
|
+
if (wasRunning)
|
|
105
|
+
this._stopTimer();
|
|
106
|
+
const pos = this.walker.seek(geometryIndex);
|
|
107
|
+
this._buildAndEmitPosition(pos.coord, pos.bearing, pos.distanceTravelled, false);
|
|
108
|
+
// Reset the completion flag so simulation can continue from the new index
|
|
109
|
+
if (this._state === 'complete' && !pos.isComplete) {
|
|
110
|
+
this._state = 'idle';
|
|
111
|
+
}
|
|
112
|
+
if (wasRunning && !pos.isComplete) {
|
|
113
|
+
this._state = 'running';
|
|
114
|
+
this._startTimer();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
setSpeed(multiplier) {
|
|
118
|
+
this._speedMultiplier = multiplier;
|
|
119
|
+
// No need to restart timer anymore as it's a fixed frequency
|
|
120
|
+
}
|
|
121
|
+
// ── Scenario management ──────────────────────────────────────────────────────
|
|
122
|
+
addScenario(scenario) {
|
|
123
|
+
this.injector.register(scenario);
|
|
124
|
+
}
|
|
125
|
+
removeScenario(name) {
|
|
126
|
+
this.injector.remove(name);
|
|
127
|
+
}
|
|
128
|
+
clearScenarios() {
|
|
129
|
+
this.injector.clear();
|
|
130
|
+
}
|
|
131
|
+
// ── State accessors ──────────────────────────────────────────────────────────
|
|
132
|
+
get state() {
|
|
133
|
+
return this._state;
|
|
134
|
+
}
|
|
135
|
+
get position() {
|
|
136
|
+
return this._position;
|
|
137
|
+
}
|
|
138
|
+
get progress() {
|
|
139
|
+
if (this.walker.totalPoints <= 1)
|
|
140
|
+
return 0;
|
|
141
|
+
return Math.min(1, this.walker.currentIndex / (this.walker.totalPoints - 1));
|
|
142
|
+
}
|
|
143
|
+
// ── Session recording ────────────────────────────────────────────────────────
|
|
144
|
+
startRecording(sessionId) {
|
|
145
|
+
this._recording = true;
|
|
146
|
+
this._recordedPositions = [];
|
|
147
|
+
this._recordedScenarios = [];
|
|
148
|
+
this._sessionId = sessionId ?? uuidV4();
|
|
149
|
+
this._sessionStart = Date.now();
|
|
150
|
+
this._noiseSamplesAdded = 0;
|
|
151
|
+
}
|
|
152
|
+
stopRecording() {
|
|
153
|
+
this._recording = false;
|
|
154
|
+
const endedAt = Date.now();
|
|
155
|
+
const startedAt = this._sessionStart ?? endedAt;
|
|
156
|
+
const positions = this._recordedPositions;
|
|
157
|
+
const durationMs = endedAt - startedAt;
|
|
158
|
+
let distanceTravelledM = 0;
|
|
159
|
+
if (positions.length > 0) {
|
|
160
|
+
distanceTravelledM = positions[positions.length - 1]?.metadata?.distanceTravelled ?? 0;
|
|
161
|
+
}
|
|
162
|
+
const avgSpeedKmh = durationMs > 0
|
|
163
|
+
? ((distanceTravelledM / (durationMs / 1000)) * 3.6)
|
|
164
|
+
: 0;
|
|
165
|
+
return {
|
|
166
|
+
id: this._sessionId ?? uuidV4(),
|
|
167
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
168
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
169
|
+
route: this.config.routeGeometry,
|
|
170
|
+
positions,
|
|
171
|
+
scenarios: this._recordedScenarios,
|
|
172
|
+
stats: {
|
|
173
|
+
durationMs,
|
|
174
|
+
distanceTravelledM,
|
|
175
|
+
avgSpeedKmh,
|
|
176
|
+
noiseSamplesAdded: this._noiseSamplesAdded,
|
|
177
|
+
scenariosFired: this._recordedScenarios.length,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
182
|
+
destroy() {
|
|
183
|
+
this._stopTimer();
|
|
184
|
+
this.injector.destroy();
|
|
185
|
+
this._state = 'idle';
|
|
186
|
+
this._position = null;
|
|
187
|
+
this._recording = false;
|
|
188
|
+
this._recordedPositions = [];
|
|
189
|
+
}
|
|
190
|
+
// ── Private ──────────────────────────────────────────────────────────────────
|
|
191
|
+
_startTimer() {
|
|
192
|
+
this._lastTick = Date.now();
|
|
193
|
+
this._timer = setInterval(() => this._tick(), SimulatorEngine.TICK_MS);
|
|
194
|
+
}
|
|
195
|
+
_stopTimer() {
|
|
196
|
+
if (this._timer !== null) {
|
|
197
|
+
clearInterval(this._timer);
|
|
198
|
+
this._timer = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
_tick() {
|
|
202
|
+
if (this._state !== 'running')
|
|
203
|
+
return;
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const deltaMs = now - this._lastTick;
|
|
206
|
+
this._lastTick = now;
|
|
207
|
+
// Calculate physical distance to move in this tick
|
|
208
|
+
// meters = (km/h / 3.6) * seconds * multiplier
|
|
209
|
+
const speedMs = (this.config.speedKmh / 3.6);
|
|
210
|
+
const distanceToMove = speedMs * (deltaMs / 1000) * this._speedMultiplier;
|
|
211
|
+
const walkerPos = this.walker.advanceByDistance(distanceToMove);
|
|
212
|
+
// Fire any scenarios at this index
|
|
213
|
+
const activeScenarioName = this.injector.tick(walkerPos.index);
|
|
214
|
+
// Apply noise (skip if GPS_BLACKOUT scenario is active)
|
|
215
|
+
const activeScenario = this.injector.activeScenario;
|
|
216
|
+
let coord = walkerPos.coord;
|
|
217
|
+
let isNoisy = false;
|
|
218
|
+
if (activeScenario?.type !== 'GPS_BLACKOUT') {
|
|
219
|
+
coord = this.noise.apply(walkerPos.coord);
|
|
220
|
+
isNoisy = this.noise.currentProfile.stdDevM > 0;
|
|
221
|
+
if (isNoisy)
|
|
222
|
+
this._noiseSamplesAdded++;
|
|
223
|
+
}
|
|
224
|
+
// Compute speed — apply speed spike if active
|
|
225
|
+
//let speedMs = (this.config.speedKmh / 3.6);
|
|
226
|
+
if (activeScenario?.type === 'SPEED_SPIKE') {
|
|
227
|
+
const spikeKmh = activeScenario.params?.['spikeKmh'] ?? this.config.speedKmh;
|
|
228
|
+
//speedMs = spikeKmh / 3.6;
|
|
229
|
+
}
|
|
230
|
+
// Compute accuracy — degrade if ACCURACY_DROP scenario is active
|
|
231
|
+
let accuracy = 8 + Math.random() * 6;
|
|
232
|
+
if (activeScenario?.type === 'ACCURACY_DROP') {
|
|
233
|
+
accuracy = activeScenario.params?.['newAccuracyM'] ?? accuracy;
|
|
234
|
+
}
|
|
235
|
+
this._buildAndEmitPosition(coord, walkerPos.bearing, walkerPos.distanceTravelled, isNoisy, {
|
|
236
|
+
speedMs,
|
|
237
|
+
accuracy,
|
|
238
|
+
activeScenarioName,
|
|
239
|
+
segmentIndex: Math.floor(walkerPos.index),
|
|
240
|
+
});
|
|
241
|
+
if (walkerPos.isComplete) {
|
|
242
|
+
this._stopTimer();
|
|
243
|
+
if (this.config.loop) {
|
|
244
|
+
// Restart from the beginning
|
|
245
|
+
this.walker = new RouteWalker(this.config.routeGeometry, 0);
|
|
246
|
+
this._startTimer();
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
this._state = 'complete';
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
this.config.onRouteComplete?.();
|
|
253
|
+
}
|
|
254
|
+
catch (_) { /* never throw */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
_buildAndEmitPosition(coord, bearing, distanceTravelled, isNoisy, opts) {
|
|
258
|
+
const speedMs = opts?.speedMs ?? (this.config.speedKmh / 3.6);
|
|
259
|
+
const accuracy = opts?.accuracy ?? 8;
|
|
260
|
+
const pos = {
|
|
261
|
+
coord: coord,
|
|
262
|
+
bearing,
|
|
263
|
+
speed: speedMs,
|
|
264
|
+
accuracy,
|
|
265
|
+
timestamp: Date.now(),
|
|
266
|
+
metadata: {
|
|
267
|
+
segmentIndex: opts?.segmentIndex ?? Math.floor(this.walker.currentIndex),
|
|
268
|
+
distanceTravelled,
|
|
269
|
+
isNoisy,
|
|
270
|
+
scenarioActive: opts?.activeScenarioName,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
this._position = pos;
|
|
274
|
+
if (this._recording) {
|
|
275
|
+
this._recordedPositions.push(pos);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
this.config.onPositionUpdate?.(pos);
|
|
279
|
+
}
|
|
280
|
+
catch (_) { /* never throw */ }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
SimulatorEngine.TICK_MS = 50; // 20 FPS for smooth movement
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @navcore/simulator — Core Types
|
|
3
|
+
*
|
|
4
|
+
* These types are the foundation of the entire simulator package.
|
|
5
|
+
* All other modules build on top of these definitions.
|
|
6
|
+
*/
|
|
7
|
+
export type Coordinate = [number, number];
|
|
8
|
+
/**
|
|
9
|
+
* A single simulated GPS position, emitted by SimulatorEngine on every tick.
|
|
10
|
+
* Shape is intentionally identical to the current inline SimPosition in
|
|
11
|
+
* NavCoreTestScreen so that migration is a search-and-replace.
|
|
12
|
+
*/
|
|
13
|
+
export interface SimPosition {
|
|
14
|
+
/** [longitude, latitude] */
|
|
15
|
+
coord: Coordinate;
|
|
16
|
+
/** Heading in degrees 0–360 */
|
|
17
|
+
bearing: number;
|
|
18
|
+
/** Speed in metres per second */
|
|
19
|
+
speed: number;
|
|
20
|
+
/** Simulated GPS accuracy radius in metres */
|
|
21
|
+
accuracy: number;
|
|
22
|
+
/** Unix timestamp in milliseconds */
|
|
23
|
+
timestamp: number;
|
|
24
|
+
/** Optional debug metadata */
|
|
25
|
+
metadata?: {
|
|
26
|
+
/** Which geometry segment the vehicle is currently on */
|
|
27
|
+
segmentIndex: number;
|
|
28
|
+
/** Total metres travelled from route start */
|
|
29
|
+
distanceTravelled: number;
|
|
30
|
+
/** Whether noise was applied on this tick */
|
|
31
|
+
isNoisy: boolean;
|
|
32
|
+
/** Name of the active scenario, if any */
|
|
33
|
+
scenarioActive?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Noise model applied to simulated GPS coordinates each tick.
|
|
38
|
+
*/
|
|
39
|
+
export interface NoiseProfile {
|
|
40
|
+
/** Noise type */
|
|
41
|
+
type: 'NONE' | 'GAUSSIAN' | 'URBAN' | 'TUNNEL' | 'CUSTOM';
|
|
42
|
+
/** Standard deviation of the Gaussian noise in metres (default: 12) */
|
|
43
|
+
stdDevM: number;
|
|
44
|
+
/**
|
|
45
|
+
* Optional seed for deterministic noise generation.
|
|
46
|
+
* Same seed + same route = identical output (critical for regression tests).
|
|
47
|
+
*/
|
|
48
|
+
seed?: number;
|
|
49
|
+
/**
|
|
50
|
+
* For CUSTOM type: return [lngOffset, latOffset] for each tick.
|
|
51
|
+
* @param index - current geometry segment index
|
|
52
|
+
*/
|
|
53
|
+
customFn?: (index: number) => [number, number];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A scenario that is injected at a specific geometry index during simulation.
|
|
57
|
+
*/
|
|
58
|
+
export interface Scenario {
|
|
59
|
+
/** Unique name for this scenario */
|
|
60
|
+
name: string;
|
|
61
|
+
/** Geometry index at which the scenario fires */
|
|
62
|
+
triggerIndex: number;
|
|
63
|
+
/** Scenario type */
|
|
64
|
+
type: 'GPS_BLACKOUT' | 'WRONG_SIDE_ROAD' | 'UTURN' | 'SPEED_SPIKE' | 'ACCURACY_DROP' | 'CUSTOM';
|
|
65
|
+
/** How long the scenario lasts in milliseconds */
|
|
66
|
+
durationMs?: number;
|
|
67
|
+
/** Scenario-specific configuration */
|
|
68
|
+
params?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Full configuration object passed to SimulatorEngine.
|
|
72
|
+
* All callback fields are optional — the engine is fully usable without them
|
|
73
|
+
* (useful for CI/headless tests).
|
|
74
|
+
*/
|
|
75
|
+
export interface SimulatorConfig {
|
|
76
|
+
/** The route geometry to simulate along */
|
|
77
|
+
routeGeometry: Coordinate[];
|
|
78
|
+
/** Simulated vehicle speed in km/h (default: 50) */
|
|
79
|
+
speedKmh?: number;
|
|
80
|
+
/** Playback speed multiplier: 1×, 2×, 4×, 8× (default: 1) */
|
|
81
|
+
speedMultiplier?: 1 | 2 | 4 | 8;
|
|
82
|
+
/** Noise model applied to each position (default: NoiseEngine.STANDARD) */
|
|
83
|
+
noiseProfile?: NoiseProfile;
|
|
84
|
+
/** Whether to restart from the beginning when the route ends (default: false) */
|
|
85
|
+
loop?: boolean;
|
|
86
|
+
/** Start simulation from this geometry index (default: 0) */
|
|
87
|
+
startIndex?: number;
|
|
88
|
+
/** If true, start() is called automatically after construction (default: false) */
|
|
89
|
+
autoStart?: boolean;
|
|
90
|
+
/** Called on every tick with the new simulated position */
|
|
91
|
+
onPositionUpdate?: (pos: SimPosition) => void;
|
|
92
|
+
/** Called when the route end is reached (or loop restarts) */
|
|
93
|
+
onRouteComplete?: () => void;
|
|
94
|
+
/** Called when a scenario begins */
|
|
95
|
+
onScenarioStart?: (scenario: Scenario) => void;
|
|
96
|
+
/** Called when a scenario ends */
|
|
97
|
+
onScenarioEnd?: (scenario: Scenario) => void;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A complete recorded simulation session.
|
|
101
|
+
* Produced by SimulatorEngine.stopRecording() / SessionRecorder.stop().
|
|
102
|
+
*/
|
|
103
|
+
export interface SimSession {
|
|
104
|
+
/** UUID v4 session identifier */
|
|
105
|
+
id: string;
|
|
106
|
+
/** ISO 8601 start timestamp */
|
|
107
|
+
startedAt: string;
|
|
108
|
+
/** ISO 8601 end timestamp */
|
|
109
|
+
endedAt: string;
|
|
110
|
+
/** The route geometry used */
|
|
111
|
+
route: Coordinate[];
|
|
112
|
+
/** All SimPositions emitted during the session */
|
|
113
|
+
positions: SimPosition[];
|
|
114
|
+
/** Scenarios that fired, with their tick timestamps */
|
|
115
|
+
scenarios: Array<{
|
|
116
|
+
scenario: Scenario;
|
|
117
|
+
/** Unix ms when the scenario fired */
|
|
118
|
+
firedAt: number;
|
|
119
|
+
}>;
|
|
120
|
+
/** Aggregate statistics for this session */
|
|
121
|
+
stats: {
|
|
122
|
+
durationMs: number;
|
|
123
|
+
distanceTravelledM: number;
|
|
124
|
+
avgSpeedKmh: number;
|
|
125
|
+
noiseSamplesAdded: number;
|
|
126
|
+
scenariosFired: number;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/core/types.ts"],"names":[],"mappings":"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"}
|