Implement shared packages (tasks 2.1-2.6)

- @trails-cool/types: Route, Activity, Waypoint, RouteVersion, RouteMetadata
- @trails-cool/gpx: GPX parser (XML→waypoints/tracks/elevation) and generator
  with round-trip test, haversine distance, elevation gain/loss computation
- @trails-cool/map: MapView (Leaflet + OSM/OpenTopoMap/CyclOSM layer switcher),
  RouteLayer (GeoJSON polyline rendering)
- @trails-cool/ui: Button (primary/secondary/ghost), Input (with label/error),
  Card components with Tailwind styling
- @trails-cool/i18n: react-i18next config with English + German translations,
  browser language detection, fallback to English

All 13 unit tests pass. Both apps build successfully.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-22 13:09:35 +01:00
parent 325a4466d5
commit 2cfa5e54e7
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
23 changed files with 809 additions and 30 deletions

View file

@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { generateGpx } from "./generate";
import { parseGpx } from "./parse";
describe("generateGpx", () => {
it("generates valid GPX with name", () => {
const gpx = generateGpx({ name: "Test Route" });
expect(gpx).toContain('<?xml version="1.0"');
expect(gpx).toContain("<name>Test Route</name>");
expect(gpx).toContain("</gpx>");
});
it("generates waypoints", () => {
const gpx = generateGpx({
waypoints: [
{ lat: 52.52, lon: 13.405, name: "Berlin" },
{ lat: 48.137, lon: 11.576 },
],
});
expect(gpx).toContain('wpt lat="52.52" lon="13.405"');
expect(gpx).toContain("<name>Berlin</name>");
expect(gpx).toContain('wpt lat="48.137" lon="11.576"');
});
it("generates track points with elevation", () => {
const gpx = generateGpx({
tracks: [[{ lat: 52.52, lon: 13.405, ele: 34 }, { lat: 48.137, lon: 11.576, ele: 519 }]],
});
expect(gpx).toContain("<trkseg>");
expect(gpx).toContain('<trkpt lat="52.52" lon="13.405">');
expect(gpx).toContain("<ele>34</ele>");
});
it("escapes XML special characters in names", () => {
const gpx = generateGpx({ name: "Route <A> & B" });
expect(gpx).toContain("Route &lt;A&gt; &amp; B");
});
it("produces round-trippable GPX", () => {
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);
expect(parsed.name).toBe("Round Trip");
expect(parsed.waypoints).toHaveLength(1);
expect(parsed.waypoints[0]!.name).toBe("Start");
expect(parsed.tracks[0]).toHaveLength(2);
});
});

View file

@ -0,0 +1,60 @@
import type { Waypoint } from "@trails-cool/types";
import type { TrackPoint } from "./types";
/**
* Generate a GPX XML string from waypoints and track points.
*/
export function generateGpx(options: {
name?: string;
waypoints?: Waypoint[];
tracks?: TrackPoint[][];
}): string {
const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<gpx version="1.1" creator="trails.cool"',
' xmlns="http://www.topografix.com/GPX/1/1">',
];
if (options.name) {
lines.push(" <metadata>", ` <name>${escapeXml(options.name)}</name>`, " </metadata>");
}
if (options.waypoints) {
for (const wpt of options.waypoints) {
lines.push(` <wpt lat="${wpt.lat}" lon="${wpt.lon}">`);
if (wpt.name) {
lines.push(` <name>${escapeXml(wpt.name)}</name>`);
}
lines.push(" </wpt>");
}
}
if (options.tracks) {
for (const track of options.tracks) {
lines.push(" <trk>", " <trkseg>");
for (const pt of track) {
lines.push(` <trkpt lat="${pt.lat}" lon="${pt.lon}">`);
if (pt.ele !== undefined) {
lines.push(` <ele>${pt.ele}</ele>`);
}
if (pt.time) {
lines.push(` <time>${escapeXml(pt.time)}</time>`);
}
lines.push(" </trkpt>");
}
lines.push(" </trkseg>", " </trk>");
}
}
lines.push("</gpx>");
return lines.join("\n");
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

View file

@ -1,4 +1,3 @@
/**
* GPX parsing, generation, and validation for trails.cool
*/
export {};
export { parseGpx } from "./parse";
export { generateGpx } from "./generate";
export type { GpxData, TrackPoint, ElevationProfile } from "./types";

View file

@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { parseGpx } from "./parse";
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="test" xmlns="http://www.topografix.com/GPX/1/1">
<metadata><name>Test Route</name></metadata>
<wpt lat="52.52" lon="13.405"><name>Berlin</name></wpt>
<wpt lat="48.137" lon="11.576"><name>Munich</name></wpt>
<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("parseGpx", () => {
it("parses route name", () => {
const result = parseGpx(sampleGpx);
expect(result.name).toBe("Test Route");
});
it("parses waypoints with lat, lon, and name", () => {
const result = parseGpx(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);
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);
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);
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");
});
});

103
packages/gpx/src/parse.ts Normal file
View file

@ -0,0 +1,103 @@
import type { Waypoint } from "@trails-cool/types";
import type { GpxData, TrackPoint, ElevationProfile } from "./types";
/**
* Parse a GPX XML string into structured data.
*/
export function parseGpx(xml: string): GpxData {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "application/xml");
const parserError = doc.querySelector("parsererror");
if (parserError) {
throw new Error(`Invalid GPX XML: ${parserError.textContent}`);
}
const name = doc.querySelector("metadata > name")?.textContent ?? undefined;
const waypoints = parseWaypoints(doc);
const tracks = parseTracks(doc);
const elevation = computeElevation(tracks);
return { name, waypoints, tracks, elevation };
}
function parseWaypoints(doc: Document): Waypoint[] {
const wpts = doc.querySelectorAll("wpt");
return Array.from(wpts).map((wpt) => {
const lat = parseFloat(wpt.getAttribute("lat") ?? "0");
const lon = parseFloat(wpt.getAttribute("lon") ?? "0");
const name = wpt.querySelector("name")?.textContent ?? undefined;
return { lat, lon, name };
});
}
function parseTracks(doc: Document): TrackPoint[][] {
const tracks: TrackPoint[][] = [];
const trksegs = doc.querySelectorAll("trk > trkseg");
for (const seg of trksegs) {
const points: TrackPoint[] = [];
for (const pt of seg.querySelectorAll("trkpt")) {
const lat = parseFloat(pt.getAttribute("lat") ?? "0");
const lon = parseFloat(pt.getAttribute("lon") ?? "0");
const eleText = pt.querySelector("ele")?.textContent;
const time = pt.querySelector("time")?.textContent ?? undefined;
points.push({
lat,
lon,
ele: eleText ? parseFloat(eleText) : undefined,
time,
});
}
tracks.push(points);
}
return tracks;
}
function computeElevation(tracks: TrackPoint[][]): GpxData["elevation"] {
let gain = 0;
let loss = 0;
const profile: ElevationProfile[] = [];
let totalDistance = 0;
for (const track of tracks) {
for (let i = 0; i < track.length; i++) {
const pt = track[i]!;
if (i > 0) {
const prev = track[i - 1]!;
totalDistance += haversineDistance(prev.lat, prev.lon, pt.lat, pt.lon);
if (pt.ele !== undefined && prev.ele !== undefined) {
const diff = pt.ele - prev.ele;
if (diff > 0) gain += diff;
else loss += Math.abs(diff);
}
}
if (pt.ele !== undefined) {
profile.push({ distance: totalDistance, elevation: pt.ele });
}
}
}
return { gain: Math.round(gain), loss: Math.round(loss), profile };
}
/** Haversine distance between two points in meters */
function haversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371000;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

26
packages/gpx/src/types.ts Normal file
View file

@ -0,0 +1,26 @@
import type { Waypoint } from "@trails-cool/types";
export interface TrackPoint {
lat: number;
lon: number;
ele?: number;
time?: string;
}
export interface ElevationProfile {
/** Distance from start in meters */
distance: number;
/** Elevation in meters */
elevation: number;
}
export interface GpxData {
name?: string;
waypoints: Waypoint[];
tracks: TrackPoint[][];
elevation: {
gain: number;
loss: number;
profile: ElevationProfile[];
};
}