trails/apps/planner/app/lib/use-elevation-data.ts
Ullrich Schäfer 34529a9432
Split ElevationChart + PlannerMap into deep modules; add visual regression testing
ElevationChart (1013 lines) split into:
- elevation-chart-draw.ts — pure drawElevationChart(ctx, w, h, params) function
- use-elevation-data.ts — useElevationData(routeData) hook
- ElevationChart.tsx — ~300 lines, interaction + render only

PlannerMap (869 lines) split into:
- MapHelpers.tsx — 7 Leaflet sub-components (MapExposer, RouteFitter, MapClickHandler,
  CursorTracker, NoGoAreaButton, OverlaySync, PoiRefresher)
- use-waypoint-manager.ts — all waypoint CRUD + route data sync
- use-gpx-drop.ts — GPX drag-and-drop hook
- PlannerMap.tsx — ~200 lines, orchestration only

Add Vitest browser visual regression tests for drawElevationChart via
@vitest/browser + Playwright (toMatchScreenshot). Tests cover plain, grade,
elevation, surface color modes plus hover and drag-select states.

Add update-visual-snapshots.yml workflow: triggered by workflow_dispatch or
the `update-snapshots` PR label; commits snapshots back to the branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:41:23 +02:00

98 lines
3.1 KiB
TypeScript

import { useEffect, useState } from "react";
import * as Y from "yjs";
import type { ColorMode } from "~/components/ColoredRoute";
import type { ElevationPoint } from "~/lib/elevation-chart-draw";
function haversine(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));
}
function extractElevation(geojsonStr: string): ElevationPoint[] {
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length === 0) return [];
const points: ElevationPoint[] = [];
let totalDist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
const prev = coords[i - 1]!;
const curr = coords[i]!;
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
}
if (coords[i]![2] !== undefined) {
points.push({
distance: totalDist,
elevation: coords[i]![2]!,
lat: coords[i]![1]!,
lon: coords[i]![0]!,
});
}
}
return points;
} catch {
return [];
}
}
function parseJsonArray(json: string | undefined): string[] {
if (!json) return [];
try { return JSON.parse(json); } catch { return []; }
}
export interface ElevationData {
points: ElevationPoint[];
colorMode: ColorMode;
surfaces: string[];
highways: string[];
maxspeeds: string[];
smoothnesses: string[];
tracktypes: string[];
cycleways: string[];
bikeroutes: string[];
}
export function useElevationData(routeData: Y.Map<unknown>): ElevationData {
const [data, setData] = useState<ElevationData>({
points: [],
colorMode: "plain",
surfaces: [],
highways: [],
maxspeeds: [],
smoothnesses: [],
tracktypes: [],
cycleways: [],
bikeroutes: [],
});
useEffect(() => {
const update = () => {
const geojson = routeData.get("geojson") as string | undefined;
setData({
points: geojson ? extractElevation(geojson) : [],
colorMode: (routeData.get("colorMode") as ColorMode | undefined) ?? "plain",
surfaces: parseJsonArray(routeData.get("surfaces") as string | undefined),
highways: parseJsonArray(routeData.get("highways") as string | undefined),
maxspeeds: parseJsonArray(routeData.get("maxspeeds") as string | undefined),
smoothnesses: parseJsonArray(routeData.get("smoothnesses") as string | undefined),
tracktypes: parseJsonArray(routeData.get("tracktypes") as string | undefined),
cycleways: parseJsonArray(routeData.get("cycleways") as string | undefined),
bikeroutes: parseJsonArray(routeData.get("bikeroutes") as string | undefined),
});
};
routeData.observe(update);
update();
return () => routeData.unobserve(update);
}, [routeData]);
return data;
}