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>
This commit is contained in:
parent
fe3621aab3
commit
b080a15fb1
22 changed files with 1259 additions and 170 deletions
127
apps/planner/app/components/ColoredRoute.tsx
Normal file
127
apps/planner/app/components/ColoredRoute.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useMemo } from "react";
|
||||
import { Polyline } from "react-leaflet";
|
||||
import type L from "leaflet";
|
||||
|
||||
export type ColorMode = "plain" | "elevation" | "surface";
|
||||
|
||||
interface ColoredRouteProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
colorMode: ColorMode;
|
||||
surfaces?: string[];
|
||||
}
|
||||
|
||||
const SURFACE_COLORS: Record<string, string> = {
|
||||
asphalt: "#6b7280",
|
||||
concrete: "#9ca3af",
|
||||
paved: "#6b7280",
|
||||
paving_stones: "#78716c",
|
||||
cobblestone: "#a8a29e",
|
||||
gravel: "#92400e",
|
||||
compacted: "#b45309",
|
||||
"fine_gravel": "#d97706",
|
||||
ground: "#65a30d",
|
||||
dirt: "#84cc16",
|
||||
grass: "#22c55e",
|
||||
sand: "#fbbf24",
|
||||
mud: "#713f12",
|
||||
wood: "#a16207",
|
||||
unpaved: "#ca8a04",
|
||||
path: "#16a34a",
|
||||
track: "#ea580c",
|
||||
};
|
||||
|
||||
const DEFAULT_SURFACE_COLOR = "#9ca3af";
|
||||
|
||||
export function elevationColor(t: number): string {
|
||||
// green (0) → yellow (0.5) → red (1)
|
||||
if (t <= 0.5) {
|
||||
const r = Math.round(255 * (t * 2));
|
||||
return `rgb(${r}, 200, 50)`;
|
||||
}
|
||||
const g = Math.round(200 * (1 - (t - 0.5) * 2));
|
||||
return `rgb(255, ${g}, 50)`;
|
||||
}
|
||||
|
||||
export function ColoredRoute({ coordinates, colorMode, surfaces }: ColoredRouteProps) {
|
||||
const segments = useMemo(() => {
|
||||
if (colorMode === "plain" || coordinates.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (colorMode === "elevation") {
|
||||
const elevations = coordinates.map((c) => c[2]);
|
||||
const minEle = Math.min(...elevations);
|
||||
const maxEle = Math.max(...elevations);
|
||||
const range = maxEle - minEle || 1;
|
||||
|
||||
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
|
||||
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||
const t = (elevations[i]! - minEle) / range;
|
||||
result.push({
|
||||
positions: [
|
||||
[coordinates[i]![1], coordinates[i]![0]],
|
||||
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
|
||||
],
|
||||
color: elevationColor(t),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// surface mode
|
||||
if (!surfaces || surfaces.length < coordinates.length) return null;
|
||||
|
||||
const result: { positions: L.LatLngExpression[]; color: string }[] = [];
|
||||
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||
const surface = surfaces[i] ?? "unknown";
|
||||
result.push({
|
||||
positions: [
|
||||
[coordinates[i]![1], coordinates[i]![0]],
|
||||
[coordinates[i + 1]![1], coordinates[i + 1]![0]],
|
||||
],
|
||||
color: SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [coordinates, colorMode, surfaces]);
|
||||
|
||||
const plainPositions = useMemo(
|
||||
() => coordinates.map((c) => [c[1], c[0]] as L.LatLngExpression),
|
||||
[coordinates],
|
||||
);
|
||||
|
||||
if (!segments) {
|
||||
return (
|
||||
<Polyline
|
||||
positions={plainPositions}
|
||||
pathOptions={{ color: "#2563eb", weight: 4, opacity: 0.8 }}
|
||||
interactive={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg.positions}
|
||||
pathOptions={{ color: seg.color, weight: 4, opacity: 0.9 }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function findSegmentForPoint(
|
||||
pointIndex: number,
|
||||
segmentBoundaries: number[],
|
||||
): number {
|
||||
for (let i = segmentBoundaries.length - 1; i >= 0; i--) {
|
||||
if (pointIndex >= segmentBoundaries[i]!) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export { SURFACE_COLORS, DEFAULT_SURFACE_COLOR };
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { elevationColor, type ColorMode } from "~/components/ColoredRoute";
|
||||
|
||||
interface ElevationPoint {
|
||||
distance: number;
|
||||
|
|
@ -59,6 +60,7 @@ interface ElevationChartProps {
|
|||
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
|
|
@ -71,6 +73,8 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
|||
} else {
|
||||
setPoints([]);
|
||||
}
|
||||
const mode = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||
setColorMode(mode ?? "plain");
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
|
|
@ -107,27 +111,54 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
|||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Fill area
|
||||
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();
|
||||
if (colorMode === "elevation") {
|
||||
// Elevation-colored fill and line segments
|
||||
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);
|
||||
|
||||
// Line
|
||||
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));
|
||||
// Fill segment
|
||||
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();
|
||||
|
||||
// Line segment
|
||||
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 {
|
||||
// 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();
|
||||
}
|
||||
ctx.strokeStyle = "#2563eb";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Axis labels
|
||||
ctx.fillStyle = "#6b7280";
|
||||
|
|
@ -172,12 +203,12 @@ export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
|||
ctx.fillText(label, labelX, PADDING.top + 10);
|
||||
}
|
||||
},
|
||||
[points],
|
||||
[points, colorMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
drawChart(hoverIdx);
|
||||
}, [points, hoverIdx, drawChart]);
|
||||
}, [points, hoverIdx, colorMode, drawChart]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
|
|
|
|||
118
apps/planner/app/components/MidpointHandles.tsx
Normal file
118
apps/planner/app/components/MidpointHandles.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
function waypointIcon(index: number): L.DivIcon {
|
||||
|
|
@ -42,9 +44,23 @@ interface PlannerMapProps {
|
|||
highlightPosition?: [number, number] | null;
|
||||
}
|
||||
|
||||
function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) {
|
||||
function MapExposer() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as Record<string, unknown>).__leafletMap = map;
|
||||
}
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject<boolean> }) {
|
||||
useMapEvents({
|
||||
click(e) {
|
||||
if (suppressRef.current) {
|
||||
suppressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onAdd(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
});
|
||||
|
|
@ -156,9 +172,13 @@ function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => v
|
|||
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
||||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
const suppressMapClickRef = useRef(false);
|
||||
|
||||
// Sync waypoints from Yjs
|
||||
useEffect(() => {
|
||||
|
|
@ -178,23 +198,49 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
};
|
||||
}, [yjs.waypoints, onRouteRequest]);
|
||||
|
||||
// Sync route data from Yjs
|
||||
// Sync route data from Yjs (enriched: coordinates + segment boundaries)
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojson) {
|
||||
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;
|
||||
|
||||
if (coordsJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(geojson);
|
||||
const coords = parsed.features?.[0]?.geometry?.coordinates;
|
||||
if (coords) {
|
||||
setRouteGeoJson(coords.map((c: number[]) => [c[1], c[0]] as L.LatLngExpression));
|
||||
}
|
||||
setRouteCoordinates(JSON.parse(coordsJson));
|
||||
} catch {
|
||||
// Invalid GeoJSON
|
||||
setRouteCoordinates(null);
|
||||
}
|
||||
} else {
|
||||
setRouteGeoJson(null);
|
||||
// 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) {
|
||||
setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number]));
|
||||
}
|
||||
} catch { setRouteCoordinates(null); }
|
||||
} else {
|
||||
setRouteCoordinates(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (boundsJson) {
|
||||
try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); }
|
||||
} else {
|
||||
setSegmentBoundaries([]);
|
||||
}
|
||||
|
||||
const surfacesJson = yjs.routeData.get("surfaces") as string | undefined;
|
||||
if (surfacesJson) {
|
||||
try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); }
|
||||
} else {
|
||||
setSurfaces([]);
|
||||
}
|
||||
|
||||
if (modeVal) setColorMode(modeVal);
|
||||
};
|
||||
|
||||
yjs.routeData.observe(update);
|
||||
|
|
@ -215,6 +261,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
[yjs.waypoints],
|
||||
);
|
||||
|
||||
const insertWaypointAtSegment = useCallback(
|
||||
(segmentIndex: number, lat: number, lon: number) => {
|
||||
const yMap = new Y.Map();
|
||||
yMap.set("lat", lat);
|
||||
yMap.set("lon", lon);
|
||||
// Insert after the segment's start waypoint
|
||||
yjs.waypoints.insert(segmentIndex + 1, [yMap]);
|
||||
},
|
||||
[yjs.waypoints],
|
||||
);
|
||||
|
||||
const handleRouteInsert = useCallback(
|
||||
(pointIndex: number, lat: number, lon: number) => {
|
||||
suppressMapClickRef.current = true;
|
||||
const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries);
|
||||
insertWaypointAtSegment(segIdx, lat, lon);
|
||||
},
|
||||
[segmentBoundaries, insertWaypointAtSegment],
|
||||
);
|
||||
|
||||
const moveWaypoint = useCallback(
|
||||
(index: number, lat: number, lng: number) => {
|
||||
const yMap = yjs.waypoints.get(index);
|
||||
|
|
@ -245,7 +311,8 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
))}
|
||||
</LayersControl>
|
||||
|
||||
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} />
|
||||
<MapExposer />
|
||||
<MapClickHandler onAdd={noGoDrawing ? () => {} : addWaypoint} suppressRef={suppressMapClickRef} />
|
||||
<CursorTracker awareness={yjs.awareness} />
|
||||
<NoGoAreaLayer noGoAreas={yjs.noGoAreas} doc={yjs.doc} enabled={noGoDrawing} onToggle={toggleNoGoDraw} />
|
||||
<NoGoAreaButton active={noGoDrawing} onClick={toggleNoGoDraw} />
|
||||
|
|
@ -269,7 +336,21 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMa
|
|||
/>
|
||||
))}
|
||||
|
||||
{routeGeoJson && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
|
||||
{routeCoordinates && routeCoordinates.length >= 2 && (
|
||||
<>
|
||||
<ColoredRoute
|
||||
coordinates={routeCoordinates}
|
||||
colorMode={colorMode}
|
||||
surfaces={surfaces}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
segmentBoundaries={segmentBoundaries}
|
||||
onInsertWaypoint={handleRouteInsert}
|
||||
disabled={noGoDrawing}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{highlightPosition && (
|
||||
<CircleMarker
|
||||
|
|
|
|||
206
apps/planner/app/components/RouteInteraction.tsx
Normal file
206
apps/planner/app/components/RouteInteraction.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
|
||||
interface RouteInteractionProps {
|
||||
coordinates: [number, number, number][]; // [lon, lat, ele]
|
||||
segmentBoundaries: number[];
|
||||
onInsertWaypoint: (pointIndex: number, lat: number, lon: number) => void;
|
||||
disabled?: 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 #2563eb;
|
||||
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 a single persistent draggable Marker with distance-based mouseout.
|
||||
*/
|
||||
export function RouteInteraction({
|
||||
coordinates,
|
||||
segmentBoundaries,
|
||||
onInsertWaypoint,
|
||||
disabled,
|
||||
}: 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: 1000,
|
||||
});
|
||||
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) => {
|
||||
if (draggingRef.current) 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 (Leaflet's L.Draggable handles text selection)
|
||||
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 as unknown as Event);
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -105,6 +105,37 @@ function useAwarenessToasts(yjs: YjsState | null, t: TFunction) {
|
|||
return toasts;
|
||||
}
|
||||
|
||||
function ColorModeToggle({ yjs }: { yjs: YjsState }) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [current, setCurrent] = useState<string>("plain");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
setCurrent((yjs.routeData.get("colorMode") as string) ?? "plain");
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const setMode = (mode: string) => {
|
||||
yjs.routeData.set("colorMode", mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
value={current}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
className="rounded border border-gray-300 px-2 py-1 text-xs text-gray-700"
|
||||
title={t("colorMode.label")}
|
||||
>
|
||||
<option value="plain">{t("colorMode.plain")}</option>
|
||||
<option value="elevation">{t("colorMode.elevation")}</option>
|
||||
<option value="surface">{t("colorMode.surface")}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTabs({ yjs, routeStats }: { yjs: YjsState; routeStats: ReturnType<typeof useRouting>["routeStats"] }) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [tab, setTab] = useState<"waypoints" | "notes">("waypoints");
|
||||
|
|
@ -186,6 +217,7 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
|
|||
/>
|
||||
)}
|
||||
<ExportButton yjs={yjs} />
|
||||
<ColorModeToggle yjs={yjs} />
|
||||
{computing && (
|
||||
<span className="text-xs text-blue-600">{t("computingRoute")}</span>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue