trails/apps/planner/app/components/ExportButton.tsx
Ullrich Schäfer aef67aab7a
Include waypoint note in GPX export and extractWaypoints type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 07:38:55 +02:00

174 lines
6.3 KiB
TypeScript

import { useCallback, useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import * as Y from "yjs";
import type { YjsState } from "~/lib/use-yjs";
import { generateGpx, computeDays } from "@trails-cool/gpx";
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
import type { WaypointPoiTags } from "@trails-cool/types";
function getTracks(yjs: YjsState): TrackPoint[][] {
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
if (!geojsonStr) return [];
try {
const geojson = JSON.parse(geojsonStr);
const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
if (coords.length > 0) {
return [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
}
} catch { /* invalid geojson */ }
return [];
}
function getWaypoints(yjs: YjsState) {
return 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,
isDayBreak: yMap.get("overnight") === true ? true : undefined,
note: yMap.get("note") as string | undefined,
osmId: yMap.get("osmId") as number | undefined,
poiTags: yMap.get("poiTags") as WaypointPoiTags | undefined,
}));
}
function getNoGoAreas(yjs: YjsState): NoGoArea[] {
return yjs.noGoAreas.toArray().map((yMap: Y.Map<unknown>) => ({
points: (yMap.get("points") as Array<{ lat: number; lon: number }>) ?? [],
})).filter((a) => a.points.length >= 3);
}
function download(gpx: string, filename: string) {
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function ExportButton({ yjs }: { yjs: YjsState }) {
const { t } = useTranslation("planner");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close dropdown on outside mousedown
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const handleExportRoute = useCallback(() => {
const tracks = getTracks(yjs);
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
download(gpx, "route.gpx");
setOpen(false);
}, [yjs]);
const handleExportPlan = useCallback(() => {
const tracks = getTracks(yjs);
const waypoints = getWaypoints(yjs);
const noGoAreas = getNoGoAreas(yjs);
const notes = yjs.notes.toString() || undefined;
const gpx = generateGpx({ name: "trails.cool route", description: notes, waypoints, tracks, noGoAreas });
download(gpx, "route-plan.gpx");
setOpen(false);
}, [yjs]);
const handleExportDays = useCallback(() => {
const tracks = getTracks(yjs);
const waypoints = getWaypoints(yjs);
const allPoints = tracks.flat();
if (allPoints.length === 0 || waypoints.length === 0) return;
const days = computeDays(waypoints, tracks);
if (days.length <= 1) {
// Single day — just export the full route
const gpx = generateGpx({ name: "trails.cool route", tracks });
download(gpx, "route.gpx");
setOpen(false);
return;
}
// Find closest track index for each waypoint
const wpTrackIndices = waypoints.map((wp) => {
let bestIdx = 0;
let bestDist = Infinity;
for (let i = 0; i < allPoints.length; i++) {
const dx = allPoints[i]!.lat - wp.lat;
const dy = allPoints[i]!.lon - wp.lon;
const d = dx * dx + dy * dy;
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
});
for (const day of days) {
const startIdx = wpTrackIndices[day.startWaypointIndex]!;
const endIdx = wpTrackIndices[day.endWaypointIndex]!;
const dayPoints = allPoints.slice(startIdx, endIdx + 1);
const dayName = day.startName && day.endName
? `Day ${day.dayNumber}: ${day.startName} - ${day.endName}`
: `Day ${day.dayNumber}`;
const gpx = generateGpx({ name: dayName, tracks: [dayPoints] });
const filename = `day-${day.dayNumber}${day.startName ? `-${day.startName.toLowerCase().replace(/\s+/g, "-")}` : ""}.gpx`;
download(gpx, filename);
}
setOpen(false);
}, [yjs]);
const hasMultipleDays = (() => {
const waypoints = getWaypoints(yjs);
return waypoints.some((w) => w.isDayBreak);
})();
return (
<div ref={ref} className="relative z-[1001]">
<div className="flex">
<button
onClick={handleExportRoute}
className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
{t("exportGpx")}
</button>
<button
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
className="rounded-r border-l border-gray-300 bg-gray-100 px-2.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
>
</button>
</div>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded border border-gray-200 bg-white py-1 shadow-lg">
<button
onClick={handleExportRoute}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportRoute")}</span>
<span className="block text-xs text-gray-400">{t("exportRouteDesc")}</span>
</button>
<button
onClick={handleExportPlan}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportPlan")}</span>
<span className="block text-xs text-gray-400">{t("exportPlanDesc")}</span>
</button>
{hasMultipleDays && (
<button
onClick={handleExportDays}
className="block w-full px-3 py-1.5 text-left hover:bg-gray-100"
>
<span className="text-sm text-gray-700">{t("exportDays")}</span>
<span className="block text-xs text-gray-400">{t("exportDaysDesc")}</span>
</button>
)}
</div>
)}
</div>
);
}