@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,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The athlete profile — the **time-varying** settings an activity is read
|
|
3
|
+
* against: body weight (for W/kg), FTP (power zones), the HR-zone basis (max HR
|
|
4
|
+
* or custom bounds), and a threshold 5 k time (pace zones). Each quantity is a
|
|
5
|
+
* sparse, effective-dated series: "from this date, my FTP was 250 W."
|
|
6
|
+
*
|
|
7
|
+
* Modeled as pond `TimeSeries` keyed by the effective date and resolved with
|
|
8
|
+
* `series.atOrBefore(activityDate)` — the value in force on the day of the
|
|
9
|
+
* activity. A natural pond fit: an irregular, step-function config series with an
|
|
10
|
+
* as-of query. {@link hydrateProfile} lifts the persisted profile JSON into the
|
|
11
|
+
* series, {@link profileAsOf} resolves a date.
|
|
12
|
+
*/
|
|
13
|
+
import { TimeSeries, Time } from 'pond-ts';
|
|
14
|
+
const SENTINEL = 1e9; // open-top edge (pond/byColumn require finite edges)
|
|
15
|
+
/** HR zones (5), Z1→Z5. Strava's max-HR defaults: Z1≤65%, Z2≤81%, Z3≤89%,
|
|
16
|
+
* Z4≤97%, Z5 above (e.g. max 198 → 129/160/176/192). */
|
|
17
|
+
const HR_ZONE_LABELS = [
|
|
18
|
+
'Recovery',
|
|
19
|
+
'Endurance',
|
|
20
|
+
'Tempo',
|
|
21
|
+
'Threshold',
|
|
22
|
+
'Anaerobic',
|
|
23
|
+
];
|
|
24
|
+
const HR_MAX_FRACTIONS = [0.65, 0.81, 0.89, 0.97]; // Z1/Z2 … Z4/Z5 upper bounds
|
|
25
|
+
/** Pace zones (6), Z1→Z6. Boundaries as multiples of 5 k pace (slower = lower
|
|
26
|
+
* zone), approximating Strava's 5 k-time model: Z1 Recovery (slowest) →
|
|
27
|
+
* Z6 Anaerobic (fastest). */
|
|
28
|
+
const PACE_ZONE_LABELS = [
|
|
29
|
+
'Recovery',
|
|
30
|
+
'Endurance',
|
|
31
|
+
'Tempo',
|
|
32
|
+
'Threshold',
|
|
33
|
+
'VO2 Max',
|
|
34
|
+
'Anaerobic',
|
|
35
|
+
];
|
|
36
|
+
const PACE_PACE_MULTIPLES = [1.33, 1.15, 1.03, 0.96, 0.9]; // of 5 k pace, slow→fast boundaries
|
|
37
|
+
/** HR zone scheme (bpm axis) from a max HR or explicit bounds. */
|
|
38
|
+
export function hrZonesFrom(basis) {
|
|
39
|
+
const bounds = 'bounds' in basis
|
|
40
|
+
? basis.bounds.slice(0, 4)
|
|
41
|
+
: HR_MAX_FRACTIONS.map((f) => Math.round(f * basis.maxHr));
|
|
42
|
+
return { edges: [0, ...bounds, SENTINEL], labels: HR_ZONE_LABELS };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Pace zone scheme over the **speed** axis (m/s, ascending) from a 5 k time.
|
|
46
|
+
* 5 k pace → boundary paces (× multiples) → boundary speeds (= dist/pace).
|
|
47
|
+
* Edges ascend in speed, so bin 0 is the slowest (Z1 Recovery) and the top bin
|
|
48
|
+
* the fastest (Z6 Anaerobic) — the labels are ordered to match.
|
|
49
|
+
*/
|
|
50
|
+
export function paceZonesFrom(fiveKSeconds) {
|
|
51
|
+
const fiveKSpeed = fiveKSeconds > 0 ? 5000 / fiveKSeconds : 0; // m/s at 5 k pace
|
|
52
|
+
// pace multiple m → speed = fiveKSpeed / m; larger pace multiple = slower.
|
|
53
|
+
// Build ascending speed edges from the slow→fast pace multiples.
|
|
54
|
+
const speedEdges = PACE_PACE_MULTIPLES.map((m) => fiveKSpeed / m);
|
|
55
|
+
return { edges: [0, ...speedEdges, SENTINEL], labels: PACE_ZONE_LABELS };
|
|
56
|
+
}
|
|
57
|
+
/** Power zones (7), Z1→Z7 (Coggan), as fractions of FTP. Z7 is open-ended. */
|
|
58
|
+
const POWER_ZONE_LABELS = [
|
|
59
|
+
'Recovery',
|
|
60
|
+
'Endurance',
|
|
61
|
+
'Tempo',
|
|
62
|
+
'Threshold',
|
|
63
|
+
'VO2Max',
|
|
64
|
+
'Anaerobic',
|
|
65
|
+
'Neuromuscular',
|
|
66
|
+
];
|
|
67
|
+
const POWER_ZONE_FRACTIONS = [0.55, 0.75, 0.9, 1.05, 1.2, 1.5]; // of FTP, Z1/Z2 … Z6/Z7
|
|
68
|
+
/** The 7 Coggan power zones as a watt-axis {@link ZoneDef}, FTP-relative. The
|
|
69
|
+
* canonical home for the scheme (HR + pace zone builders live here too); the
|
|
70
|
+
* power module's `powerZoneDef` delegates to this. */
|
|
71
|
+
export function powerZonesFrom(ftp) {
|
|
72
|
+
return {
|
|
73
|
+
edges: [0, ...POWER_ZONE_FRACTIONS.map((f) => f * ftp), SENTINEL],
|
|
74
|
+
labels: POWER_ZONE_LABELS,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Date key + a throwaway seq column (pond wants ≥1 value column); the rich
|
|
78
|
+
* payload rides in the key→entry map, not these columns. */
|
|
79
|
+
const AS_OF_SCHEMA = [
|
|
80
|
+
{ name: 'time', kind: 'time' },
|
|
81
|
+
{ name: 'seq', kind: 'number' },
|
|
82
|
+
];
|
|
83
|
+
/**
|
|
84
|
+
* Build a pond `TimeSeries` keyed by each entry's effective date and return an
|
|
85
|
+
* as-of resolver. pond owns the date axis + the `atOrBefore` search (exactly
|
|
86
|
+
* the effective-dated lookup — no hand-rolled binary search); the rich/union
|
|
87
|
+
* payload rides in a key→entry map and is read back by the event's key, which
|
|
88
|
+
* keeps optional/union fields out of pond's scalar columns. Distinct dates win
|
|
89
|
+
* last (a same-day re-edit supersedes); empty → always `undefined`.
|
|
90
|
+
*/
|
|
91
|
+
function asOfSeries(name, entries) {
|
|
92
|
+
if (entries.length === 0)
|
|
93
|
+
return { at: () => undefined };
|
|
94
|
+
// dedupe by ms (last wins), then sort ascending — pond requires non-decreasing keys.
|
|
95
|
+
const byMs = new Map();
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const ms = Date.parse(e.at);
|
|
98
|
+
if (Number.isFinite(ms))
|
|
99
|
+
byMs.set(ms, e);
|
|
100
|
+
}
|
|
101
|
+
const sorted = [...byMs.keys()].sort((a, b) => a - b);
|
|
102
|
+
if (sorted.length === 0)
|
|
103
|
+
return { at: () => undefined };
|
|
104
|
+
const series = new TimeSeries({
|
|
105
|
+
name,
|
|
106
|
+
schema: AS_OF_SCHEMA,
|
|
107
|
+
rows: sorted.map((ms, i) => [ms, i]),
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
at(dateUtc) {
|
|
111
|
+
const ms = Date.parse(dateUtc);
|
|
112
|
+
if (!Number.isFinite(ms))
|
|
113
|
+
return undefined;
|
|
114
|
+
const ev = series.atOrBefore(new Time(ms));
|
|
115
|
+
return ev ? byMs.get(ev.begin()) : undefined;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Lift the vault JSON into pond-backed as-of resolvers. */
|
|
120
|
+
export function hydrateProfile(json) {
|
|
121
|
+
return {
|
|
122
|
+
weightKg: asOfSeries('weightKg', json.weightKg ?? []),
|
|
123
|
+
ftpWatts: asOfSeries('ftpWatts', json.ftpWatts ?? []),
|
|
124
|
+
hrZone: asOfSeries('hrZone', json.hrZone ?? []),
|
|
125
|
+
paceThreshold: asOfSeries('paceThreshold', json.paceThreshold ?? []),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Resolve every series to the values in force on `activityDateUtc`, deriving
|
|
129
|
+
* the HR + pace zone schemes. The one call the analytics layer makes.
|
|
130
|
+
* Note: bare-date entries (`"2026-03-01"`) parse to UTC midnight, so an
|
|
131
|
+
* activity timestamped late on the prior day in a positive UTC offset can pick
|
|
132
|
+
* up a same-dated change a local day "early". Immaterial for a step-function
|
|
133
|
+
* config series; use full datetimes in `at` if you need finer alignment. */
|
|
134
|
+
export function profileAsOf(json, activityDateUtc) {
|
|
135
|
+
const h = hydrateProfile(json);
|
|
136
|
+
const weight = h.weightKg.at(activityDateUtc)?.value;
|
|
137
|
+
const ftp = h.ftpWatts.at(activityDateUtc)?.value;
|
|
138
|
+
const hr = h.hrZone.at(activityDateUtc);
|
|
139
|
+
const pace = h.paceThreshold.at(activityDateUtc);
|
|
140
|
+
const resolved = {};
|
|
141
|
+
if (typeof weight === 'number')
|
|
142
|
+
resolved.weightKg = weight;
|
|
143
|
+
if (typeof ftp === 'number')
|
|
144
|
+
resolved.ftpWatts = ftp;
|
|
145
|
+
if (hr) {
|
|
146
|
+
if ('bounds' in hr)
|
|
147
|
+
resolved.hrZones = hrZonesFrom({ bounds: hr.bounds });
|
|
148
|
+
else if (typeof hr.maxHr === 'number')
|
|
149
|
+
resolved.hrZones = hrZonesFrom({ maxHr: hr.maxHr });
|
|
150
|
+
}
|
|
151
|
+
if (pace && typeof pace.fiveKSeconds === 'number')
|
|
152
|
+
resolved.paceZones = paceZonesFrom(pace.fiveKSeconds);
|
|
153
|
+
return resolved;
|
|
154
|
+
}
|
|
155
|
+
// ── Profile (the value object passed into the analytics) ─────────────────────
|
|
156
|
+
/**
|
|
157
|
+
* An athlete's settings resolved to a single activity date — the value object
|
|
158
|
+
* an {@link import('../activity/index.js').Activity} is read against via
|
|
159
|
+
* `activity.usingProfile(bob)`. Wraps the as-of resolution ({@link profileAsOf})
|
|
160
|
+
* and exposes the zone *ranges* (the per-channel {@link ZoneDef}s); the
|
|
161
|
+
* per-activity *data* (time-in-zone) is the profiled activity's `by…Zone()`.
|
|
162
|
+
*
|
|
163
|
+
* Immutable; carries only athlete data, never an activity's evidence — so one
|
|
164
|
+
* `Profile` is reused across every activity on its date.
|
|
165
|
+
*/
|
|
166
|
+
export class Profile {
|
|
167
|
+
resolved;
|
|
168
|
+
asOfDate;
|
|
169
|
+
constructor(resolved,
|
|
170
|
+
/** The activity date this profile was resolved as-of (ISO 8601, UTC);
|
|
171
|
+
* `undefined` for a history-less profile from {@link of}. */
|
|
172
|
+
asOfDate) {
|
|
173
|
+
this.resolved = resolved;
|
|
174
|
+
this.asOfDate = asOfDate;
|
|
175
|
+
}
|
|
176
|
+
/** Resolve the athlete's stored profile to the values in force on the
|
|
177
|
+
* activity's date — the values used for FTP-relative power, W/kg, and zones. */
|
|
178
|
+
static asOf(json, activityDateUtc) {
|
|
179
|
+
return new Profile(profileAsOf(json, activityDateUtc), activityDateUtc);
|
|
180
|
+
}
|
|
181
|
+
/** A profile from explicit settings with **no effective-dated history** — the
|
|
182
|
+
* "I just have an FTP / weight" case (demos, fixtures, a fallback prop, or a
|
|
183
|
+
* settings form with nothing recorded yet). For history-aware resolution use
|
|
184
|
+
* {@link asOf}. Power zones derive from `ftpWatts`; HR / pace zones are absent
|
|
185
|
+
* (they need a basis, which a bare settings object doesn't carry). */
|
|
186
|
+
static of(settings) {
|
|
187
|
+
const resolved = {};
|
|
188
|
+
if (settings.ftpWatts != null)
|
|
189
|
+
resolved.ftpWatts = settings.ftpWatts;
|
|
190
|
+
if (settings.weightKg != null)
|
|
191
|
+
resolved.weightKg = settings.weightKg;
|
|
192
|
+
return new Profile(resolved);
|
|
193
|
+
}
|
|
194
|
+
/** Functional Threshold Power (watts) in force on the date, if recorded. */
|
|
195
|
+
get ftpWatts() {
|
|
196
|
+
return this.resolved.ftpWatts;
|
|
197
|
+
}
|
|
198
|
+
/** Body weight (kg) in force on the date, if recorded — drives W/kg. */
|
|
199
|
+
get weightKg() {
|
|
200
|
+
return this.resolved.weightKg;
|
|
201
|
+
}
|
|
202
|
+
/** Heart-rate zone ranges (bpm axis), if an HR basis is recorded. */
|
|
203
|
+
get heartRateZones() {
|
|
204
|
+
return this.resolved.hrZones;
|
|
205
|
+
}
|
|
206
|
+
/** Pace zone ranges (speed axis, m/s; Z1 slowest), if a threshold is recorded. */
|
|
207
|
+
get paceZones() {
|
|
208
|
+
return this.resolved.paceZones;
|
|
209
|
+
}
|
|
210
|
+
/** Power zone ranges (watt axis, Coggan), derived from FTP if recorded. */
|
|
211
|
+
get powerZones() {
|
|
212
|
+
return this.resolved.ftpWatts == null
|
|
213
|
+
? undefined
|
|
214
|
+
: powerZonesFrom(this.resolved.ftpWatts);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fitness quantity types — the ergonomic, unit-safe value layer of the activity
|
|
3
|
+
* domain.
|
|
4
|
+
*
|
|
5
|
+
* Each is an immutable value object holding ONE canonical SI number, with
|
|
6
|
+
* unit-named constructors in / accessors out — so a function returns a `Speed`
|
|
7
|
+
* and the caller asks it `.asMinsPerMile()`, never juggling "is this m or mi?".
|
|
8
|
+
* Conversion math + formatting reuse `units.ts` (one source of truth); these
|
|
9
|
+
* types are the fluent face over it. Fitness-relevant set only — not a general
|
|
10
|
+
* dimensional-analysis system.
|
|
11
|
+
*/
|
|
12
|
+
import { type DistanceUnit, type ElevationUnit, type SpeedPaceUnit } from './units.js';
|
|
13
|
+
/** A length — canonical metres. */
|
|
14
|
+
export declare class Distance {
|
|
15
|
+
readonly meters: number;
|
|
16
|
+
private constructor();
|
|
17
|
+
static meters(m: number): Distance;
|
|
18
|
+
static km(km: number): Distance;
|
|
19
|
+
static miles(mi: number): Distance;
|
|
20
|
+
get km(): number;
|
|
21
|
+
get miles(): number;
|
|
22
|
+
/** Numeric value in the chosen unit — the dynamic-unit peer of `.km` / `.miles`
|
|
23
|
+
* (for when the unit is a runtime preference). */
|
|
24
|
+
in(unit: DistanceUnit): number;
|
|
25
|
+
/** Display string with unit label, e.g. `"12.34 mi"`. */
|
|
26
|
+
format(unit: DistanceUnit, decimals?: number): string;
|
|
27
|
+
}
|
|
28
|
+
/** A climb/altitude — canonical metres, shown in feet or metres (a separate type
|
|
29
|
+
* from {@link Distance} because elevation reads in ft/m, not mi/km). */
|
|
30
|
+
export declare class Elevation {
|
|
31
|
+
readonly meters: number;
|
|
32
|
+
private constructor();
|
|
33
|
+
static meters(m: number): Elevation;
|
|
34
|
+
static feet(ft: number): Elevation;
|
|
35
|
+
get feet(): number;
|
|
36
|
+
/** Numeric value in the chosen unit (dynamic-unit peer of `.feet` / `.meters`). */
|
|
37
|
+
in(unit: ElevationUnit): number;
|
|
38
|
+
/** Display string with unit label, e.g. `"1200 ft"`. */
|
|
39
|
+
format(unit: ElevationUnit, decimals?: number): string;
|
|
40
|
+
}
|
|
41
|
+
/** An elapsed time — canonical seconds. */
|
|
42
|
+
export declare class Duration {
|
|
43
|
+
readonly seconds: number;
|
|
44
|
+
private constructor();
|
|
45
|
+
static seconds(s: number): Duration;
|
|
46
|
+
static minutes(m: number): Duration;
|
|
47
|
+
static hours(h: number): Duration;
|
|
48
|
+
get minutes(): number;
|
|
49
|
+
get hours(): number;
|
|
50
|
+
/** "h:mm:ss" / "m:ss". */
|
|
51
|
+
format(): string;
|
|
52
|
+
}
|
|
53
|
+
/** A speed — canonical metres/second. The inverse view is {@link Pace}. */
|
|
54
|
+
export declare class Speed {
|
|
55
|
+
readonly metersPerSecond: number;
|
|
56
|
+
private constructor();
|
|
57
|
+
static metersPerSecond(v: number): Speed;
|
|
58
|
+
static kmh(v: number): Speed;
|
|
59
|
+
static mph(v: number): Speed;
|
|
60
|
+
get kmh(): number;
|
|
61
|
+
get mph(): number;
|
|
62
|
+
/** As pace (time per distance) — the same measurement, shown the other way. */
|
|
63
|
+
pace(): Pace;
|
|
64
|
+
asMinsPerMile(): string;
|
|
65
|
+
asMinsPerKm(): string;
|
|
66
|
+
/** Numeric value in the chosen unit — mph (imperial) or km/h (metric). */
|
|
67
|
+
in(unit: SpeedPaceUnit): number;
|
|
68
|
+
/** Display string with unit label, e.g. `"15.2 mph"` / `"24.5 km/h"`. */
|
|
69
|
+
format(unit: SpeedPaceUnit, decimals?: number): string;
|
|
70
|
+
}
|
|
71
|
+
/** A pace — canonical seconds per kilometre. The inverse view is {@link Speed}. */
|
|
72
|
+
export declare class Pace {
|
|
73
|
+
readonly secondsPerKm: number;
|
|
74
|
+
private constructor();
|
|
75
|
+
static secondsPerKm(s: number): Pace;
|
|
76
|
+
static secondsPerMile(s: number): Pace;
|
|
77
|
+
get secondsPerMile(): number;
|
|
78
|
+
speed(): Speed;
|
|
79
|
+
/** "m:ss/km" (metric, default) or "m:ss/mi" (imperial). */
|
|
80
|
+
format(unit?: SpeedPaceUnit): string;
|
|
81
|
+
}
|
|
82
|
+
/** Mechanical power — canonical watts. */
|
|
83
|
+
export declare class Power {
|
|
84
|
+
readonly watts: number;
|
|
85
|
+
private constructor();
|
|
86
|
+
static watts(w: number): Power;
|
|
87
|
+
/** Power-to-weight (W/kg) given a body weight; NaN for a non-positive weight. */
|
|
88
|
+
perKg(weightKg: number): number;
|
|
89
|
+
/** Display string, e.g. `"291 W"`. */
|
|
90
|
+
format(decimals?: number): string;
|
|
91
|
+
}
|
|
92
|
+
/** Heart rate — canonical beats/minute. */
|
|
93
|
+
export declare class HeartRate {
|
|
94
|
+
readonly bpm: number;
|
|
95
|
+
private constructor();
|
|
96
|
+
static bpm(b: number): HeartRate;
|
|
97
|
+
/** Display string, e.g. `"152 bpm"`. */
|
|
98
|
+
format(decimals?: number): string;
|
|
99
|
+
}
|
|
100
|
+
/** Cadence — canonical revolutions/minute (pedal strokes, run steps/min, …). */
|
|
101
|
+
export declare class Cadence {
|
|
102
|
+
readonly rpm: number;
|
|
103
|
+
private constructor();
|
|
104
|
+
static rpm(r: number): Cadence;
|
|
105
|
+
/** Display string, e.g. `"90 rpm"`. */
|
|
106
|
+
format(decimals?: number): string;
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=quantities.d.ts.map
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fitness quantity types — the ergonomic, unit-safe value layer of the activity
|
|
3
|
+
* domain.
|
|
4
|
+
*
|
|
5
|
+
* Each is an immutable value object holding ONE canonical SI number, with
|
|
6
|
+
* unit-named constructors in / accessors out — so a function returns a `Speed`
|
|
7
|
+
* and the caller asks it `.asMinsPerMile()`, never juggling "is this m or mi?".
|
|
8
|
+
* Conversion math + formatting reuse `units.ts` (one source of truth); these
|
|
9
|
+
* types are the fluent face over it. Fitness-relevant set only — not a general
|
|
10
|
+
* dimensional-analysis system.
|
|
11
|
+
*/
|
|
12
|
+
import { METERS_PER_MILE, METERS_PER_FOOT, formatDuration, formatPace, convertDistance, convertElevation, convertSpeed, distanceUnitLabel, elevationUnitLabel, speedUnitLabel, } from './units.js';
|
|
13
|
+
/** A length — canonical metres. */
|
|
14
|
+
export class Distance {
|
|
15
|
+
meters;
|
|
16
|
+
constructor(meters) {
|
|
17
|
+
this.meters = meters;
|
|
18
|
+
}
|
|
19
|
+
static meters(m) {
|
|
20
|
+
return new Distance(m);
|
|
21
|
+
}
|
|
22
|
+
static km(km) {
|
|
23
|
+
return new Distance(km * 1000);
|
|
24
|
+
}
|
|
25
|
+
static miles(mi) {
|
|
26
|
+
return new Distance(mi * METERS_PER_MILE);
|
|
27
|
+
}
|
|
28
|
+
get km() {
|
|
29
|
+
return this.meters / 1000;
|
|
30
|
+
}
|
|
31
|
+
get miles() {
|
|
32
|
+
return this.meters / METERS_PER_MILE;
|
|
33
|
+
}
|
|
34
|
+
/** Numeric value in the chosen unit — the dynamic-unit peer of `.km` / `.miles`
|
|
35
|
+
* (for when the unit is a runtime preference). */
|
|
36
|
+
in(unit) {
|
|
37
|
+
return convertDistance(this.meters, unit);
|
|
38
|
+
}
|
|
39
|
+
/** Display string with unit label, e.g. `"12.34 mi"`. */
|
|
40
|
+
format(unit, decimals = 2) {
|
|
41
|
+
return `${this.in(unit).toFixed(decimals)} ${distanceUnitLabel(unit)}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** A climb/altitude — canonical metres, shown in feet or metres (a separate type
|
|
45
|
+
* from {@link Distance} because elevation reads in ft/m, not mi/km). */
|
|
46
|
+
export class Elevation {
|
|
47
|
+
meters;
|
|
48
|
+
constructor(meters) {
|
|
49
|
+
this.meters = meters;
|
|
50
|
+
}
|
|
51
|
+
static meters(m) {
|
|
52
|
+
return new Elevation(m);
|
|
53
|
+
}
|
|
54
|
+
static feet(ft) {
|
|
55
|
+
return new Elevation(ft * METERS_PER_FOOT);
|
|
56
|
+
}
|
|
57
|
+
get feet() {
|
|
58
|
+
return this.meters / METERS_PER_FOOT;
|
|
59
|
+
}
|
|
60
|
+
/** Numeric value in the chosen unit (dynamic-unit peer of `.feet` / `.meters`). */
|
|
61
|
+
in(unit) {
|
|
62
|
+
return convertElevation(this.meters, unit);
|
|
63
|
+
}
|
|
64
|
+
/** Display string with unit label, e.g. `"1200 ft"`. */
|
|
65
|
+
format(unit, decimals = 0) {
|
|
66
|
+
return `${this.in(unit).toFixed(decimals)} ${elevationUnitLabel(unit)}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** An elapsed time — canonical seconds. */
|
|
70
|
+
export class Duration {
|
|
71
|
+
seconds;
|
|
72
|
+
constructor(seconds) {
|
|
73
|
+
this.seconds = seconds;
|
|
74
|
+
}
|
|
75
|
+
static seconds(s) {
|
|
76
|
+
return new Duration(s);
|
|
77
|
+
}
|
|
78
|
+
static minutes(m) {
|
|
79
|
+
return new Duration(m * 60);
|
|
80
|
+
}
|
|
81
|
+
static hours(h) {
|
|
82
|
+
return new Duration(h * 3600);
|
|
83
|
+
}
|
|
84
|
+
get minutes() {
|
|
85
|
+
return this.seconds / 60;
|
|
86
|
+
}
|
|
87
|
+
get hours() {
|
|
88
|
+
return this.seconds / 3600;
|
|
89
|
+
}
|
|
90
|
+
/** "h:mm:ss" / "m:ss". */
|
|
91
|
+
format() {
|
|
92
|
+
return formatDuration(this.seconds);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** A speed — canonical metres/second. The inverse view is {@link Pace}. */
|
|
96
|
+
export class Speed {
|
|
97
|
+
metersPerSecond;
|
|
98
|
+
constructor(metersPerSecond) {
|
|
99
|
+
this.metersPerSecond = metersPerSecond;
|
|
100
|
+
}
|
|
101
|
+
static metersPerSecond(v) {
|
|
102
|
+
return new Speed(v);
|
|
103
|
+
}
|
|
104
|
+
static kmh(v) {
|
|
105
|
+
return new Speed(v / 3.6);
|
|
106
|
+
}
|
|
107
|
+
static mph(v) {
|
|
108
|
+
return new Speed(v / 2.236936);
|
|
109
|
+
}
|
|
110
|
+
get kmh() {
|
|
111
|
+
return this.metersPerSecond * 3.6;
|
|
112
|
+
}
|
|
113
|
+
get mph() {
|
|
114
|
+
return this.metersPerSecond * 2.236936;
|
|
115
|
+
}
|
|
116
|
+
/** As pace (time per distance) — the same measurement, shown the other way. */
|
|
117
|
+
pace() {
|
|
118
|
+
return Pace.secondsPerKm(this.metersPerSecond > 0 ? 1000 / this.metersPerSecond : Infinity);
|
|
119
|
+
}
|
|
120
|
+
asMinsPerMile() {
|
|
121
|
+
return this.pace().format('imperial');
|
|
122
|
+
}
|
|
123
|
+
asMinsPerKm() {
|
|
124
|
+
return this.pace().format('metric');
|
|
125
|
+
}
|
|
126
|
+
/** Numeric value in the chosen unit — mph (imperial) or km/h (metric). */
|
|
127
|
+
in(unit) {
|
|
128
|
+
return convertSpeed(this.metersPerSecond, unit);
|
|
129
|
+
}
|
|
130
|
+
/** Display string with unit label, e.g. `"15.2 mph"` / `"24.5 km/h"`. */
|
|
131
|
+
format(unit, decimals = 1) {
|
|
132
|
+
return `${this.in(unit).toFixed(decimals)} ${speedUnitLabel(unit)}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** A pace — canonical seconds per kilometre. The inverse view is {@link Speed}. */
|
|
136
|
+
export class Pace {
|
|
137
|
+
secondsPerKm;
|
|
138
|
+
constructor(secondsPerKm) {
|
|
139
|
+
this.secondsPerKm = secondsPerKm;
|
|
140
|
+
}
|
|
141
|
+
static secondsPerKm(s) {
|
|
142
|
+
return new Pace(s);
|
|
143
|
+
}
|
|
144
|
+
static secondsPerMile(s) {
|
|
145
|
+
return new Pace(s / (METERS_PER_MILE / 1000));
|
|
146
|
+
}
|
|
147
|
+
get secondsPerMile() {
|
|
148
|
+
return this.secondsPerKm * (METERS_PER_MILE / 1000);
|
|
149
|
+
}
|
|
150
|
+
speed() {
|
|
151
|
+
const s = this.secondsPerKm;
|
|
152
|
+
// 0 pace = infinitely fast; ∞ pace (stopped) = 0; garbage (negative/NaN) = 0.
|
|
153
|
+
const mps = s > 0 && Number.isFinite(s) ? 1000 / s : s === 0 ? Infinity : 0;
|
|
154
|
+
return Speed.metersPerSecond(mps);
|
|
155
|
+
}
|
|
156
|
+
/** "m:ss/km" (metric, default) or "m:ss/mi" (imperial). */
|
|
157
|
+
format(unit = 'metric') {
|
|
158
|
+
return formatPace(this.secondsPerKm, unit);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Mechanical power — canonical watts. */
|
|
162
|
+
export class Power {
|
|
163
|
+
watts;
|
|
164
|
+
constructor(watts) {
|
|
165
|
+
this.watts = watts;
|
|
166
|
+
}
|
|
167
|
+
static watts(w) {
|
|
168
|
+
return new Power(w);
|
|
169
|
+
}
|
|
170
|
+
/** Power-to-weight (W/kg) given a body weight; NaN for a non-positive weight. */
|
|
171
|
+
perKg(weightKg) {
|
|
172
|
+
return weightKg > 0 ? this.watts / weightKg : NaN;
|
|
173
|
+
}
|
|
174
|
+
/** Display string, e.g. `"291 W"`. */
|
|
175
|
+
format(decimals = 0) {
|
|
176
|
+
return `${this.watts.toFixed(decimals)} W`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Heart rate — canonical beats/minute. */
|
|
180
|
+
export class HeartRate {
|
|
181
|
+
bpm;
|
|
182
|
+
constructor(bpm) {
|
|
183
|
+
this.bpm = bpm;
|
|
184
|
+
}
|
|
185
|
+
static bpm(b) {
|
|
186
|
+
return new HeartRate(b);
|
|
187
|
+
}
|
|
188
|
+
/** Display string, e.g. `"152 bpm"`. */
|
|
189
|
+
format(decimals = 0) {
|
|
190
|
+
return `${this.bpm.toFixed(decimals)} bpm`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Cadence — canonical revolutions/minute (pedal strokes, run steps/min, …). */
|
|
194
|
+
export class Cadence {
|
|
195
|
+
rpm;
|
|
196
|
+
constructor(rpm) {
|
|
197
|
+
this.rpm = rpm;
|
|
198
|
+
}
|
|
199
|
+
static rpm(r) {
|
|
200
|
+
return new Cadence(r);
|
|
201
|
+
}
|
|
202
|
+
/** Display string, e.g. `"90 rpm"`. */
|
|
203
|
+
format(decimals = 0) {
|
|
204
|
+
return `${this.rpm.toFixed(decimals)} rpm`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
//# sourceMappingURL=quantities.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ImportedActivity, ActivityStreams, Lap } from '../types.js';
|
|
2
|
+
import * as geo from '../geo/index.js';
|
|
3
|
+
import type { Split, ProfilePoint, ProfileSample } from '../geo/index.js';
|
|
4
|
+
export type { Split, ProfilePoint, ProfileSample, Lap };
|
|
5
|
+
/** A channel that can be plotted against distance OR time on the DATA chart. */
|
|
6
|
+
export type ChannelKey = 'elevation' | 'speed' | 'heartrate' | 'power' | 'cadence' | 'temperature';
|
|
7
|
+
/**
|
|
8
|
+
* One resampled point carrying BOTH axes — distance and elapsed time — so the
|
|
9
|
+
* chart can switch x-axis without recomputing. Buckets are even in distance;
|
|
10
|
+
* `timeSeconds` is the elapsed time reached at that distance, so the time axis
|
|
11
|
+
* spans 0 → total elapsed. `value` is native units (m, m/s, bpm, W, rpm, °C).
|
|
12
|
+
*/
|
|
13
|
+
export interface ChannelSample {
|
|
14
|
+
distanceMeters: number;
|
|
15
|
+
timeSeconds: number;
|
|
16
|
+
/** Bucket median (the robust line). */
|
|
17
|
+
value: number;
|
|
18
|
+
/** Outer band edges — central-90% percentiles (the faint variance envelope). */
|
|
19
|
+
bandLo: number;
|
|
20
|
+
bandHi: number;
|
|
21
|
+
/** Inner band edges — the inter-quartile range (the denser typical spread). */
|
|
22
|
+
innerLo: number;
|
|
23
|
+
innerHi: number;
|
|
24
|
+
/** This bucket is a SUSTAINED coast (you stopped pedalling/moving for longer
|
|
25
|
+
* than the bridge window) on an output channel — value/band are NaN here, and
|
|
26
|
+
* the chart draws a drop-to-baseline rather than a line. Brief coasts aren't
|
|
27
|
+
* flagged: they stay real values and just dip the smoothed line. */
|
|
28
|
+
coast: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One channel resampled onto the grid. Only channels the activity actually
|
|
32
|
+
* carries appear; the display layer converts native units.
|
|
33
|
+
*/
|
|
34
|
+
export interface ChannelProfile {
|
|
35
|
+
key: ChannelKey;
|
|
36
|
+
points: ChannelSample[];
|
|
37
|
+
}
|
|
38
|
+
/** Everything the activity summary holds, computed from one activity's streams. */
|
|
39
|
+
export interface ActivitySummary {
|
|
40
|
+
startTimeUtc: string;
|
|
41
|
+
pointCount: number;
|
|
42
|
+
distanceMeters: number;
|
|
43
|
+
/** Time the clock ran (last sample − first sample). */
|
|
44
|
+
elapsedTimeSeconds: number;
|
|
45
|
+
/** Time actually moving (speed above the stopped threshold). */
|
|
46
|
+
movingTimeSeconds: number;
|
|
47
|
+
elevationGainMeters: number;
|
|
48
|
+
elevationLossMeters: number;
|
|
49
|
+
/** `[[minLat, minLng], [maxLat, maxLng]]`, or null for a GPS-less activity. */
|
|
50
|
+
bounds: [[number, number], [number, number]] | null;
|
|
51
|
+
/** Douglas–Peucker-simplified `[lat, lng]` line for the map overview; empty
|
|
52
|
+
* for a GPS-less activity (no map). */
|
|
53
|
+
polyline: Array<[number, number]>;
|
|
54
|
+
/** Distance-grid profiles, one per present channel (elevation first). */
|
|
55
|
+
channels: ChannelProfile[];
|
|
56
|
+
/** Per-kilometre splits (computed, evenly spaced). */
|
|
57
|
+
splits: Split[];
|
|
58
|
+
/** Recorded laps (device-marked), if the source carried them. */
|
|
59
|
+
laps: Lap[];
|
|
60
|
+
}
|
|
61
|
+
/** Options for {@link computeActivitySummary}. */
|
|
62
|
+
export interface ActivitySummaryOptions {
|
|
63
|
+
/** Split interval in metres (default 1000 = per-km). */
|
|
64
|
+
splitMeters?: number;
|
|
65
|
+
/** Polyline simplification tolerance in metres (default 12). */
|
|
66
|
+
simplifyMeters?: number;
|
|
67
|
+
/** Channel-profile bucket width in metres (default 100). */
|
|
68
|
+
profileBucketMeters?: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the canonical pond series from an activity's streams (timeSeconds are
|
|
72
|
+
* offsets). Works for GPS and GPS-less alike: lat/lng are `undefined` when the
|
|
73
|
+
* source had no positions, so the series is then just time + the recorded
|
|
74
|
+
* channels. The sample count is the positions when present, else the longest
|
|
75
|
+
* recorded channel.
|
|
76
|
+
*/
|
|
77
|
+
export declare function buildTrackFromStreams(name: string, streams: ActivityStreams, startMs: number): geo.TrackSeries;
|
|
78
|
+
/**
|
|
79
|
+
* The streams decoded once into the per-sample arrays every downstream metric
|
|
80
|
+
* reads — track, columns, cumulative distance, derived speed, relative time.
|
|
81
|
+
* Building the pond track + reading columns is the expensive part of the
|
|
82
|
+
* summary compute, so {@link prepareActivity} does it once and both the summary
|
|
83
|
+
* ({@link summaryFromPrepared}) and the zoom-resolution profiles
|
|
84
|
+
* ({@link windowChannels}) reuse it — no second decode per zoom. Opaque shape;
|
|
85
|
+
* treat it as a handle, not a public data contract.
|
|
86
|
+
*/
|
|
87
|
+
export interface PreparedActivity {
|
|
88
|
+
imported: ImportedActivity;
|
|
89
|
+
/** The canonical pond series — always present (GPS or GPS-less). `hasTrack`
|
|
90
|
+
* says whether it carries positions. */
|
|
91
|
+
track: geo.TrackSeries;
|
|
92
|
+
cols: ReturnType<typeof geo.readColumns>;
|
|
93
|
+
cum: Float64Array;
|
|
94
|
+
step: Float64Array;
|
|
95
|
+
/** Derived instantaneous speed (m/s); NaN where no timestamp delta. */
|
|
96
|
+
speed: Float64Array;
|
|
97
|
+
/** Elapsed seconds since the first sample. */
|
|
98
|
+
timeRel: Float64Array;
|
|
99
|
+
n: number;
|
|
100
|
+
/** Whether the source carried timestamps (so speed is meaningful). */
|
|
101
|
+
hasTime: boolean;
|
|
102
|
+
/** Whether the activity has a GPS track. False for GPS-less sources (indoor /
|
|
103
|
+
* GPS-off head units): no map, distance comes from the device stream. */
|
|
104
|
+
hasTrack: boolean;
|
|
105
|
+
/** Per-sample sustained-coast mask per output channel (speed/power/cadence),
|
|
106
|
+
* computed ONCE here (it's window-independent and the pond-fill pass is the
|
|
107
|
+
* expensive part) and reused by every channel build / zoom. */
|
|
108
|
+
coastMasks: Partial<Record<ChannelKey, boolean[]>>;
|
|
109
|
+
}
|
|
110
|
+
/** Decode an activity's streams into the shared per-sample arrays. ONE path now:
|
|
111
|
+
* build the canonical series, read its columns; distance comes from haversine
|
|
112
|
+
* when there are positions, else the device `distance` column (GPS-less). */
|
|
113
|
+
export declare function prepareActivity(imported: ImportedActivity): PreparedActivity;
|
|
114
|
+
/** Options for {@link windowChannels}. */
|
|
115
|
+
export interface WindowChannelOptions {
|
|
116
|
+
startMeters: number;
|
|
117
|
+
endMeters: number;
|
|
118
|
+
/** Target number of buckets across the window (default 160). The bucket width
|
|
119
|
+
* is the window span / this, clamped to [minBucketMeters, maxBucketMeters]. */
|
|
120
|
+
targetBuckets?: number;
|
|
121
|
+
/** Floor on bucket width, m — don't out-resolve the raw sampling (default 5). */
|
|
122
|
+
minBucketMeters?: number;
|
|
123
|
+
/** Ceiling on bucket width, m — never coarser than the overview (default 100). */
|
|
124
|
+
maxBucketMeters?: number;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Re-bucket the channels at high resolution within a distance window — the
|
|
128
|
+
* chart's payoff when a split/lap is locked and zoomed: the same lines, but
|
|
129
|
+
* resolved to ~10 m instead of the whole-activity 100 m grid, revealing detail
|
|
130
|
+
* the overview averages away. Distances are absolute, so the result drops into
|
|
131
|
+
* the same axis as {@link ActivitySummary.channels} and the chart's range clip.
|
|
132
|
+
*/
|
|
133
|
+
export declare function windowChannels(prep: PreparedActivity, opts: WindowChannelOptions): ChannelProfile[];
|
|
134
|
+
/** The full activity summary from already-prepared streams (single decode). */
|
|
135
|
+
export declare function summaryFromPrepared(prep: PreparedActivity, options?: ActivitySummaryOptions): ActivitySummary;
|
|
136
|
+
/** Compute the full activity summary for one imported activity. */
|
|
137
|
+
export declare function computeActivitySummary(imported: ImportedActivity, options?: ActivitySummaryOptions): ActivitySummary;
|
|
138
|
+
//# sourceMappingURL=index.d.ts.map
|