trails/packages/gpx/src/elevation-series.ts
Ullrich Schäfer 29be9cbe5f
feat(gpx): apply elevation cleaning to totals + profile chart
Task group 2 (part a) of elevation-profile-hardening — wire the group-1
primitives into the headline stat and the chart.

- computeElevation (parse.ts): despike each segment's elevation sequence,
  then take filteredTotals; gain/loss are now noise-filtered (no more
  20–50% inflation from per-point jitter), the profile is built from the
  despiked data, and additive gainRaw/lossRaw expose the unfiltered sums.
- elevationSeries: despike per segment (no cross-trkseg interpolation)
  before downsampling, so the chart and the headline totals derive from
  the same cleaned data.
- types.ts: GpxData.elevation gains gainRaw/lossRaw (additive).

Well-formed monotonic climbs are unchanged (steps exceed the 5 m
threshold → filtered == raw); a shared noisy-track test proves jitter is
filtered and a 300 m single-point spike is interpolated out of both the
totals and the chart.

Verified: gpx 111/111; full pnpm typecheck 13/13, lint 13/13, test 11/11
(journal + planner compile with the additive fields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:56:36 +02:00

83 lines
3 KiB
TypeScript

import type { TrackPoint } from "./types.ts";
import { despike } from "./elevation-clean.ts";
export interface ElevationSample {
/** Cumulative distance from start, metres */
d: number;
/** Elevation, metres */
e: number;
lat: number;
lng: number;
}
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Build an elevation series — cumulative distance + elevation + position per
* sample — for the elevation profile chart and its map↔chart sync. Returns an
* empty array when the track has fewer than two points carrying elevation
* (planned routes / imports without elevation), so callers can omit the chart.
* The result is downsampled to at most `maxPoints` for chart performance, always
* keeping the first and last sample.
*/
export function elevationSeries(tracks: TrackPoint[][], maxPoints = 400): ElevationSample[] {
// Elevation-bearing points, tagged with their segment so despiking never
// interpolates across a trkseg gap.
const withEle: Array<{ p: TrackPoint; seg: number }> = [];
tracks.forEach((seg, si) => {
for (const p of seg) if (typeof p.ele === "number") withEle.push({ p, seg: si });
});
if (withEle.length < 2) return [];
// Cumulative distance across consecutive elevation points (chart x-axis).
const dists: number[] = [];
let cum = 0;
for (let i = 0; i < withEle.length; i++) {
if (i > 0) {
cum += haversineMeters(
withEle[i - 1]!.p.lat, withEle[i - 1]!.p.lon, withEle[i]!.p.lat, withEle[i]!.p.lon,
);
}
dists.push(cum);
}
// Despike each segment's slice so the chart matches the headline stats
// (spec: elevation-profile-hardening). Only elevation is cleaned; distance
// and position are untouched.
const cleanE = new Array<number>(withEle.length);
for (let start = 0; start < withEle.length; ) {
let end = start;
while (end + 1 < withEle.length && withEle[end + 1]!.seg === withEle[start]!.seg) end++;
const cleaned = despike(
withEle.slice(start, end + 1).map((x, i) => ({ distance: dists[start + i]!, elevation: x.p.ele as number })),
);
for (let i = 0; i < cleaned.length; i++) cleanE[start + i] = cleaned[i]!.elevation;
start = end + 1;
}
const full: ElevationSample[] = withEle.map((x, i) => ({
d: dists[i]!,
e: cleanE[i]!,
lat: x.p.lat,
lng: x.p.lon,
}));
if (full.length <= maxPoints) return full;
// Stride downsample, preserving first and last.
const stride = Math.ceil(full.length / maxPoints);
const out: ElevationSample[] = [];
for (let i = 0; i < full.length; i += stride) out.push(full[i]!);
const last = full[full.length - 1]!;
if (out[out.length - 1] !== last) out.push(last);
return out;
}