feat: GPX-Parser mit Länge, Höhenmetern und Streckenvereinfachung

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Michelberger 2026-08-06 17:19:02 +02:00
parent f576550429
commit 64c5139d71
5 changed files with 1291 additions and 9 deletions

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,8 @@
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"generate-pocketbase-types": "node scripts/schema-to-json.mjs > .schema.tmp.json && pocketbase-typegen --json .schema.tmp.json --out ./src/lib/types.d.ts && rm -f .schema.tmp.json"
},
"devDependencies": {
@ -26,6 +28,7 @@
"@tailwindcss/vite": "^4.1.13",
"bits-ui": "^2.18.1",
"clsx": "^2.1.1",
"jsdom": "^30.0.1",
"pocketbase-typegen": "^1.3.1",
"svelte": "^5.39.5",
"svelte-check": "^4.3.2",
@ -34,7 +37,8 @@
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.2",
"vite": "^7.1.7"
"vite": "^7.1.7",
"vitest": "^4.1.10"
},
"dependencies": {
"date-fns": "^4.1.0",

View file

@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify } from './gpx'
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
const SAMPLE = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Testtrail</name><trkseg>
<trkpt lat="49.5100" lon="11.4300"><ele>340</ele></trkpt>
<trkpt lat="49.5110" lon="11.4320"><ele>350</ele></trkpt>
<trkpt lat="49.5120" lon="11.4340"><ele>345</ele></trkpt>
<trkpt lat="49.5130" lon="11.4360"><ele>400</ele></trkpt>
</trkseg></trk>
</gpx>`
describe('haversine', () => {
it('misst die Distanz zwischen zwei Punkten in Metern', () => {
const d = haversine(
{ lat: 49.51, lng: 11.43, ele: null },
{ lat: 49.52, lng: 11.43, ele: null },
)
// 0,01 Grad Breite sind rund 1112 m
expect(d).toBeGreaterThan(1100)
expect(d).toBeLessThan(1125)
})
it('liefert 0 für identische Punkte', () => {
const p = { lat: 49.51, lng: 11.43, ele: null }
expect(haversine(p, p)).toBe(0)
})
})
describe('parseGpx', () => {
it('liest alle Trackpunkte', () => {
const r = parseGpx(SAMPLE)
expect(r.points).toHaveLength(4)
expect(r.points[0]).toEqual({ lat: 49.51, lng: 11.43, ele: 340 })
})
it('berechnet eine plausible Streckenlänge', () => {
const r = parseGpx(SAMPLE)
expect(r.distance_m).toBeGreaterThan(400)
expect(r.distance_m).toBeLessThan(700)
})
it('zählt nur Anstiege über der Rauschschwelle', () => {
const r = parseGpx(SAMPLE)
// +10 (340→350), -5 (350→345, zählt nicht), +55 (345→400) = 65
expect(r.ascent_m).toBe(65)
})
it('ignoriert Höhenschwankungen unter 3 m', () => {
const noisy = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"><ele>300</ele></trkpt>
<trkpt lat="49.511" lon="11.431"><ele>301</ele></trkpt>
<trkpt lat="49.512" lon="11.432"><ele>302</ele></trkpt>
<trkpt lat="49.513" lon="11.433"><ele>303</ele></trkpt>
</trkseg></trk></gpx>`
// Lauter 1-m-Schritte: reines GPS-Rauschen, kein echter Anstieg
expect(parseGpx(noisy).ascent_m).toBe(0)
})
it('liefert die Bounding-Box als [[minLng,minLat],[maxLng,maxLat]]', () => {
const r = parseGpx(SAMPLE)
expect(r.bounds).toEqual([[11.43, 49.51], [11.436, 49.513]])
})
it('liefert GeoJSON mit [lng, lat] in dieser Reihenfolge', () => {
const r = parseGpx(SAMPLE)
expect(r.geojson.type).toBe('LineString')
expect(r.geojson.coordinates[0]).toEqual([11.43, 49.51])
})
it('liefert ein Höhenprofil mit aufsummierter Distanz', () => {
const r = parseGpx(SAMPLE)
expect(r.elevation).toHaveLength(4)
expect(r.elevation[0]).toEqual({ d: 0, ele: 340 })
expect(r.elevation[3].d).toBeCloseTo(r.distance_m, 0)
})
it('kommt mit fehlenden Höhenangaben zurecht', () => {
const noEle = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>
<trkpt lat="49.510" lon="11.430"/>
<trkpt lat="49.511" lon="11.431"/>
</trkseg></trk></gpx>`
const r = parseGpx(noEle)
expect(r.points[0].ele).toBeNull()
expect(r.ascent_m).toBe(0)
expect(r.elevation).toHaveLength(0)
})
it('greift auf Routenpunkte zurück, wenn Trackpunkte fehlen', () => {
const route = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><rte>
<rtept lat="49.510" lon="11.430"><ele>300</ele></rtept>
<rtept lat="49.511" lon="11.431"><ele>310</ele></rtept>
</rte></gpx>`
expect(parseGpx(route).points).toHaveLength(2)
})
it('wirft bei einer Datei ohne Punkte', () => {
const empty = `<?xml version="1.0"?>
<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"></gpx>`
expect(() => parseGpx(empty)).toThrow(/keine Punkte/i)
})
it('wirft bei ungültigem XML', () => {
expect(() => parseGpx('kein xml')).toThrow(/ung(ü|ue)ltig/i)
})
})
describe('simplify', () => {
it('lässt kurze Punktfolgen unangetastet', () => {
const pts = [
{ lat: 49.51, lng: 11.43, ele: null },
{ lat: 49.52, lng: 11.44, ele: null },
]
expect(simplify(pts, 100)).toHaveLength(2)
})
it('reduziert lange Punktfolgen auf das Maximum', () => {
const pts = Array.from({ length: 5000 }, (_, i) => ({
lat: 49.5 + i * 0.0001,
lng: 11.4 + Math.sin(i / 50) * 0.01,
ele: null,
}))
const out = simplify(pts, 500)
expect(out.length).toBeLessThanOrEqual(500)
expect(out.length).toBeGreaterThan(10)
})
it('behält Anfang und Ende bei', () => {
const pts = Array.from({ length: 1000 }, (_, i) => ({
lat: 49.5 + i * 0.001,
lng: 11.4 + i * 0.001,
ele: null,
}))
const out = simplify(pts, 50)
expect(out[0]).toEqual(pts[0])
expect(out[out.length - 1]).toEqual(pts[pts.length - 1])
})
})

197
frontend/src/lib/gpx.ts Normal file
View file

@ -0,0 +1,197 @@
/**
* 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 ungültig.')
}
// 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 lat = Number(n.getAttribute('lat'))
const lng = Number(n.getAttribute('lon'))
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 (-3...+3) 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),
}
}

View file

@ -11,4 +11,7 @@ export default defineConfig({
strictPort: true,
open: `http://${pkg.name}.localhost:31337`,
},
})
test: {
environment: 'jsdom',
},
} as any)