@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
package/README.md
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# @ingissa/navcore-simulator
|
|
2
|
+
|
|
3
|
+
> Headless GPS simulation engine for the NavCore ecosystem.
|
|
4
|
+
> Works in **Node.js** (CI), **React Native**, and any JavaScript environment.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- [Installation](#installation)
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Core Classes](#core-classes)
|
|
13
|
+
- [SimulatorEngine](#simulatorengine)
|
|
14
|
+
- [RouteWalker](#routewalker)
|
|
15
|
+
- [NoiseEngine](#noiseengine)
|
|
16
|
+
- [ScenarioInjector](#scenarioinjector)
|
|
17
|
+
- [GpxParser](#gpxparser)
|
|
18
|
+
- [SessionRecorder](#sessionrecorder)
|
|
19
|
+
- [FleetSimulator](#fleetsimulator-phase-2)
|
|
20
|
+
- [React Native](#react-native)
|
|
21
|
+
- [useSimulator](#usesimulator)
|
|
22
|
+
- [SimulatorControls](#simulatorcontrols)
|
|
23
|
+
- [Noise Profiles](#noise-profiles)
|
|
24
|
+
- [Scenario Types](#scenario-types)
|
|
25
|
+
- [GPX Round-Trip Loop](#gpx-round-trip-loop)
|
|
26
|
+
- [CI / Regression Testing](#ci--regression-testing)
|
|
27
|
+
- [Migration from useGpsSimulator](#migration-from-usegpssimulator)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Inside the go4exams monorepo — already available via workspace
|
|
35
|
+
# Import directly from the source tree:
|
|
36
|
+
import { SimulatorEngine, NoiseEngine } from '@ingissa/navcore-simulator'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Peer dependencies:** `@ingissa/navcore-core` (already in monorepo). React Native hooks additionally require `react` and `react-native`, which are optional peer deps (tree-shaken in Node.js CI).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { SimulatorEngine, NoiseEngine, ScenarioInjector } from '@ingissa/navcore-simulator';
|
|
47
|
+
|
|
48
|
+
const engine = new SimulatorEngine({
|
|
49
|
+
routeGeometry: myRoute, // Coordinate[] from @ingissa/navcore-core
|
|
50
|
+
speedKmh: 50,
|
|
51
|
+
speedMultiplier: 4, // 4× real-time
|
|
52
|
+
noiseProfile: NoiseEngine.STANDARD,
|
|
53
|
+
loop: false,
|
|
54
|
+
onPositionUpdate: (pos) => {
|
|
55
|
+
console.log(pos.coord, pos.bearing, pos.speed);
|
|
56
|
+
},
|
|
57
|
+
onRouteComplete: () => console.log('Route complete'),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
engine.addScenario(ScenarioInjector.gpsBlackout(42, 5000));
|
|
61
|
+
engine.startRecording();
|
|
62
|
+
engine.start();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Core Classes
|
|
68
|
+
|
|
69
|
+
### SimulatorEngine
|
|
70
|
+
|
|
71
|
+
The main orchestrator. Headless — no React, no Expo APIs. Runs in Node.js CI.
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const engine = new SimulatorEngine(config: SimulatorConfig);
|
|
75
|
+
|
|
76
|
+
// Playback
|
|
77
|
+
engine.start()
|
|
78
|
+
engine.stop()
|
|
79
|
+
engine.pause()
|
|
80
|
+
engine.resume()
|
|
81
|
+
engine.seek(geometryIndex: number) // jump to any route index, recalculates bearing
|
|
82
|
+
engine.setSpeed(1 | 2 | 4 | 8)
|
|
83
|
+
|
|
84
|
+
// Scenarios
|
|
85
|
+
engine.addScenario(scenario: Scenario)
|
|
86
|
+
engine.removeScenario(name: string)
|
|
87
|
+
engine.clearScenarios()
|
|
88
|
+
|
|
89
|
+
// State
|
|
90
|
+
engine.state // 'idle' | 'running' | 'paused' | 'complete'
|
|
91
|
+
engine.position // SimPosition | null
|
|
92
|
+
engine.progress // 0–1
|
|
93
|
+
|
|
94
|
+
// Recording
|
|
95
|
+
engine.startRecording(sessionId?: string)
|
|
96
|
+
engine.stopRecording(): SimSession
|
|
97
|
+
|
|
98
|
+
// Cleanup
|
|
99
|
+
engine.destroy() // clears all timers — no leaks
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Timer internals:** `setInterval` at `1000ms / speedMultiplier`. Always cleaned up on `destroy()` and on natural completion.
|
|
103
|
+
|
|
104
|
+
**Callback safety:** All callbacks (`onPositionUpdate`, `onRouteComplete`, `onScenarioStart`, `onScenarioEnd`) are wrapped in `try/catch` — a throwing callback never crashes the engine.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### RouteWalker
|
|
109
|
+
|
|
110
|
+
Manages geometry traversal. Extracted from the engine so it's independently testable.
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const walker = new RouteWalker(geometry: Coordinate[], startIndex?: number);
|
|
114
|
+
|
|
115
|
+
walker.advance(steps: number): WalkerPosition // fractional steps supported
|
|
116
|
+
walker.seek(index: number): WalkerPosition
|
|
117
|
+
walker.interpolate(index: number, fraction: number): Coordinate
|
|
118
|
+
walker.bearingAt(index: number): number // forward-looking bearing
|
|
119
|
+
|
|
120
|
+
walker.totalDistanceM // total route length in metres
|
|
121
|
+
walker.totalPoints // geometry.length
|
|
122
|
+
walker.isComplete // true when at last point
|
|
123
|
+
walker.currentIndex // current floating-point index
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
### NoiseEngine
|
|
129
|
+
|
|
130
|
+
GPS noise simulation using the Box-Muller transform. Zero external dependencies.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const engine = new NoiseEngine(profile: NoiseProfile);
|
|
134
|
+
|
|
135
|
+
engine.apply(coord: Coordinate): Coordinate // returns noisy coord (never mutates input)
|
|
136
|
+
engine.setProfile(profile: NoiseProfile) // hot-swap mid-simulation
|
|
137
|
+
|
|
138
|
+
// Static presets
|
|
139
|
+
NoiseEngine.NONE // stdDevM: 0 — exact coordinates
|
|
140
|
+
NoiseEngine.CLEAN // stdDevM: 3 — good urban GPS
|
|
141
|
+
NoiseEngine.STANDARD // stdDevM: 12 — matches the original Go4Exams ±0.00011°
|
|
142
|
+
NoiseEngine.URBAN // stdDevM: 25 — city canyon
|
|
143
|
+
NoiseEngine.DEGRADED // stdDevM: 50 — bad conditions
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Seeded mode:** Add `seed: number` to any profile for deterministic output — same seed + same route = identical noise trace (critical for regression tests).
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
const deterministicProfile = { ...NoiseEngine.STANDARD, seed: 42 };
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
### ScenarioInjector
|
|
155
|
+
|
|
156
|
+
Registers scenarios to fire at geometry indices. Called internally by `SimulatorEngine` on every tick.
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
const injector = new ScenarioInjector(onStart?, onEnd?);
|
|
160
|
+
|
|
161
|
+
injector.register(scenario: Scenario)
|
|
162
|
+
injector.remove(name: string)
|
|
163
|
+
injector.clear()
|
|
164
|
+
injector.tick(currentIndex: number): string | undefined // returns active scenario name
|
|
165
|
+
|
|
166
|
+
// Static factories
|
|
167
|
+
ScenarioInjector.gpsBlackout(atIndex, durationMs)
|
|
168
|
+
ScenarioInjector.wrongSideRoad(atIndex, offsetM)
|
|
169
|
+
ScenarioInjector.uTurn(atIndex)
|
|
170
|
+
ScenarioInjector.speedSpike(atIndex, spikeKmh, durationMs?)
|
|
171
|
+
ScenarioInjector.accuracyDrop(atIndex, newAccuracyM, durationMs)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**No double-fire:** Each scenario fires exactly once per simulation run, even if `tick()` is called with the same index multiple times.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### GpxParser
|
|
179
|
+
|
|
180
|
+
Zero-dependency GPX 1.1 parser. Pure string/regex — no DOMParser, no xml2js.
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
// Parse GPX string
|
|
184
|
+
const result = GpxParser.parse(gpxString: string): GpxParseResult
|
|
185
|
+
|
|
186
|
+
// Load from file (Node.js only)
|
|
187
|
+
const result = GpxParser.fromFile('/path/to/route.gpx')
|
|
188
|
+
|
|
189
|
+
// Load from base64 (React Native file picker)
|
|
190
|
+
const result = GpxParser.fromBase64(base64String)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
interface GpxParseResult {
|
|
195
|
+
coordinates: Coordinate[]; // [lng, lat] — NavCore convention
|
|
196
|
+
timestamps: number[]; // ms since epoch per point
|
|
197
|
+
metadata: {
|
|
198
|
+
name?: string;
|
|
199
|
+
totalPoints: number;
|
|
200
|
+
durationMs: number;
|
|
201
|
+
distanceM: number;
|
|
202
|
+
avgSpeedKmh: number;
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Supports both `<trkpt>` (track points) and `<wpt>` (waypoints).
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### SessionRecorder
|
|
212
|
+
|
|
213
|
+
Records and exports simulation sessions.
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
const recorder = new SessionRecorder();
|
|
217
|
+
recorder.start(sessionId?: string)
|
|
218
|
+
recorder.stop(existing?: SimSession): SimSession
|
|
219
|
+
|
|
220
|
+
// Export formats
|
|
221
|
+
recorder.export('json') // full SimSession as JSON
|
|
222
|
+
recorder.export('gpx') // standard GPX 1.1 — re-importable by GpxParser
|
|
223
|
+
recorder.export('csv') // timestamp,lng,lat,bearing,speed,accuracy
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**The GPX export is critical:** a recorded session → GPX → `GpxParser` → `SimulatorEngine` creates a full regression test loop.
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### FleetSimulator (Phase 2)
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const fleet = FleetSimulator.spawn(5, {
|
|
234
|
+
routeGeometry: myRoute,
|
|
235
|
+
speedKmh: 50,
|
|
236
|
+
noiseProfile: NoiseEngine.STANDARD,
|
|
237
|
+
profiles: [
|
|
238
|
+
{ label: 'urban', noiseProfile: NoiseEngine.URBAN },
|
|
239
|
+
{ label: 'clean', noiseProfile: NoiseEngine.CLEAN },
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
fleet.start();
|
|
244
|
+
// ... wait for completion
|
|
245
|
+
const report = fleet.report();
|
|
246
|
+
// report.comparison.avgScoreByProfile
|
|
247
|
+
// report.comparison.deviationsBySegment (heatmap data)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
> **@experimental** — fully typed but not tested in Phase 1.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## React Native
|
|
255
|
+
|
|
256
|
+
### useSimulator
|
|
257
|
+
|
|
258
|
+
Drop-in replacement for `useGpsSimulator`:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// BEFORE
|
|
262
|
+
const simGps = useGpsSimulator(route, isNavigating, speedMultiplier);
|
|
263
|
+
|
|
264
|
+
// AFTER
|
|
265
|
+
const { position, controls, session, isRecording } = useSimulator({
|
|
266
|
+
routeGeometry: route,
|
|
267
|
+
active: isNavigating,
|
|
268
|
+
config: {
|
|
269
|
+
speedKmh: 50,
|
|
270
|
+
speedMultiplier: 1,
|
|
271
|
+
noiseProfile: NoiseEngine.STANDARD,
|
|
272
|
+
loop: false,
|
|
273
|
+
},
|
|
274
|
+
scenarios: [
|
|
275
|
+
ScenarioInjector.gpsBlackout(42, 5000),
|
|
276
|
+
ScenarioInjector.wrongSideRoad(78, 18),
|
|
277
|
+
],
|
|
278
|
+
onScenarioFired: (s) => console.log('Scenario:', s.name),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// position has the exact same shape as the old SimPosition
|
|
282
|
+
// controls: { pause, resume, seek, setSpeed }
|
|
283
|
+
// session: { start, stop } — for recording
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**The engine is recreated only when `routeGeometry` changes** (identity comparison). Destroyed automatically on unmount.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
### SimulatorControls
|
|
291
|
+
|
|
292
|
+
Self-contained control panel. Drop it into any screen:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
<SimulatorControls
|
|
296
|
+
simulator={controls}
|
|
297
|
+
session={session}
|
|
298
|
+
style="minimal" // 'minimal' | 'full' | 'floating'
|
|
299
|
+
showScenarioLog={true}
|
|
300
|
+
showSessionRecorder={true}
|
|
301
|
+
currentSpeed={1}
|
|
302
|
+
engineState="running"
|
|
303
|
+
isRecording={isRecording}
|
|
304
|
+
onExportSession={(s) => shareFile(JSON.stringify(s))}
|
|
305
|
+
/>
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
| Style | Description |
|
|
309
|
+
|---|---|
|
|
310
|
+
| `minimal` | Horizontal bar: speed selector (1×/2×/4×/8×) + pause/resume |
|
|
311
|
+
| `full` | Card: speed, pause/resume, scenario log, session recorder |
|
|
312
|
+
| `floating` | Collapsible bottom sheet — does not obscure the map |
|
|
313
|
+
|
|
314
|
+
Uses Klaroways colour tokens (`#081525`, `#00BF76`, etc.) to match `NavCoreTestScreen`.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Noise Profiles
|
|
319
|
+
|
|
320
|
+
| Profile | stdDevM | Use case |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| `NONE` | 0 | Exact coordinates — CI tests |
|
|
323
|
+
| `CLEAN` | 3 m | Good open-sky GPS |
|
|
324
|
+
| `STANDARD` | 12 m | Matches original Go4Exams ±0.00011° |
|
|
325
|
+
| `URBAN` | 25 m | Dense city canyon |
|
|
326
|
+
| `DEGRADED` | 50 m | Tunnel exit / bad conditions |
|
|
327
|
+
| `CUSTOM` | any | Provide `customFn(index) → [lngOff, latOff]` |
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Scenario Types
|
|
332
|
+
|
|
333
|
+
| Type | Factory | Effect |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| `GPS_BLACKOUT` | `gpsBlackout(idx, ms)` | Pauses noise application (dead reckoning stress test) |
|
|
336
|
+
| `WRONG_SIDE_ROAD` | `wrongSideRoad(idx, offsetM)` | Position offset perpendicular to route |
|
|
337
|
+
| `UTURN` | `uTurn(idx)` | Fires at index — engine handles turn logic |
|
|
338
|
+
| `SPEED_SPIKE` | `speedSpike(idx, kmh)` | Temporarily reports higher speed |
|
|
339
|
+
| `ACCURACY_DROP` | `accuracyDrop(idx, m, ms)` | GPS accuracy degrades to `m` metres for `ms` |
|
|
340
|
+
| `CUSTOM` | — | Provide `params` manually |
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## GPX Round-Trip Loop
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
real GPS trace
|
|
348
|
+
↓
|
|
349
|
+
.gpx file
|
|
350
|
+
↓
|
|
351
|
+
GpxParser.parse()
|
|
352
|
+
↓
|
|
353
|
+
SimulatorEngine (replay with noise + scenarios)
|
|
354
|
+
↓
|
|
355
|
+
SessionRecorder.export('gpx')
|
|
356
|
+
↓
|
|
357
|
+
GpxParser.parse() ←── CI assertion: reparsed.coordinates.length === session.positions.length
|
|
358
|
+
↓
|
|
359
|
+
Next regression run
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
This loop is the foundation of the CI strategy. Every format change to `SessionRecorder._toGpx()` that breaks `GpxParser.parse()` will fail the integration test immediately.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## CI / Regression Testing
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
cd packages/navcore-sdk
|
|
370
|
+
npx vitest run --reporter=verbose
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Test files:
|
|
374
|
+
- `packages/simulator/src/core/__tests__/RouteWalker.test.ts`
|
|
375
|
+
- `packages/simulator/src/core/__tests__/NoiseEngine.test.ts`
|
|
376
|
+
- `packages/simulator/src/core/__tests__/SimulatorEngine.test.ts`
|
|
377
|
+
- `packages/simulator/src/scenarios/__tests__/ScenarioInjector.test.ts`
|
|
378
|
+
- `packages/simulator/src/replay/__tests__/GpxParser.test.ts`
|
|
379
|
+
- `packages/simulator/src/__tests__/integration/simulator-pipeline.test.ts`
|
|
380
|
+
|
|
381
|
+
All tests use `vi.useFakeTimers()` — no real waiting, no flakiness.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Migration from useGpsSimulator
|
|
386
|
+
|
|
387
|
+
The migration is a **search-and-replace**, not a rewrite.
|
|
388
|
+
|
|
389
|
+
### 1. Remove the inline hook and local type
|
|
390
|
+
|
|
391
|
+
Delete `useGpsSimulator` function (lines 114–174 in `NavCoreTestScreen`) and the local `SimPosition` interface.
|
|
392
|
+
|
|
393
|
+
### 2. Add import
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
import {
|
|
397
|
+
useSimulator,
|
|
398
|
+
SimulatorControls,
|
|
399
|
+
NoiseEngine,
|
|
400
|
+
ScenarioInjector,
|
|
401
|
+
} from '../../../../packages/navcore-sdk/packages/simulator/src';
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### 3. Replace the hook call
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
// Before
|
|
408
|
+
const simGps = useGpsSimulator(route, isNavigating, speed);
|
|
409
|
+
|
|
410
|
+
// After
|
|
411
|
+
const { position: simGps, controls } = useSimulator({
|
|
412
|
+
routeGeometry: route,
|
|
413
|
+
active: isNavigating,
|
|
414
|
+
config: { speedKmh: 50, noiseProfile: NoiseEngine.STANDARD },
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### 4. Replace the speed UI
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
// Before: 40 lines of inline speedRow JSX + speed state
|
|
422
|
+
// After:
|
|
423
|
+
<SimulatorControls simulator={controls} style="minimal" />
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
The `position` object shape is **identical** to the original `SimPosition` — `coord`, `bearing`, `speed`, `accuracy` are all present.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoiseEngine — GPS noise simulation.
|
|
3
|
+
*
|
|
4
|
+
* Formalises the ±0.00011° noise from the original useGpsSimulator
|
|
5
|
+
* into a testable, seeded, swappable noise model.
|
|
6
|
+
*
|
|
7
|
+
* Box-Muller transform for Gaussian sampling — ZERO external dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Degrees-per-metre constants:
|
|
10
|
+
* 1° latitude ≈ 110540 m
|
|
11
|
+
* 1° longitude ≈ 111320 m (at equator; good enough for noise simulation)
|
|
12
|
+
*/
|
|
13
|
+
import type { Coordinate } from './types';
|
|
14
|
+
import type { NoiseProfile } from './types';
|
|
15
|
+
export declare class NoiseEngine {
|
|
16
|
+
private profile;
|
|
17
|
+
private rng;
|
|
18
|
+
private _tickIndex;
|
|
19
|
+
constructor(profile: NoiseProfile);
|
|
20
|
+
/** No noise — returns the exact coordinate */
|
|
21
|
+
static readonly NONE: NoiseProfile;
|
|
22
|
+
/** Good urban GPS — ~3m standard deviation */
|
|
23
|
+
static readonly CLEAN: NoiseProfile;
|
|
24
|
+
/** Standard Go4Exams value — ~12m standard deviation (matches original ±0.00011°) */
|
|
25
|
+
static readonly STANDARD: NoiseProfile;
|
|
26
|
+
/** Dense city canyon — ~25m standard deviation */
|
|
27
|
+
static readonly URBAN: NoiseProfile;
|
|
28
|
+
/** Bad conditions — ~50m standard deviation */
|
|
29
|
+
static readonly DEGRADED: NoiseProfile;
|
|
30
|
+
/**
|
|
31
|
+
* Apply noise to a coordinate.
|
|
32
|
+
* Returns a new coordinate — never mutates the input.
|
|
33
|
+
*/
|
|
34
|
+
apply(coord: Coordinate): Coordinate;
|
|
35
|
+
/** Hot-swap the noise profile mid-simulation */
|
|
36
|
+
setProfile(profile: NoiseProfile): void;
|
|
37
|
+
get currentProfile(): NoiseProfile;
|
|
38
|
+
/**
|
|
39
|
+
* Box-Muller transform: produces Gaussian N(0, stdDev²) offset.
|
|
40
|
+
* Returns [lngOffset, latOffset] in degrees.
|
|
41
|
+
*/
|
|
42
|
+
private _gaussianOffset;
|
|
43
|
+
/** Returns a float in [0, 1) — uses seeded RNG if available, else Math.random */
|
|
44
|
+
private _rand;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=NoiseEngine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NoiseEngine.d.ts","sourceRoot":"","sources":["../../src/core/NoiseEngine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAyB5C,qBAAa,WAAW;IACtB,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,GAAG,CAAsB;IACjC,OAAO,CAAC,UAAU,CAAK;gBAEX,OAAO,EAAE,YAAY;IAOjC,8CAA8C;IAC9C,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAgC;IAElE,8CAA8C;IAC9C,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAoC;IAEvE,qFAAqF;IACrF,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAqC;IAE3E,kDAAkD;IAClD,MAAM,CAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAkC;IAErE,+CAA+C;IAC/C,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAqC;IAI3E;;;OAGG;IACH,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU;IA6BpC,gDAAgD;IAChD,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI;IAMvC,IAAI,cAAc,IAAI,YAAY,CAEjC;IAID;;;OAGG;IACH,OAAO,CAAC,eAAe;IAcvB,iFAAiF;IACjF,OAAO,CAAC,KAAK;CAGd"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NoiseEngine — GPS noise simulation.
|
|
3
|
+
*
|
|
4
|
+
* Formalises the ±0.00011° noise from the original useGpsSimulator
|
|
5
|
+
* into a testable, seeded, swappable noise model.
|
|
6
|
+
*
|
|
7
|
+
* Box-Muller transform for Gaussian sampling — ZERO external dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Degrees-per-metre constants:
|
|
10
|
+
* 1° latitude ≈ 110540 m
|
|
11
|
+
* 1° longitude ≈ 111320 m (at equator; good enough for noise simulation)
|
|
12
|
+
*/
|
|
13
|
+
// Conversion factors
|
|
14
|
+
const LAT_DEG_PER_M = 1 / 110540;
|
|
15
|
+
const LNG_DEG_PER_M = 1 / 111320;
|
|
16
|
+
/**
|
|
17
|
+
* Simple seeded LCG (Linear Congruential Generator).
|
|
18
|
+
* Same seed guarantees identical noise sequences — critical for CI regression.
|
|
19
|
+
*/
|
|
20
|
+
class SeededRandom {
|
|
21
|
+
constructor(seed) {
|
|
22
|
+
this.state = seed >>> 0; // ensure unsigned 32-bit
|
|
23
|
+
}
|
|
24
|
+
/** Returns a float in [0, 1) */
|
|
25
|
+
next() {
|
|
26
|
+
// LCG parameters from Numerical Recipes
|
|
27
|
+
this.state = (Math.imul(1664525, this.state) + 1013904223) >>> 0;
|
|
28
|
+
return this.state / 0x100000000;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class NoiseEngine {
|
|
32
|
+
constructor(profile) {
|
|
33
|
+
this._tickIndex = 0;
|
|
34
|
+
this.profile = profile;
|
|
35
|
+
this.rng = profile.seed !== undefined ? new SeededRandom(profile.seed) : null;
|
|
36
|
+
}
|
|
37
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Apply noise to a coordinate.
|
|
40
|
+
* Returns a new coordinate — never mutates the input.
|
|
41
|
+
*/
|
|
42
|
+
apply(coord) {
|
|
43
|
+
const idx = this._tickIndex++;
|
|
44
|
+
switch (this.profile.type) {
|
|
45
|
+
case 'NONE':
|
|
46
|
+
return coord;
|
|
47
|
+
case 'GAUSSIAN':
|
|
48
|
+
case 'URBAN': {
|
|
49
|
+
const [dLng, dLat] = this._gaussianOffset(this.profile.stdDevM);
|
|
50
|
+
return [coord[0] + dLng, coord[1] + dLat];
|
|
51
|
+
}
|
|
52
|
+
case 'TUNNEL':
|
|
53
|
+
// Signal loss: return a fixed position (last known would be handled by engine)
|
|
54
|
+
// Here we just return the exact coord (engine handles blackout via scenario)
|
|
55
|
+
return coord;
|
|
56
|
+
case 'CUSTOM': {
|
|
57
|
+
if (!this.profile.customFn)
|
|
58
|
+
return coord;
|
|
59
|
+
const [lngOff, latOff] = this.profile.customFn(idx);
|
|
60
|
+
return [coord[0] + lngOff, coord[1] + latOff];
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
return coord;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Hot-swap the noise profile mid-simulation */
|
|
67
|
+
setProfile(profile) {
|
|
68
|
+
this.profile = profile;
|
|
69
|
+
this.rng = profile.seed !== undefined ? new SeededRandom(profile.seed) : null;
|
|
70
|
+
this._tickIndex = 0;
|
|
71
|
+
}
|
|
72
|
+
get currentProfile() {
|
|
73
|
+
return this.profile;
|
|
74
|
+
}
|
|
75
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Box-Muller transform: produces Gaussian N(0, stdDev²) offset.
|
|
78
|
+
* Returns [lngOffset, latOffset] in degrees.
|
|
79
|
+
*/
|
|
80
|
+
_gaussianOffset(stdDevM) {
|
|
81
|
+
if (stdDevM === 0)
|
|
82
|
+
return [0, 0];
|
|
83
|
+
const u1 = this._rand();
|
|
84
|
+
const u2 = this._rand();
|
|
85
|
+
// Box-Muller
|
|
86
|
+
const mag = stdDevM * Math.sqrt(-2 * Math.log(Math.max(u1, 1e-10)));
|
|
87
|
+
const z0 = mag * Math.cos(2 * Math.PI * u2);
|
|
88
|
+
const z1 = mag * Math.sin(2 * Math.PI * u2);
|
|
89
|
+
return [z0 * LNG_DEG_PER_M, z1 * LAT_DEG_PER_M];
|
|
90
|
+
}
|
|
91
|
+
/** Returns a float in [0, 1) — uses seeded RNG if available, else Math.random */
|
|
92
|
+
_rand() {
|
|
93
|
+
return this.rng ? this.rng.next() : Math.random();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// ── Static presets ─────────────────────────────────────────────────────────
|
|
97
|
+
/** No noise — returns the exact coordinate */
|
|
98
|
+
NoiseEngine.NONE = { type: 'NONE', stdDevM: 0 };
|
|
99
|
+
/** Good urban GPS — ~3m standard deviation */
|
|
100
|
+
NoiseEngine.CLEAN = { type: 'GAUSSIAN', stdDevM: 3 };
|
|
101
|
+
/** Standard Go4Exams value — ~12m standard deviation (matches original ±0.00011°) */
|
|
102
|
+
NoiseEngine.STANDARD = { type: 'GAUSSIAN', stdDevM: 12 };
|
|
103
|
+
/** Dense city canyon — ~25m standard deviation */
|
|
104
|
+
NoiseEngine.URBAN = { type: 'URBAN', stdDevM: 25 };
|
|
105
|
+
/** Bad conditions — ~50m standard deviation */
|
|
106
|
+
NoiseEngine.DEGRADED = { type: 'GAUSSIAN', stdDevM: 50 };
|
|
@@ -0,0 +1,75 @@
|
|
|
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 type { Coordinate } from './types';
|
|
10
|
+
export interface WalkerPosition {
|
|
11
|
+
/** Current floating-point index (e.g. 3.75 = between points 3 and 4) */
|
|
12
|
+
index: number;
|
|
13
|
+
/** Interpolated coordinate at the current index */
|
|
14
|
+
coord: Coordinate;
|
|
15
|
+
/** Forward-looking bearing at the current segment */
|
|
16
|
+
bearing: number;
|
|
17
|
+
/** Total metres travelled from route start */
|
|
18
|
+
distanceTravelled: number;
|
|
19
|
+
/** True if the walker has reached or passed the last point */
|
|
20
|
+
isComplete: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare class RouteWalker {
|
|
23
|
+
private readonly geometry;
|
|
24
|
+
private _index;
|
|
25
|
+
private _distanceTravelled;
|
|
26
|
+
private readonly _cumulativeDistances;
|
|
27
|
+
private _isComplete;
|
|
28
|
+
constructor(geometry: Coordinate[], startIndex?: number);
|
|
29
|
+
/**
|
|
30
|
+
* Advance the walker by `steps` geometry points.
|
|
31
|
+
* Fractional steps are supported for sub-second precision.
|
|
32
|
+
*/
|
|
33
|
+
advance(steps: number): WalkerPosition;
|
|
34
|
+
/**
|
|
35
|
+
* Advance the walker by a physical distance in metres.
|
|
36
|
+
* This is the preferred way for smooth constant-speed simulation.
|
|
37
|
+
*/
|
|
38
|
+
advanceByDistance(meters: number): WalkerPosition;
|
|
39
|
+
private _finalizeAdvance;
|
|
40
|
+
/**
|
|
41
|
+
* Seek directly to a geometry index (integer or fractional).
|
|
42
|
+
* Recalculates bearing from the new position.
|
|
43
|
+
*/
|
|
44
|
+
seek(index: number): WalkerPosition;
|
|
45
|
+
/**
|
|
46
|
+
* Get an interpolated coordinate between geometry[index] and geometry[index+1].
|
|
47
|
+
* @param index - integer index
|
|
48
|
+
* @param fraction - 0..1 interpolation factor
|
|
49
|
+
*/
|
|
50
|
+
interpolate(index: number, fraction: number): Coordinate;
|
|
51
|
+
/**
|
|
52
|
+
* Compute the forward-looking bearing at a given integer index.
|
|
53
|
+
* At the last point, returns the bearing of the final segment.
|
|
54
|
+
*/
|
|
55
|
+
bearingAt(index: number): number;
|
|
56
|
+
/**
|
|
57
|
+
* Total haversine distance from the route start to the given index.
|
|
58
|
+
* Uses the pre-computed cumulative distances (O(1)).
|
|
59
|
+
*/
|
|
60
|
+
distanceAtIndex(index: number): number;
|
|
61
|
+
distanceToIndex(index: number): number;
|
|
62
|
+
/**
|
|
63
|
+
* Convert a distance in metres back to a fractional index.
|
|
64
|
+
* Uses binary search for O(log N) performance.
|
|
65
|
+
*/
|
|
66
|
+
private _distanceToIndex;
|
|
67
|
+
get totalDistanceM(): number;
|
|
68
|
+
get totalPoints(): number;
|
|
69
|
+
get isComplete(): boolean;
|
|
70
|
+
get currentIndex(): number;
|
|
71
|
+
private _currentPosition;
|
|
72
|
+
/** Interpolate cumulative distance for fractional index */
|
|
73
|
+
private _interpolatedDistance;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=RouteWalker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RouteWalker.d.ts","sourceRoot":"","sources":["../../src/core/RouteWalker.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,KAAK,EAAE,MAAM,CAAC;IACd,mDAAmD;IACnD,KAAK,EAAE,UAAU,CAAC;IAClB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,8DAA8D;IAC9D,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAW;IAChD,OAAO,CAAC,WAAW,CAAU;gBAEjB,QAAQ,EAAE,UAAU,EAAE,EAAE,UAAU,SAAI;IAuBlD;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc;IAMtC;;;OAGG;IACH,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc;IAejD,OAAO,CAAC,gBAAgB;IAUxB;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc;IAOnC;;;;OAIG;IACH,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,UAAU;IAQxD;;;OAGG;IACH,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAOhC;;;OAGG;IACH,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAKtC,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAItC;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAgCxB,IAAI,cAAc,IAAI,MAAM,CAE3B;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,IAAI,YAAY,IAAI,MAAM,CAEzB;IAID,OAAO,CAAC,gBAAgB;IAexB,2DAA2D;IAC3D,OAAO,CAAC,qBAAqB;CAQ9B"}
|