stammtisch-hersbruck/frontend/src/lib/gpx.ts
Daniel Michelberger 1eb25cd7e4 fix: Rausch-Schwelle auf >= und <=, Tests korrigiert, Fehlertext angepasst
- Operator auf >= und <= geändert (kumulativ zählen)
- Test "ignoriert Höhenschwankungen" ersetzt durch zwei separate Tests:
  * "erfasst einen gleichmäßigen Anstieg auch in feinen Schritten" (300→303 in 1m Schritten = 3m)
  * "ignoriert Rauschen, das um denselben Wert schwankt" (auf/ab ohne Gewinn = 0m)
- Fehlertext auf Brief-Fassung: "Die Datei ist kein gültiges XML."
- Test-Regex angepasst zum neuen Fehlertext

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:34:16 +02:00

204 lines
6.4 KiB
TypeScript

/**
* 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),
}
}