diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts
index 4e48b8c..e3a399c 100644
--- a/apps/journal/app/lib/activities.server.ts
+++ b/apps/journal/app/lib/activities.server.ts
@@ -23,8 +23,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
if (input.gpx) {
try {
const gpxData = await parseGpxAsync(input.gpx);
- const profile = gpxData.elevation.profile;
- distance = profile.length > 0 ? Math.round(profile[profile.length - 1]!.distance) : null;
+ distance = gpxData.distance || null;
elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss;
diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts
index c9f1b45..e8bd8d3 100644
--- a/apps/journal/app/lib/routes.server.ts
+++ b/apps/journal/app/lib/routes.server.ts
@@ -145,11 +145,7 @@ async function computeRouteStats(gpxString: string) {
try {
const gpxData = await parseGpxAsync(gpxString);
return {
- distance: Math.round(
- gpxData.elevation.profile.length > 0
- ? gpxData.elevation.profile[gpxData.elevation.profile.length - 1]!.distance
- : 0,
- ),
+ distance: gpxData.distance,
elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss,
};
diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts
index 3778341..3364385 100644
--- a/apps/planner/app/routes/api.sessions.ts
+++ b/apps/planner/app/routes/api.sessions.ts
@@ -1,7 +1,7 @@
import { data } from "react-router";
import type { Route } from "./+types/api.sessions";
import { createSession, listSessions } from "~/lib/sessions";
-import { parseGpxAsync } from "@trails-cool/gpx";
+import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { withDb } from "@trails-cool/db";
export async function action({ request }: Route.ActionArgs) {
@@ -23,7 +23,8 @@ export async function action({ request }: Route.ActionArgs) {
if (gpx) {
try {
const gpxData = await parseGpxAsync(gpx);
- initialWaypoints = gpxData.waypoints;
+ const wps = extractWaypoints(gpxData);
+ if (wps.length > 0) initialWaypoints = wps;
} catch {
// Continue with empty session if GPX is invalid
}
diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx
index f37c72c..4150c29 100644
--- a/apps/planner/app/routes/new.tsx
+++ b/apps/planner/app/routes/new.tsx
@@ -1,7 +1,7 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/new";
import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
-import { parseGpx } from "@trails-cool/gpx";
+import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx";
import { checkRateLimit } from "~/lib/rate-limit";
export async function loader({ request }: Route.LoaderArgs) {
@@ -35,8 +35,8 @@ export async function loader({ request }: Route.LoaderArgs) {
if (gpxEncoded) {
try {
const gpx = decodeURIComponent(gpxEncoded);
- const gpxData = parseGpx(gpx);
- initializeSessionWithWaypoints(session.id, gpxData.waypoints);
+ const gpxData = await parseGpxAsync(gpx);
+ initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData));
} catch {
// Continue with empty session if GPX is invalid
}
diff --git a/packages/gpx/src/generate.test.ts b/packages/gpx/src/generate.test.ts
index 6b4fd34..0eefc9f 100644
--- a/packages/gpx/src/generate.test.ts
+++ b/packages/gpx/src/generate.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { generateGpx } from "./generate.ts";
-import { parseGpx } from "./parse.ts";
+import { parseGpxAsync } from "./parse.ts";
describe("generateGpx", () => {
it("generates valid GPX with name", () => {
@@ -36,13 +36,13 @@ describe("generateGpx", () => {
expect(gpx).toContain("Route <A> & B");
});
- it("produces round-trippable GPX", () => {
+ it("produces round-trippable GPX", async () => {
const original = generateGpx({
name: "Round Trip",
waypoints: [{ lat: 52.52, lon: 13.405, name: "Start" }],
tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]],
});
- const parsed = parseGpx(original);
+ const parsed = await parseGpxAsync(original);
expect(parsed.name).toBe("Round Trip");
expect(parsed.waypoints).toHaveLength(1);
expect(parsed.waypoints[0]!.name).toBe("Start");
diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts
index edc7f3d..f8887f2 100644
--- a/packages/gpx/src/index.ts
+++ b/packages/gpx/src/index.ts
@@ -1,3 +1,4 @@
-export { parseGpx, parseGpxAsync } from "./parse.ts";
+export { parseGpxAsync } from "./parse.ts";
export { generateGpx } from "./generate.ts";
+export { extractWaypoints } from "./waypoints.ts";
export type { GpxData, TrackPoint, ElevationProfile } from "./types.ts";
diff --git a/packages/gpx/src/parse.test.ts b/packages/gpx/src/parse.test.ts
index f38ac8b..6e05634 100644
--- a/packages/gpx/src/parse.test.ts
+++ b/packages/gpx/src/parse.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { parseGpx } from "./parse.ts";
+import { parseGpxAsync } from "./parse.ts";
const sampleGpx = `
@@ -15,42 +15,50 @@ const sampleGpx = `
`;
-describe("parseGpx", () => {
- it("parses route name", () => {
- const result = parseGpx(sampleGpx);
+describe("parseGpxAsync", () => {
+ it("parses route name", async () => {
+ const result = await parseGpxAsync(sampleGpx);
expect(result.name).toBe("Test Route");
});
- it("parses waypoints with lat, lon, and name", () => {
- const result = parseGpx(sampleGpx);
+ it("parses waypoints with lat, lon, and name", async () => {
+ const result = await parseGpxAsync(sampleGpx);
expect(result.waypoints).toHaveLength(2);
expect(result.waypoints[0]).toEqual({ lat: 52.52, lon: 13.405, name: "Berlin" });
expect(result.waypoints[1]).toEqual({ lat: 48.137, lon: 11.576, name: "Munich" });
});
- it("parses track points with elevation", () => {
- const result = parseGpx(sampleGpx);
+ it("parses track points with elevation", async () => {
+ const result = await parseGpxAsync(sampleGpx);
expect(result.tracks).toHaveLength(1);
expect(result.tracks[0]).toHaveLength(3);
expect(result.tracks[0]![0]).toEqual({ lat: 52.52, lon: 13.405, ele: 34, time: undefined });
});
- it("computes elevation gain and loss", () => {
- const result = parseGpx(sampleGpx);
+ it("computes elevation gain and loss", async () => {
+ const result = await parseGpxAsync(sampleGpx);
expect(result.elevation.gain).toBeGreaterThan(0);
expect(result.elevation.loss).toBe(0); // monotonically increasing elevation
expect(result.elevation.gain).toBe(485); // 113-34 + 519-113
});
- it("builds elevation profile", () => {
- const result = parseGpx(sampleGpx);
+ it("builds elevation profile", async () => {
+ const result = await parseGpxAsync(sampleGpx);
expect(result.elevation.profile).toHaveLength(3);
expect(result.elevation.profile[0]!.distance).toBe(0);
expect(result.elevation.profile[0]!.elevation).toBe(34);
expect(result.elevation.profile[2]!.distance).toBeGreaterThan(0);
});
- it("throws on invalid XML", () => {
- expect(() => parseGpx("not xml at all <<<<")).toThrow("Invalid GPX XML");
+ it("computes total distance independently of elevation", async () => {
+ const result = await parseGpxAsync(sampleGpx);
+ expect(result.distance).toBeGreaterThan(0);
+ // Berlin to Munich is ~500km, our 3-point track should be in that range
+ expect(result.distance).toBeGreaterThan(400_000);
+ expect(result.distance).toBeLessThan(600_000);
+ });
+
+ it("throws on invalid XML", async () => {
+ await expect(parseGpxAsync("not xml at all <<<<")).rejects.toThrow();
});
});
diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts
index d1b118b..1ff1b79 100644
--- a/packages/gpx/src/parse.ts
+++ b/packages/gpx/src/parse.ts
@@ -15,7 +15,7 @@ async function getDOMParser(): Promise {
return _LinkedDOMParser;
}
-export function parseGpx(xml: string): GpxData {
+function parseGpx(xml: string): GpxData {
// Synchronous path for browser
if (typeof DOMParser !== "undefined") {
return parseGpxWithParser(new DOMParser(), xml);
@@ -46,9 +46,9 @@ function parseGpxWithParser(parser: DOMParser, xml: string): GpxData {
const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
const waypoints = parseWaypoints(doc);
const tracks = parseTracks(doc);
- const elevation = computeElevation(tracks);
+ const { totalDistance, ...elevation } = computeElevation(tracks);
- return { name, waypoints, tracks, elevation };
+ return { name, waypoints, tracks, distance: totalDistance, elevation };
}
function parseWaypoints(doc: Document): Waypoint[] {
@@ -85,7 +85,7 @@ function parseTracks(doc: Document): TrackPoint[][] {
return tracks;
}
-function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] {
+function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] & { totalDistance: number } {
let gain = 0;
let loss = 0;
const profile: ElevationProfile[] = [];
@@ -112,7 +112,7 @@ function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] {
}
}
- return { gain: Math.round(gain), loss: Math.round(loss), profile };
+ return { totalDistance: Math.round(totalDistance), gain: Math.round(gain), loss: Math.round(loss), profile };
}
/** Haversine distance between two points in meters */
diff --git a/packages/gpx/src/types.ts b/packages/gpx/src/types.ts
index d7861cc..bb7d0a2 100644
--- a/packages/gpx/src/types.ts
+++ b/packages/gpx/src/types.ts
@@ -18,6 +18,8 @@ export interface GpxData {
name?: string;
waypoints: Waypoint[];
tracks: TrackPoint[][];
+ /** Total distance in meters (haversine, works with or without elevation data) */
+ distance: number;
elevation: {
gain: number;
loss: number;
diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts
new file mode 100644
index 0000000..dddfffb
--- /dev/null
+++ b/packages/gpx/src/waypoints.ts
@@ -0,0 +1,97 @@
+import type { GpxData } from "./types.ts";
+
+/**
+ * Extract waypoints from parsed GPX data.
+ * Uses explicit elements if present, otherwise simplifies the
+ * track using Douglas-Peucker to find significant turning points.
+ */
+export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: number; name?: string }> {
+ if (gpxData.waypoints.length > 0) return gpxData.waypoints;
+ if (gpxData.tracks.length === 0) return [];
+
+ // Collect start of each segment + end of last (for multi-segment GPX)
+ const segmentEndpoints: Array<{ lat: number; lon: number }> = [];
+ for (const seg of gpxData.tracks) {
+ if (seg.length === 0) continue;
+ const first = seg[0]!;
+ const prev = segmentEndpoints[segmentEndpoints.length - 1];
+ if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) {
+ segmentEndpoints.push({ lat: first.lat, lon: first.lon });
+ }
+ }
+ const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!;
+ if (lastSeg.length > 0) {
+ const last = lastSeg[lastSeg.length - 1]!;
+ const prev = segmentEndpoints[segmentEndpoints.length - 1];
+ if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) {
+ segmentEndpoints.push({ lat: last.lat, lon: last.lon });
+ }
+ }
+
+ // If multi-segment already gives us enough waypoints, use those
+ if (segmentEndpoints.length > 2) return segmentEndpoints;
+
+ // Single segment: simplify the track to find key turning points
+ const allPoints = gpxData.tracks.flat().map((p) => ({ lat: p.lat, lon: p.lon }));
+ if (allPoints.length < 2) return allPoints;
+
+ return douglasPeucker(allPoints, 0.005);
+}
+
+/**
+ * Douglas-Peucker line simplification.
+ * Epsilon is in degrees (~0.005° ≈ 500m at mid-latitudes).
+ * Recursively finds the point with maximum perpendicular distance
+ * from the line between start and end, keeping significant turns.
+ */
+function douglasPeucker(
+ points: Array<{ lat: number; lon: number }>,
+ epsilon: number,
+): Array<{ lat: number; lon: number }> {
+ if (points.length <= 2) return points;
+
+ let maxDist = 0;
+ let maxIdx = 0;
+
+ const start = points[0]!;
+ const end = points[points.length - 1]!;
+
+ for (let i = 1; i < points.length - 1; i++) {
+ const dist = perpendicularDistance(points[i]!, start, end);
+ if (dist > maxDist) {
+ maxDist = dist;
+ maxIdx = i;
+ }
+ }
+
+ if (maxDist > epsilon) {
+ const left = douglasPeucker(points.slice(0, maxIdx + 1), epsilon);
+ const right = douglasPeucker(points.slice(maxIdx), epsilon);
+ return [...left.slice(0, -1), ...right];
+ }
+
+ return [start, end];
+}
+
+function perpendicularDistance(
+ point: { lat: number; lon: number },
+ lineStart: { lat: number; lon: number },
+ lineEnd: { lat: number; lon: number },
+): number {
+ const dx = lineEnd.lon - lineStart.lon;
+ const dy = lineEnd.lat - lineStart.lat;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq === 0) {
+ const px = point.lon - lineStart.lon;
+ const py = point.lat - lineStart.lat;
+ return Math.sqrt(px * px + py * py);
+ }
+ const t = Math.max(0, Math.min(1,
+ ((point.lon - lineStart.lon) * dx + (point.lat - lineStart.lat) * dy) / lenSq,
+ ));
+ const projLon = lineStart.lon + t * dx;
+ const projLat = lineStart.lat + t * dy;
+ const px = point.lon - projLon;
+ const py = point.lat - projLat;
+ return Math.sqrt(px * px + py * py);
+}