feat: Marker nur noch auf dem Trail setzen, Karte und Hoehenprofil koppeln

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
This commit is contained in:
Daniel Michelberger 2026-09-02 12:08:02 +02:00
parent f29361b7f3
commit a8049e7406
6 changed files with 530 additions and 74 deletions

View file

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
/** /**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente die * Höhenprofil als SVG-Fläche. Die hervorgehobene Stelle kommt als Prop
* Distanz des Punktes; die Elternkomponente reicht sie an die Karte * herein und geht beim Überfahren als Callback hinaus — die Elternseite
* weiter, damit dort dieselbe Stelle hervorgehoben wird. Distanz statt * hält den Wert und reicht ihn ebenso an die Karte weiter. So zeigen
* Index, weil die Karte eine andere (vereinfachte) Punktreihe zeigt. * Karte und Profil immer dieselbe Stelle, egal wo gerade gezeigt wird.
* Gekoppelt wird über die Distanz, nicht über den Index: Die Karte zeigt
* eine vereinfachte Punktreihe, das Profil alle Originalpunkte.
*/ */
let { let {
elevation, elevation,
onhover, onhover,
highlightDistance = null,
height = '120px', height = '120px',
}: { }: {
elevation: { d: number; ele: number }[] elevation: { d: number; ele: number }[]
onhover?: (distance: number | null) => void onhover?: (distance: number | null) => void
highlightDistance?: number | null
height?: string height?: string
} = $props() } = $props()
@ -46,7 +50,37 @@
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z` return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
}) })
let hoverX = $state<number | null>(null) /**
* Der Punkt zur hervorgehobenen Distanz. Gesucht wird binär, weil das
* Profil alle Originalpunkte enthält — bei jeder Mausbewegung linear
* durch mehrere tausend Einträge zu laufen wäre unnötig teuer.
*/
const hover = $derived.by(() => {
if (!stats || highlightDistance == null) return null
let lo = 0
let hi = elevation.length - 1
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if (elevation[mid].d <= highlightDistance) lo = mid
else hi = mid
}
const p =
Math.abs(elevation[lo].d - highlightDistance) <=
Math.abs(elevation[hi].d - highlightDistance)
? elevation[lo]
: elevation[hi]
return {
ele: p.ele,
d: p.d,
// In Prozent, damit die HTML-Overlays dieselbe Stelle treffen wie
// das über viewBox skalierte SVG.
left: (p.d / stats.totalD) * 100,
top: (1 - (p.ele - stats.min) / stats.span) * 100,
}
})
function handleMove(e: MouseEvent) { function handleMove(e: MouseEvent) {
if (!elevation.length) return if (!elevation.length) return
@ -56,18 +90,16 @@
const idx = Math.round(ratio * (elevation.length - 1)) const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx)) const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
hoverX = ratio * W
onhover?.(elevation[clamped].d) onhover?.(elevation[clamped].d)
} }
function handleLeave() { function handleLeave() {
hoverX = null
onhover?.(null) onhover?.(null)
} }
</script> </script>
{#if stats} {#if stats}
<div class="w-full" style:height> <div class="relative w-full" style:height>
<svg <svg
viewBox="0 0 {W} {H}" viewBox="0 0 {W} {H}"
preserveAspectRatio="none" preserveAspectRatio="none"
@ -78,10 +110,37 @@
onmouseleave={handleLeave} onmouseleave={handleLeave}
> >
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" /> <path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
{#if hoverX !== null} {#if hover}
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" /> <line
x1={(hover.left / 100) * W}
y1="0"
x2={(hover.left / 100) * W}
y2={H}
stroke="rgb(14 165 233)"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
{/if} {/if}
</svg> </svg>
{#if hover}
<!--
Punkt und Beschriftung liegen als HTML über dem SVG: dessen
viewBox wird ungleichmäßig gestreckt, ein Kreis darin würde
zur Ellipse und Text mitverzerrt.
-->
<span
class="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-sky-500 ring-2 ring-background"
style:left="{hover.left}%"
style:top="{hover.top}%"
></span>
<span
class="pointer-events-none absolute top-0 -translate-x-1/2 rounded bg-background/90 px-1.5 py-0.5 text-xs font-medium whitespace-nowrap shadow-sm"
style:left="clamp(2.5rem, {hover.left}%, calc(100% - 2.5rem))"
>
{Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km
</span>
{/if}
</div> </div>
<div class="flex justify-between text-xs text-muted-foreground mt-1"> <div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span> <span>{Math.round(stats.min)} m</span>

View file

@ -6,7 +6,7 @@
*/ */
import { onMount } from 'svelte' import { onMount } from 'svelte'
import * as maplibregl from 'maplibre-gl' import * as maplibregl from 'maplibre-gl'
import type { Map as MapLibreMap, Marker } from 'maplibre-gl' import type { Map as MapLibreMap, Marker, Popup } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css' import 'maplibre-gl/dist/maplibre-gl.css'
// MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und // MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und
// lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides // lädt den Worker zur Laufzeit relativ zum eigenen Verzeichnis nach. Beides
@ -22,6 +22,7 @@
// Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren // Datei — ein blosses ?url würde nur die Rohdatei kopieren und ihren
// relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404). // relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404).
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx'
maplibregl.setWorkerUrl(workerUrl) maplibregl.setWorkerUrl(workerUrl)
@ -32,7 +33,13 @@
lng: number lng: number
color: string color: string
resolved: boolean resolved: boolean
/** Was — der Flag-Typ */
label: string label: string
/** Wann — bereits formatiert */
when?: string
/** Wer — bereits aufgelöst */
who?: string
note?: string
} }
let { let {
@ -40,22 +47,26 @@
bounds = null, bounds = null,
markers = [], markers = [],
status = 'offen', status = 'offen',
placing = false,
coordDistances = [], coordDistances = [],
highlightDistance = null, highlightDistance = null,
onplace, onplace,
onmarkerclick, ontrailhover,
height = '420px', height = '420px',
}: { }: {
geojson?: LineGeoJSON | null geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[] markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt' status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
coordDistances?: number[] coordDistances?: number[]
highlightDistance?: number | null highlightDistance?: number | null
onplace?: (coords: { lat: number; lng: number }) => void /**
onmarkerclick?: (id: string) => void * Wird nur beim Klick auf die Trail-Linie gerufen — und zwar mit der
* darauf projizierten Stelle, nie mit dem rohen Klickpunkt. Ist der
* Callback nicht gesetzt, ist die Linie nicht klickbar.
*/
onplace?: (p: { lat: number; lng: number; distance: number | null }) => void
/** Streckenkilometer unter dem Zeiger, null beim Verlassen der Linie. */
ontrailhover?: (distance: number | null) => void
height?: string height?: string
} = $props() } = $props()
@ -78,6 +89,7 @@
let ready = $state(false) let ready = $state(false)
let markerObjects: Marker[] = [] let markerObjects: Marker[] = []
let highlightMarker: Marker | null = null let highlightMarker: Marker | null = null
let popup: Popup | null = null
onMount(() => { onMount(() => {
let cleanupMap: (() => void) | null = null let cleanupMap: (() => void) | null = null
@ -109,10 +121,6 @@
ready = true ready = true
}) })
instance.on('click', (e) => {
if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
})
cleanupMap = () => { cleanupMap = () => {
// Map.remove() räumt Controls auf, aber keine Marker — die tragen // Map.remove() räumt Controls auf, aber keine Marker — die tragen
// eigene move-Listener auf der Karte und werden hier zuerst entfernt. // eigene move-Listener auf der Karte und werden hier zuerst entfernt.
@ -120,6 +128,8 @@
markerObjects = [] markerObjects = []
highlightMarker?.remove() highlightMarker?.remove()
highlightMarker = null highlightMarker = null
popup?.remove()
popup = null
instance.remove() instance.remove()
map = null map = null
@ -146,6 +156,52 @@
} }
}) })
/**
* Hover und Klick hängen an einer unsichtbaren, breiten Kopie der Linie:
* die sichtbaren 4 px wären als Ziel zu schmal, und maplibre liefert
* layerbezogene Ereignisse nur für das, was wirklich getroffen wurde.
* Deshalb landet ein Klick neben dem Trail auch nirgends — Marker können
* gar nicht abseits der Strecke entstehen.
*
* Registriert wird einmalig direkt nach dem Anlegen der Layer; vorher
* hätten die layerbezogenen Listener kein Ziel.
*/
function bindTrailEvents(instance: MapLibreMap) {
const project = (e: maplibregl.MapMouseEvent) => {
const coords = geojson?.coordinates
if (!coords?.length) return null
const pos = nearestOnLine(coords, e.lngLat.lng, e.lngLat.lat)
if (!pos) return null
return { pos, distance: distanceAlong(pos, coordDistances) }
}
instance.on('mousemove', 'trail-hit', (e) => {
const hit = project(e)
if (!hit) return
instance.getCanvas().style.cursor = onplace ? 'pointer' : ''
ontrailhover?.(hit.distance)
})
instance.on('mouseleave', 'trail-hit', () => {
instance.getCanvas().style.cursor = ''
ontrailhover?.(null)
})
instance.on('click', 'trail-hit', (e) => {
if (!onplace) return
const hit = project(e)
if (!hit) return
// Ein offenes Marker-Popup gehört zur vorherigen Stelle.
popup?.remove()
onplace({ lat: hit.pos.lat, lng: hit.pos.lng, distance: hit.distance })
})
}
// Trail-Linie zeichnen bzw. aktualisieren // Trail-Linie zeichnen bzw. aktualisieren
$effect(() => { $effect(() => {
if (!map || !ready) return if (!map || !ready) return
@ -175,6 +231,14 @@
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 4 }, paint: { 'line-color': color, 'line-width': 4 },
}) })
map.addLayer({
id: 'trail-hit',
type: 'line',
source: 'trail',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': color, 'line-width': 20, 'line-opacity': 0 },
})
bindTrailEvents(map)
} }
}) })
@ -184,10 +248,53 @@
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 }) map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
}) })
/** Popup-Inhalt zu einem Marker: was, wann, wer — plus Notiz. */
function popupContent(data: MarkerData): HTMLElement {
const root = document.createElement('div')
root.className = 'trail-popup'
const head = document.createElement('div')
head.className = 'trail-popup-head'
const dot = document.createElement('span')
dot.className = 'trail-popup-dot'
dot.style.background = data.color
head.appendChild(dot)
const title = document.createElement('strong')
// textContent, nicht innerHTML: Label und Notiz sind Nutzereingaben.
title.textContent = data.label
head.appendChild(title)
if (data.resolved) {
const badge = document.createElement('span')
badge.className = 'trail-popup-badge'
badge.textContent = 'erledigt'
head.appendChild(badge)
}
root.appendChild(head)
if (data.note) {
const note = document.createElement('p')
note.className = 'trail-popup-note'
note.textContent = data.note
root.appendChild(note)
}
const meta = document.createElement('p')
meta.className = 'trail-popup-meta'
meta.textContent = [data.when, data.who].filter(Boolean).join(' · ')
if (meta.textContent) root.appendChild(meta)
return root
}
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und // Marker neu aufbauen — bei überschaubaren Mengen einfacher und
// zuverlässiger als einzelne Aktualisierungen. // zuverlässiger als einzelne Aktualisierungen.
$effect(() => { $effect(() => {
if (!map || !ready) return if (!map || !ready) return
const instance = map
for (const m of markerObjects) m.remove() for (const m of markerObjects) m.remove()
markerObjects = [] markerObjects = []
@ -204,9 +311,20 @@
opacity: ${data.resolved ? '0.35' : '1'}; opacity: ${data.resolved ? '0.35' : '1'};
box-shadow: 0 1px 4px rgba(0,0,0,.4); box-shadow: 0 1px 4px rgba(0,0,0,.4);
` `
// Der Klick bleibt beim Marker: Er darf kein neues Marker-Fenster
// öffnen, sondern zeigt nur, was hier schon gemeldet wurde.
el.addEventListener('click', (ev) => { el.addEventListener('click', (ev) => {
ev.stopPropagation() ev.stopPropagation()
onmarkerclick?.(data.id)
popup?.remove()
popup = new maplibregl.Popup({
offset: 14,
closeButton: false,
className: 'trail-popup-shell',
})
.setLngLat([data.lng, data.lat])
.setDOMContent(popupContent(data))
.addTo(instance)
}) })
el.addEventListener('focus', () => { el.addEventListener('focus', () => {
el.style.outline = '2px solid #0ea5e9' el.style.outline = '2px solid #0ea5e9'
@ -219,7 +337,7 @@
markerObjects.push( markerObjects.push(
new maplibregl.Marker({ element: el }) new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat]) .setLngLat([data.lng, data.lat])
.addTo(map), .addTo(instance),
) )
} }
}) })
@ -234,20 +352,11 @@
highlightMarker = null highlightMarker = null
const coords = geojson?.coordinates const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length || !coordDistances.length) return if (highlightDistance == null || !coords?.length) return
// Nächstgelegene Koordinate zur gesuchten Distanz const at = positionAtDistance(coords, coordDistances, highlightDistance)
let best = 0 if (!at) return
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') const el = document.createElement('div')
el.style.cssText = ` el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px; width: 12px; height: 12px; border-radius: 9999px;
@ -255,9 +364,7 @@
box-shadow: 0 1px 4px rgba(0,0,0,.4); box-shadow: 0 1px 4px rgba(0,0,0,.4);
` `
highlightMarker = new maplibregl.Marker({ element: el }) highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
.setLngLat(coords[idx])
.addTo(map)
}) })
</script> </script>
@ -265,11 +372,54 @@
bind:this={container} bind:this={container}
style:height style:height
class="w-full rounded-lg overflow-hidden border" class="w-full rounded-lg overflow-hidden border"
class:cursor-crosshair={placing}
></div> ></div>
{#if placing} <!--
<p class="text-sm text-muted-foreground mt-2"> Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
Klicke auf die Karte, um die Stelle zu markieren. Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
</p> im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
{/if} -->
<style>
:global(.trail-popup-shell .maplibregl-popup-content) {
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
font-family: inherit;
max-width: 15rem;
}
:global(.trail-popup-head) {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.875rem;
color: #0f172a;
}
:global(.trail-popup-dot) {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
:global(.trail-popup-badge) {
font-size: 0.6875rem;
padding: 0 0.35rem;
border-radius: 9999px;
background: #e2e8f0;
color: #475569;
}
:global(.trail-popup-note) {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: #334155;
}
:global(.trail-popup-meta) {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #64748b;
}
</style>

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parseGpx, haversine, simplify } from './gpx' import { parseGpx, haversine, simplify, nearestOnLine, distanceAlong, positionAtDistance } from './gpx'
// Zwei Punkte in Hersbruck, rund 1,4 km auseinander. // Zwei Punkte in Hersbruck, rund 1,4 km auseinander.
const SAMPLE = `<?xml version="1.0"?> const SAMPLE = `<?xml version="1.0"?>
@ -189,3 +189,103 @@ describe('coordDistances', () => {
} }
}) })
}) })
describe('nearestOnLine', () => {
// Zwei Segmente entlang eines Breitengrads, dann nach Norden.
const LINE: [number, number][] = [
[11.43, 49.51],
[11.44, 49.51],
[11.44, 49.52],
]
it('projiziert einen Punkt neben der Linie auf das nächste Segment', () => {
const pos = nearestOnLine(LINE, 11.435, 49.5105)!
expect(pos.index).toBe(0)
expect(pos.lat).toBeCloseTo(49.51, 6)
expect(pos.lng).toBeCloseTo(11.435, 6)
expect(pos.t).toBeCloseTo(0.5, 3)
})
it('bleibt an den Enden auf der Linie', () => {
const before = nearestOnLine(LINE, 11.42, 49.51)!
expect(before.lng).toBeCloseTo(11.43, 6)
expect(before.t).toBe(0)
const after = nearestOnLine(LINE, 11.44, 49.53)!
expect(after.index).toBe(1)
expect(after.lat).toBeCloseTo(49.52, 6)
expect(after.t).toBe(1)
})
it('wählt das wirklich nächste Segment, nicht das erste passende', () => {
const pos = nearestOnLine(LINE, 11.4405, 49.5195)!
expect(pos.index).toBe(1)
})
it('liefert null ohne Koordinaten', () => {
expect(nearestOnLine([], 11.43, 49.51)).toBeNull()
})
})
describe('distanceAlong', () => {
const DISTANCES = [0, 1000, 1500]
it('interpoliert innerhalb eines Segments', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.25 }, DISTANCES)).toBe(250)
expect(distanceAlong({ lng: 0, lat: 0, index: 1, t: 0.5 }, DISTANCES)).toBe(1250)
})
it('liefert den letzten Wert am Ende der Linie', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 2, t: 0 }, DISTANCES)).toBe(1500)
})
it('liefert null ohne Distanztabelle', () => {
expect(distanceAlong({ lng: 0, lat: 0, index: 0, t: 0.5 }, [])).toBeNull()
})
})
describe('positionAtDistance', () => {
const LINE: [number, number][] = [
[11.43, 49.51],
[11.44, 49.51],
[11.44, 49.52],
]
const DISTANCES = [0, 1000, 2000]
it('findet die Koordinate zu einem Streckenkilometer', () => {
const at = positionAtDistance(LINE, DISTANCES, 500)!
expect(at[0]).toBeCloseTo(11.435, 6)
expect(at[1]).toBeCloseTo(49.51, 6)
})
it('interpoliert auch im zweiten Segment', () => {
const at = positionAtDistance(LINE, DISTANCES, 1750)!
expect(at[0]).toBeCloseTo(11.44, 6)
expect(at[1]).toBeCloseTo(49.5175, 6)
})
it('klemmt Werte außerhalb der Strecke auf Anfang und Ende', () => {
expect(positionAtDistance(LINE, DISTANCES, -100)).toEqual(LINE[0])
expect(positionAtDistance(LINE, DISTANCES, 99999)).toEqual(LINE[2])
})
it('liefert null ohne Distanztabelle', () => {
expect(positionAtDistance(LINE, [], 500)).toBeNull()
})
})
describe('Zusammenspiel mit parseGpx', () => {
it('führt Projektion und Rückweg auf dieselbe Stelle', () => {
const parsed = parseGpx(SAMPLE)
const coords = parsed.geojson.coordinates
// Ein Punkt leicht neben der Mitte des zweiten Segments
const pos = nearestOnLine(coords, coords[1][0] + 0.0002, coords[1][1])!
const d = distanceAlong(pos, parsed.coordDistances)!
const back = positionAtDistance(coords, parsed.coordDistances, d)!
expect(back[0]).toBeCloseTo(pos.lng, 4)
expect(back[1]).toBeCloseTo(pos.lat, 4)
})
})

View file

@ -222,3 +222,112 @@ export function parseGpx(xml: string): ParsedGpx {
ascent_m: Math.round(ascent), 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,
]
}

View file

@ -1,12 +1,20 @@
import { getContext, setContext } from 'svelte' import { getContext, setContext } from 'svelte'
import { api, auth } from './pocketbase.svelte' import { api, auth } from './pocketbase.svelte'
import { getTeamContext } from './teams.svelte' import { getTeamContext } from './teams.svelte'
import type { TrailMarkersResponse } from '$lib/types' import type { TrailMarkersResponse, UsersResponse } from '$lib/types'
const KEY = Symbol('trailMarker') const KEY = Symbol('trailMarker')
/**
* Marker samt aufgelöstem Melder. Ob `expand.created_by` gefüllt ist, hängt
* an den Sichtbarkeitsregeln der users-Collection deshalb optional.
*/
export type TrailMarker = Omit<TrailMarkersResponse, 'expand'> & {
expand?: { created_by?: UsersResponse }
}
export class TrailMarkerStore { export class TrailMarkerStore {
records = $state<TrailMarkersResponse[]>([]) records = $state<TrailMarker[]>([])
loading = $state(false) loading = $state(false)
error = $state<string | null>(null) error = $state<string | null>(null)
@ -14,14 +22,14 @@ export class TrailMarkerStore {
private teams = getTeamContext() private teams = getTeamContext()
/** Alle Marker eines Trails, jüngste zuerst */ /** Alle Marker eines Trails, jüngste zuerst */
byTrail(trailId: string): TrailMarkersResponse[] { byTrail(trailId: string): TrailMarker[] {
return this.records return this.records
.filter((r) => r.trail === trailId) .filter((r) => r.trail === trailId)
.sort((a, b) => (a.created < b.created ? 1 : -1)) .sort((a, b) => (a.created < b.created ? 1 : -1))
} }
/** Nur die offenen — was auf der Karte auffallen soll */ /** Nur die offenen — was auf der Karte auffallen soll */
openByTrail(trailId: string): TrailMarkersResponse[] { openByTrail(trailId: string): TrailMarker[] {
return this.byTrail(trailId).filter((r) => !r.resolved) return this.byTrail(trailId).filter((r) => !r.resolved)
} }
@ -30,7 +38,7 @@ export class TrailMarkerStore {
* Team-Admins dieselbe Regel wie in der updateRule der Collection. * Team-Admins dieselbe Regel wie in der updateRule der Collection.
* Der Trail wird übergeben, weil der Store ihn nicht kennt. * Der Trail wird übergeben, weil der Store ihn nicht kennt.
*/ */
canEdit(marker: TrailMarkersResponse, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean { canEdit(marker: TrailMarker, stewards: string[], teamOwner?: string, teamAdmins: string[] = []): boolean {
const uid = auth.user?.id const uid = auth.user?.id
if (!uid) return false if (!uid) return false
@ -42,12 +50,25 @@ export class TrailMarkerStore {
) )
} }
/**
* Wer den Marker gemeldet hat. Fremde User-Datensätze sind derzeit nicht
* sichtbar (users.listRule: `id = @request.auth.id`), deshalb bleibt der
* Name bei Meldungen anderer offen.
*/
authorName(marker: TrailMarker): string {
const name = marker.expand?.created_by?.name
if (name) return name
if (marker.created_by && marker.created_by === auth.user?.id) return 'Du'
return 'Unbekannt'
}
async load() { async load() {
this.loading = true this.loading = true
this.error = null this.error = null
try { try {
this.records = await api.collection('trail_markers').getFullList({ this.records = await api.collection('trail_markers').getFullList({
sort: '-created', sort: '-created',
expand: 'created_by',
requestKey: null, requestKey: null,
}) })
} catch (e: any) { } catch (e: any) {
@ -61,11 +82,14 @@ export class TrailMarkerStore {
subscribe() { subscribe() {
if (this.unsubscribe) return if (this.unsubscribe) return
api.collection('trail_markers').subscribe('*', (e) => { api.collection('trail_markers').subscribe('*', (e) => {
const idx = this.records.findIndex((r) => r.id === e.record.id) // Realtime liefert den Datensatz ungetypt; angefordert wird unten
if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] // dasselbe expand wie beim Laden.
else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record const record = e.record as TrailMarker
const idx = this.records.findIndex((r) => r.id === record.id)
if (e.action === 'create' && idx === -1) this.records = [record, ...this.records]
else if (e.action === 'update' && idx !== -1) this.records[idx] = record
else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id) else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== e.record.id)
}).then((unsub) => { }, { expand: 'created_by' }).then((unsub) => {
this.unsubscribe = unsub this.unsubscribe = unsub
}).catch((e) => console.warn('trail_markers subscribe failed:', e)) }).catch((e) => console.warn('trail_markers subscribe failed:', e))
} }
@ -82,7 +106,7 @@ export class TrailMarkerStore {
}) })
} }
async edit(id: string, data: Partial<TrailMarkersResponse>) { async edit(id: string, data: Partial<TrailMarker>) {
return await api.collection('trail_markers').update(id, data) return await api.collection('trail_markers').update(id, data)
} }

View file

@ -65,7 +65,9 @@
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : []) const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : []) const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
// Für die Karte aufbereitete Marker // Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
// und keine Datumslogik braucht.
const mapMarkers = $derived( const mapMarkers = $derived(
trailMarkers.map((m) => { trailMarkers.map((m) => {
const flag = flags.getById(m.flag) const flag = flags.getById(m.flag)
@ -76,6 +78,9 @@
color: flag?.color ?? '#64748b', color: flag?.color ?? '#64748b',
resolved: !!m.resolved, resolved: !!m.resolved,
label: flag?.label ?? 'Marker', label: flag?.label ?? 'Marker',
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
} }
}), }),
) )
@ -112,19 +117,25 @@
} }
// --- Marker setzen --------------------------------------------------- // --- Marker setzen ---------------------------------------------------
let placing = $state(false) // Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte;
// abseits des Trails lässt sich gar nicht erst klicken.
let markerDialog = $state(false) let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null) let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerDistance = $state<number | null>(null)
let markerFlag = $state('') let markerFlag = $state('')
let markerNote = $state('') let markerNote = $state('')
let markerError = $state<string | null>(null) let markerError = $state<string | null>(null)
function onPlace(coords: { lat: number; lng: number }) { const canPlace = $derived(flags.scoped.length > 0)
markerCoords = coords
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
if (!canPlace) return
markerCoords = { lat: p.lat, lng: p.lng }
markerDistance = p.distance
markerFlag = flags.scoped[0]?.id ?? '' markerFlag = flags.scoped[0]?.id ?? ''
markerNote = '' markerNote = ''
markerError = null markerError = null
placing = false
markerDialog = true markerDialog = true
} }
@ -145,6 +156,7 @@
}) })
markerDialog = false markerDialog = false
markerCoords = null markerCoords = null
markerDistance = null
} catch (e: any) { } catch (e: any) {
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.' markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
} }
@ -290,28 +302,24 @@
bounds={version.bounds} bounds={version.bounds}
markers={mapMarkers} markers={mapMarkers}
status={trail.status} status={trail.status}
{placing}
coordDistances={version.coord_distances ?? []} coordDistances={version.coord_distances ?? []}
highlightDistance={highlight} highlightDistance={highlight}
onplace={onPlace} onplace={canPlace ? onPlace : undefined}
ontrailhover={(d) => (highlight = d)}
/> />
<div class="flex flex-wrap items-center gap-6 text-sm"> <div class="flex flex-wrap items-center gap-6 text-sm">
<span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span> <span><strong>{(version.distance_m / 1000).toFixed(1)}</strong> km</span>
<span><strong>{version.ascent_m}</strong> hm</span> <span><strong>{version.ascent_m}</strong> hm</span>
<Button {#if canPlace}
variant={placing ? 'default' : 'outline'} <span class="ml-auto flex items-center gap-2 text-muted-foreground">
size="sm" <MapPin class="size-4" />
class="ml-auto" Auf die Trail-Linie klicken, um dort einen Marker zu setzen
onclick={() => (placing = !placing)} </span>
disabled={flags.scoped.length === 0} {/if}
>
<MapPin class="size-4 mr-2" />
{placing ? 'Abbrechen' : 'Marker setzen'}
</Button>
</div> </div>
{#if flags.scoped.length === 0} {#if !canPlace}
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Es gibt noch keine Flag-Typen. Es gibt noch keine Flag-Typen.
<a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a> <a href="/dashboard/settings/flags" class="underline">Jetzt anlegen</a>
@ -322,7 +330,8 @@
<Separator /> <Separator />
<ElevationProfile <ElevationProfile
elevation={version.elevation} elevation={version.elevation}
onhover={(i) => (highlight = i)} highlightDistance={highlight}
onhover={(d) => (highlight = d)}
/> />
{/if} {/if}
{:else} {:else}
@ -374,7 +383,7 @@
{#if tab === 'marker'} {#if tab === 'marker'}
{#if trailMarkers.length === 0} {#if trailMarkers.length === 0}
<p class="text-muted-foreground text-sm"> <p class="text-muted-foreground text-sm">
Noch keine Marker. Setze einen über „Marker setzen" auf der Karte. Noch keine Marker. Klicke dafür auf der Karte auf die Trail-Linie.
</p> </p>
{:else} {:else}
<div class="space-y-2"> <div class="space-y-2">
@ -534,6 +543,11 @@
<Dialog.Content> <Dialog.Content>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Marker setzen</Dialog.Title> <Dialog.Title>Marker setzen</Dialog.Title>
{#if markerDistance != null}
<Dialog.Description>
bei km {(markerDistance / 1000).toFixed(2)} des Trails
</Dialog.Description>
{/if}
</Dialog.Header> </Dialog.Header>
<div class="space-y-4"> <div class="space-y-4">