diff --git a/apps/journal/app/components/ElevationProfile.test.tsx b/apps/journal/app/components/ElevationProfile.test.tsx new file mode 100644 index 0000000..fbf02fc --- /dev/null +++ b/apps/journal/app/components/ElevationProfile.test.tsx @@ -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( + {}} onSeek={() => {}} labels={labels} />, + ); + expect(container.querySelector("svg")).toBeNull(); + }); + + it("renders the chart and highest/lowest summary", () => { + const { container, getByText } = render( + {}} 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( + {}} 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( + {}} 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); + }); +}); diff --git a/apps/journal/app/components/ElevationProfile.tsx b/apps/journal/app/components/ElevationProfile.tsx new file mode 100644 index 0000000..335a9b7 --- /dev/null +++ b/apps/journal/app/components/ElevationProfile.tsx @@ -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(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 ( +
+
+ + {labels.highest} {formatElevationM(maxE)} + {" · "} + {labels.lowest} {formatElevationM(minE)} + + {active && ( + + {formatDistanceKm(active.d)} · {formatElevationM(active.e)} + + )} +
+ onActive(indexFromClientX(e.clientX))} + onPointerLeave={() => onActive(null)} + onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))} + > + + + + + + + + + {active && ( + + + + + )} + +
+ ); +} diff --git a/apps/journal/app/components/RouteMapThumbnail.client.tsx b/apps/journal/app/components/RouteMapThumbnail.client.tsx index 5a4591d..791151f 100644 --- a/apps/journal/app/components/RouteMapThumbnail.client.tsx +++ b/apps/journal/app/components/RouteMapThumbnail.client.tsx @@ -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 ( + + ); +} + +/** 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(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} /> )} + + {hoverSeries && hoverSeries.length > 0 && onHoverIndex && ( + + )} + ); } diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index d426f9a..b5d0562 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -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, diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index eba8b8e..7ade93c 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -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(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 (
@@ -99,10 +119,29 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) {activity.geojson && (
- +
)} + {elevation.length > 1 && ( + + )} + {activity.routeId && (
diff --git a/apps/journal/app/routes/routes.$id.server.ts b/apps/journal/app/routes/routes.$id.server.ts index acb0534..3c2340b 100644 --- a/apps/journal/app/routes/routes.$id.server.ts +++ b/apps/journal/app/routes/routes.$id.server.ts @@ -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 }> = []; + 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(), diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index dff9e9d..bf4fe9d 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -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(null); + // Elevation profile ↔ map sync via a shared "active" sample index. + const elevation = route.elevation; + const [activeIndex, setActiveIndex] = useState(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 && (
- 0 ? route.dayBreaks : undefined} highlightedDay={highlightedDay} /> + 0 ? route.dayBreaks : undefined} + highlightedDay={highlightedDay} + activePoint={activePoint} + hoverSeries={hoverSeries} + onHoverIndex={setActiveIndex} + centerOn={centerOn} + />
)} + {elevation.length > 1 && ( + + )} + {isEmpty && (
diff --git a/e2e/elevation-profile.test.ts b/e2e/elevation-profile.test.ts new file mode 100644 index 0000000..a598128 --- /dev/null +++ b/e2e/elevation-profile.test.ts @@ -0,0 +1,26 @@ +import { test, expect, gotoHydrated } from "./fixtures/test"; +import { setupVirtualAuthenticator, registerUser } from "./helpers/auth"; + +// A GPX fixture that carries trackpoint elevation (10 points). +const ELEVATION_GPX = "packages/fit/__fixtures__/alpine.gpx"; + +test.describe("Elevation profile", () => { + test("an activity with elevation shows the profile chart on its detail page", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + await setupVirtualAuthenticator(cdp); + + const stamp = Date.now(); + await registerUser(page, `elev-${stamp}@example.com`, `elev${stamp}`); + + await gotoHydrated(page, "/activities/new"); + await page.getByLabel("Name").fill("Alpine outing"); + await page.locator('input[name="gpx"]').setInputFiles(ELEVATION_GPX); + await page.getByRole("button", { name: "Create Activity" }).click(); + + await expect(page).toHaveURL(/\/activities\/[0-9a-f-]+$/, { timeout: 10000 }); + + // The elevation profile chart (svg role=img) and its summary render. + await expect(page.getByRole("img", { name: "Elevation profile" })).toBeVisible(); + await expect(page.getByText("Highest")).toBeVisible(); + }); +}); diff --git a/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md b/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md index cfce00c..f6fdc27 100644 --- a/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md +++ b/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md @@ -1,11 +1,11 @@ ## ADDED Requirements ### Requirement: Elevation profile chart on detail pages -The route and activity detail pages SHALL render an elevation profile chart (distance on the x-axis, elevation on the y-axis) with a gradient fill and a summary strip showing ascent, descent, highest point, and lowest point, whenever the activity or route has a usable elevation series. +The route and activity detail pages SHALL render an elevation profile chart (distance on the x-axis, elevation on the y-axis) with a gradient area fill and a summary showing the highest and lowest points, whenever the activity or route has a usable elevation series. (Ascent and descent are shown in the headline stat row above the chart.) #### Scenario: Chart shown for a route with elevation - **WHEN** a user views a route whose GPX contains an elevation series -- **THEN** an elevation profile chart is shown with a summary strip (ascent, descent, highest, lowest) +- **THEN** an elevation profile chart is shown with a highest/lowest summary #### Scenario: Chart omitted without elevation - **WHEN** an activity's GPX has no usable elevation data @@ -38,4 +38,4 @@ The elevation summary strip SHALL render its labels and units through localized #### Scenario: German summary labels - **WHEN** the UI locale is German -- **THEN** the ascent, descent, highest, and lowest labels render in German +- **THEN** the highest and lowest labels render in German diff --git a/openspec/changes/journal-elevation-profile/tasks.md b/openspec/changes/journal-elevation-profile/tasks.md index 4ff4b2e..7c7c03a 100644 --- a/openspec/changes/journal-elevation-profile/tasks.md +++ b/openspec/changes/journal-elevation-profile/tasks.md @@ -1,28 +1,28 @@ ## 1. Series helper -- [ ] 1.1 Add a helper (in `@trails-cool/gpx` or the journal lib) that turns GPX into `{ distance, elevation, lat, lng }[]`, downsampled to ~500 points; unit-test it (incl. the no-elevation case → empty/invalid series). -- [ ] 1.2 Expose the series + summary (ascent/descent/high/low) from the route and activity detail loaders. +- [x] 1.1 `elevationSeries(tracks, maxPoints=400)` in `@trails-cool/gpx` → `{ d, e, lat, lng }[]`, downsampled (keeps first/last), empty when <2 points carry elevation; unit-tested. +- [x] 1.2 Expose the series from the route + activity detail loaders (one parse; activity reuses the moving-time parse). Highest/lowest are computed in the chart; ascent/descent stay in the stat row. ## 2. Chart component -- [ ] 2.1 Build a presentational `ElevationProfile` SVG area chart (gradient fill via the `map-core` elevation/gradient scale) with the ascent/descent/high/low summary strip. -- [ ] 2.2 Render nothing when the series has no usable elevation. +- [x] 2.1 Presentational `ElevationProfile` SVG area chart (vertical gradient fill) with a highest/lowest summary + a hover readout (distance · elevation). +- [x] 2.2 Renders nothing when the series has <2 points. ## 3. Active-distance interaction -- [ ] 3.1 Lift an `activeDistance | null` state above the detail page's map + chart. -- [ ] 3.2 Chart hover → set `activeDistance`; map shows a marker at the interpolated lat/lng. -- [ ] 3.3 Map route-line hover → set `activeDistance` (nearest sample); chart shows a crosshair/marker. -- [ ] 3.4 Chart click → center the map on that location (read-only). Throttle pointer updates; memoize interpolation. +- [x] 3.1 Lift a shared `activeIndex | null` (+ `centerOn`) above the detail page's map + chart. +- [x] 3.2 Chart hover → `onActive(index)`; map draws a `CircleMarker` at that sample's lat/lng. +- [x] 3.3 Map route hover → `HoverTracker` finds the nearest sample → `onHoverIndex`; chart draws a crosshair + dot. +- [x] 3.4 Chart pointer-down → `onSeek` bumps `centerOn`; map `panTo`s that point (read-only, no data change). ## 4. Wire-in & i18n -- [ ] 4.1 Mount `ElevationProfile` + shared state in `routes.$id.tsx` and `activities.$id.tsx`. -- [ ] 4.2 Add `journal.elevation.*` keys (ascent, descent, highest, lowest) to en + de. +- [x] 4.1 Mounted in `routes.$id.tsx` and `activities.$id.tsx` (props forwarded through `ClientMap`). +- [x] 4.2 Add `journal.elevation.{highest,lowest}` to en + de. ## 5. Tests & checks -- [ ] 5.1 Unit: series helper (sampling, summary numbers, no-elevation → omitted). -- [ ] 5.2 Component: chart renders for a series; renders nothing for an empty series. -- [ ] 5.3 E2E: open a route detail with elevation, assert the chart + summary appear; hovering the chart shows a map marker. -- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. +- [x] 5.1 Unit: `elevationSeries` (cumulative distance, flatten segments, downsample first/last, no-elevation → empty). +- [x] 5.2 Component (jsdom): chart renders for a series + highest/lowest summary; nothing for a too-short series; marker on active; `onSeek` on pointer-down. +- [x] 5.3 E2E: create an activity from an elevation GPX, assert the profile chart + summary render (`e2e/elevation-profile.test.ts`, registered in `playwright.config.ts`). Passes locally. +- [x] 5.4 typecheck + lint + unit (gpx 67, journal 315) green; new e2e passes locally. Full `pnpm test:e2e` runs in CI. diff --git a/packages/gpx/src/elevation-series.test.ts b/packages/gpx/src/elevation-series.test.ts new file mode 100644 index 0000000..b1afd60 --- /dev/null +++ b/packages/gpx/src/elevation-series.test.ts @@ -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); + }); +}); diff --git a/packages/gpx/src/elevation-series.ts b/packages/gpx/src/elevation-series.ts new file mode 100644 index 0000000..3256d32 --- /dev/null +++ b/packages/gpx/src/elevation-series.ts @@ -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; +} diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index 7cc11af..6f5b3d3 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -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"; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ff1c098..07291df 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -405,6 +405,10 @@ export default { ascent: "Anstieg", descent: "Abstieg", }, + elevation: { + highest: "Höchster Punkt", + lowest: "Tiefster Punkt", + }, settings: { title: "Einstellungen", nav: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 4fd48d2..bb263ab 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -405,6 +405,10 @@ export default { ascent: "Ascent", descent: "Descent", }, + elevation: { + highest: "Highest", + lowest: "Lowest", + }, settings: { title: "Settings", nav: { diff --git a/playwright.config.ts b/playwright.config.ts index 9e5c0dc..ba4a5ad 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -122,6 +122,14 @@ export default defineConfig({ baseURL: "http://localhost:3000", }, }, + { + name: "elevation-profile", + testMatch: "elevation-profile.test.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:3000", + }, + }, ], webServer: process.env.CI ? [