@pond-ts/fit 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ );