/** * GPX-Verarbeitung im Browser. * * Das Backend bleibt reines PocketBase ohne Hooks: Beim Upload liest das * Frontend die Datei, rechnet Länge, Höhenmeter und Bounding-Box aus und * schickt Originaldatei plus abgeleitete Werte gemeinsam an die API. */ export type TrackPoint = { lat: number; lng: number; ele: number | null } export type ParsedGpx = { points: TrackPoint[] geojson: { type: 'LineString'; coordinates: [number, number][] } elevation: { d: number; ele: number }[] bounds: [[number, number], [number, number]] distance_m: number ascent_m: number } /** Erdradius in Metern */ const R = 6_371_000 /** * Anstiege unterhalb dieser Schwelle gelten als GPS-Rauschen. Ohne sie * summieren sich Messfehler zu absurden Höhenmetern — ein flacher Waldweg * käme leicht auf mehrere hundert Meter. */ const ELE_NOISE_M = 3 /** Distanz zweier Punkte in Metern (Haversine). */ export function haversine(a: TrackPoint, b: TrackPoint): number { const toRad = (d: number) => (d * Math.PI) / 180 const dLat = toRad(b.lat - a.lat) const dLng = toRad(b.lng - a.lng) const lat1 = toRad(a.lat) const lat2 = toRad(b.lat) const h = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2) return 2 * R * Math.asin(Math.sqrt(h)) } /** * Douglas-Peucker: entfernt Punkte, die kaum von der Verbindungslinie ihrer * Nachbarn abweichen. Die Toleranz wird verdoppelt, bis das Ergebnis unter * maxPoints liegt — so bleibt die Form erhalten, während die Datenmenge * beherrschbar wird. */ export function simplify(points: TrackPoint[], maxPoints = 2000): TrackPoint[] { if (points.length <= maxPoints) return points let tolerance = 0.00001 let out = points // Obergrenze gegen Endlosschleifen bei entarteten Daten for (let i = 0; i < 30 && out.length > maxPoints; i++) { out = douglasPeucker(points, tolerance) tolerance *= 2 } return out } function douglasPeucker(pts: TrackPoint[], tolerance: number): TrackPoint[] { if (pts.length < 3) return pts let maxDist = 0 let index = 0 for (let i = 1; i < pts.length - 1; i++) { const d = perpendicularDistance(pts[i], pts[0], pts[pts.length - 1]) if (d > maxDist) { maxDist = d index = i } } if (maxDist <= tolerance) return [pts[0], pts[pts.length - 1]] const left = douglasPeucker(pts.slice(0, index + 1), tolerance) const right = douglasPeucker(pts.slice(index), tolerance) return [...left.slice(0, -1), ...right] } /** * Abstand eines Punktes zur Geraden durch start und end — in Grad, nicht in * Metern. Für den Vergleich innerhalb eines Tracks genügt das und spart die * teure Projektion. */ function perpendicularDistance(p: TrackPoint, start: TrackPoint, end: TrackPoint): number { const dx = end.lng - start.lng const dy = end.lat - start.lat if (dx === 0 && dy === 0) { return Math.hypot(p.lng - start.lng, p.lat - start.lat) } const t = ((p.lng - start.lng) * dx + (p.lat - start.lat) * dy) / (dx * dx + dy * dy) const clamped = Math.max(0, Math.min(1, t)) return Math.hypot( p.lng - (start.lng + clamped * dx), p.lat - (start.lat + clamped * dy), ) } /** * Liest eine GPX-Datei. Wirft mit einer für Nutzer verständlichen Meldung, * wenn die Datei kein gültiges XML ist oder keine Punkte enthält. */ export function parseGpx(xml: string): ParsedGpx { const doc = new DOMParser().parseFromString(xml, 'application/xml') if (doc.querySelector('parsererror')) { throw new Error('Die Datei ist kein gültiges XML.') } // Manche Programme exportieren Routen (rtept) statt Tracks (trkpt). let nodes = Array.from(doc.getElementsByTagName('trkpt')) if (nodes.length === 0) { nodes = Array.from(doc.getElementsByTagName('rtept')) } if (nodes.length === 0) { throw new Error('Die GPX-Datei enthält keine Punkte.') } const points: TrackPoint[] = [] for (const n of nodes) { const latAttr = n.getAttribute('lat') const lngAttr = n.getAttribute('lon') // Number(null) ist 0, nicht NaN — ohne diese Prüfung landen Punkte // ohne Koordinaten bei 0/0 im Golf von Guinea und verfälschen // Streckenlänge und Bounding-Box. if (latAttr === null || lngAttr === null) continue const lat = Number(latAttr) const lng = Number(lngAttr) if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue const eleText = n.getElementsByTagName('ele')[0]?.textContent const ele = eleText != null && eleText.trim() !== '' ? Number(eleText) : null points.push({ lat, lng, ele: Number.isFinite(ele as number) ? ele : null }) } if (points.length === 0) { throw new Error('Die GPX-Datei enthält keine Punkte.') } // Länge, Höhenprofil und Höhenmeter in einem Durchlauf let distance = 0 let ascent = 0 const elevation: { d: number; ele: number }[] = [] let lastCountedEle: number | null = points[0].ele if (points[0].ele != null) elevation.push({ d: 0, ele: points[0].ele }) for (let i = 1; i < points.length; i++) { distance += haversine(points[i - 1], points[i]) const ele = points[i].ele if (ele == null) continue elevation.push({ d: Math.round(distance), ele }) if (lastCountedEle == null) { lastCountedEle = ele } else { const delta = ele - lastCountedEle if (delta >= ELE_NOISE_M) { ascent += delta lastCountedEle = ele } else if (delta <= -ELE_NOISE_M) { // Abstieg über Schwelle: neuer Bezugspunkt, aber nicht gezählt lastCountedEle = ele } // Kleine Schwankungen (unter 3 m) ignorieren, Bezugspunkt bleibt } } const lats = points.map((p) => p.lat) const lngs = points.map((p) => p.lng) const simplified = simplify(points) return { points: simplified, geojson: { type: 'LineString', coordinates: simplified.map((p) => [p.lng, p.lat] as [number, number]), }, elevation, bounds: [ [Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)], ], distance_m: Math.round(distance), ascent_m: Math.round(ascent), } }