@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,652 @@
|
|
|
1
|
+
import * as geo from '../geo/index.js';
|
|
2
|
+
import { prepareActivity, summaryFromPrepared, windowChannels, } from '../summary/index.js';
|
|
3
|
+
import { computePower, powerCurve, powerBestEfforts, normalizedPower, averagePower, maxPower, zoneDistribution, } from '../power/index.js';
|
|
4
|
+
import { hrZoneDistribution, paceZoneDistribution, } from '../zones/index.js';
|
|
5
|
+
import { Distance, Elevation, Duration, Speed, Power, HeartRate, Cadence, } from '../quantities.js';
|
|
6
|
+
// ── small array helpers (validity-aware, gap-safe) ──────────────────────────
|
|
7
|
+
/** Copy a per-sample array to Float64 with non-finite → NaN; `undefined` if the
|
|
8
|
+
* channel is absent or has no finite sample (so callers can branch on presence). */
|
|
9
|
+
function clean(values) {
|
|
10
|
+
if (!values)
|
|
11
|
+
return undefined;
|
|
12
|
+
const out = new Float64Array(values.length);
|
|
13
|
+
let any = false;
|
|
14
|
+
for (let i = 0; i < values.length; i++) {
|
|
15
|
+
const v = values[i];
|
|
16
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
17
|
+
out[i] = v;
|
|
18
|
+
any = true;
|
|
19
|
+
}
|
|
20
|
+
else
|
|
21
|
+
out[i] = NaN;
|
|
22
|
+
}
|
|
23
|
+
return any ? out : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Mean of the finite samples in `[lo, hi)`; `undefined` if none. */
|
|
26
|
+
function meanIn(arr, lo, hi) {
|
|
27
|
+
if (!arr)
|
|
28
|
+
return undefined;
|
|
29
|
+
let sum = 0;
|
|
30
|
+
let n = 0;
|
|
31
|
+
for (let i = lo; i < hi; i++) {
|
|
32
|
+
const v = arr[i];
|
|
33
|
+
if (Number.isFinite(v)) {
|
|
34
|
+
sum += v;
|
|
35
|
+
n += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return n > 0 ? sum / n : undefined;
|
|
39
|
+
}
|
|
40
|
+
/** Max of the finite samples in `[lo, hi)`; `undefined` if none. */
|
|
41
|
+
function maxIn(arr, lo, hi) {
|
|
42
|
+
if (!arr)
|
|
43
|
+
return undefined;
|
|
44
|
+
let hiV = -Infinity;
|
|
45
|
+
for (let i = lo; i < hi; i++) {
|
|
46
|
+
const v = arr[i];
|
|
47
|
+
if (Number.isFinite(v) && v > hiV)
|
|
48
|
+
hiV = v;
|
|
49
|
+
}
|
|
50
|
+
return hiV > -Infinity ? hiV : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** Smallest index `i` with `timeRel[i] >= t` (binary search; clamped to range). */
|
|
53
|
+
function indexAtElapsed(timeRel, t) {
|
|
54
|
+
let lo = 0;
|
|
55
|
+
let hi = timeRel.length - 1;
|
|
56
|
+
if (t <= timeRel[0])
|
|
57
|
+
return 0;
|
|
58
|
+
if (t >= timeRel[hi])
|
|
59
|
+
return hi;
|
|
60
|
+
while (lo < hi) {
|
|
61
|
+
const mid = (lo + hi) >> 1;
|
|
62
|
+
if (timeRel[mid] < t)
|
|
63
|
+
lo = mid + 1;
|
|
64
|
+
else
|
|
65
|
+
hi = mid;
|
|
66
|
+
}
|
|
67
|
+
return lo;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A range of an activity with its own analytics — a split, a lap, or a slice.
|
|
71
|
+
* Quantity-typed views over a {@link SectionMetrics} bundle; nothing computes
|
|
72
|
+
* here (the producer already did), so accessors are pure getters.
|
|
73
|
+
*/
|
|
74
|
+
export class Section {
|
|
75
|
+
m;
|
|
76
|
+
constructor(m) {
|
|
77
|
+
this.m = m;
|
|
78
|
+
}
|
|
79
|
+
get label() {
|
|
80
|
+
return this.m.label;
|
|
81
|
+
}
|
|
82
|
+
/** Elapsed `[from, to]` window into the activity, in seconds — the time anchor
|
|
83
|
+
* a contiguous Focus / range annotation maps onto. */
|
|
84
|
+
get fromSeconds() {
|
|
85
|
+
return this.m.fromSec;
|
|
86
|
+
}
|
|
87
|
+
get toSeconds() {
|
|
88
|
+
return this.m.toSec;
|
|
89
|
+
}
|
|
90
|
+
/** Cumulative-distance window [start, end] into the activity (metres) — the
|
|
91
|
+
* anchor for a map-segment highlight, the distance-axis peer of from/toSeconds. */
|
|
92
|
+
get startMeters() {
|
|
93
|
+
return this.m.startMeters;
|
|
94
|
+
}
|
|
95
|
+
get endMeters() {
|
|
96
|
+
return this.m.endMeters;
|
|
97
|
+
}
|
|
98
|
+
distance() {
|
|
99
|
+
return Distance.meters(this.m.distanceMeters);
|
|
100
|
+
}
|
|
101
|
+
duration() {
|
|
102
|
+
return Duration.seconds(this.m.durationSeconds);
|
|
103
|
+
}
|
|
104
|
+
movingTime() {
|
|
105
|
+
return Duration.seconds(this.m.movingSeconds);
|
|
106
|
+
}
|
|
107
|
+
elevationGain() {
|
|
108
|
+
return Elevation.meters(this.m.elevationGainMeters);
|
|
109
|
+
}
|
|
110
|
+
elevationLoss() {
|
|
111
|
+
return this.m.elevationLossMeters == null
|
|
112
|
+
? undefined
|
|
113
|
+
: Elevation.meters(this.m.elevationLossMeters);
|
|
114
|
+
}
|
|
115
|
+
normalizedPower() {
|
|
116
|
+
return this.m.normalizedWatts == null
|
|
117
|
+
? undefined
|
|
118
|
+
: Power.watts(this.m.normalizedWatts);
|
|
119
|
+
}
|
|
120
|
+
avgPower() {
|
|
121
|
+
return this.m.avgWatts == null ? undefined : Power.watts(this.m.avgWatts);
|
|
122
|
+
}
|
|
123
|
+
maxPower() {
|
|
124
|
+
return this.m.maxWatts == null ? undefined : Power.watts(this.m.maxWatts);
|
|
125
|
+
}
|
|
126
|
+
avgHeartRate() {
|
|
127
|
+
return this.m.avgHeartrate == null
|
|
128
|
+
? undefined
|
|
129
|
+
: HeartRate.bpm(this.m.avgHeartrate);
|
|
130
|
+
}
|
|
131
|
+
maxHeartRate() {
|
|
132
|
+
return this.m.maxHeartrate == null
|
|
133
|
+
? undefined
|
|
134
|
+
: HeartRate.bpm(this.m.maxHeartrate);
|
|
135
|
+
}
|
|
136
|
+
avgCadence() {
|
|
137
|
+
return this.m.avgCadence == null
|
|
138
|
+
? undefined
|
|
139
|
+
: Cadence.rpm(this.m.avgCadence);
|
|
140
|
+
}
|
|
141
|
+
/** Average speed — the recorded average when present, else distance ÷ moving
|
|
142
|
+
* time. `undefined` only when neither is available (no distance / no time). */
|
|
143
|
+
avgSpeed() {
|
|
144
|
+
if (this.m.avgSpeedMps != null)
|
|
145
|
+
return Speed.metersPerSecond(this.m.avgSpeedMps);
|
|
146
|
+
const t = this.m.movingSeconds;
|
|
147
|
+
return t > 0 && this.m.distanceMeters > 0
|
|
148
|
+
? Speed.metersPerSecond(this.m.distanceMeters / t)
|
|
149
|
+
: undefined;
|
|
150
|
+
}
|
|
151
|
+
maxSpeed() {
|
|
152
|
+
return this.m.maxSpeedMps == null
|
|
153
|
+
? undefined
|
|
154
|
+
: Speed.metersPerSecond(this.m.maxSpeedMps);
|
|
155
|
+
}
|
|
156
|
+
/** Average pace — the inverse view of {@link avgSpeed}. */
|
|
157
|
+
pace() {
|
|
158
|
+
return this.avgSpeed()?.pace();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The activity façade. Construct from an imported activity (`fromStreams`); read
|
|
163
|
+
* the canonical series (`timeSeries`), slice it (`splits` / `laps` / `range`),
|
|
164
|
+
* sample it (`at`), and run analytics (`power` / `powerCurve` / `bestEfforts` /
|
|
165
|
+
* `hrZones`). Immutable; derived results memoize on first call.
|
|
166
|
+
*/
|
|
167
|
+
export class Activity {
|
|
168
|
+
prep;
|
|
169
|
+
_summary;
|
|
170
|
+
/** NaN-for-missing power, lazily cleaned; `null` once we know there is none. */
|
|
171
|
+
_watts;
|
|
172
|
+
/** Splits memoized per interval (metres) — the summary recompute isn't cheap. */
|
|
173
|
+
_splits = new Map();
|
|
174
|
+
constructor(prep) {
|
|
175
|
+
this.prep = prep;
|
|
176
|
+
}
|
|
177
|
+
/** Build from an imported activity (streams + metadata + optional laps) — the
|
|
178
|
+
* Strava / fixture path. `fromFit` / `fromGpx` / `fromTcx` land with ingest. */
|
|
179
|
+
static fromStreams(imported) {
|
|
180
|
+
return new Activity(prepareActivity(imported));
|
|
181
|
+
}
|
|
182
|
+
/** Session metadata — id, name, sport, start, totals. */
|
|
183
|
+
get meta() {
|
|
184
|
+
return this.prep.imported.activity;
|
|
185
|
+
}
|
|
186
|
+
/** Whether the activity carries GPS positions (a map; otherwise indoor / GPS-off). */
|
|
187
|
+
get hasTrack() {
|
|
188
|
+
return this.prep.hasTrack;
|
|
189
|
+
}
|
|
190
|
+
/** Whether a power channel was recorded. */
|
|
191
|
+
get hasPower() {
|
|
192
|
+
return this.watts() != null;
|
|
193
|
+
}
|
|
194
|
+
/** The canonical pond series (the escape hatch to the functional/pond layer). */
|
|
195
|
+
timeSeries() {
|
|
196
|
+
return this.prep.track;
|
|
197
|
+
}
|
|
198
|
+
/** The prepared per-sample columns + derived series — for consumers still on
|
|
199
|
+
* the functional path (e.g. the chart) while they migrate to the façade. */
|
|
200
|
+
prepared() {
|
|
201
|
+
return this.prep;
|
|
202
|
+
}
|
|
203
|
+
/** The full journey summary (totals, channels, polyline, splits, laps). */
|
|
204
|
+
summary(options = {}) {
|
|
205
|
+
// memoize only the default summary; a custom-options call recomputes.
|
|
206
|
+
if (Object.keys(options).length === 0) {
|
|
207
|
+
return (this._summary ??= summaryFromPrepared(this.prep));
|
|
208
|
+
}
|
|
209
|
+
return summaryFromPrepared(this.prep, options);
|
|
210
|
+
}
|
|
211
|
+
/** Channels re-bucketed over a distance window `[startMeters, endMeters]` — the
|
|
212
|
+
* chart-zoom resolution path, finer than the whole-activity profile from
|
|
213
|
+
* {@link summary}. The façade home for the functional `windowChannels`. */
|
|
214
|
+
windowChannels(options) {
|
|
215
|
+
return windowChannels(this.prep, options);
|
|
216
|
+
}
|
|
217
|
+
/** Total moving / elapsed / distance, quantity-typed. */
|
|
218
|
+
distance() {
|
|
219
|
+
return Distance.meters(this.summary().distanceMeters);
|
|
220
|
+
}
|
|
221
|
+
elapsedTime() {
|
|
222
|
+
return Duration.seconds(this.summary().elapsedTimeSeconds);
|
|
223
|
+
}
|
|
224
|
+
movingTime() {
|
|
225
|
+
return Duration.seconds(this.summary().movingTimeSeconds);
|
|
226
|
+
}
|
|
227
|
+
/** Even-distance splits (per-km, per-mile, …) as Sections, in order. */
|
|
228
|
+
splits(interval) {
|
|
229
|
+
const key = interval.meters;
|
|
230
|
+
const cached = this._splits.get(key);
|
|
231
|
+
if (cached)
|
|
232
|
+
return cached;
|
|
233
|
+
const splits = this.summary({ splitMeters: key }).splits;
|
|
234
|
+
let fromSec = 0;
|
|
235
|
+
let fromMeters = 0;
|
|
236
|
+
const sections = splits.map((s) => {
|
|
237
|
+
const sec = sectionFromSplit(s, fromSec, fromMeters);
|
|
238
|
+
fromSec += s.durationSeconds;
|
|
239
|
+
fromMeters += s.distanceMeters;
|
|
240
|
+
return new Section(sec);
|
|
241
|
+
});
|
|
242
|
+
this._splits.set(key, sections);
|
|
243
|
+
return sections;
|
|
244
|
+
}
|
|
245
|
+
/** Device-recorded laps as Sections (empty if the source recorded none). */
|
|
246
|
+
laps() {
|
|
247
|
+
const laps = this.prep.imported.laps ?? [];
|
|
248
|
+
const startMs = Date.parse(this.meta.startTimeUtc);
|
|
249
|
+
let acc = 0; // fallback elapsed cursor when a lap has no start time
|
|
250
|
+
return laps.map((lap) => {
|
|
251
|
+
const sec = sectionFromLap(lap, startMs, acc);
|
|
252
|
+
acc = sec.toSec;
|
|
253
|
+
return new Section(sec);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/** An arbitrary slice `[from, to]` (elapsed time) as a Section, with metrics
|
|
257
|
+
* computed from the series over that window. Clamped to the activity. */
|
|
258
|
+
range(from, to, label = 'Range') {
|
|
259
|
+
const { timeRel, cols, step } = this.prep;
|
|
260
|
+
const lo = indexAtElapsed(timeRel, from.seconds);
|
|
261
|
+
const hi = indexAtElapsed(timeRel, to.seconds);
|
|
262
|
+
return new Section(this.metricsForRange(lo, hi, label, cols, step, timeRel));
|
|
263
|
+
}
|
|
264
|
+
/** Power summary (NP, IF, TSS, distribution, zones, curve) at the given FTP —
|
|
265
|
+
* `undefined` when no power was recorded. `elapsedSeconds` drives TSS. */
|
|
266
|
+
power(ftp) {
|
|
267
|
+
const watts = this.watts();
|
|
268
|
+
if (!watts)
|
|
269
|
+
return undefined;
|
|
270
|
+
// elapsed = last − first sample, straight off the prepared relative-time axis
|
|
271
|
+
// (timeRel[n−1]); avoids forcing the full journey summary just to read TSS.
|
|
272
|
+
const elapsed = this.prep.n > 0 ? (this.prep.timeRel[this.prep.n - 1] ?? 0) : 0;
|
|
273
|
+
return computePower(this.prep.cols.timeSec, watts, ftp, elapsed);
|
|
274
|
+
}
|
|
275
|
+
/** Mean-maximal power curve; `[]` when no power. */
|
|
276
|
+
powerCurve(durations) {
|
|
277
|
+
const watts = this.watts();
|
|
278
|
+
return watts ? powerCurve(this.prep.cols.timeSec, watts, durations) : [];
|
|
279
|
+
}
|
|
280
|
+
/** Power best efforts at the canonical durations (+ W/kg if `weightKg`); `[]`
|
|
281
|
+
* when no power. */
|
|
282
|
+
bestEfforts(opts = {}) {
|
|
283
|
+
const watts = this.watts();
|
|
284
|
+
return watts ? powerBestEfforts(this.prep.cols.timeSec, watts, opts) : [];
|
|
285
|
+
}
|
|
286
|
+
/** Distance best efforts — fastest time over each canonical distance window
|
|
287
|
+
* (400 m … marathon), with avg HR. Needs a time axis; `[]` otherwise. */
|
|
288
|
+
distanceBestEfforts(distances) {
|
|
289
|
+
if (!this.prep.hasTime)
|
|
290
|
+
return [];
|
|
291
|
+
return geo.bestEffortsByDistance(this.prep.cum, this.prep.cols.timeSec, distances, clean(this.prep.cols.hr));
|
|
292
|
+
}
|
|
293
|
+
/** Time-in-zone for heart rate against the given zone definition; `[]` with no HR. */
|
|
294
|
+
hrZones(def) {
|
|
295
|
+
const hr = clean(this.prep.cols.hr);
|
|
296
|
+
return hr ? hrZoneDistribution(this.prep.cols.timeSec, hr, def) : [];
|
|
297
|
+
}
|
|
298
|
+
/** Time-in-zone for pace (from the derived speed) against `def`; `[]` with no time. */
|
|
299
|
+
paceZones(def) {
|
|
300
|
+
if (!this.prep.hasTime)
|
|
301
|
+
return [];
|
|
302
|
+
return paceZoneDistribution(this.prep.cols.timeSec, this.prep.speed, def);
|
|
303
|
+
}
|
|
304
|
+
/** Bind an athlete {@link Profile} for the analytics that need FTP / body
|
|
305
|
+
* weight / zone definitions — power summary, time-in-zone, W/kg. Returns a
|
|
306
|
+
* {@link ProfiledActivity} whose slices (`splits` / `range` / `laps`) stay
|
|
307
|
+
* bound to the same profile (turtles all the way down). The bare `Activity`
|
|
308
|
+
* keeps only the profile-agnostic methods. */
|
|
309
|
+
usingProfile(profile) {
|
|
310
|
+
return new ProfiledActivity(this, profile);
|
|
311
|
+
}
|
|
312
|
+
/** Channel values interpolated at one elapsed instant — the scrub / annotation
|
|
313
|
+
* anchor sample. Linear between the bracketing samples. */
|
|
314
|
+
at(t) {
|
|
315
|
+
const { timeRel, cols, cum, speed } = this.prep;
|
|
316
|
+
const tc = Math.max(timeRel[0], Math.min(t.seconds, timeRel[timeRel.length - 1]));
|
|
317
|
+
const hi = indexAtElapsed(timeRel, tc);
|
|
318
|
+
const lo = hi > 0 ? hi - 1 : 0;
|
|
319
|
+
const span = timeRel[hi] - timeRel[lo];
|
|
320
|
+
const f = span > 0 ? (tc - timeRel[lo]) / span : 0;
|
|
321
|
+
const lerp = (a) => {
|
|
322
|
+
if (!a)
|
|
323
|
+
return undefined;
|
|
324
|
+
const x = a[lo];
|
|
325
|
+
const y = a[hi];
|
|
326
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
327
|
+
return Number.isFinite(y) ? y : x;
|
|
328
|
+
return x + (y - x) * f;
|
|
329
|
+
};
|
|
330
|
+
const out = { atSeconds: tc };
|
|
331
|
+
const dist = lerp(cum);
|
|
332
|
+
if (dist != null && Number.isFinite(dist))
|
|
333
|
+
out.distance = Distance.meters(dist);
|
|
334
|
+
const ele = lerp(cols.ele);
|
|
335
|
+
if (ele != null && Number.isFinite(ele))
|
|
336
|
+
out.elevation = Elevation.meters(ele);
|
|
337
|
+
const hr = lerp(cols.hr);
|
|
338
|
+
if (hr != null && Number.isFinite(hr))
|
|
339
|
+
out.heartRate = HeartRate.bpm(hr);
|
|
340
|
+
const pw = lerp(cols.power);
|
|
341
|
+
if (pw != null && Number.isFinite(pw))
|
|
342
|
+
out.power = Power.watts(pw);
|
|
343
|
+
const cad = lerp(cols.cadence);
|
|
344
|
+
if (cad != null && Number.isFinite(cad))
|
|
345
|
+
out.cadence = Cadence.rpm(cad);
|
|
346
|
+
const sp = lerp(speed);
|
|
347
|
+
if (sp != null && Number.isFinite(sp))
|
|
348
|
+
out.speed = Speed.metersPerSecond(sp);
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
// ── internals ──
|
|
352
|
+
/** Cleaned (NaN-for-missing) power column, memoized; `undefined` if no power. */
|
|
353
|
+
watts() {
|
|
354
|
+
if (this._watts === undefined)
|
|
355
|
+
this._watts = clean(this.prep.cols.power) ?? null;
|
|
356
|
+
return this._watts ?? undefined;
|
|
357
|
+
}
|
|
358
|
+
/** Compute a range's metrics from the columns over `[lo, hi]` (inclusive). */
|
|
359
|
+
metricsForRange(lo, hi, label, cols, step, timeRel) {
|
|
360
|
+
const a = Math.min(lo, hi);
|
|
361
|
+
const b = Math.max(lo, hi);
|
|
362
|
+
const end = b + 1; // exclusive bound for the slice
|
|
363
|
+
const timeSlice = cols.timeSec.slice(a, end);
|
|
364
|
+
const stepSlice = step.slice(a, end);
|
|
365
|
+
const { gainMeters, lossMeters } = geo.elevationGainLoss(cols.ele.slice(a, end));
|
|
366
|
+
const distanceMeters = (this.prep.cum[b] ?? 0) - (this.prep.cum[a] ?? 0);
|
|
367
|
+
const durationSeconds = timeRel[b] - timeRel[a];
|
|
368
|
+
const movingSeconds = geo.movingTimeSeconds(stepSlice, timeSlice);
|
|
369
|
+
const watts = this.watts();
|
|
370
|
+
const wattsSlice = watts?.slice(a, end);
|
|
371
|
+
const m = {
|
|
372
|
+
label,
|
|
373
|
+
fromSec: timeRel[a],
|
|
374
|
+
toSec: timeRel[b],
|
|
375
|
+
startMeters: this.prep.cum[a] ?? 0,
|
|
376
|
+
endMeters: this.prep.cum[b] ?? 0,
|
|
377
|
+
distanceMeters,
|
|
378
|
+
durationSeconds,
|
|
379
|
+
movingSeconds,
|
|
380
|
+
elevationGainMeters: gainMeters,
|
|
381
|
+
elevationLossMeters: lossMeters,
|
|
382
|
+
};
|
|
383
|
+
// Only when the window actually has a finite watt: averagePower/normalizedPower
|
|
384
|
+
// return 0 (not undefined) on an all-missing slice, which would fabricate a
|
|
385
|
+
// "0 W" reading over a power gap — and diverge from splits(), where the same
|
|
386
|
+
// window reports `undefined`. Gate on presence so the two agree.
|
|
387
|
+
if (wattsSlice && wattsSlice.some((v) => Number.isFinite(v))) {
|
|
388
|
+
m.normalizedWatts = normalizedPower(timeSlice, wattsSlice);
|
|
389
|
+
m.avgWatts = averagePower(wattsSlice);
|
|
390
|
+
m.maxWatts = maxPower(wattsSlice);
|
|
391
|
+
}
|
|
392
|
+
m.avgHeartrate = meanIn(clean(cols.hr), a, end);
|
|
393
|
+
m.maxHeartrate = maxIn(clean(cols.hr), a, end);
|
|
394
|
+
m.avgCadence = meanIn(clean(cols.cadence), a, end);
|
|
395
|
+
if (this.prep.hasTime) {
|
|
396
|
+
m.avgSpeedMps =
|
|
397
|
+
movingSeconds > 0 ? distanceMeters / movingSeconds : undefined;
|
|
398
|
+
m.maxSpeedMps = maxIn(this.prep.speed, a, end);
|
|
399
|
+
}
|
|
400
|
+
return m;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** Normalize a computed {@link Split} to the shared metric bundle. Splits report
|
|
404
|
+
* elapsed duration only (no separate moving time). */
|
|
405
|
+
function sectionFromSplit(s, fromSec, fromMeters) {
|
|
406
|
+
return {
|
|
407
|
+
label: `Split ${s.index}`,
|
|
408
|
+
fromSec,
|
|
409
|
+
toSec: fromSec + s.durationSeconds,
|
|
410
|
+
startMeters: fromMeters,
|
|
411
|
+
endMeters: fromMeters + s.distanceMeters,
|
|
412
|
+
distanceMeters: s.distanceMeters,
|
|
413
|
+
durationSeconds: s.durationSeconds,
|
|
414
|
+
movingSeconds: s.durationSeconds,
|
|
415
|
+
elevationGainMeters: s.elevationGainMeters,
|
|
416
|
+
elevationLossMeters: s.elevationLossMeters,
|
|
417
|
+
normalizedWatts: s.normalizedWatts,
|
|
418
|
+
avgWatts: s.avgWatts,
|
|
419
|
+
maxWatts: s.maxWatts,
|
|
420
|
+
avgHeartrate: s.avgHeartrate,
|
|
421
|
+
maxHeartrate: s.maxHeartrate,
|
|
422
|
+
avgCadence: s.avgCadence,
|
|
423
|
+
avgSpeedMps: s.avgSpeedMps,
|
|
424
|
+
maxSpeedMps: s.maxSpeedMps,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** Normalize a recorded {@link Lap} to the shared metric bundle. The lap's elapsed
|
|
428
|
+
* window is its start (from its timestamp, else the running cursor) + elapsed. */
|
|
429
|
+
function sectionFromLap(lap, startMs, cursorSec) {
|
|
430
|
+
const fromSec = lap.startTimeUtc && Number.isFinite(Date.parse(lap.startTimeUtc))
|
|
431
|
+
? (Date.parse(lap.startTimeUtc) - startMs) / 1000
|
|
432
|
+
: cursorSec;
|
|
433
|
+
return {
|
|
434
|
+
label: `Lap ${lap.index}`,
|
|
435
|
+
fromSec,
|
|
436
|
+
toSec: fromSec + lap.elapsedSeconds,
|
|
437
|
+
startMeters: lap.startDistanceMeters,
|
|
438
|
+
endMeters: lap.startDistanceMeters + lap.distanceMeters,
|
|
439
|
+
distanceMeters: lap.distanceMeters,
|
|
440
|
+
durationSeconds: lap.elapsedSeconds,
|
|
441
|
+
movingSeconds: lap.movingSeconds,
|
|
442
|
+
elevationGainMeters: lap.elevationGainMeters ?? 0,
|
|
443
|
+
normalizedWatts: undefined, // not recorded per lap; compute via range() if needed
|
|
444
|
+
avgWatts: lap.avgWatts,
|
|
445
|
+
maxWatts: lap.maxWatts,
|
|
446
|
+
avgHeartrate: lap.avgHeartrate,
|
|
447
|
+
maxHeartrate: lap.maxHeartrate,
|
|
448
|
+
avgSpeedMps: lap.avgSpeedMps,
|
|
449
|
+
maxSpeedMps: lap.maxSpeedMps,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
// ── Profile-bound view ──────────────────────────────────────────────────────
|
|
453
|
+
/**
|
|
454
|
+
* An {@link Activity} bound to an athlete {@link Profile} — the home for the
|
|
455
|
+
* analytics that need FTP / body weight / zone definitions. Obtained via
|
|
456
|
+
* `activity.usingProfile(bob)`. Its slices (`splits` / `range` / `laps`) return
|
|
457
|
+
* {@link ProfiledSection}s carrying the same profile, so the binding flows all
|
|
458
|
+
* the way down. The bare {@link Activity} keeps only the profile-agnostic
|
|
459
|
+
* methods, so a missing FTP / weight is a type-level absence, not a runtime
|
|
460
|
+
* `undefined`.
|
|
461
|
+
*/
|
|
462
|
+
export class ProfiledActivity {
|
|
463
|
+
activity;
|
|
464
|
+
profile;
|
|
465
|
+
constructor(activity, profile) {
|
|
466
|
+
this.activity = activity;
|
|
467
|
+
this.profile = profile;
|
|
468
|
+
}
|
|
469
|
+
/** Full power summary (NP, IF, TSS, distribution, zones, curve) at the
|
|
470
|
+
* profile's FTP. `undefined` when no power was recorded or no FTP is known. */
|
|
471
|
+
power() {
|
|
472
|
+
const ftp = this.profile.ftpWatts;
|
|
473
|
+
return ftp == null ? undefined : this.activity.power(ftp);
|
|
474
|
+
}
|
|
475
|
+
/** Time in each Coggan power zone (FTP-relative); `[]` with no power or no FTP. */
|
|
476
|
+
byPowerZone() {
|
|
477
|
+
const ftp = this.profile.ftpWatts;
|
|
478
|
+
if (ftp == null)
|
|
479
|
+
return [];
|
|
480
|
+
const prep = this.activity.prepared();
|
|
481
|
+
const watts = clean(prep.cols.power);
|
|
482
|
+
return watts ? zoneDistribution(prep.cols.timeSec, watts, ftp) : [];
|
|
483
|
+
}
|
|
484
|
+
/** Time in each heart-rate zone; `[]` with no HR or no HR zones on the profile. */
|
|
485
|
+
byHeartRateZone() {
|
|
486
|
+
const zones = this.profile.heartRateZones;
|
|
487
|
+
return zones ? this.activity.hrZones(zones) : [];
|
|
488
|
+
}
|
|
489
|
+
/** Time in each pace zone; `[]` with no time axis or no pace zones on the profile. */
|
|
490
|
+
byPaceZone() {
|
|
491
|
+
const zones = this.profile.paceZones;
|
|
492
|
+
return zones ? this.activity.paceZones(zones) : [];
|
|
493
|
+
}
|
|
494
|
+
/** Power best efforts (+ W/kg from the profile's body weight); `[]` with no power. */
|
|
495
|
+
bestEfforts(durations) {
|
|
496
|
+
const opts = {};
|
|
497
|
+
if (this.profile.weightKg != null)
|
|
498
|
+
opts.weightKg = this.profile.weightKg;
|
|
499
|
+
if (durations)
|
|
500
|
+
opts.durations = durations;
|
|
501
|
+
return this.activity.bestEfforts(opts);
|
|
502
|
+
}
|
|
503
|
+
/** Even-distance splits as profile-bound sections. */
|
|
504
|
+
splits(interval) {
|
|
505
|
+
return this.activity
|
|
506
|
+
.splits(interval)
|
|
507
|
+
.map((s) => new ProfiledSection(this.activity, s, this.profile));
|
|
508
|
+
}
|
|
509
|
+
/** Device-recorded laps as profile-bound sections (empty if none recorded). */
|
|
510
|
+
laps() {
|
|
511
|
+
return this.activity
|
|
512
|
+
.laps()
|
|
513
|
+
.map((s) => new ProfiledSection(this.activity, s, this.profile));
|
|
514
|
+
}
|
|
515
|
+
/** An arbitrary `[from, to]` slice as a profile-bound section. */
|
|
516
|
+
range(from, to, label = 'Range') {
|
|
517
|
+
return new ProfiledSection(this.activity, this.activity.range(from, to, label), this.profile);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A {@link Section} bound to a {@link Profile} — adds the FTP / weight / zone
|
|
522
|
+
* analytics over the section's own window. Profile-agnostic metrics delegate to
|
|
523
|
+
* the underlying section; the zone / power methods recompute over the section's
|
|
524
|
+
* `[fromSeconds, toSeconds]` slice of the parent activity.
|
|
525
|
+
*/
|
|
526
|
+
export class ProfiledSection {
|
|
527
|
+
activity;
|
|
528
|
+
section;
|
|
529
|
+
profile;
|
|
530
|
+
_window;
|
|
531
|
+
constructor(activity, section, profile) {
|
|
532
|
+
this.activity = activity;
|
|
533
|
+
this.section = section;
|
|
534
|
+
this.profile = profile;
|
|
535
|
+
}
|
|
536
|
+
/** The underlying profile-agnostic section. */
|
|
537
|
+
get unprofiled() {
|
|
538
|
+
return this.section;
|
|
539
|
+
}
|
|
540
|
+
// ── profile-agnostic — delegate to the underlying section ──
|
|
541
|
+
get label() {
|
|
542
|
+
return this.section.label;
|
|
543
|
+
}
|
|
544
|
+
get fromSeconds() {
|
|
545
|
+
return this.section.fromSeconds;
|
|
546
|
+
}
|
|
547
|
+
get toSeconds() {
|
|
548
|
+
return this.section.toSeconds;
|
|
549
|
+
}
|
|
550
|
+
get startMeters() {
|
|
551
|
+
return this.section.startMeters;
|
|
552
|
+
}
|
|
553
|
+
get endMeters() {
|
|
554
|
+
return this.section.endMeters;
|
|
555
|
+
}
|
|
556
|
+
distance() {
|
|
557
|
+
return this.section.distance();
|
|
558
|
+
}
|
|
559
|
+
duration() {
|
|
560
|
+
return this.section.duration();
|
|
561
|
+
}
|
|
562
|
+
movingTime() {
|
|
563
|
+
return this.section.movingTime();
|
|
564
|
+
}
|
|
565
|
+
elevationGain() {
|
|
566
|
+
return this.section.elevationGain();
|
|
567
|
+
}
|
|
568
|
+
elevationLoss() {
|
|
569
|
+
return this.section.elevationLoss();
|
|
570
|
+
}
|
|
571
|
+
normalizedPower() {
|
|
572
|
+
return this.section.normalizedPower();
|
|
573
|
+
}
|
|
574
|
+
avgPower() {
|
|
575
|
+
return this.section.avgPower();
|
|
576
|
+
}
|
|
577
|
+
maxPower() {
|
|
578
|
+
return this.section.maxPower();
|
|
579
|
+
}
|
|
580
|
+
avgHeartRate() {
|
|
581
|
+
return this.section.avgHeartRate();
|
|
582
|
+
}
|
|
583
|
+
maxHeartRate() {
|
|
584
|
+
return this.section.maxHeartRate();
|
|
585
|
+
}
|
|
586
|
+
avgCadence() {
|
|
587
|
+
return this.section.avgCadence();
|
|
588
|
+
}
|
|
589
|
+
avgSpeed() {
|
|
590
|
+
return this.section.avgSpeed();
|
|
591
|
+
}
|
|
592
|
+
maxSpeed() {
|
|
593
|
+
return this.section.maxSpeed();
|
|
594
|
+
}
|
|
595
|
+
pace() {
|
|
596
|
+
return this.section.pace();
|
|
597
|
+
}
|
|
598
|
+
// ── profile-dependent — recomputed over this section's window ──
|
|
599
|
+
/** Power summary over the section's window; `undefined` with no power / no FTP. */
|
|
600
|
+
power() {
|
|
601
|
+
const ftp = this.profile.ftpWatts;
|
|
602
|
+
const w = this.window();
|
|
603
|
+
return ftp != null && w.watts
|
|
604
|
+
? computePower(w.timeSec, w.watts, ftp, this.toSeconds - this.fromSeconds)
|
|
605
|
+
: undefined;
|
|
606
|
+
}
|
|
607
|
+
/** Time in each power zone over the section's window; `[]` with no power / FTP. */
|
|
608
|
+
byPowerZone() {
|
|
609
|
+
const ftp = this.profile.ftpWatts;
|
|
610
|
+
const w = this.window();
|
|
611
|
+
return ftp != null && w.watts
|
|
612
|
+
? zoneDistribution(w.timeSec, w.watts, ftp)
|
|
613
|
+
: [];
|
|
614
|
+
}
|
|
615
|
+
/** Time in each HR zone over the section's window; `[]` with no HR / HR zones. */
|
|
616
|
+
byHeartRateZone() {
|
|
617
|
+
const zones = this.profile.heartRateZones;
|
|
618
|
+
const w = this.window();
|
|
619
|
+
return zones && w.heartRate
|
|
620
|
+
? hrZoneDistribution(w.timeSec, w.heartRate, zones)
|
|
621
|
+
: [];
|
|
622
|
+
}
|
|
623
|
+
/** Time in each pace zone over the section's window; `[]` with no time / pace zones. */
|
|
624
|
+
byPaceZone() {
|
|
625
|
+
const zones = this.profile.paceZones;
|
|
626
|
+
const w = this.window();
|
|
627
|
+
return zones && w.speed
|
|
628
|
+
? paceZoneDistribution(w.timeSec, w.speed, zones)
|
|
629
|
+
: [];
|
|
630
|
+
}
|
|
631
|
+
/** Slice the parent columns to `[fromSeconds, toSeconds]`, computed once. */
|
|
632
|
+
window() {
|
|
633
|
+
if (this._window)
|
|
634
|
+
return this._window;
|
|
635
|
+
const prep = this.activity.prepared();
|
|
636
|
+
const a = indexAtElapsed(prep.timeRel, this.fromSeconds);
|
|
637
|
+
const b = indexAtElapsed(prep.timeRel, this.toSeconds);
|
|
638
|
+
const lo = Math.min(a, b);
|
|
639
|
+
const end = Math.max(a, b) + 1;
|
|
640
|
+
const w = { timeSec: prep.cols.timeSec.slice(lo, end) };
|
|
641
|
+
const watts = clean(prep.cols.power)?.slice(lo, end);
|
|
642
|
+
if (watts)
|
|
643
|
+
w.watts = watts;
|
|
644
|
+
const hr = clean(prep.cols.hr)?.slice(lo, end);
|
|
645
|
+
if (hr)
|
|
646
|
+
w.heartRate = hr;
|
|
647
|
+
if (prep.hasTime)
|
|
648
|
+
w.speed = prep.speed.slice(lo, end);
|
|
649
|
+
return (this._window = w);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// @pond-ts/fit ships as ES modules only. This stub is the `require` target in
|
|
4
|
+
// the package's `exports` map so that CommonJS consumers get a clear,
|
|
5
|
+
// actionable error instead of Node's cryptic `ERR_PACKAGE_PATH_NOT_EXPORTED`.
|
|
6
|
+
//
|
|
7
|
+
// It is copied verbatim into `dist/` during `prepack` (see package.json) so it
|
|
8
|
+
// rides along in the published tarball; the source of truth lives at the
|
|
9
|
+
// package root and is never touched by `tsc`.
|
|
10
|
+
|
|
11
|
+
throw new Error(
|
|
12
|
+
'@pond-ts/fit is an ES module package and cannot be loaded with require(). ' +
|
|
13
|
+
"Use `import { Activity } from '@pond-ts/fit'` instead, or a dynamic " +
|
|
14
|
+
"`await import('@pond-ts/fit')` from CommonJS. See https://nodejs.org/api/esm.html.",
|
|
15
|
+
);
|