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:
parent
f576550429
commit
64c5139d71
5 changed files with 1291 additions and 9 deletions
949
frontend/package-lock.json
generated
949
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,8 @@
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"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"
|
"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": {
|
"devDependencies": {
|
||||||
|
|
@ -26,6 +28,7 @@
|
||||||
"@tailwindcss/vite": "^4.1.13",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"bits-ui": "^2.18.1",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"pocketbase-typegen": "^1.3.1",
|
"pocketbase-typegen": "^1.3.1",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
"svelte-check": "^4.3.2",
|
"svelte-check": "^4.3.2",
|
||||||
|
|
@ -34,7 +37,8 @@
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|
|
||||||
143
frontend/src/lib/gpx.test.ts
Normal file
143
frontend/src/lib/gpx.test.ts
Normal 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
197
frontend/src/lib/gpx.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,4 +11,7 @@ export default defineConfig({
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
open: `http://${pkg.name}.localhost:31337`,
|
open: `http://${pkg.name}.localhost:31337`,
|
||||||
},
|
},
|
||||||
})
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
Loading…
Reference in a new issue