Fix waypoint icons and add elevation chart scrubbing
Waypoint icons: - Replace broken Leaflet default icons with numbered blue circles - Numbers match sidebar order (1, 2, 3...) Elevation chart: - Mouse hover shows crosshair, elevation, and distance at cursor - Red dot appears on the map at the corresponding route position - Dot disappears when mouse leaves the chart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce0cd84562
commit
6ee07f0570
3 changed files with 198 additions and 73 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
|
||||
interface ElevationPoint {
|
||||
distance: number; // meters from start
|
||||
elevation: number; // meters
|
||||
distance: number;
|
||||
elevation: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
function extractElevation(geojsonStr: string): ElevationPoint[] {
|
||||
|
|
@ -22,7 +24,12 @@ function extractElevation(geojsonStr: string): ElevationPoint[] {
|
|||
totalDist += haversine(prev[1]!, prev[0]!, curr[1]!, curr[0]!);
|
||||
}
|
||||
if (coords[i]![2] !== undefined) {
|
||||
points.push({ distance: totalDist, elevation: coords[i]![2]! });
|
||||
points.push({
|
||||
distance: totalDist,
|
||||
elevation: coords[i]![2]!,
|
||||
lat: coords[i]![1]!,
|
||||
lon: coords[i]![0]!,
|
||||
});
|
||||
}
|
||||
}
|
||||
return points;
|
||||
|
|
@ -42,9 +49,19 @@ function haversine(lat1: number, lon1: number, lat2: number, lon2: number): numb
|
|||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs }: { yjs: YjsState }) {
|
||||
const PADDING = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
|
||||
interface ElevationChartProps {
|
||||
yjs: YjsState;
|
||||
onHover?: (position: [number, number] | null) => void;
|
||||
}
|
||||
|
||||
export function ElevationChart({ yjs, onHover }: ElevationChartProps) {
|
||||
const [points, setPoints] = useState<ElevationPoint[]>([]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointsRef = useRef<ElevationPoint[]>([]);
|
||||
pointsRef.current = points;
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
|
|
@ -60,76 +77,161 @@ export function ElevationChart({ yjs }: { yjs: YjsState }) {
|
|||
return () => yjs.routeData.unobserve(update);
|
||||
}, [yjs.routeData]);
|
||||
|
||||
const drawChart = useCallback(
|
||||
(highlightIdx: number | null) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
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);
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(PADDING.left, PADDING.top + chartH);
|
||||
for (const p of points) {
|
||||
ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
ctx.lineTo(PADDING.left + chartW, PADDING.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
||||
ctx.fill();
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
if (i === 0) ctx.moveTo(toX(p.distance), toY(p.elevation));
|
||||
else ctx.lineTo(toX(p.distance), toY(p.elevation));
|
||||
}
|
||||
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);
|
||||
|
||||
// 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";
|
||||
const label = `${Math.round(p.elevation)}m · ${(p.distance / 1000).toFixed(1)}km`;
|
||||
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);
|
||||
}
|
||||
},
|
||||
[points],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
drawChart(hoverIdx);
|
||||
}, [points, hoverIdx, drawChart]);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || points.length < 2) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const chartW = rect.width - PADDING.left - PADDING.right;
|
||||
const ratio = (mouseX - PADDING.left) / chartW;
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const padding = { top: 10, right: 10, bottom: 25, left: 40 };
|
||||
const chartW = w - padding.left - padding.right;
|
||||
const chartH = h - padding.top - padding.bottom;
|
||||
if (ratio < 0 || ratio > 1) {
|
||||
setHoverIdx(null);
|
||||
onHover?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
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 maxDist = points[points.length - 1]!.distance;
|
||||
const targetDist = ratio * maxDist;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// Find closest point
|
||||
let closest = 0;
|
||||
let minDiff = Infinity;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const diff = Math.abs(points[i]!.distance - targetDist);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closest = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill area
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top + chartH);
|
||||
for (const p of points) {
|
||||
const x = padding.left + (p.distance / maxDist) * chartW;
|
||||
const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH;
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padding.left + chartW, padding.top + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(37, 99, 235, 0.15)";
|
||||
ctx.fill();
|
||||
setHoverIdx(closest);
|
||||
const p = points[closest]!;
|
||||
onHover?.([p.lat, p.lon]);
|
||||
},
|
||||
[points, onHover],
|
||||
);
|
||||
|
||||
// Line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i]!;
|
||||
const x = padding.left + (p.distance / maxDist) * chartW;
|
||||
const y = padding.top + chartH - ((p.elevation - minEle) / eleRange) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
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);
|
||||
}, [points]);
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoverIdx(null);
|
||||
onHover?.(null);
|
||||
}, [onHover]);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 px-2 py-2">
|
||||
<p className="mb-1 px-2 text-xs font-medium text-gray-500">Elevation Profile</p>
|
||||
<canvas ref={canvasRef} className="h-24 w-full" />
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-24 w-full cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, useMapEvents, useMap } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, LayersControl, Marker, Polyline, CircleMarker, useMapEvents, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import * as Y from "yjs";
|
||||
import type { YjsState } from "~/lib/use-yjs";
|
||||
import { baseLayers } from "@trails-cool/map";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix Leaflet default icon paths in bundled environment
|
||||
import iconUrl from "leaflet/dist/images/marker-icon.png";
|
||||
import iconRetinaUrl from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import shadowUrl from "leaflet/dist/images/marker-shadow.png";
|
||||
|
||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl });
|
||||
function waypointIcon(index: number): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:24px;height:24px;border-radius:50%;
|
||||
background:#2563eb;color:white;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:12px;font-weight:600;
|
||||
border:2px solid white;box-shadow:0 1px 4px rgba(0,0,0,0.3);
|
||||
transform:translate(-12px,-12px);
|
||||
">${index + 1}</div>`,
|
||||
iconSize: [0, 0],
|
||||
});
|
||||
}
|
||||
|
||||
interface WaypointData {
|
||||
lat: number;
|
||||
|
|
@ -30,6 +38,7 @@ function getWaypointsFromYjs(waypoints: Y.Array<Y.Map<unknown>>): WaypointData[]
|
|||
interface PlannerMapProps {
|
||||
yjs: YjsState;
|
||||
onRouteRequest?: (waypoints: WaypointData[]) => void;
|
||||
highlightPosition?: [number, number] | null;
|
||||
}
|
||||
|
||||
function MapClickHandler({ onAdd }: { onAdd: (lat: number, lng: number) => void }) {
|
||||
|
|
@ -94,7 +103,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
key={clientId}
|
||||
position={[cursor.lat, cursor.lng]}
|
||||
icon={L.divIcon({
|
||||
className: "cursor-marker",
|
||||
className: "",
|
||||
html: `<div style="background:${cursor.color};color:white;padding:2px 6px;border-radius:4px;font-size:11px;white-space:nowrap;transform:translate(10px,-50%)">${cursor.name}</div>`,
|
||||
iconSize: [0, 0],
|
||||
})}
|
||||
|
|
@ -104,7 +113,7 @@ function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
||||
export function PlannerMap({ yjs, onRouteRequest, highlightPosition }: PlannerMapProps) {
|
||||
const [waypoints, setWaypoints] = useState<WaypointData[]>([]);
|
||||
const [routeGeoJson, setRouteGeoJson] = useState<L.LatLngExpression[] | null>(null);
|
||||
|
||||
|
|
@ -201,6 +210,7 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
|||
key={i}
|
||||
position={[wp.lat, wp.lon]}
|
||||
draggable
|
||||
icon={waypointIcon(i)}
|
||||
eventHandlers={{
|
||||
dragend: (e) => {
|
||||
const { lat, lng } = e.target.getLatLng();
|
||||
|
|
@ -215,6 +225,14 @@ export function PlannerMap({ yjs, onRouteRequest }: PlannerMapProps) {
|
|||
))}
|
||||
|
||||
{routeGeoJson && <Polyline positions={routeGeoJson} color="#2563eb" weight={4} opacity={0.8} />}
|
||||
|
||||
{highlightPosition && (
|
||||
<CircleMarker
|
||||
center={highlightPosition}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#ef4444", fillColor: "#ef4444", fillOpacity: 1, weight: 2 }}
|
||||
/>
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Suspense, lazy } from "react";
|
||||
import { Suspense, lazy, useState, useCallback } from "react";
|
||||
import { useYjs } from "~/lib/use-yjs";
|
||||
import { useRouting } from "~/lib/use-routing";
|
||||
import { ProfileSelector } from "~/components/ProfileSelector";
|
||||
|
|
@ -17,6 +17,11 @@ const ElevationChart = lazy(() =>
|
|||
export function SessionView({ sessionId }: { sessionId: string }) {
|
||||
const yjs = useYjs(sessionId);
|
||||
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
|
||||
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
|
||||
|
||||
const handleElevationHover = useCallback((pos: [number, number] | null) => {
|
||||
setHighlightPosition(pos);
|
||||
}, []);
|
||||
|
||||
if (!yjs) {
|
||||
return (
|
||||
|
|
@ -58,11 +63,11 @@ export function SessionView({ sessionId }: { sessionId: string }) {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
|
||||
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} highlightPosition={highlightPosition} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<ElevationChart yjs={yjs} />
|
||||
<ElevationChart yjs={yjs} onHover={handleElevationHover} />
|
||||
</Suspense>
|
||||
</main>
|
||||
<aside className="w-72 border-l border-gray-200 bg-white">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue