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 ''",
|
||||
"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",
|
||||
|
|
|
|||
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,
|
||||
open: `http://${pkg.name}.localhost:31337`,
|
||||
},
|
||||
})
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
} as any)
|
||||
Loading…
Reference in a new issue