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:
parent
325a4466d5
commit
2cfa5e54e7
23 changed files with 809 additions and 30 deletions
103
packages/gpx/src/parse.ts
Normal file
103
packages/gpx/src/parse.ts
Normal 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));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue