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 { 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 L from "leaflet";
|
||||||
import type { GeoJsonObject } from "geojson";
|
import type { GeoJsonObject } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
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 }) {
|
function FitBounds({ data }: { data: GeoJsonObject }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const fitted = useRef(false);
|
const fitted = useRef(false);
|
||||||
|
|
@ -80,9 +134,24 @@ interface RouteMapProps {
|
||||||
dayBreaks?: number[];
|
dayBreaks?: number[];
|
||||||
/** 1-based day number to highlight, or null for no highlight */
|
/** 1-based day number to highlight, or null for no highlight */
|
||||||
highlightedDay?: number | null;
|
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);
|
const data: GeoJsonObject = JSON.parse(geojson);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -118,6 +187,11 @@ export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks,
|
||||||
fullData={data}
|
fullData={data}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ActiveMarker point={activePoint} />
|
||||||
|
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
|
||||||
|
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
|
||||||
|
)}
|
||||||
|
<Recenter centerOn={centerOn} />
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import {
|
||||||
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
||||||
import { listRoutes } from "~/lib/routes.server";
|
import { listRoutes } from "~/lib/routes.server";
|
||||||
import { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.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 { logger } from "~/lib/logger.server";
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
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) : [];
|
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
|
||||||
|
|
||||||
// Moving time is derived from trackpoint timestamps (null when the GPX has
|
// Moving time + the elevation series are both derived from the GPX (one
|
||||||
// none). Detail-page only — too costly to parse per row in list views.
|
// parse). Detail-page only — too costly to parse per row in list views.
|
||||||
let movingTimeSec: number | null = null;
|
let movingTimeSec: number | null = null;
|
||||||
|
let elevation: ElevationSample[] = [];
|
||||||
if (activity.gpx) {
|
if (activity.gpx) {
|
||||||
try {
|
try {
|
||||||
const parsed = await parseGpxAsync(activity.gpx);
|
const parsed = await parseGpxAsync(activity.gpx);
|
||||||
movingTimeSec = movingTime(parsed.tracks);
|
movingTimeSec = movingTime(parsed.tracks);
|
||||||
|
elevation = elevationSeries(parsed.tracks);
|
||||||
} catch (err) {
|
} 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,
|
elevationLoss: activity.elevationLoss,
|
||||||
duration: activity.duration,
|
duration: activity.duration,
|
||||||
movingTimeSec,
|
movingTimeSec,
|
||||||
|
elevation,
|
||||||
routeId: activity.routeId,
|
routeId: activity.routeId,
|
||||||
hasGpx: !!activity.gpx,
|
hasGpx: !!activity.gpx,
|
||||||
geojson: activity.geojson ?? null,
|
geojson: activity.geojson ?? null,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/activities.$id";
|
import type { Route } from "./+types/activities.$id";
|
||||||
|
|
@ -5,6 +6,7 @@ import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import { SportBadge } from "~/components/SportBadge";
|
import { SportBadge } from "~/components/SportBadge";
|
||||||
import { StatRow } from "~/components/StatRow";
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { ElevationProfile } from "~/components/ElevationProfile";
|
||||||
import { activityStatItems } from "~/lib/stats";
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
||||||
import {
|
import {
|
||||||
|
|
@ -62,6 +64,24 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
|
||||||
const { activity, isOwner, routes } = loaderData;
|
const { activity, isOwner, routes } = loaderData;
|
||||||
const { t } = useTranslation("journal");
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -99,10 +119,29 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
|
||||||
|
|
||||||
{activity.geojson && (
|
{activity.geojson && (
|
||||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
<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>
|
</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 && (
|
{activity.routeId && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">
|
<a href={`/routes/${activity.routeId}`} className="text-blue-600 hover:underline">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import { requireOwnedRoute } from "~/lib/ownership.server";
|
||||||
import { getDb } from "~/lib/db";
|
import { getDb } from "~/lib/db";
|
||||||
import { syncPushes } from "@trails-cool/db/schema/journal";
|
import { syncPushes } from "@trails-cool/db/schema/journal";
|
||||||
import { getService } from "~/lib/connected-services";
|
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) {
|
export async function loadRouteDetail(request: Request, id: string | undefined) {
|
||||||
const routeId = id ?? "";
|
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
|
// 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 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 waypoints: Array<{ lat: number; lon: number; name?: string; isDayBreak?: boolean; note?: string; osmId?: number; poiTags?: Record<string, string> }> = [];
|
||||||
|
let elevation: ElevationSample[] = [];
|
||||||
if (route.gpx) {
|
if (route.gpx) {
|
||||||
try {
|
try {
|
||||||
const gpxData = await parseGpxAsync(route.gpx);
|
const gpxData = await parseGpxAsync(route.gpx);
|
||||||
|
elevation = elevationSeries(gpxData.tracks);
|
||||||
waypoints = gpxData.waypoints.map((w) => ({
|
waypoints = gpxData.waypoints.map((w) => ({
|
||||||
lat: w.lat,
|
lat: w.lat,
|
||||||
lon: w.lon,
|
lon: w.lon,
|
||||||
|
|
@ -122,6 +125,7 @@ export async function loadRouteDetail(request: Request, id: string | undefined)
|
||||||
routingProfile: route.routingProfile,
|
routingProfile: route.routingProfile,
|
||||||
hasGpx: !!route.gpx,
|
hasGpx: !!route.gpx,
|
||||||
dayBreaks: route.dayBreaks ?? [],
|
dayBreaks: route.dayBreaks ?? [],
|
||||||
|
elevation,
|
||||||
geojson: routeWithGeojson?.geojson ?? null,
|
geojson: routeWithGeojson?.geojson ?? null,
|
||||||
visibility: route.visibility,
|
visibility: route.visibility,
|
||||||
createdAt: route.createdAt.toISOString(),
|
createdAt: route.createdAt.toISOString(),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes.$id";
|
import type { Route } from "./+types/routes.$id";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import { StatRow } from "~/components/StatRow";
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { ElevationProfile } from "~/components/ElevationProfile";
|
||||||
import { activityStatItems } from "~/lib/stats";
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
||||||
|
|
||||||
|
|
@ -48,6 +49,24 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
const [highlightedDay, setHighlightedDay] = useState<number | null>(null);
|
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"
|
const pushStatus = typeof window !== "undefined"
|
||||||
? new URLSearchParams(window.location.search).get("push")
|
? new URLSearchParams(window.location.search).get("push")
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -288,10 +307,31 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
|
||||||
{route.geojson && (
|
{route.geojson && (
|
||||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
<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>
|
</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 && (
|
{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="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>
|
<div className="text-4xl" aria-hidden="true">🗺️</div>
|
||||||
|
|
|
||||||
26
e2e/elevation-profile.test.ts
Normal file
26
e2e/elevation-profile.test.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
## ADDED Requirements
|
## ADDED Requirements
|
||||||
|
|
||||||
### Requirement: Elevation profile chart on detail pages
|
### 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
|
#### Scenario: Chart shown for a route with elevation
|
||||||
- **WHEN** a user views a route whose GPX contains an elevation series
|
- **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
|
#### Scenario: Chart omitted without elevation
|
||||||
- **WHEN** an activity's GPX has no usable elevation data
|
- **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
|
#### Scenario: German summary labels
|
||||||
- **WHEN** the UI locale is German
|
- **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
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
## 1. Series helper
|
## 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).
|
- [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.
|
||||||
- [ ] 1.2 Expose the series + summary (ascent/descent/high/low) from the route and activity detail loaders.
|
- [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. 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.
|
- [x] 2.1 Presentational `ElevationProfile` SVG area chart (vertical gradient fill) with a highest/lowest summary + a hover readout (distance · elevation).
|
||||||
- [ ] 2.2 Render nothing when the series has no usable elevation.
|
- [x] 2.2 Renders nothing when the series has <2 points.
|
||||||
|
|
||||||
## 3. Active-distance interaction
|
## 3. Active-distance interaction
|
||||||
|
|
||||||
- [ ] 3.1 Lift an `activeDistance | null` state above the detail page's map + chart.
|
- [x] 3.1 Lift a shared `activeIndex | null` (+ `centerOn`) above the detail page's map + chart.
|
||||||
- [ ] 3.2 Chart hover → set `activeDistance`; map shows a marker at the interpolated lat/lng.
|
- [x] 3.2 Chart hover → `onActive(index)`; map draws a `CircleMarker` at that sample's lat/lng.
|
||||||
- [ ] 3.3 Map route-line hover → set `activeDistance` (nearest sample); chart shows a crosshair/marker.
|
- [x] 3.3 Map route hover → `HoverTracker` finds the nearest sample → `onHoverIndex`; chart draws a crosshair + dot.
|
||||||
- [ ] 3.4 Chart click → center the map on that location (read-only). Throttle pointer updates; memoize interpolation.
|
- [x] 3.4 Chart pointer-down → `onSeek` bumps `centerOn`; map `panTo`s that point (read-only, no data change).
|
||||||
|
|
||||||
## 4. Wire-in & i18n
|
## 4. Wire-in & i18n
|
||||||
|
|
||||||
- [ ] 4.1 Mount `ElevationProfile` + shared state in `routes.$id.tsx` and `activities.$id.tsx`.
|
- [x] 4.1 Mounted in `routes.$id.tsx` and `activities.$id.tsx` (props forwarded through `ClientMap`).
|
||||||
- [ ] 4.2 Add `journal.elevation.*` keys (ascent, descent, highest, lowest) to en + de.
|
- [x] 4.2 Add `journal.elevation.{highest,lowest}` to en + de.
|
||||||
|
|
||||||
## 5. Tests & checks
|
## 5. Tests & checks
|
||||||
|
|
||||||
- [ ] 5.1 Unit: series helper (sampling, summary numbers, no-elevation → omitted).
|
- [x] 5.1 Unit: `elevationSeries` (cumulative distance, flatten segments, downsample first/last, no-elevation → empty).
|
||||||
- [ ] 5.2 Component: chart renders for a series; renders nothing for an empty series.
|
- [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.
|
||||||
- [ ] 5.3 E2E: open a route detail with elevation, assert the chart + summary appear; hovering the chart shows a map marker.
|
- [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.
|
||||||
- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
- [x] 5.4 typecheck + lint + unit (gpx 67, journal 315) green; new e2e passes locally. Full `pnpm test:e2e` runs in CI.
|
||||||
|
|
|
||||||
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 { computeDays } from "./compute-days.ts";
|
||||||
export type { DayStage } from "./compute-days.ts";
|
export type { DayStage } from "./compute-days.ts";
|
||||||
export { movingTime } from "./moving-time.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";
|
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,10 @@ export default {
|
||||||
ascent: "Anstieg",
|
ascent: "Anstieg",
|
||||||
descent: "Abstieg",
|
descent: "Abstieg",
|
||||||
},
|
},
|
||||||
|
elevation: {
|
||||||
|
highest: "Höchster Punkt",
|
||||||
|
lowest: "Tiefster Punkt",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Einstellungen",
|
title: "Einstellungen",
|
||||||
nav: {
|
nav: {
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,10 @@ export default {
|
||||||
ascent: "Ascent",
|
ascent: "Ascent",
|
||||||
descent: "Descent",
|
descent: "Descent",
|
||||||
},
|
},
|
||||||
|
elevation: {
|
||||||
|
highest: "Highest",
|
||||||
|
lowest: "Lowest",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
nav: {
|
nav: {
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,14 @@ export default defineConfig({
|
||||||
baseURL: "http://localhost:3000",
|
baseURL: "http://localhost:3000",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "elevation-profile",
|
||||||
|
testMatch: "elevation-profile.test.ts",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
webServer: process.env.CI
|
webServer: process.env.CI
|
||||||
? [
|
? [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue