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>
54 lines
2.1 KiB
TypeScript
54 lines
2.1 KiB
TypeScript
// @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);
|
|
});
|
|
});
|