diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e51c7b4..e3a399c 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -3,6 +3,7 @@ import { eq, desc } from "drizzle-orm"; import { getDb } from "./db.ts"; import { activities, routes } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; +import { setGeomFromGpx } from "./routes.server.ts"; export interface ActivityInput { name: string; @@ -19,12 +20,10 @@ export async function createActivity(ownerId: string, input: ActivityInput) { let elevationGain: number | null = null; let elevationLoss: number | null = null; let startedAt: Date | null = null; - 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; @@ -50,6 +49,10 @@ export async function createActivity(ownerId: string, input: ActivityInput) { startedAt, }); + if (input.gpx) { + await setGeomFromGpx(id, "activities", input.gpx); + } + return id; } @@ -93,6 +96,8 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin elevationLoss: activity.elevationLoss, }); + await setGeomFromGpx(routeId, "routes", activity.gpx); + // Link the activity to the new route await db .update(activities) diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 1d36af3..e8bd8d3 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; import { routes, routeVersions } from "@trails-cool/db/schema/journal"; -import { parseGpx } from "@trails-cool/gpx"; +import { parseGpxAsync } from "@trails-cool/gpx"; +import { sql } from "drizzle-orm"; export interface RouteInput { name: string; @@ -18,9 +19,8 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; - if (input.gpx) { - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; @@ -38,6 +38,10 @@ export async function createRoute(ownerId: string, input: RouteInput) { elevationLoss, }); + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } + // Create initial version if GPX provided if (input.gpx) { await db.insert(routeVersions).values({ @@ -95,7 +99,7 @@ export async function updateRoute( if (input.gpx) { updateData.gpx = input.gpx; - const stats = computeRouteStats(input.gpx); + const stats = await computeRouteStats(input.gpx); updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; @@ -122,6 +126,10 @@ export async function updateRoute( .update(routes) .set(updateData) .where(and(eq(routes.id, id), eq(routes.ownerId, ownerId))); + + if (input.gpx) { + await setGeomFromGpx(id, "routes", input.gpx); + } } export async function deleteRoute(id: string, ownerId: string) { @@ -133,15 +141,11 @@ export async function deleteRoute(id: string, ownerId: string) { return result.length > 0; } -function computeRouteStats(gpxString: string) { +async function computeRouteStats(gpxString: string) { try { - const gpxData = parseGpx(gpxString); + 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, }; @@ -149,3 +153,20 @@ function computeRouteStats(gpxString: string) { return { distance: null, elevationGain: null, elevationLoss: null }; } } + +async function setGeomFromGpx(id: string, table: "routes" | "activities", gpxString: string) { + try { + const gpxData = await parseGpxAsync(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length < 2) return; + const geojson = JSON.stringify({ type: "LineString", coordinates: coords }); + const db = getDb(); + await db.execute( + sql`UPDATE ${sql.identifier("journal")}.${sql.identifier(table)} SET geom = ST_GeomFromGeoJSON(${geojson}) WHERE id = ${id}`, + ); + } catch (e) { + console.error(`Failed to set geom for ${table}/${id}:`, e); + } +} + +export { setGeomFromGpx }; diff --git a/apps/planner/app/lib/metrics.server.ts b/apps/planner/app/lib/metrics.server.ts index 85590c6..46d2060 100644 --- a/apps/planner/app/lib/metrics.server.ts +++ b/apps/planner/app/lib/metrics.server.ts @@ -1,29 +1,44 @@ import client from "prom-client"; -// Collect default Node.js metrics (event loop, heap, GC) -client.collectDefaultMetrics(); +// Guard all metric registration — Vite's dev server can re-evaluate +// this module, causing "already registered" errors. +function getOrCreate(name: string, create: () => T): T { + return (client.register.getSingleMetric(name) as T) ?? create(); +} -export const httpRequestDuration = new client.Histogram({ - name: "http_request_duration_seconds", - help: "Duration of HTTP requests in seconds", - labelNames: ["method", "route", "status"] as const, - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], -}); +if (!client.register.getSingleMetric("process_cpu_user_seconds_total")) { + client.collectDefaultMetrics(); +} -export const plannerActiveSessions = new client.Gauge({ - name: "planner_active_sessions", - help: "Number of active planner sessions", -}); +export const httpRequestDuration = getOrCreate("http_request_duration_seconds", () => + new client.Histogram({ + name: "http_request_duration_seconds", + help: "Duration of HTTP requests in seconds", + labelNames: ["method", "route", "status"] as const, + buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], + }), +); -export const plannerConnectedClients = new client.Gauge({ - name: "planner_connected_clients", - help: "Number of connected WebSocket clients", -}); +export const plannerActiveSessions = getOrCreate("planner_active_sessions", () => + new client.Gauge({ + name: "planner_active_sessions", + help: "Number of active planner sessions", + }), +); -export const brouterRequestDuration = new client.Histogram({ - name: "brouter_request_duration_seconds", - help: "Duration of BRouter API requests in seconds", - buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], -}); +export const plannerConnectedClients = getOrCreate("planner_connected_clients", () => + new client.Gauge({ + name: "planner_connected_clients", + help: "Number of connected WebSocket clients", + }), +); + +export const brouterRequestDuration = getOrCreate("brouter_request_duration_seconds", () => + new client.Histogram({ + name: "brouter_request_duration_seconds", + help: "Duration of BRouter API requests in seconds", + buckets: [0.1, 0.25, 0.5, 1, 2, 5, 10], + }), +); export const registry = client.register; diff --git a/apps/planner/app/lib/yjs-server.ts b/apps/planner/app/lib/yjs-server.ts index ccb22ba..af9e53f 100644 --- a/apps/planner/app/lib/yjs-server.ts +++ b/apps/planner/app/lib/yjs-server.ts @@ -6,6 +6,7 @@ import * as encoding from "lib0/encoding"; import * as decoding from "lib0/decoding"; import type { IncomingMessage, Server } from "node:http"; import { saveSessionState, loadSessionState, touchSession } from "./sessions.ts"; +import { plannerActiveSessions, plannerConnectedClients } from "./metrics.server.ts"; const messageSync = 0; const messageAwareness = 1; @@ -159,10 +160,13 @@ export function setupYjsWebSocket(server: Server): WebSocketServer { }); wss.on("connection", async (ws: WebSocket, _request: IncomingMessage, sessionId: string) => { + const isNewSession = !docs.has(sessionId); const doc = await getOrLoadDoc(sessionId); const awareness = getAwareness(sessionId, doc); conns.set(ws, { sessionId, clientIds: new Set() }); + plannerConnectedClients.inc(); + if (isNewSession) plannerActiveSessions.inc(); // Broadcast doc updates to all connections in this session const onUpdate = (update: Uint8Array, origin: unknown) => { @@ -218,11 +222,13 @@ export function setupYjsWebSocket(server: Server): WebSocketServer { ); } conns.delete(ws); + plannerConnectedClients.dec(); doc.off("update", onUpdate); // Save when last client leaves const hasClients = Array.from(conns.values()).some((m) => m.sessionId === sessionId); if (!hasClients) { + plannerActiveSessions.dec(); saveSessionState(sessionId).catch(() => {}); } }); 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/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index c97b378..3f59adf 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -154,6 +154,7 @@ services: GF_AUTH_GITHUB_CLIENT_SECRET: ${GF_AUTH_GITHUB_CLIENT_SECRET:-} GF_AUTH_GITHUB_ALLOWED_ORGANIZATIONS: trails-cool GF_AUTH_GITHUB_SCOPES: user:email,read:org + GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP: "true" GF_AUTH_DISABLE_LOGIN_FORM: "true" GF_SMTP_ENABLED: "true" GF_SMTP_HOST: ${GF_SMTP_HOST:-} diff --git a/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index 2bda741..eefe45a 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -1,6 +1,6 @@ global: - scrape_interval: 15s - evaluation_interval: 15s + scrape_interval: 5s + evaluation_interval: 5s scrape_configs: - job_name: "journal" diff --git a/openspec/specs/brouter-integration/spec.md b/openspec/specs/brouter-integration/spec.md index b2e8053..6757712 100644 --- a/openspec/specs/brouter-integration/spec.md +++ b/openspec/specs/brouter-integration/spec.md @@ -32,7 +32,7 @@ The Planner SHALL elect one participant per session as the "routing host" who is #### Scenario: Host failover - **WHEN** the current routing host disconnects -- **THEN** the longest-connected remaining participant becomes the new host within 5 seconds +- **THEN** failover is immediate via deterministic Yjs clientID election: the client with the lowest remaining ID becomes host instantly ### Requirement: Route broadcast The routing host SHALL store computed route results in the Yjs document so that all participants receive route updates automatically. @@ -45,7 +45,7 @@ The routing host SHALL store computed route results in the Yjs document so that The Planner SHALL support selecting a routing profile that determines how BRouter computes the route. #### Scenario: Switch routing profile -- **WHEN** a user changes the routing profile from "trekking" to "shortest" +- **WHEN** a user changes the routing profile (available profiles: `trekking`, `fastbike`, `safety`, `shortest`, `car`) - **THEN** the profile change syncs via Yjs and the routing host recomputes the route ### Requirement: BRouter API proxy diff --git a/openspec/specs/infrastructure/spec.md b/openspec/specs/infrastructure/spec.md index 61fb6c5..a1bb45b 100644 --- a/openspec/specs/infrastructure/spec.md +++ b/openspec/specs/infrastructure/spec.md @@ -5,7 +5,7 @@ Infrastructure SHALL be provisioned on Hetzner Cloud using Terraform with the He #### Scenario: Provision server - **WHEN** `terraform apply` is run -- **THEN** a Hetzner CX21 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed +- **THEN** a Hetzner cx23 server (2 vCPU, 4 GB RAM, 40 GB SSD) is created with Docker installed ### Requirement: Docker Compose deployment All services SHALL be deployed via Docker Compose on the Hetzner server. diff --git a/openspec/specs/observability/spec.md b/openspec/specs/observability/spec.md index 850e803..88ae7ca 100644 --- a/openspec/specs/observability/spec.md +++ b/openspec/specs/observability/spec.md @@ -5,11 +5,11 @@ Both apps SHALL expose a `/health` endpoint returning service and database statu #### Scenario: Healthy service - **WHEN** the app is running and the database is reachable -- **THEN** `GET /health` returns `{ "status": "ok", "db": "connected" }` with HTTP 200 +- **THEN** `GET /health` returns `{ "status": "ok", "db": "connected", "version": "" }` with HTTP 200 (version from `SENTRY_RELEASE` env var, defaults to `"dev"`) #### Scenario: Degraded service - **WHEN** the app is running but the database is unreachable -- **THEN** `GET /health` returns `{ "status": "degraded", "db": "unreachable" }` with HTTP 503 +- **THEN** `GET /health` returns `{ "status": "degraded", "db": "unreachable", "version": "" }` with HTTP 503 ### Requirement: Prometheus metrics Both apps SHALL expose a `/metrics` endpoint with Prometheus-formatted metrics. @@ -26,6 +26,10 @@ Both apps SHALL expose a `/metrics` endpoint with Prometheus-formatted metrics. - **WHEN** the Planner is running - **THEN** `planner_active_sessions` and `planner_connected_clients` gauges reflect current state +#### Scenario: BRouter latency metrics +- **WHEN** the Planner proxies a routing request to BRouter +- **THEN** `brouter_request_duration_seconds` histogram is updated (buckets: 0.1s to 10s) + ### Requirement: Structured logging Both apps SHALL output structured JSON logs in production. diff --git a/openspec/specs/planner-session/spec.md b/openspec/specs/planner-session/spec.md index accceb9..414aa90 100644 --- a/openspec/specs/planner-session/spec.md +++ b/openspec/specs/planner-session/spec.md @@ -45,7 +45,7 @@ The Planner SHALL persist Yjs session state to PostgreSQL so that sessions survi - **THEN** reconnecting clients recover the full session state from PostgreSQL ### Requirement: Session expiry -The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, max: 30 days). +The Planner SHALL automatically expire sessions after a configurable period of inactivity (default: 7 days, no hard ceiling enforced). #### Scenario: Session expires - **WHEN** no edits are made to a session for 7 days diff --git a/openspec/specs/secret-management/spec.md b/openspec/specs/secret-management/spec.md index abaa0ae..3d64bb1 100644 --- a/openspec/specs/secret-management/spec.md +++ b/openspec/specs/secret-management/spec.md @@ -1,15 +1,15 @@ ## Requirements ### Requirement: Encrypted secrets in repository -Production secrets SHALL be stored as a SOPS-encrypted file in the repository, decryptable only with a single age private key. +Production secrets SHALL be stored as SOPS-encrypted files in the repository, decryptable only with a single age private key. Secrets are split into two files: `secrets.app.env` (application secrets) and `secrets.infra.env` (infrastructure secrets). #### Scenario: Edit secrets -- **WHEN** a developer runs `sops infrastructure/secrets.env` +- **WHEN** a developer runs `sops infrastructure/secrets.app.env` or `sops infrastructure/secrets.infra.env` - **THEN** the file is decrypted in a temporary editor, and re-encrypted on save #### Scenario: CD decryption - **WHEN** the CD workflow runs -- **THEN** the encrypted secrets file is decrypted using the AGE_SECRET_KEY GitHub secret and provided to docker-compose as an env file +- **THEN** both encrypted secrets files are decrypted using the AGE_SECRET_KEY GitHub secret and merged at deploy time as env files for docker-compose #### Scenario: Secret audit trail - **WHEN** a secret is changed diff --git a/openspec/specs/shared-packages/spec.md b/openspec/specs/shared-packages/spec.md index e40a8e1..ec7aa45 100644 --- a/openspec/specs/shared-packages/spec.md +++ b/openspec/specs/shared-packages/spec.md @@ -27,7 +27,7 @@ The `@trails-cool/gpx` package SHALL parse GPX XML into structured data (waypoin - **THEN** it returns elevation gain, loss, and a profile array of distance/elevation pairs ### Requirement: Map rendering package -The `@trails-cool/map` package SHALL provide React components for rendering Leaflet maps with configurable base layers and route overlays. +The `@trails-cool/map` package SHALL provide core React components (MapView, RouteLayer) for rendering Leaflet maps with configurable base layers and route overlays. Interactive features (route drag-reshape, ghost markers, no-go area drawing, elevation chart, cursor tracking, colored routes) are implemented directly in the Planner app since they are Planner-specific. #### Scenario: Render map component - **WHEN** the map package's MapView component is rendered with a center and zoom diff --git a/openspec/specs/transactional-emails/spec.md b/openspec/specs/transactional-emails/spec.md index d88f2c3..4e50bee 100644 --- a/openspec/specs/transactional-emails/spec.md +++ b/openspec/specs/transactional-emails/spec.md @@ -5,7 +5,7 @@ The system SHALL provide a provider-agnostic email sending function that support #### Scenario: Send email in production - **WHEN** the system sends a transactional email in production -- **THEN** the email is delivered via the configured provider (Resend) to the recipient +- **THEN** the email is delivered via Nodemailer with SMTP (configured via `SMTP_URL` connection string and `SMTP_FROM` env var) to the recipient #### Scenario: Dev mode skips sending - **WHEN** the system sends a transactional email in development 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/geom.test.ts b/packages/gpx/src/geom.test.ts new file mode 100644 index 0000000..a1521ed --- /dev/null +++ b/packages/gpx/src/geom.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { parseGpxAsync } from "./parse.ts"; + +const sampleGpx = ` + + + + 34 + 113 + 519 + + +`; + +describe("GPX to geometry coordinates", () => { + it("extracts [lon, lat] coordinates from track points", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(3); + // GeoJSON order: [lon, lat] + expect(coords[0]).toEqual([13.405, 52.52]); + expect(coords[1]).toEqual([13.74, 51.05]); + expect(coords[2]).toEqual([11.576, 48.137]); + }); + + it("produces valid GeoJSON LineString", async () => { + const gpxData = await parseGpxAsync(sampleGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + const geojson = { type: "LineString", coordinates: coords }; + + expect(geojson.type).toBe("LineString"); + expect(geojson.coordinates).toHaveLength(3); + // Verify serialization doesn't throw + expect(() => JSON.stringify(geojson)).not.toThrow(); + }); + + it("handles empty GPX gracefully", async () => { + const emptyGpx = ` + + + `; + const gpxData = await parseGpxAsync(emptyGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(0); + }); + + it("handles single point (insufficient for LineString)", async () => { + const singlePointGpx = ` + + + 34 + + `; + const gpxData = await parseGpxAsync(singlePointGpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + + expect(coords).toHaveLength(1); + // Caller should check coords.length >= 2 before creating LineString + }); +}); 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); +}