Complete Planner UI: profile selector, elevation chart, GPX export

- ProfileSelector: dropdown synced via Y.Map, triggers route recompute
  (Hiking, Cycling fast/safe, Shortest, Car profiles)
- ElevationChart: canvas-based elevation profile extracted from BRouter
  GeoJSON, shows min/max elevation and distance
- ExportButton: generates GPX from current waypoints + route track,
  downloads as route.gpx

All Groups 5 + 6 tasks complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 00:57:39 +01:00
parent b83384ab17
commit 54b6232c5d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 277 additions and 14 deletions

View file

@ -0,0 +1,135 @@
import { useEffect, useState, useRef } from "react";
import type { YjsState } from "~/lib/use-yjs";
interface ElevationPoint {
distance: number; // meters from start
elevation: number; // meters
}
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]! });
}
}
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));
}
export function ElevationChart({ yjs }: { yjs: YjsState }) {
const [points, setPoints] = useState<ElevationPoint[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const update = () => {
const geojson = yjs.routeData.get("geojson") as string | undefined;
if (geojson) {
setPoints(extractElevation(geojson));
} else {
setPoints([]);
}
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
useEffect(() => {
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 padding = { top: 10, right: 10, bottom: 25, left: 40 };
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;
ctx.clearRect(0, 0, w, h);
// 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();
// 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]);
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" />
</div>
);
}

View file

@ -0,0 +1,57 @@
import { useCallback } from "react";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";
export function ExportButton({ yjs }: { yjs: YjsState }) {
const handleExport = useCallback(() => {
// Get waypoints from Yjs
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
lat: yMap.get("lat") as number,
lon: yMap.get("lon") as number,
name: yMap.get("name") as string | undefined,
}));
// Get route track from GeoJSON
let tracks: TrackPoint[][] = [];
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
if (geojsonStr) {
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length > 0) {
tracks = [
coords.map((c) => ({
lat: c[1]!,
lon: c[0]!,
ele: c[2],
})),
];
}
} catch {
// Invalid GeoJSON
}
}
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks });
// Download
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "route.gpx";
a.click();
URL.revokeObjectURL(url);
}, [yjs.waypoints, yjs.routeData]);
return (
<button
onClick={handleExport}
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
Export GPX
</button>
);
}

View file

@ -0,0 +1,57 @@
import { useEffect, useState, useCallback } from "react";
import type { YjsState } from "~/lib/use-yjs";
const PROFILES = [
{ id: "trekking", label: "Hiking" },
{ id: "fastbike", label: "Cycling (fast)" },
{ id: "safety", label: "Cycling (safe)" },
{ id: "shortest", label: "Shortest" },
{ id: "car-eco", label: "Car" },
];
interface ProfileSelectorProps {
yjs: YjsState;
}
export function ProfileSelector({ yjs }: ProfileSelectorProps) {
const [profile, setProfile] = useState("trekking");
useEffect(() => {
const update = () => {
const p = yjs.routeData.get("profile") as string | undefined;
if (p) setProfile(p);
};
yjs.routeData.observe(update);
update();
return () => yjs.routeData.unobserve(update);
}, [yjs.routeData]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setProfile(value);
yjs.routeData.set("profile", value);
},
[yjs.routeData],
);
return (
<div className="flex items-center gap-2">
<label htmlFor="profile" className="text-sm text-gray-600">
Profile:
</label>
<select
id="profile"
value={profile}
onChange={handleChange}
className="rounded border border-gray-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{PROFILES.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</div>
);
}

View file

@ -1,6 +1,8 @@
import { Suspense, lazy } from "react";
import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
const PlannerMap = lazy(() =>
import("~/components/PlannerMap").then((m) => ({ default: m.PlannerMap })),
@ -8,6 +10,9 @@ const PlannerMap = lazy(() =>
const WaypointSidebar = lazy(() =>
import("~/components/WaypointSidebar").then((m) => ({ default: m.WaypointSidebar })),
);
const ElevationChart = lazy(() =>
import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })),
);
export function SessionView({ sessionId }: { sessionId: string }) {
const yjs = useYjs(sessionId);
@ -24,8 +29,12 @@ export function SessionView({ sessionId }: { sessionId: string }) {
return (
<>
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
<ProfileSelector yjs={yjs} />
</div>
<div className="flex items-center gap-3">
<ExportButton yjs={yjs} />
{computing && (
<span className="text-xs text-blue-600">Computing route...</span>
)}
@ -40,15 +49,20 @@ export function SessionView({ sessionId }: { sessionId: string }) {
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<main className="flex-1">
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
Loading map...
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
<main className="flex-1 flex flex-col">
<div className="flex-1">
<Suspense
fallback={
<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">
Loading map...
</div>
}
>
<PlannerMap yjs={yjs} onRouteRequest={requestRoute} />
</Suspense>
</div>
<Suspense fallback={null}>
<ElevationChart yjs={yjs} />
</Suspense>
</main>
<aside className="w-72 border-l border-gray-200 bg-white">