Merge pull request #534 from trails-cool/apply-journal-elevation-profile
journal-elevation-profile: elevation chart + map↔chart sync
This commit is contained in:
commit
1ba063a085
16 changed files with 509 additions and 27 deletions
54
apps/journal/app/components/ElevationProfile.test.tsx
Normal file
54
apps/journal/app/components/ElevationProfile.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
122
apps/journal/app/components/ElevationProfile.tsx
Normal file
122
apps/journal/app/components/ElevationProfile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
@ -118,6 +187,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue