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>
This commit is contained in:
parent
5e6fcde281
commit
536a8f98b9
16 changed files with 509 additions and 27 deletions
43
packages/gpx/src/elevation-series.test.ts
Normal file
43
packages/gpx/src/elevation-series.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { elevationSeries } from "./elevation-series.ts";
|
||||
import type { TrackPoint } from "./types.ts";
|
||||
|
||||
function pt(lat: number, lon: number, ele?: number): TrackPoint {
|
||||
return { lat, lon, ele };
|
||||
}
|
||||
|
||||
describe("elevationSeries", () => {
|
||||
it("returns empty when fewer than two points carry elevation", () => {
|
||||
expect(elevationSeries([[pt(0, 0), pt(0, 0.001)]])).toEqual([]);
|
||||
expect(elevationSeries([[pt(0, 0, 100)]])).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds cumulative distance, elevation and position", () => {
|
||||
const s = elevationSeries([[pt(0, 0, 100), pt(0, 0.001, 110), pt(0, 0.002, 105)]]);
|
||||
expect(s).toHaveLength(3);
|
||||
expect(s[0]!.d).toBe(0);
|
||||
expect(s[0]!.e).toBe(100);
|
||||
expect(s[1]!.d).toBeGreaterThan(0);
|
||||
expect(s[2]!.d).toBeGreaterThan(s[1]!.d);
|
||||
expect(s[2]!.e).toBe(105);
|
||||
expect(s[0]!.lat).toBe(0);
|
||||
});
|
||||
|
||||
it("flattens multiple track segments", () => {
|
||||
const s = elevationSeries([
|
||||
[pt(0, 0, 100), pt(0, 0.001, 110)],
|
||||
[pt(0, 0.002, 120), pt(0, 0.003, 130)],
|
||||
]);
|
||||
expect(s).toHaveLength(4);
|
||||
expect(s.map((x) => x.e)).toEqual([100, 110, 120, 130]);
|
||||
});
|
||||
|
||||
it("downsamples to at most maxPoints, keeping first and last", () => {
|
||||
const seg: TrackPoint[] = [];
|
||||
for (let i = 0; i < 1000; i++) seg.push(pt(0, i * 0.0001, 100 + i));
|
||||
const s = elevationSeries([seg], 100);
|
||||
expect(s.length).toBeLessThanOrEqual(101);
|
||||
expect(s[0]!.e).toBe(100);
|
||||
expect(s[s.length - 1]!.e).toBe(1099);
|
||||
});
|
||||
});
|
||||
58
packages/gpx/src/elevation-series.ts
Normal file
58
packages/gpx/src/elevation-series.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -4,4 +4,6 @@ export { extractWaypoints } from "./waypoints.ts";
|
|||
export { computeDays } from "./compute-days.ts";
|
||||
export type { DayStage } from "./compute-days.ts";
|
||||
export { movingTime } from "./moving-time.ts";
|
||||
export { elevationSeries } from "./elevation-series.ts";
|
||||
export type { ElevationSample } from "./elevation-series.ts";
|
||||
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue