From a8049e7406d952186a85ca04809181ab8d81cc5a Mon Sep 17 00:00:00 2001 From: Daniel Michelberger Date: Wed, 2 Sep 2026 12:08:02 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o --- .../lib/components/ElevationProfile.svelte | 79 ++++++- frontend/src/lib/components/TrailMap.svelte | 216 +++++++++++++++--- frontend/src/lib/gpx.test.ts | 102 ++++++++- frontend/src/lib/gpx.ts | 109 +++++++++ .../src/lib/stores/trailMarkers.svelte.ts | 44 +++- .../routes/dashboard/trails/[id]/+page.svelte | 54 +++-- 6 files changed, 530 insertions(+), 74 deletions(-) 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}