diff --git a/apps/planner/app/components/ElevationChart.tsx b/apps/planner/app/components/ElevationChart.tsx new file mode 100644 index 0000000..0c36eeb --- /dev/null +++ b/apps/planner/app/components/ElevationChart.tsx @@ -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([]); + const canvasRef = useRef(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 ( +
+

Elevation Profile

+ +
+ ); +} diff --git a/apps/planner/app/components/ExportButton.tsx b/apps/planner/app/components/ExportButton.tsx new file mode 100644 index 0000000..43b941b --- /dev/null +++ b/apps/planner/app/components/ExportButton.tsx @@ -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) => ({ + 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 ( + + ); +} diff --git a/apps/planner/app/components/ProfileSelector.tsx b/apps/planner/app/components/ProfileSelector.tsx new file mode 100644 index 0000000..0f8b629 --- /dev/null +++ b/apps/planner/app/components/ProfileSelector.tsx @@ -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) => { + const value = e.target.value; + setProfile(value); + yjs.routeData.set("profile", value); + }, + [yjs.routeData], + ); + + return ( +
+ + +
+ ); +} diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx index 4876bdb..ad4467a 100644 --- a/apps/planner/app/components/SessionView.tsx +++ b/apps/planner/app/components/SessionView.tsx @@ -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 ( <>
-

trails.cool Planner

+
+

trails.cool Planner

+ +
+ {computing && ( Computing route... )} @@ -40,15 +49,20 @@ export function SessionView({ sessionId }: { sessionId: string }) {
-
- - Loading map... -
- } - > - +
+
+ + Loading map... +
+ } + > + + + + +