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>
77 lines
2.5 KiB
TypeScript
77 lines
2.5 KiB
TypeScript
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 };
|
|
}
|