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:
Ullrich Schäfer 2026-06-12 15:34:45 +02:00
parent 5e6fcde281
commit 536a8f98b9
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
16 changed files with 509 additions and 27 deletions

View file

@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { ElevationProfile } from "./ElevationProfile.tsx";
import type { ElevationSample } from "@trails-cool/gpx";
afterEach(cleanup);
const labels = { highest: "Highest", lowest: "Lowest" };
const series: ElevationSample[] = [
{ d: 0, e: 100, lat: 0, lng: 0 },
{ d: 500, e: 160, lat: 0, lng: 0.005 },
{ d: 1000, e: 120, lat: 0, lng: 0.01 },
];
describe("ElevationProfile", () => {
it("renders nothing for a too-short series", () => {
const { container } = render(
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).toBeNull();
});
it("renders the chart and highest/lowest summary", () => {
const { container, getByText } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("svg")).not.toBeNull();
// area + line paths
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2);
expect(getByText("160 m")).toBeTruthy(); // highest
expect(getByText("100 m")).toBeTruthy(); // lowest
});
it("draws an active marker when activeIndex is set", () => {
const { container } = render(
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} onSeek={() => {}} labels={labels} />,
);
expect(container.querySelector("circle")).not.toBeNull();
expect(container.querySelector("line")).not.toBeNull();
});
it("reports seek on pointer down", () => {
const onSeek = vi.fn();
const { container } = render(
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={onSeek} labels={labels} />,
);
const svg = container.querySelector("svg")!;
// jsdom getBoundingClientRect returns zeros; we only assert the handler fires.
fireEvent.pointerDown(svg, { clientX: 10 });
expect(onSeek).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,122 @@
import { useRef } from "react";
import type { ElevationSample } from "@trails-cool/gpx";
import { formatElevationM, formatDistanceKm } from "~/lib/stats";
const W = 1000;
const H = 220;
const PAD = { top: 16, right: 8, bottom: 22, left: 46 };
const PLOT_W = W - PAD.left - PAD.right;
const PLOT_H = H - PAD.top - PAD.bottom;
/**
* Read-only elevation profile chart (SVG, responsive via viewBox). Distance on
* x, elevation on y, with a gradient area fill. Reports the hovered sample
* index via `onActive` (for the map marker) and the clicked index via `onSeek`
* (centre the map), and draws a marker at `activeIndex` (set by the map when the
* route line is hovered). Renders nothing for an empty/too-short series.
*/
export function ElevationProfile({
series,
activeIndex,
onActive,
onSeek,
labels,
className,
}: {
series: ElevationSample[];
activeIndex: number | null;
onActive: (index: number | null) => void;
onSeek: (index: number) => void;
labels: { highest: string; lowest: string };
className?: string;
}) {
const ref = useRef<SVGSVGElement>(null);
if (series.length < 2) return null;
const maxD = series[series.length - 1]!.d || 1;
let minE = Infinity;
let maxE = -Infinity;
for (const s of series) {
if (s.e < minE) minE = s.e;
if (s.e > maxE) maxE = s.e;
}
const eRange = Math.max(1, maxE - minE);
const baseY = PAD.top + PLOT_H;
const x = (d: number) => PAD.left + (d / maxD) * PLOT_W;
const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H;
const linePath = series
.map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`)
.join(" ");
const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`;
const active = activeIndex != null ? series[activeIndex] : null;
function indexFromClientX(clientX: number): number {
const rect = ref.current!.getBoundingClientRect();
const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units
const d = ((px - PAD.left) / PLOT_W) * maxD;
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const delta = Math.abs(series[i]!.d - d);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
}
return (
<div className={className}>
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span>
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
{" · "}
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
</span>
{active && (
<span className="tabular-nums">
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
</span>
)}
</div>
<svg
ref={ref}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
className="h-40 w-full touch-none select-none"
role="img"
aria-label="Elevation profile"
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
onPointerLeave={() => onActive(null)}
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
>
<defs>
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#elev-fill)" />
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{active && (
<g>
<line
x1={x(active.d)}
y1={PAD.top}
x2={x(active.d)}
y2={baseY}
stroke="#9ca3af"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
</g>
)}
</svg>
</div>
);
}

View file

@ -1,9 +1,63 @@
import { useEffect, useRef } from "react";
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet";
import L from "leaflet";
import type { GeoJsonObject } from "geojson";
import "leaflet/dist/leaflet.css";
/** Marker shown at the position the elevation chart is pointing at. */
function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) {
if (!point) return null;
return (
<CircleMarker
center={[point.lat, point.lng]}
radius={6}
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
/>
);
}
/** Reports the route sample nearest the cursor so the chart can highlight it. */
function HoverTracker({
series,
onHoverIndex,
}: {
series: Array<[number, number]>;
onHoverIndex: (index: number | null) => void;
}) {
useMapEvents({
mousemove(e) {
const { lat, lng } = e.latlng;
let best = -1;
let bestDelta = Infinity;
for (let i = 0; i < series.length; i++) {
const [slat, slng] = series[i]!;
const delta = (slat - lat) ** 2 + (slng - lng) ** 2;
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
onHoverIndex(best >= 0 ? best : null);
},
mouseout() {
onHoverIndex(null);
},
});
return null;
}
/** Pans the map when the chart is clicked (centerOn.v bumps per click). */
function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) {
const map = useMap();
const lastV = useRef<number | null>(null);
useEffect(() => {
if (!centerOn || centerOn.v === lastV.current) return;
lastV.current = centerOn.v;
map.panTo([centerOn.lat, centerOn.lng], { animate: true });
}, [centerOn, map]);
return null;
}
function FitBounds({ data }: { data: GeoJsonObject }) {
const map = useMap();
const fitted = useRef(false);
@ -80,9 +134,24 @@ interface RouteMapProps {
dayBreaks?: number[];
/** 1-based day number to highlight, or null for no highlight */
highlightedDay?: number | null;
/** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */
activePoint?: { lat: number; lng: number } | null;
hoverSeries?: Array<[number, number]>;
onHoverIndex?: (index: number | null) => void;
centerOn?: { lat: number; lng: number; v: number } | null;
}
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
export function RouteMapThumbnail({
geojson,
interactive,
className,
dayBreaks,
highlightedDay,
activePoint,
hoverSeries,
onHoverIndex,
centerOn,
}: RouteMapProps) {
const data: GeoJsonObject = JSON.parse(geojson);
return (
@ -114,6 +183,11 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks,
fullData={data}
/>
)}
<ActiveMarker point={activePoint} />
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
)}
<Recenter centerOn={centerOn} />
</MapContainer>
);
}

View file

@ -13,7 +13,8 @@ import {
import { deleteImportByActivity } from "~/lib/sync/imports.server";
import { listRoutes } from "~/lib/routes.server";
import { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.server";
import { parseGpxAsync, movingTime } from "@trails-cool/gpx";
import { parseGpxAsync, movingTime, elevationSeries } from "@trails-cool/gpx";
import type { ElevationSample } from "@trails-cool/gpx";
import { logger } from "~/lib/logger.server";
import type { Visibility } from "@trails-cool/db/schema/journal";
@ -38,15 +39,17 @@ export async function loadActivityDetail(request: Request, id: string | undefine
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
// Moving time is derived from trackpoint timestamps (null when the GPX has
// none). Detail-page only — too costly to parse per row in list views.
// Moving time + the elevation series are both derived from the GPX (one
// parse). Detail-page only — too costly to parse per row in list views.
let movingTimeSec: number | null = null;
let elevation: ElevationSample[] = [];
if (activity.gpx) {
try {
const parsed = await parseGpxAsync(activity.gpx);
movingTimeSec = movingTime(parsed.tracks);
elevation = elevationSeries(parsed.tracks);
} catch (err) {
logger.warn({ activityId: activity.id, err }, "moving-time: failed to parse gpx");
logger.warn({ activityId: activity.id, err }, "activity detail: failed to parse gpx");
}
}
@ -61,6 +64,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine
elevationLoss: activity.elevationLoss,
duration: activity.duration,
movingTimeSec,
elevation,
routeId: activity.routeId,
hasGpx: !!activity.gpx,
geojson: activity.geojson ?? null,

View file

@ -1,3 +1,4 @@
import { useMemo, useState } from "react";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
@ -5,6 +6,7 @@ import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { SportBadge } from "~/components/SportBadge";
import { StatRow } from "~/components/StatRow";
import { ElevationProfile } from "~/components/ElevationProfile";
import { activityStatItems } from "~/lib/stats";
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
import {
@ -62,6 +64,24 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
const { activity, isOwner, routes } = loaderData;
const { t } = useTranslation("journal");
// Elevation profile ↔ map sync via a shared "active" sample index.
const elevation = activity.elevation;
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null);
const hoverSeries = useMemo(
() => elevation.map((s) => [s.lat, s.lng] as [number, number]),
[elevation],
);
const activePoint =
activeIndex != null && elevation[activeIndex]
? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng }
: null;
const seek = (i: number) => {
const s = elevation[i];
if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 }));
setActiveIndex(i);
};
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="flex items-center gap-3">
@ -99,10 +119,29 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
{activity.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<ClientMap geojson={activity.geojson} interactive className="h-full w-full" />
<ClientMap
geojson={activity.geojson}
interactive
className="h-full w-full"
activePoint={activePoint}
hoverSeries={hoverSeries}
onHoverIndex={setActiveIndex}
centerOn={centerOn}
/>
</div>
)}
{elevation.length > 1 && (
<ElevationProfile
className="mt-4"
series={elevation}
activeIndex={activeIndex}
onActive={setActiveIndex}
onSeek={seek}
labels={{ highest: t("elevation.highest"), lowest: t("elevation.lowest") }}
/>
)}
{activity.routeId && (
<div className="mt-6">
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">

View file

@ -9,7 +9,8 @@ import { requireOwnedRoute } from "~/lib/ownership.server";
import { getDb } from "~/lib/db";
import { syncPushes } from "@trails-cool/db/schema/journal";
import { getService } from "~/lib/connected-services";
import { computeDays, parseGpxAsync } from "@trails-cool/gpx";
import { computeDays, parseGpxAsync, elevationSeries } from "@trails-cool/gpx";
import type { ElevationSample } from "@trails-cool/gpx";
export async function loadRouteDetail(request: Request, id: string | undefined) {
const routeId = id ?? "";
@ -32,9 +33,11 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
// Parse GPX once for day stats and waypoint POI data
let dayStats: Array<{ dayNumber: number; startName?: string; endName?: string; distance: number; ascent: number; descent: number }> = [];
let waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
let elevation: ElevationSample[] = [];
if (route.gpx) {
try {
const gpxData = await parseGpxAsync(route.gpx);
elevation = elevationSeries(gpxData.tracks);
waypoints = gpxData.waypoints.map((w) => ({
lat: w.lat,
lon: w.lon,
@ -122,6 +125,7 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
routingProfile: route.routingProfile,
hasGpx: !!route.gpx,
dayBreaks: route.dayBreaks ?? [],
elevation,
geojson: routeWithGeojson?.geojson ?? null,
visibility: route.visibility,
createdAt: route.createdAt.toISOString(),

View file

@ -1,10 +1,11 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useMemo } from "react";
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/routes.$id";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { StatRow } from "~/components/StatRow";
import { ElevationProfile } from "~/components/ElevationProfile";
import { activityStatItems } from "~/lib/stats";
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
@ -48,6 +49,24 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
const [editLoading, setEditLoading] = useState(false);
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
// Elevation profile ↔ map sync via a shared "active" sample index.
const elevation = route.elevation;
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [centerOn, setCenterOn] = useState<{ lat: number; lng: number; v: number } | null>(null);
const hoverSeries = useMemo(
() => elevation.map((s) => [s.lat, s.lng] as [number, number]),
[elevation],
);
const activePoint =
activeIndex != null && elevation[activeIndex]
? { lat: elevation[activeIndex]!.lat, lng: elevation[activeIndex]!.lng }
: null;
const seekElevation = (i: number) => {
const s = elevation[i];
if (s) setCenterOn((prev) => ({ lat: s.lat, lng: s.lng, v: (prev?.v ?? 0) + 1 }));
setActiveIndex(i);
};
const pushStatus = typeof window !== "undefined"
? new URLSearchParams(window.location.search).get("push")
: null;
@ -288,10 +307,31 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
{route.geojson && (
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
<ClientMap geojson={route.geojson} interactive className="h-full w-full" dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} />
<ClientMap
geojson={route.geojson}
interactive
className="h-full w-full"
dayBreaks={route.dayBreaks.length > 0 ? route.dayBreaks : undefined}
highlightedDay={highlightedDay}
activePoint={activePoint}
hoverSeries={hoverSeries}
onHoverIndex={setActiveIndex}
centerOn={centerOn}
/>
</div>
)}
{elevation.length > 1 && (
<ElevationProfile
className="mt-4"
series={elevation}
activeIndex={activeIndex}
onActive={setActiveIndex}
onSeek={seekElevation}
labels={{ highest: t("elevation.highest"), lowest: t("elevation.lowest") }}
/>
)}
{isEmpty && (
<div className="mt-6 flex flex-col items-center rounded-lg border border-dashed border-gray-300 bg-gray-50 px-6 py-12 text-center">
<div className="text-4xl" aria-hidden="true">🗺</div>