@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,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
@@ -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