fix: Höhenprofil-Hervorhebung über Distanz statt Index koppeln

elevation enthält einen Eintrag pro Originalpunkt, geojson.coordinates
nur die per Douglas-Peucker vereinfachten Punkte. Ein gemeinsamer Index
zeigte deshalb ab rund 3 % des Profils auf das Streckenende. Die neue
Distanz-Kopplung nutzt coord_distances (kumulative Distanz je
vereinfachter Koordinate), um beim Überfahren des Höhenprofils die
nächstgelegene Stelle auf der Karte zu finden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBMBfip6BAG8VV9raSf6vu
This commit is contained in:
Daniel Michelberger 2026-08-06 19:28:28 +02:00
parent 08af521d19
commit 7b1df79a37
10 changed files with 138 additions and 36 deletions

View file

@ -1260,11 +1260,22 @@ migrate((app) => {
"presentable": false,
"system": false,
"type": "autodate"
},
{
"help": "",
"hidden": false,
"id": "json1889387860",
"maxSize": 2000000,
"name": "coord_distances",
"presentable": false,
"required": false,
"system": false,
"type": "json"
}
],
"indexes": [],
"created": "2026-08-06 14:55:19.501Z",
"updated": "2026-08-06 14:55:19.501Z",
"updated": "2026-08-06 17:20:30.496Z",
"system": false
},
{

View file

@ -0,0 +1,20 @@
/// <reference path="../pb_data/types.d.ts" />
// Kumulative Distanz je Koordinate der vereinfachten Geometrie. Nötig, weil
// elevation einen Eintrag pro Originalpunkt hat, geojson aber nur die
// vereinfachten — ohne diese Brücke zeigt die Hervorhebung aus dem
// Höhenprofil auf der Karte an die falsche Stelle.
migrate((app) => {
const c = app.findCollectionByNameOrId('trail_versions')
c.fields.add(new Field({
name: 'coord_distances',
type: 'json',
maxSize: 2000000,
}))
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId('trail_versions')
c.fields.removeByName('coord_distances')
app.save(c)
})

View file

@ -1,8 +1,9 @@
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente den
* Index des Punktes; die Elternkomponente reicht ihn an die Karte weiter,
* damit dort dieselbe Stelle hervorgehoben wird.
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt.
*/
let {
elevation,
@ -10,7 +11,7 @@
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (index: number | null) => void
onhover?: (distance: number | null) => void
height?: string
} = $props()
@ -56,7 +57,7 @@
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
hoverX = ratio * W
onhover?.(clamped)
onhover?.(elevation[clamped].d)
}
function handleLeave() {

View file

@ -25,7 +25,8 @@
markers = [],
status = 'offen',
placing = false,
highlightIndex = null,
coordDistances = [],
highlightDistance = null,
onplace,
onmarkerclick,
height = '420px',
@ -35,7 +36,8 @@
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
highlightIndex?: number | null
coordDistances?: number[]
highlightDistance?: number | null
onplace?: (coords: { lat: number; lng: number }) => void
onmarkerclick?: (id: string) => void
height?: string
@ -206,7 +208,9 @@
}
})
// Stelle aus dem Höhenprofil hervorheben
// Stelle aus dem Höhenprofil hervorheben. Die Kopplung läuft über die
// Distanz, nicht über den Index: elevation hat einen Eintrag pro
// Originalpunkt, coordinates nur die vereinfachten.
$effect(() => {
if (!map || !ready) return
@ -214,9 +218,20 @@
highlightMarker = null
const coords = geojson?.coordinates
if (highlightIndex == null || !coords?.length) return
if (highlightDistance == null || !coords?.length || !coordDistances.length) return
const idx = Math.max(0, Math.min(coords.length - 1, highlightIndex))
// Nächstgelegene Koordinate zur gesuchten Distanz
let best = 0
let bestDiff = Infinity
for (let i = 0; i < coordDistances.length; i++) {
const diff = Math.abs(coordDistances[i] - highlightDistance)
if (diff < bestDiff) {
bestDiff = diff
best = i
}
}
const idx = Math.min(coords.length - 1, best)
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;

View file

@ -171,3 +171,21 @@ describe('simplify', () => {
expect(out[out.length - 1]).toEqual(pts[pts.length - 1])
})
})
describe('coordDistances', () => {
it('hat dieselbe Länge wie die Koordinaten', () => {
let pts = ''
for (let i = 0; i < 5000; i++) {
pts += `<trkpt lat="${49.5 + i * 0.00002}" lon="${11.4 + Math.sin(i / 300) * 0.01}"><ele>${300 + i * 0.01}</ele></trkpt>`
}
const r = parseGpx(`<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"><trk><trkseg>${pts}</trkseg></trk></gpx>`)
expect(r.coordDistances).toHaveLength(r.geojson.coordinates.length)
// monoton steigend, beginnt bei 0, endet bei der Gesamtlänge
expect(r.coordDistances[0]).toBe(0)
expect(r.coordDistances[r.coordDistances.length - 1]).toBeCloseTo(r.distance_m, -1)
for (let i = 1; i < r.coordDistances.length; i++) {
expect(r.coordDistances[i]).toBeGreaterThanOrEqual(r.coordDistances[i - 1])
}
})
})

View file

@ -11,6 +11,8 @@ 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
@ -49,38 +51,48 @@ export function haversine(a: TrackPoint, b: TrackPoint): number {
* beherrschbar wird.
*/
export function simplify(points: TrackPoint[], maxPoints = 2000): TrackPoint[] {
if (points.length <= maxPoints) return points
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
let out = points.map((_, i) => i)
// Obergrenze gegen Endlosschleifen bei entarteten Daten
for (let i = 0; i < 30 && out.length > maxPoints; i++) {
out = douglasPeucker(points, tolerance)
out = douglasPeuckerIndices(points, points.map((_, k) => k), tolerance)
tolerance *= 2
}
return out
}
function douglasPeucker(pts: TrackPoint[], tolerance: number): TrackPoint[] {
if (pts.length < 3) return pts
function douglasPeuckerIndices(all: TrackPoint[], idx: number[], tolerance: number): number[] {
if (idx.length < 3) return idx
let maxDist = 0
let index = 0
let split = 0
for (let i = 1; i < pts.length - 1; i++) {
const d = perpendicularDistance(pts[i], pts[0], pts[pts.length - 1])
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
index = i
split = i
}
}
if (maxDist <= tolerance) return [pts[0], pts[pts.length - 1]]
if (maxDist <= tolerance) return [idx[0], idx[idx.length - 1]]
const left = douglasPeucker(pts.slice(0, index + 1), tolerance)
const right = douglasPeucker(pts.slice(index), tolerance)
const left = douglasPeuckerIndices(all, idx.slice(0, split + 1), tolerance)
const right = douglasPeuckerIndices(all, idx.slice(split), tolerance)
return [...left.slice(0, -1), ...right]
}
@ -157,10 +169,16 @@ export function parseGpx(xml: string): ParsedGpx {
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
@ -185,7 +203,8 @@ export function parseGpx(xml: string): ParsedGpx {
const lats = points.map((p) => p.lat)
const lngs = points.map((p) => p.lng)
const simplified = simplify(points)
const keep = simplifyIndices(points)
const simplified = keep.map((i) => points[i])
return {
points: simplified,
@ -193,6 +212,7 @@ export function parseGpx(xml: string): ParsedGpx {
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)],

View file

@ -67,6 +67,7 @@ export class TrailVersionStore {
form.append('trail', trailId)
form.append('gpx', file)
form.append('geojson', JSON.stringify(parsed.geojson))
form.append('coord_distances', JSON.stringify(parsed.coordDistances))
form.append('elevation', JSON.stringify(parsed.elevation))
form.append('bounds', JSON.stringify(parsed.bounds))
form.append('distance_m', String(parsed.distance_m))

View file

@ -158,9 +158,10 @@ export type TrailMarkersRecord = {
updated?: IsoDateString
}
export type TrailVersionsRecord<Tbounds = unknown, Televation = unknown, Tgeojson = unknown> = {
export type TrailVersionsRecord<Tbounds = unknown, Tcoord_distances = unknown, Televation = unknown, Tgeojson = unknown> = {
ascent_m?: number
bounds?: null | Tbounds
coord_distances?: null | Tcoord_distances
created?: IsoDateString
distance_m?: number
elevation?: null | Televation
@ -215,7 +216,7 @@ export type TimesResponse<Texpand = unknown> = Required<TimesRecord> & BaseSyste
export type TrailCommentsResponse<Texpand = unknown> = Required<TrailCommentsRecord> & BaseSystemFields<Texpand>
export type TrailFlagsResponse<Texpand = unknown> = Required<TrailFlagsRecord> & BaseSystemFields<Texpand>
export type TrailMarkersResponse<Texpand = unknown> = Required<TrailMarkersRecord> & BaseSystemFields<Texpand>
export type TrailVersionsResponse<Tbounds = unknown, Televation = unknown, Tgeojson = unknown, Texpand = unknown> = Required<TrailVersionsRecord<Tbounds, Televation, Tgeojson>> & BaseSystemFields<Texpand>
export type TrailVersionsResponse<Tbounds = unknown, Tcoord_distances = unknown, Televation = unknown, Tgeojson = unknown, Texpand = unknown> = Required<TrailVersionsRecord<Tbounds, Tcoord_distances, Televation, Tgeojson>> & BaseSystemFields<Texpand>
export type TrailsResponse<Texpand = unknown> = Required<TrailsRecord> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>

View file

@ -40,6 +40,7 @@
function currentVersion(trailId: string): TrailVersionsResponse<
[[number, number], [number, number]] | null,
unknown,
unknown,
LineGeoJSON | null
> | null {
const trail = trails.getById(trailId)
@ -47,6 +48,7 @@
return (versions.records.find((v) => v.id === trail.current) ?? null) as TrailVersionsResponse<
[[number, number], [number, number]] | null,
unknown,
unknown,
LineGeoJSON | null
> | null
}

View file

@ -6,6 +6,7 @@
import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte'
import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import ElevationProfile from '$lib/components/ElevationProfile.svelte'
import { Button } from '@/components/ui/button'
@ -30,6 +31,7 @@
type Elevation = { d: number; ele: number }[]
type Version = TrailVersionsResponse<
[[number, number], [number, number]] | null,
number[] | null,
Elevation | null,
LineGeoJSON | null
>
@ -38,9 +40,11 @@
const versions = getTrailVersionContext()
const flags = getTrailFlagContext()
const markers = getTrailMarkerContext()
const teams = getTeamContext()
const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined)
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
const team = $derived(trail ? teams.records.find((t) => t.id === trail.team) : undefined)
/**
* description ist ein editor-Feld und enthält HTML. Gesetzt wird es zwar
@ -287,7 +291,8 @@
markers={mapMarkers}
status={trail.status}
{placing}
highlightIndex={highlight}
coordDistances={version.coord_distances ?? []}
highlightDistance={highlight}
onplace={onPlace}
/>
@ -388,14 +393,22 @@
{/if}
<p class="text-xs text-muted-foreground">{formatDate(m.created)}</p>
</div>
<Button
variant="ghost"
size="sm"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={() => markers.toggleResolved(m.id)}
>
<Check class="size-4" />
</Button>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<Button
variant="ghost"
size="sm"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
}
}}
>
<Check class="size-4" />
</Button>
{/if}
</CardContent>
</Card>
{/each}