@pond-ts/fit 0.31.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.
@@ -0,0 +1,864 @@
1
+ /**
2
+ * Geospatial operators on a pond-ts `TimeSeries` — the geo layer of
3
+ * `@pond-ts/fit`. The thesis: a GPS track is already a time-keyed series of
4
+ * `number` columns, so the geospatial value lives in operators over those
5
+ * columns, not in a new storage primitive.
6
+ *
7
+ * Built strictly on pond's PUBLIC surface: two `number` columns for lat/lng,
8
+ * `column(name).toFloat64Array()` for zero-copy reads, and column reductions.
9
+ * Two places reach past what pond expresses directly today:
10
+ * - Scalar / plain-array results are computed over the raw typed arrays rather
11
+ * than attached back as derived columns — a deliberate choice (no row
12
+ * rebuild). Where a derived column IS wanted, pond's `withColumn` covers it.
13
+ * - Distance-axis aggregation: value-axis histograms (zones / distribution)
14
+ * are pond-native via `byColumn`, but per-interval *splits* (many metrics
15
+ * per distance bucket) and contiguous-run segmentation still walk the arrays
16
+ * — candidate pond primitives (a multi-metric value-axis aggregate; a
17
+ * `scan` / run-length family). See `splitsByDistance` / `segmentsInRange`.
18
+ */
19
+ import { TimeSeries } from 'pond-ts';
20
+ /**
21
+ * The canonical activity schema: time key + lat/lng, with every other per-sample
22
+ * channel optional (present iff the source recorded it). This is the single
23
+ * source the compute layer reads — the "consume the series directly" seam:
24
+ * power/cadence/temp live IN the series alongside ele/hr, not as orphaned
25
+ * parallel arrays.
26
+ */
27
+ export const TRACK_SCHEMA = [
28
+ { name: 'time', kind: 'time' },
29
+ { name: 'lat', kind: 'number', required: false },
30
+ { name: 'lng', kind: 'number', required: false },
31
+ { name: 'ele', kind: 'number', required: false },
32
+ { name: 'hr', kind: 'number', required: false },
33
+ { name: 'power', kind: 'number', required: false },
34
+ { name: 'cadence', kind: 'number', required: false },
35
+ { name: 'temp', kind: 'number', required: false },
36
+ { name: 'distance', kind: 'number', required: false },
37
+ ];
38
+ /** Build a pond track from parallel sample tuples (already time-sorted). */
39
+ export function buildTrack(name, points) {
40
+ return new TimeSeries({ name, schema: TRACK_SCHEMA, rows: points });
41
+ }
42
+ /** Does any of the first `n` cells pass the validity bitmap? (Distinguishes a
43
+ * channel the source recorded with gaps from one it lacked entirely.) */
44
+ function anyDefined(validity, n) {
45
+ for (let i = 0; i < n; i++)
46
+ if (validity.isDefined(i))
47
+ return true;
48
+ return false;
49
+ }
50
+ /**
51
+ * Read one number column as a `Float64Array`, honouring the validity bitmap:
52
+ * `toFloat64Array()` flattens a missing cell to `0` by design, but pond exposes
53
+ * validity separately (`column.hasMissing()` / `column.validity`), so we map
54
+ * missing cells to `NaN` and the math skips them. (Verified against pond's
55
+ * types: missing IS recoverable via the validity API.)
56
+ */
57
+ function numberColumn(track, name) {
58
+ const col = track.column(name);
59
+ const arr = col.toFloat64Array();
60
+ if (!col.hasMissing() || !col.validity)
61
+ return arr;
62
+ const v = col.validity;
63
+ const out = new Float64Array(arr.length);
64
+ for (let i = 0; i < arr.length; i++)
65
+ out[i] = v.isDefined(i) ? arr[i] : NaN;
66
+ return out;
67
+ }
68
+ /**
69
+ * Read a track's columns as `Float64Array`s. lat/lng off the packed value
70
+ * columns (required — never missing); the time axis off the key column via
71
+ * `keyColumn().begin` (a `Float64Array` of ms-since-epoch). Optional channels
72
+ * are read validity-aware and included only when the column carried any value.
73
+ */
74
+ export function readColumns(track) {
75
+ const beginsMs = track.keyColumn().begin;
76
+ const nRows = beginsMs.length;
77
+ const timeSec = new Float64Array(nRows);
78
+ for (let i = 0; i < nRows; i++)
79
+ timeSec[i] = beginsMs[i] / 1000;
80
+ // Whether the source recorded a channel: fully present, or partly present
81
+ // (some sample defined). All-missing ⇒ the source lacked it.
82
+ const present = (name) => {
83
+ const col = track.column(name);
84
+ return (!col.hasMissing() ||
85
+ (col.validity ? anyDefined(col.validity, nRows) : false));
86
+ };
87
+ // lat/lng: full for a GPS activity, EMPTY when the source had no positions —
88
+ // preserving `cols.lat.length` as the hasTrack signal the compute layer uses.
89
+ // Require BOTH columns: the builder always writes them as a pair, so a lone
90
+ // present `lat` would mean a malformed track — read it and we'd emit phantom
91
+ // `(lat, 0)` points on the prime meridian, poisoning distance/bounds.
92
+ const hasPos = present('lat') && present('lng');
93
+ const lat = hasPos
94
+ ? track.column('lat').toFloat64Array()
95
+ : new Float64Array(0);
96
+ const lng = hasPos
97
+ ? track.column('lng').toFloat64Array()
98
+ : new Float64Array(0);
99
+ const ele = numberColumn(track, 'ele');
100
+ const cols = { lat, lng, ele, timeSec };
101
+ // Optional channels: included only when the source recorded them — a channel
102
+ // the source lacked is `undefined` in `cols`, mirroring an absent `streams.*`
103
+ // array, so consumers omit it.
104
+ for (const name of ['hr', 'power', 'cadence', 'temp', 'distance']) {
105
+ if (present(name))
106
+ cols[name] = numberColumn(track, name);
107
+ }
108
+ return cols;
109
+ }
110
+ /** Guard a distance-grid size (bucket / split interval): a non-finite or ≤0
111
+ * value would make `Math.ceil(total / size)` blow up to `Infinity` buckets and
112
+ * spin the bucketing loop forever. Reject it at the boundary instead. */
113
+ function assertPositiveMeters(meters, name) {
114
+ if (!Number.isFinite(meters) || meters <= 0) {
115
+ throw new RangeError(`${name} must be a finite positive number of metres, got ${meters}`);
116
+ }
117
+ }
118
+ const EARTH_RADIUS_M = 6371008.8; // IUGG mean radius
119
+ const DEG = Math.PI / 180;
120
+ /** Great-circle distance between two WGS84 points, in metres. */
121
+ export function haversineMeters(lat1, lng1, lat2, lng2) {
122
+ const dLat = (lat2 - lat1) * DEG;
123
+ const dLng = (lng2 - lng1) * DEG;
124
+ const a = Math.sin(dLat / 2) ** 2 +
125
+ Math.cos(lat1 * DEG) * Math.cos(lat2 * DEG) * Math.sin(dLng / 2) ** 2;
126
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
127
+ }
128
+ /** Per-step distances (step[0] = 0), in metres. */
129
+ export function stepDistances(lat, lng) {
130
+ const n = lat.length;
131
+ const out = new Float64Array(n);
132
+ for (let i = 1; i < n; i++)
133
+ out[i] = haversineMeters(lat[i - 1], lng[i - 1], lat[i], lng[i]);
134
+ return out;
135
+ }
136
+ /** Running total of a per-step series (cumulative[0] = step[0]). */
137
+ export function cumulative(step) {
138
+ const out = new Float64Array(step.length);
139
+ let acc = 0;
140
+ for (let i = 0; i < step.length; i++) {
141
+ acc += step[i];
142
+ out[i] = acc;
143
+ }
144
+ return out;
145
+ }
146
+ /** Total track distance, in metres. */
147
+ export function totalDistanceMeters(lat, lng) {
148
+ let total = 0;
149
+ for (let i = 1; i < lat.length; i++)
150
+ total += haversineMeters(lat[i - 1], lng[i - 1], lat[i], lng[i]);
151
+ return total;
152
+ }
153
+ /**
154
+ * Cumulative elevation gain/loss with a hysteresis threshold to reject the
155
+ * metre-scale jitter in barometric/GPS elevation. We only commit a move once it
156
+ * exceeds `thresholdMeters` from the last committed reference — the standard way
157
+ * to keep noise from inflating "gain" (raw positive-diff sums overcount wildly).
158
+ * NaN samples (missing ele) are skipped.
159
+ */
160
+ export function elevationGainLoss(ele, thresholdMeters = 3) {
161
+ let gain = 0;
162
+ let loss = 0;
163
+ let ref = NaN;
164
+ for (let i = 0; i < ele.length; i++) {
165
+ const e = ele[i];
166
+ if (Number.isNaN(e))
167
+ continue;
168
+ if (Number.isNaN(ref)) {
169
+ ref = e;
170
+ continue;
171
+ }
172
+ const d = e - ref;
173
+ if (d >= thresholdMeters) {
174
+ gain += d;
175
+ ref = e;
176
+ }
177
+ else if (d <= -thresholdMeters) {
178
+ loss += -d;
179
+ ref = e;
180
+ }
181
+ }
182
+ return { gainMeters: gain, lossMeters: loss };
183
+ }
184
+ /**
185
+ * Moving time: sum of inter-sample intervals where instantaneous speed is at or
186
+ * above `speedThresholdMps` (default 0.5 m/s ≈ 1.8 km/h — below this you're
187
+ * stopped/drifting). This is what separates "moving time" from "elapsed time".
188
+ */
189
+ export function movingTimeSeconds(step, timeSec, speedThresholdMps = 0.5) {
190
+ let moving = 0;
191
+ for (let i = 1; i < step.length; i++) {
192
+ const dt = timeSec[i] - timeSec[i - 1];
193
+ if (dt <= 0)
194
+ continue;
195
+ if (step[i] / dt >= speedThresholdMps)
196
+ moving += dt;
197
+ }
198
+ return moving;
199
+ }
200
+ /** Bounding box as `[[minLat, minLng], [maxLat, maxLng]]`. */
201
+ export function boundsOf(lat, lng) {
202
+ let minLat = Infinity;
203
+ let minLng = Infinity;
204
+ let maxLat = -Infinity;
205
+ let maxLng = -Infinity;
206
+ for (let i = 0; i < lat.length; i++) {
207
+ if (lat[i] < minLat)
208
+ minLat = lat[i];
209
+ if (lat[i] > maxLat)
210
+ maxLat = lat[i];
211
+ if (lng[i] < minLng)
212
+ minLng = lng[i];
213
+ if (lng[i] > maxLng)
214
+ maxLng = lng[i];
215
+ }
216
+ return [
217
+ [minLat, minLng],
218
+ [maxLat, maxLng],
219
+ ];
220
+ }
221
+ /** Cumulative distance (m) at each polyline vertex; `[0] = 0`. */
222
+ export function polylineCumulative(polyline) {
223
+ const out = new Array(polyline.length);
224
+ if (polyline.length > 0)
225
+ out[0] = 0;
226
+ for (let i = 1; i < polyline.length; i++) {
227
+ const a = polyline[i - 1];
228
+ const b = polyline[i];
229
+ out[i] = out[i - 1] + haversineMeters(a[0], a[1], b[0], b[1]);
230
+ }
231
+ return out;
232
+ }
233
+ /** The `[lat,lng]` point at cumulative distance `meters` along the polyline,
234
+ * linearly interpolated between the bracketing vertices. Clamps to the ends;
235
+ * non-finite `meters` resolves to the start; null for an empty polyline.
236
+ * `cum` may be passed to avoid recomputation — it MUST correspond to
237
+ * `polyline` (same length/order); a mismatch yields silently wrong results. */
238
+ export function interpolateAtDistance(polyline, meters, cum = polylineCumulative(polyline)) {
239
+ const n = polyline.length;
240
+ if (n === 0)
241
+ return null;
242
+ if (n === 1)
243
+ return [polyline[0][0], polyline[0][1]];
244
+ // NaN/Infinity in → the start, rather than emitting [NaN,NaN] (which Leaflet
245
+ // would render as a broken marker). Not reachable from the lap caller, but
246
+ // the scrub UI could feed a stray 0/0 progress ratio.
247
+ if (!Number.isFinite(meters))
248
+ return [polyline[0][0], polyline[0][1]];
249
+ const total = cum[n - 1];
250
+ const d = Math.max(0, Math.min(meters, total));
251
+ // first vertex at or past d
252
+ let hi = 1;
253
+ while (hi < n - 1 && cum[hi] < d)
254
+ hi++;
255
+ const lo = hi - 1;
256
+ const span = cum[hi] - cum[lo];
257
+ const frac = span > 0 ? (d - cum[lo]) / span : 0;
258
+ const a = polyline[lo];
259
+ const b = polyline[hi];
260
+ return [a[0] + (b[0] - a[0]) * frac, a[1] + (b[1] - a[1]) * frac];
261
+ }
262
+ /**
263
+ * The sub-polyline covering `[startMeters, endMeters]` of the route, with the
264
+ * endpoints interpolated to the exact distances (so a highlight begins and ends
265
+ * where the segment does, not at the nearest vertex) plus every vertex strictly
266
+ * between. Range is clamped to the track and normalized (start ≤ end). Returns
267
+ * the two interpolated endpoints for a zero-length range, `[]` for an empty
268
+ * polyline.
269
+ *
270
+ * `opts.domainTotal` is the length of the ruler `start`/`end` are measured in
271
+ * when that differs from this polyline's own length — laps come in FIT
272
+ * odometer metres and splits in raw-track haversine metres, but the polyline is
273
+ * Douglas–Peucker-simplified and a fraction shorter, so feeding those metres in
274
+ * raw would drift the highlight (≈1 km late on a 180 km ride). Given
275
+ * `domainTotal`, the window is rescaled proportionally onto the polyline so the
276
+ * same FRACTION of the route is sliced. `opts.cum`, if passed, MUST correspond
277
+ * to `polyline`.
278
+ */
279
+ export function polylineSlice(polyline, startMeters, endMeters, opts = {}) {
280
+ const cum = opts.cum ?? polylineCumulative(polyline);
281
+ const n = polyline.length;
282
+ if (n === 0)
283
+ return [];
284
+ const total = cum[n - 1];
285
+ // rescale the window from its own ruler onto the polyline's length
286
+ const scale = opts.domainTotal && opts.domainTotal > 0 ? total / opts.domainTotal : 1;
287
+ const startScaled = startMeters * scale;
288
+ const endScaled = endMeters * scale;
289
+ const s = Math.max(0, Math.min(Math.min(startScaled, endScaled), total));
290
+ const e = Math.max(0, Math.min(Math.max(startScaled, endScaled), total));
291
+ const out = [];
292
+ out.push(interpolateAtDistance(polyline, s, cum));
293
+ for (let i = 0; i < n; i++) {
294
+ if (cum[i] > s && cum[i] < e)
295
+ out.push([polyline[i][0], polyline[i][1]]);
296
+ }
297
+ out.push(interpolateAtDistance(polyline, e, cum));
298
+ return out;
299
+ }
300
+ /**
301
+ * Bounding box via pond's column min/max reductions — the pond-native path,
302
+ * over the public column API.
303
+ */
304
+ export function boundsViaPond(track) {
305
+ const lat = track.column('lat');
306
+ const lng = track.column('lng');
307
+ return [
308
+ [lat.min(), lng.min()],
309
+ [lat.max(), lng.max()],
310
+ ];
311
+ }
312
+ /**
313
+ * Douglas–Peucker polyline simplification with a metre tolerance, for the map
314
+ * overview line. Returns the kept `[lat, lng]` points (endpoints always kept).
315
+ * Perpendicular distance is approximated in a local equirectangular projection
316
+ * (fine at track scale; we are not a projection engine — RFC §6 non-goals).
317
+ */
318
+ export function simplify(points, toleranceMeters) {
319
+ if (points.length <= 2)
320
+ return points.map((p) => [p[0], p[1]]);
321
+ const keep = new Uint8Array(points.length);
322
+ keep[0] = 1;
323
+ keep[points.length - 1] = 1;
324
+ const lat0 = (points[0][0] * Math.PI) / 180;
325
+ const mPerDegLat = 111132.92;
326
+ const mPerDegLng = 111412.84 * Math.cos(lat0);
327
+ const proj = (p) => [
328
+ p[1] * mPerDegLng,
329
+ p[0] * mPerDegLat,
330
+ ];
331
+ const stack = [[0, points.length - 1]];
332
+ while (stack.length) {
333
+ const [first, last] = stack.pop();
334
+ let maxDist = 0;
335
+ let idx = -1;
336
+ const [ax, ay] = proj(points[first]);
337
+ const [bx, by] = proj(points[last]);
338
+ const dx = bx - ax;
339
+ const dy = by - ay;
340
+ const segLen2 = dx * dx + dy * dy;
341
+ for (let i = first + 1; i < last; i++) {
342
+ const [px, py] = proj(points[i]);
343
+ let dist;
344
+ if (segLen2 === 0) {
345
+ dist = Math.hypot(px - ax, py - ay);
346
+ }
347
+ else {
348
+ const t = ((px - ax) * dx + (py - ay) * dy) / segLen2;
349
+ const tc = Math.max(0, Math.min(1, t));
350
+ dist = Math.hypot(px - (ax + tc * dx), py - (ay + tc * dy));
351
+ }
352
+ if (dist > maxDist) {
353
+ maxDist = dist;
354
+ idx = i;
355
+ }
356
+ }
357
+ if (maxDist > toleranceMeters && idx !== -1) {
358
+ keep[idx] = 1;
359
+ stack.push([first, idx], [idx, last]);
360
+ }
361
+ }
362
+ const out = [];
363
+ for (let i = 0; i < points.length; i++)
364
+ if (keep[i])
365
+ out.push([points[i][0], points[i][1]]);
366
+ return out;
367
+ }
368
+ /**
369
+ * Per-interval splits over the DISTANCE axis (per-km, per-mile). A split is a
370
+ * bucket over *cumulative distance* (a derived, monotonic, non-key column) that
371
+ * carries MANY metrics per bucket — time, elevation gain/loss, NP, and avg/max
372
+ * of arbitrary extra channels. `byColumn` bins a single reducer over a value
373
+ * column; this multi-metric-per-bucket shape isn't a single `byColumn` call, so
374
+ * it walks the arrays here. A multi-metric value-axis aggregate is a candidate
375
+ * pond primitive.
376
+ */
377
+ export function splitsByDistance(step, timeSec, ele, intervalMeters = 1000,
378
+ /** Optional 30 s-rolled power (NP smoothing) aligned to the samples; when
379
+ * given, each split gets `normalizedWatts = (mean of rolled^4)^¼`. */
380
+ npRolled,
381
+ /** Optional raw channels to aggregate per split (avg + max). See {@link SplitExtras}. */
382
+ extras = {}) {
383
+ assertPositiveMeters(intervalMeters, 'intervalMeters');
384
+ const splits = [];
385
+ let acc = 0;
386
+ let splitDist = 0;
387
+ let splitStart = timeSec[0] ?? 0;
388
+ // refEle carries the hysteresis reference ACROSS split boundaries (a climb
389
+ // spanning a boundary isn't restarted); gain/loss reset each split.
390
+ let refEle = ele[0] ?? NaN;
391
+ let gain = 0;
392
+ let loss = 0;
393
+ let sum4 = 0;
394
+ let cnt = 0;
395
+ let index = 1;
396
+ // mean = sum/n of finite samples, max = max finite — per channel, per split.
397
+ const hr = new MeanMax();
398
+ const watt = new MeanMax();
399
+ const cad = new MeanMax();
400
+ const spd = new MeanMax();
401
+ const np = () => cnt > 0 ? (sum4 / cnt) ** 0.25 : undefined;
402
+ const emit = (idx, distanceMeters, endTime) => ({
403
+ index: idx,
404
+ distanceMeters,
405
+ durationSeconds: endTime - splitStart,
406
+ elevationGainMeters: gain,
407
+ elevationLossMeters: loss,
408
+ normalizedWatts: np(),
409
+ avgHeartrate: extras.heartrate ? hr.mean() : undefined,
410
+ maxHeartrate: extras.heartrate ? hr.peak() : undefined,
411
+ avgWatts: extras.watts ? watt.mean() : undefined,
412
+ maxWatts: extras.watts ? watt.peak() : undefined,
413
+ avgCadence: extras.cadence ? cad.mean() : undefined,
414
+ avgSpeedMps: extras.speed ? spd.mean() : undefined,
415
+ maxSpeedMps: extras.speed ? spd.peak() : undefined,
416
+ });
417
+ // Clear the per-split aggregates (channel means/maxes + the elevation
418
+ // hysteresis gain/loss) WITHOUT moving the distance/time cursor. refEle is
419
+ // intentionally left alone — the climb reference carries across boundaries.
420
+ const clearAggregates = () => {
421
+ gain = 0;
422
+ loss = 0;
423
+ sum4 = 0;
424
+ cnt = 0;
425
+ hr.reset();
426
+ watt.reset();
427
+ cad.reset();
428
+ spd.reset();
429
+ };
430
+ const resetSplit = (endTime) => {
431
+ acc -= intervalMeters;
432
+ splitDist = 0;
433
+ splitStart = endTime;
434
+ clearAggregates();
435
+ };
436
+ // Accumulate sample i's channel/elevation/NP data into the currently-open
437
+ // split. Used by the dense path (sample i lands in the split being built) and
438
+ // by the gap path's remainder split (sample i is at the END of a big step).
439
+ const accumulateSample = (i) => {
440
+ const e = ele[i];
441
+ if (!Number.isNaN(e) && !Number.isNaN(refEle)) {
442
+ const d = e - refEle;
443
+ if (d >= 3) {
444
+ gain += d;
445
+ refEle = e;
446
+ }
447
+ else if (d <= -3) {
448
+ loss += -d;
449
+ refEle = e;
450
+ }
451
+ }
452
+ else if (!Number.isNaN(e)) {
453
+ refEle = e;
454
+ }
455
+ if (npRolled) {
456
+ const r = npRolled[i];
457
+ if (Number.isFinite(r)) {
458
+ sum4 += r ** 4;
459
+ cnt += 1;
460
+ }
461
+ }
462
+ if (extras.heartrate)
463
+ hr.add(extras.heartrate[i]);
464
+ if (extras.watts)
465
+ watt.add(extras.watts[i]);
466
+ if (extras.cadence)
467
+ cad.add(extras.cadence[i]);
468
+ if (extras.speed)
469
+ spd.add(extras.speed[i]);
470
+ };
471
+ for (let i = 1; i < step.length; i++) {
472
+ const dStep = step[i];
473
+ splitDist += dStep;
474
+ acc += dStep;
475
+ if (dStep <= intervalMeters) {
476
+ // ---- DENSE PATH ----------------------------------------------------
477
+ // The step fits inside one interval, so (acc was < intervalMeters before
478
+ // this step) it can close AT MOST one split. This branch is the original
479
+ // algorithm untouched — dense, gap-free tracks stay byte-identical.
480
+ accumulateSample(i);
481
+ if (acc >= intervalMeters) {
482
+ splits.push(emit(index++, splitDist, timeSec[i]));
483
+ resetSplit(timeSec[i]);
484
+ }
485
+ continue;
486
+ }
487
+ // ---- GAP PATH --------------------------------------------------------
488
+ // One step is larger than the interval — a GPS gap (tunnel, lost signal,
489
+ // auto-pause) drops one big haversine step between consecutive fixes. The
490
+ // original code closed only ONE split here and folded the rest of the
491
+ // distance into it; instead we peel a split at EVERY interval boundary the
492
+ // step spans, apportioning the step's distance and time linearly.
493
+ //
494
+ // The channel/elevation sample sits at the END of the step (sample i), so
495
+ // it belongs only to the final (remainder) split. The synthetic peels in
496
+ // between carry no channel samples (avg/max → undefined). Any aggregates
497
+ // already accrued from earlier dense samples in the open split ride out on
498
+ // the FIRST peel (that is, in time, where they physically landed).
499
+ const openDist = splitDist; // full open-split distance, incl. this step
500
+ const carried = acc - splitDist; // leftover offset from a prior overshoot
501
+ const splitDistPrev = openDist - dStep; // distance accrued before this step
502
+ const t0 = timeSec[i - 1] ?? splitStart;
503
+ const t1 = timeSec[i];
504
+ let bPrev = 0;
505
+ // bk = open-split distance from splitStart to the next interval boundary.
506
+ // Strict `<`: a step that ends exactly ON a boundary leaves a full-interval
507
+ // remainder rather than a 0 m one, so the end-of-step sample (below) always
508
+ // has a non-empty split to land in. The remainder is in (0, interval].
509
+ for (let bk = intervalMeters - carried; bk < openDist; bk += intervalMeters) {
510
+ const endTime = dStep > 0 ? t0 + ((bk - splitDistPrev) / dStep) * (t1 - t0) : t1;
511
+ splits.push(emit(index++, bk - bPrev, endTime));
512
+ splitStart = endTime;
513
+ clearAggregates();
514
+ bPrev = bk;
515
+ }
516
+ // The leftover past the last boundary stays open as the remainder split. It
517
+ // starts exactly on a boundary, so the carried offset resets to 0.
518
+ splitDist = openDist - bPrev;
519
+ acc = splitDist;
520
+ accumulateSample(i);
521
+ }
522
+ if (splitDist > 1) {
523
+ splits.push(emit(index, splitDist, timeSec[timeSec.length - 1]));
524
+ }
525
+ return splits;
526
+ }
527
+ /** A running mean (of finite samples) + max — one per channel per split. NaN /
528
+ * non-finite samples are skipped, so a gap doesn't drag the average to 0. */
529
+ class MeanMax {
530
+ sum = 0;
531
+ n = 0;
532
+ hi = -Infinity;
533
+ add(v) {
534
+ if (!Number.isFinite(v))
535
+ return;
536
+ this.sum += v;
537
+ this.n += 1;
538
+ if (v > this.hi)
539
+ this.hi = v;
540
+ }
541
+ mean() {
542
+ return this.n > 0 ? this.sum / this.n : undefined;
543
+ }
544
+ peak() {
545
+ return this.n > 0 ? this.hi : undefined;
546
+ }
547
+ reset() {
548
+ this.sum = 0;
549
+ this.n = 0;
550
+ this.hi = -Infinity;
551
+ }
552
+ }
553
+ /**
554
+ * The elevation-vs-distance profile, resampled onto an even distance grid
555
+ * (distance-domain, not time). We average elevation within each distance bucket.
556
+ * Used for the profile chart and as the downsample for drawing.
557
+ */
558
+ export function elevationProfile(cumDist, ele, bucketMeters = 100) {
559
+ return profileByDistance(cumDist, ele, bucketMeters).map((p) => ({
560
+ distanceMeters: p.distanceMeters,
561
+ elevationMeters: p.value,
562
+ }));
563
+ }
564
+ /** Fraction trimmed from EACH tail for the OUTER band edges (0.05 ⇒ p5..p95).
565
+ * The band/scale follow the central 90% so GPS-spike anomalies (speed
566
+ * especially) don't dominate. Tighten by raising this. */
567
+ const BAND_TAIL = 0.05;
568
+ /** The INNER band is the inter-quartile range (p25..p75) — the dense, typical
569
+ * middle half of each bucket. Drawn denser than the outer envelope. */
570
+ const INNER_LO_Q = 0.25;
571
+ const INNER_HI_Q = 0.75;
572
+ /** How far {@link bucketByColumn} carries the last value across empty bins
573
+ * before it gives up and breaks the line. Short gaps (a few missing samples)
574
+ * carry forward for continuity; a SUSTAINED hole — a dropped HR strap for an
575
+ * hour, a paused sensor — would otherwise draw a long flat line at a stale
576
+ * value, so beyond this distance we emit NaN and let the chart break instead
577
+ * of fabricating data. */
578
+ const MAX_CARRY_METERS = 1000;
579
+ /**
580
+ * Resample ANY per-sample channel (elevation, hr, power, cadence, speed, …)
581
+ * onto an even distance grid — the generalization of {@link elevationProfile}
582
+ * to any value column. Per bucket: `value` is the **median** (robust), and
583
+ * `bandLo`/`bandHi` are the central-90% **percentiles** (not raw min/max), so
584
+ * a single anomalous sample can't set the band or the chart scale. `NaN`
585
+ * (missing) samples are skipped; the last bucket carries forward so the line
586
+ * stays continuous. Distance-domain bucketing of a value array — like the
587
+ * `byColumn` histograms, but emitting a median + percentile band per bucket.
588
+ */
589
+ export function profileByDistance(cumDist, values, bucketMeters = 100) {
590
+ assertPositiveMeters(bucketMeters, 'bucketMeters');
591
+ const total = cumDist[cumDist.length - 1] ?? 0;
592
+ const nBuckets = Math.max(1, Math.ceil(total / bucketMeters));
593
+ return bucketByColumn(cumDist, values, bucketMeters, 0, nBuckets);
594
+ }
595
+ /**
596
+ * Like {@link profileByDistance} but buckets ONLY the samples whose cumulative
597
+ * distance falls in `[startMeters, endMeters]`, at `bucketMeters` resolution.
598
+ * Returned `distanceMeters` are absolute (offset by `startMeters`), so the
599
+ * output drops straight into the same distance axis as the full-activity
600
+ * profile. This is what lets the chart reveal fine detail when zoomed to a
601
+ * locked split/lap — the bucket can be far finer than the whole-activity grid
602
+ * (e.g. ~10 m vs 100 m) without paying to re-bucket the entire track. Buckets
603
+ * here are aligned to `startMeters`, an independent grid from profileByDistance.
604
+ */
605
+ export function profileByDistanceWindow(cumDist, values, startMeters, endMeters, bucketMeters = 25) {
606
+ assertPositiveMeters(bucketMeters, 'bucketMeters');
607
+ const span = Math.max(0, endMeters - startMeters);
608
+ const nBuckets = Math.max(1, Math.ceil(span / bucketMeters));
609
+ return bucketByColumn(cumDist, values, bucketMeters, startMeters, nBuckets, startMeters, endMeters);
610
+ }
611
+ /** The 3-column series `bucketByColumn` feeds to pond's value-axis aggregator:
612
+ * a dummy time key (byColumn ignores the temporal axis), the distance the
613
+ * bin is keyed on, and the channel value being reduced. */
614
+ const PROFILE_SCHEMA = [
615
+ { name: 'time', kind: 'time' },
616
+ { name: 'dist', kind: 'number' },
617
+ // optional so a missing/NaN sample can ride as `undefined` (validity bitmap);
618
+ // pond's reducer non-finite policy then skips it.
619
+ { name: 'val', kind: 'number', required: false },
620
+ ];
621
+ /**
622
+ * Distance-domain bucketing of an arbitrary value column — the line + band — via
623
+ * pond's `byColumn` value-axis aggregator. Per bin: `value` is the **median**,
624
+ * `bandLo/bandHi` the central-90% percentiles (`p5/p95`), `innerLo/innerHi` the
625
+ * IQR (`p25/p75`) — all pond reducers (linear-interpolated percentiles). We then
626
+ * scatter the bins onto a FIXED `0..nBuckets-1` grid and **carry forward** across
627
+ * empty bins (NaN before the first occupied), preserving the prior continuity
628
+ * semantics. The final-boundary sample (`dist === total`) is clamped into the
629
+ * last bin (as the old `Math.min` did) so the grids match exactly. `window`
630
+ * restricts to `[start, end]`; `origin` aligns bin 0's left edge.
631
+ */
632
+ function bucketByColumn(cumDist, values, bucketMeters, originMeters, nBuckets, startMeters, endMeters) {
633
+ const top = originMeters + nBuckets * bucketMeters;
634
+ const len = Math.min(cumDist.length, values.length);
635
+ const rows = [];
636
+ for (let i = 0; i < len; i++) {
637
+ const d = cumDist[i];
638
+ if (startMeters !== undefined && (d < startMeters || d > endMeters))
639
+ continue;
640
+ const v = values[i];
641
+ // clamp the boundary sample into the last bin (the old Math.min behaviour),
642
+ // and drop NaN to `undefined` so pond's reducer non-finite policy skips it.
643
+ rows.push([i, Math.min(d, top - 1e-6), Number.isNaN(v) ? undefined : v]);
644
+ }
645
+ const bins = rows.length
646
+ ? new TimeSeries({
647
+ name: 'profile',
648
+ schema: PROFILE_SCHEMA,
649
+ rows,
650
+ }).byColumn('dist', { width: bucketMeters, origin: originMeters }, {
651
+ value: { from: 'val', using: 'median' },
652
+ bandLo: { from: 'val', using: `p${BAND_TAIL * 100}` },
653
+ bandHi: { from: 'val', using: `p${(1 - BAND_TAIL) * 100}` },
654
+ innerLo: { from: 'val', using: `p${INNER_LO_Q * 100}` },
655
+ innerHi: { from: 'val', using: `p${INNER_HI_Q * 100}` },
656
+ count: { from: 'val', using: 'count' },
657
+ })
658
+ : [];
659
+ const byIndex = new Map();
660
+ for (const b of bins)
661
+ byIndex.set(Math.round((b.start - originMeters) / bucketMeters), b);
662
+ const out = [];
663
+ let lastV = 0;
664
+ let lastLo = 0;
665
+ let lastHi = 0;
666
+ let lastILo = 0;
667
+ let lastIHi = 0;
668
+ let seen = false;
669
+ // carry forward across short gaps, but break the line across a sustained hole
670
+ // (see MAX_CARRY_METERS) rather than draw a long flat line at a stale value.
671
+ const maxCarryBins = Math.max(1, Math.ceil(MAX_CARRY_METERS / bucketMeters));
672
+ let emptyRun = 0;
673
+ for (let i = 0; i < nBuckets; i++) {
674
+ const b = byIndex.get(i);
675
+ if (b && b.count > 0) {
676
+ lastV = b.value;
677
+ lastLo = b.bandLo;
678
+ lastHi = b.bandHi;
679
+ lastILo = b.innerLo;
680
+ lastIHi = b.innerHi;
681
+ seen = true;
682
+ emptyRun = 0;
683
+ }
684
+ else {
685
+ emptyRun++;
686
+ }
687
+ // draw the carried value only before the gap outgrows the carry window
688
+ const live = seen && emptyRun <= maxCarryBins;
689
+ out.push({
690
+ distanceMeters: originMeters + i * bucketMeters,
691
+ value: live ? lastV : NaN,
692
+ bandLo: live ? lastLo : NaN,
693
+ bandHi: live ? lastHi : NaN,
694
+ innerLo: live ? lastILo : NaN,
695
+ innerHi: live ? lastIHi : NaN,
696
+ });
697
+ }
698
+ return out;
699
+ }
700
+ /**
701
+ * Rolling percentile spread of a raw per-sample channel, evaluated at each
702
+ * `distance` over a FIXED ±`radiusMeters` window of the raw samples — NOT the
703
+ * chart's buckets. This is what makes the variance underlay zoom-stable: the
704
+ * band measures the same real span of churn whether the chart is bucketed at
705
+ * 100 m (whole ride) or 10 m (a locked split), so it blooms where effort was
706
+ * genuinely punchy and pinches where steady, identically at every zoom. The
707
+ * within-bucket percentiles in {@link profileByDistance} can't do this — their
708
+ * width scales with bucket duration.
709
+ *
710
+ * pond 0.30's `rollingByColumn('dist', { radius, at: distances }, …)` owns the
711
+ * whole thing: the raw samples are the rows, and `at` evaluates the ±radius
712
+ * window + the four percentiles at each chart-grid center directly (one record
713
+ * per center) — no interleave, no read-back bookkeeping. `cum` and `distances`
714
+ * must both ascend (rollingByColumn enforces it). The `{ at }` option (added in
715
+ * pond 0.30) evaluates the window at each grid center directly.
716
+ */
717
+ export function rollingSpread(cumDist, values, distances, radiusMeters) {
718
+ const n = Math.min(cumDist.length, values.length);
719
+ // The raw samples become the rows (NaN/±Inf → undefined so the percentile
720
+ // reducer skips them); `distances` are passed as explicit window centers via
721
+ // pond 0.30's `{ at }`, returning one record per center over the ±radius
722
+ // window of surrounding samples. Both `dist` and `at` must be non-decreasing —
723
+ // rollingByColumn throws otherwise, enforcing the ascending precondition.
724
+ const rows = [];
725
+ for (let i = 0; i < n; i++) {
726
+ const v = values[i];
727
+ rows.push([i, cumDist[i], Number.isFinite(v) ? v : undefined]);
728
+ }
729
+ const recs = new TimeSeries({
730
+ name: 'spread',
731
+ schema: PROFILE_SCHEMA,
732
+ rows,
733
+ }).rollingByColumn('dist', { radius: radiusMeters, at: distances }, {
734
+ bandLo: { from: 'val', using: `p${BAND_TAIL * 100}` },
735
+ bandHi: { from: 'val', using: `p${(1 - BAND_TAIL) * 100}` },
736
+ innerLo: { from: 'val', using: `p${INNER_LO_Q * 100}` },
737
+ innerHi: { from: 'val', using: `p${INNER_HI_Q * 100}` },
738
+ });
739
+ return recs.map((r) => ({
740
+ bandLo: r.bandLo ?? NaN,
741
+ bandHi: r.bandHi ?? NaN,
742
+ innerLo: r.innerLo ?? NaN,
743
+ innerHi: r.innerHi ?? NaN,
744
+ }));
745
+ }
746
+ /** Canonical distances (metres) for a run's best-efforts table: 400 m, ½ mi,
747
+ * 1 K, 1 mi, 2 mi, 5 K, 10 K, half, full. */
748
+ export const BEST_EFFORT_DISTANCES = [
749
+ 400, 804.672, 1000, 1609.344, 3218.688, 5000, 10000, 21097.5, 42195,
750
+ ];
751
+ /**
752
+ * Best efforts over distance: for each target distance, the fastest time over
753
+ * any window covering at least that distance. The distance-axis analogue of the
754
+ * power curve — a two-pointer over cumulative distance keeps the window just ≥
755
+ * the target while minimising elapsed time, O(n) per distance. Times are clamped
756
+ * non-decreasing across the (ascending) distance list. Walks the arrays: it's a
757
+ * fastest-window search over a derived monotonic axis swept across many target
758
+ * distances — beyond pond's single-window `rolling` (a multi-window sweep is a
759
+ * candidate pond primitive). `hr` (optional) yields the mean heart rate over each
760
+ * fastest window.
761
+ * Precondition: `distances` must be ascending — the `> total` early-exit and the
762
+ * non-decreasing-time clamp both rely on it (`BEST_EFFORT_DISTANCES` is).
763
+ */
764
+ export function bestEffortsByDistance(cumDist, timeSec, distances = BEST_EFFORT_DISTANCES, hr) {
765
+ const n = Math.min(cumDist.length, timeSec.length);
766
+ const total = n > 0 ? cumDist[n - 1] - cumDist[0] : 0;
767
+ const out = [];
768
+ let prev = 0;
769
+ for (const d of distances) {
770
+ if (d > total)
771
+ break;
772
+ let best = Infinity;
773
+ let bestLo = 0;
774
+ let bestHi = 0;
775
+ let lo = 0;
776
+ for (let hi = 1; hi < n; hi++) {
777
+ // advance lo to the tightest window ending at hi that still covers ≥ d
778
+ while (lo + 1 < hi && cumDist[hi] - cumDist[lo + 1] >= d)
779
+ lo++;
780
+ if (cumDist[hi] - cumDist[lo] >= d) {
781
+ const t = timeSec[hi] - timeSec[lo];
782
+ if (t < best) {
783
+ best = t;
784
+ bestLo = lo;
785
+ bestHi = hi;
786
+ }
787
+ }
788
+ }
789
+ if (!Number.isFinite(best))
790
+ continue;
791
+ // fastest time can only grow with distance; clamp the discrete-window blips.
792
+ const seconds = Math.max(best, prev);
793
+ prev = seconds;
794
+ const e = {
795
+ meters: d,
796
+ seconds,
797
+ startIndex: bestLo,
798
+ endIndex: bestHi,
799
+ };
800
+ if (hr) {
801
+ let sum = 0;
802
+ let cnt = 0;
803
+ for (let i = bestLo; i <= bestHi && i < hr.length; i++) {
804
+ if (Number.isFinite(hr[i])) {
805
+ sum += hr[i];
806
+ cnt += 1;
807
+ }
808
+ }
809
+ if (cnt > 0)
810
+ e.avgHeartrate = sum / cnt;
811
+ }
812
+ out.push(e);
813
+ }
814
+ return out;
815
+ }
816
+ /**
817
+ * The contiguous track pieces where a per-sample channel falls within
818
+ * `[lo, hi]` (inclusive) — the selection driver behind zone / power-distribution
819
+ * highlighting. A value predicate over the series yields the scattered stretches
820
+ * of the ride that match (e.g. "every part in the Tempo HR band"). Non-finite
821
+ * samples are out of range (they break a run). `cumDist` and `values` are
822
+ * sample-aligned. Adjacent runs separated by ≤ `bridgeMeters` of out-of-range
823
+ * distance merge into one (default 0 = faithful, no merge) so a momentary
824
+ * excursion doesn't shatter the selection into hundreds of slivers. Each run
825
+ * spans [cum at its first in-range sample, cum at its last]; a lone sample is a
826
+ * zero-length segment at its point.
827
+ *
828
+ * Run-length encoding of a predicate over a value column. pond has no
829
+ * contiguous-run / RLE-by-predicate primitive yet — a `scan` / segmentation
830
+ * family is a candidate pond primitive; until then this walks the arrays.
831
+ */
832
+ export function segmentsInRange(cumDist, values, lo, hi, bridgeMeters = 0) {
833
+ const n = Math.min(cumDist.length, values.length);
834
+ const raw = [];
835
+ let start = -1;
836
+ let end = -1;
837
+ for (let i = 0; i < n; i++) {
838
+ const v = values[i];
839
+ if (Number.isFinite(v) && v >= lo && v <= hi) {
840
+ if (start < 0)
841
+ start = i;
842
+ end = i;
843
+ }
844
+ else if (start >= 0) {
845
+ raw.push({ startMeters: cumDist[start], endMeters: cumDist[end] });
846
+ start = -1;
847
+ }
848
+ }
849
+ if (start >= 0)
850
+ raw.push({ startMeters: cumDist[start], endMeters: cumDist[end] });
851
+ if (bridgeMeters <= 0 || raw.length < 2)
852
+ return raw;
853
+ // merge runs whose out-of-range gap is within bridgeMeters
854
+ const merged = [{ ...raw[0] }];
855
+ for (let i = 1; i < raw.length; i++) {
856
+ const last = merged[merged.length - 1];
857
+ if (raw[i].startMeters - last.endMeters <= bridgeMeters)
858
+ last.endMeters = raw[i].endMeters;
859
+ else
860
+ merged.push({ ...raw[i] });
861
+ }
862
+ return merged;
863
+ }
864
+ //# sourceMappingURL=index.js.map