@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,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity summary: turn one activity's streams into the metrics + derived
|
|
3
|
+
* series a Strava-class app shows — distance, moving time, elevation gain, the
|
|
4
|
+
* map polyline, the elevation-vs-distance profile, per-km splits. This is the
|
|
5
|
+
* domain layer over the pond-based geo operators in `../geo`.
|
|
6
|
+
*/
|
|
7
|
+
import { TimeSeries } from 'pond-ts';
|
|
8
|
+
import * as geo from '../geo/index.js';
|
|
9
|
+
/**
|
|
10
|
+
* Copy a per-sample array to a Float64Array, mapping every missing/non-finite
|
|
11
|
+
* reading to NaN (so the bucketer skips it, not counts a false 0). Returns null
|
|
12
|
+
* if the channel has no finite sample at all — it's then absent from the view.
|
|
13
|
+
*/
|
|
14
|
+
function cleanColumn(values) {
|
|
15
|
+
if (!values)
|
|
16
|
+
return null;
|
|
17
|
+
const arr = new Float64Array(values.length);
|
|
18
|
+
let any = false;
|
|
19
|
+
for (let i = 0; i < values.length; i++) {
|
|
20
|
+
const v = values[i];
|
|
21
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
22
|
+
arr[i] = v;
|
|
23
|
+
any = true;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
arr[i] = NaN;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return any ? arr : null;
|
|
30
|
+
}
|
|
31
|
+
/** Fixed window (±metres) for the variance band's rolling spread — see
|
|
32
|
+
* geo.rollingSpread. A real distance, not a bucket count, so the band is
|
|
33
|
+
* zoom-stable: the same ~quarter-km of churn whether the chart is at 100 m
|
|
34
|
+
* (whole ride) or 10 m (a locked split) buckets. */
|
|
35
|
+
const SPREAD_RADIUS_M = 120;
|
|
36
|
+
/** A reading at/under this is "off" — coasting (0 W / 0 rpm) or stopped (~0
|
|
37
|
+
* speed). */
|
|
38
|
+
const COAST_EPS = 0.5;
|
|
39
|
+
/** A coast run longer than this is "sustained" (drop to baseline); shorter runs
|
|
40
|
+
* are flicker (between pedal strokes, a momentary freewheel) and just dip the
|
|
41
|
+
* smoothed line. Pond's fill({maxGap}) draws the line. */
|
|
42
|
+
const COAST_MAX_GAP = '10s';
|
|
43
|
+
/** Output channels where 0 = "you stopped doing it" (a real coast), not a real
|
|
44
|
+
* reading. (HR 0 = a strap dropout → handled as missing, not coast.) */
|
|
45
|
+
const COAST_CHANNELS = new Set(['speed', 'power', 'cadence']);
|
|
46
|
+
/** A 0-bpm heart rate is a strap dropout, never a real reading — blank it to
|
|
47
|
+
* NaN (missing) so it bridges/breaks like other missing data, instead of the
|
|
48
|
+
* line diving to zero. (Power/cadence/speed keep their real 0s: those are
|
|
49
|
+
* coasts/stops, handled by the coast classifier.) */
|
|
50
|
+
function blankHrDropout(key, arr) {
|
|
51
|
+
if (key !== 'heartrate')
|
|
52
|
+
return;
|
|
53
|
+
for (let i = 0; i < arr.length; i++)
|
|
54
|
+
if (arr[i] <= COAST_EPS)
|
|
55
|
+
arr[i] = NaN;
|
|
56
|
+
}
|
|
57
|
+
/** A 2-column series for the coast run-length classifier: time + a fillable
|
|
58
|
+
* value (required:false so gap cells can be `undefined`). */
|
|
59
|
+
const COAST_SCHEMA = [
|
|
60
|
+
{ name: 'time', kind: 'time' },
|
|
61
|
+
{ name: 'v', kind: 'number', required: false },
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Per-sample mask of SUSTAINED coasting, using pond's `fill({maxGap})` as a
|
|
65
|
+
* temporal run-length classifier: mark every coast/missing sample `undefined`,
|
|
66
|
+
* fill gaps ≤ {@link COAST_MAX_GAP}, and whatever stays `undefined` was a run
|
|
67
|
+
* longer than the cap. Of those, the ones that began as a real coast (a sub-eps
|
|
68
|
+
* reading, not absent data) are the sustained coasts. Brief coasts come back
|
|
69
|
+
* filled → not flagged → they stay real 0s and merely dip the line.
|
|
70
|
+
*/
|
|
71
|
+
function sustainedCoastMask(key, values, timeSec) {
|
|
72
|
+
const n = values.length;
|
|
73
|
+
const mask = new Array(n).fill(false);
|
|
74
|
+
if (!COAST_CHANNELS.has(key))
|
|
75
|
+
return mask;
|
|
76
|
+
const wasCoast = new Array(n).fill(false);
|
|
77
|
+
const rows = new Array(n);
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const v = values[i];
|
|
80
|
+
const coast = Number.isFinite(v) && v <= COAST_EPS;
|
|
81
|
+
wasCoast[i] = coast;
|
|
82
|
+
// pond rejects NaN; a gap cell (coast or missing) is `undefined`.
|
|
83
|
+
rows[i] = [
|
|
84
|
+
Math.round(timeSec[i] * 1000),
|
|
85
|
+
coast || !Number.isFinite(v) ? undefined : v,
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
// After fill, a cell still UNDEFINED was a gap longer than maxGap — a
|
|
89
|
+
// sustained run. (Read the validity bitmap, not toFloat64Array, which renders
|
|
90
|
+
// a gap as 0 — see geo.readColumns.) Of those, the ones that began as a real
|
|
91
|
+
// coast (not absent data) are the sustained coasts we flag.
|
|
92
|
+
// hold (forward) bridges interior + trailing gaps ≤ maxGap; it can't touch a
|
|
93
|
+
// LEADING gap (no left neighbour), so a coast at sample 0 would always look
|
|
94
|
+
// sustained — chain bfill to bridge a brief leading run from its right side.
|
|
95
|
+
const filledCol = new TimeSeries({
|
|
96
|
+
name: 'coast',
|
|
97
|
+
schema: COAST_SCHEMA,
|
|
98
|
+
rows,
|
|
99
|
+
})
|
|
100
|
+
.fill('hold', { maxGap: COAST_MAX_GAP })
|
|
101
|
+
.fill('bfill', { maxGap: COAST_MAX_GAP })
|
|
102
|
+
.column('v');
|
|
103
|
+
const validity = filledCol.validity;
|
|
104
|
+
const hasMissing = filledCol.hasMissing();
|
|
105
|
+
for (let i = 0; i < n; i++) {
|
|
106
|
+
const stillGap = hasMissing && validity ? !validity.isDefined(i) : false;
|
|
107
|
+
mask[i] = !!wasCoast[i] && stillGap;
|
|
108
|
+
}
|
|
109
|
+
return mask;
|
|
110
|
+
}
|
|
111
|
+
/** Bucket the per-sample coast mask onto a profile's distance grid: a bucket is
|
|
112
|
+
* a coast if ≥ half its samples are sustained-coast. `cum`/`distances` ascend;
|
|
113
|
+
* a two-pointer keeps it ~O(samples + buckets). */
|
|
114
|
+
function bucketCoast(distances, bucketMeters, cum, mask) {
|
|
115
|
+
const n = Math.min(cum.length, mask.length);
|
|
116
|
+
const out = new Array(distances.length).fill(false);
|
|
117
|
+
let p = 0;
|
|
118
|
+
for (let b = 0; b < distances.length; b++) {
|
|
119
|
+
const lo = distances[b];
|
|
120
|
+
const hi = lo + bucketMeters;
|
|
121
|
+
while (p < n && cum[p] < lo)
|
|
122
|
+
p++;
|
|
123
|
+
let coast = 0;
|
|
124
|
+
let total = 0;
|
|
125
|
+
let q = p;
|
|
126
|
+
while (q < n && cum[q] < hi) {
|
|
127
|
+
total++;
|
|
128
|
+
if (mask[q])
|
|
129
|
+
coast++;
|
|
130
|
+
q++;
|
|
131
|
+
}
|
|
132
|
+
out[b] = total > 0 && coast * 2 >= total;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Zip a distance-bucketed profile with the matching time axis (`timeProf`, same
|
|
138
|
+
* bucketing) and the rolling variance spread into a ChannelProfile. The band
|
|
139
|
+
* edges come from `spread` (a fixed-window percentile of the raw samples), NOT
|
|
140
|
+
* the profile's within-bucket percentiles. Sustained-coast buckets (`coast[i]`)
|
|
141
|
+
* have value + band NaN'd and carry the flag, so the chart breaks the line and
|
|
142
|
+
* draws a drop there.
|
|
143
|
+
*/
|
|
144
|
+
function zipProfile(key, prof, timeProf, spread, coast) {
|
|
145
|
+
return {
|
|
146
|
+
key,
|
|
147
|
+
points: prof.map((p, i) => {
|
|
148
|
+
const isCoast = coast[i] ?? false;
|
|
149
|
+
return {
|
|
150
|
+
distanceMeters: p.distanceMeters,
|
|
151
|
+
timeSeconds: timeProf[i]?.value ?? 0,
|
|
152
|
+
value: isCoast ? NaN : p.value,
|
|
153
|
+
bandLo: isCoast ? NaN : (spread[i]?.bandLo ?? NaN),
|
|
154
|
+
bandHi: isCoast ? NaN : (spread[i]?.bandHi ?? NaN),
|
|
155
|
+
innerLo: isCoast ? NaN : (spread[i]?.innerLo ?? NaN),
|
|
156
|
+
innerHi: isCoast ? NaN : (spread[i]?.innerHi ?? NaN),
|
|
157
|
+
coast: isCoast,
|
|
158
|
+
};
|
|
159
|
+
}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Build one channel over the whole track at `bucketMeters`, or null if empty.
|
|
163
|
+
* `timeSec` is the per-sample time (for the coast classifier). */
|
|
164
|
+
function channelProfile(key, values, cum, mask, bucketMeters, timeProf) {
|
|
165
|
+
const arr = cleanColumn(values);
|
|
166
|
+
if (!arr)
|
|
167
|
+
return null;
|
|
168
|
+
blankHrDropout(key, arr);
|
|
169
|
+
const prof = geo.profileByDistance(cum, arr, bucketMeters);
|
|
170
|
+
const dists = prof.map((p) => p.distanceMeters);
|
|
171
|
+
const spread = geo.rollingSpread(cum, arr, dists, SPREAD_RADIUS_M);
|
|
172
|
+
const coast = bucketCoast(dists, bucketMeters, cum, mask ?? []);
|
|
173
|
+
return zipProfile(key, prof, timeProf, spread, coast);
|
|
174
|
+
}
|
|
175
|
+
/** Build one channel within a distance window at `bucketMeters` (fine grid). */
|
|
176
|
+
function windowChannelProfile(key, values, cum, mask, startMeters, endMeters, bucketMeters, timeProf) {
|
|
177
|
+
const arr = cleanColumn(values);
|
|
178
|
+
if (!arr)
|
|
179
|
+
return null;
|
|
180
|
+
blankHrDropout(key, arr);
|
|
181
|
+
const prof = geo.profileByDistanceWindow(cum, arr, startMeters, endMeters, bucketMeters);
|
|
182
|
+
const dists = prof.map((p) => p.distanceMeters);
|
|
183
|
+
// spread + coast use the FULL cum/arr/mask (not just the window) so edges near
|
|
184
|
+
// the window's ends still see their ±radius of raw samples / surrounding runs.
|
|
185
|
+
const spread = geo.rollingSpread(cum, arr, dists, SPREAD_RADIUS_M);
|
|
186
|
+
const coast = bucketCoast(dists, bucketMeters, cum, mask ?? []);
|
|
187
|
+
return zipProfile(key, prof, timeProf, spread, coast);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Trailing `windowSec` boxcar mean (the NP smoothing), NaN-skipping. A small
|
|
191
|
+
* array-side rolling, deliberately separate from `power.normalizedPower`'s
|
|
192
|
+
* pond-based `rolling('30s')`: per-split NP would otherwise build ~one pond
|
|
193
|
+
* series per split. It's a time-windowed mean (gaps just yield fewer samples,
|
|
194
|
+
* never credited time), and reconciles with the pond path to rounding on real
|
|
195
|
+
* 1 Hz data (151.7 vs 152 W on Vineman).
|
|
196
|
+
*/
|
|
197
|
+
function rolling(values, timeSec, windowSec = 30) {
|
|
198
|
+
const n = values.length;
|
|
199
|
+
const out = new Float64Array(n);
|
|
200
|
+
let lo = 0;
|
|
201
|
+
let sum = 0;
|
|
202
|
+
let cnt = 0;
|
|
203
|
+
for (let hi = 0; hi < n; hi++) {
|
|
204
|
+
if (Number.isFinite(values[hi])) {
|
|
205
|
+
sum += values[hi];
|
|
206
|
+
cnt += 1;
|
|
207
|
+
}
|
|
208
|
+
while (lo < hi && timeSec[hi] - timeSec[lo] > windowSec) {
|
|
209
|
+
if (Number.isFinite(values[lo])) {
|
|
210
|
+
sum -= values[lo];
|
|
211
|
+
cnt -= 1;
|
|
212
|
+
}
|
|
213
|
+
lo += 1;
|
|
214
|
+
}
|
|
215
|
+
out[hi] = cnt > 0 ? sum / cnt : NaN;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Build the canonical pond series from an activity's streams (timeSeconds are
|
|
221
|
+
* offsets). Works for GPS and GPS-less alike: lat/lng are `undefined` when the
|
|
222
|
+
* source had no positions, so the series is then just time + the recorded
|
|
223
|
+
* channels. The sample count is the positions when present, else the longest
|
|
224
|
+
* recorded channel.
|
|
225
|
+
*/
|
|
226
|
+
export function buildTrackFromStreams(name, streams, startMs) {
|
|
227
|
+
const { latlng, altitudeMeters, timeSeconds, heartrate, watts, cadence, temperatureC, } = streams;
|
|
228
|
+
const distanceMeters = streams.distanceMeters;
|
|
229
|
+
// Sample count: positions when present, else the longest recorded channel. The
|
|
230
|
+
// parsers (gpx/tcx/fit) build every channel by mapping one sample list, so all
|
|
231
|
+
// present channels are equal-length — the tie-break order here is moot.
|
|
232
|
+
const n = Math.max(latlng.length, timeSeconds?.length ?? 0, distanceMeters?.length ?? 0, heartrate?.length ?? 0, watts?.length ?? 0, cadence?.length ?? 0, temperatureC?.length ?? 0);
|
|
233
|
+
// Missing/non-finite → `undefined` (NOT 0, a valid reading). pond rejects
|
|
234
|
+
// null/NaN but accepts undefined for required:false columns and records it in
|
|
235
|
+
// the validity bitmap, so the gap stays a gap — lossless; readColumns reads it
|
|
236
|
+
// back as NaN.
|
|
237
|
+
const cell = (a, i) => {
|
|
238
|
+
const v = a?.[i];
|
|
239
|
+
return v != null && Number.isFinite(v) ? v : undefined;
|
|
240
|
+
};
|
|
241
|
+
const points = new Array(n);
|
|
242
|
+
for (let i = 0; i < n; i++) {
|
|
243
|
+
const ms = startMs + (timeSeconds?.[i] ?? i) * 1000;
|
|
244
|
+
const pos = latlng[i];
|
|
245
|
+
points[i] = [
|
|
246
|
+
ms,
|
|
247
|
+
pos ? pos[0] : undefined,
|
|
248
|
+
pos ? pos[1] : undefined,
|
|
249
|
+
cell(altitudeMeters, i),
|
|
250
|
+
cell(heartrate, i),
|
|
251
|
+
cell(watts, i),
|
|
252
|
+
cell(cadence, i),
|
|
253
|
+
cell(temperatureC, i),
|
|
254
|
+
cell(distanceMeters, i),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
return geo.buildTrack(name, points);
|
|
258
|
+
}
|
|
259
|
+
/** Cumulative distance from the device's `distance` column (clamped
|
|
260
|
+
* non-decreasing so splits/byColumn hold) — the GPS-less distance axis;
|
|
261
|
+
* all-zero when the source recorded no distance (a pure-HR indoor session). */
|
|
262
|
+
function cumFromCols(distance, n) {
|
|
263
|
+
const cum = new Float64Array(n);
|
|
264
|
+
if (!distance)
|
|
265
|
+
return cum;
|
|
266
|
+
let prev = 0;
|
|
267
|
+
for (let i = 0; i < n; i++) {
|
|
268
|
+
const v = distance[i];
|
|
269
|
+
prev = typeof v === 'number' && Number.isFinite(v) && v > prev ? v : prev;
|
|
270
|
+
cum[i] = prev;
|
|
271
|
+
}
|
|
272
|
+
return cum;
|
|
273
|
+
}
|
|
274
|
+
/** Decode an activity's streams into the shared per-sample arrays. ONE path now:
|
|
275
|
+
* build the canonical series, read its columns; distance comes from haversine
|
|
276
|
+
* when there are positions, else the device `distance` column (GPS-less). */
|
|
277
|
+
export function prepareActivity(imported) {
|
|
278
|
+
const { activity, streams } = imported;
|
|
279
|
+
const startMs = Date.parse(activity.startTimeUtc);
|
|
280
|
+
const track = buildTrackFromStreams(activity.name, streams, startMs);
|
|
281
|
+
const cols = geo.readColumns(track);
|
|
282
|
+
const n = cols.timeSec.length;
|
|
283
|
+
const hasTrack = cols.lat.length > 0;
|
|
284
|
+
let step;
|
|
285
|
+
let cum;
|
|
286
|
+
if (hasTrack) {
|
|
287
|
+
step = geo.stepDistances(cols.lat, cols.lng);
|
|
288
|
+
cum = geo.cumulative(step);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
cum = cumFromCols(cols.distance, n);
|
|
292
|
+
step = new Float64Array(n);
|
|
293
|
+
for (let i = 1; i < n; i++)
|
|
294
|
+
step[i] = cum[i] - cum[i - 1];
|
|
295
|
+
}
|
|
296
|
+
// instantaneous speed (m/s) derived from the track — a channel even when the
|
|
297
|
+
// source never recorded one. Only meaningful with real timestamps.
|
|
298
|
+
const speed = new Float64Array(n);
|
|
299
|
+
speed[0] = NaN;
|
|
300
|
+
for (let i = 1; i < n; i++) {
|
|
301
|
+
const dt = cols.timeSec[i] - cols.timeSec[i - 1];
|
|
302
|
+
speed[i] = dt > 0 ? step[i] / dt : NaN;
|
|
303
|
+
}
|
|
304
|
+
const timeRel = new Float64Array(n);
|
|
305
|
+
for (let i = 0; i < n; i++)
|
|
306
|
+
timeRel[i] = cols.timeSec[i] - cols.timeSec[0];
|
|
307
|
+
// sustained-coast masks for the output channels — once, not per zoom. Power /
|
|
308
|
+
// cadence from the canonical `cols`; speed is the derived track speed.
|
|
309
|
+
const coastMasks = {};
|
|
310
|
+
const maskSources = [
|
|
311
|
+
['speed', streams.timeSeconds ? speed : undefined],
|
|
312
|
+
['power', cols.power],
|
|
313
|
+
['cadence', cols.cadence],
|
|
314
|
+
];
|
|
315
|
+
for (const [k, vals] of maskSources) {
|
|
316
|
+
const a = cleanColumn(vals);
|
|
317
|
+
if (a)
|
|
318
|
+
coastMasks[k] = sustainedCoastMask(k, a, cols.timeSec);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
imported,
|
|
322
|
+
track,
|
|
323
|
+
cols,
|
|
324
|
+
cum,
|
|
325
|
+
step,
|
|
326
|
+
speed,
|
|
327
|
+
timeRel,
|
|
328
|
+
n,
|
|
329
|
+
hasTime: !!streams.timeSeconds,
|
|
330
|
+
hasTrack,
|
|
331
|
+
coastMasks,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** The raw per-sample array backing each channel, in display order (elevation
|
|
335
|
+
* first). One source of truth for which stream feeds which channel, and the
|
|
336
|
+
* emit order — shared by the whole-track build and the windowed (zoom) build
|
|
337
|
+
* so they can't drift. Only present (non-null) channels are emitted. */
|
|
338
|
+
function channelInputs(prep) {
|
|
339
|
+
// Sourced from the canonical `cols` — power/cadence/temp/hr now live in the
|
|
340
|
+
// series (GPS) or the GPS-less `cols`, not the orphaned `streams` arrays.
|
|
341
|
+
// (channelProfile still applies cleanColumn + blankHrDropout downstream, so the
|
|
342
|
+
// NaN-for-missing columns behave exactly as the raw streams did.)
|
|
343
|
+
const { cols } = prep;
|
|
344
|
+
return [
|
|
345
|
+
{ key: 'elevation', values: cols.ele },
|
|
346
|
+
{ key: 'speed', values: prep.hasTime ? prep.speed : undefined },
|
|
347
|
+
{ key: 'heartrate', values: cols.hr },
|
|
348
|
+
{ key: 'power', values: cols.power },
|
|
349
|
+
{ key: 'cadence', values: cols.cadence },
|
|
350
|
+
{ key: 'temperature', values: cols.temp },
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
/** All present channels over the whole track at `bucketMeters` (display order). */
|
|
354
|
+
function buildChannels(prep, bucketMeters) {
|
|
355
|
+
const timeProf = geo.profileByDistance(prep.cum, prep.timeRel, bucketMeters);
|
|
356
|
+
return channelInputs(prep)
|
|
357
|
+
.map((c) => channelProfile(c.key, c.values, prep.cum, prep.coastMasks[c.key], bucketMeters, timeProf))
|
|
358
|
+
.filter((c) => c != null);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Re-bucket the channels at high resolution within a distance window — the
|
|
362
|
+
* chart's payoff when a split/lap is locked and zoomed: the same lines, but
|
|
363
|
+
* resolved to ~10 m instead of the whole-activity 100 m grid, revealing detail
|
|
364
|
+
* the overview averages away. Distances are absolute, so the result drops into
|
|
365
|
+
* the same axis as {@link ActivitySummary.channels} and the chart's range clip.
|
|
366
|
+
*/
|
|
367
|
+
export function windowChannels(prep, opts) {
|
|
368
|
+
const { startMeters, endMeters, targetBuckets = 160, minBucketMeters = 5, maxBucketMeters = 100, } = opts;
|
|
369
|
+
const span = Math.max(0, endMeters - startMeters);
|
|
370
|
+
const bucket = Math.min(maxBucketMeters, Math.max(minBucketMeters, span / targetBuckets));
|
|
371
|
+
const timeProf = geo.profileByDistanceWindow(prep.cum, prep.timeRel, startMeters, endMeters, bucket);
|
|
372
|
+
return channelInputs(prep)
|
|
373
|
+
.map((c) => windowChannelProfile(c.key, c.values, prep.cum, prep.coastMasks[c.key], startMeters, endMeters, bucket, timeProf))
|
|
374
|
+
.filter((c) => c != null);
|
|
375
|
+
}
|
|
376
|
+
/** The full activity summary from already-prepared streams (single decode). */
|
|
377
|
+
export function summaryFromPrepared(prep, options = {}) {
|
|
378
|
+
const { splitMeters = 1000, simplifyMeters = 12, profileBucketMeters = 100, } = options;
|
|
379
|
+
const { imported, track, cols, cum, step, n } = prep;
|
|
380
|
+
const { activity } = imported;
|
|
381
|
+
const { gainMeters, lossMeters } = geo.elevationGainLoss(cols.ele);
|
|
382
|
+
// map outputs only exist for a GPS activity; GPS-less ⇒ no polyline / bounds.
|
|
383
|
+
const latlng = prep.hasTrack ? new Array(n) : [];
|
|
384
|
+
if (prep.hasTrack)
|
|
385
|
+
for (let i = 0; i < n; i++)
|
|
386
|
+
latlng[i] = [cols.lat[i], cols.lng[i]];
|
|
387
|
+
const channels = buildChannels(prep, profileBucketMeters);
|
|
388
|
+
// cleaned per-sample power (NaN for gaps) — feeds both NP rolling and the
|
|
389
|
+
// per-split avg/max watts. Sourced from the canonical `cols` (cleanColumn
|
|
390
|
+
// copies, so blanking below can't mutate the shared column).
|
|
391
|
+
const watts = cleanColumn(cols.power);
|
|
392
|
+
// per-split normalized power needs the 30 s-rolled power (NP smoothing)
|
|
393
|
+
const npRolled = watts ? rolling(watts, cols.timeSec, 30) : undefined;
|
|
394
|
+
// per-split channel aggregates (avg + max): HR / cadence from `cols`, speed
|
|
395
|
+
// from the derived track speed (prepared once). Blank HR dropouts (0 / -1
|
|
396
|
+
// sentinels) so a dropped-strap split reports no HR, not a fake -1.
|
|
397
|
+
const hr = cleanColumn(cols.hr);
|
|
398
|
+
if (hr)
|
|
399
|
+
blankHrDropout('heartrate', hr);
|
|
400
|
+
const splitExtras = {
|
|
401
|
+
heartrate: hr ?? undefined,
|
|
402
|
+
watts: watts ?? undefined,
|
|
403
|
+
cadence: cleanColumn(cols.cadence) ?? undefined,
|
|
404
|
+
speed: prep.hasTime ? prep.speed : undefined,
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
startTimeUtc: activity.startTimeUtc,
|
|
408
|
+
pointCount: n,
|
|
409
|
+
distanceMeters: cum[n - 1] ?? 0,
|
|
410
|
+
elapsedTimeSeconds: n > 1 ? cols.timeSec[n - 1] - cols.timeSec[0] : 0,
|
|
411
|
+
movingTimeSeconds: geo.movingTimeSeconds(step, cols.timeSec),
|
|
412
|
+
elevationGainMeters: gainMeters,
|
|
413
|
+
elevationLossMeters: lossMeters,
|
|
414
|
+
bounds: prep.hasTrack && track ? geo.boundsViaPond(track) : null,
|
|
415
|
+
polyline: prep.hasTrack ? geo.simplify(latlng, simplifyMeters) : [],
|
|
416
|
+
channels,
|
|
417
|
+
splits: geo.splitsByDistance(step, cols.timeSec, cols.ele, splitMeters, npRolled, splitExtras),
|
|
418
|
+
laps: imported.laps ?? [],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/** Compute the full activity summary for one imported activity. */
|
|
422
|
+
export function computeActivitySummary(imported, options = {}) {
|
|
423
|
+
// Truly empty only when there are NO samples at all — no positions, no
|
|
424
|
+
// distance, no time. A GPS-less source (positions empty but distance/time
|
|
425
|
+
// present) flows through prepareActivity to a track-less, map-less activity.
|
|
426
|
+
const s = imported.streams;
|
|
427
|
+
const empty = s.latlng.length === 0 &&
|
|
428
|
+
!(s.distanceMeters?.length ?? 0) &&
|
|
429
|
+
!(s.timeSeconds?.length ?? 0) &&
|
|
430
|
+
!(s.heartrate?.length ?? 0) &&
|
|
431
|
+
!(s.watts?.length ?? 0);
|
|
432
|
+
if (empty) {
|
|
433
|
+
return {
|
|
434
|
+
startTimeUtc: imported.activity.startTimeUtc,
|
|
435
|
+
pointCount: 0,
|
|
436
|
+
distanceMeters: 0,
|
|
437
|
+
elapsedTimeSeconds: 0,
|
|
438
|
+
movingTimeSeconds: 0,
|
|
439
|
+
elevationGainMeters: 0,
|
|
440
|
+
elevationLossMeters: 0,
|
|
441
|
+
bounds: null,
|
|
442
|
+
polyline: [],
|
|
443
|
+
channels: [],
|
|
444
|
+
splits: [],
|
|
445
|
+
laps: imported.laps ?? [],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
return summaryFromPrepared(prepareActivity(imported), options);
|
|
449
|
+
}
|
|
450
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Track` — an ergonomic value object over a bare polyline (`GeoPoint[]`), for
|
|
3
|
+
* the GPS-only case with no activity behind it: a route drawn from stored
|
|
4
|
+
* `[lat, lng]` vertices, a map overlay, a planned line. The fluent face over the
|
|
5
|
+
* polyline operators in {@link import('../geo/index.js') geo} (`polylineCumulative`,
|
|
6
|
+
* `interpolateAtDistance`, `polylineSlice`, `boundsOf`) — measure it, sample a
|
|
7
|
+
* point at a distance, slice a sub-track, get its bounds.
|
|
8
|
+
*
|
|
9
|
+
* For a track WITH time/channels (a recorded activity), use {@link
|
|
10
|
+
* import('../activity/index.js').Activity} instead — `Track` is deliberately the
|
|
11
|
+
* lightweight, position-only sibling. Immutable; the cumulative profile memoizes.
|
|
12
|
+
*/
|
|
13
|
+
import type { GeoPoint } from '../types.js';
|
|
14
|
+
import { Distance } from '../quantities.js';
|
|
15
|
+
export declare class Track {
|
|
16
|
+
private readonly pts;
|
|
17
|
+
private _cumulative?;
|
|
18
|
+
private constructor();
|
|
19
|
+
/** Wrap a polyline — an array of `[latitude, longitude]` vertices. */
|
|
20
|
+
static of(points: ReadonlyArray<GeoPoint>): Track;
|
|
21
|
+
/** The underlying polyline vertices (the escape hatch to the raw line). */
|
|
22
|
+
get points(): ReadonlyArray<GeoPoint>;
|
|
23
|
+
/** Number of vertices. */
|
|
24
|
+
get count(): number;
|
|
25
|
+
get isEmpty(): boolean;
|
|
26
|
+
/** Cumulative distance (metres) at each vertex; `[0] = 0`. Memoized. */
|
|
27
|
+
cumulativeMeters(): number[];
|
|
28
|
+
/** Total length along the polyline. */
|
|
29
|
+
distance(): Distance;
|
|
30
|
+
/** Bounding box `[[minLatitude, minLongitude], [maxLatitude, maxLongitude]]`;
|
|
31
|
+
* `null` for an empty track. */
|
|
32
|
+
bounds(): [[number, number], [number, number]] | null;
|
|
33
|
+
/** The point at a distance along the track, interpolated between the
|
|
34
|
+
* bracketing vertices and clamped to the ends; `null` for an empty track. */
|
|
35
|
+
pointAt(distance: Distance): GeoPoint | null;
|
|
36
|
+
/** The sub-track over `[from, to]`, with the endpoints interpolated to the
|
|
37
|
+
* exact distances. `domainTotal` is the length the `from`/`to` ruler is
|
|
38
|
+
* measured in when it differs from this track's own (e.g. odometer metres vs
|
|
39
|
+
* a simplified polyline) — the window is then rescaled proportionally. */
|
|
40
|
+
slice(from: Distance, to: Distance, opts?: {
|
|
41
|
+
domainTotal?: Distance;
|
|
42
|
+
}): Track;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { polylineCumulative, interpolateAtDistance, polylineSlice, boundsOf, } from '../geo/index.js';
|
|
2
|
+
import { Distance } from '../quantities.js';
|
|
3
|
+
export class Track {
|
|
4
|
+
pts;
|
|
5
|
+
_cumulative;
|
|
6
|
+
constructor(pts) {
|
|
7
|
+
this.pts = pts;
|
|
8
|
+
}
|
|
9
|
+
/** Wrap a polyline — an array of `[latitude, longitude]` vertices. */
|
|
10
|
+
static of(points) {
|
|
11
|
+
return new Track(points);
|
|
12
|
+
}
|
|
13
|
+
/** The underlying polyline vertices (the escape hatch to the raw line). */
|
|
14
|
+
get points() {
|
|
15
|
+
return this.pts;
|
|
16
|
+
}
|
|
17
|
+
/** Number of vertices. */
|
|
18
|
+
get count() {
|
|
19
|
+
return this.pts.length;
|
|
20
|
+
}
|
|
21
|
+
get isEmpty() {
|
|
22
|
+
return this.pts.length === 0;
|
|
23
|
+
}
|
|
24
|
+
/** Cumulative distance (metres) at each vertex; `[0] = 0`. Memoized. */
|
|
25
|
+
cumulativeMeters() {
|
|
26
|
+
return (this._cumulative ??= polylineCumulative(this.pts));
|
|
27
|
+
}
|
|
28
|
+
/** Total length along the polyline. */
|
|
29
|
+
distance() {
|
|
30
|
+
const cumulative = this.cumulativeMeters();
|
|
31
|
+
return Distance.meters(cumulative.length ? cumulative[cumulative.length - 1] : 0);
|
|
32
|
+
}
|
|
33
|
+
/** Bounding box `[[minLatitude, minLongitude], [maxLatitude, maxLongitude]]`;
|
|
34
|
+
* `null` for an empty track. */
|
|
35
|
+
bounds() {
|
|
36
|
+
const n = this.pts.length;
|
|
37
|
+
if (n === 0)
|
|
38
|
+
return null;
|
|
39
|
+
const latitude = new Float64Array(n);
|
|
40
|
+
const longitude = new Float64Array(n);
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
latitude[i] = this.pts[i][0];
|
|
43
|
+
longitude[i] = this.pts[i][1];
|
|
44
|
+
}
|
|
45
|
+
return boundsOf(latitude, longitude);
|
|
46
|
+
}
|
|
47
|
+
/** The point at a distance along the track, interpolated between the
|
|
48
|
+
* bracketing vertices and clamped to the ends; `null` for an empty track. */
|
|
49
|
+
pointAt(distance) {
|
|
50
|
+
return interpolateAtDistance(this.pts, distance.meters, this.cumulativeMeters());
|
|
51
|
+
}
|
|
52
|
+
/** The sub-track over `[from, to]`, with the endpoints interpolated to the
|
|
53
|
+
* exact distances. `domainTotal` is the length the `from`/`to` ruler is
|
|
54
|
+
* measured in when it differs from this track's own (e.g. odometer metres vs
|
|
55
|
+
* a simplified polyline) — the window is then rescaled proportionally. */
|
|
56
|
+
slice(from, to, opts = {}) {
|
|
57
|
+
const sliceOpts = {
|
|
58
|
+
cum: this.cumulativeMeters(),
|
|
59
|
+
};
|
|
60
|
+
if (opts.domainTotal)
|
|
61
|
+
sliceOpts.domainTotal = opts.domainTotal.meters;
|
|
62
|
+
return new Track(polylineSlice(this.pts, from.meters, to.meters, sliceOpts));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=index.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core activity types for the fitness domain.
|
|
3
|
+
*
|
|
4
|
+
* Units are SI throughout (meters, seconds) — the same units the Strava API
|
|
5
|
+
* returns. Conversion to miles/feet happens at the display edge (see
|
|
6
|
+
* `units.ts`); the model never stores imperial. (An earlier design stored
|
|
7
|
+
* miles/feet and paid for it in conversion bugs.)
|
|
8
|
+
*/
|
|
9
|
+
/** A geographic point as [latitude, longitude]. Matches Leaflet + tracks.json. */
|
|
10
|
+
export type GeoPoint = [number, number];
|
|
11
|
+
/** Where an activity came from. Extensible; Strava is the only source for now. */
|
|
12
|
+
export type ActivitySource = 'strava' | 'manual';
|
|
13
|
+
/**
|
|
14
|
+
* The time-series channels recorded during an activity. `latlng` is the track;
|
|
15
|
+
* everything else is optional and present only if the source provided it.
|
|
16
|
+
* Channels are parallel arrays — index i of each is the same sample.
|
|
17
|
+
*/
|
|
18
|
+
export interface ActivityStreams {
|
|
19
|
+
latlng: GeoPoint[];
|
|
20
|
+
altitudeMeters?: number[];
|
|
21
|
+
timeSeconds?: number[];
|
|
22
|
+
heartrate?: number[];
|
|
23
|
+
distanceMeters?: number[];
|
|
24
|
+
/** Power, watts (from a power meter — real, not estimated). */
|
|
25
|
+
watts?: number[];
|
|
26
|
+
/** Cadence, rpm. */
|
|
27
|
+
cadence?: number[];
|
|
28
|
+
/** Temperature, °C. */
|
|
29
|
+
temperatureC?: number[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Activity metadata — the witness statement, minus its substance. The track
|
|
33
|
+
* and other streams live in {@link ActivityStreams}, stored alongside but
|
|
34
|
+
* separately (a track is large; metadata is small and listed often).
|
|
35
|
+
*/
|
|
36
|
+
export interface ActivityMeta {
|
|
37
|
+
/** Stable activity id, `${source}:${externalId}` — never a raw provider id. */
|
|
38
|
+
id: string;
|
|
39
|
+
source: ActivitySource;
|
|
40
|
+
/** The id this activity has in its source system (e.g. the Strava id). */
|
|
41
|
+
externalId: string;
|
|
42
|
+
name: string;
|
|
43
|
+
/** ISO 8601, UTC. */
|
|
44
|
+
startTimeUtc: string;
|
|
45
|
+
/** ISO 8601, local to where the activity happened (if the source gives it). */
|
|
46
|
+
startTimeLocal?: string;
|
|
47
|
+
distanceMeters: number;
|
|
48
|
+
movingTimeSeconds: number;
|
|
49
|
+
elapsedTimeSeconds: number;
|
|
50
|
+
elevationGainMeters: number;
|
|
51
|
+
/** Source's own sport label, e.g. "Run", "Ride". Free text by design. */
|
|
52
|
+
sportType: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A recorded lap — a segment the device marked (auto-lap by distance/time, or
|
|
56
|
+
* a button press), as opposed to the evenly-spaced splits this library *computes*.
|
|
57
|
+
* Recorded laps are the rider's own structure (intervals, climbs, rest stops),
|
|
58
|
+
* so they're carried through as first-class evidence. SI throughout; optional
|
|
59
|
+
* fields are present only when the source recorded them.
|
|
60
|
+
*/
|
|
61
|
+
export interface Lap {
|
|
62
|
+
/** 1-based lap number in recorded order. */
|
|
63
|
+
index: number;
|
|
64
|
+
/** ISO 8601, UTC — when the lap began. Optional: absent (not `''`) when the
|
|
65
|
+
* source recorded no valid start time, so consumers must null-check rather
|
|
66
|
+
* than `Date.parse('')` → NaN. */
|
|
67
|
+
startTimeUtc?: string;
|
|
68
|
+
/** Cumulative distance at the lap's start (sum of prior laps' distance),
|
|
69
|
+
* metres — the lap's [start, start+distance] range into the activity, for
|
|
70
|
+
* chart bands and map-section highlighting. */
|
|
71
|
+
startDistanceMeters: number;
|
|
72
|
+
distanceMeters: number;
|
|
73
|
+
/** Wall-clock for the lap (total_elapsed_time). */
|
|
74
|
+
elapsedSeconds: number;
|
|
75
|
+
/** Timer time, i.e. moving time (total_timer_time). */
|
|
76
|
+
movingSeconds: number;
|
|
77
|
+
avgSpeedMps?: number;
|
|
78
|
+
maxSpeedMps?: number;
|
|
79
|
+
avgWatts?: number;
|
|
80
|
+
maxWatts?: number;
|
|
81
|
+
avgHeartrate?: number;
|
|
82
|
+
maxHeartrate?: number;
|
|
83
|
+
/** Mechanical work over the lap, kJ (FIT total_work is joules). */
|
|
84
|
+
totalWorkKj?: number;
|
|
85
|
+
/** Energy, kcal. */
|
|
86
|
+
calories?: number;
|
|
87
|
+
elevationGainMeters?: number;
|
|
88
|
+
}
|
|
89
|
+
/** An activity together with its streams — the shape an import yields. Laps are
|
|
90
|
+
* present only for sources that record them (FIT); GPX/manual omit them. */
|
|
91
|
+
export interface ImportedActivity {
|
|
92
|
+
activity: ActivityMeta;
|
|
93
|
+
streams: ActivityStreams;
|
|
94
|
+
laps?: Lap[] | undefined;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core activity types for the fitness domain.
|
|
3
|
+
*
|
|
4
|
+
* Units are SI throughout (meters, seconds) — the same units the Strava API
|
|
5
|
+
* returns. Conversion to miles/feet happens at the display edge (see
|
|
6
|
+
* `units.ts`); the model never stores imperial. (An earlier design stored
|
|
7
|
+
* miles/feet and paid for it in conversion bugs.)
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|