From d5bcecb3457f6b878995c078eb7a4811988c0f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:19:55 +0100 Subject: [PATCH 1/7] Fix distance calculation for GPX files without elevation data Distance was only stored in the elevation profile array, which was empty when track points had no elements. Now computed via haversine independently and exposed as gpxData.distance. Tested: berlin-dresden-radweg-2021.gpx (5660 points, no elevation) now correctly reports 248.8 km. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/lib/activities.server.ts | 3 +-- apps/journal/app/lib/routes.server.ts | 6 +----- packages/gpx/src/parse.ts | 8 ++++---- packages/gpx/src/types.ts | 2 ++ 4 files changed, 8 insertions(+), 11 deletions(-) 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/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts index d1b118b..70a3a0e 100644 --- a/packages/gpx/src/parse.ts +++ b/packages/gpx/src/parse.ts @@ -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; From 5f3d4ae84694a577345092cc0928d135c2e4253c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:29:04 +0100 Subject: [PATCH 2/7] Remove sync parseGpx, use parseGpxAsync everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseGpx (sync) needs browser DOMParser which doesn't exist on the server — it silently failed in every server-side caller. Removed the export and migrated all callers to parseGpxAsync. Updated tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/new.tsx | 4 ++-- packages/gpx/src/generate.test.ts | 6 +++--- packages/gpx/src/index.ts | 2 +- packages/gpx/src/parse.test.ts | 36 +++++++++++++++++++------------ packages/gpx/src/parse.ts | 2 +- 5 files changed, 29 insertions(+), 21 deletions(-) diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index f37c72c..8a9f948 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 } from "@trails-cool/gpx"; import { checkRateLimit } from "~/lib/rate-limit"; export async function loader({ request }: Route.LoaderArgs) { @@ -35,7 +35,7 @@ export async function loader({ request }: Route.LoaderArgs) { if (gpxEncoded) { try { const gpx = decodeURIComponent(gpxEncoded); - const gpxData = parseGpx(gpx); + const gpxData = await parseGpxAsync(gpx); initializeSessionWithWaypoints(session.id, gpxData.waypoints); } 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..e84a560 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,3 @@ -export { parseGpx, parseGpxAsync } from "./parse.ts"; +export { parseGpxAsync } from "./parse.ts"; export { generateGpx } from "./generate.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 70a3a0e..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); From 79942bb99ec2fa485bff4162ac44f60152db77df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:40:11 +0100 Subject: [PATCH 3/7] Extract start/end from track when GPX has no waypoints The planner's GPX import only used elements, which many GPX files don't have. Now falls back to the first and last track point, giving the planner two endpoints to route between. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/new.tsx | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx index 8a9f948..aa01380 100644 --- a/apps/planner/app/routes/new.tsx +++ b/apps/planner/app/routes/new.tsx @@ -36,7 +36,31 @@ export async function loader({ request }: Route.LoaderArgs) { try { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); - initializeSessionWithWaypoints(session.id, gpxData.waypoints); + // Use explicit waypoints if present, otherwise extract from track segments + let waypoints = gpxData.waypoints; + if (waypoints.length === 0 && gpxData.tracks.length > 0) { + const extracted: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + // Deduplicate: skip if same as previous waypoint + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + extracted.push({ lat: first.lat, lon: first.lon }); + } + } + // Add the end of the last segment + const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; + if (lastSeg.length > 0) { + const last = lastSeg[lastSeg.length - 1]!; + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + extracted.push({ lat: last.lat, lon: last.lon }); + } + } + waypoints = extracted; + } + initializeSessionWithWaypoints(session.id, waypoints); } catch { // Continue with empty session if GPX is invalid } From e0f7c5b806b6052e78619a2d6098d07f04cc1b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:45:05 +0100 Subject: [PATCH 4/7] Apply track-segment waypoint extraction to sessions API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as new.tsx — the journal→planner handoff also only used elements from GPX. Now both code paths extract waypoints from track segments when no explicit waypoints exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/api.sessions.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 3778341..26ad9c5 100644 --- a/apps/planner/app/routes/api.sessions.ts +++ b/apps/planner/app/routes/api.sessions.ts @@ -23,7 +23,29 @@ export async function action({ request }: Route.ActionArgs) { if (gpx) { try { const gpxData = await parseGpxAsync(gpx); - initialWaypoints = gpxData.waypoints; + // Use explicit waypoints if present, otherwise extract from track segments + if (gpxData.waypoints.length > 0) { + initialWaypoints = gpxData.waypoints; + } else if (gpxData.tracks.length > 0) { + const extracted: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + const prev = extracted[extracted.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + extracted.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 = extracted[extracted.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + extracted.push({ lat: last.lat, lon: last.lon }); + } + } + initialWaypoints = extracted; + } } catch { // Continue with empty session if GPX is invalid } From cc90c32a0ee208c6bd4ec0df5d3d7f187510fb9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 20:47:17 +0100 Subject: [PATCH 5/7] Extract shared extractWaypoints into @trails-cool/gpx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new.tsx and api.sessions.ts had duplicated track-segment waypoint extraction logic. Moved to packages/gpx as extractWaypoints(gpxData) — uses elements when present, falls back to start of each track segment + end of last. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/planner/app/routes/api.sessions.ts | 27 +++----------------- apps/planner/app/routes/new.tsx | 28 ++------------------- packages/gpx/src/index.ts | 1 + packages/gpx/src/waypoints.ts | 33 +++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 50 deletions(-) create mode 100644 packages/gpx/src/waypoints.ts diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts index 26ad9c5..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,29 +23,8 @@ export async function action({ request }: Route.ActionArgs) { if (gpx) { try { const gpxData = await parseGpxAsync(gpx); - // Use explicit waypoints if present, otherwise extract from track segments - if (gpxData.waypoints.length > 0) { - initialWaypoints = gpxData.waypoints; - } else if (gpxData.tracks.length > 0) { - const extracted: Array<{ lat: number; lon: number }> = []; - for (const seg of gpxData.tracks) { - if (seg.length === 0) continue; - const first = seg[0]!; - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - extracted.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 = extracted[extracted.length - 1]; - if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - extracted.push({ lat: last.lat, lon: last.lon }); - } - } - initialWaypoints = extracted; - } + 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 aa01380..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 { parseGpxAsync } from "@trails-cool/gpx"; +import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { checkRateLimit } from "~/lib/rate-limit"; export async function loader({ request }: Route.LoaderArgs) { @@ -36,31 +36,7 @@ export async function loader({ request }: Route.LoaderArgs) { try { const gpx = decodeURIComponent(gpxEncoded); const gpxData = await parseGpxAsync(gpx); - // Use explicit waypoints if present, otherwise extract from track segments - let waypoints = gpxData.waypoints; - if (waypoints.length === 0 && gpxData.tracks.length > 0) { - const extracted: Array<{ lat: number; lon: number }> = []; - for (const seg of gpxData.tracks) { - if (seg.length === 0) continue; - const first = seg[0]!; - // Deduplicate: skip if same as previous waypoint - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - extracted.push({ lat: first.lat, lon: first.lon }); - } - } - // Add the end of the last segment - const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; - if (lastSeg.length > 0) { - const last = lastSeg[lastSeg.length - 1]!; - const prev = extracted[extracted.length - 1]; - if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - extracted.push({ lat: last.lat, lon: last.lon }); - } - } - waypoints = extracted; - } - initializeSessionWithWaypoints(session.id, waypoints); + initializeSessionWithWaypoints(session.id, extractWaypoints(gpxData)); } catch { // Continue with empty session if GPX is invalid } diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts index e84a560..f8887f2 100644 --- a/packages/gpx/src/index.ts +++ b/packages/gpx/src/index.ts @@ -1,3 +1,4 @@ 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/waypoints.ts b/packages/gpx/src/waypoints.ts new file mode 100644 index 0000000..eca8da4 --- /dev/null +++ b/packages/gpx/src/waypoints.ts @@ -0,0 +1,33 @@ +import type { GpxData } from "./types.ts"; + +/** + * Extract waypoints from parsed GPX data. + * Uses explicit elements if present, otherwise extracts the start + * of each track segment + end of the last segment. + */ +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 []; + + const result: Array<{ lat: number; lon: number }> = []; + for (const seg of gpxData.tracks) { + if (seg.length === 0) continue; + const first = seg[0]!; + const prev = result[result.length - 1]; + if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { + result.push({ lat: first.lat, lon: first.lon }); + } + } + + // Add end of last segment + const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; + if (lastSeg.length > 0) { + const last = lastSeg[lastSeg.length - 1]!; + const prev = result[result.length - 1]; + if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { + result.push({ lat: last.lat, lon: last.lon }); + } + } + + return result; +} From 0e27d6eaef26d2630785622cbbfad1573396aedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 22:36:13 +0100 Subject: [PATCH 6/7] Use Douglas-Peucker simplification for single-segment GPX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For GPX files with one long track segment, extracting only start/end gives just 2 waypoints. Now uses Douglas-Peucker line simplification (epsilon=0.05° ≈ 5km) to find significant turning points. Result: berlin-dresden-radweg (249km, 5660 points) → 10 waypoints that capture the route's key direction changes. Multi-segment GPX files still use segment endpoints as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/waypoints.ts | 84 ++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index eca8da4..4d4da9b 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -2,32 +2,96 @@ import type { GpxData } from "./types.ts"; /** * Extract waypoints from parsed GPX data. - * Uses explicit elements if present, otherwise extracts the start - * of each track segment + end of the last segment. + * 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 []; - const result: Array<{ lat: number; lon: number }> = []; + // 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 = result[result.length - 1]; + const prev = segmentEndpoints[segmentEndpoints.length - 1]; if (!prev || prev.lat !== first.lat || prev.lon !== first.lon) { - result.push({ lat: first.lat, lon: first.lon }); + segmentEndpoints.push({ lat: first.lat, lon: first.lon }); } } - - // Add end of last segment const lastSeg = gpxData.tracks[gpxData.tracks.length - 1]!; if (lastSeg.length > 0) { const last = lastSeg[lastSeg.length - 1]!; - const prev = result[result.length - 1]; + const prev = segmentEndpoints[segmentEndpoints.length - 1]; if (!prev || prev.lat !== last.lat || prev.lon !== last.lon) { - result.push({ lat: last.lat, lon: last.lon }); + segmentEndpoints.push({ lat: last.lat, lon: last.lon }); } } - return result; + // 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.05); +} + +/** + * 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); } From 2b0b89add84de17de8263f563fa8f3da231324e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 2 Apr 2026 22:40:01 +0100 Subject: [PATCH 7/7] Use epsilon 0.005 for Douglas-Peucker waypoint extraction Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/gpx/src/waypoints.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gpx/src/waypoints.ts b/packages/gpx/src/waypoints.ts index 4d4da9b..dddfffb 100644 --- a/packages/gpx/src/waypoints.ts +++ b/packages/gpx/src/waypoints.ts @@ -35,7 +35,7 @@ export function extractWaypoints(gpxData: GpxData): Array<{ lat: number; lon: nu const allPoints = gpxData.tracks.flat().map((p) => ({ lat: p.lat, lon: p.lon })); if (allPoints.length < 2) return allPoints; - return douglasPeucker(allPoints, 0.05); + return douglasPeucker(allPoints, 0.005); } /**