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
|
|
@ -3,74 +3,17 @@ import { useTranslation } from "react-i18next";
|
|||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
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,
|
||||
SURFACE_COLORS,
|
||||
DEFAULT_SURFACE_COLOR,
|
||||
HIGHWAY_COLORS,
|
||||
DEFAULT_HIGHWAY_COLOR,
|
||||
SMOOTHNESS_COLORS,
|
||||
DEFAULT_SMOOTHNESS_COLOR,
|
||||
CYCLEWAY_COLORS,
|
||||
DEFAULT_CYCLEWAY_COLOR,
|
||||
} from "@trails-cool/map-core";
|
||||
import type { ColorMode } from "~/components/ColoredRoute";
|
||||
|
||||
function gradeColor(grade: number): string {
|
||||
const absGrade = Math.abs(grade);
|
||||
if (absGrade < 3) return "#22c55e"; // green: flat/gentle
|
||||
if (absGrade < 6) return "#eab308"; // yellow: moderate
|
||||
if (absGrade < 10) return "#f97316"; // orange: steep
|
||||
if (absGrade < 15) return "#ef4444"; // red: very steep
|
||||
return "#991b1b"; // dark red: extreme
|
||||
}
|
||||
|
||||
interface ElevationPoint {
|
||||
distance: number;
|
||||
elevation: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
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 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));
|
||||
}
|
||||
|
||||
const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
import { drawElevationChart, PADDING } from "~/lib/elevation-chart-draw";
|
||||
import { useElevationData } from "~/lib/use-elevation-data";
|
||||
|
||||
interface ElevationChartProps {
|
||||
yjs: YjsState;
|
||||
|
|
@ -83,19 +26,9 @@ interface ElevationChartProps {
|
|||
|
||||
export function ElevationChart({ yjs, onHover, highlightDistance, onClickPosition, onDragSelect, days }: ElevationChartProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const { points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes } = useElevationData(yjs.routeData);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
|
||||
const [tracktypes, setTracktypes] = useState<string[]>([]);
|
||||
const [cycleways, setCycleways] = useState<string[]>([]);
|
||||
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
const isExternalHover = useRef(false);
|
||||
const dragStartX = useRef<number | null>(null);
|
||||
const dragStartClientX = useRef<number | null>(null);
|
||||
|
|
@ -124,68 +57,9 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
}
|
||||
isExternalHover.current = true;
|
||||
setHoverIdx(closest);
|
||||
// Do NOT call onHover here to avoid feedback loop
|
||||
}, [highlightDistance, points]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const geojson = yjs.routeData.get("geojson") as string | undefined;
|
||||
if (geojson) {
|
||||
setPoints(extractElevation(geojson));
|
||||
} else {
|
||||
setPoints([]);
|
||||
}
|
||||
const mode = yjs.routeData.get("colorMode") as ColorMode | undefined;
|
||||
setColorMode(mode ?? "plain");
|
||||
const surfacesJson = yjs.routeData.get("surfaces") as string | undefined;
|
||||
if (surfacesJson) {
|
||||
try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); }
|
||||
} else {
|
||||
setSurfaces([]);
|
||||
}
|
||||
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
|
||||
if (highwaysJson) {
|
||||
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
|
||||
} else {
|
||||
setHighways([]);
|
||||
}
|
||||
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
|
||||
if (maxspeedsJson) {
|
||||
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
|
||||
} else {
|
||||
setMaxspeeds([]);
|
||||
}
|
||||
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
|
||||
if (smoothnessesJson) {
|
||||
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
|
||||
} else {
|
||||
setSmoothnesses([]);
|
||||
}
|
||||
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
|
||||
if (tracktypesJson) {
|
||||
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
|
||||
} else {
|
||||
setTracktypes([]);
|
||||
}
|
||||
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
|
||||
if (cyclewaysJson) {
|
||||
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
|
||||
} else {
|
||||
setCycleways([]);
|
||||
}
|
||||
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
|
||||
if (bikeroutesJson) {
|
||||
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
|
||||
} else {
|
||||
setBikeroutes([]);
|
||||
}
|
||||
};
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const drawChart = useCallback(
|
||||
const draw = useCallback(
|
||||
(highlightIdx: number | null) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
|
|
@ -199,384 +73,29 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.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") {
|
||||
// Grade-colored segments: color by steepness
|
||||
for (let i = 0; i < points.length - 1; 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;
|
||||
const color = gradeColor(grade);
|
||||
|
||||
// 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(")", ", 0.25)").replace("rgb", "rgba").replace("#", "");
|
||||
// hex to rgba fill
|
||||
ctx.fillStyle = color + "40";
|
||||
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 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);
|
||||
|
||||
// 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 if (colorMode === "surface" && surfaces.length >= points.length) {
|
||||
// Surface-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const surface = surfaces[i] ?? "unknown";
|
||||
const color = SURFACE_COLORS[surface] ?? DEFAULT_SURFACE_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "highway" && highways.length >= points.length) {
|
||||
// Highway-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const highway = highways[i] ?? "unknown";
|
||||
const color = HIGHWAY_COLORS[highway] ?? DEFAULT_HIGHWAY_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "maxspeed" && maxspeeds.length >= points.length) {
|
||||
// Maxspeed-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const speed = maxspeeds[i] ?? "unknown";
|
||||
const color = maxspeedColor(speed);
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "smoothness" && smoothnesses.length >= points.length) {
|
||||
// Smoothness-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const smoothness = smoothnesses[i] ?? "unknown";
|
||||
const color = SMOOTHNESS_COLORS[smoothness] ?? DEFAULT_SMOOTHNESS_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "tracktype" && tracktypes.length >= points.length) {
|
||||
// Track type-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const tracktype = tracktypes[i] ?? "unknown";
|
||||
const color = TRACKTYPE_COLORS[tracktype] ?? DEFAULT_TRACKTYPE_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "cycleway" && cycleways.length >= points.length) {
|
||||
// Cycleway-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const cycleway = cycleways[i] ?? "unknown";
|
||||
const color = CYCLEWAY_COLORS[cycleway] ?? DEFAULT_CYCLEWAY_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} else if (colorMode === "bikeroute" && bikeroutes.length >= points.length) {
|
||||
// Bike route-colored segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i]!;
|
||||
const p1 = points[i + 1]!;
|
||||
const bikeroute = bikeroutes[i] ?? "none";
|
||||
const color = BIKEROUTE_COLORS[bikeroute] ?? DEFAULT_BIKEROUTE_COLOR;
|
||||
|
||||
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();
|
||||
}
|
||||
} 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++) {
|
||||
// Find the point closest to the day boundary distance
|
||||
const boundaryDist = days.slice(0, d + 1).reduce((sum, s) => sum + s.distance, 0);
|
||||
const bx = toX(boundaryDist);
|
||||
|
||||
// Dashed vertical line
|
||||
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([]);
|
||||
|
||||
// Day label at top
|
||||
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 (highlightIdx !== null && highlightIdx >= 0 && highlightIdx < points.length) {
|
||||
const p = points[highlightIdx]!;
|
||||
const hx = toX(p.distance);
|
||||
const hy = toY(p.elevation);
|
||||
|
||||
// Vertical line
|
||||
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();
|
||||
|
||||
// Dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(hx, hy, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "white";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Info label
|
||||
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" && highlightIdx > 0) {
|
||||
const prev = points[highlightIdx - 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[highlightIdx]) {
|
||||
label += ` · ${surfaces[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "highway" && highways[highlightIdx]) {
|
||||
label += ` · ${highways[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "maxspeed" && maxspeeds[highlightIdx]) {
|
||||
const s = maxspeeds[highlightIdx]!;
|
||||
label += ` · ${s === "unknown" ? s : `${s} km/h`}`;
|
||||
}
|
||||
if (colorMode === "smoothness" && smoothnesses[highlightIdx]) {
|
||||
label += ` · ${smoothnesses[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "tracktype" && tracktypes[highlightIdx]) {
|
||||
label += ` · ${tracktypes[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "cycleway" && cycleways[highlightIdx]) {
|
||||
label += ` · ${cycleways[highlightIdx]}`;
|
||||
}
|
||||
if (colorMode === "bikeroute" && bikeroutes[highlightIdx]) {
|
||||
const names: Record<string, string> = { icn: "International", ncn: "National", rcn: "Regional", lcn: "Local", none: "None" };
|
||||
label += ` · ${names[bikeroutes[highlightIdx]!] ?? bikeroutes[highlightIdx]}`;
|
||||
}
|
||||
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.current && dragStartX.current != null && dragCurrentX.current != null) {
|
||||
const x1 = Math.max(PADDING.left, Math.min(dragStartX.current, PADDING.left + chartW));
|
||||
const x2 = Math.max(PADDING.left, Math.min(dragCurrentX.current, 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);
|
||||
}
|
||||
drawElevationChart(ctx, rect.width, rect.height, {
|
||||
points,
|
||||
colorMode,
|
||||
surfaces,
|
||||
highways,
|
||||
maxspeeds,
|
||||
smoothnesses,
|
||||
tracktypes,
|
||||
cycleways,
|
||||
bikeroutes,
|
||||
hoverIdx: highlightIdx,
|
||||
isDragging: isDragging.current,
|
||||
dragStartX: dragStartX.current,
|
||||
dragCurrentX: dragCurrentX.current,
|
||||
days,
|
||||
});
|
||||
},
|
||||
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days, t],
|
||||
[points, colorMode, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, days],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
drawChart(hoverIdx);
|
||||
}, [points, hoverIdx, colorMode, drawChart]);
|
||||
draw(hoverIdx);
|
||||
}, [points, hoverIdx, colorMode, draw]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
|
|
@ -588,13 +107,12 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||
const ratio = (mouseX - PADDING.left) / chartW;
|
||||
|
||||
// Handle drag selection
|
||||
if (dragStartClientX.current != null) {
|
||||
const dx = Math.abs(e.clientX - dragStartClientX.current);
|
||||
if (dx > 5) {
|
||||
isDragging.current = true;
|
||||
dragCurrentX.current = mouseX;
|
||||
drawChart(hoverIdx);
|
||||
draw(hoverIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -608,7 +126,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const maxDist = points[points.length - 1]!.distance;
|
||||
const targetDist = ratio * maxDist;
|
||||
|
||||
// Find closest point
|
||||
let closest = 0;
|
||||
let minDiff = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
|
|
@ -624,7 +141,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const p = points[closest]!;
|
||||
onHover?.([p.lat, p.lon]);
|
||||
},
|
||||
[points, onHover, hoverIdx, drawChart],
|
||||
[points, onHover, hoverIdx, draw],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
|
|
@ -636,18 +153,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
dragCurrentX.current = null;
|
||||
}, [onHover]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dragStartX.current = e.clientX - rect.left;
|
||||
dragStartClientX.current = e.clientX;
|
||||
isDragging.current = false;
|
||||
dragCurrentX.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dragStartX.current = e.clientX - rect.left;
|
||||
dragStartClientX.current = e.clientX;
|
||||
isDragging.current = false;
|
||||
dragCurrentX.current = null;
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
|
|
@ -665,14 +179,12 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const maxDist = points[points.length - 1]!.distance;
|
||||
|
||||
if (isDragging.current && dragStartX.current != null) {
|
||||
// Drag-select: compute bounding box of selected range
|
||||
const endX = e.clientX - rect.left;
|
||||
const startRatio = Math.max(0, Math.min(1, (dragStartX.current - PADDING.left) / chartW));
|
||||
const endRatio = Math.max(0, Math.min(1, (endX - PADDING.left) / chartW));
|
||||
const startDist = Math.min(startRatio, endRatio) * maxDist;
|
||||
const endDist = Math.max(startRatio, endRatio) * maxDist;
|
||||
|
||||
// Find all points in the selected distance range
|
||||
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
|
||||
let found = false;
|
||||
for (const p of points) {
|
||||
|
|
@ -684,12 +196,10 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (found && onDragSelect) {
|
||||
onDragSelect([[minLat, minLon], [maxLat, maxLon]]);
|
||||
}
|
||||
} else if (dragStartClientX.current != null) {
|
||||
// Click (not drag): pan map to clicked point
|
||||
const dx = Math.abs(e.clientX - dragStartClientX.current);
|
||||
if (dx <= 5 && onClickPosition) {
|
||||
const mouseX = e.clientX - rect.left;
|
||||
|
|
@ -705,8 +215,7 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
closest = i;
|
||||
}
|
||||
}
|
||||
const p = points[closest]!;
|
||||
onClickPosition([p.lat, p.lon]);
|
||||
onClickPosition([points[closest]!.lat, points[closest]!.lon]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -715,12 +224,11 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
dragStartClientX.current = null;
|
||||
isDragging.current = false;
|
||||
dragCurrentX.current = null;
|
||||
drawChart(hoverIdx);
|
||||
draw(hoverIdx);
|
||||
},
|
||||
[points, onClickPosition, onDragSelect, hoverIdx, drawChart],
|
||||
[points, onClickPosition, onDragSelect, hoverIdx, draw],
|
||||
);
|
||||
|
||||
// Touch handlers for mobile
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -729,13 +237,11 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
// Single touch: start scrub + potential drag
|
||||
const x = e.touches[0]!.clientX - rect.left;
|
||||
dragStartX.current = x;
|
||||
dragStartClientX.current = e.touches[0]!.clientX;
|
||||
isDragging.current = false;
|
||||
dragCurrentX.current = null;
|
||||
// Immediately show highlight at touch position
|
||||
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||
const ratio = (x - PADDING.left) / chartW;
|
||||
if (ratio >= 0 && ratio <= 1 && points.length >= 2) {
|
||||
|
|
@ -753,16 +259,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
onHover?.([p.lat, p.lon]);
|
||||
}
|
||||
} else if (e.touches.length === 2) {
|
||||
// Two fingers: range select
|
||||
const x1 = e.touches[0]!.clientX - rect.left;
|
||||
const x2 = e.touches[1]!.clientX - rect.left;
|
||||
dragStartX.current = Math.min(x1, x2);
|
||||
dragCurrentX.current = Math.max(x1, x2);
|
||||
isDragging.current = true;
|
||||
drawChart(hoverIdx);
|
||||
draw(hoverIdx);
|
||||
}
|
||||
},
|
||||
[points, onHover, hoverIdx, drawChart],
|
||||
[points, onHover, hoverIdx, draw],
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
|
|
@ -773,7 +278,6 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
if (e.touches.length === 1 && !isDragging.current) {
|
||||
// Single touch scrub: update highlight
|
||||
const x = e.touches[0]!.clientX - rect.left;
|
||||
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||
const ratio = (x - PADDING.left) / chartW;
|
||||
|
|
@ -792,22 +296,20 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
onHover?.([p.lat, p.lon]);
|
||||
}
|
||||
} else if (e.touches.length === 2) {
|
||||
// Two finger range select: update selection
|
||||
const x1 = e.touches[0]!.clientX - rect.left;
|
||||
const x2 = e.touches[1]!.clientX - rect.left;
|
||||
dragStartX.current = Math.min(x1, x2);
|
||||
dragCurrentX.current = Math.max(x1, x2);
|
||||
isDragging.current = true;
|
||||
drawChart(hoverIdx);
|
||||
draw(hoverIdx);
|
||||
}
|
||||
},
|
||||
[points, onHover, hoverIdx, drawChart],
|
||||
[points, onHover, hoverIdx, draw],
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (isDragging.current && dragStartX.current != null && dragCurrentX.current != null && points.length >= 2) {
|
||||
// Two finger range complete: zoom map
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
|
@ -834,10 +336,8 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
}
|
||||
}
|
||||
} else if (e.changedTouches.length === 1 && onClickPosition && dragStartClientX.current != null) {
|
||||
// Single tap (no significant movement): pan map
|
||||
const dx = Math.abs(e.changedTouches[0]!.clientX - dragStartClientX.current);
|
||||
if (dx <= 10) {
|
||||
// Treat as tap → pan
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas && points.length >= 2) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
|
@ -859,16 +359,15 @@ export function ElevationChart({ yjs, onHover, highlightDistance, onClickPositio
|
|||
}
|
||||
}
|
||||
|
||||
// Clear highlight on touch end
|
||||
setHoverIdx(null);
|
||||
onHover?.(null);
|
||||
dragStartX.current = null;
|
||||
dragStartClientX.current = null;
|
||||
isDragging.current = false;
|
||||
dragCurrentX.current = null;
|
||||
drawChart(null);
|
||||
draw(null);
|
||||
},
|
||||
[points, onHover, onClickPosition, onDragSelect, drawChart],
|
||||
[points, onHover, onClickPosition, onDragSelect, draw],
|
||||
);
|
||||
|
||||
const setMode = useCallback((mode: string) => {
|
||||
|
|
|
|||
275
apps/planner/app/components/MapHelpers.tsx
Normal file
275
apps/planner/app/components/MapHelpers.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import { useMap, useMapEvents, Marker } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { overlayLayers } from "@trails-cool/map";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { Z_CURSOR } from "@trails-cool/map-core";
|
||||
|
||||
// Exposes the Leaflet map instance on window.__leafletMap for E2E testing and external integrations.
|
||||
export function MapExposer() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as Record<string, unknown>).__leafletMap = map;
|
||||
}
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fits the map to the route bounds once on first load.
|
||||
export function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) {
|
||||
const map = useMap();
|
||||
const hasFitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFitted.current || !coordinates || coordinates.length < 2) return;
|
||||
|
||||
const bounds = L.latLngBounds(
|
||||
coordinates.map((c) => [c[1]!, c[0]!] as [number, number]),
|
||||
);
|
||||
if (!bounds.isValid()) return;
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
map.invalidateSize();
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
hasFitted.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [coordinates, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Routes map click events to a callback, with a suppressRef for one-shot suppression (e.g. after route insert).
|
||||
export 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);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Broadcasts the local user's cursor position via Yjs awareness and renders remote cursors.
|
||||
export function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
||||
const map = useMap();
|
||||
const [cursors, setCursors] = useState<
|
||||
Map<number, { lat: number; lng: number; color: string; name: string }>
|
||||
>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const localId = awareness.clientID;
|
||||
|
||||
const handleMouseMove = (e: L.LeafletMouseEvent) => {
|
||||
awareness.setLocalStateField("mapCursor", { lat: e.latlng.lat, lng: e.latlng.lng });
|
||||
};
|
||||
const handleMouseOut = () => {
|
||||
awareness.setLocalStateField("mapCursor", null);
|
||||
};
|
||||
|
||||
map.on("mousemove", handleMouseMove);
|
||||
map.on("mouseout", handleMouseOut);
|
||||
|
||||
const updateCursors = () => {
|
||||
const states = awareness.getStates();
|
||||
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
|
||||
states.forEach((state, clientId) => {
|
||||
if (clientId !== localId && state.mapCursor && state.user) {
|
||||
newCursors.set(clientId, {
|
||||
lat: state.mapCursor.lat,
|
||||
lng: state.mapCursor.lng,
|
||||
color: state.user.color,
|
||||
name: state.user.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
setCursors(newCursors);
|
||||
};
|
||||
|
||||
awareness.on("change", updateCursors);
|
||||
|
||||
return () => {
|
||||
map.off("mousemove", handleMouseMove);
|
||||
map.off("mouseout", handleMouseOut);
|
||||
awareness.off("change", updateCursors);
|
||||
};
|
||||
}, [map, awareness]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
|
||||
<Marker
|
||||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
zIndexOffset={Z_CURSOR}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;z-index:400;pointer-events:none">
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
|
||||
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
|
||||
</svg>
|
||||
<span style="
|
||||
position:absolute;left:16px;top:2px;
|
||||
background:${cursor.color};color:white;
|
||||
padding:1px 6px;border-radius:6px;
|
||||
font-size:11px;font-weight:500;white-space:nowrap;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.2);
|
||||
line-height:1.4;
|
||||
">${cursor.name}</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Leaflet control button for toggling no-go area drawing mode.
|
||||
export function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
|
||||
<div className="leaflet-control leaflet-bar">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
title={active ? "Cancel no-go area" : "Draw no-go area"}
|
||||
onClick={(e) => { e.preventDefault(); onClick(); }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
fontSize: 16,
|
||||
background: active ? "#fecaca" : "white",
|
||||
color: active ? "#dc2626" : "#333",
|
||||
}}
|
||||
>
|
||||
⊘
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keeps Yjs routeData and the Leaflet LayersControl in sync (both directions).
|
||||
export function OverlaySync({
|
||||
yjs,
|
||||
onOverlayChange,
|
||||
onBaseLayerChange,
|
||||
}: {
|
||||
yjs: YjsState;
|
||||
onOverlayChange: (ids: string[]) => void;
|
||||
onBaseLayerChange: (name: string) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const suppressRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAdd = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
const layer = overlayLayers.find((l) => l.name === e.name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!current.includes(layer.id)) {
|
||||
const updated = [...current, layer.id];
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
onOverlayChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
const layer = overlayLayers.find((l) => l.name === e.name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
const updated = current.filter((id) => id !== layer.id);
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
onOverlayChange(updated);
|
||||
};
|
||||
|
||||
const handleBaseChange = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
yjs.routeData.set("baseLayer", e.name);
|
||||
};
|
||||
|
||||
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
|
||||
map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn);
|
||||
map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
|
||||
return () => {
|
||||
map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn);
|
||||
map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn);
|
||||
map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
|
||||
};
|
||||
}, [map, yjs, onOverlayChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
if (raw) {
|
||||
try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ }
|
||||
}
|
||||
const base = yjs.routeData.get("baseLayer") as string | undefined;
|
||||
if (base) onBaseLayerChange(base);
|
||||
};
|
||||
yjs.routeData.observe(handleChange);
|
||||
handleChange();
|
||||
return () => yjs.routeData.unobserve(handleChange);
|
||||
}, [yjs, onOverlayChange, onBaseLayerChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Triggers POI refreshes on map move and category changes.
|
||||
export function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
|
||||
const map = useMap();
|
||||
const refreshRef = useRef(poiState.refresh);
|
||||
refreshRef.current = poiState.refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
refreshRef.current(
|
||||
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
|
||||
zoom,
|
||||
);
|
||||
};
|
||||
map.on("moveend", refresh);
|
||||
return () => { map.off("moveend", refresh); };
|
||||
}, [map]);
|
||||
|
||||
const prevCategories = useRef(poiState.enabledCategories);
|
||||
useEffect(() => {
|
||||
if (prevCategories.current === poiState.enabledCategories) return;
|
||||
prevCategories.current = poiState.enabledCategories;
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
poiState.refresh(
|
||||
{ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() },
|
||||
zoom,
|
||||
);
|
||||
}, [map, poiState.enabledCategories, poiState.refresh]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,42 +1,33 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DayStage } from "@trails-cool/gpx";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers, overlayLayers } from "@trails-cool/map";
|
||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||
import { isOvernight } from "~/lib/overnight";
|
||||
import { setOvernight } from "~/lib/overnight";
|
||||
import { usePois } from "~/lib/use-pois";
|
||||
import { useProfileDefaults } from "~/lib/use-profile-defaults";
|
||||
import { useYjsPoiSync } from "~/lib/use-yjs-poi-sync";
|
||||
import { snapToPoi } from "~/lib/poi-snap";
|
||||
import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core";
|
||||
import { Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core";
|
||||
import { NoGoAreaLayer } from "./NoGoAreaLayer";
|
||||
import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute";
|
||||
import { ColoredRoute } from "./ColoredRoute";
|
||||
import { RouteInteraction } from "./RouteInteraction";
|
||||
import { PoiPanel, PoiMarkers } from "./PoiPanel";
|
||||
import { WaypointContextMenu } from "./WaypointContextMenu";
|
||||
import {
|
||||
MapExposer,
|
||||
RouteFitter,
|
||||
MapClickHandler,
|
||||
CursorTracker,
|
||||
NoGoAreaButton,
|
||||
OverlaySync,
|
||||
PoiRefresher,
|
||||
} from "./MapHelpers";
|
||||
import { useWaypointManager } from "~/lib/use-waypoint-manager";
|
||||
import { useGpxDrop } from "~/lib/use-gpx-drop";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
/** Distance from a point to a line segment in degrees (approximate) */
|
||||
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 waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon {
|
||||
const bg = overnight ? "#8B6D3A" : "#2563eb";
|
||||
const scale = highlighted ? "scale(1.17)" : "scale(1)";
|
||||
|
|
@ -70,26 +61,10 @@ function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon {
|
|||
});
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
interface PlannerMapProps {
|
||||
yjs: YjsState;
|
||||
sessionId: string;
|
||||
onRouteRequest?: (waypoints: WaypointData[]) => void;
|
||||
onRouteRequest?: (waypoints: { lat: number; lon: number; name?: string; overnight: boolean }[]) => void;
|
||||
onImportError?: (message: string) => void;
|
||||
highlightPosition?: [number, number] | null;
|
||||
highlightedWaypoint?: number | null;
|
||||
|
|
@ -97,625 +72,45 @@ interface PlannerMapProps {
|
|||
days?: DayStage[];
|
||||
}
|
||||
|
||||
function MapExposer() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as Record<string, unknown>).__leafletMap = map;
|
||||
}
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) {
|
||||
const map = useMap();
|
||||
const hasFitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFitted.current || !coordinates || coordinates.length < 2) return;
|
||||
|
||||
// Coordinates are in [lon, lat, elevation] GeoJSON format
|
||||
const bounds = L.latLngBounds(
|
||||
coordinates.map((c) => [c[1]!, c[0]!] as [number, number]),
|
||||
);
|
||||
|
||||
if (!bounds.isValid()) return;
|
||||
|
||||
// Delay fitBounds so the layout has settled (elevation chart may resize the map)
|
||||
const raf = requestAnimationFrame(() => {
|
||||
map.invalidateSize();
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
|
||||
hasFitted.current = true;
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [coordinates, 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);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
||||
const map = useMap();
|
||||
const [cursors, setCursors] = useState<Map<number, { lat: number; lng: number; color: string; name: string }>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const localId = awareness.clientID;
|
||||
|
||||
const handleMouseMove = (e: L.LeafletMouseEvent) => {
|
||||
awareness.setLocalStateField("mapCursor", {
|
||||
lat: e.latlng.lat,
|
||||
lng: e.latlng.lng,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseOut = () => {
|
||||
awareness.setLocalStateField("mapCursor", null);
|
||||
};
|
||||
|
||||
map.on("mousemove", handleMouseMove);
|
||||
map.on("mouseout", handleMouseOut);
|
||||
|
||||
const updateCursors = () => {
|
||||
const states = awareness.getStates();
|
||||
const newCursors = new Map<number, { lat: number; lng: number; color: string; name: string }>();
|
||||
states.forEach((state, clientId) => {
|
||||
if (clientId !== localId && state.mapCursor && state.user) {
|
||||
newCursors.set(clientId, {
|
||||
lat: state.mapCursor.lat,
|
||||
lng: state.mapCursor.lng,
|
||||
color: state.user.color,
|
||||
name: state.user.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
setCursors(newCursors);
|
||||
};
|
||||
|
||||
awareness.on("change", updateCursors);
|
||||
|
||||
return () => {
|
||||
map.off("mousemove", handleMouseMove);
|
||||
map.off("mouseout", handleMouseOut);
|
||||
awareness.off("change", updateCursors);
|
||||
};
|
||||
}, [map, awareness]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(cursors.entries()).map(([clientId, cursor]) => (
|
||||
<Marker
|
||||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
zIndexOffset={Z_CURSOR}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;z-index:400;pointer-events:none">
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3))">
|
||||
<path d="M0 0L16 12L8 12L4 20Z" fill="${cursor.color}" />
|
||||
</svg>
|
||||
<span style="
|
||||
position:absolute;left:16px;top:2px;
|
||||
background:${cursor.color};color:white;
|
||||
padding:1px 6px;border-radius:6px;
|
||||
font-size:11px;font-weight:500;white-space:nowrap;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.2);
|
||||
line-height:1.4;
|
||||
">${cursor.name}</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Prevent clicks from reaching the Leaflet map (same as built-in controls)
|
||||
useEffect(() => {
|
||||
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="leaflet-top leaflet-left" style={{ marginTop: 80 }}>
|
||||
<div className="leaflet-control leaflet-bar">
|
||||
<a
|
||||
href="#"
|
||||
role="button"
|
||||
title={active ? "Cancel no-go area" : "Draw no-go area"}
|
||||
onClick={(e) => { e.preventDefault(); onClick(); }}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
fontSize: 16,
|
||||
background: active ? "#fecaca" : "white",
|
||||
color: active ? "#dc2626" : "#333",
|
||||
}}
|
||||
>
|
||||
⊘
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) {
|
||||
const map = useMap();
|
||||
const suppressRef = useRef(false);
|
||||
|
||||
// Map events → Yjs
|
||||
useEffect(() => {
|
||||
const handleAdd = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
const name = e.name;
|
||||
const layer = overlayLayers.find((l) => l.name === name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!current.includes(layer.id)) {
|
||||
const updated = [...current, layer.id];
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
onOverlayChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
const name = e.name;
|
||||
const layer = overlayLayers.find((l) => l.name === name);
|
||||
if (!layer) return;
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
const current: string[] = raw ? JSON.parse(raw) : [];
|
||||
const updated = current.filter((id) => id !== layer.id);
|
||||
yjs.routeData.set("overlays", JSON.stringify(updated));
|
||||
onOverlayChange(updated);
|
||||
};
|
||||
|
||||
// Base layer change → Yjs
|
||||
const handleBaseChange = (e: L.LayersControlEvent) => {
|
||||
if (suppressRef.current) return;
|
||||
yjs.routeData.set("baseLayer", e.name);
|
||||
};
|
||||
|
||||
map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn);
|
||||
map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn);
|
||||
map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
|
||||
return () => {
|
||||
map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn);
|
||||
map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn);
|
||||
map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn);
|
||||
};
|
||||
}, [map, yjs, onOverlayChange]);
|
||||
|
||||
// Yjs → Map: load initial state from Yjs
|
||||
useEffect(() => {
|
||||
const handleChange = () => {
|
||||
const raw = yjs.routeData.get("overlays") as string | undefined;
|
||||
if (raw) {
|
||||
try {
|
||||
onOverlayChange(JSON.parse(raw));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const base = yjs.routeData.get("baseLayer") as string | undefined;
|
||||
if (base) onBaseLayerChange(base);
|
||||
};
|
||||
yjs.routeData.observe(handleChange);
|
||||
handleChange();
|
||||
return () => yjs.routeData.unobserve(handleChange);
|
||||
}, [yjs, onOverlayChange, onBaseLayerChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PoiRefresher({ poiState }: { poiState: ReturnType<typeof usePois> }) {
|
||||
const map = useMap();
|
||||
const refreshRef = useRef(poiState.refresh);
|
||||
refreshRef.current = poiState.refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
refreshRef.current({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
}, zoom);
|
||||
};
|
||||
map.on("moveend", refresh);
|
||||
// Don't call refresh() immediately — let moveend trigger it
|
||||
return () => { map.off("moveend", refresh); };
|
||||
}, [map]);
|
||||
|
||||
// Trigger refresh when categories change (but not on mount)
|
||||
const prevCategories = useRef(poiState.enabledCategories);
|
||||
useEffect(() => {
|
||||
if (prevCategories.current === poiState.enabledCategories) return;
|
||||
prevCategories.current = poiState.enabledCategories;
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
poiState.refresh({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
}, zoom);
|
||||
}, [map, poiState.enabledCategories, poiState.refresh]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, onRouteHover, onImportError, days }: PlannerMapProps) {
|
||||
const { t } = useTranslation("planner");
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const poiState = usePois(sessionId);
|
||||
useProfileDefaults(yjs, poiState);
|
||||
useYjsPoiSync(yjs, poiState);
|
||||
const [enabledOverlays, setEnabledOverlays] = useState<string[]>([]);
|
||||
const [selectedBaseLayer, setSelectedBaseLayer] = useState<string>(baseLayers[0]!.name);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; index: number } | null>(null);
|
||||
const [draggingOver, setDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null);
|
||||
const [segmentBoundaries, setSegmentBoundaries] = useState<number[]>([]);
|
||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||
const [highways, setHighways] = useState<string[]>([]);
|
||||
const [maxspeeds, setMaxspeeds] = useState<string[]>([]);
|
||||
const [smoothnesses, setSmoothnesses] = useState<string[]>([]);
|
||||
const [tracktypes, setTracktypes] = useState<string[]>([]);
|
||||
const [cycleways, setCycleways] = useState<string[]>([]);
|
||||
const [bikeroutes, setBikeroutes] = useState<string[]>([]);
|
||||
const [colorMode, setColorMode] = useState<ColorMode>("plain");
|
||||
const [noGoDrawing, setNoGoDrawing] = useState(false);
|
||||
const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []);
|
||||
const suppressMapClickRef = useRef(false);
|
||||
const routeInteractionSuspendedRef = useRef(false);
|
||||
const waypointDraggingRef = useRef(false);
|
||||
|
||||
// Sync waypoints from Yjs
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const wps = getWaypointsFromYjs(yjs.waypoints);
|
||||
setWaypoints(wps);
|
||||
if (wps.length >= 2 && onRouteRequest) {
|
||||
onRouteRequest(wps);
|
||||
}
|
||||
};
|
||||
const {
|
||||
waypoints,
|
||||
routeCoordinates,
|
||||
segmentBoundaries,
|
||||
surfaces,
|
||||
highways,
|
||||
maxspeeds,
|
||||
smoothnesses,
|
||||
tracktypes,
|
||||
cycleways,
|
||||
bikeroutes,
|
||||
colorMode,
|
||||
addWaypoint,
|
||||
handleRouteInsert,
|
||||
moveWaypoint,
|
||||
deleteWaypoint,
|
||||
handleRoutePolylineHover,
|
||||
} = useWaypointManager(yjs, poiState, suppressMapClickRef, onRouteRequest);
|
||||
|
||||
yjs.waypoints.observeDeep(update);
|
||||
update();
|
||||
|
||||
return () => {
|
||||
yjs.waypoints.unobserveDeep(update);
|
||||
};
|
||||
}, [yjs.waypoints, onRouteRequest]);
|
||||
|
||||
// Sync route data from Yjs (enriched: coordinates + segment boundaries)
|
||||
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;
|
||||
|
||||
if (coordsJson) {
|
||||
try {
|
||||
setRouteCoordinates(JSON.parse(coordsJson));
|
||||
} catch {
|
||||
setRouteCoordinates(null);
|
||||
}
|
||||
} 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) {
|
||||
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([]);
|
||||
}
|
||||
|
||||
const highwaysJson = yjs.routeData.get("highways") as string | undefined;
|
||||
if (highwaysJson) {
|
||||
try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); }
|
||||
} else {
|
||||
setHighways([]);
|
||||
}
|
||||
|
||||
const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined;
|
||||
if (maxspeedsJson) {
|
||||
try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); }
|
||||
} else {
|
||||
setMaxspeeds([]);
|
||||
}
|
||||
|
||||
const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined;
|
||||
if (smoothnessesJson) {
|
||||
try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); }
|
||||
} else {
|
||||
setSmoothnesses([]);
|
||||
}
|
||||
|
||||
const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined;
|
||||
if (tracktypesJson) {
|
||||
try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); }
|
||||
} else {
|
||||
setTracktypes([]);
|
||||
}
|
||||
|
||||
const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined;
|
||||
if (cyclewaysJson) {
|
||||
try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); }
|
||||
} else {
|
||||
setCycleways([]);
|
||||
}
|
||||
|
||||
const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined;
|
||||
if (bikeroutesJson) {
|
||||
try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); }
|
||||
} else {
|
||||
setBikeroutes([]);
|
||||
}
|
||||
|
||||
if (modeVal) setColorMode(modeVal);
|
||||
};
|
||||
|
||||
yjs.routeData.observe(update);
|
||||
update();
|
||||
|
||||
return () => {
|
||||
yjs.routeData.unobserve(update);
|
||||
};
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const addWaypoint = useCallback(
|
||||
(lat: number, lng: number, name?: string) => {
|
||||
const snap = snapToPoi(lat, lng, poiState.pois);
|
||||
const finalLat = snap.lat;
|
||||
const finalLon = snap.snapped ? snap.lon : lng;
|
||||
|
||||
// Find the best insertion index: if the point is near the route,
|
||||
// insert between the closest segment's waypoints instead of appending
|
||||
let insertIndex = yjs.waypoints.length; // default: append
|
||||
if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) {
|
||||
let bestDist = Infinity;
|
||||
let bestSegment = -1;
|
||||
// For each segment, find the closest point on the route
|
||||
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 within ~1km of the route, insert after the segment's waypoint
|
||||
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, routeCoordinates, segmentBoundaries],
|
||||
);
|
||||
|
||||
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, segmentBoundaries);
|
||||
insertWaypointAtSegment(segIdx, lat, lon);
|
||||
},
|
||||
[segmentBoundaries, insertWaypointAtSegment],
|
||||
);
|
||||
|
||||
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: L.LeafletMouseEvent) => {
|
||||
if (!routeCoordinates || routeCoordinates.length < 2 || !onRouteHover) return;
|
||||
const { lat, lng } = e.latlng;
|
||||
// Find the closest coordinate index
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Compute cumulative distance to this point using haversine
|
||||
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);
|
||||
},
|
||||
[routeCoordinates, onRouteHover],
|
||||
);
|
||||
const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError);
|
||||
|
||||
const handleRoutePolylineOut = useCallback(() => {
|
||||
onRouteHover?.(null);
|
||||
}, [onRouteHover]);
|
||||
|
||||
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(() => {
|
||||
// Replace waypoints
|
||||
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]);
|
||||
}
|
||||
|
||||
// Replace no-go areas
|
||||
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]);
|
||||
}
|
||||
|
||||
// Replace notes if GPX has a description
|
||||
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 (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
|
|
@ -731,128 +126,123 @@ export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition,
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
|
||||
<LayersControl position="topright">
|
||||
{baseLayers.map((layer) => (
|
||||
<LayersControl.BaseLayer key={layer.name} checked={layer.name === selectedBaseLayer} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
{overlayLayers.map((layer) => (
|
||||
<LayersControl.Overlay key={layer.id} checked={enabledOverlays.includes(layer.id)} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
|
||||
</LayersControl.Overlay>
|
||||
))}
|
||||
</LayersControl>
|
||||
<MapContainer center={[50.1, 10.0]} zoom={6} className="h-full w-full">
|
||||
<LayersControl position="topright">
|
||||
{baseLayers.map((layer) => (
|
||||
<LayersControl.BaseLayer key={layer.name} checked={layer.name === selectedBaseLayer} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} />
|
||||
</LayersControl.BaseLayer>
|
||||
))}
|
||||
{overlayLayers.map((layer) => (
|
||||
<LayersControl.Overlay key={layer.id} checked={enabledOverlays.includes(layer.id)} name={layer.name}>
|
||||
<TileLayer url={layer.url} attribution={layer.attribution} maxZoom={layer.maxZoom} opacity={layer.opacity ?? 0.7} />
|
||||
</LayersControl.Overlay>
|
||||
))}
|
||||
</LayersControl>
|
||||
|
||||
<MapExposer />
|
||||
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
|
||||
<RouteFitter coordinates={routeCoordinates} />
|
||||
<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} />
|
||||
<PoiRefresher poiState={poiState} />
|
||||
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
|
||||
<PoiPanel poiState={poiState} />
|
||||
<MapExposer />
|
||||
<OverlaySync yjs={yjs} onOverlayChange={setEnabledOverlays} onBaseLayerChange={setSelectedBaseLayer} />
|
||||
<RouteFitter coordinates={routeCoordinates} />
|
||||
<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} />
|
||||
<PoiRefresher poiState={poiState} />
|
||||
<PoiMarkers poiState={poiState} onAddWaypoint={addWaypoint} />
|
||||
<PoiPanel poiState={poiState} />
|
||||
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
|
||||
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
|
||||
eventHandlers={{
|
||||
mouseover: () => {
|
||||
routeInteractionSuspendedRef.current = true;
|
||||
},
|
||||
mouseout: () => {
|
||||
if (!waypointDraggingRef.current) {
|
||||
routeInteractionSuspendedRef.current = false;
|
||||
}
|
||||
},
|
||||
dragstart: () => {
|
||||
waypointDraggingRef.current = true;
|
||||
yjs.undoManager.stopCapturing();
|
||||
routeInteractionSuspendedRef.current = true;
|
||||
},
|
||||
dragend: (e) => {
|
||||
waypointDraggingRef.current = false;
|
||||
routeInteractionSuspendedRef.current = false;
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
moveWaypoint(i, lat, lng);
|
||||
},
|
||||
contextmenu: (e) => {
|
||||
L.DomEvent.preventDefault(e as unknown as Event);
|
||||
const orig = e.originalEvent as MouseEvent;
|
||||
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Day boundary labels on map */}
|
||||
{days && days.length > 1 && days.map((day) => {
|
||||
const wp = waypoints[day.endWaypointIndex];
|
||||
if (!wp || day.dayNumber === days.length) return null;
|
||||
return (
|
||||
{waypoints.map((wp, i) => (
|
||||
<Marker
|
||||
key={`day-${day.dayNumber}`}
|
||||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
|
||||
interactive={false}
|
||||
draggable
|
||||
zIndexOffset={highlightedWaypoint === i ? Z_WAYPOINT_HIGHLIGHTED : Z_WAYPOINT}
|
||||
icon={waypointIcon(i, wp.overnight, highlightedWaypoint === i)}
|
||||
eventHandlers={{
|
||||
mouseover: () => { routeInteractionSuspendedRef.current = true; },
|
||||
mouseout: () => {
|
||||
if (!waypointDraggingRef.current) routeInteractionSuspendedRef.current = false;
|
||||
},
|
||||
dragstart: () => {
|
||||
waypointDraggingRef.current = true;
|
||||
yjs.undoManager.stopCapturing();
|
||||
routeInteractionSuspendedRef.current = true;
|
||||
},
|
||||
dragend: (e) => {
|
||||
waypointDraggingRef.current = false;
|
||||
routeInteractionSuspendedRef.current = false;
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
moveWaypoint(i, lat, lng);
|
||||
},
|
||||
contextmenu: (e) => {
|
||||
L.DomEvent.preventDefault(e as unknown as Event);
|
||||
const orig = e.originalEvent as MouseEvent;
|
||||
setContextMenu({ x: orig.clientX, y: orig.clientY, index: i });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
{routeCoordinates && routeCoordinates.length >= 2 && (
|
||||
<>
|
||||
<ColoredRoute
|
||||
coordinates={routeCoordinates}
|
||||
colorMode={colorMode}
|
||||
surfaces={surfaces}
|
||||
highways={highways}
|
||||
maxspeeds={maxspeeds}
|
||||
smoothnesses={smoothnesses}
|
||||
tracktypes={tracktypes}
|
||||
cycleways={cycleways}
|
||||
bikeroutes={bikeroutes}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
segmentBoundaries={segmentBoundaries}
|
||||
onInsertWaypoint={handleRouteInsert}
|
||||
suspendedRef={routeInteractionSuspendedRef}
|
||||
disabled={noGoDrawing}
|
||||
/>
|
||||
{onRouteHover && (
|
||||
<Polyline
|
||||
positions={routeCoordinates.map((c) => [c[1]!, c[0]!] as [number, number])}
|
||||
pathOptions={{ weight: 20, opacity: 0, interactive: true }}
|
||||
eventHandlers={{
|
||||
mouseover: handleRoutePolylineHover,
|
||||
mousemove: handleRoutePolylineHover,
|
||||
mouseout: handleRoutePolylineOut,
|
||||
}}
|
||||
{days && days.length > 1 && days.map((day) => {
|
||||
const wp = waypoints[day.endWaypointIndex];
|
||||
if (!wp || day.dayNumber === days.length) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={`day-${day.dayNumber}`}
|
||||
position={[wp.lat, wp.lon]}
|
||||
icon={dayLabelIcon(day.dayNumber, (day.distance / 1000).toFixed(1))}
|
||||
interactive={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{highlightPosition && (
|
||||
<Marker
|
||||
position={highlightPosition}
|
||||
zIndexOffset={Z_HIGHLIGHT}
|
||||
interactive={false}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: '<div style="width:12px;height:12px;border-radius:50%;background:#ef4444;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translate(-6px,-6px)"></div>',
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
{routeCoordinates && routeCoordinates.length >= 2 && (
|
||||
<>
|
||||
<ColoredRoute
|
||||
coordinates={routeCoordinates}
|
||||
colorMode={colorMode}
|
||||
surfaces={surfaces}
|
||||
highways={highways}
|
||||
maxspeeds={maxspeeds}
|
||||
smoothnesses={smoothnesses}
|
||||
tracktypes={tracktypes}
|
||||
cycleways={cycleways}
|
||||
bikeroutes={bikeroutes}
|
||||
/>
|
||||
<RouteInteraction
|
||||
coordinates={routeCoordinates}
|
||||
segmentBoundaries={segmentBoundaries}
|
||||
onInsertWaypoint={handleRouteInsert}
|
||||
suspendedRef={routeInteractionSuspendedRef}
|
||||
disabled={noGoDrawing}
|
||||
/>
|
||||
{onRouteHover && (
|
||||
<Polyline
|
||||
positions={routeCoordinates.map((c) => [c[1]!, c[0]!] as [number, number])}
|
||||
pathOptions={{ weight: 20, opacity: 0, interactive: true }}
|
||||
eventHandlers={{
|
||||
mouseover: (e) => handleRoutePolylineHover(e, onRouteHover),
|
||||
mousemove: (e) => handleRoutePolylineHover(e, onRouteHover),
|
||||
mouseout: handleRoutePolylineOut,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{highlightPosition && (
|
||||
<Marker
|
||||
position={highlightPosition}
|
||||
zIndexOffset={Z_HIGHLIGHT}
|
||||
interactive={false}
|
||||
icon={L.divIcon({
|
||||
className: "",
|
||||
html: '<div style="width:12px;height:12px;border-radius:50%;background:#ef4444;border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.3);transform:translate(-6px,-6px)"></div>',
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
{contextMenu && (
|
||||
<WaypointContextMenu
|
||||
position={{ x: contextMenu.x, y: contextMenu.y }}
|
||||
|
|
|
|||
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