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:
parent
08af521d19
commit
7b1df79a37
10 changed files with 138 additions and 36 deletions
|
|
@ -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
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
5
frontend/src/lib/types.d.ts
vendored
5
frontend/src/lib/types.d.ts
vendored
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{#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={() => markers.toggleResolved(m.id)}
|
||||
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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue