+
+ {#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}