{"version":3,"sources":["../src/charts.ts","../src/geo.ts","../src/analyzer.ts"],"sourcesContent":["/**\n * Chart.js config builders for Stride.\n *\n * This module is intentionally side-effect free — it returns plain Chart.js\n * config objects. The caller is responsible for instantiating Chart.js, which\n * allows use in any environment (browser, Node canvas, etc.) and keeps the\n * library tree-shakeable.\n *\n * Usage:\n *   import { Chart } from 'chart.js/auto'\n *   import { paceChartConfig } from '@alosha/stride'\n *   new Chart(canvas, paceChartConfig(activity, stats))\n */\n\nimport type { Activity, ActivityStats, ChartOptions, Split } from './types.js'\nimport type { ChartConfiguration } from 'chart.js'\nimport { formatPace, formatDuration } from './analyzer.js'\nimport { cumulativeDistances } from './geo.js'\n\n// Indices for downsampling a point series, always including the final point.\n// A plain `for (i = 0; i < n; i += step)` loop stops at the last multiple of\n// `step`, silently truncating up to `step - 1` points from the end: a 4416-point\n// run at step 22 ends its x-axis at 12.3 km for a 12.4 km activity. The chart\n// must end where the run ended.\nfunction sampleIndices(length: number, step: number): number[] {\n  const idx: number[] = []\n  for (let i = 0; i < length; i += step) idx.push(i)\n  if (length > 0 && idx[idx.length - 1] !== length - 1) idx.push(length - 1)\n  return idx\n}\n\nconst GREEN = 'rgba(34,197,94,1)'\nconst GREEN_FILL = 'rgba(34,197,94,0.15)'\nconst BLUE = 'rgba(59,130,246,1)'\nconst BLUE_FILL = 'rgba(59,130,246,0.15)'\nconst RED = 'rgba(239,68,68,1)'\nconst ORANGE = 'rgba(249,115,22,1)'\nconst YELLOW = 'rgba(234,179,8,1)'\n\n// Fades an `rgba(...,1)` constant to a lower alpha, used to visually mark the\n// trailing partial split's bar as \"less than the others\" rather than letting\n// it read as a full kilometre.\nfunction fade(rgba: string, alpha: number): string {\n  return rgba.replace(/,1\\)$/, `,${alpha})`)\n}\n\n// A partial split's `distanceM` differs from 1000 (see types.ts) — label it\n// distinctly so a chart never presents it as if a full kilometre were run.\nfunction splitLabel(split: Split, units: 'metric' | 'imperial'): string {\n  if (split.distanceM === 1000) return `km ${split.km}`\n  const partialDist = units === 'imperial'\n    ? `${(split.distanceM / 1609.34).toFixed(2)} mi`\n    : `${(split.distanceM / 1000).toFixed(2)} km`\n  return `km ${split.km} (${partialDist})`\n}\n\n// ---------------------------------------------------------------------------\n// Pace over distance\n// ---------------------------------------------------------------------------\n\nexport function paceChartConfig(\n  activity: Activity,\n  stats: ActivityStats,\n  opts: ChartOptions = {}\n): ChartConfiguration {\n  const units = opts.units ?? 'metric'\n  const splits = stats.splits\n\n  return {\n    type: 'line',\n    data: {\n      labels: splits.map(s => splitLabel(s, units)),\n      datasets: [{\n        label: `Pace (${units === 'metric' ? 'min/km' : 'min/mi'})`,\n        data: splits.map(s => +(s.paceSecPerKm / 60).toFixed(2)),\n        borderColor: GREEN,\n        backgroundColor: GREEN_FILL,\n        fill: true,\n        tension: 0.3,\n        pointRadius: 4,\n      }],\n    },\n    options: {\n      responsive: true,\n      plugins: {\n        legend: { display: false },\n        title: { display: true, text: 'Pace per km' },\n        tooltip: {\n          callbacks: {\n            label: (ctx) => ctx.parsed.y != null ? formatPace(ctx.parsed.y * 60, units) : '',\n          },\n        },\n      },\n      scales: {\n        y: {\n          reverse: true, // lower pace = faster = top of chart\n          ticks: {\n            callback: (v) => formatPace(Number(v) * 60, units),\n          },\n        },\n      },\n    },\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Elevation profile\n// ---------------------------------------------------------------------------\n\nexport function elevationChartConfig(\n  activity: Activity,\n  stats: ActivityStats,\n  opts: ChartOptions = {}\n): ChartConfiguration {\n  const units = opts.units ?? 'metric'\n\n  // Downsample to max 200 points for performance\n  const pts = activity.points.filter(p => p.elevation != null)\n  const step = Math.max(1, Math.floor(pts.length / 200))\n\n  // Cumulative distance over every (elevation-bearing) point (see src/geo.ts)\n  // — sampling below indexes into this, rather than summing chords of the\n  // decimated points, which would undercount the true path length. The axis\n  // must follow whichever source the analyzer chose (stats.distanceSource),\n  // so this chart's distance never disagrees with stats.distanceM: when that\n  // was the device stream, re-base its cumulative `distanceM` to the first\n  // point; otherwise fall back to the same haversine maths as analyze().\n  const useDevice = stats.distanceSource === 'device' && pts.every(p => p.distanceM != null)\n  const cumDist = useDevice\n    ? pts.map(p => p.distanceM! - pts[0].distanceM!)\n    : cumulativeDistances(pts)\n\n  const labels: string[] = []\n  const elevData: number[] = []\n  for (const i of sampleIndices(pts.length, step)) {\n    const distKm = cumDist[i] / 1000\n    labels.push(units === 'imperial'\n      ? `${(distKm * 0.621371).toFixed(1)} mi`\n      : `${distKm.toFixed(1)} km`)\n\n    const elevation = pts[i].elevation!\n    elevData.push(units === 'imperial' ? +(elevation * 3.28084).toFixed(1) : +elevation.toFixed(1))\n  }\n\n  return {\n    type: 'line',\n    data: {\n      labels,\n      datasets: [{\n        label: `Elevation (${units === 'imperial' ? 'ft' : 'm'})`,\n        data: elevData,\n        borderColor: BLUE,\n        backgroundColor: BLUE_FILL,\n        fill: true,\n        tension: 0.2,\n        pointRadius: 0,\n        borderWidth: 1.5,\n      }],\n    },\n    options: {\n      responsive: true,\n      plugins: {\n        legend: { display: false },\n        title: { display: true, text: 'Elevation profile' },\n      },\n      scales: {\n        x: { ticks: { maxTicksLimit: 8 } },\n      },\n    },\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Heart rate over distance\n// ---------------------------------------------------------------------------\n\nexport function heartRateChartConfig(\n  activity: Activity,\n  stats: ActivityStats,\n  opts: ChartOptions = {}\n): ChartConfiguration {\n  const units = opts.units ?? 'metric'\n\n  // Downsample to max 200 points for performance\n  const pts = activity.points.filter(p => p.heartRate != null)\n  const step = Math.max(1, Math.floor(pts.length / 200))\n\n  // x-axis is distance, not sample index. Smart-recording watches sample\n  // hard efforts densely and steady running sparsely, so a sample-index\n  // axis stretches the hard sections and compresses the easy ones —\n  // distorting exactly the feature this chart exists to show. Same source\n  // discipline as the elevation chart: follow whichever series the analyzer\n  // chose (stats.distanceSource) so the axis never disagrees with\n  // stats.distanceM.\n  const useDevice = stats.distanceSource === 'device' && pts.every(p => p.distanceM != null)\n  const cumDist = useDevice\n    ? pts.map(p => p.distanceM! - pts[0].distanceM!)\n    : cumulativeDistances(pts)\n\n  const labels: string[] = []\n  const hrData: number[] = []\n  for (const i of sampleIndices(pts.length, step)) {\n    const distKm = cumDist[i] / 1000\n    labels.push(units === 'imperial'\n      ? `${(distKm * 0.621371).toFixed(1)} mi`\n      : `${distKm.toFixed(1)} km`)\n    hrData.push(pts[i].heartRate!)\n  }\n\n  return {\n    type: 'line',\n    data: {\n      labels,\n      datasets: [{\n        label: 'Heart rate (bpm)',\n        data: hrData,\n        borderColor: RED,\n        backgroundColor: 'rgba(239,68,68,0.1)',\n        fill: true,\n        tension: 0.3,\n        pointRadius: 0,\n        borderWidth: 1.5,\n      }],\n    },\n    options: {\n      responsive: true,\n      plugins: {\n        legend: { display: false },\n        title: { display: true, text: 'Heart rate' },\n      },\n      scales: {\n        x: { ticks: { maxTicksLimit: 8 } },\n      },\n    },\n  }\n}\n\n// ---------------------------------------------------------------------------\n// HR zone doughnut\n// ---------------------------------------------------------------------------\n\n// A GPX/TCX/FIT file with no heart rate data is ordinary input (many watches\n// don't pair a strap), not an error condition — so this returns a usable,\n// all-zero placeholder chart rather than throwing. A caller rendering a\n// dashboard of several charts gets a clearly-labelled empty doughnut instead\n// of a crash that takes the rest of the dashboard down with it.\nexport function hrZonesChartConfig(stats: ActivityStats): ChartConfiguration {\n  const zones = stats.hrZones ?? { z1: 0, z2: 0, z3: 0, z4: 0, z5: 0 }\n  const zoneValues = [zones.z1, zones.z2, zones.z3, zones.z4, zones.z5]\n\n  return {\n    type: 'doughnut',\n    data: {\n      labels: ['Z1 Easy', 'Z2 Aerobic', 'Z3 Tempo', 'Z4 Threshold', 'Z5 Max'],\n      datasets: [{\n        data: zoneValues,\n        backgroundColor: [BLUE, GREEN, YELLOW, ORANGE, RED],\n        borderWidth: 0,\n      }],\n    },\n    options: {\n      responsive: true,\n      plugins: {\n        title: {\n          display: true,\n          text: stats.hrZones\n            ? 'Heart rate zones'\n            : ['Heart rate zones', 'No heart rate data recorded'],\n        },\n        tooltip: {\n          callbacks: {\n            // Zones are seconds (as of 1.0.0, not sample counts) — show them\n            // as minutes:seconds, which is friendlier than a bare number of\n            // seconds, alongside the share of HR-covered time it represents.\n            label: (ctx) => {\n              const total = zoneValues.reduce((a, b) => a + b, 0)\n              const pct = total > 0 ? ((ctx.parsed / total) * 100).toFixed(1) : '0'\n              return `${ctx.label}: ${formatDuration(ctx.parsed)} (${pct}%)`\n            },\n          },\n        },\n      },\n    },\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Splits bar chart\n// ---------------------------------------------------------------------------\n\nexport function splitsChartConfig(\n  stats: ActivityStats,\n  opts: ChartOptions = {}\n): ChartConfiguration {\n  const units = opts.units ?? 'metric'\n  const splits = stats.splits\n  const avgPace = stats.avgPaceSecPerKm\n\n  return {\n    type: 'bar',\n    data: {\n      labels: splits.map(s => splitLabel(s, units)),\n      datasets: [{\n        label: 'Split pace',\n        data: splits.map(s => +(s.paceSecPerKm / 60).toFixed(2)),\n        // Trailing partial split's bar is faded to signal \"less than the\n        // others\" visually, on top of its distinct label.\n        backgroundColor: splits.map(s => {\n          const base = s.paceSecPerKm <= avgPace ? GREEN : ORANGE\n          return s.distanceM === 1000 ? base : fade(base, 0.5)\n        }),\n        borderRadius: 4,\n      }],\n    },\n    options: {\n      responsive: true,\n      plugins: {\n        legend: { display: false },\n        title: { display: true, text: 'Splits (green = faster than avg)' },\n        tooltip: {\n          callbacks: {\n            label: (ctx) => ctx.parsed.y != null ? formatPace(ctx.parsed.y * 60, units) : '',\n          },\n        },\n      },\n      scales: {\n        y: {\n          reverse: true,\n          ticks: { callback: (v) => formatPace(Number(v) * 60, units) },\n        },\n      },\n    },\n  }\n}\n","// ---------------------------------------------------------------------------\n// Shared geo helpers — distance maths lives here so analyzer.ts and\n// charts.ts never disagree about how far apart two points are.\n// ---------------------------------------------------------------------------\n\nconst R = 6_371_000 // Earth radius in metres (docs/metrics-spec.md Appendix A)\n\nexport function haversine(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {\n  const toRad = (d: number) => (d * Math.PI) / 180\n  const dLat = toRad(b.lat - a.lat)\n  const dLon = toRad(b.lon - a.lon)\n  const sinDLat = Math.sin(dLat / 2)\n  const sinDLon = Math.sin(dLon / 2)\n  const x =\n    sinDLat * sinDLat +\n    Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinDLon * sinDLon\n  return 2 * R * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x))\n}\n\n/**\n * Cumulative distance in metres, one entry per point, index 0 = 0.\n *\n * Always compute this over the full point set. Callers that need a series\n * for a downsampled/decimated set of points must sample *into* the result,\n * not sum haversine distances over the decimated points themselves — that\n * measures the chords of the decimated track, not the track, and undercounts\n * the true path length.\n */\nexport function cumulativeDistances(points: { lat: number; lon: number }[]): number[] {\n  const cumDist = new Array(points.length).fill(0)\n  for (let i = 1; i < points.length; i++) {\n    cumDist[i] = cumDist[i - 1] + haversine(points[i - 1], points[i])\n  }\n  return cumDist\n}\n\n/**\n * True when the device's own cumulative distance stream is usable for the\n * whole track: every point carries a finite `distanceM`, the series is\n * non-decreasing (a pause repeats a value — that is fine), and it is not all\n * zeros. A field that appears on only some points, goes backwards, or is\n * uniformly zero is treated as absent — half a device series is worse than\n * none, because splicing it onto haversine would put a discontinuity in the\n * cumulative distance.\n */\nexport function hasUsableDeviceDistance(points: { distanceM?: number }[]): boolean {\n  if (points.length === 0) return false\n  let prev = -Infinity\n  let max = 0\n  for (const p of points) {\n    const d = p.distanceM\n    if (d == null || !Number.isFinite(d)) return false\n    if (d < prev) return false            // non-monotonic → treat as absent\n    prev = d\n    if (d > max) max = d\n  }\n  return max > 0                          // all-zero → treat as absent\n}\n\n/**\n * Cumulative distance in metres, one entry per point, index 0 = 0, built from\n * whichever source is trustworthy for the *whole* track: the device's own\n * `distanceM` stream when {@link hasUsableDeviceDistance} holds, otherwise\n * summed haversine ({@link cumulativeDistances}). Never mix the two per\n * segment. The device series is re-based to the first point so it shares\n * haversine's frame (distance from the first recorded point, not from the\n * device's own zero, which may sit before the first GPS fix).\n *\n * This is the single series that must feed `distanceM`, `splits[]`,\n * `bestKmPaceSecPerKm` and the elevation chart's x-axis — compute it once.\n */\nexport function cumulativeDistanceSeries(\n  points: { lat: number; lon: number; distanceM?: number }[]\n): { cumDist: number[]; source: 'device' | 'computed' } {\n  if (hasUsableDeviceDistance(points)) {\n    const base = points[0].distanceM!\n    return { cumDist: points.map(p => p.distanceM! - base), source: 'device' }\n  }\n  return { cumDist: cumulativeDistances(points), source: 'computed' }\n}\n","import type { Activity, ActivityStats, Split, HeartRateZones } from './types.js'\nimport { cumulativeDistanceSeries } from './geo.js'\n\n// ---------------------------------------------------------------------------\n// HR zone helpers\n// ---------------------------------------------------------------------------\n\n// Which reference the zone percentage is computed against. Default 'hrmax'\n// reproduces the historical behaviour exactly. 'reserve' uses the Karvonen\n// formula (pct = (hr - restingHR) / (maxHR - restingHR), i.e. % of heart-rate\n// reserve rather than % of HRmax) — a common alternative anchor that shifts\n// low-intensity samples out of z1 relative to %HRmax for the same boundaries.\nexport type HrZoneModel =\n  | { type: 'hrmax'; boundaries?: [number, number, number, number] }\n  | { type: 'reserve'; restingHR: number; boundaries?: [number, number, number, number] }\n\n// Default boundaries reproduce the historical, hardcoded 60/70/80/90% bands\n// (see metrics-spec.md §1.3: a deliberate deviation from Garmin's 50-floor\n// default, kept so z1..z5 always sum to the full elapsed time).\nexport const DEFAULT_ZONE_BOUNDARIES: [number, number, number, number] = [0.6, 0.7, 0.8, 0.9]\n\nfunction validateZoneBoundaries(boundaries: [number, number, number, number]): void {\n  for (const b of boundaries) {\n    if (!(b > 0 && b < 1)) {\n      throw new Error(\n        `Invalid HR zone boundaries [${boundaries.join(', ')}]: each boundary must be strictly between 0 and 1 (got ${b}).`\n      )\n    }\n  }\n  for (let i = 1; i < boundaries.length; i++) {\n    if (!(boundaries[i] > boundaries[i - 1])) {\n      throw new Error(\n        `Invalid HR zone boundaries [${boundaries.join(', ')}]: boundaries must be strictly increasing.`\n      )\n    }\n  }\n}\n\n// Plausible range for a *maximum* heart rate, in bpm — not a formula, just a\n// screen against non-finite/nonsensical caller input (0, negative, NaN, a\n// resting HR typo'd into the maxHR slot). Upper bound mirrors the ubiquitous\n// age-predicted-max estimate (\"220 minus your age\", as published by the\n// American Heart Association's target-heart-rate guidance): at age 0 that\n// formula's own ceiling is 220, and this library has no infant runners.\n// Lower bound is deliberately generous rather than tuned to \"typical\" — it\n// only needs to admit every real maxHR, including heavily beta-blocked or\n// elderly athletes with pronounced chronotropic incompetence during\n// exercise, while still rejecting values with no physiological plausibility\n// at all.\nexport const MIN_PLAUSIBLE_MAX_HR_BPM = 60\nexport const MAX_PLAUSIBLE_MAX_HR_BPM = 220\n\n// Validates the two numbers HR zone percentages are computed *against* —\n// distinct from validateZoneBoundaries, which validates the percentages\n// themselves. Without this, maxHR: 0, NaN, or a negative value, and (for the\n// 'reserve' model) a missing/negative/non-finite restingHR or one >= maxHR,\n// each divide-by-zero or invert the pct formula silently and dump every\n// segment into z1 or z5 — a confident, wrong answer, not a crash. A plain-JS\n// caller gets no type error for an omitted restingHR (HrZoneModel's\n// 'reserve' variant types it as required, but that's a compile-time promise\n// only), so this is the only backstop.\nfunction validateHeartRateInputs(maxHR: number, zoneModel: HrZoneModel): void {\n  if (!Number.isFinite(maxHR) || maxHR < MIN_PLAUSIBLE_MAX_HR_BPM || maxHR > MAX_PLAUSIBLE_MAX_HR_BPM) {\n    throw new Error(\n      `Invalid maxHR: expected a finite number between ${MIN_PLAUSIBLE_MAX_HR_BPM} and ${MAX_PLAUSIBLE_MAX_HR_BPM} bpm (got ${maxHR}).`\n    )\n  }\n  if (zoneModel.type === 'reserve') {\n    const { restingHR } = zoneModel\n    if (restingHR == null || !Number.isFinite(restingHR) || restingHR < 0) {\n      throw new Error(\n        `Invalid restingHR: expected a finite, non-negative number (got ${restingHR}).`\n      )\n    }\n    if (restingHR >= maxHR) {\n      throw new Error(\n        `Invalid restingHR: must be less than maxHR (got restingHR ${restingHR}, maxHR ${maxHR}).`\n      )\n    }\n  }\n}\n\n// Each entry is a segment: `weightSec` is the duration to attribute to the\n// zone of `heartRate` (the segment's *ending* sample — see metrics-spec.md\n// §1.2). A segment with no attributable duration (missing/non-monotonic\n// timestamp) or no ending HR sample must carry `weightSec` / `heartRate` as\n// 0 / undefined respectively so it contributes nothing.\nfunction hrZones(\n  segments: { heartRate?: number; weightSec: number }[],\n  pctOf: (heartRate: number) => number,\n  boundaries: [number, number, number, number]\n): HeartRateZones {\n  const zones: HeartRateZones = { z1: 0, z2: 0, z3: 0, z4: 0, z5: 0 }\n  const [b1, b2, b3, b4] = boundaries\n\n  for (const seg of segments) {\n    if (seg.heartRate == null || seg.weightSec <= 0) continue\n    const pct = pctOf(seg.heartRate)\n    // Deliberate deviation from Garmin's 50/60/70/80/90 model: Garmin's z1\n    // floor is 50% HRmax (below that is \"no zone\"). We have no floor — z1 is\n    // \"everything below the first boundary\" — so that z1..z5 always sum to\n    // the full elapsed time (see spec §1.3/§1.4); a floor would leave\n    // below-floor samples unattributed and break that invariant.\n    if (pct < b1) zones.z1 += seg.weightSec\n    else if (pct < b2) zones.z2 += seg.weightSec\n    else if (pct < b3) zones.z3 += seg.weightSec\n    else if (pct < b4) zones.z4 += seg.weightSec\n    else zones.z5 += seg.weightSec\n  }\n\n  return zones\n}\n\n// ---------------------------------------------------------------------------\n// Elevation hysteresis filter (metrics-spec.md §5)\n// ---------------------------------------------------------------------------\n\n// Fallback path (§5.3 step 2). When a FIT file carries a device-computed\n// session.total_ascent/total_descent, the parser surfaces it as\n// Activity.deviceElevationGainM/LossM and resolveElevation() below prefers\n// it over this filter (§5.3 step 1) — it is what Garmin/Strava will agree\n// with and was filtered on-device. This hysteresis pass still always runs:\n// splits[] need its per-point gains regardless of source, and its totals are\n// the activity figures whenever no trusted device total exists (GPX and TCX\n// always, FIT when the session omits the field).\n\n// Default threshold per spec §5.3: every format this library parses is\n// GPS-derived (GPX always; FIT usually, absent a device total_ascent — see\n// the note above), so the 2-3m *barometric* threshold does not apply here.\n// 8m sits within the GPS-derived band authoritative sources actually\n// recommend (Strava ~10m for non-barometric activities, GPS Visualizer\n// 6-9m). Expose it as a parameter so a caller who knows their source is\n// barometric can lower it toward that 2-3m figure instead.\nexport const DEFAULT_ELEVATION_THRESHOLD_M = 8\n\n// Accumulates confirmed climbs/descents only once the *cumulative* rise from\n// the last confirmed reference point clears `thresholdM`, rejecting\n// oscillation within the noise band. Returns, per point, the confirmed\n// gain/loss amount attributed to that point (its \"ending point\", consistent\n// with the same convention used for HR zones and splits) so callers can slice\n// the totals by distance range without re-running the filter.\nfunction elevationHysteresis(\n  points: { elevation?: number }[],\n  thresholdM: number\n): { gainAtPoint: number[]; lossAtPoint: number[]; totalGainM: number; totalLossM: number } {\n  const gainAtPoint = new Array(points.length).fill(0)\n  const lossAtPoint = new Array(points.length).fill(0)\n  let ref: number | null = null\n  let totalGainM = 0\n  let totalLossM = 0\n\n  for (let i = 0; i < points.length; i++) {\n    const ele = points[i].elevation\n    if (ele == null) continue\n    if (ref == null) { ref = ele; continue }\n\n    const diff = ele - ref\n    if (diff >= thresholdM) {\n      gainAtPoint[i] = diff\n      totalGainM += diff\n      ref = ele\n    } else if (-diff >= thresholdM) {\n      lossAtPoint[i] = -diff\n      totalLossM += -diff\n      ref = ele\n    }\n    // else: within the noise band — ignore, keep ref\n  }\n\n  return { gainAtPoint, lossAtPoint, totalGainM, totalLossM }\n}\n\n// ---------------------------------------------------------------------------\n// Cumulative-series helpers (metrics-spec.md §2.3 / §4.2)\n// ---------------------------------------------------------------------------\n\n// Elapsed time at cumulative distance `x`, linearly interpolated within the\n// segment that contains it. `cumDist`/`cumTime` must be non-decreasing and\n// the same length, with index 0 = the activity's start (0, 0).\nfunction timeAt(x: number, cumDist: number[], cumTime: number[]): number {\n  const n = cumDist.length\n  if (x <= cumDist[0]) return cumTime[0]\n  if (x >= cumDist[n - 1]) return cumTime[n - 1]\n\n  let lo = 1\n  let hi = n - 1\n  while (lo < hi) {\n    const mid = (lo + hi) >> 1\n    if (cumDist[mid] < x) lo = mid + 1\n    else hi = mid\n  }\n  const segLen = cumDist[lo] - cumDist[lo - 1]\n  const f = segLen > 0 ? (x - cumDist[lo - 1]) / segLen : 0\n  return cumTime[lo - 1] + f * (cumTime[lo] - cumTime[lo - 1])\n}\n\n// Fastest 1000m window anywhere in the activity (metrics-spec.md §2), found\n// by scanning the finite candidate set of breakpoints where a window edge\n// coincides with a recorded point — the minimum of the continuous\n// timeAt(s+1000) - timeAt(s) function is always attained at one of these, so\n// a continuous scan isn't needed. Computed from the cumulative series only\n// — independent of splits[] (§2.4) — so it can be faster than (never slower\n// than) the fastest full split, since it isn't quantised to km marks.\nfunction computeBestKmPaceSecPerKm(cumDist: number[], cumTime: number[]): number | null {\n  const total = cumDist[cumDist.length - 1]\n  if (total < 1000) return null\n\n  const candidates = new Set<number>([0, total - 1000])\n  for (const d of cumDist) {\n    if (d + 1000 <= total) candidates.add(d)\n    if (d - 1000 >= 0) candidates.add(d - 1000)\n  }\n\n  let best = Infinity\n  for (const s of candidates) {\n    const windowTime = timeAt(s + 1000, cumDist, cumTime) - timeAt(s, cumDist, cumTime)\n    if (windowTime < best) best = windowTime\n  }\n  return Math.round(best)\n}\n\n// Splits at exact 1000m marks, carrying any overshoot forward instead of\n// resetting at the emitting segment's own (drifting) distance, plus a\n// trailing partial split for any remainder under 1000m (metrics-spec.md §3 +\n// §4). Per-split elevation gain and average HR are attributed to whichever\n// split contains each segment's *ending* point (same convention as HR zones,\n// §1.2), not interpolated — the spec calls exact interpolation there\n// unnecessary precision.\nfunction buildSplits(\n  pts: { heartRate?: number }[],\n  cumDist: number[],\n  cumTime: number[],\n  gainAtPoint: number[]\n): Split[] {\n  const total = cumDist[cumDist.length - 1]\n  const splits: Split[] = []\n\n  let mark = 0\n  let km = 1\n  while (mark + 1000 <= total) {\n    const t0 = timeAt(mark, cumDist, cumTime)\n    const t1 = timeAt(mark + 1000, cumDist, cumTime)\n    splits.push({ km: km++, distanceM: 1000, paceSecPerKm: Math.round(t1 - t0), elevationGainM: 0 })\n    mark += 1000\n  }\n\n  // Trailing partial (§3.2/§3.3): epsilon guards against floating-point dust\n  // emitting a spurious 0m split when total is an exact multiple of 1000.\n  if (total - mark > 1e-6) {\n    const remainderM = total - mark\n    const remainderTimeSec = timeAt(total, cumDist, cumTime) - timeAt(mark, cumDist, cumTime)\n    const pace = remainderTimeSec / (remainderM / 1000)\n    splits.push({ km: km++, distanceM: Math.round(remainderM), paceSecPerKm: Math.round(pace), elevationGainM: 0 })\n  }\n\n  const numSplits = splits.length\n  const hrSum = new Array(numSplits).fill(0)\n  const hrCount = new Array(numSplits).fill(0)\n  const elevSum = new Array(numSplits).fill(0)\n  for (let i = 1; i < cumDist.length; i++) {\n    const idx = Math.ceil(cumDist[i] / 1000) - 1\n    if (idx < 0 || idx >= numSplits) continue\n    elevSum[idx] += gainAtPoint[i]\n    const hr = pts[i].heartRate\n    if (hr != null) { hrSum[idx] += hr; hrCount[idx]++ }\n  }\n  for (let k = 0; k < numSplits; k++) {\n    splits[k].elevationGainM = Math.round(elevSum[k])\n    if (hrCount[k] > 0) splits[k].avgHeartRate = Math.round(hrSum[k] / hrCount[k])\n  }\n\n  return splits\n}\n\n// ---------------------------------------------------------------------------\n// Main analyzer\n// ---------------------------------------------------------------------------\n\n// Guards Activity.deviceDistanceM the same way item 2 guards the per-point\n// device distance stream: a value that isn't plausibly \"this activity's\n// total\" is treated as absent rather than surfaced as-is. A total of 0 means\n// the device didn't record one (most formats omit the field entirely when\n// they have nothing to say, but some emit a zeroed placeholder). A total\n// smaller than the point-stream distance can't be this activity's total\n// either — the device's own total is always at least as large as what was\n// recorded between the first and last point (see Activity.deviceDistanceM\n// for why it's often larger, never smaller, when genuine).\nfunction resolveDeviceDistanceM(raw: number | undefined, pointStreamDistanceM: number): number | undefined {\n  if (raw == null || raw <= 0) return undefined\n  if (raw < pointStreamDistanceM) return undefined\n  return raw\n}\n\n// Resolve the activity's elevation gain/loss and record which source produced\n// them (metrics-spec.md §5.3 step 1 + §5.6). The device's own session-computed\n// total (FIT session.total_ascent/total_descent, surfaced as\n// Activity.deviceElevationGainM/LossM) is preferred when the file carries one:\n// it is barometric/fused, filtered on-device, and the figure Garmin Connect\n// and Strava agree with. Otherwise fall back to the GPS-altitude hysteresis\n// totals passed in.\nfunction resolveElevation(\n  activity: Activity,\n  hystGainM: number,\n  hystLossM: number,\n  hasAltitudeStream: boolean,\n): { gainM: number; lossM: number; source: 'device' | 'computed' } {\n  const deviceGain = activity.deviceElevationGainM\n  if (deviceGain == null) {\n    return { gainM: hystGainM, lossM: hystLossM, source: 'computed' }\n  }\n  // Zero-guard: a device total_ascent of 0 on a track that plainly climbs —\n  // a raw altitude stream is present *and* the hysteresis filter already\n  // found real gain — is a device that never populated the field, not a flat\n  // run, so fall back to the computed figure rather than report a false 0. A\n  // 0 with no altitude stream, or with a hysteresis gain of 0, is a genuinely\n  // flat activity and the device's 0 is honoured (source stays 'device').\n  if (deviceGain === 0 && hasAltitudeStream && hystGainM > 0) {\n    return { gainM: hystGainM, lossM: hystLossM, source: 'computed' }\n  }\n  return {\n    gainM: deviceGain,\n    lossM: activity.deviceElevationLossM ?? 0,\n    source: 'device',\n  }\n}\n\nexport interface AnalyzeOptions {\n  maxHR?: number\n  elevationThresholdM?: number\n  /** Zone model + boundaries for `hrZones`. Default: `{ type: 'hrmax' }` with\n   * boundaries {@link DEFAULT_ZONE_BOUNDARIES}, reproducing historical output. */\n  zoneModel?: HrZoneModel\n  /** Speed (m/s) below which a segment counts as paused, not moving. Default 0.3. */\n  pauseThresholdMps?: number\n}\n\nexport function analyze(activity: Activity, options?: AnalyzeOptions): ActivityStats\n/**\n * @deprecated positional arguments will be removed in 3.0.0; pass an options object\n */\nexport function analyze(activity: Activity, maxHR: number, elevationThresholdM?: number): ActivityStats\nexport function analyze(activity: Activity, arg2?: AnalyzeOptions | number, arg3?: number): ActivityStats {\n  const options: AnalyzeOptions = typeof arg2 === 'number' ? { maxHR: arg2, elevationThresholdM: arg3 } : (arg2 ?? {})\n  const {\n    maxHR = 190,\n    elevationThresholdM = DEFAULT_ELEVATION_THRESHOLD_M,\n    zoneModel = { type: 'hrmax' as const },\n    pauseThresholdMps = 0.3,\n  } = options\n\n  const zoneBoundaries = zoneModel.boundaries ?? DEFAULT_ZONE_BOUNDARIES\n  validateZoneBoundaries(zoneBoundaries)\n  validateHeartRateInputs(maxHR, zoneModel)\n\n  const pctOfMax = zoneModel.type === 'reserve'\n    ? (hr: number) => (hr - zoneModel.restingHR) / (maxHR - zoneModel.restingHR)\n    : (hr: number) => hr / maxHR\n\n  const pts = activity.points\n\n  // Elevation is resolved before the <2-point guard: the device totals are\n  // activity-level scalars, independent of how many points were recorded, so\n  // a sparse track that still carries a session total_ascent should surface\n  // it. The hysteresis pass also produces gainAtPoint, which splits[] needs.\n  const { gainAtPoint, totalGainM: hystGainM, totalLossM: hystLossM } =\n    elevationHysteresis(pts, elevationThresholdM)\n  const hasAltitudeStream = pts.some(p => p.elevation != null)\n  const { gainM: elevationGainM, lossM: elevationLossM, source: elevationSource } =\n    resolveElevation(activity, hystGainM, hystLossM, hasAltitudeStream)\n\n  if (pts.length < 2) {\n    return {\n      distanceM: 0, distanceSource: 'computed',\n      deviceDistanceM: resolveDeviceDistanceM(activity.deviceDistanceM, 0),\n      elapsedTimeSec: 0, movingTimeSec: 0,\n      avgPaceSecPerKm: 0, bestKmPaceSecPerKm: null,\n      elevationGainM: Math.round(elevationGainM), elevationLossM: Math.round(elevationLossM),\n      elevationSource,\n      avgHeartRate: null, maxHeartRate: null, hrZones: null,\n      avgCadence: null, splits: [],\n    }\n  }\n\n  let movingTimeSec = 0\n\n  // Cumulative distance series (metrics-spec.md §2.3), from the device's own\n  // distance stream when the whole track carries a usable one, else summed\n  // haversine. Built once here and reused for splits, best-km and the segment\n  // speeds below — every distance-derived metric shares this one source so\n  // they can never disagree (the elevation chart's x-axis reads it too, via\n  // stats.distanceSource). Per-segment distance is a difference of adjacent\n  // entries, never re-summed a second way.\n  const { cumDist, source: distanceSource } = cumulativeDistanceSeries(pts)\n\n  // Elapsed-time companion series, index 0 = 0, filled in the loop below.\n  const cumTime: number[] = [0]\n\n  // HR\n  const hrValues = pts.map(p => p.heartRate).filter((h): h is number => h != null)\n  const hasHR = hrValues.length > 0\n\n  // HR zone weighting (metrics-spec.md §1): if no point in the whole activity\n  // carries a timestamp, fall back to counting each segment as 1s (§1.5.3).\n  // Otherwise a segment with a missing/non-monotonic timestamp contributes 0s\n  // — it must NOT fall back to 1s, that would re-introduce the count bias\n  // for exactly the corrupt segments (§1.5.2).\n  const noTimestampsAtAll = pts.every(p => p.timestamp == null)\n  const hrZoneSegments: { heartRate?: number; weightSec: number }[] = []\n\n  // Cadence\n  const cadValues = pts.map(p => p.cadence).filter((c): c is number => c != null)\n  const hasCadence = cadValues.length > 0\n\n  for (let i = 1; i < pts.length; i++) {\n    const prev = pts[i - 1]\n    const curr = pts[i]\n\n    // Segment distance is a difference of the shared cumulative series, so it\n    // reflects whichever source won — never a second, independent haversine.\n    const segDist = cumDist[i] - cumDist[i - 1]\n\n    // Time delta\n    let segTimeSec = 1 // default 1s between points if no timestamps\n    if (prev.timestamp && curr.timestamp) {\n      segTimeSec = (curr.timestamp.getTime() - prev.timestamp.getTime()) / 1000\n    }\n    if (segTimeSec <= 0) segTimeSec = 1\n\n    const speedMps = segDist / segTimeSec\n    const isMoving = speedMps > pauseThresholdMps\n\n    if (isMoving) movingTimeSec += segTimeSec\n\n    cumTime.push(cumTime[i - 1] + segTimeSec)\n\n    // HR zone weight for this segment (attributed to curr, the ending sample)\n    let zoneWeightSec: number\n    if (noTimestampsAtAll) {\n      zoneWeightSec = 1\n    } else if (prev.timestamp == null || curr.timestamp == null) {\n      zoneWeightSec = 0\n    } else {\n      const rawDt = (curr.timestamp.getTime() - prev.timestamp.getTime()) / 1000\n      zoneWeightSec = rawDt > 0 ? rawDt : 0\n    }\n    hrZoneSegments.push({ heartRate: curr.heartRate, weightSec: zoneWeightSec })\n  }\n\n  const distanceM = cumDist[cumDist.length - 1]\n  const splits = buildSplits(pts, cumDist, cumTime, gainAtPoint)\n\n  const bestKmPace = computeBestKmPaceSecPerKm(cumDist, cumTime)\n\n  const elapsedTimeSec =\n    pts[0].timestamp && pts[pts.length - 1].timestamp\n      ? (pts[pts.length - 1].timestamp!.getTime() - pts[0].timestamp!.getTime()) / 1000\n      : movingTimeSec\n\n  const avgPaceSecPerKm = distanceM > 0 ? (movingTimeSec / (distanceM / 1000)) : 0\n\n  return {\n    distanceM: Math.round(distanceM),\n    distanceSource,\n    deviceDistanceM: resolveDeviceDistanceM(activity.deviceDistanceM, distanceM),\n    elapsedTimeSec: Math.round(elapsedTimeSec),\n    movingTimeSec: Math.round(movingTimeSec),\n    avgPaceSecPerKm: Math.round(avgPaceSecPerKm),\n    bestKmPaceSecPerKm: bestKmPace != null ? Math.round(bestKmPace) : null,\n    elevationGainM: Math.round(elevationGainM),\n    elevationLossM: Math.round(elevationLossM),\n    elevationSource,\n    avgHeartRate: hasHR ? Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length) : null,\n    // reduce, not Math.max(...hrValues): spreading a long HR array (a multi-hour\n    // activity at 1 Hz is tens of thousands of samples, some FIT files far more)\n    // into a call blows the argument-count/stack limit. avgHeartRate above uses\n    // the same single-pass style.\n    maxHeartRate: hasHR ? hrValues.reduce((m, h) => (h > m ? h : m), -Infinity) : null,\n    hrZones: hasHR ? hrZones(hrZoneSegments, pctOfMax, zoneBoundaries) : null,\n    avgCadence: hasCadence ? Math.round(cadValues.reduce((a, b) => a + b, 0) / cadValues.length) : null,\n    splits,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Formatting helpers (exported for CLI + reports)\n// ---------------------------------------------------------------------------\n\nexport function formatPace(secPerKm: number, units: 'metric' | 'imperial' = 'metric'): string {\n  const adjusted = units === 'imperial' ? secPerKm * 1.60934 : secPerKm\n  // Round to whole seconds first, then split, so 479.6s → 8:00 (not 7:60).\n  const totalSec = Math.round(adjusted)\n  const min = Math.floor(totalSec / 60)\n  const sec = totalSec % 60\n  const unit = units === 'imperial' ? '/mi' : '/km'\n  return `${min}:${sec.toString().padStart(2, '0')}${unit}`\n}\n\nexport function formatDistance(metres: number, units: 'metric' | 'imperial' = 'metric'): string {\n  if (units === 'imperial') return `${(metres / 1609.34).toFixed(2)} mi`\n  return `${(metres / 1000).toFixed(2)} km`\n}\n\nexport function formatDuration(seconds: number): string {\n  // Round to whole seconds *before* splitting so the carry propagates:\n  // 59.6s → 1:00, not 0:60 (same rule formatPace applies).\n  const totalSec = Math.round(seconds)\n  const h = Math.floor(totalSec / 3600)\n  const m = Math.floor((totalSec % 3600) / 60)\n  const s = totalSec % 60\n  if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`\n  return `${m}:${s.toString().padStart(2, '0')}`\n}\n"],"mappings":"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","names":[]}