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); +}