trails/packages/gpx/src/elevation-series.ts
Ullrich Schäfer 536a8f98b9
journal-elevation-profile: elevation chart + map↔chart sync
Implements the journal-elevation-profile change (specs/journal-elevation-profile):

- gpx: `elevationSeries(tracks)` → { d, e, lat, lng }[] with cumulative distance,
  downsampled (keeps first/last), empty when <2 points carry elevation.
- ElevationProfile: read-only SVG area chart (vertical gradient fill), highest/
  lowest summary + a hover readout. Reports hovered index (onActive) and clicked
  index (onSeek); draws a marker at the active index. Renders nothing for an
  empty series.
- RouteMapThumbnail: ActiveMarker (CircleMarker at the chart's active point),
  HoverTracker (route hover → nearest sample → onHoverIndex), Recenter (panTo on
  chart click). Props forwarded through ClientMap.
- Wired into the activity + route detail pages via a shared activeIndex/centerOn
  state; loaders expose the series (activity reuses the moving-time parse).
- i18n journal.elevation.{highest,lowest} in en + de.

Ascent/descent stay in the stat row (#532); the chart summary shows highest/
lowest to avoid duplication.

Tests: elevationSeries unit; ElevationProfile component (jsdom); e2e creates an
activity from an elevation GPX and asserts the chart renders. typecheck + lint +
unit (gpx 67, journal 315) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:34:45 +02:00

58 lines
2 KiB
TypeScript

import type { TrackPoint } from "./types.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[] {
const withEle: TrackPoint[] = [];
for (const seg of tracks) {
for (const p of seg) {
if (typeof p.ele === "number") withEle.push(p);
}
}
if (withEle.length < 2) return [];
const full: ElevationSample[] = [];
let cum = 0;
let prev: TrackPoint | null = null;
for (const p of withEle) {
if (prev) cum += haversineMeters(prev.lat, prev.lon, p.lat, p.lon);
full.push({ d: cum, e: p.ele as number, lat: p.lat, lng: p.lon });
prev = p;
}
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;
}