Die Trail-Linie ist jetzt selbst die Bedienflaeche: Eine unsichtbare breite Kopie der Linie faengt Hover und Klick ab, deshalb kann daneben gar nichts mehr passieren. Geklickte Punkte werden auf die Linie projiziert, der Setzmodus samt Button entfaellt. Beim Ueberfahren wandert der Punkt in Karte und Hoehenprofil synchron mit, inklusive Anzeige von Hoehe und Streckenkilometer. Die Kopplung laeuft in beide Richtungen ueber die Distanz und interpoliert zwischen den Stuetzpunkten, statt zum naechsten Vertex zu springen. Ein Klick auf einen vorhandenen Marker oeffnet kein Setzen-Fenster mehr, sondern ein Popup mit Typ, Notiz, Zeitpunkt und Melder. Fremde User-Namen bleiben vorerst verborgen, weil users.listRule nur den eigenen Datensatz freigibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
333 lines
11 KiB
TypeScript
333 lines
11 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][] }
|
|
/** Kumulative Distanz je Koordinate in `geojson.coordinates`, in Metern. */
|
|
coordDistances: 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[] {
|
|
return simplifyIndices(points, maxPoints).map((i) => points[i])
|
|
}
|
|
|
|
/**
|
|
* Wie `simplify`, liefert aber die Indizes der erhaltenen Punkte statt der
|
|
* Punkte selbst. Damit lässt sich zu jeder vereinfachten Koordinate die
|
|
* ursprüngliche Distanz nachschlagen.
|
|
*/
|
|
export function simplifyIndices(points: TrackPoint[], maxPoints = 2000): number[] {
|
|
if (points.length <= maxPoints) {
|
|
return points.map((_, i) => i)
|
|
}
|
|
|
|
let tolerance = 0.00001
|
|
let out = points.map((_, i) => i)
|
|
|
|
for (let i = 0; i < 30 && out.length > maxPoints; i++) {
|
|
out = douglasPeuckerIndices(points, points.map((_, k) => k), tolerance)
|
|
tolerance *= 2
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
function douglasPeuckerIndices(all: TrackPoint[], idx: number[], tolerance: number): number[] {
|
|
if (idx.length < 3) return idx
|
|
|
|
let maxDist = 0
|
|
let split = 0
|
|
|
|
for (let i = 1; i < idx.length - 1; i++) {
|
|
const d = perpendicularDistance(all[idx[i]], all[idx[0]], all[idx[idx.length - 1]])
|
|
if (d > maxDist) {
|
|
maxDist = d
|
|
split = i
|
|
}
|
|
}
|
|
|
|
if (maxDist <= tolerance) return [idx[0], idx[idx.length - 1]]
|
|
|
|
const left = douglasPeuckerIndices(all, idx.slice(0, split + 1), tolerance)
|
|
const right = douglasPeuckerIndices(all, idx.slice(split), 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
|
|
|
|
// Kumulative Distanz je Originalpunkt — Brücke zwischen elevation (ein
|
|
// Eintrag pro Originalpunkt) und der vereinfachten Geometrie.
|
|
const distAt: number[] = new Array(points.length)
|
|
distAt[0] = 0
|
|
|
|
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])
|
|
distAt[i] = distance
|
|
|
|
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 keep = simplifyIndices(points)
|
|
const simplified = keep.map((i) => points[i])
|
|
|
|
return {
|
|
points: simplified,
|
|
geojson: {
|
|
type: 'LineString',
|
|
coordinates: simplified.map((p) => [p.lng, p.lat] as [number, number]),
|
|
},
|
|
coordDistances: keep.map((i) => Math.round(distAt[i])),
|
|
elevation,
|
|
bounds: [
|
|
[Math.min(...lngs), Math.min(...lats)],
|
|
[Math.max(...lngs), Math.max(...lats)],
|
|
],
|
|
distance_m: Math.round(distance),
|
|
ascent_m: Math.round(ascent),
|
|
}
|
|
}
|
|
|
|
/** Ein auf die Trail-Linie projizierter Punkt. */
|
|
export type LinePosition = {
|
|
lng: number
|
|
lat: number
|
|
/** Index des Segmentanfangs in `coordinates` */
|
|
index: number
|
|
/** Lage im Segment zwischen 0 und 1 */
|
|
t: number
|
|
}
|
|
|
|
/**
|
|
* Projiziert einen beliebigen Punkt auf die nächstgelegene Stelle der Linie.
|
|
* Damit landet ein Marker exakt auf dem Track, egal wie genau geklickt wurde.
|
|
*
|
|
* Gerechnet wird in Grad, die Längengrade aber mit cos(lat) gestaucht — sonst
|
|
* wäre ein Grad Ost-West bei uns fast doppelt so „lang" wie einer Nord-Süd und
|
|
* die Projektion würde spürbar daneben liegen. Für die paar Meter Suchradius
|
|
* genügt das; eine echte Projektion wäre hier Aufwand ohne Gewinn.
|
|
*/
|
|
export function nearestOnLine(
|
|
coords: [number, number][],
|
|
lng: number,
|
|
lat: number,
|
|
): LinePosition | null {
|
|
if (!coords.length) return null
|
|
if (coords.length === 1) {
|
|
return { lng: coords[0][0], lat: coords[0][1], index: 0, t: 0 }
|
|
}
|
|
|
|
const kx = Math.cos((lat * Math.PI) / 180) || 1
|
|
|
|
let best: LinePosition | null = null
|
|
let bestDist = Infinity
|
|
|
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
const [ax, ay] = coords[i]
|
|
const [bx, by] = coords[i + 1]
|
|
|
|
const dx = (bx - ax) * kx
|
|
const dy = by - ay
|
|
const px = (lng - ax) * kx
|
|
const py = lat - ay
|
|
|
|
const len = dx * dx + dy * dy
|
|
// Doppelter Punkt im Track: das Segment hat keine Richtung.
|
|
const t = len === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / len))
|
|
|
|
const ox = px - t * dx
|
|
const oy = py - t * dy
|
|
const dist = ox * ox + oy * oy
|
|
|
|
if (dist < bestDist) {
|
|
bestDist = dist
|
|
best = { lng: ax + (bx - ax) * t, lat: ay + (by - ay) * t, index: i, t }
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
/**
|
|
* Streckenkilometer einer projizierten Stelle. `coordDistances` hat einen
|
|
* Eintrag je Koordinate; zwischen zwei Stützpunkten wird linear interpoliert.
|
|
* Ohne Distanztabelle (Versionen von vor dem Feld) gibt es kein Ergebnis.
|
|
*/
|
|
export function distanceAlong(pos: LinePosition, coordDistances: number[]): number | null {
|
|
if (coordDistances.length <= pos.index) return null
|
|
|
|
const a = coordDistances[pos.index]
|
|
const b = coordDistances[pos.index + 1]
|
|
if (b == null) return a
|
|
|
|
return a + (b - a) * pos.t
|
|
}
|
|
|
|
/**
|
|
* Gegenrichtung zu `distanceAlong`: die Koordinate zu einem Streckenkilometer.
|
|
* Ebenfalls interpoliert, damit ein Punkt beim Überfahren gleichmäßig wandert
|
|
* statt von Stützpunkt zu Stützpunkt zu springen.
|
|
*/
|
|
export function positionAtDistance(
|
|
coords: [number, number][],
|
|
coordDistances: number[],
|
|
distance: number,
|
|
): [number, number] | null {
|
|
if (!coords.length || coordDistances.length < 2) return null
|
|
|
|
const last = Math.min(coords.length, coordDistances.length) - 1
|
|
if (distance <= coordDistances[0]) return coords[0]
|
|
if (distance >= coordDistances[last]) return coords[last]
|
|
|
|
// Binäre Suche nach dem Segment, in dem die Distanz liegt.
|
|
let lo = 0
|
|
let hi = last
|
|
while (hi - lo > 1) {
|
|
const mid = (lo + hi) >> 1
|
|
if (coordDistances[mid] <= distance) lo = mid
|
|
else hi = mid
|
|
}
|
|
|
|
const span = coordDistances[hi] - coordDistances[lo]
|
|
const t = span === 0 ? 0 : (distance - coordDistances[lo]) / span
|
|
|
|
return [
|
|
coords[lo][0] + (coords[hi][0] - coords[lo][0]) * t,
|
|
coords[lo][1] + (coords[hi][1] - coords[lo][1]) * t,
|
|
]
|
|
}
|