@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,41 @@
1
+ /** Display-edge unit conversion. The model stores SI; UI converts here. */
2
+ export declare const METERS_PER_MILE = 1609.344;
3
+ export declare const METERS_PER_FOOT = 0.3048;
4
+ export declare const metersToMiles: (m: number) => number;
5
+ export declare const metersToFeet: (m: number) => number;
6
+ /** Seconds → "h:mm:ss" or "m:ss". */
7
+ export declare function formatDuration(totalSeconds: number): string;
8
+ export type DistanceUnit = 'mi' | 'km';
9
+ export type ElevationUnit = 'ft' | 'm';
10
+ export type TemperatureUnit = 'F' | 'C';
11
+ export type SpeedPaceUnit = 'imperial' | 'metric';
12
+ export interface UnitPreferences {
13
+ distance: DistanceUnit;
14
+ elevation: ElevationUnit;
15
+ temperature: TemperatureUnit;
16
+ speedPace: SpeedPaceUnit;
17
+ }
18
+ /** US defaults — the archive's origin. Each axis is independently overridable. */
19
+ export declare const DEFAULT_UNITS: UnitPreferences;
20
+ /** Distance: metres → display number in the chosen unit. */
21
+ export declare function convertDistance(meters: number, unit: DistanceUnit): number;
22
+ /** Elevation: metres → display number in the chosen unit. */
23
+ export declare function convertElevation(meters: number, unit: ElevationUnit): number;
24
+ /** Temperature: °C → display number in the chosen unit. */
25
+ export declare function convertTemperature(celsius: number, unit: TemperatureUnit): number;
26
+ /** Speed: m/s → display number (mph or km/h). */
27
+ export declare function convertSpeed(metersPerSecond: number, unit: SpeedPaceUnit): number;
28
+ /** The label shown next to a converted value, e.g. for axis ticks / stat tiles. */
29
+ export declare function distanceUnitLabel(unit: DistanceUnit): string;
30
+ export declare function elevationUnitLabel(unit: ElevationUnit): string;
31
+ export declare function temperatureUnitLabel(unit: TemperatureUnit): string;
32
+ export declare function speedUnitLabel(unit: SpeedPaceUnit): string;
33
+ export declare function paceUnitLabel(unit: SpeedPaceUnit): string;
34
+ /**
35
+ * Seconds-per-kilometre → "m:ss/km" (default) or "m:ss/mi" when `unit` is
36
+ * imperial. Rounds total seconds *first* then splits, so a pace rounding up to
37
+ * a full minute carries (119.88 → "2:00/km", never "1:60/km"). Same
38
+ * round-then-split discipline as {@link formatDuration}.
39
+ */
40
+ export declare function formatPace(secondsPerKm: number, unit?: SpeedPaceUnit): string;
41
+ //# sourceMappingURL=units.d.ts.map
package/dist/units.js ADDED
@@ -0,0 +1,69 @@
1
+ /** Display-edge unit conversion. The model stores SI; UI converts here. */
2
+ export const METERS_PER_MILE = 1609.344;
3
+ export const METERS_PER_FOOT = 0.3048;
4
+ export const metersToMiles = (m) => m / METERS_PER_MILE;
5
+ export const metersToFeet = (m) => m / METERS_PER_FOOT;
6
+ /** Seconds → "h:mm:ss" or "m:ss". */
7
+ export function formatDuration(totalSeconds) {
8
+ const s = Math.round(totalSeconds);
9
+ const h = Math.floor(s / 3600);
10
+ const m = Math.floor((s % 3600) / 60);
11
+ const sec = s % 60;
12
+ const pad = (n) => String(n).padStart(2, '0');
13
+ return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
14
+ }
15
+ /** US defaults — the archive's origin. Each axis is independently overridable. */
16
+ export const DEFAULT_UNITS = {
17
+ distance: 'mi',
18
+ elevation: 'ft',
19
+ temperature: 'F',
20
+ speedPace: 'imperial',
21
+ };
22
+ /** Distance: metres → display number in the chosen unit. */
23
+ export function convertDistance(meters, unit) {
24
+ return unit === 'km' ? meters / 1000 : metersToMiles(meters);
25
+ }
26
+ /** Elevation: metres → display number in the chosen unit. */
27
+ export function convertElevation(meters, unit) {
28
+ return unit === 'm' ? meters : metersToFeet(meters);
29
+ }
30
+ /** Temperature: °C → display number in the chosen unit. */
31
+ export function convertTemperature(celsius, unit) {
32
+ return unit === 'F' ? celsius * 1.8 + 32 : celsius;
33
+ }
34
+ /** Speed: m/s → display number (mph or km/h). */
35
+ export function convertSpeed(metersPerSecond, unit) {
36
+ return unit === 'metric' ? metersPerSecond * 3.6 : metersPerSecond * 2.236936;
37
+ }
38
+ /** The label shown next to a converted value, e.g. for axis ticks / stat tiles. */
39
+ export function distanceUnitLabel(unit) {
40
+ return unit;
41
+ }
42
+ export function elevationUnitLabel(unit) {
43
+ return unit;
44
+ }
45
+ export function temperatureUnitLabel(unit) {
46
+ return unit === 'F' ? '°F' : '°C';
47
+ }
48
+ export function speedUnitLabel(unit) {
49
+ return unit === 'metric' ? 'km/h' : 'mph';
50
+ }
51
+ export function paceUnitLabel(unit) {
52
+ return unit === 'metric' ? '/km' : '/mi';
53
+ }
54
+ /**
55
+ * Seconds-per-kilometre → "m:ss/km" (default) or "m:ss/mi" when `unit` is
56
+ * imperial. Rounds total seconds *first* then splits, so a pace rounding up to
57
+ * a full minute carries (119.88 → "2:00/km", never "1:60/km"). Same
58
+ * round-then-split discipline as {@link formatDuration}.
59
+ */
60
+ export function formatPace(secondsPerKm, unit = 'metric') {
61
+ const perUnit = unit === 'imperial'
62
+ ? secondsPerKm * (METERS_PER_MILE / 1000)
63
+ : secondsPerKm;
64
+ const s = Math.round(perUnit);
65
+ const m = Math.floor(s / 60);
66
+ const sec = s % 60;
67
+ return `${m}:${String(sec).padStart(2, '0')}${paceUnitLabel(unit)}`;
68
+ }
69
+ //# sourceMappingURL=units.js.map
@@ -0,0 +1,32 @@
1
+ import type { ZoneDef } from '../profile/index.js';
2
+ /** One zone's time + share. `hi` is `Infinity` for the open top. */
3
+ export interface ZoneTime {
4
+ /** 1-based zone number (Z1 = the lowest band). */
5
+ zone: number;
6
+ label: string;
7
+ /** Inclusive lower edge, in the value axis (watts / bpm / m·s⁻¹). */
8
+ lo: number;
9
+ /** Upper edge; `Infinity` for the top zone. */
10
+ hi: number;
11
+ seconds: number;
12
+ /** Share of total in-zone time, [0, 1]. */
13
+ fraction: number;
14
+ }
15
+ /**
16
+ * Time spent in each zone, bucketing `values` by the ascending `edges` and
17
+ * summing per-sample `dt`. pond `byColumn({ edges, inclusive: '(]' })` over the
18
+ * value axis — Coggan-style **inclusive-upper** bins natively (a sample exactly
19
+ * on a boundary counts in the lower zone), no ε-nudge. Non-finite values are
20
+ * dropped (can't be placed); sub-zero clamps to the bottom zone. pond 0.30 made
21
+ * the floor edge of `'(]'` inclusive (the `include_lowest` convention), so a 0
22
+ * sample (a stop / coast) lands in zone 1 with the edges passed as-is — no
23
+ * floor-push needed (F-inclusive-floor, resolved in 0.30).
24
+ */
25
+ export declare function zoneDistributionByValue(values: ArrayLike<number>, dt: ArrayLike<number>, zones: ZoneDef): ZoneTime[];
26
+ /** Time in each HR zone (bpm axis). `hrZones` from `profile.profileAsOf`. */
27
+ export declare function hrZoneDistribution(timeSec: Float64Array, heartrate: ArrayLike<number>, hrZones: ZoneDef): ZoneTime[];
28
+ /** Time in each pace zone. We bucket the **speed** channel (m/s) against
29
+ * speed-axis edges (Z1 = slowest) so a stop doesn't blow the reciprocal up;
30
+ * the UI labels the bands as paces. `paceZones` from `profile.profileAsOf`. */
31
+ export declare function paceZoneDistribution(timeSec: Float64Array, speed: ArrayLike<number>, paceZones: ZoneDef): ZoneTime[];
32
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Time-in-zone over a **value axis** — the engine behind the power, heart-rate,
3
+ * and pace zone distributions. Each is "how long did this channel spend in each
4
+ * band," i.e. bucket the per-sample value by the zone edges and sum each
5
+ * sample's duration. That's pond `byColumn` over the value column, summing a
6
+ * gap-clamped `dt` weight — the same shape the power distribution uses,
7
+ * generalized so HR and pace share one tested core.
8
+ */
9
+ import { TimeSeries } from 'pond-ts';
10
+ import { intervals } from '../intervals.js';
11
+ const BIN_SCHEMA = [
12
+ { name: 'time', kind: 'time' },
13
+ // optional so a non-finite sample rides as `undefined` and byColumn drops it.
14
+ { name: 'val', kind: 'number', required: false },
15
+ { name: 'dt', kind: 'number' },
16
+ ];
17
+ const SENTINEL = 1e9; // the open-top edge ZoneDef carries
18
+ /**
19
+ * Time spent in each zone, bucketing `values` by the ascending `edges` and
20
+ * summing per-sample `dt`. pond `byColumn({ edges, inclusive: '(]' })` over the
21
+ * value axis — Coggan-style **inclusive-upper** bins natively (a sample exactly
22
+ * on a boundary counts in the lower zone), no ε-nudge. Non-finite values are
23
+ * dropped (can't be placed); sub-zero clamps to the bottom zone. pond 0.30 made
24
+ * the floor edge of `'(]'` inclusive (the `include_lowest` convention), so a 0
25
+ * sample (a stop / coast) lands in zone 1 with the edges passed as-is — no
26
+ * floor-push needed (F-inclusive-floor, resolved in 0.30).
27
+ */
28
+ export function zoneDistributionByValue(values, dt, zones) {
29
+ const { edges, labels } = zones;
30
+ const rows = [];
31
+ for (let i = 0; i < values.length; i++) {
32
+ const v = values[i];
33
+ rows.push([i, Number.isFinite(v) ? Math.max(0, v) : undefined, dt[i] ?? 0]);
34
+ }
35
+ const bins = new TimeSeries({
36
+ name: 'zones',
37
+ schema: BIN_SCHEMA,
38
+ rows,
39
+ }).byColumn('val', { edges, inclusive: '(]' }, { seconds: { from: 'dt', using: 'sum' } });
40
+ const secs = bins.map((b) => b.seconds ?? 0);
41
+ const total = secs.reduce((a, b) => a + b, 0) || 1;
42
+ return labels.map((label, z) => ({
43
+ zone: z + 1,
44
+ label,
45
+ lo: edges[z],
46
+ hi: (edges[z + 1] ?? SENTINEL) >= SENTINEL ? Infinity : edges[z + 1],
47
+ seconds: secs[z] ?? 0,
48
+ fraction: (secs[z] ?? 0) / total,
49
+ }));
50
+ }
51
+ /** Time in each HR zone (bpm axis). `hrZones` from `profile.profileAsOf`. */
52
+ export function hrZoneDistribution(timeSec, heartrate, hrZones) {
53
+ return zoneDistributionByValue(heartrate, intervals(timeSec), hrZones);
54
+ }
55
+ /** Time in each pace zone. We bucket the **speed** channel (m/s) against
56
+ * speed-axis edges (Z1 = slowest) so a stop doesn't blow the reciprocal up;
57
+ * the UI labels the bands as paces. `paceZones` from `profile.profileAsOf`. */
58
+ export function paceZoneDistribution(timeSec, speed, paceZones) {
59
+ return zoneDistributionByValue(speed, intervals(timeSec), paceZones);
60
+ }
61
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pond-ts/fit",
3
+ "version": "0.31.0",
4
+ "private": false,
5
+ "description": "Fitness & activity domain library on pond-ts — quantities, canonical activity series, and analytics (geo, power, zones, splits)",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pjm17971/pond-ts.git",
10
+ "directory": "packages/fit"
11
+ },
12
+ "type": "module",
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/cjs-fallback.cjs"
20
+ }
21
+ },
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "CHANGELOG.md"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json",
31
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
32
+ "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
33
+ "prepack": "cp ../../README.md ./README.md && cp ../../LICENSE ./LICENSE && cp ../../CHANGELOG.md ./CHANGELOG.md && npm run build && cp cjs-fallback.cjs dist/cjs-fallback.cjs && find dist -name '*.map' -delete",
34
+ "test": "npm run test:type && npm run test:runtime",
35
+ "test:type": "tsc -p tsconfig.types.json",
36
+ "test:runtime": "vitest run",
37
+ "verify": "npm run format:check && npm run build && npm test"
38
+ },
39
+ "peerDependencies": {
40
+ "pond-ts": "^0.31.0"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.6.3",
44
+ "vitest": "^2.1.4"
45
+ }
46
+ }