Merge remote-tracking branch 'origin/main' into copilot/identify-spec-implementation-drift
Co-authored-by: stigi <13815+stigi@users.noreply.github.com>
This commit is contained in:
commit
9c3900e7b5
22 changed files with 299 additions and 76 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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<T>(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;
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: "journal"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": "<string>" }` 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": "<string>" }` 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
62
packages/gpx/src/geom.test.ts
Normal file
62
packages/gpx/src/geom.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseGpxAsync } from "./parse.ts";
|
||||
|
||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk>
|
||||
<trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
<trkpt lat="51.05" lon="13.74"><ele>113</ele></trkpt>
|
||||
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`;
|
||||
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg></trkseg></trk>
|
||||
</gpx>`;
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<trk><trkseg>
|
||||
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
|
||||
</trkseg></trk>
|
||||
</gpx>`;
|
||||
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
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseGpx } from "./parse.ts";
|
||||
import { parseGpxAsync } from "./parse.ts";
|
||||
|
||||
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
|
|
@ -15,42 +15,50 @@ const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
|||
</trk>
|
||||
</gpx>`;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ async function getDOMParser(): Promise<typeof DOMParser> {
|
|||
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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
97
packages/gpx/src/waypoints.ts
Normal file
97
packages/gpx/src/waypoints.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { GpxData } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Extract waypoints from parsed GPX data.
|
||||
* Uses explicit <wpt> 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue