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>
This commit is contained in:
parent
6c5fb6df0e
commit
34529a9432
13 changed files with 1764 additions and 1322 deletions
90
apps/planner/app/lib/elevation-chart-draw.browser.test.tsx
Normal file
90
apps/planner/app/lib/elevation-chart-draw.browser.test.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { drawElevationChart } from "./elevation-chart-draw";
|
||||
import type { DrawChartParams } from "./elevation-chart-draw";
|
||||
|
||||
// 10-point synthetic route: gentle climb from 100m to 200m over 10km
|
||||
const BASE_POINTS = Array.from({ length: 10 }, (_, i) => ({
|
||||
distance: i * 1000,
|
||||
elevation: 100 + i * 10,
|
||||
lat: 48 + i * 0.01,
|
||||
lon: 11 + i * 0.01,
|
||||
}));
|
||||
|
||||
const BASE_PARAMS: DrawChartParams = {
|
||||
points: BASE_POINTS,
|
||||
colorMode: "plain",
|
||||
surfaces: [],
|
||||
highways: [],
|
||||
maxspeeds: [],
|
||||
smoothnesses: [],
|
||||
tracktypes: [],
|
||||
cycleways: [],
|
||||
bikeroutes: [],
|
||||
hoverIdx: null,
|
||||
isDragging: false,
|
||||
dragStartX: null,
|
||||
dragCurrentX: null,
|
||||
};
|
||||
|
||||
function ChartFixture({ params }: { params: DrawChartParams }) {
|
||||
return (
|
||||
<canvas
|
||||
data-testid="chart"
|
||||
width={600}
|
||||
height={100}
|
||||
style={{ display: "block" }}
|
||||
ref={(canvas) => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
drawElevationChart(ctx, 600, 100, params);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("drawElevationChart visual regression", () => {
|
||||
it("renders plain color mode", async () => {
|
||||
render(<ChartFixture params={BASE_PARAMS} />);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("plain.png");
|
||||
});
|
||||
|
||||
it("renders grade color mode", async () => {
|
||||
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "grade" }} />);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("grade.png");
|
||||
});
|
||||
|
||||
it("renders elevation color mode", async () => {
|
||||
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "elevation" }} />);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("elevation.png");
|
||||
});
|
||||
|
||||
it("renders surface color mode", async () => {
|
||||
const surfaces = BASE_POINTS.map((_, i) =>
|
||||
i < 5 ? "asphalt" : "gravel",
|
||||
);
|
||||
render(<ChartFixture params={{ ...BASE_PARAMS, colorMode: "surface", surfaces }} />);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("surface.png");
|
||||
});
|
||||
|
||||
it("renders hover crosshair at index 5", async () => {
|
||||
render(<ChartFixture params={{ ...BASE_PARAMS, hoverIdx: 5 }} />);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("hover.png");
|
||||
});
|
||||
|
||||
it("renders drag selection overlay", async () => {
|
||||
render(
|
||||
<ChartFixture
|
||||
params={{
|
||||
...BASE_PARAMS,
|
||||
isDragging: true,
|
||||
dragStartX: 100,
|
||||
dragCurrentX: 300,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await expect(page.getByTestId("chart")).toMatchScreenshot("drag-select.png");
|
||||
});
|
||||
});
|
||||
289
apps/planner/app/lib/elevation-chart-draw.ts
Normal file
289
apps/planner/app/lib/elevation-chart-draw.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import {
|
||||
elevationColor,
|
||||
maxspeedColor,
|
||||
SURFACE_COLORS,
|
||||
DEFAULT_SURFACE_COLOR,
|
||||
HIGHWAY_COLORS,
|
||||
DEFAULT_HIGHWAY_COLOR,
|
||||
SMOOTHNESS_COLORS,
|
||||
DEFAULT_SMOOTHNESS_COLOR,
|
||||
TRACKTYPE_COLORS,
|
||||
DEFAULT_TRACKTYPE_COLOR,
|
||||
CYCLEWAY_COLORS,
|
||||
DEFAULT_CYCLEWAY_COLOR,
|
||||
BIKEROUTE_COLORS,
|
||||
DEFAULT_BIKEROUTE_COLOR,
|
||||
} from "@trails-cool/map-core";
|
||||
import type { ColorMode } from "~/components/ColoredRoute";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
|
||||
export interface ElevationPoint {
|
||||
distance: number;
|
||||
elevation: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
function gradeColor(grade: number): string {
|
||||
const absGrade = Math.abs(grade);
|
||||
if (absGrade < 3) return "#22c55e";
|
||||
if (absGrade < 6) return "#eab308";
|
||||
if (absGrade < 10) return "#f97316";
|
||||
if (absGrade < 15) return "#ef4444";
|
||||
return "#991b1b";
|
||||
}
|
||||
|
||||
export const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
|
||||
export interface DrawChartParams {
|
||||
points: ElevationPoint[];
|
||||
colorMode: ColorMode;
|
||||
surfaces: string[];
|
||||
highways: string[];
|
||||
maxspeeds: string[];
|
||||
smoothnesses: string[];
|
||||
tracktypes: string[];
|
||||
cycleways: string[];
|
||||
bikeroutes: string[];
|
||||
hoverIdx: number | null;
|
||||
isDragging: boolean;
|
||||
dragStartX: number | null;
|
||||
dragCurrentX: number | null;
|
||||
days?: DayStage[];
|
||||
}
|
||||
|
||||
function drawSegments(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
points: ElevationPoint[],
|
||||
chartH: number,
|
||||
getColor: (i: number) => string,
|
||||
toX: (d: number) => number,
|
||||
toY: (e: number) => number,
|
||||
) {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const color = getColor(i);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
||||
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color + "40";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
export function drawElevationChart(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
params: DrawChartParams,
|
||||
): void {
|
||||
const {
|
||||
points,
|
||||
colorMode,
|
||||
surfaces,
|
||||
highways,
|
||||
maxspeeds,
|
||||
smoothnesses,
|
||||
tracktypes,
|
||||
cycleways,
|
||||
bikeroutes,
|
||||
hoverIdx,
|
||||
isDragging,
|
||||
dragStartX,
|
||||
dragCurrentX,
|
||||
days,
|
||||
} = params;
|
||||
|
||||
if (points.length < 2) return;
|
||||
|
||||
const w = width;
|
||||
const h = height;
|
||||
const chartW = w - PADDING.left - PADDING.right;
|
||||
const chartH = h - PADDING.top - PADDING.bottom;
|
||||
|
||||
const maxDist = points[points.length - 1]!.distance;
|
||||
const elevations = points.map((p) => p.elevation);
|
||||
const minEle = Math.min(...elevations);
|
||||
const maxEle = Math.max(...elevations);
|
||||
const eleRange = maxEle - minEle || 1;
|
||||
|
||||
const toX = (d: number) => PADDING.left + (d / maxDist) * chartW;
|
||||
const toY = (e: number) => PADDING.top + chartH - ((e - minEle) / eleRange) * chartH;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (colorMode === "grade") {
|
||||
drawSegments(ctx, points, chartH, (i) => {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const dDist = p1.distance - p0.distance;
|
||||
const grade = dDist > 0 ? ((p1.elevation - p0.elevation) / dDist) * 100 : 0;
|
||||
return gradeColor(grade);
|
||||
}, toX, toY);
|
||||
} else if (colorMode === "elevation") {
|
||||
// elevationColor returns rgb(...), not hex, so we can't use hex alpha suffix
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const t = (p0.elevation - minEle) / eleRange;
|
||||
const color = elevationColor(t);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), PADDING.top + chartH);
|
||||
ctx.lineTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.lineTo(toX(p1.distance), PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color.replace("rgb", "rgba").replace(")", ", 0.2)");
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(p0.distance), toY(p0.elevation));
|
||||
ctx.lineTo(toX(p1.distance), toY(p1.elevation));
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
} else if (colorMode === "surface" && surfaces.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => SURFACE_COLORS[surfaces[i] ?? "unknown"] ?? DEFAULT_SURFACE_COLOR, toX, toY);
|
||||
} else if (colorMode === "highway" && highways.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => HIGHWAY_COLORS[highways[i] ?? "unknown"] ?? DEFAULT_HIGHWAY_COLOR, toX, toY);
|
||||
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => maxspeedColor(maxspeeds[i] ?? "unknown"), toX, toY);
|
||||
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => SMOOTHNESS_COLORS[smoothnesses[i] ?? "unknown"] ?? DEFAULT_SMOOTHNESS_COLOR, toX, toY);
|
||||
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => TRACKTYPE_COLORS[tracktypes[i] ?? "unknown"] ?? DEFAULT_TRACKTYPE_COLOR, toX, toY);
|
||||
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => CYCLEWAY_COLORS[cycleways[i] ?? "unknown"] ?? DEFAULT_CYCLEWAY_COLOR, toX, toY);
|
||||
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
|
||||
drawSegments(ctx, points, chartH, (i) => BIKEROUTE_COLORS[bikeroutes[i] ?? "none"] ?? DEFAULT_BIKEROUTE_COLOR, toX, toY);
|
||||
} else {
|
||||
// Plain fill and line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PADDING.left, PADDING.top + chartH);
|
||||
for (const p of points) {
|
||||
ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
|
||||
else ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Axis labels
|
||||
ctx.fillStyle = "#6b7280";
|
||||
ctx.font = "10px sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText(`${Math.round(maxEle)}m`, PADDING.left - 4, PADDING.top + 10);
|
||||
ctx.fillText(`${Math.round(minEle)}m`, PADDING.left - 4, PADDING.top + chartH);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("0 km", PADDING.left, h - 4);
|
||||
ctx.fillText(`${(maxDist / 1000).toFixed(1)} km`, PADDING.left + chartW, h - 4);
|
||||
|
||||
// Day dividers
|
||||
if (days && days.length > 1) {
|
||||
for (let d = 0; d < days.length - 1; d++) {
|
||||
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
|
||||
const bx = toX(boundaryDist);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.moveTo(bx, PADDING.top);
|
||||
ctx.lineTo(bx, PADDING.top + chartH);
|
||||
ctx.strokeStyle = "#9A9484";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.fillStyle = "#9A9484";
|
||||
ctx.font = "9px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(`${(boundaryDist / 1000).toFixed(0)} km`, bx, h - 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Hover crosshair + info
|
||||
if (hoverIdx !== null && hoverIdx >= 0 && hoverIdx < points.length) {
|
||||
const p = points[hoverIdx]!;
|
||||
const hx = toX(p.distance);
|
||||
const hy = toY(p.elevation);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(hx, PADDING.top);
|
||||
ctx.lineTo(hx, PADDING.top + chartH);
|
||||
ctx.strokeStyle = "rgba(239, 68, 68, 0.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#1f2937";
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
let label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
|
||||
if (colorMode === "grade" && hoverIdx > 0) {
|
||||
const prev = points[hoverIdx - 1]!;
|
||||
const dDist = p.distance - prev.distance;
|
||||
const grade = dDist > 0 ? ((p.elevation - prev.elevation) / dDist) * 100 : 0;
|
||||
label += ` · ${grade > 0 ? "+" : ""}${grade.toFixed(1)}%`;
|
||||
}
|
||||
if (colorMode === "surface" && surfaces[hoverIdx]) label += ` · ${surfaces[hoverIdx]}`;
|
||||
if (colorMode === "highway" && highways[hoverIdx]) label += ` · ${highways[hoverIdx]}`;
|
||||
if (colorMode === "maxspeed" && maxspeeds[hoverIdx]) {
|
||||
const s = maxspeeds[hoverIdx]!;
|
||||
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
|
||||
}
|
||||
if (colorMode === "smoothness" && smoothnesses[hoverIdx]) label += ` · ${smoothnesses[hoverIdx]}`;
|
||||
if (colorMode === "tracktype" && tracktypes[hoverIdx]) label += ` · ${tracktypes[hoverIdx]}`;
|
||||
if (colorMode === "cycleway" && cycleways[hoverIdx]) label += ` · ${cycleways[hoverIdx]}`;
|
||||
if (colorMode === "bikeroute" && bikeroutes[hoverIdx]) {
|
||||
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
|
||||
label += ` · ${names[bikeroutes[hoverIdx]!] ?? bikeroutes[hoverIdx]}`;
|
||||
}
|
||||
const labelX = hx + 8 > w - 80 ? hx - 8 : hx + 8;
|
||||
ctx.textAlign = hx + 8 > w - 80 ? "right" : "left";
|
||||
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||
}
|
||||
|
||||
// Drag selection overlay
|
||||
if (isDragging && dragStartX != null && dragCurrentX != null) {
|
||||
const x1 = Math.max(PADDING.left, Math.min(dragStartX, PADDING.left + chartW));
|
||||
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX, PADDING.left + chartW));
|
||||
const selLeft = Math.min(x1, x2);
|
||||
const selRight = Math.max(x1, x2);
|
||||
ctx.fillStyle = "rgba(59, 130, 246, 0.15)";
|
||||
ctx.fillRect(selLeft, PADDING.top, selRight - selLeft, chartH);
|
||||
ctx.strokeStyle = "rgba(59, 130, 246, 0.5)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(selLeft, PADDING.top, selRight - selLeft, chartH);
|
||||
}
|
||||
}
|
||||
98
apps/planner/app/lib/use-elevation-data.ts
Normal file
98
apps/planner/app/lib/use-elevation-data.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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;
|
||||
}
|
||||
77
apps/planner/app/lib/use-gpx-drop.ts
Normal file
77
apps/planner/app/lib/use-gpx-drop.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState, useRef, useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
export function useGpxDrop(yjs: YjsState, onImportError?: (message: string) => void) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current++;
|
||||
if (dragCounterRef.current === 1) setDraggingOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) setDraggingOver(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current = 0;
|
||||
setDraggingOver(false);
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".gpx")) {
|
||||
onImportError?.(t("importGpxError"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const gpxData = await parseGpxAsync(text);
|
||||
const newWaypoints = extractWaypoints(gpxData);
|
||||
if (newWaypoints.length < 2) return;
|
||||
|
||||
if (!window.confirm(t("replaceRouteConfirm"))) return;
|
||||
|
||||
yjs.doc.transact(() => {
|
||||
yjs.waypoints.delete(0, yjs.waypoints.length);
|
||||
for (const wp of newWaypoints) {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", wp.lat);
|
||||
yMap.set("lon", wp.lon);
|
||||
if (wp.name) yMap.set("name", wp.name);
|
||||
if (wp.isDayBreak) yMap.set("overnight", true);
|
||||
yjs.waypoints.push([yMap]);
|
||||
}
|
||||
|
||||
yjs.noGoAreas.delete(0, yjs.noGoAreas.length);
|
||||
for (const area of gpxData.noGoAreas) {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("points", area.points);
|
||||
yjs.noGoAreas.push([yMap]);
|
||||
}
|
||||
|
||||
if (gpxData.description) {
|
||||
yjs.notes.delete(0, yjs.notes.length);
|
||||
yjs.notes.insert(0, gpxData.description);
|
||||
}
|
||||
}, "local");
|
||||
} catch {
|
||||
onImportError?.(t("importGpxError"));
|
||||
}
|
||||
}, [yjs, t, onImportError]);
|
||||
|
||||
return { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop };
|
||||
}
|
||||
286
apps/planner/app/lib/use-waypoint-manager.ts
Normal file
286
apps/planner/app/lib/use-waypoint-manager.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import type { ColorMode } from "~/components/ColoredRoute";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { snapToPoi } from "~/lib/poi-snap";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { findSegmentForPoint } from "~/components/ColoredRoute";
|
||||
|
||||
export interface WaypointData {
|
||||
lat: number;
|
||||
lon: number;
|
||||
name?: string;
|
||||
overnight: boolean;
|
||||
}
|
||||
|
||||
function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[] {
|
||||
return waypoints.toArray().map((yMap) => ({
|
||||
lat: yMap.get("lat") as number,
|
||||
lon: yMap.get("lon") as number,
|
||||
name: yMap.get("name") as string | undefined,
|
||||
overnight: isOvernight(yMap),
|
||||
}));
|
||||
}
|
||||
|
||||
function pointToSegmentDist(
|
||||
pLat: number, pLon: number,
|
||||
aLat: number, aLon: number,
|
||||
bLat: number, bLon: number,
|
||||
): number {
|
||||
const dx = bLon - aLon;
|
||||
const dy = bLat - aLat;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2);
|
||||
const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq));
|
||||
const projLon = aLon + t * dx;
|
||||
const projLat = aLat + t * dy;
|
||||
return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2);
|
||||
}
|
||||
|
||||
function parseJsonArray<T>(json: string | undefined): T[] {
|
||||
if (!json) return [];
|
||||
try { return JSON.parse(json); } catch { return []; }
|
||||
}
|
||||
|
||||
export interface RouteState {
|
||||
waypoints: WaypointData[];
|
||||
routeCoordinates: [number, number, number][] | null;
|
||||
segmentBoundaries: number[];
|
||||
surfaces: string[];
|
||||
highways: string[];
|
||||
maxspeeds: string[];
|
||||
smoothnesses: string[];
|
||||
tracktypes: string[];
|
||||
cycleways: string[];
|
||||
bikeroutes: string[];
|
||||
colorMode: ColorMode;
|
||||
}
|
||||
|
||||
export function useWaypointManager(
|
||||
yjs: YjsState,
|
||||
poiState: ReturnType<typeof usePois>,
|
||||
suppressMapClickRef: React.RefObject<boolean>,
|
||||
onRouteRequest?: (waypoints: WaypointData[]) => void,
|
||||
) {
|
||||
const [state, setState] = useState<RouteState>({
|
||||
waypoints: [],
|
||||
routeCoordinates: null,
|
||||
segmentBoundaries: [],
|
||||
surfaces: [],
|
||||
highways: [],
|
||||
maxspeeds: [],
|
||||
smoothnesses: [],
|
||||
tracktypes: [],
|
||||
cycleways: [],
|
||||
bikeroutes: [],
|
||||
colorMode: "plain",
|
||||
});
|
||||
|
||||
// Sync waypoints from Yjs
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const wps = getWaypointsFromYjs(yjs.waypoints);
|
||||
setState((prev) => ({ ...prev, waypoints: wps }));
|
||||
if (wps.length >= 2 && onRouteRequest) {
|
||||
onRouteRequest(wps);
|
||||
}
|
||||
};
|
||||
yjs.waypoints.observeDeep(update);
|
||||
update();
|
||||
return () => yjs.waypoints.unobserveDeep(update);
|
||||
}, [yjs.waypoints, onRouteRequest]);
|
||||
|
||||
// Sync route data from Yjs
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const coordsJson = yjs.routeData.get("coordinates") as string | undefined;
|
||||
const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined;
|
||||
const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||
|
||||
let routeCoordinates: [number, number, number][] | null = null;
|
||||
if (coordsJson) {
|
||||
try { routeCoordinates = JSON.parse(coordsJson); } catch { /* ignore */ }
|
||||
} else {
|
||||
// Fallback: parse from geojson for backwards compat
|
||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojson) {
|
||||
try {
|
||||
const parsed = JSON.parse(geojson);
|
||||
const coords = parsed.features?.[0]?.geometry?.coordinates;
|
||||
if (coords) {
|
||||
routeCoordinates = coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
routeCoordinates,
|
||||
segmentBoundaries: parseJsonArray<number>(boundsJson),
|
||||
surfaces: parseJsonArray<string>(yjs.routeData.get("surfaces") as string | undefined),
|
||||
highways: parseJsonArray<string>(yjs.routeData.get("highways") as string | undefined),
|
||||
maxspeeds: parseJsonArray<string>(yjs.routeData.get("maxspeeds") as string | undefined),
|
||||
smoothnesses: parseJsonArray<string>(yjs.routeData.get("smoothnesses") as string | undefined),
|
||||
tracktypes: parseJsonArray<string>(yjs.routeData.get("tracktypes") as string | undefined),
|
||||
cycleways: parseJsonArray<string>(yjs.routeData.get("cycleways") as string | undefined),
|
||||
bikeroutes: parseJsonArray<string>(yjs.routeData.get("bikeroutes") as string | undefined),
|
||||
colorMode: modeVal ?? "plain",
|
||||
}));
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const addWaypoint = useCallback(
|
||||
(lat: number, lng: number, name?: string) => {
|
||||
const { routeCoordinates, segmentBoundaries } = state;
|
||||
const snap = snapToPoi(lat, lng, poiState.pois);
|
||||
const finalLat = snap.lat;
|
||||
const finalLon = snap.snapped ? snap.lon : lng;
|
||||
|
||||
let insertIndex = yjs.waypoints.length;
|
||||
if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) {
|
||||
let bestDist = Infinity;
|
||||
let bestSegment = -1;
|
||||
for (let seg = 0; seg < segmentBoundaries.length; seg++) {
|
||||
const start = segmentBoundaries[seg]!;
|
||||
const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length;
|
||||
for (let i = start; i < end - 1; i++) {
|
||||
const c1 = routeCoordinates[i]!;
|
||||
const c2 = routeCoordinates[i + 1]!;
|
||||
const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestSegment = seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestDist < 0.01 && bestSegment >= 0) {
|
||||
insertIndex = bestSegment + 1;
|
||||
}
|
||||
}
|
||||
|
||||
yjs.doc.transact(() => {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", finalLat);
|
||||
yMap.set("lon", finalLon);
|
||||
if (snap.name) yMap.set("name", snap.name);
|
||||
else if (name) yMap.set("name", name);
|
||||
if (snap.osmId) yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
yjs.waypoints.insert(insertIndex, [yMap]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.doc, yjs.waypoints, poiState.pois, state],
|
||||
);
|
||||
|
||||
const insertWaypointAtSegment = useCallback(
|
||||
(segmentIndex: number, lat: number, lon: number) => {
|
||||
const snap = snapToPoi(lat, lon, poiState.pois);
|
||||
yjs.doc.transact(() => {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", snap.lat);
|
||||
yMap.set("lon", snap.snapped ? snap.lon : lon);
|
||||
if (snap.name) yMap.set("name", snap.name);
|
||||
if (snap.osmId) yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.doc, yjs.waypoints, poiState.pois],
|
||||
);
|
||||
|
||||
const handleRouteInsert = useCallback(
|
||||
(pointIndex: number, lat: number, lon: number) => {
|
||||
suppressMapClickRef.current = true;
|
||||
const segIdx = findSegmentForPoint(pointIndex, state.segmentBoundaries);
|
||||
insertWaypointAtSegment(segIdx, lat, lon);
|
||||
},
|
||||
[state.segmentBoundaries, insertWaypointAtSegment, suppressMapClickRef],
|
||||
);
|
||||
|
||||
const moveWaypoint = useCallback(
|
||||
(index: number, lat: number, lng: number) => {
|
||||
const snap = snapToPoi(lat, lng, poiState.pois);
|
||||
const yMap = yjs.waypoints.get(index);
|
||||
if (yMap) {
|
||||
yjs.doc.transact(() => {
|
||||
yMap.set("lat", snap.lat);
|
||||
yMap.set("lon", snap.snapped ? snap.lon : lng);
|
||||
if (snap.snapped && snap.name) {
|
||||
yMap.set("name", snap.name);
|
||||
} else {
|
||||
yMap.delete("name");
|
||||
}
|
||||
if (snap.osmId) {
|
||||
yMap.set("osmId", snap.osmId);
|
||||
if (snap.poiTags) yMap.set("poiTags", snap.poiTags);
|
||||
} else {
|
||||
yMap.delete("osmId");
|
||||
yMap.delete("poiTags");
|
||||
}
|
||||
}, "local");
|
||||
}
|
||||
},
|
||||
[yjs.waypoints, yjs.doc, poiState.pois],
|
||||
);
|
||||
|
||||
const deleteWaypoint = useCallback(
|
||||
(index: number) => {
|
||||
yjs.doc.transact(() => {
|
||||
yjs.waypoints.delete(index, 1);
|
||||
}, "local");
|
||||
},
|
||||
[yjs.waypoints],
|
||||
);
|
||||
|
||||
const handleRoutePolylineHover = useCallback(
|
||||
(e: { latlng: { lat: number; lng: number } }, onRouteHover: (d: number | null) => void) => {
|
||||
const { routeCoordinates } = state;
|
||||
if (!routeCoordinates || routeCoordinates.length < 2) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < routeCoordinates.length; i++) {
|
||||
const c = routeCoordinates[i]!;
|
||||
const dLat = c[1]! - lat;
|
||||
const dLon = c[0]! - lng;
|
||||
const dist = dLat * dLat + dLon * dLon;
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
let totalDist = 0;
|
||||
for (let i = 1; i <= bestIdx; i++) {
|
||||
const prev = routeCoordinates[i - 1]!;
|
||||
const curr = routeCoordinates[i]!;
|
||||
const R = 6371000;
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(curr[1]! - prev[1]!);
|
||||
const dLon = toRad(curr[0]! - prev[0]!);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2;
|
||||
totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
onRouteHover(totalDist);
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
addWaypoint,
|
||||
insertWaypointAtSegment,
|
||||
handleRouteInsert,
|
||||
moveWaypoint,
|
||||
deleteWaypoint,
|
||||
handleRoutePolylineHover,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue