trails/apps/planner/app/components/MidpointHandles.tsx
Ullrich Schäfer b080a15fb1
Add route interactions: click-to-split, drag-to-reshape, colored route rendering
- Enrich BRouter response with per-point 3D coordinates and segment boundary
  tracking (EnrichedRoute interface)
- ColoredRoute component: plain, elevation gradient (green→yellow→red), and
  surface color modes with invisible wide polyline for click targeting
- Click-to-split: click on route polyline inserts waypoint at nearest point,
  mapped to correct segment via boundary indices
- MidpointHandles: draggable CircleMarkers at route segment midpoints for
  reshaping, hidden below zoom 12, opaque on hover
- Color mode toggle (select) synced via Yjs routeData
- i18n keys for color mode labels (en + de)
- Unit tests for segment boundary tracking (13 tests)
- E2E tests for enriched route response and color mode toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:15:55 +01:00

118 lines
3.5 KiB
TypeScript

import { useMemo, useState, useEffect } from "react";
import { Marker, useMap } from "react-leaflet";
import L from "leaflet";
interface MidpointHandlesProps {
coordinates: [number, number, number][]; // [lon, lat, ele]
segmentBoundaries: number[];
onInsertWaypoint: (segmentIndex: number, lat: number, lon: number) => void;
}
function getSegmentMidpoint(
coordinates: [number, number, number][],
startIdx: number,
endIdx: number,
): [number, number] | null {
if (endIdx <= startIdx) return null;
const midCoordIdx = Math.floor((startIdx + endIdx) / 2);
const c = coordinates[midCoordIdx];
if (!c) return null;
return [c[1], c[0]]; // [lat, lon]
}
const midpointIcon = L.divIcon({
className: "",
html: `<div style="
width:12px;height:12px;border-radius:50%;
background:#93c5fd;border:2px solid #2563eb;
cursor:grab;transform:translate(-6px,-6px);
opacity:0.7;
" data-midpoint="true"></div>`,
iconSize: [0, 0],
});
const dragIcon = L.divIcon({
className: "",
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:#3b82f6;border:2px solid white;
box-shadow:0 1px 4px rgba(0,0,0,0.3);
transform:translate(-7px,-7px);
"></div>`,
iconSize: [0, 0],
});
export function MidpointHandles({
coordinates,
segmentBoundaries,
onInsertWaypoint,
}: MidpointHandlesProps) {
const map = useMap();
const [zoom, setZoom] = useState(map.getZoom());
const [dragging, setDragging] = useState<{ segmentIndex: number; lat: number; lon: number } | null>(null);
// Track zoom changes
useEffect(() => {
const onZoom = () => setZoom(map.getZoom());
map.on("zoomend", onZoom);
return () => { map.off("zoomend", onZoom); };
}, [map]);
const midpoints = useMemo(() => {
if (segmentBoundaries.length < 1) return [];
const result: { lat: number; lon: number; segmentIndex: number }[] = [];
for (let i = 0; i < segmentBoundaries.length; i++) {
const startIdx = segmentBoundaries[i]!;
const endIdx = i + 1 < segmentBoundaries.length
? segmentBoundaries[i + 1]!
: coordinates.length;
const mid = getSegmentMidpoint(coordinates, startIdx, endIdx);
if (mid) {
result.push({ lat: mid[0], lon: mid[1], segmentIndex: i });
}
}
return result;
}, [coordinates, segmentBoundaries]);
// Hide at low zoom levels
if (zoom < 12) return null;
return (
<>
{midpoints.map((mp) => (
<Marker
key={mp.segmentIndex}
position={[mp.lat, mp.lon]}
icon={midpointIcon}
eventHandlers={{
mousedown: () => {
setDragging({ segmentIndex: mp.segmentIndex, lat: mp.lat, lon: mp.lon });
const onMouseMove = (e: L.LeafletMouseEvent) => {
setDragging({ segmentIndex: mp.segmentIndex, lat: e.latlng.lat, lon: e.latlng.lng });
};
const onMouseUp = (e: L.LeafletMouseEvent) => {
map.off("mousemove", onMouseMove);
map.off("mouseup", onMouseUp);
map.dragging.enable();
setDragging(null);
onInsertWaypoint(mp.segmentIndex, e.latlng.lat, e.latlng.lng);
};
map.dragging.disable();
map.on("mousemove", onMouseMove);
map.on("mouseup", onMouseUp);
},
}}
/>
))}
{dragging && (
<Marker
position={[dragging.lat, dragging.lon]}
icon={dragIcon}
interactive={false}
/>
)}
</>
);
}