- 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>
204 lines
6.4 KiB
TypeScript
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),
|
|
}
|
|
}
|