@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.
- package/LICENSE +3 -3
- package/README.md +161 -356
- package/dist/index.d.mts +87 -0
- package/dist/{simulator/src/react-native/useSimulator.d.ts → index.d.ts} +37 -9
- package/dist/index.js +1393 -0
- package/dist/index.mjs +1383 -0
- package/dist/node.d.mts +527 -0
- package/dist/node.d.ts +527 -0
- package/dist/node.js +957 -0
- package/dist/node.mjs +949 -0
- package/dist/react-native.d.mts +4 -0
- package/dist/react-native.d.ts +4 -0
- package/dist/react-native.js +1393 -0
- package/dist/react-native.mjs +1383 -0
- package/package.json +61 -58
- package/dist/core/src/NavCore.d.ts +0 -142
- package/dist/core/src/NavCore.d.ts.map +0 -1
- package/dist/core/src/NavCore.js +0 -390
- package/dist/core/src/bearing/BearingEngine.d.ts +0 -24
- package/dist/core/src/bearing/BearingEngine.d.ts.map +0 -1
- package/dist/core/src/bearing/BearingEngine.js +0 -50
- package/dist/core/src/constants.d.ts +0 -34
- package/dist/core/src/constants.d.ts.map +0 -1
- package/dist/core/src/constants.js +0 -41
- package/dist/core/src/corridor/DeviationDetector.d.ts +0 -33
- package/dist/core/src/corridor/DeviationDetector.d.ts.map +0 -1
- package/dist/core/src/corridor/DeviationDetector.js +0 -83
- package/dist/core/src/corridor/DynamicCorridor.d.ts +0 -13
- package/dist/core/src/corridor/DynamicCorridor.d.ts.map +0 -1
- package/dist/core/src/corridor/DynamicCorridor.js +0 -26
- package/dist/core/src/dead-reckoning/DeadReckoning.d.ts +0 -35
- package/dist/core/src/dead-reckoning/DeadReckoning.d.ts.map +0 -1
- package/dist/core/src/dead-reckoning/DeadReckoning.js +0 -49
- package/dist/core/src/directions/DirectionsProvider.d.ts +0 -70
- package/dist/core/src/directions/DirectionsProvider.d.ts.map +0 -1
- package/dist/core/src/directions/DirectionsProvider.js +0 -7
- package/dist/core/src/directions/OSRMDirectionsProvider.d.ts +0 -26
- package/dist/core/src/directions/OSRMDirectionsProvider.d.ts.map +0 -1
- package/dist/core/src/directions/OSRMDirectionsProvider.js +0 -90
- package/dist/core/src/directions/OpenRouteServiceProvider.d.ts +0 -30
- package/dist/core/src/directions/OpenRouteServiceProvider.d.ts.map +0 -1
- package/dist/core/src/directions/OpenRouteServiceProvider.js +0 -111
- package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts +0 -33
- package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts.map +0 -1
- package/dist/core/src/directions/ValhallaDirectionsProvider.js +0 -155
- package/dist/core/src/eta/ETAEngine.d.ts +0 -61
- package/dist/core/src/eta/ETAEngine.d.ts.map +0 -1
- package/dist/core/src/eta/ETAEngine.js +0 -74
- package/dist/core/src/features.d.ts +0 -76
- package/dist/core/src/features.d.ts.map +0 -1
- package/dist/core/src/features.js +0 -76
- package/dist/core/src/geo/bearing.d.ts +0 -11
- package/dist/core/src/geo/bearing.d.ts.map +0 -1
- package/dist/core/src/geo/bearing.js +0 -25
- package/dist/core/src/geo/cumulative.d.ts +0 -9
- package/dist/core/src/geo/cumulative.d.ts.map +0 -1
- package/dist/core/src/geo/cumulative.js +0 -20
- package/dist/core/src/geo/haversine.d.ts +0 -13
- package/dist/core/src/geo/haversine.d.ts.map +0 -1
- package/dist/core/src/geo/haversine.js +0 -39
- package/dist/core/src/geo/projection.d.ts +0 -7
- package/dist/core/src/geo/projection.d.ts.map +0 -1
- package/dist/core/src/geo/projection.js +0 -17
- package/dist/core/src/geofencing/GeofencingEngine.d.ts +0 -93
- package/dist/core/src/geofencing/GeofencingEngine.d.ts.map +0 -1
- package/dist/core/src/geofencing/GeofencingEngine.js +0 -154
- package/dist/core/src/index.d.ts +0 -66
- package/dist/core/src/index.d.ts.map +0 -1
- package/dist/core/src/index.js +0 -62
- package/dist/core/src/kalman/KalmanFilter2D.d.ts +0 -46
- package/dist/core/src/kalman/KalmanFilter2D.d.ts.map +0 -1
- package/dist/core/src/kalman/KalmanFilter2D.js +0 -77
- package/dist/core/src/license/LicenseManager.d.ts +0 -40
- package/dist/core/src/license/LicenseManager.d.ts.map +0 -1
- package/dist/core/src/license/LicenseManager.js +0 -119
- package/dist/core/src/license/verifyP256Signature.d.ts +0 -2
- package/dist/core/src/license/verifyP256Signature.d.ts.map +0 -1
- package/dist/core/src/license/verifyP256Signature.js +0 -26
- package/dist/core/src/off-route/NavigationFSM.d.ts +0 -42
- package/dist/core/src/off-route/NavigationFSM.d.ts.map +0 -1
- package/dist/core/src/off-route/NavigationFSM.js +0 -56
- package/dist/core/src/off-route/OffRouteFSM.d.ts +0 -70
- package/dist/core/src/off-route/OffRouteFSM.d.ts.map +0 -1
- package/dist/core/src/off-route/OffRouteFSM.js +0 -151
- package/dist/core/src/predictor/RouteOffsetPredictor.d.ts +0 -24
- package/dist/core/src/predictor/RouteOffsetPredictor.d.ts.map +0 -1
- package/dist/core/src/predictor/RouteOffsetPredictor.js +0 -55
- package/dist/core/src/progress/InstructionEditor.d.ts +0 -77
- package/dist/core/src/progress/InstructionEditor.d.ts.map +0 -1
- package/dist/core/src/progress/InstructionEditor.js +0 -151
- package/dist/core/src/progress/InstructionResolver.d.ts +0 -25
- package/dist/core/src/progress/InstructionResolver.d.ts.map +0 -1
- package/dist/core/src/progress/InstructionResolver.js +0 -78
- package/dist/core/src/progress/ProgressTracker.d.ts +0 -12
- package/dist/core/src/progress/ProgressTracker.d.ts.map +0 -1
- package/dist/core/src/progress/ProgressTracker.js +0 -22
- package/dist/core/src/renderer/MapRendererAdapter.d.ts +0 -72
- package/dist/core/src/renderer/MapRendererAdapter.d.ts.map +0 -1
- package/dist/core/src/renderer/MapRendererAdapter.js +0 -19
- package/dist/core/src/route-builder/CustomRouteBuilder.d.ts +0 -83
- package/dist/core/src/route-builder/CustomRouteBuilder.d.ts.map +0 -1
- package/dist/core/src/route-builder/CustomRouteBuilder.js +0 -160
- package/dist/core/src/route-plan/RoutePlan.d.ts +0 -38
- package/dist/core/src/route-plan/RoutePlan.d.ts.map +0 -1
- package/dist/core/src/route-plan/RoutePlan.js +0 -102
- package/dist/core/src/session/NavigationSession.d.ts +0 -81
- package/dist/core/src/session/NavigationSession.d.ts.map +0 -1
- package/dist/core/src/session/NavigationSession.js +0 -115
- package/dist/core/src/snapper/ParallelRoadResolver.d.ts +0 -86
- package/dist/core/src/snapper/ParallelRoadResolver.d.ts.map +0 -1
- package/dist/core/src/snapper/ParallelRoadResolver.js +0 -83
- package/dist/core/src/snapper/RouteSnapper.d.ts +0 -38
- package/dist/core/src/snapper/RouteSnapper.d.ts.map +0 -1
- package/dist/core/src/snapper/RouteSnapper.js +0 -80
- package/dist/core/src/voice/VoiceTriggerEngine.d.ts +0 -80
- package/dist/core/src/voice/VoiceTriggerEngine.d.ts.map +0 -1
- package/dist/core/src/voice/VoiceTriggerEngine.js +0 -108
- package/dist/simulator/src/core/NoiseEngine.d.ts +0 -46
- package/dist/simulator/src/core/NoiseEngine.d.ts.map +0 -1
- package/dist/simulator/src/core/NoiseEngine.js +0 -106
- package/dist/simulator/src/core/RouteWalker.d.ts +0 -78
- package/dist/simulator/src/core/RouteWalker.d.ts.map +0 -1
- package/dist/simulator/src/core/RouteWalker.js +0 -180
- package/dist/simulator/src/core/SimulatorEngine.d.ts +0 -53
- package/dist/simulator/src/core/SimulatorEngine.d.ts.map +0 -1
- package/dist/simulator/src/core/SimulatorEngine.js +0 -298
- package/dist/simulator/src/core/types.d.ts +0 -157
- package/dist/simulator/src/core/types.d.ts.map +0 -1
- package/dist/simulator/src/core/types.js +0 -7
- package/dist/simulator/src/fleet/FleetSimulator.d.ts +0 -60
- package/dist/simulator/src/fleet/FleetSimulator.d.ts.map +0 -1
- package/dist/simulator/src/fleet/FleetSimulator.js +0 -114
- package/dist/simulator/src/index.d.ts +0 -28
- package/dist/simulator/src/index.d.ts.map +0 -1
- package/dist/simulator/src/index.js +0 -29
- package/dist/simulator/src/node.d.ts +0 -26
- package/dist/simulator/src/node.d.ts.map +0 -1
- package/dist/simulator/src/node.js +0 -25
- package/dist/simulator/src/react-native/SimulatorControls.d.ts +0 -35
- package/dist/simulator/src/react-native/SimulatorControls.d.ts.map +0 -1
- package/dist/simulator/src/react-native/SimulatorControls.js +0 -254
- package/dist/simulator/src/react-native/useSimulator.d.ts.map +0 -1
- package/dist/simulator/src/react-native/useSimulator.js +0 -100
- package/dist/simulator/src/replay/GpxParser.d.ts +0 -40
- package/dist/simulator/src/replay/GpxParser.d.ts.map +0 -1
- package/dist/simulator/src/replay/GpxParser.js +0 -95
- package/dist/simulator/src/replay/SessionRecorder.d.ts +0 -37
- package/dist/simulator/src/replay/SessionRecorder.d.ts.map +0 -1
- package/dist/simulator/src/replay/SessionRecorder.js +0 -120
- package/dist/simulator/src/scenarios/ScenarioInjector.d.ts +0 -55
- package/dist/simulator/src/scenarios/ScenarioInjector.d.ts.map +0 -1
- package/dist/simulator/src/scenarios/ScenarioInjector.js +0 -150
- package/src/__tests__/integration/simulator-pipeline.test.ts +0 -233
- package/src/core/NoiseEngine.ts +0 -137
- package/src/core/RouteWalker.ts +0 -227
- package/src/core/SimulatorEngine.ts +0 -364
- package/src/core/__tests__/NoiseEngine.test.ts +0 -131
- package/src/core/__tests__/RouteWalker.test.ts +0 -139
- package/src/core/__tests__/SimulatorEngine.test.ts +0 -317
- package/src/core/types.ts +0 -191
- package/src/fleet/FleetSimulator.ts +0 -162
- package/src/index.ts +0 -54
- package/src/node.ts +0 -48
- package/src/react-native/SimulatorControls.tsx +0 -473
- package/src/react-native/useSimulator.ts +0 -155
- package/src/replay/GpxParser.ts +0 -122
- package/src/replay/SessionRecorder.ts +0 -138
- package/src/replay/__tests__/GpxParser.test.ts +0 -136
- package/src/scenarios/ScenarioInjector.ts +0 -170
- package/src/scenarios/__tests__/ScenarioInjector.test.ts +0 -171
package/dist/index.js
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var navcoreCore = require('@ingissa/navcore-core');
|
|
4
|
+
var react = require('react');
|
|
5
|
+
var reactNative = require('react-native');
|
|
6
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
7
|
+
|
|
8
|
+
// src/core/NoiseEngine.ts
|
|
9
|
+
var LAT_DEG_PER_M = 1 / 110540;
|
|
10
|
+
var LNG_DEG_PER_M = 1 / 111320;
|
|
11
|
+
var SeededRandom = class {
|
|
12
|
+
constructor(seed) {
|
|
13
|
+
this.state = seed >>> 0;
|
|
14
|
+
}
|
|
15
|
+
/** Returns a float in [0, 1) */
|
|
16
|
+
next() {
|
|
17
|
+
this.state = Math.imul(1664525, this.state) + 1013904223 >>> 0;
|
|
18
|
+
return this.state / 4294967296;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var NoiseEngine = class {
|
|
22
|
+
constructor(profile) {
|
|
23
|
+
this._tickIndex = 0;
|
|
24
|
+
this.profile = profile;
|
|
25
|
+
this.rng = profile.seed !== void 0 ? new SeededRandom(profile.seed) : null;
|
|
26
|
+
}
|
|
27
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Apply noise to a coordinate.
|
|
30
|
+
* Returns a new coordinate — never mutates the input.
|
|
31
|
+
*/
|
|
32
|
+
apply(coord) {
|
|
33
|
+
const idx = this._tickIndex++;
|
|
34
|
+
switch (this.profile.type) {
|
|
35
|
+
case "NONE":
|
|
36
|
+
return coord;
|
|
37
|
+
case "GAUSSIAN":
|
|
38
|
+
case "URBAN": {
|
|
39
|
+
const [dLng, dLat] = this._gaussianOffset(this.profile.stdDevM);
|
|
40
|
+
return [coord[0] + dLng, coord[1] + dLat];
|
|
41
|
+
}
|
|
42
|
+
case "TUNNEL":
|
|
43
|
+
return coord;
|
|
44
|
+
case "CUSTOM": {
|
|
45
|
+
if (!this.profile.customFn) return coord;
|
|
46
|
+
const [lngOff, latOff] = this.profile.customFn(idx);
|
|
47
|
+
return [coord[0] + lngOff, coord[1] + latOff];
|
|
48
|
+
}
|
|
49
|
+
default:
|
|
50
|
+
return coord;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Hot-swap the noise profile mid-simulation */
|
|
54
|
+
setProfile(profile) {
|
|
55
|
+
this.profile = profile;
|
|
56
|
+
this.rng = profile.seed !== void 0 ? new SeededRandom(profile.seed) : null;
|
|
57
|
+
this._tickIndex = 0;
|
|
58
|
+
}
|
|
59
|
+
get currentProfile() {
|
|
60
|
+
return this.profile;
|
|
61
|
+
}
|
|
62
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Box-Muller transform: produces Gaussian N(0, stdDev²) offset.
|
|
65
|
+
* Returns [lngOffset, latOffset] in degrees.
|
|
66
|
+
*/
|
|
67
|
+
_gaussianOffset(stdDevM) {
|
|
68
|
+
if (stdDevM === 0) return [0, 0];
|
|
69
|
+
const u1 = this._rand();
|
|
70
|
+
const u2 = this._rand();
|
|
71
|
+
const mag = stdDevM * Math.sqrt(-2 * Math.log(Math.max(u1, 1e-10)));
|
|
72
|
+
const z0 = mag * Math.cos(2 * Math.PI * u2);
|
|
73
|
+
const z1 = mag * Math.sin(2 * Math.PI * u2);
|
|
74
|
+
return [z0 * LNG_DEG_PER_M, z1 * LAT_DEG_PER_M];
|
|
75
|
+
}
|
|
76
|
+
/** Returns a float in [0, 1) — uses seeded RNG if available, else Math.random */
|
|
77
|
+
_rand() {
|
|
78
|
+
return this.rng ? this.rng.next() : Math.random();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// ── Static presets ─────────────────────────────────────────────────────────
|
|
82
|
+
/** No noise — returns the exact coordinate */
|
|
83
|
+
NoiseEngine.NONE = { type: "NONE", stdDevM: 0 };
|
|
84
|
+
/** Good urban GPS — ~3m standard deviation */
|
|
85
|
+
NoiseEngine.CLEAN = { type: "GAUSSIAN", stdDevM: 3 };
|
|
86
|
+
/** Standard Go4Exams value — ~12m standard deviation (matches original ±0.00011°) */
|
|
87
|
+
NoiseEngine.STANDARD = { type: "GAUSSIAN", stdDevM: 12 };
|
|
88
|
+
/** Dense city canyon — ~25m standard deviation */
|
|
89
|
+
NoiseEngine.URBAN = { type: "URBAN", stdDevM: 25 };
|
|
90
|
+
/** Bad conditions — ~50m standard deviation */
|
|
91
|
+
NoiseEngine.DEGRADED = { type: "GAUSSIAN", stdDevM: 50 };
|
|
92
|
+
var RouteWalker = class {
|
|
93
|
+
constructor(geometry, startIndex = 0) {
|
|
94
|
+
if (geometry.length < 2) {
|
|
95
|
+
throw new Error("RouteWalker requires at least 2 geometry points");
|
|
96
|
+
}
|
|
97
|
+
this.geometry = geometry;
|
|
98
|
+
this._index = Math.max(0, Math.min(startIndex, geometry.length - 1));
|
|
99
|
+
this._isComplete = false;
|
|
100
|
+
this._cumulativeDistances = new Array(geometry.length).fill(0);
|
|
101
|
+
for (let i = 1; i < geometry.length; i++) {
|
|
102
|
+
const a = geometry[i - 1];
|
|
103
|
+
const b = geometry[i];
|
|
104
|
+
this._cumulativeDistances[i] = (this._cumulativeDistances[i - 1] ?? 0) + navcoreCore.haversineDistance(a[1], a[0], b[1], b[0]);
|
|
105
|
+
}
|
|
106
|
+
this._distanceTravelled = this._cumulativeDistances[this._index] ?? 0;
|
|
107
|
+
}
|
|
108
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* Advance the walker by `steps` geometry points.
|
|
111
|
+
* Fractional steps are supported for sub-second precision.
|
|
112
|
+
*/
|
|
113
|
+
advance(steps) {
|
|
114
|
+
if (this._isComplete) return this._currentPosition();
|
|
115
|
+
this._index += steps;
|
|
116
|
+
return this._finalizeAdvance();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Advance the walker by a physical distance in metres.
|
|
120
|
+
* This is the preferred way for smooth constant-speed simulation.
|
|
121
|
+
*/
|
|
122
|
+
advanceByDistance(meters) {
|
|
123
|
+
if (this._isComplete) return this._currentPosition();
|
|
124
|
+
this._distanceTravelled += meters;
|
|
125
|
+
if (this._distanceTravelled >= this.totalDistanceM) {
|
|
126
|
+
this._distanceTravelled = this.totalDistanceM;
|
|
127
|
+
this._index = this.geometry.length - 1;
|
|
128
|
+
this._isComplete = true;
|
|
129
|
+
} else {
|
|
130
|
+
this._index = this._distanceToIndex(this._distanceTravelled);
|
|
131
|
+
}
|
|
132
|
+
return this._currentPosition();
|
|
133
|
+
}
|
|
134
|
+
_finalizeAdvance() {
|
|
135
|
+
if (this._index >= this.geometry.length - 1) {
|
|
136
|
+
this._index = this.geometry.length - 1;
|
|
137
|
+
this._isComplete = true;
|
|
138
|
+
}
|
|
139
|
+
this._distanceTravelled = this._interpolatedDistance(this._index);
|
|
140
|
+
return this._currentPosition();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Seek directly to a geometry index (integer or fractional).
|
|
144
|
+
* Recalculates bearing from the new position.
|
|
145
|
+
*/
|
|
146
|
+
seek(index) {
|
|
147
|
+
this._index = Math.max(0, Math.min(index, this.geometry.length - 1));
|
|
148
|
+
this._isComplete = this._index >= this.geometry.length - 1;
|
|
149
|
+
this._distanceTravelled = this._interpolatedDistance(this._index);
|
|
150
|
+
return this._currentPosition();
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get an interpolated coordinate between geometry[index] and geometry[index+1].
|
|
154
|
+
* @param index - integer index
|
|
155
|
+
* @param fraction - 0..1 interpolation factor
|
|
156
|
+
*/
|
|
157
|
+
interpolate(index, fraction) {
|
|
158
|
+
const clampedIdx = Math.max(0, Math.min(index, this.geometry.length - 2));
|
|
159
|
+
const a = this.geometry[clampedIdx];
|
|
160
|
+
const b = this.geometry[clampedIdx + 1];
|
|
161
|
+
const f = Math.max(0, Math.min(1, fraction));
|
|
162
|
+
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Compute the forward-looking bearing at a given integer index.
|
|
166
|
+
* At the last point, returns the bearing of the final segment.
|
|
167
|
+
*/
|
|
168
|
+
bearingAt(index) {
|
|
169
|
+
const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 2));
|
|
170
|
+
const a = this.geometry[clampedIdx];
|
|
171
|
+
const b = this.geometry[clampedIdx + 1];
|
|
172
|
+
return navcoreCore.getBearingBetweenPoints(a, b);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Total haversine distance from the route start to the given index.
|
|
176
|
+
* Uses the pre-computed cumulative distances (O(1)).
|
|
177
|
+
*/
|
|
178
|
+
distanceAtIndex(index) {
|
|
179
|
+
const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 1));
|
|
180
|
+
return this._cumulativeDistances[clampedIdx] ?? 0;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Alias for distanceAtIndex.
|
|
184
|
+
*/
|
|
185
|
+
distanceToIndex(index) {
|
|
186
|
+
return this.distanceAtIndex(index);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Convert a distance in metres back to a fractional index.
|
|
190
|
+
* Uses binary search for O(log N) performance.
|
|
191
|
+
*/
|
|
192
|
+
_distanceToIndex(targetDistance) {
|
|
193
|
+
if (targetDistance <= 0) return 0;
|
|
194
|
+
if (targetDistance >= this.totalDistanceM) return this.geometry.length - 1;
|
|
195
|
+
let low = 0;
|
|
196
|
+
let high = this._cumulativeDistances.length - 1;
|
|
197
|
+
let i = 0;
|
|
198
|
+
while (low <= high) {
|
|
199
|
+
const mid = Math.floor((low + high) / 2);
|
|
200
|
+
if (this._cumulativeDistances[mid] <= targetDistance) {
|
|
201
|
+
i = mid;
|
|
202
|
+
low = mid + 1;
|
|
203
|
+
} else {
|
|
204
|
+
high = mid - 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const dStart = this._cumulativeDistances[i];
|
|
208
|
+
const dEnd = this._cumulativeDistances[i + 1];
|
|
209
|
+
const segmentDist = dEnd - dStart;
|
|
210
|
+
if (segmentDist <= 0) return i;
|
|
211
|
+
const fraction = (targetDistance - dStart) / segmentDist;
|
|
212
|
+
return i + fraction;
|
|
213
|
+
}
|
|
214
|
+
// ── Readonly state ──────────────────────────────────────────────────────────
|
|
215
|
+
get totalDistanceM() {
|
|
216
|
+
return this._cumulativeDistances[this.geometry.length - 1] ?? 0;
|
|
217
|
+
}
|
|
218
|
+
get totalPoints() {
|
|
219
|
+
return this.geometry.length;
|
|
220
|
+
}
|
|
221
|
+
get isComplete() {
|
|
222
|
+
return this._isComplete;
|
|
223
|
+
}
|
|
224
|
+
get currentIndex() {
|
|
225
|
+
return this._index;
|
|
226
|
+
}
|
|
227
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
228
|
+
_currentPosition() {
|
|
229
|
+
const floorIdx = Math.min(Math.floor(this._index), this.geometry.length - 2);
|
|
230
|
+
const fraction = this._index - floorIdx;
|
|
231
|
+
const coord = this.interpolate(floorIdx, fraction);
|
|
232
|
+
const bearing = this.bearingAt(floorIdx);
|
|
233
|
+
return {
|
|
234
|
+
index: this._index,
|
|
235
|
+
coord,
|
|
236
|
+
bearing,
|
|
237
|
+
distanceTravelled: this._distanceTravelled,
|
|
238
|
+
isComplete: this._isComplete
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** Interpolate cumulative distance for fractional index */
|
|
242
|
+
_interpolatedDistance(index) {
|
|
243
|
+
const floor = Math.min(Math.floor(index), this.geometry.length - 1);
|
|
244
|
+
const ceil = Math.min(floor + 1, this.geometry.length - 1);
|
|
245
|
+
const fraction = index - floor;
|
|
246
|
+
const dFloor = this._cumulativeDistances[floor] ?? 0;
|
|
247
|
+
const dCeil = this._cumulativeDistances[ceil] ?? 0;
|
|
248
|
+
return dFloor + (dCeil - dFloor) * fraction;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// src/scenarios/ScenarioInjector.ts
|
|
253
|
+
var ScenarioInjector = class {
|
|
254
|
+
constructor(onStart, onEnd) {
|
|
255
|
+
/** Map from triggerIndex → Scenario */
|
|
256
|
+
this.scenarios = /* @__PURE__ */ new Map();
|
|
257
|
+
/** Tracks which scenarios have already fired (by name) to avoid double-fire */
|
|
258
|
+
this.fired = /* @__PURE__ */ new Set();
|
|
259
|
+
/** Currently active scenario, if any */
|
|
260
|
+
this._activeScenario = null;
|
|
261
|
+
this._activeEndTimer = null;
|
|
262
|
+
this.onStart = onStart ?? (() => void 0);
|
|
263
|
+
this.onEnd = onEnd ?? (() => void 0);
|
|
264
|
+
}
|
|
265
|
+
// ── Registration ────────────────────────────────────────────────────────────
|
|
266
|
+
register(scenario) {
|
|
267
|
+
this.scenarios.set(scenario.triggerIndex, scenario);
|
|
268
|
+
}
|
|
269
|
+
remove(name) {
|
|
270
|
+
for (const [idx, s2] of this.scenarios) {
|
|
271
|
+
if (s2.name === name) {
|
|
272
|
+
this.scenarios.delete(idx);
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
clear() {
|
|
278
|
+
this.scenarios.clear();
|
|
279
|
+
this.fired.clear();
|
|
280
|
+
this._clearActiveTimer();
|
|
281
|
+
this._activeScenario = null;
|
|
282
|
+
}
|
|
283
|
+
// ── Tick ────────────────────────────────────────────────────────────────────
|
|
284
|
+
/**
|
|
285
|
+
* Check and fire any scenario whose triggerIndex matches (or was passed) by
|
|
286
|
+
* currentIndex. Called by SimulatorEngine on every tick.
|
|
287
|
+
*
|
|
288
|
+
* Returns the name of the active scenario (if any), for metadata injection.
|
|
289
|
+
*/
|
|
290
|
+
tick(currentIndex) {
|
|
291
|
+
const floorIdx = Math.floor(currentIndex);
|
|
292
|
+
for (const [triggerIdx, scenario] of this.scenarios) {
|
|
293
|
+
if (floorIdx >= triggerIdx && !this.fired.has(scenario.name)) {
|
|
294
|
+
this.fired.add(scenario.name);
|
|
295
|
+
this._fire(scenario);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return this._activeScenario?.name;
|
|
299
|
+
}
|
|
300
|
+
get activeScenario() {
|
|
301
|
+
return this._activeScenario;
|
|
302
|
+
}
|
|
303
|
+
/** Clean up any pending timers */
|
|
304
|
+
destroy() {
|
|
305
|
+
this._clearActiveTimer();
|
|
306
|
+
}
|
|
307
|
+
// ── Static factories ────────────────────────────────────────────────────────
|
|
308
|
+
/**
|
|
309
|
+
* GPS blackout: no position updates for `durationMs`.
|
|
310
|
+
* Engine handles signal loss; this scenario signals the blackout period.
|
|
311
|
+
*/
|
|
312
|
+
static gpsBlackout(atIndex, durationMs) {
|
|
313
|
+
return {
|
|
314
|
+
name: `gps-blackout-@${atIndex}`,
|
|
315
|
+
triggerIndex: atIndex,
|
|
316
|
+
type: "GPS_BLACKOUT",
|
|
317
|
+
durationMs,
|
|
318
|
+
params: { durationMs }
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Wrong-side road: offset the simulated position by `offsetM` metres
|
|
323
|
+
* perpendicular to the route to stress-test the parallel road resolver.
|
|
324
|
+
*/
|
|
325
|
+
static wrongSideRoad(atIndex, offsetM) {
|
|
326
|
+
return {
|
|
327
|
+
name: `wrong-side-@${atIndex}`,
|
|
328
|
+
triggerIndex: atIndex,
|
|
329
|
+
type: "WRONG_SIDE_ROAD",
|
|
330
|
+
params: { offsetM }
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/** U-turn at the given index — stress-tests U-turn protection. */
|
|
334
|
+
static uTurn(atIndex) {
|
|
335
|
+
return {
|
|
336
|
+
name: `uturn-@${atIndex}`,
|
|
337
|
+
triggerIndex: atIndex,
|
|
338
|
+
type: "UTURN",
|
|
339
|
+
params: {}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
/** Speed spike: briefly report spikeKmh instead of the configured speed. */
|
|
343
|
+
static speedSpike(atIndex, spikeKmh, durationMs = 2e3) {
|
|
344
|
+
return {
|
|
345
|
+
name: `speed-spike-@${atIndex}`,
|
|
346
|
+
triggerIndex: atIndex,
|
|
347
|
+
type: "SPEED_SPIKE",
|
|
348
|
+
durationMs,
|
|
349
|
+
params: { spikeKmh }
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Accuracy drop: GPS accuracy degrades to newAccuracyM for durationMs.
|
|
354
|
+
*/
|
|
355
|
+
static accuracyDrop(atIndex, newAccuracyM, durationMs) {
|
|
356
|
+
return {
|
|
357
|
+
name: `accuracy-drop-@${atIndex}`,
|
|
358
|
+
triggerIndex: atIndex,
|
|
359
|
+
type: "ACCURACY_DROP",
|
|
360
|
+
durationMs,
|
|
361
|
+
params: { newAccuracyM }
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// ── Private ─────────────────────────────────────────────────────────────────
|
|
365
|
+
_fire(scenario) {
|
|
366
|
+
this._activeScenario = scenario;
|
|
367
|
+
try {
|
|
368
|
+
this.onStart(scenario);
|
|
369
|
+
} catch (_) {
|
|
370
|
+
}
|
|
371
|
+
if (scenario.durationMs != null && scenario.durationMs > 0) {
|
|
372
|
+
this._clearActiveTimer();
|
|
373
|
+
this._activeEndTimer = setTimeout(() => {
|
|
374
|
+
this._activeScenario = null;
|
|
375
|
+
try {
|
|
376
|
+
this.onEnd(scenario);
|
|
377
|
+
} catch (_) {
|
|
378
|
+
}
|
|
379
|
+
}, scenario.durationMs);
|
|
380
|
+
} else {
|
|
381
|
+
this._activeScenario = null;
|
|
382
|
+
try {
|
|
383
|
+
this.onEnd(scenario);
|
|
384
|
+
} catch (_) {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
_clearActiveTimer() {
|
|
389
|
+
if (this._activeEndTimer !== null) {
|
|
390
|
+
clearTimeout(this._activeEndTimer);
|
|
391
|
+
this._activeEndTimer = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// src/core/SimulatorEngine.ts
|
|
397
|
+
function uuidV4() {
|
|
398
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
399
|
+
const r = Math.random() * 16 | 0;
|
|
400
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
401
|
+
return v.toString(16);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
var SimulatorEngine = class {
|
|
405
|
+
constructor(config) {
|
|
406
|
+
// ── State ────────────────────────────────────────────────────────────────────
|
|
407
|
+
this._state = "idle";
|
|
408
|
+
this._position = null;
|
|
409
|
+
this._timer = null;
|
|
410
|
+
this._lastTick = 0;
|
|
411
|
+
// 20 FPS for smooth movement
|
|
412
|
+
// ── Recording ────────────────────────────────────────────────────────────────
|
|
413
|
+
this._recording = false;
|
|
414
|
+
this._recordedPositions = [];
|
|
415
|
+
this._recordedScenarios = [];
|
|
416
|
+
this._sessionId = null;
|
|
417
|
+
this._sessionStart = null;
|
|
418
|
+
this._noiseSamplesAdded = 0;
|
|
419
|
+
const geometry = config.routeGeometry;
|
|
420
|
+
if (!geometry || geometry.length < 2) {
|
|
421
|
+
throw new Error("SimulatorEngine: routeGeometry must have at least 2 points");
|
|
422
|
+
}
|
|
423
|
+
this._speedMultiplier = config.speedMultiplier ?? 1;
|
|
424
|
+
this.config = {
|
|
425
|
+
routeGeometry: geometry,
|
|
426
|
+
speedKmh: config.speedKmh ?? 50,
|
|
427
|
+
speedMultiplier: this._speedMultiplier,
|
|
428
|
+
noiseProfile: config.noiseProfile ?? NoiseEngine.STANDARD,
|
|
429
|
+
loop: config.loop ?? false,
|
|
430
|
+
startIndex: config.startIndex ?? 0,
|
|
431
|
+
autoStart: config.autoStart ?? false,
|
|
432
|
+
onPositionUpdate: config.onPositionUpdate,
|
|
433
|
+
onRouteComplete: config.onRouteComplete,
|
|
434
|
+
onScenarioStart: config.onScenarioStart,
|
|
435
|
+
onScenarioEnd: config.onScenarioEnd
|
|
436
|
+
};
|
|
437
|
+
this.walker = new RouteWalker(geometry, this.config.startIndex);
|
|
438
|
+
this.noise = new NoiseEngine(this.config.noiseProfile);
|
|
439
|
+
this.injector = new ScenarioInjector(
|
|
440
|
+
(s2) => {
|
|
441
|
+
if (this._recording) {
|
|
442
|
+
this._recordedScenarios.push({ scenario: s2, firedAt: Date.now() });
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
this.config.onScenarioStart?.(s2);
|
|
446
|
+
} catch (_) {
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
(s2) => {
|
|
450
|
+
try {
|
|
451
|
+
this.config.onScenarioEnd?.(s2);
|
|
452
|
+
} catch (_) {
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
if (this.config.autoStart) {
|
|
457
|
+
this.start();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// ── Playback controls ────────────────────────────────────────────────────────
|
|
461
|
+
start() {
|
|
462
|
+
if (this._state === "running") return;
|
|
463
|
+
if (this._state === "complete") return;
|
|
464
|
+
this._state = "running";
|
|
465
|
+
this._startTimer();
|
|
466
|
+
}
|
|
467
|
+
stop() {
|
|
468
|
+
this._stopTimer();
|
|
469
|
+
this._state = "idle";
|
|
470
|
+
this._position = null;
|
|
471
|
+
}
|
|
472
|
+
pause() {
|
|
473
|
+
if (this._state !== "running") return;
|
|
474
|
+
this._stopTimer();
|
|
475
|
+
this._state = "paused";
|
|
476
|
+
}
|
|
477
|
+
resume() {
|
|
478
|
+
if (this._state !== "paused") return;
|
|
479
|
+
this._state = "running";
|
|
480
|
+
this._startTimer();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Jump to any geometry index.
|
|
484
|
+
* Bearing is recalculated from the new segment — seek() is always bearing-safe.
|
|
485
|
+
*/
|
|
486
|
+
seek(geometryIndex) {
|
|
487
|
+
const wasRunning = this._state === "running";
|
|
488
|
+
if (wasRunning) this._stopTimer();
|
|
489
|
+
const pos = this.walker.seek(geometryIndex);
|
|
490
|
+
this._buildAndEmitPosition(pos.coord, pos.bearing, pos.distanceTravelled, false);
|
|
491
|
+
if (this._state === "complete" && !pos.isComplete) {
|
|
492
|
+
this._state = "idle";
|
|
493
|
+
}
|
|
494
|
+
if (wasRunning && !pos.isComplete) {
|
|
495
|
+
this._state = "running";
|
|
496
|
+
this._startTimer();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
setSpeed(multiplier) {
|
|
500
|
+
this._speedMultiplier = multiplier;
|
|
501
|
+
if (this._state === "running") {
|
|
502
|
+
this._stopTimer();
|
|
503
|
+
this._startTimer();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ── Scenario management ──────────────────────────────────────────────────────
|
|
507
|
+
addScenario(scenario) {
|
|
508
|
+
this.injector.register(scenario);
|
|
509
|
+
}
|
|
510
|
+
removeScenario(name) {
|
|
511
|
+
this.injector.remove(name);
|
|
512
|
+
}
|
|
513
|
+
clearScenarios() {
|
|
514
|
+
this.injector.clear();
|
|
515
|
+
}
|
|
516
|
+
// ── State accessors ──────────────────────────────────────────────────────────
|
|
517
|
+
get state() {
|
|
518
|
+
return this._state;
|
|
519
|
+
}
|
|
520
|
+
get position() {
|
|
521
|
+
return this._position;
|
|
522
|
+
}
|
|
523
|
+
get progress() {
|
|
524
|
+
if (this.walker.totalPoints <= 1) return 0;
|
|
525
|
+
return Math.min(1, this.walker.currentIndex / (this.walker.totalPoints - 1));
|
|
526
|
+
}
|
|
527
|
+
// ── Session recording ────────────────────────────────────────────────────────
|
|
528
|
+
startRecording(sessionId) {
|
|
529
|
+
this._recording = true;
|
|
530
|
+
this._recordedPositions = [];
|
|
531
|
+
this._recordedScenarios = [];
|
|
532
|
+
this._sessionId = sessionId ?? uuidV4();
|
|
533
|
+
this._sessionStart = Date.now();
|
|
534
|
+
this._noiseSamplesAdded = 0;
|
|
535
|
+
}
|
|
536
|
+
stopRecording() {
|
|
537
|
+
this._recording = false;
|
|
538
|
+
const endedAt = Date.now();
|
|
539
|
+
const startedAt = this._sessionStart ?? endedAt;
|
|
540
|
+
const positions = this._recordedPositions;
|
|
541
|
+
const durationMs = endedAt - startedAt;
|
|
542
|
+
let distanceTravelledM = 0;
|
|
543
|
+
if (positions.length > 0) {
|
|
544
|
+
distanceTravelledM = positions[positions.length - 1]?.metadata?.distanceTravelled ?? 0;
|
|
545
|
+
}
|
|
546
|
+
const avgSpeedKmh = durationMs > 0 ? distanceTravelledM / (durationMs / 1e3) * 3.6 : 0;
|
|
547
|
+
return {
|
|
548
|
+
id: this._sessionId ?? uuidV4(),
|
|
549
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
550
|
+
endedAt: new Date(endedAt).toISOString(),
|
|
551
|
+
route: this.config.routeGeometry,
|
|
552
|
+
positions,
|
|
553
|
+
scenarios: this._recordedScenarios,
|
|
554
|
+
stats: {
|
|
555
|
+
durationMs,
|
|
556
|
+
distanceTravelledM,
|
|
557
|
+
avgSpeedKmh,
|
|
558
|
+
noiseSamplesAdded: this._noiseSamplesAdded,
|
|
559
|
+
scenariosFired: this._recordedScenarios.length
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
// ── Destroy ──────────────────────────────────────────────────────────────────
|
|
564
|
+
destroy() {
|
|
565
|
+
this._stopTimer();
|
|
566
|
+
this.injector.destroy();
|
|
567
|
+
this._state = "idle";
|
|
568
|
+
this._position = null;
|
|
569
|
+
this._recording = false;
|
|
570
|
+
this._recordedPositions = [];
|
|
571
|
+
}
|
|
572
|
+
// ── Private ──────────────────────────────────────────────────────────────────
|
|
573
|
+
_startTimer() {
|
|
574
|
+
this._lastTick = Date.now();
|
|
575
|
+
const intervalMs = Math.round(1e3 / this._speedMultiplier);
|
|
576
|
+
this._timer = setInterval(() => this._tick(), intervalMs);
|
|
577
|
+
}
|
|
578
|
+
_stopTimer() {
|
|
579
|
+
if (this._timer !== null) {
|
|
580
|
+
clearInterval(this._timer);
|
|
581
|
+
this._timer = null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
_tick() {
|
|
585
|
+
if (this._state !== "running") return;
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
this._lastTick = now;
|
|
588
|
+
const speedMs = this.config.speedKmh / 3.6;
|
|
589
|
+
const walkerPos = this.walker.advance(1);
|
|
590
|
+
const activeScenarioName = this.injector.tick(walkerPos.index);
|
|
591
|
+
const activeScenario = this.injector.activeScenario;
|
|
592
|
+
let coord = walkerPos.coord;
|
|
593
|
+
let isNoisy = false;
|
|
594
|
+
if (activeScenario?.type !== "GPS_BLACKOUT") {
|
|
595
|
+
coord = this.noise.apply(walkerPos.coord);
|
|
596
|
+
isNoisy = this.noise.currentProfile.stdDevM > 0;
|
|
597
|
+
if (isNoisy) this._noiseSamplesAdded++;
|
|
598
|
+
}
|
|
599
|
+
if (activeScenario?.type === "SPEED_SPIKE") {
|
|
600
|
+
activeScenario.params?.["spikeKmh"] ?? this.config.speedKmh;
|
|
601
|
+
}
|
|
602
|
+
let accuracy = 8 + Math.random() * 6;
|
|
603
|
+
if (activeScenario?.type === "ACCURACY_DROP") {
|
|
604
|
+
accuracy = activeScenario.params?.["newAccuracyM"] ?? accuracy;
|
|
605
|
+
}
|
|
606
|
+
this._buildAndEmitPosition(coord, walkerPos.bearing, walkerPos.distanceTravelled, isNoisy, {
|
|
607
|
+
speedMs,
|
|
608
|
+
accuracy,
|
|
609
|
+
activeScenarioName,
|
|
610
|
+
segmentIndex: Math.floor(walkerPos.index)
|
|
611
|
+
});
|
|
612
|
+
if (walkerPos.isComplete) {
|
|
613
|
+
this._stopTimer();
|
|
614
|
+
if (this.config.loop) {
|
|
615
|
+
this.walker = new RouteWalker(this.config.routeGeometry, 0);
|
|
616
|
+
this._startTimer();
|
|
617
|
+
} else {
|
|
618
|
+
this._state = "complete";
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
this.config.onRouteComplete?.();
|
|
622
|
+
} catch (_) {
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
_buildAndEmitPosition(coord, bearing, distanceTravelled, isNoisy, opts) {
|
|
627
|
+
const speedMs = opts?.speedMs ?? this.config.speedKmh / 3.6;
|
|
628
|
+
const accuracy = opts?.accuracy ?? 8;
|
|
629
|
+
const ts = Date.now();
|
|
630
|
+
const locationCoords = {
|
|
631
|
+
latitude: coord[1],
|
|
632
|
+
longitude: coord[0],
|
|
633
|
+
altitude: 0,
|
|
634
|
+
accuracy,
|
|
635
|
+
altitudeAccuracy: null,
|
|
636
|
+
heading: bearing,
|
|
637
|
+
speed: speedMs
|
|
638
|
+
};
|
|
639
|
+
const pos = {
|
|
640
|
+
coord,
|
|
641
|
+
bearing,
|
|
642
|
+
speed: speedMs,
|
|
643
|
+
accuracy,
|
|
644
|
+
timestamp: ts,
|
|
645
|
+
coords: locationCoords,
|
|
646
|
+
location: {
|
|
647
|
+
coords: locationCoords,
|
|
648
|
+
timestamp: ts
|
|
649
|
+
},
|
|
650
|
+
metadata: {
|
|
651
|
+
segmentIndex: opts?.segmentIndex ?? Math.floor(this.walker.currentIndex),
|
|
652
|
+
distanceTravelled,
|
|
653
|
+
isNoisy,
|
|
654
|
+
scenarioActive: opts?.activeScenarioName
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
this._position = pos;
|
|
658
|
+
if (this._recording) {
|
|
659
|
+
this._recordedPositions.push(pos);
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
this.config.onPositionUpdate?.(pos);
|
|
663
|
+
} catch (_) {
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
SimulatorEngine.TICK_MS = 50;
|
|
668
|
+
var GpxParser = class _GpxParser {
|
|
669
|
+
/**
|
|
670
|
+
* Parse a GPX XML string into coordinates and metadata.
|
|
671
|
+
* Supports both <trkpt> and <wpt> elements.
|
|
672
|
+
*/
|
|
673
|
+
static parse(gpxString) {
|
|
674
|
+
const coordinates = [];
|
|
675
|
+
const timestamps = [];
|
|
676
|
+
const pointRegex = /<(?:trkpt|wpt)\s+([^>]+)>([\s\S]*?)<\/(?:trkpt|wpt)>/g;
|
|
677
|
+
const latLonRegex = /lat="([^"]+)"/;
|
|
678
|
+
const lonRegex = /lon="([^"]+)"/;
|
|
679
|
+
const timeRegex = /<time>([^<]+)<\/time>/;
|
|
680
|
+
let match;
|
|
681
|
+
while ((match = pointRegex.exec(gpxString)) !== null) {
|
|
682
|
+
const attrs = match[1] ?? "";
|
|
683
|
+
const body = match[2] ?? "";
|
|
684
|
+
const latMatch = latLonRegex.exec(attrs);
|
|
685
|
+
const lonMatch = lonRegex.exec(attrs);
|
|
686
|
+
if (!latMatch || !lonMatch) continue;
|
|
687
|
+
const lat = parseFloat(latMatch[1] ?? "0");
|
|
688
|
+
const lon = parseFloat(lonMatch[1] ?? "0");
|
|
689
|
+
if (isNaN(lat) || isNaN(lon)) continue;
|
|
690
|
+
coordinates.push([lon, lat]);
|
|
691
|
+
const timeMatch = timeRegex.exec(body);
|
|
692
|
+
if (timeMatch) {
|
|
693
|
+
const ms = Date.parse(timeMatch[1] ?? "");
|
|
694
|
+
timestamps.push(isNaN(ms) ? 0 : ms);
|
|
695
|
+
} else {
|
|
696
|
+
timestamps.push(0);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const nameMatch = /<name>([^<]+)<\/name>/.exec(gpxString);
|
|
700
|
+
const name = nameMatch ? nameMatch[1]?.trim() : void 0;
|
|
701
|
+
let distanceM = 0;
|
|
702
|
+
for (let i = 1; i < coordinates.length; i++) {
|
|
703
|
+
const a = coordinates[i - 1];
|
|
704
|
+
const b = coordinates[i];
|
|
705
|
+
distanceM += navcoreCore.haversineDistance(a[1], a[0], b[1], b[0]);
|
|
706
|
+
}
|
|
707
|
+
const validTs = timestamps.filter((t) => t > 0);
|
|
708
|
+
let durationMs = 0;
|
|
709
|
+
let avgSpeedKmh = 0;
|
|
710
|
+
if (validTs.length >= 2) {
|
|
711
|
+
durationMs = (validTs[validTs.length - 1] ?? 0) - (validTs[0] ?? 0);
|
|
712
|
+
if (durationMs > 0) {
|
|
713
|
+
const speedMs = distanceM / (durationMs / 1e3);
|
|
714
|
+
avgSpeedKmh = speedMs * 3.6;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
coordinates,
|
|
719
|
+
timestamps,
|
|
720
|
+
metadata: {
|
|
721
|
+
name,
|
|
722
|
+
totalPoints: coordinates.length,
|
|
723
|
+
durationMs,
|
|
724
|
+
distanceM,
|
|
725
|
+
avgSpeedKmh
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Parse a base64-encoded GPX string.
|
|
731
|
+
* Works in React Native (uses atob) and Node.js (uses Buffer.from).
|
|
732
|
+
*/
|
|
733
|
+
static fromBase64(base64) {
|
|
734
|
+
let decoded;
|
|
735
|
+
if (typeof atob !== "undefined") {
|
|
736
|
+
decoded = atob(base64);
|
|
737
|
+
} else {
|
|
738
|
+
decoded = Buffer.from(base64, "base64").toString("utf-8");
|
|
739
|
+
}
|
|
740
|
+
return _GpxParser.parse(decoded);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// src/replay/SessionRecorder.ts
|
|
745
|
+
function uuidV42() {
|
|
746
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
747
|
+
const r = Math.random() * 16 | 0;
|
|
748
|
+
const v = c === "x" ? r : r & 3 | 8;
|
|
749
|
+
return v.toString(16);
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
var SessionRecorder = class _SessionRecorder {
|
|
753
|
+
constructor() {
|
|
754
|
+
this._sessionId = null;
|
|
755
|
+
this._startedAt = null;
|
|
756
|
+
this._session = null;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Start a new recording.
|
|
760
|
+
* If the engine already has a session via startRecording(), you can pass
|
|
761
|
+
* the existing sessionId to keep IDs consistent.
|
|
762
|
+
*/
|
|
763
|
+
start(sessionId) {
|
|
764
|
+
this._sessionId = sessionId ?? uuidV42();
|
|
765
|
+
this._startedAt = Date.now();
|
|
766
|
+
this._session = null;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Stop recording and return the captured session.
|
|
770
|
+
* If called with an existing SimSession (from SimulatorEngine.stopRecording()),
|
|
771
|
+
* it stores that session for subsequent export() calls.
|
|
772
|
+
*/
|
|
773
|
+
stop(existing) {
|
|
774
|
+
if (existing) {
|
|
775
|
+
this._session = existing;
|
|
776
|
+
return existing;
|
|
777
|
+
}
|
|
778
|
+
const session = {
|
|
779
|
+
id: this._sessionId ?? uuidV42(),
|
|
780
|
+
startedAt: new Date(this._startedAt ?? Date.now()).toISOString(),
|
|
781
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
782
|
+
route: [],
|
|
783
|
+
positions: [],
|
|
784
|
+
scenarios: [],
|
|
785
|
+
stats: {
|
|
786
|
+
durationMs: Date.now() - (this._startedAt ?? Date.now()),
|
|
787
|
+
distanceTravelledM: 0,
|
|
788
|
+
avgSpeedKmh: 0,
|
|
789
|
+
noiseSamplesAdded: 0,
|
|
790
|
+
scenariosFired: 0
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
this._session = session;
|
|
794
|
+
return session;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Export the most recent session in the specified format.
|
|
798
|
+
* Must call stop() first.
|
|
799
|
+
*/
|
|
800
|
+
export(format) {
|
|
801
|
+
const session = this._session;
|
|
802
|
+
if (!session) throw new Error("SessionRecorder: call stop() before export()");
|
|
803
|
+
switch (format) {
|
|
804
|
+
case "json":
|
|
805
|
+
return JSON.stringify(session, null, 2);
|
|
806
|
+
case "gpx":
|
|
807
|
+
return _SessionRecorder._toGpx(session);
|
|
808
|
+
case "csv":
|
|
809
|
+
return _SessionRecorder._toCsv(session);
|
|
810
|
+
default:
|
|
811
|
+
throw new Error(`SessionRecorder: unknown format "${format}"`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// ── Static export helpers ───────────────────────────────────────────────────
|
|
815
|
+
static _toGpx(session) {
|
|
816
|
+
const trkpts = session.positions.map((p) => {
|
|
817
|
+
const time = new Date(p.timestamp).toISOString();
|
|
818
|
+
return ` <trkpt lat="${p.coord[1]}" lon="${p.coord[0]}">
|
|
819
|
+
<time>${time}</time>
|
|
820
|
+
<extensions>
|
|
821
|
+
<speed>${p.speed.toFixed(4)}</speed>
|
|
822
|
+
<bearing>${p.bearing.toFixed(4)}</bearing>
|
|
823
|
+
<accuracy>${p.accuracy.toFixed(2)}</accuracy>
|
|
824
|
+
</extensions>
|
|
825
|
+
</trkpt>`;
|
|
826
|
+
}).join("\n");
|
|
827
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
828
|
+
<gpx version="1.1" creator="@ingissa/navcore-simulator"
|
|
829
|
+
xmlns="http://www.topografix.com/GPX/1/1"
|
|
830
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
831
|
+
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
|
|
832
|
+
<metadata>
|
|
833
|
+
<name>${session.id}</name>
|
|
834
|
+
<time>${session.startedAt}</time>
|
|
835
|
+
</metadata>
|
|
836
|
+
<trk>
|
|
837
|
+
<name>${session.id}</name>
|
|
838
|
+
<trkseg>
|
|
839
|
+
` + trkpts + `
|
|
840
|
+
</trkseg>
|
|
841
|
+
</trk>
|
|
842
|
+
</gpx>`;
|
|
843
|
+
}
|
|
844
|
+
static _toCsv(session) {
|
|
845
|
+
const header = "timestamp,lng,lat,bearing,speed,accuracy";
|
|
846
|
+
const rows = session.positions.map(
|
|
847
|
+
(p) => `${p.timestamp},${p.coord[0]},${p.coord[1]},${p.bearing.toFixed(4)},${p.speed.toFixed(4)},${p.accuracy.toFixed(2)}`
|
|
848
|
+
);
|
|
849
|
+
return [header, ...rows].join("\n");
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// src/fleet/FleetSimulator.ts
|
|
854
|
+
var FleetSimulator = class _FleetSimulator {
|
|
855
|
+
constructor(engines, profiles, baseConfig) {
|
|
856
|
+
this.engines = engines;
|
|
857
|
+
this.profiles = profiles;
|
|
858
|
+
this.baseConfig = baseConfig;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Spawn N SimulatorEngine instances.
|
|
862
|
+
*
|
|
863
|
+
* @param count - number of vehicles
|
|
864
|
+
* @param config - base config (onPositionUpdate is per-vehicle, excluded here)
|
|
865
|
+
*/
|
|
866
|
+
static spawn(count, config) {
|
|
867
|
+
const { profiles = [], ...baseConfig } = config;
|
|
868
|
+
const engines = [];
|
|
869
|
+
const resolvedProfiles = [];
|
|
870
|
+
for (let i = 0; i < count; i++) {
|
|
871
|
+
const profile = profiles[i] ?? {};
|
|
872
|
+
resolvedProfiles.push(profile);
|
|
873
|
+
const engine = new SimulatorEngine({
|
|
874
|
+
...baseConfig,
|
|
875
|
+
...profile,
|
|
876
|
+
onPositionUpdate: void 0
|
|
877
|
+
});
|
|
878
|
+
engines.push(engine);
|
|
879
|
+
}
|
|
880
|
+
return new _FleetSimulator(engines, resolvedProfiles, baseConfig);
|
|
881
|
+
}
|
|
882
|
+
/** Start all engines simultaneously */
|
|
883
|
+
start() {
|
|
884
|
+
for (const engine of this.engines) {
|
|
885
|
+
engine.startRecording();
|
|
886
|
+
engine.start();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/** Stop all engines */
|
|
890
|
+
stop() {
|
|
891
|
+
for (const engine of this.engines) {
|
|
892
|
+
engine.stop();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Get the engine at the given index.
|
|
897
|
+
* Use this for per-vehicle control (seek, setSpeed, addScenario, etc.)
|
|
898
|
+
*/
|
|
899
|
+
vehicle(index) {
|
|
900
|
+
const engine = this.engines[index];
|
|
901
|
+
if (!engine) throw new Error(`FleetSimulator: no vehicle at index ${index}`);
|
|
902
|
+
return engine;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Stop all engines, collect sessions, and return an aggregate report.
|
|
906
|
+
*/
|
|
907
|
+
report() {
|
|
908
|
+
const vehicleReports = this.engines.map((engine, i) => {
|
|
909
|
+
const session = engine.stopRecording();
|
|
910
|
+
const profile = this.profiles[i] ?? {};
|
|
911
|
+
return {
|
|
912
|
+
id: i,
|
|
913
|
+
label: profile.label ?? `vehicle-${i}`,
|
|
914
|
+
session,
|
|
915
|
+
profile
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
const avgScoreByProfile = {};
|
|
919
|
+
for (const v of vehicleReports) {
|
|
920
|
+
const key = v.label;
|
|
921
|
+
const existing = avgScoreByProfile[key];
|
|
922
|
+
if (existing === void 0) {
|
|
923
|
+
avgScoreByProfile[key] = v.session.stats.avgSpeedKmh;
|
|
924
|
+
} else {
|
|
925
|
+
avgScoreByProfile[key] = (existing + v.session.stats.avgSpeedKmh) / 2;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
const segmentCount = this.baseConfig.routeGeometry.length;
|
|
929
|
+
const deviationsBySegment = new Array(segmentCount).fill(0);
|
|
930
|
+
for (const v of vehicleReports) {
|
|
931
|
+
for (const pos of v.session.positions) {
|
|
932
|
+
const segIdx = pos.metadata?.segmentIndex ?? 0;
|
|
933
|
+
if (segIdx < segmentCount) {
|
|
934
|
+
deviationsBySegment[segIdx] = (deviationsBySegment[segIdx] ?? 0) + pos.accuracy;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
for (let i = 0; i < deviationsBySegment.length; i++) {
|
|
939
|
+
deviationsBySegment[i] = (deviationsBySegment[i] ?? 0) / Math.max(1, vehicleReports.length);
|
|
940
|
+
}
|
|
941
|
+
return {
|
|
942
|
+
vehicles: vehicleReports,
|
|
943
|
+
comparison: { avgScoreByProfile, deviationsBySegment }
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
/** Destroy all engines and clean up */
|
|
947
|
+
destroy() {
|
|
948
|
+
for (const engine of this.engines) {
|
|
949
|
+
engine.destroy();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
function useSimulator(options) {
|
|
954
|
+
const { routeGeometry, active, config = {}, scenarios = [], onScenarioFired } = options;
|
|
955
|
+
const [position, setPosition] = react.useState(null);
|
|
956
|
+
const [isRecording, setIsRecording] = react.useState(false);
|
|
957
|
+
const engineRef = react.useRef(null);
|
|
958
|
+
const onScenarioFiredRef = react.useRef(onScenarioFired);
|
|
959
|
+
onScenarioFiredRef.current = onScenarioFired;
|
|
960
|
+
react.useEffect(() => {
|
|
961
|
+
if (!routeGeometry || routeGeometry.length < 2) {
|
|
962
|
+
setPosition(null);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const engine = new SimulatorEngine({
|
|
966
|
+
routeGeometry,
|
|
967
|
+
speedKmh: config.speedKmh ?? 50,
|
|
968
|
+
speedMultiplier: config.speedMultiplier ?? 1,
|
|
969
|
+
noiseProfile: config.noiseProfile ?? NoiseEngine.STANDARD,
|
|
970
|
+
loop: config.loop ?? false,
|
|
971
|
+
startIndex: config.startIndex ?? 0,
|
|
972
|
+
onPositionUpdate: (pos) => setPosition(pos),
|
|
973
|
+
onRouteComplete: () => {
|
|
974
|
+
if (options.onRouteComplete) {
|
|
975
|
+
options.onRouteComplete();
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
onScenarioStart: (s2) => {
|
|
979
|
+
try {
|
|
980
|
+
onScenarioFiredRef.current?.(s2);
|
|
981
|
+
} catch (_) {
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
scenarios.forEach((s2) => engine.addScenario(s2));
|
|
986
|
+
engineRef.current = engine;
|
|
987
|
+
return () => {
|
|
988
|
+
engine.destroy();
|
|
989
|
+
engineRef.current = null;
|
|
990
|
+
setPosition(null);
|
|
991
|
+
};
|
|
992
|
+
}, [routeGeometry]);
|
|
993
|
+
react.useEffect(() => {
|
|
994
|
+
const engine = engineRef.current;
|
|
995
|
+
if (!engine) return;
|
|
996
|
+
if (active) {
|
|
997
|
+
engine.start();
|
|
998
|
+
} else {
|
|
999
|
+
engine.stop();
|
|
1000
|
+
setPosition(null);
|
|
1001
|
+
}
|
|
1002
|
+
}, [active]);
|
|
1003
|
+
const pause = react.useCallback(() => engineRef.current?.pause(), []);
|
|
1004
|
+
const resume = react.useCallback(() => engineRef.current?.resume(), []);
|
|
1005
|
+
const seek = react.useCallback((index) => engineRef.current?.seek(index), []);
|
|
1006
|
+
const setSpeed = react.useCallback((m) => engineRef.current?.setSpeed(m), []);
|
|
1007
|
+
const startSession = react.useCallback((sessionId) => {
|
|
1008
|
+
engineRef.current?.startRecording(sessionId);
|
|
1009
|
+
setIsRecording(true);
|
|
1010
|
+
}, []);
|
|
1011
|
+
const stopSession = react.useCallback(() => {
|
|
1012
|
+
setIsRecording(false);
|
|
1013
|
+
return engineRef.current?.stopRecording() ?? null;
|
|
1014
|
+
}, []);
|
|
1015
|
+
const location = position?.location ?? (position ? { coords: position.coords, timestamp: position.timestamp } : null);
|
|
1016
|
+
return {
|
|
1017
|
+
position,
|
|
1018
|
+
location,
|
|
1019
|
+
rawLocation: location,
|
|
1020
|
+
controls: { pause, resume, seek, setSpeed },
|
|
1021
|
+
session: { start: startSession, stop: stopSession },
|
|
1022
|
+
isRecording
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
var KW = {
|
|
1026
|
+
navyDeep: "#081525",
|
|
1027
|
+
navyCard: "#152240",
|
|
1028
|
+
navyBorder: "#1E3255",
|
|
1029
|
+
emerald: "#00BF76",
|
|
1030
|
+
emeraldDark: "#04342C",
|
|
1031
|
+
emeraldMid: "#009A5F",
|
|
1032
|
+
amber: "#F5A623",
|
|
1033
|
+
slate: "#6B7FA3",
|
|
1034
|
+
slateLight: "#8FA3C8",
|
|
1035
|
+
fault: "#FF4848",
|
|
1036
|
+
faultDark: "#3D0000",
|
|
1037
|
+
mono: reactNative.Platform.OS === "ios" ? "Courier New" : "monospace"
|
|
1038
|
+
};
|
|
1039
|
+
function SpeedSelector({
|
|
1040
|
+
current,
|
|
1041
|
+
onChange
|
|
1042
|
+
}) {
|
|
1043
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: spd.row, children: [
|
|
1044
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: spd.label, children: "SIM SPEED" }),
|
|
1045
|
+
[1, 2, 4, 8].map((v) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
1046
|
+
reactNative.TouchableOpacity,
|
|
1047
|
+
{
|
|
1048
|
+
style: [spd.btn, current === v && spd.btnActive],
|
|
1049
|
+
onPress: () => onChange(v),
|
|
1050
|
+
activeOpacity: 0.7,
|
|
1051
|
+
children: /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: [spd.btnText, current === v && { color: KW.emeraldDark }], children: [
|
|
1052
|
+
v,
|
|
1053
|
+
"\xD7"
|
|
1054
|
+
] })
|
|
1055
|
+
},
|
|
1056
|
+
v
|
|
1057
|
+
))
|
|
1058
|
+
] });
|
|
1059
|
+
}
|
|
1060
|
+
var spd = reactNative.StyleSheet.create({
|
|
1061
|
+
row: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
1062
|
+
label: { fontSize: 9, letterSpacing: 1.2, color: KW.slate, marginRight: 4 },
|
|
1063
|
+
btn: {
|
|
1064
|
+
paddingHorizontal: 10,
|
|
1065
|
+
paddingVertical: 5,
|
|
1066
|
+
borderRadius: 6,
|
|
1067
|
+
borderWidth: 1,
|
|
1068
|
+
borderColor: KW.navyBorder
|
|
1069
|
+
},
|
|
1070
|
+
btnActive: { backgroundColor: KW.emerald, borderColor: KW.emerald },
|
|
1071
|
+
btnText: { fontSize: 11, fontWeight: "700", color: KW.slate }
|
|
1072
|
+
});
|
|
1073
|
+
function PauseResumeButton({
|
|
1074
|
+
state,
|
|
1075
|
+
onPause,
|
|
1076
|
+
onResume
|
|
1077
|
+
}) {
|
|
1078
|
+
if (state !== "running" && state !== "paused") return null;
|
|
1079
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
1080
|
+
reactNative.TouchableOpacity,
|
|
1081
|
+
{
|
|
1082
|
+
style: [pr.btn, state === "paused" && pr.btnResume],
|
|
1083
|
+
onPress: state === "running" ? onPause : onResume,
|
|
1084
|
+
activeOpacity: 0.75,
|
|
1085
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: pr.text, children: state === "running" ? "\u23F8" : "\u25B6" })
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
var pr = reactNative.StyleSheet.create({
|
|
1090
|
+
btn: {
|
|
1091
|
+
paddingHorizontal: 14,
|
|
1092
|
+
paddingVertical: 7,
|
|
1093
|
+
borderRadius: 8,
|
|
1094
|
+
backgroundColor: KW.navyDeep,
|
|
1095
|
+
borderWidth: 1,
|
|
1096
|
+
borderColor: KW.navyBorder,
|
|
1097
|
+
alignItems: "center"
|
|
1098
|
+
},
|
|
1099
|
+
btnResume: { borderColor: KW.emerald },
|
|
1100
|
+
text: { fontSize: 16 }
|
|
1101
|
+
});
|
|
1102
|
+
function ScenarioLog({ entries }) {
|
|
1103
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: log.container, children: [
|
|
1104
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: log.title, children: "// scenario log" }),
|
|
1105
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.ScrollView, { style: log.scroll, showsVerticalScrollIndicator: false, children: entries.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: log.empty, children: "No scenarios fired yet\u2026" }) : entries.map((e, i) => {
|
|
1106
|
+
const d = new Date(e.ts);
|
|
1107
|
+
const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
1108
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: log.entry, children: [
|
|
1109
|
+
/* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: log.time, children: [
|
|
1110
|
+
time,
|
|
1111
|
+
" "
|
|
1112
|
+
] }),
|
|
1113
|
+
/* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: { color: KW.amber }, children: [
|
|
1114
|
+
"[",
|
|
1115
|
+
e.type,
|
|
1116
|
+
"]"
|
|
1117
|
+
] }),
|
|
1118
|
+
" ",
|
|
1119
|
+
e.name
|
|
1120
|
+
] }, i);
|
|
1121
|
+
}) })
|
|
1122
|
+
] });
|
|
1123
|
+
}
|
|
1124
|
+
var log = reactNative.StyleSheet.create({
|
|
1125
|
+
container: {
|
|
1126
|
+
backgroundColor: KW.navyDeep,
|
|
1127
|
+
borderRadius: 8,
|
|
1128
|
+
padding: 8,
|
|
1129
|
+
borderWidth: 1,
|
|
1130
|
+
borderColor: KW.navyBorder,
|
|
1131
|
+
maxHeight: 100,
|
|
1132
|
+
marginTop: 8
|
|
1133
|
+
},
|
|
1134
|
+
title: {
|
|
1135
|
+
fontSize: 9,
|
|
1136
|
+
letterSpacing: 1,
|
|
1137
|
+
color: KW.slate,
|
|
1138
|
+
fontFamily: KW.mono,
|
|
1139
|
+
marginBottom: 4
|
|
1140
|
+
},
|
|
1141
|
+
scroll: { flex: 1 },
|
|
1142
|
+
empty: { fontSize: 10, color: KW.slate, fontFamily: KW.mono },
|
|
1143
|
+
entry: { fontSize: 10, color: KW.slateLight, fontFamily: KW.mono, marginBottom: 2 },
|
|
1144
|
+
time: { color: KW.slate }
|
|
1145
|
+
});
|
|
1146
|
+
function SessionRecorderControls({
|
|
1147
|
+
isRecording,
|
|
1148
|
+
session,
|
|
1149
|
+
onExport,
|
|
1150
|
+
lastSession
|
|
1151
|
+
}) {
|
|
1152
|
+
if (!session) return null;
|
|
1153
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: rec.row, children: [
|
|
1154
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1155
|
+
reactNative.TouchableOpacity,
|
|
1156
|
+
{
|
|
1157
|
+
style: [rec.btn, isRecording && rec.btnActive],
|
|
1158
|
+
onPress: () => {
|
|
1159
|
+
if (isRecording) ; else {
|
|
1160
|
+
session.start();
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
activeOpacity: 0.75,
|
|
1164
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: rec.btnText, children: isRecording ? "\u23F9 STOP REC" : "\u23FA RECORD" })
|
|
1165
|
+
}
|
|
1166
|
+
),
|
|
1167
|
+
lastSession && onExport && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1168
|
+
reactNative.TouchableOpacity,
|
|
1169
|
+
{
|
|
1170
|
+
style: rec.exportBtn,
|
|
1171
|
+
onPress: () => onExport(lastSession),
|
|
1172
|
+
activeOpacity: 0.75,
|
|
1173
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: rec.exportText, children: "EXPORT JSON" })
|
|
1174
|
+
}
|
|
1175
|
+
)
|
|
1176
|
+
] });
|
|
1177
|
+
}
|
|
1178
|
+
var rec = reactNative.StyleSheet.create({
|
|
1179
|
+
row: { flexDirection: "row", gap: 8, marginTop: 8 },
|
|
1180
|
+
btn: {
|
|
1181
|
+
flex: 1,
|
|
1182
|
+
paddingVertical: 8,
|
|
1183
|
+
borderRadius: 8,
|
|
1184
|
+
backgroundColor: KW.navyDeep,
|
|
1185
|
+
borderWidth: 1,
|
|
1186
|
+
borderColor: KW.navyBorder,
|
|
1187
|
+
alignItems: "center"
|
|
1188
|
+
},
|
|
1189
|
+
btnActive: { borderColor: KW.fault, backgroundColor: KW.faultDark },
|
|
1190
|
+
btnText: { fontSize: 11, fontWeight: "700", color: KW.slateLight, letterSpacing: 0.8 },
|
|
1191
|
+
exportBtn: {
|
|
1192
|
+
paddingHorizontal: 12,
|
|
1193
|
+
paddingVertical: 8,
|
|
1194
|
+
borderRadius: 8,
|
|
1195
|
+
backgroundColor: KW.navyDeep,
|
|
1196
|
+
borderWidth: 1,
|
|
1197
|
+
borderColor: KW.emeraldMid,
|
|
1198
|
+
alignItems: "center"
|
|
1199
|
+
},
|
|
1200
|
+
exportText: { fontSize: 10, fontWeight: "700", color: KW.emerald, letterSpacing: 0.5 }
|
|
1201
|
+
});
|
|
1202
|
+
function SimulatorControls({
|
|
1203
|
+
simulator,
|
|
1204
|
+
session,
|
|
1205
|
+
style = "minimal",
|
|
1206
|
+
showScenarioLog = false,
|
|
1207
|
+
showSessionRecorder = false,
|
|
1208
|
+
scenarioLog = [],
|
|
1209
|
+
currentSpeed = 1,
|
|
1210
|
+
engineState = "idle",
|
|
1211
|
+
isRecording = false,
|
|
1212
|
+
onExportSession,
|
|
1213
|
+
containerStyle
|
|
1214
|
+
}) {
|
|
1215
|
+
const [speed, setSpeedState] = react.useState(currentSpeed);
|
|
1216
|
+
const [lastSession, setLastSession] = react.useState(null);
|
|
1217
|
+
const sheetAnim = react.useRef(new reactNative.Animated.Value(0)).current;
|
|
1218
|
+
const [isExpanded, setIsExpanded] = react.useState(false);
|
|
1219
|
+
const handleSpeedChange = react.useCallback(
|
|
1220
|
+
(v) => {
|
|
1221
|
+
setSpeedState(v);
|
|
1222
|
+
simulator.setSpeed(v);
|
|
1223
|
+
},
|
|
1224
|
+
[simulator]
|
|
1225
|
+
);
|
|
1226
|
+
react.useCallback(() => {
|
|
1227
|
+
const s2 = session?.stop();
|
|
1228
|
+
if (s2) setLastSession(s2);
|
|
1229
|
+
}, [session]);
|
|
1230
|
+
const toggleSheet = react.useCallback(() => {
|
|
1231
|
+
const toValue = isExpanded ? 0 : 1;
|
|
1232
|
+
setIsExpanded(!isExpanded);
|
|
1233
|
+
reactNative.Animated.spring(sheetAnim, {
|
|
1234
|
+
toValue,
|
|
1235
|
+
useNativeDriver: false,
|
|
1236
|
+
bounciness: 4
|
|
1237
|
+
}).start();
|
|
1238
|
+
}, [isExpanded, sheetAnim]);
|
|
1239
|
+
if (style === "minimal") {
|
|
1240
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [s.minimal, containerStyle], children: [
|
|
1241
|
+
/* @__PURE__ */ jsxRuntime.jsx(SpeedSelector, { current: speed, onChange: handleSpeedChange }),
|
|
1242
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
1243
|
+
PauseResumeButton,
|
|
1244
|
+
{
|
|
1245
|
+
state: engineState,
|
|
1246
|
+
onPause: simulator.pause,
|
|
1247
|
+
onResume: simulator.resume
|
|
1248
|
+
}
|
|
1249
|
+
)
|
|
1250
|
+
] });
|
|
1251
|
+
}
|
|
1252
|
+
if (style === "full") {
|
|
1253
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: [s.full, containerStyle], children: [
|
|
1254
|
+
/* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: s.fullHeader, children: [
|
|
1255
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: s.fullTitle, children: "SIMULATOR" }),
|
|
1256
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: [s.stateBadge, engineState === "running" && s.stateBadgeActive], children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.Text, { style: s.stateText, children: engineState.toUpperCase() }) })
|
|
1257
|
+
] }),
|
|
1258
|
+
/* @__PURE__ */ jsxRuntime.jsx(SpeedSelector, { current: speed, onChange: handleSpeedChange }),
|
|
1259
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: s.pauseRow, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1260
|
+
PauseResumeButton,
|
|
1261
|
+
{
|
|
1262
|
+
state: engineState,
|
|
1263
|
+
onPause: simulator.pause,
|
|
1264
|
+
onResume: simulator.resume
|
|
1265
|
+
}
|
|
1266
|
+
) }),
|
|
1267
|
+
showScenarioLog && /* @__PURE__ */ jsxRuntime.jsx(ScenarioLog, { entries: scenarioLog }),
|
|
1268
|
+
showSessionRecorder && /* @__PURE__ */ jsxRuntime.jsx(
|
|
1269
|
+
SessionRecorderControls,
|
|
1270
|
+
{
|
|
1271
|
+
isRecording,
|
|
1272
|
+
session,
|
|
1273
|
+
onExport: onExportSession,
|
|
1274
|
+
lastSession
|
|
1275
|
+
}
|
|
1276
|
+
)
|
|
1277
|
+
] });
|
|
1278
|
+
}
|
|
1279
|
+
const sheetHeight = sheetAnim.interpolate({
|
|
1280
|
+
inputRange: [0, 1],
|
|
1281
|
+
outputRange: [44, showScenarioLog ? 260 : 160]
|
|
1282
|
+
});
|
|
1283
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(reactNative.Animated.View, { style: [s.floating, { height: sheetHeight }, containerStyle], children: [
|
|
1284
|
+
/* @__PURE__ */ jsxRuntime.jsxs(reactNative.TouchableOpacity, { style: s.floatHandle, onPress: toggleSheet, activeOpacity: 0.8, children: [
|
|
1285
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: s.handleBar }),
|
|
1286
|
+
/* @__PURE__ */ jsxRuntime.jsxs(reactNative.Text, { style: s.floatTitle, children: [
|
|
1287
|
+
"SIMULATOR ",
|
|
1288
|
+
isExpanded ? "\u25BC" : "\u25B2"
|
|
1289
|
+
] })
|
|
1290
|
+
] }),
|
|
1291
|
+
isExpanded && /* @__PURE__ */ jsxRuntime.jsxs(reactNative.View, { style: s.floatContent, children: [
|
|
1292
|
+
/* @__PURE__ */ jsxRuntime.jsx(SpeedSelector, { current: speed, onChange: handleSpeedChange }),
|
|
1293
|
+
/* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { style: s.pauseRow, children: /* @__PURE__ */ jsxRuntime.jsx(
|
|
1294
|
+
PauseResumeButton,
|
|
1295
|
+
{
|
|
1296
|
+
state: engineState,
|
|
1297
|
+
onPause: simulator.pause,
|
|
1298
|
+
onResume: simulator.resume
|
|
1299
|
+
}
|
|
1300
|
+
) }),
|
|
1301
|
+
showScenarioLog && /* @__PURE__ */ jsxRuntime.jsx(ScenarioLog, { entries: scenarioLog })
|
|
1302
|
+
] })
|
|
1303
|
+
] });
|
|
1304
|
+
}
|
|
1305
|
+
var s = reactNative.StyleSheet.create({
|
|
1306
|
+
// Minimal
|
|
1307
|
+
minimal: {
|
|
1308
|
+
flexDirection: "row",
|
|
1309
|
+
alignItems: "center",
|
|
1310
|
+
gap: 12,
|
|
1311
|
+
paddingHorizontal: 4,
|
|
1312
|
+
paddingVertical: 6
|
|
1313
|
+
},
|
|
1314
|
+
// Full
|
|
1315
|
+
full: {
|
|
1316
|
+
backgroundColor: KW.navyCard,
|
|
1317
|
+
borderRadius: 12,
|
|
1318
|
+
padding: 14,
|
|
1319
|
+
borderWidth: 1,
|
|
1320
|
+
borderColor: KW.navyBorder
|
|
1321
|
+
},
|
|
1322
|
+
fullHeader: {
|
|
1323
|
+
flexDirection: "row",
|
|
1324
|
+
justifyContent: "space-between",
|
|
1325
|
+
alignItems: "center",
|
|
1326
|
+
marginBottom: 10
|
|
1327
|
+
},
|
|
1328
|
+
fullTitle: {
|
|
1329
|
+
fontSize: 11,
|
|
1330
|
+
fontWeight: "800",
|
|
1331
|
+
color: KW.slateLight,
|
|
1332
|
+
letterSpacing: 1.5
|
|
1333
|
+
},
|
|
1334
|
+
stateBadge: {
|
|
1335
|
+
paddingHorizontal: 8,
|
|
1336
|
+
paddingVertical: 3,
|
|
1337
|
+
borderRadius: 4,
|
|
1338
|
+
backgroundColor: KW.navyDeep,
|
|
1339
|
+
borderWidth: 1,
|
|
1340
|
+
borderColor: KW.navyBorder
|
|
1341
|
+
},
|
|
1342
|
+
stateBadgeActive: { borderColor: KW.emerald },
|
|
1343
|
+
stateText: { fontSize: 9, fontWeight: "700", color: KW.slate, letterSpacing: 1 },
|
|
1344
|
+
pauseRow: { marginTop: 8 },
|
|
1345
|
+
// Floating
|
|
1346
|
+
floating: {
|
|
1347
|
+
position: "absolute",
|
|
1348
|
+
bottom: 0,
|
|
1349
|
+
left: 0,
|
|
1350
|
+
right: 0,
|
|
1351
|
+
backgroundColor: KW.navyCard,
|
|
1352
|
+
borderTopLeftRadius: 16,
|
|
1353
|
+
borderTopRightRadius: 16,
|
|
1354
|
+
borderTopWidth: 1,
|
|
1355
|
+
borderColor: KW.navyBorder,
|
|
1356
|
+
overflow: "hidden",
|
|
1357
|
+
zIndex: 100
|
|
1358
|
+
},
|
|
1359
|
+
floatHandle: {
|
|
1360
|
+
height: 44,
|
|
1361
|
+
alignItems: "center",
|
|
1362
|
+
justifyContent: "center",
|
|
1363
|
+
flexDirection: "row",
|
|
1364
|
+
gap: 10
|
|
1365
|
+
},
|
|
1366
|
+
handleBar: {
|
|
1367
|
+
width: 36,
|
|
1368
|
+
height: 4,
|
|
1369
|
+
backgroundColor: KW.navyBorder,
|
|
1370
|
+
borderRadius: 2
|
|
1371
|
+
},
|
|
1372
|
+
floatTitle: {
|
|
1373
|
+
fontSize: 10,
|
|
1374
|
+
fontWeight: "700",
|
|
1375
|
+
color: KW.slate,
|
|
1376
|
+
letterSpacing: 1.2
|
|
1377
|
+
},
|
|
1378
|
+
floatContent: {
|
|
1379
|
+
paddingHorizontal: 16,
|
|
1380
|
+
paddingBottom: 16,
|
|
1381
|
+
gap: 8
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
exports.FleetSimulator = FleetSimulator;
|
|
1386
|
+
exports.GpxParser = GpxParser;
|
|
1387
|
+
exports.NoiseEngine = NoiseEngine;
|
|
1388
|
+
exports.RouteWalker = RouteWalker;
|
|
1389
|
+
exports.ScenarioInjector = ScenarioInjector;
|
|
1390
|
+
exports.SessionRecorder = SessionRecorder;
|
|
1391
|
+
exports.SimulatorControls = SimulatorControls;
|
|
1392
|
+
exports.SimulatorEngine = SimulatorEngine;
|
|
1393
|
+
exports.useSimulator = useSimulator;
|