Bring the map surface on-brand (visual-redesign group 4): - Waypoint markers: sage accent numbered circles (was blue #2563eb); overnight stops keep the warm stop tone; note indicator uses the eg-mid gold token. - Plain route line + ghost (insert) marker: sage accent (was blue). - Map highlight dot (synced with the chart hover): sage accent (was red), so map and chart hovers match. - Day labels: bg-raised surface + text-hi. - No-go areas: danger token (#a03c3c) instead of bright red. - GPX drag-over overlay: accent tokens. Per-mode route coloring (surface/grade/…) is unchanged (data-viz); coordinating the elevation-mode gradient with the route line is task 1.5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
6.5 KiB
TypeScript
214 lines
6.5 KiB
TypeScript
import { useEffect, useRef, useCallback } from "react";
|
|
import { useMap } from "react-leaflet";
|
|
import L from "leaflet";
|
|
import { Z_GHOST_WAYPOINT } from "@trails-cool/map-core";
|
|
|
|
interface RouteInteractionProps {
|
|
coordinates: [number, number, number][]; // [lon, lat, ele]
|
|
segmentBoundaries: number[];
|
|
onInsertWaypoint: (pointIndex: number, lat: number, lon: number) => void;
|
|
disabled?: boolean;
|
|
/** Set true to suspend ghost marker (e.g. while hovering/dragging a waypoint) */
|
|
suspendedRef?: React.RefObject<boolean>;
|
|
}
|
|
|
|
const SNAP_TOLERANCE_PX = 15;
|
|
|
|
const ghostIcon = L.divIcon({
|
|
className: "route-ghost-marker",
|
|
html: `<div style="
|
|
width:14px;height:14px;border-radius:50%;
|
|
background:white;border:3px solid #4a6b40;
|
|
box-shadow:0 1px 4px rgba(0,0,0,0.3);
|
|
transform:translate(-7px,-7px);
|
|
cursor:grab;
|
|
"></div>`,
|
|
iconSize: [0, 0],
|
|
});
|
|
|
|
/**
|
|
* Route interaction layer: shows a ghost marker on hover that can be
|
|
* clicked or dragged to insert a waypoint. Follows brouter-web's pattern
|
|
* of suspending when the mouse is over a waypoint marker.
|
|
*/
|
|
export function RouteInteraction({
|
|
coordinates,
|
|
segmentBoundaries,
|
|
onInsertWaypoint,
|
|
disabled,
|
|
suspendedRef,
|
|
}: RouteInteractionProps) {
|
|
const map = useMap();
|
|
const markerRef = useRef<L.Marker | null>(null);
|
|
const draggingRef = useRef(false);
|
|
const snappedIdxRef = useRef(0);
|
|
const coordinatesRef = useRef(coordinates);
|
|
const segBoundsRef = useRef(segmentBoundaries);
|
|
const onInsertRef = useRef(onInsertWaypoint);
|
|
coordinatesRef.current = coordinates;
|
|
segBoundsRef.current = segmentBoundaries;
|
|
onInsertRef.current = onInsertWaypoint;
|
|
|
|
// Trailer lines (dashed lines from ghost to adjacent waypoints)
|
|
const trailer1Ref = useRef<L.Polyline | null>(null);
|
|
const trailer2Ref = useRef<L.Polyline | null>(null);
|
|
|
|
const findClosestOnRoute = useCallback((latlng: L.LatLng): { idx: number; lat: number; lon: number; distPx: number } | null => {
|
|
const coords = coordinatesRef.current;
|
|
if (coords.length < 2) return null;
|
|
|
|
let minDistPx = Infinity;
|
|
let bestIdx = 0;
|
|
let bestLat = 0;
|
|
let bestLon = 0;
|
|
|
|
// Find closest point on route in pixel space for accuracy
|
|
const clickPt = map.latLngToContainerPoint(latlng);
|
|
|
|
for (let i = 0; i < coords.length; i++) {
|
|
const c = coords[i]!;
|
|
const pt = map.latLngToContainerPoint([c[1], c[0]]);
|
|
const dx = pt.x - clickPt.x;
|
|
const dy = pt.y - clickPt.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < minDistPx) {
|
|
minDistPx = dist;
|
|
bestIdx = i;
|
|
bestLat = c[1];
|
|
bestLon = c[0];
|
|
}
|
|
}
|
|
|
|
return { idx: bestIdx, lat: bestLat, lon: bestLon, distPx: minDistPx };
|
|
}, [map]);
|
|
|
|
useEffect(() => {
|
|
if (disabled) return;
|
|
|
|
// Create persistent ghost marker (hidden until hover)
|
|
const marker = L.marker([0, 0], {
|
|
icon: ghostIcon,
|
|
draggable: true,
|
|
interactive: true,
|
|
zIndexOffset: Z_GHOST_WAYPOINT,
|
|
});
|
|
markerRef.current = marker;
|
|
|
|
// Trailer lines
|
|
const trailer1 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
|
|
const trailer2 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map);
|
|
trailer1Ref.current = trailer1;
|
|
trailer2Ref.current = trailer2;
|
|
|
|
let shown = false;
|
|
|
|
const showMarker = (lat: number, lon: number) => {
|
|
marker.setLatLng([lat, lon]);
|
|
if (!shown) {
|
|
marker.addTo(map);
|
|
shown = true;
|
|
}
|
|
};
|
|
|
|
const hideMarker = () => {
|
|
if (shown && !draggingRef.current) {
|
|
marker.remove();
|
|
shown = false;
|
|
}
|
|
};
|
|
|
|
// Listen for mousemove on the map (distance-based approach, not polyline events)
|
|
const onMouseMove = (e: L.LeafletMouseEvent) => {
|
|
// Suspended while hovering over or dragging a waypoint marker (brouter-web pattern)
|
|
if (draggingRef.current || suspendedRef?.current) {
|
|
hideMarker();
|
|
return;
|
|
}
|
|
|
|
const snap = findClosestOnRoute(e.latlng);
|
|
if (!snap || snap.distPx > SNAP_TOLERANCE_PX) {
|
|
hideMarker();
|
|
return;
|
|
}
|
|
|
|
snappedIdxRef.current = snap.idx;
|
|
showMarker(snap.lat, snap.lon);
|
|
};
|
|
|
|
const onMouseOut = () => {
|
|
hideMarker();
|
|
};
|
|
|
|
// Drag events on the ghost marker
|
|
marker.on("dragstart", () => {
|
|
draggingRef.current = true;
|
|
trailer1.setStyle({ opacity: 0.6 });
|
|
trailer2.setStyle({ opacity: 0.6 });
|
|
});
|
|
|
|
marker.on("drag", (e) => {
|
|
const latlng = (e.target as L.Marker).getLatLng();
|
|
// Update trailer lines to adjacent waypoints
|
|
const idx = snappedIdxRef.current;
|
|
const bounds = segBoundsRef.current;
|
|
const coords = coordinatesRef.current;
|
|
|
|
// Find which segment this point is in
|
|
let segIdx = 0;
|
|
for (let i = bounds.length - 1; i >= 0; i--) {
|
|
if (idx >= bounds[i]!) { segIdx = i; break; }
|
|
}
|
|
|
|
// Trailer to previous waypoint (segment start)
|
|
if (segIdx >= 0) {
|
|
const startCoordIdx = bounds[segIdx]!;
|
|
const c = coords[startCoordIdx];
|
|
if (c) trailer1.setLatLngs([latlng, [c[1], c[0]]]);
|
|
}
|
|
// Trailer to next waypoint (next segment start or end)
|
|
const nextStart = segIdx + 1 < bounds.length ? bounds[segIdx + 1]! : coords.length - 1;
|
|
const nc = coords[nextStart];
|
|
if (nc) trailer2.setLatLngs([latlng, [nc[1], nc[0]]]);
|
|
});
|
|
|
|
marker.on("dragend", (e) => {
|
|
const latlng = (e.target as L.Marker).getLatLng();
|
|
draggingRef.current = false;
|
|
trailer1.setStyle({ opacity: 0 });
|
|
trailer2.setStyle({ opacity: 0 });
|
|
marker.remove();
|
|
shown = false;
|
|
|
|
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
|
|
});
|
|
|
|
// Click on ghost marker inserts at snapped position
|
|
marker.on("click", () => {
|
|
if (draggingRef.current) return;
|
|
const latlng = marker.getLatLng();
|
|
marker.remove();
|
|
shown = false;
|
|
|
|
onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng);
|
|
});
|
|
|
|
// Prevent double-click zoom when clicking ghost marker
|
|
marker.on("dblclick", (e) => {
|
|
L.DomEvent.stop(e.originalEvent);
|
|
});
|
|
|
|
map.on("mousemove", onMouseMove);
|
|
map.on("mouseout", onMouseOut);
|
|
|
|
return () => {
|
|
map.off("mousemove", onMouseMove);
|
|
map.off("mouseout", onMouseOut);
|
|
if (shown) marker.remove();
|
|
trailer1.remove();
|
|
trailer2.remove();
|
|
markerRef.current = null;
|
|
};
|
|
}, [map, disabled, findClosestOnRoute, suspendedRef]);
|
|
|
|
return null;
|
|
}
|