@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.
Files changed (38) hide show
  1. package/README.md +426 -0
  2. package/dist/core/NoiseEngine.d.ts +46 -0
  3. package/dist/core/NoiseEngine.d.ts.map +1 -0
  4. package/dist/core/NoiseEngine.js +106 -0
  5. package/dist/core/RouteWalker.d.ts +75 -0
  6. package/dist/core/RouteWalker.d.ts.map +1 -0
  7. package/dist/core/RouteWalker.js +177 -0
  8. package/dist/core/SimulatorEngine.d.ts +53 -0
  9. package/dist/core/SimulatorEngine.d.ts.map +1 -0
  10. package/dist/core/SimulatorEngine.js +283 -0
  11. package/dist/core/types.d.ts +129 -0
  12. package/dist/core/types.d.ts.map +1 -0
  13. package/dist/core/types.js +7 -0
  14. package/dist/fleet/FleetSimulator.d.ts +60 -0
  15. package/dist/fleet/FleetSimulator.d.ts.map +1 -0
  16. package/dist/fleet/FleetSimulator.js +114 -0
  17. package/dist/index.d.ts +28 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +29 -0
  20. package/dist/node.d.ts +26 -0
  21. package/dist/node.d.ts.map +1 -0
  22. package/dist/node.js +25 -0
  23. package/dist/react-native/SimulatorControls.d.ts +35 -0
  24. package/dist/react-native/SimulatorControls.d.ts.map +1 -0
  25. package/dist/react-native/SimulatorControls.js +254 -0
  26. package/dist/react-native/useSimulator.d.ts +55 -0
  27. package/dist/react-native/useSimulator.d.ts.map +1 -0
  28. package/dist/react-native/useSimulator.js +97 -0
  29. package/dist/replay/GpxParser.d.ts +40 -0
  30. package/dist/replay/GpxParser.d.ts.map +1 -0
  31. package/dist/replay/GpxParser.js +95 -0
  32. package/dist/replay/SessionRecorder.d.ts +37 -0
  33. package/dist/replay/SessionRecorder.d.ts.map +1 -0
  34. package/dist/replay/SessionRecorder.js +120 -0
  35. package/dist/scenarios/ScenarioInjector.d.ts +55 -0
  36. package/dist/scenarios/ScenarioInjector.d.ts.map +1 -0
  37. package/dist/scenarios/ScenarioInjector.js +150 -0
  38. 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"}