@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.
- package/CHANGELOG.md +3254 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/activity/index.d.ts +254 -0
- package/dist/activity/index.js +652 -0
- package/dist/cjs-fallback.cjs +15 -0
- package/dist/geo/index.d.ts +358 -0
- package/dist/geo/index.js +864 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/intervals.d.ts +10 -0
- package/dist/intervals.js +17 -0
- package/dist/power/index.d.ts +126 -0
- package/dist/power/index.js +279 -0
- package/dist/profile/index.d.ts +125 -0
- package/dist/profile/index.js +217 -0
- package/dist/quantities.d.ts +108 -0
- package/dist/quantities.js +207 -0
- package/dist/summary/index.d.ts +138 -0
- package/dist/summary/index.js +450 -0
- package/dist/track/index.d.ts +44 -0
- package/dist/track/index.js +65 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.js +10 -0
- package/dist/units.d.ts +41 -0
- package/dist/units.js +69 -0
- package/dist/zones/index.d.ts +32 -0
- package/dist/zones/index.js +61 -0
- package/package.json +46 -0
|
@@ -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
|