Add split export: Export Route vs Export Plan
Split button with default "Export GPX" (track only) and dropdown: - Export Route: clean track for use in any app - Export Plan: track + waypoints + no-go areas in GPX extensions (trails:planning namespace) for reimporting into the planner Also: - Add no-go area serialization to generateGpx (GPX extensions) - Parse no-go areas from GPX extensions on import - Initialize no-go areas in Yjs session from imported GPX - Save to Journal now exports track only (consistent with Export Route) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
237660db5e
commit
4b711abaec
11 changed files with 172 additions and 60 deletions
|
|
@ -1,60 +1,110 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import type { YjsState } from "~/lib/use-yjs";
|
import type { YjsState } from "~/lib/use-yjs";
|
||||||
import { generateGpx } from "@trails-cool/gpx";
|
import { generateGpx } from "@trails-cool/gpx";
|
||||||
import type { TrackPoint } from "@trails-cool/gpx";
|
import type { TrackPoint, NoGoArea } from "@trails-cool/gpx";
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
export function ExportButton({ yjs }: { yjs: YjsState }) {
|
||||||
const { t } = useTranslation("planner");
|
const { t } = useTranslation("planner");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
// Close dropdown on outside click
|
||||||
// Get waypoints from Yjs
|
useEffect(() => {
|
||||||
const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map<unknown>) => ({
|
if (!open) return;
|
||||||
lat: yMap.get("lat") as number,
|
const handler = (e: MouseEvent) => {
|
||||||
lon: yMap.get("lon") as number,
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
name: yMap.get("name") as string | undefined,
|
};
|
||||||
}));
|
document.addEventListener("click", handler);
|
||||||
|
return () => document.removeEventListener("click", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Get route track from GeoJSON
|
const handleExportRoute = useCallback(() => {
|
||||||
let tracks: TrackPoint[][] = [];
|
const tracks = getTracks(yjs);
|
||||||
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
|
||||||
if (geojsonStr) {
|
download(gpx, "route.gpx");
|
||||||
try {
|
setOpen(false);
|
||||||
const geojson = JSON.parse(geojsonStr);
|
}, [yjs]);
|
||||||
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 });
|
const handleExportPlan = useCallback(() => {
|
||||||
|
const tracks = getTracks(yjs);
|
||||||
// Download
|
const waypoints = getWaypoints(yjs);
|
||||||
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
const noGoAreas = getNoGoAreas(yjs);
|
||||||
const url = URL.createObjectURL(blob);
|
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks, noGoAreas });
|
||||||
const a = document.createElement("a");
|
download(gpx, "route-plan.gpx");
|
||||||
a.href = url;
|
setOpen(false);
|
||||||
a.download = "route.gpx";
|
}, [yjs]);
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, [yjs.waypoints, yjs.routeData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div ref={ref} className="relative">
|
||||||
onClick={handleExport}
|
<div className="flex">
|
||||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
<button
|
||||||
>
|
onClick={handleExportRoute}
|
||||||
{t("exportGpx")}
|
className="rounded-l bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||||
</button>
|
>
|
||||||
|
{t("exportGpx")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="rounded-r border-l border-gray-300 bg-gray-100 px-1.5 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1 w-44 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 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{t("exportRoute")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleExportPlan}
|
||||||
|
className="block w-full px-3 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{t("exportPlan")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build GPX from current Yjs state
|
// Build GPX from computed track (not editing waypoints).
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let tracks: TrackPoint[][] = [];
|
let tracks: TrackPoint[][] = [];
|
||||||
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
|
||||||
if (geojsonStr) {
|
if (geojsonStr) {
|
||||||
|
|
@ -42,7 +36,7 @@ export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl
|
||||||
} catch { /* invalid geojson */ }
|
} catch { /* invalid geojson */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks });
|
const gpx = generateGpx({ name: "trails.cool route", waypoints: [], tracks });
|
||||||
|
|
||||||
// POST to Journal callback
|
// POST to Journal callback
|
||||||
const response = await fetch(callbackUrl, {
|
const response = await fetch(callbackUrl, {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,22 @@ export function initializeSessionWithWaypoints(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initializeSessionWithNoGoAreas(
|
||||||
|
sessionId: string,
|
||||||
|
noGoAreas: Array<{ points: Array<{ lat: number; lon: number }> }>,
|
||||||
|
): void {
|
||||||
|
const doc = getOrCreateDoc(sessionId);
|
||||||
|
const yNoGoAreas = doc.getArray("noGoAreas");
|
||||||
|
|
||||||
|
doc.transact(() => {
|
||||||
|
for (const area of noGoAreas) {
|
||||||
|
const yMap = new Y.Map();
|
||||||
|
yMap.set("points", area.points);
|
||||||
|
yNoGoAreas.push([yMap]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSessions(): Promise<SessionMetadata[]> {
|
export async function listSessions(): Promise<SessionMetadata[]> {
|
||||||
return getDb()
|
return getDb()
|
||||||
.select()
|
.select()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { data } from "react-router";
|
import { data } from "react-router";
|
||||||
import type { Route } from "./+types/api.sessions";
|
import type { Route } from "./+types/api.sessions";
|
||||||
import { createSession, listSessions } from "~/lib/sessions";
|
import { createSession, listSessions, initializeSessionWithNoGoAreas } from "~/lib/sessions";
|
||||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||||
import { withDb } from "@trails-cool/db";
|
import { withDb } from "@trails-cool/db";
|
||||||
|
|
||||||
|
|
@ -25,6 +25,9 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
const gpxData = await parseGpxAsync(gpx);
|
const gpxData = await parseGpxAsync(gpx);
|
||||||
const wps = extractWaypoints(gpxData);
|
const wps = extractWaypoints(gpxData);
|
||||||
if (wps.length > 0) initialWaypoints = wps;
|
if (wps.length > 0) initialWaypoints = wps;
|
||||||
|
if (gpxData.noGoAreas.length > 0) {
|
||||||
|
initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Continue with empty session if GPX is invalid
|
// Continue with empty session if GPX is invalid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { redirect, data } from "react-router";
|
import { redirect, data } from "react-router";
|
||||||
import type { Route } from "./+types/new";
|
import type { Route } from "./+types/new";
|
||||||
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
|
import { createSession, initializeSessionWithWaypoints, initializeSessionWithNoGoAreas } from "~/lib/sessions";
|
||||||
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
|
||||||
import { checkRateLimit } from "~/lib/rate-limit";
|
import { checkRateLimit } from "~/lib/rate-limit";
|
||||||
|
|
||||||
|
|
@ -37,6 +37,9 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const gpx = decodeURIComponent(gpxEncoded);
|
const gpx = decodeURIComponent(gpxEncoded);
|
||||||
const gpxData = await parseGpxAsync(gpx);
|
const gpxData = await parseGpxAsync(gpx);
|
||||||
initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData));
|
initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData));
|
||||||
|
if (gpxData.noGoAreas.length > 0) {
|
||||||
|
initializeSessionWithNoGoAreas(session.id, gpxData.noGoAreas);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Continue with empty session if GPX is invalid
|
// Continue with empty session if GPX is invalid
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,23 @@ import type { TrackPoint } from "./types.ts";
|
||||||
/**
|
/**
|
||||||
* Generate a GPX XML string from waypoints and track points.
|
* Generate a GPX XML string from waypoints and track points.
|
||||||
*/
|
*/
|
||||||
|
export interface NoGoArea {
|
||||||
|
points: Array<{ lat: number; lon: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export function generateGpx(options: {
|
export function generateGpx(options: {
|
||||||
name?: string;
|
name?: string;
|
||||||
waypoints?: Waypoint[];
|
waypoints?: Waypoint[];
|
||||||
tracks?: TrackPoint[][];
|
tracks?: TrackPoint[][];
|
||||||
|
noGoAreas?: NoGoArea[];
|
||||||
}): string {
|
}): string {
|
||||||
|
const hasExtensions = options.noGoAreas && options.noGoAreas.length > 0;
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
'<gpx version="1.1" creator="trails.cool"',
|
'<gpx version="1.1" creator="trails.cool"',
|
||||||
' xmlns="http://www.topografix.com/GPX/1/1">',
|
' xmlns="http://www.topografix.com/GPX/1/1"' +
|
||||||
|
(hasExtensions ? '\n xmlns:trails="https://trails.cool/gpx/1"' : "") +
|
||||||
|
">",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (options.name) {
|
if (options.name) {
|
||||||
|
|
@ -46,6 +54,18 @@ export function generateGpx(options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.noGoAreas && options.noGoAreas.length > 0) {
|
||||||
|
lines.push(" <extensions>", " <trails:planning>");
|
||||||
|
for (const area of options.noGoAreas) {
|
||||||
|
lines.push(" <trails:nogo>");
|
||||||
|
for (const pt of area.points) {
|
||||||
|
lines.push(` <trails:point lat="${pt.lat}" lon="${pt.lon}"/>`);
|
||||||
|
}
|
||||||
|
lines.push(" </trails:nogo>");
|
||||||
|
}
|
||||||
|
lines.push(" </trails:planning>", " </extensions>");
|
||||||
|
}
|
||||||
|
|
||||||
lines.push("</gpx>");
|
lines.push("</gpx>");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { parseGpxAsync } from "./parse.ts";
|
export { parseGpxAsync } from "./parse.ts";
|
||||||
export { generateGpx } from "./generate.ts";
|
export { generateGpx } from "./generate.ts";
|
||||||
export { extractWaypoints } from "./waypoints.ts";
|
export { extractWaypoints } from "./waypoints.ts";
|
||||||
export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts";
|
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Waypoint } from "@trails-cool/types";
|
import type { Waypoint } from "@trails-cool/types";
|
||||||
import type { GpxData, TrackPoint, ElevationProfile } from "./types.ts";
|
import type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a GPX XML string into structured data.
|
* Parse a GPX XML string into structured data.
|
||||||
|
|
@ -46,9 +46,10 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData {
|
||||||
const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
|
const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
|
||||||
const waypoints = parseWaypoints(doc);
|
const waypoints = parseWaypoints(doc);
|
||||||
const tracks = parseTracks(doc);
|
const tracks = parseTracks(doc);
|
||||||
|
const noGoAreas = parseNoGoAreas(doc);
|
||||||
const { totalDistance, ...elevation } = computeElevation(tracks);
|
const { totalDistance, ...elevation } = computeElevation(tracks);
|
||||||
|
|
||||||
return { name, waypoints, tracks, distance: totalDistance, elevation };
|
return { name, waypoints, tracks, noGoAreas, distance: totalDistance, elevation };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWaypoints(doc: Document): Waypoint[] {
|
function parseWaypoints(doc: Document): Waypoint[] {
|
||||||
|
|
@ -85,6 +86,22 @@ function parseTracks(doc: Document): TrackPoint[][] {
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNoGoAreas(doc: Document): NoGoArea[] {
|
||||||
|
const areas: NoGoArea[] = [];
|
||||||
|
const nogos = doc.querySelectorAll("nogo");
|
||||||
|
for (const nogo of Array.from(nogos)) {
|
||||||
|
const points: Array<{ lat: number; lon: number }> = [];
|
||||||
|
const pts = nogo.querySelectorAll("point");
|
||||||
|
for (const pt of Array.from(pts)) {
|
||||||
|
const lat = parseFloat(pt.getAttribute("lat") ?? "0");
|
||||||
|
const lon = parseFloat(pt.getAttribute("lon") ?? "0");
|
||||||
|
points.push({ lat, lon });
|
||||||
|
}
|
||||||
|
if (points.length >= 3) areas.push({ points });
|
||||||
|
}
|
||||||
|
return areas;
|
||||||
|
}
|
||||||
|
|
||||||
function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } {
|
function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } {
|
||||||
let gain = 0;
|
let gain = 0;
|
||||||
let loss = 0;
|
let loss = 0;
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,15 @@ export interface ElevationProfile {
|
||||||
elevation: number;
|
elevation: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NoGoArea {
|
||||||
|
points: Array<{ lat: number; lon: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GpxData {
|
export interface GpxData {
|
||||||
name?: string;
|
name?: string;
|
||||||
waypoints: Waypoint[];
|
waypoints: Waypoint[];
|
||||||
tracks: TrackPoint[][];
|
tracks: TrackPoint[][];
|
||||||
|
noGoAreas: NoGoArea[];
|
||||||
/** Total distance in meters (haversine, works with or without elevation data) */
|
/** Total distance in meters (haversine, works with or without elevation data) */
|
||||||
distance: number;
|
distance: number;
|
||||||
elevation: {
|
elevation: {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export default {
|
||||||
newSession: "Neue Sitzung",
|
newSession: "Neue Sitzung",
|
||||||
saveRoute: "Route speichern",
|
saveRoute: "Route speichern",
|
||||||
exportGpx: "GPX exportieren",
|
exportGpx: "GPX exportieren",
|
||||||
|
exportRoute: "Route exportieren",
|
||||||
|
exportPlan: "Plan exportieren",
|
||||||
profile: "Profil",
|
profile: "Profil",
|
||||||
connecting: "Verbinde...",
|
connecting: "Verbinde...",
|
||||||
loadingMap: "Karte wird geladen...",
|
loadingMap: "Karte wird geladen...",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export default {
|
||||||
newSession: "New Session",
|
newSession: "New Session",
|
||||||
saveRoute: "Save Route",
|
saveRoute: "Save Route",
|
||||||
exportGpx: "Export GPX",
|
exportGpx: "Export GPX",
|
||||||
|
exportRoute: "Export Route",
|
||||||
|
exportPlan: "Export Plan",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
connecting: "Connecting...",
|
connecting: "Connecting...",
|
||||||
loadingMap: "Loading map...",
|
loadingMap: "Loading map...",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue