@glandais/vcyclist-engine 1.2.1 → 3.0.0

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 CHANGED
@@ -3,6 +3,10 @@
3
3
  [![npm engine](https://img.shields.io/npm/v/@glandais/vcyclist-engine?label=%40glandais%2Fvcyclist-engine)](https://www.npmjs.com/package/@glandais/vcyclist-engine)
4
4
  [![npm elevation](https://img.shields.io/npm/v/@glandais/vcyclist-elevation?label=%40glandais%2Fvcyclist-elevation)](https://www.npmjs.com/package/@glandais/vcyclist-elevation)
5
5
  [![Maven Central](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-engine?label=io.github.glandais%3Avcyclist-engine)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-engine)
6
+ [![Maven Central elevation](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-elevation?label=io.github.glandais%3Avcyclist-elevation)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-elevation)
7
+ [![Maven Central gpx](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-gpx?label=io.github.glandais%3Avcyclist-gpx)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-gpx)
8
+ [![Maven Central fit](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-fit?label=io.github.glandais%3Avcyclist-fit)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-fit)
9
+ [![Maven Central map](https://img.shields.io/maven-central/v/io.github.glandais/vcyclist-map?label=io.github.glandais%3Avcyclist-map)](https://central.sonatype.com/artifact/io.github.glandais/vcyclist-map)
6
10
 
7
11
  Kotlin Multiplatform port of [`@glandais/virtual-cyclist`](https://github.com/glandais/virtual-cyclist):
8
12
  physics-based cycling simulator that turns a static GPS trace into a virtualized ride with
@@ -37,19 +41,29 @@ TypeScript library for elevation data.
37
41
 
38
42
  | Module | Purpose | Targets |
39
43
  |---|---|---|
40
- | **`:elevation`** | Terrarium tile fetch + DEM lookup + Haversine + Douglas-Peucker 3D + triangular smoother. See [`elevation/README.md`](elevation/README.md). | JVM, JS Node, JS browser, Wasm browser |
41
- | **`:engine`** | Path model (36 fields × `DoubleArray`), physics (4 resistive `PowerProvider`s + cyclist input + `MaxSpeedComputer` + `VirtualizeService`), GPX I/O, `Enhancer` pipeline, JVM CLI. | JVM, JS Node, JS browser, Wasm browser |
44
+ | **`:elevation`** | Terrarium tile fetch + DEM lookup + Haversine + Douglas-Peucker 3D + triangular smoother. See [`elevation/README.md`](elevation/README.md). | JVM, JS Node, JS browser |
45
+ | **`:gpx`** | Path model (36 fields × `DoubleArray`), resamplers, Douglas-Peucker simplifier, elevation steps, GPX I/O. Published to Maven Central; **not** published to npm — its JS output ships inside `@glandais/vcyclist-engine`. | JVM, JS Node, JS browser |
46
+ | **`:engine`** | Physics (4 resistive `PowerProvider`s + cyclist input + `MaxSpeedComputer` + `VirtualizeService`), `Enhancer` pipeline, JVM CLI, JS façades. Re-exports `:gpx` via `api`, so `io.github.glandais.engine.path.*` and `…engine.gpx.*` stay importable from `:engine`. | JVM, JS Node, JS browser |
47
+ | **`:fit`** | Garmin FIT encoding. `FitCourse` model + unit conversions in commonMain; `expect object FitEncoder` with a JVM `actual` on `com.garmin:fit` and a JS `actual` on `@garmin/fitsdk`. | JVM, JS Node, JS browser |
48
+ | **`:map`** | Static map rendering: Web Mercator projection, image framing, tile download + cache, PNG output (`java.awt` / `ImageIO`). **JVM-only.** No default tile source — see [`map/README.md`](map/README.md) for the usage-policy obligations. | JVM only |
49
+ | **`:cli`** | Command-line tool (picocli). **JVM-only, not published as a library** — distributed as an executable jar. Replaces gpx2web's `gpxtools-cli`. | JVM only |
42
50
  | **`:codegen`** | Tiny build-time helper that regenerates `GeneratedPath.kt` + `PointFieldAccessors.kt` from `PointField` (run only when the field list changes). | JVM only |
43
51
 
52
+ ### Migrating from gpx2web
53
+
54
+ vcyclist replaces the `gpx` and `gpxtools-cli` modules of
55
+ [gpx2web](https://github.com/glandais/gpx2web).
56
+ [`docs/gpx2web-coverage.md`](docs/gpx2web-coverage.md) has one row per Java class — ported,
57
+ replaced, or not ported with the reason — plus the deliberate behavioural differences. For the
58
+ command-line options specifically, see [`cli/README.md`](cli/README.md).
59
+
44
60
  ## Install
45
61
 
46
- ### npm (Kotlin/JS or Kotlin/Wasm consumers)
62
+ ### npm (Kotlin/JS consumers)
47
63
 
48
64
  ```bash
49
65
  npm install @glandais/vcyclist-engine # Kotlin/JS bundle
50
- npm install @glandais/vcyclist-engine-wasm # Kotlin/Wasm bundle
51
66
  npm install @glandais/vcyclist-elevation # Kotlin/JS bundle
52
- npm install @glandais/vcyclist-elevation-wasm # Kotlin/Wasm bundle
53
67
  ```
54
68
 
55
69
  ### Gradle / Maven (JVM or KMP consumers)
@@ -57,38 +71,48 @@ npm install @glandais/vcyclist-elevation-wasm # Kotlin/Wasm bundle
57
71
  ```kotlin
58
72
  // Gradle Kotlin DSL
59
73
  dependencies {
60
- implementation("io.github.glandais:vcyclist-engine:1.0.0") // pulls -jvm / -js / -wasm-js per target
74
+ implementation("io.github.glandais:vcyclist-engine:1.0.0") // pulls -jvm / -js per target
61
75
  implementation("io.github.glandais:vcyclist-elevation:1.0.0")
62
76
  }
63
77
  ```
64
78
 
65
79
  Replace `1.0.0` by the latest version shown in the badges above. KMP consumers automatically
66
- get the platform-specific variant (`-jvm`, `-js`, `-wasm-js`) for their target.
80
+ get the platform-specific variant (`-jvm`, `-js`) for their target.
81
+ `vcyclist-gpx` (the `Path` model + GPX I/O) comes in transitively via `vcyclist-engine`, and
82
+ can also be depended on alone if you only need parsing and resampling.
67
83
 
68
84
  See [`docs/publishing.md`](docs/publishing.md) for the release process.
69
85
 
70
86
  ## Quick start
71
87
 
72
- ### Run the JVM CLI
88
+ ### Run the CLI
89
+
90
+ The CLI is **not** on Maven Central — it is an application, distributed as a self-contained jar
91
+ attached to each [GitHub release](https://github.com/glandais/vcyclist/releases). Download
92
+ `vcyclist-cli-<version>-all.jar` and run it with Java 21+, or build it yourself:
73
93
 
74
94
  ```bash
75
- # Enhance a GPX file with the default cyclist (80 kg / 280 W) and bike (Crr 0.004) :
76
- ./gradlew :engine:run -Pargs="enhance path/to/input.gpx -o /tmp/output.gpx"
95
+ # Build a self-contained jar, then run it from anywhere:
96
+ ./gradlew :cli:executableJar
97
+ java -jar cli/build/libs/vcyclist-cli-*-all.jar enhance route.gpx --gpx out.gpx --csv out.csv
98
+
99
+ # Or, during development:
100
+ ./gradlew :cli:run -Pargs="enhance route.gpx --gpx /tmp/out.gpx"
77
101
  ```
78
102
 
79
- The CLI runs the full enhancement pipeline (no elevation correction — no HTTP) and writes the
80
- simulated trace back to a GPX file. See [`engine/src/jvmMain/.../EngineCli.kt`](engine/src/jvmMain/kotlin/io/github/glandais/engine/EngineCli.kt).
103
+ `enhance` runs the physics pipeline; `export` produces maps, FIT, CSV and JSON from a file you
104
+ already have. Elevation correction is off unless you pass `--fix-elevation`, so nothing touches
105
+ the network by default. Full usage, exit codes and the migration table from gpx2web's
106
+ `gpxtools-cli` are in [`cli/README.md`](cli/README.md).
81
107
 
82
- ### Try the browser demos (elevation only)
108
+ ### Try the browser demo (elevation only)
83
109
 
84
110
  ```bash
85
- # Kotlin/Wasm demo
86
- ./gradlew :elevation:wasmJsBrowserDevelopmentRun
87
- # Kotlin/JS demo (sibling, same UI)
111
+ # Kotlin/JS demo
88
112
  ./gradlew :elevation:jsBrowserDevelopmentRun
89
113
  ```
90
114
 
91
- Both demos share the [original TS demo](https://github.com/glandais/elevation) UI (Leaflet +
115
+ The demo shares the [original TS demo](https://github.com/glandais/elevation) UI (Leaflet +
92
116
  Chart.js + GPX upload). See [`elevation/README.md`](elevation/README.md) for details.
93
117
 
94
118
  ### Use from Kotlin
@@ -107,18 +131,89 @@ suspend fun virtualize(xml: String): String {
107
131
  }
108
132
  ```
109
133
 
134
+ ### Use from Java
135
+
136
+ The library is Kotlin-first, so its asynchronous entry points — elevation lookups and the
137
+ `Enhancer` pipeline that may call them — are `suspend` functions. From Java that would mean
138
+ hand-writing a `Continuation`, so each of them has a JVM bridge in two shapes:
139
+
140
+ | Shape | Suffix | Returns | For |
141
+ |---|---|---|---|
142
+ | Blocking | `…Blocking` | the value | batch jobs, CLIs, tests |
143
+ | Asynchronous | `…Async` | `CompletableFuture<T>` | servers, UIs |
144
+
145
+ ```java
146
+ import io.github.glandais.elevation.ElevationProvider;
147
+ import io.github.glandais.elevation.ElevationProviderJvm;
148
+ import io.github.glandais.engine.EnhancerJvm;
149
+ import io.github.glandais.engine.gpx.GpxParserJvm;
150
+ import io.github.glandais.engine.gpx.GpxToPathKt;
151
+ import io.github.glandais.engine.gpx.GpxWriterJvm;
152
+ import io.github.glandais.engine.path.Path;
153
+
154
+ String xml = Files.readString(java.nio.file.Path.of("route.gpx"));
155
+ Path input = GpxToPathKt.firstTrackAsPath(GpxParserJvm.parse(xml));
156
+
157
+ // Physics only, nothing touches the network:
158
+ Path enhanced = EnhancerJvm.enhanceCourseDefaultBlocking(input);
159
+
160
+ // Or with elevation correction, off the calling thread:
161
+ ElevationProvider provider = ElevationProviderJvm.newElevationProvider();
162
+ CompletableFuture<Path> future = EnhancerJvm.enhanceCourseDefaultAsync(input, provider);
163
+
164
+ String out = GpxWriterJvm.write(enhanced);
165
+ ```
166
+
167
+ **Every entry point has a `…Jvm` twin**, in the same package as the Kotlin original:
168
+ `GpxParserJvm`, `GpxWriterJvm`, `GpxFromPathJvm`, `GpxModelJvm`, `PathSimplifierJvm`,
169
+ `ElevationStepJvm`, `TabularWritersJvm` (`:gpx`), `ElevationProviderJvm`, `TileFetcherJvm`
170
+ (`:elevation`), `EnhancerJvm`, `EngineModelJvm`, `ClimbDetectorJvm` (`:engine`), `PathToFitJvm`
171
+ (`:fit`), `MapFactoriesJvm` (`:map`). They exist for two reasons: Kotlin default arguments are
172
+ invisible to Java, and `@JvmOverloads` cannot be used on the common source sets where the API
173
+ lives. Call them and every optional parameter becomes optional again.
174
+
175
+ - `…Blocking` parks the calling thread. **Never call it from a UI thread, from inside a
176
+ coroutine, or from a thread of the executor you passed** — the first two freeze the caller,
177
+ the third can deadlock the pool. Exceptions propagate unchanged.
178
+ - `…Async` takes an optional `java.util.concurrent.Executor` (default: the coroutines IO
179
+ dispatcher, the right pool for network-bound work). Cancelling the returned future cancels the
180
+ work underneath; failures arrive as a `CompletionException`, so unwrap with `getCause()`.
181
+ - Everything else in the library is already synchronous and needs no bridge: `GpxParser`,
182
+ `GpxWriter`, `ElevationStep.smoothElevation`, `PathSimplifier`, the resamplers, FIT and the
183
+ CSV / JSON writers.
184
+ - `ElevationProviderJvm.newElevationProvider(config, fetcher)` takes a
185
+ `Function<String, RawTile>`, so a disk cache — or any other tile transport — plugs in from Java.
186
+ The fetcher may block (it runs on the IO dispatcher) but must be thread-safe: up to ten tiles
187
+ are fetched at once. See [`elevation/README.md`](elevation/README.md) for a complete example.
188
+
110
189
  ### Use from JavaScript / TypeScript
111
190
 
112
- `generateTypeScriptDefinitions()` is enabled on both `js(IR)` and `wasmJs`, so you get a
113
- `.d.ts` next to the bundle in `build/dist/{js,wasmJs}/productionExecutable/vcyclist-engine.d.{ts,mts}`.
191
+ `generateTypeScriptDefinitions()` is enabled on `js(IR)`, so you get a `.d.ts` next to the
192
+ bundle in `build/dist/js/productionExecutable/vcyclist-engine.d.{ts,mts}`.
114
193
 
115
194
  The Kotlin/JS variant (`@glandais/vcyclist-engine`, `@glandais/vcyclist-elevation`) runs
116
- **in both browser and Node.js / Bun**. The Wasm variants (`*-wasm`) are browser-only.
195
+ **in both browser and Node.js / Bun**.
196
+
197
+ #### Getting at the exports
198
+
199
+ Kotlin/JS emits a **UMD bundle that preserves the package namespace**, so the module has
200
+ exactly one top-level export, `io`. Named imports do not work — `import { parseGpx } from
201
+ '@glandais/vcyclist-engine'` fails under Node ESM with *"Named export 'parseGpx' not found"*.
202
+ Unwrap the namespace once and destructure from it :
203
+
204
+ ```js
205
+ import * as engineRaw from '@glandais/vcyclist-engine'; // or: require('@glandais/vcyclist-engine')
206
+
207
+ const engine = engineRaw.io.github.glandais.engine;
208
+ ```
209
+
210
+ Every snippet below assumes that `engine`. `demo/src/engine-shim.ts` does the same unwrap and
211
+ adds TypeScript types for the DTOs, which Kotlin/JS emits as referenced names only.
117
212
 
118
213
  #### Browser
119
214
 
120
215
  ```js
121
- import { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } from '@glandais/vcyclist-engine';
216
+ const { parseGpx, enhance, writeGpx, pathSize, pathTotalDistance } = engine;
122
217
 
123
218
  const handle = parseGpx(gpxXml);
124
219
  console.log('input points:', pathSize(handle));
@@ -130,7 +225,7 @@ const xml = writeGpx(out);
130
225
  #### Node.js / Bun (with elevation correction)
131
226
 
132
227
  ```js
133
- import { parseGpx, enhance, writeGpx } from '@glandais/vcyclist-engine';
228
+ const { parseGpx, enhance, writeGpx } = engine;
134
229
 
135
230
  const handle = parseGpx(gpxXml);
136
231
  const out = await enhance(handle, { fixElevation: true }); // fetches DEM tiles, decodes WebP
@@ -141,6 +236,49 @@ const xml = writeGpx(out);
141
236
  (mapterhorn Terrarium tiles) and runs the full pipeline (densify → fix elevation → smooth →
142
237
  max speeds → virtualize → resample → simplify).
143
238
 
239
+ #### FIT export
240
+
241
+ ```js
242
+ const { parseGpx, enhance, pathToFit } = engine;
243
+
244
+ const out = await enhance(parseGpx(gpxXml), null);
245
+ const fit = pathToFit(out, 'My route', Date.parse('2026-08-01T08:00:00Z'));
246
+ // Kotlin/JS returns an Int8Array containing the FIT file.
247
+ ```
248
+
249
+ FIT has no relative clock, so the start instant is mandatory. The output is a **Course** file
250
+ (a route to follow), which is what a virtualized trace should be — not an Activity.
251
+
252
+ #### Multi-track GPX
253
+
254
+ `parseGpx` returns the **first** track, which is what most files contain. For documents with
255
+ several `<trk>` or several `<trkseg>` :
256
+
257
+ ```js
258
+ const { parseGpxTracks, parseGpxSegments, writeGpxTracks } = engine;
259
+
260
+ const tracks = parseGpxTracks(gpxXml); // one path per <trk> *and* per <rte>
261
+ const segments = parseGpxSegments(gpxXml); // one path per <trkseg>, always continuous
262
+ const xml = writeGpxTracks(tracks); // one <trk> per path
263
+ ```
264
+
265
+ Concatenating segments folds the pause between them into `totalDistance` — a `<trkseg>`
266
+ boundary is a physical discontinuity. Use `parseGpxSegments` when that artefact matters.
267
+
268
+ `parseGpxTracks` also returns `<rte>` routes, which many planners emit and which used to be
269
+ dropped silently. `parseGpxTracksOnly` and `parseGpxRoutesOnly` select one container or the other.
270
+
271
+ #### Options on the writers
272
+
273
+ ```js
274
+ const bare = engine.writeGpx(path, false); // no <extensions>: no power, heart rate, cadence
275
+ const fit = engine.pathsToFit([a, b], 'Two days', Date.parse('2026-08-01T08:00:00Z'));
276
+ ```
277
+
278
+ `writeGpx`, `writeGpxAt` and `writeGpxTracks` take a trailing `writeExtensions` flag (default
279
+ `true`). `pathsToFit` encodes several paths into one FIT course — a lap and a timer event pair
280
+ each — where `pathToFit` takes a single one.
281
+
144
282
  On Node.js / Bun, tile decoding uses [`@jsquash/webp`](https://www.npmjs.com/package/@jsquash/webp)
145
283
  (a pure-WASM WebP decoder, ~50 KB, listed as a runtime `dependency` of
146
284
  `@glandais/vcyclist-engine` and `@glandais/vcyclist-elevation`). It is loaded lazily via
@@ -165,7 +303,7 @@ See [`demo/README.md`](demo/README.md) for the dev workflow and architecture.
165
303
 
166
304
  ```bash
167
305
  ./gradlew check # full build + all tests on all targets
168
- ./gradlew :engine:allTests # engine tests across JVM / JS Node / JS browser / Wasm browser
306
+ ./gradlew :engine:allTests # engine tests across JVM / JS Node / JS browser
169
307
  ./gradlew :elevation:allTests # elevation tests
170
308
  ./gradlew :elevation:jvmTest --tests '*Integration*' \
171
309
  -PINTEGRATION=1 # live HTTP tests against tiles.mapterhorn.com
@@ -182,7 +320,7 @@ vcyclist/
182
320
  │ ├── PLAN.md # task-by-task progress (Phases 1-2bis)
183
321
  │ ├── parity.md # parity strategy vs the TS reference
184
322
  │ ├── elevation-integration.md # how to run live HTTP integration tests
185
- │ ├── kotlin-wasm-jvm-webp.md # Kotlin/Wasm ↔ JS interop guide
323
+ │ ├── kotlin-js-jvm-webp.md # Kotlin/JS ↔ JS interop guide
186
324
  │ └── tasks/ # one Markdown per implementation task (00-31, + bonus demos)
187
325
  ├── elevation/ # :elevation KMP module
188
326
  ├── engine/ # :engine KMP module (depends on :elevation)
@@ -201,11 +339,11 @@ vcyclist/
201
339
  `TileFetcher.js.kt` (browser path unchanged, Node path uses `globalThis.fetch` +
202
340
  `@jsquash/webp` WASM decoder loaded via lazy `eval('require')`), webpack externals to keep
203
341
  the browser bundle free of `@jsquash/webp`, `ElevationProvider` auto-instantiation in
204
- `EngineJsApi.enhance` when `opts.fixElevation` is true (JS + Wasm façades), 6 jsTest classes
342
+ `EngineJsApi.enhance` when `opts.fixElevation` is true (JS façade), 6 jsTest classes
205
343
  gated by `INTEGRATION=1`.
206
344
 
207
- Total `:engine` test coverage : 32 test classes / ~326 commonTest cases / 4 targets =
208
- ~1300 green executions, plus JVM-only smoke tests for the CLI and the full pipeline.
345
+ Total `:engine` test coverage : 32 test classes / ~326 commonTest cases / 3 targets =
346
+ ~1000 green executions, plus JVM-only smoke tests for the CLI and the full pipeline.
209
347
 
210
348
  End-to-end smoke (after Phase 2bis) : sample.gpx (3569 source points, 130 km, ~4550 m gain)
211
349
  runs through the complete `Enhancer` pipeline in ~1.7 s on JVM, producing ~1000 simplified
@@ -216,7 +354,7 @@ output points covering ~128.6 km / ~5.3 h of simulated ride.
216
354
  - [`docs/PLAN.md`](docs/PLAN.md) — task-by-task plan with commit hashes for every step.
217
355
  - [`docs/tasks/`](docs/tasks/) — detailed Markdown spec for each task (00-31 + bonus demos).
218
356
  - [`docs/parity.md`](docs/parity.md) — TS↔Kotlin parity approach and tolerances.
219
- - [`docs/kotlin-wasm-jvm-webp.md`](docs/kotlin-wasm-jvm-webp.md) — Kotlin/Wasm ↔ JS interop
357
+ - [`docs/kotlin-js-jvm-webp.md`](docs/kotlin-js-jvm-webp.md) — Kotlin/JS ↔ JS interop
220
358
  guide that underpins the `@JsExport` façades and the WebP tile decoding.
221
359
  - [`docs/publishing.md`](docs/publishing.md) — release flow (Maven Central + npm via
222
360
  semantic-release on push to `develop`).