diff --git a/frontend/src/lib/components/ElevationProfile.svelte b/frontend/src/lib/components/ElevationProfile.svelte index 116dc1b..693903d 100644 --- a/frontend/src/lib/components/ElevationProfile.svelte +++ b/frontend/src/lib/components/ElevationProfile.svelte @@ -1,17 +1,21 @@ {#if stats} -
+
- {#if hoverX !== null} - + {#if hover} + {/if} + + {#if hover} + + + + {Math.round(hover.ele)} m · {(hover.d / 1000).toFixed(2)} km + + {/if}
{Math.round(stats.min)} m diff --git a/frontend/src/lib/components/TrailMap.svelte b/frontend/src/lib/components/TrailMap.svelte index c4fee32..b6237d9 100644 --- a/frontend/src/lib/components/TrailMap.svelte +++ b/frontend/src/lib/components/TrailMap.svelte @@ -6,7 +6,7 @@ */ import { onMount } from 'svelte' 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' // MapLibre 6 liegt als drei Dateien vor (Hauptdatei, shared, worker) und // 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 // relativen Import auf ./maplibre-gl-shared.mjs unaufgelöst lassen (404). import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' + import { nearestOnLine, distanceAlong, positionAtDistance } from '$lib/gpx' maplibregl.setWorkerUrl(workerUrl) @@ -32,7 +33,13 @@ lng: number color: string resolved: boolean + /** Was — der Flag-Typ */ label: string + /** Wann — bereits formatiert */ + when?: string + /** Wer — bereits aufgelöst */ + who?: string + note?: string } let { @@ -40,22 +47,26 @@ bounds = null, markers = [], status = 'offen', - placing = false, coordDistances = [], highlightDistance = null, onplace, - onmarkerclick, + ontrailhover, height = '420px', }: { geojson?: LineGeoJSON | null bounds?: [[number, number], [number, number]] | null markers?: MarkerData[] status?: 'offen' | 'eingeschraenkt' | 'gesperrt' - placing?: boolean coordDistances?: number[] 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 } = $props() @@ -78,6 +89,7 @@ let ready = $state(false) let markerObjects: Marker[] = [] let highlightMarker: Marker | null = null + let popup: Popup | null = null onMount(() => { let cleanupMap: (() => void) | null = null @@ -109,10 +121,6 @@ ready = true }) - instance.on('click', (e) => { - if (placing) onplace?.({ lat: e.lngLat.lat, lng: e.lngLat.lng }) - }) - cleanupMap = () => { // Map.remove() räumt Controls auf, aber keine Marker — die tragen // eigene move-Listener auf der Karte und werden hier zuerst entfernt. @@ -120,6 +128,8 @@ markerObjects = [] highlightMarker?.remove() highlightMarker = null + popup?.remove() + popup = null instance.remove() 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 $effect(() => { if (!map || !ready) return @@ -175,6 +231,14 @@ layout: { 'line-join': 'round', 'line-cap': 'round' }, 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 }) }) + /** 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 // zuverlässiger als einzelne Aktualisierungen. $effect(() => { if (!map || !ready) return + const instance = map for (const m of markerObjects) m.remove() markerObjects = [] @@ -204,9 +311,20 @@ opacity: ${data.resolved ? '0.35' : '1'}; 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) => { 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.style.outline = '2px solid #0ea5e9' @@ -219,7 +337,7 @@ markerObjects.push( new maplibregl.Marker({ element: el }) .setLngLat([data.lng, data.lat]) - .addTo(map), + .addTo(instance), ) } }) @@ -234,20 +352,11 @@ highlightMarker = null const coords = geojson?.coordinates - if (highlightDistance == null || !coords?.length || !coordDistances.length) return + if (highlightDistance == null || !coords?.length) return - // 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 at = positionAtDistance(coords, coordDistances, highlightDistance) + if (!at) return - const idx = Math.min(coords.length - 1, best) const el = document.createElement('div') el.style.cssText = ` width: 12px; height: 12px; border-radius: 9999px; @@ -255,9 +364,7 @@ box-shadow: 0 1px 4px rgba(0,0,0,.4); ` - highlightMarker = new maplibregl.Marker({ element: el }) - .setLngLat(coords[idx]) - .addTo(map) + highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map) }) @@ -265,11 +372,54 @@ bind:this={container} style:height class="w-full rounded-lg overflow-hidden border" - class:cursor-crosshair={placing} >
-{#if placing} -

- Klicke auf die Karte, um die Stelle zu markieren. -

-{/if} + + diff --git a/frontend/src/lib/gpx.test.ts b/frontend/src/lib/gpx.test.ts index 089204f..2417544 100644 --- a/frontend/src/lib/gpx.test.ts +++ b/frontend/src/lib/gpx.test.ts @@ -1,5 +1,5 @@ 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. const SAMPLE = ` @@ -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) + }) +}) diff --git a/frontend/src/lib/gpx.ts b/frontend/src/lib/gpx.ts index 5081411..5f8e275 100644 --- a/frontend/src/lib/gpx.ts +++ b/frontend/src/lib/gpx.ts @@ -222,3 +222,112 @@ export function parseGpx(xml: string): ParsedGpx { 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, + ] +} diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts index 948b525..7a1fbe4 100644 --- a/frontend/src/lib/stores/trailMarkers.svelte.ts +++ b/frontend/src/lib/stores/trailMarkers.svelte.ts @@ -1,12 +1,20 @@ import { getContext, setContext } from 'svelte' import { api, auth } from './pocketbase.svelte' import { getTeamContext } from './teams.svelte' -import type { TrailMarkersResponse } from '$lib/types' +import type { TrailMarkersResponse, UsersResponse } from '$lib/types' 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 & { + expand?: { created_by?: UsersResponse } +} + export class TrailMarkerStore { - records = $state([]) + records = $state([]) loading = $state(false) error = $state(null) @@ -14,14 +22,14 @@ export class TrailMarkerStore { private teams = getTeamContext() /** Alle Marker eines Trails, jüngste zuerst */ - byTrail(trailId: string): TrailMarkersResponse[] { + byTrail(trailId: string): TrailMarker[] { return this.records .filter((r) => r.trail === trailId) .sort((a, b) => (a.created < b.created ? 1 : -1)) } /** 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) } @@ -30,7 +38,7 @@ export class TrailMarkerStore { * Team-Admins — dieselbe Regel wie in der updateRule der Collection. * 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 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() { this.loading = true this.error = null try { this.records = await api.collection('trail_markers').getFullList({ sort: '-created', + expand: 'created_by', requestKey: null, }) } catch (e: any) { @@ -61,11 +82,14 @@ export class TrailMarkerStore { subscribe() { if (this.unsubscribe) return api.collection('trail_markers').subscribe('*', (e) => { - const idx = this.records.findIndex((r) => r.id === e.record.id) - if (e.action === 'create' && idx === -1) this.records = [e.record, ...this.records] - else if (e.action === 'update' && idx !== -1) this.records[idx] = e.record + // Realtime liefert den Datensatz ungetypt; angefordert wird unten + // dasselbe expand wie beim Laden. + 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) - }).then((unsub) => { + }, { expand: 'created_by' }).then((unsub) => { this.unsubscribe = unsub }).catch((e) => console.warn('trail_markers subscribe failed:', e)) } @@ -82,7 +106,7 @@ export class TrailMarkerStore { }) } - async edit(id: string, data: Partial) { + async edit(id: string, data: Partial) { return await api.collection('trail_markers').update(id, data) } diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte index ab22512..bfb7fe2 100644 --- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/trails/[id]/+page.svelte @@ -65,7 +65,9 @@ const trailVersions = $derived(trail ? versions.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( trailMarkers.map((m) => { const flag = flags.getById(m.flag) @@ -76,6 +78,9 @@ color: flag?.color ?? '#64748b', resolved: !!m.resolved, label: flag?.label ?? 'Marker', + note: m.note, + when: formatDate(m.created), + who: markers.authorName(m), } }), ) @@ -112,19 +117,25 @@ } // --- 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 markerCoords = $state<{ lat: number; lng: number } | null>(null) + let markerDistance = $state(null) let markerFlag = $state('') let markerNote = $state('') let markerError = $state(null) - function onPlace(coords: { lat: number; lng: number }) { - markerCoords = coords + const canPlace = $derived(flags.scoped.length > 0) + + 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 ?? '' markerNote = '' markerError = null - placing = false markerDialog = true } @@ -145,6 +156,7 @@ }) markerDialog = false markerCoords = null + markerDistance = null } catch (e: any) { markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.' } @@ -290,28 +302,24 @@ bounds={version.bounds} markers={mapMarkers} status={trail.status} - {placing} coordDistances={version.coord_distances ?? []} highlightDistance={highlight} - onplace={onPlace} + onplace={canPlace ? onPlace : undefined} + ontrailhover={(d) => (highlight = d)} />
{(version.distance_m / 1000).toFixed(1)} km {version.ascent_m} hm - + {#if canPlace} + + + Auf die Trail-Linie klicken, um dort einen Marker zu setzen + + {/if}
- {#if flags.scoped.length === 0} + {#if !canPlace}

Es gibt noch keine Flag-Typen. Jetzt anlegen @@ -322,7 +330,8 @@ (highlight = i)} + highlightDistance={highlight} + onhover={(d) => (highlight = d)} /> {/if} {:else} @@ -374,7 +383,7 @@ {#if tab === 'marker'} {#if trailMarkers.length === 0}

- Noch keine Marker. Setze einen über „Marker setzen" auf der Karte. + Noch keine Marker. Klicke dafür auf der Karte auf die Trail-Linie.

{:else}
@@ -534,6 +543,11 @@ Marker setzen + {#if markerDistance != null} + + bei km {(markerDistance / 1000).toFixed(2)} des Trails + + {/if}