stammtisch-hersbruck/frontend/src/lib/components/TrailMap.svelte
Daniel Michelberger a8049e7406 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
2026-09-02 12:08:02 +02:00

425 lines
14 KiB
Svelte

<script lang="ts">
/**
* Karte für einen Trail. Bewusst ohne Store-Zugriff: Alles kommt über
* Props, Ereignisse gehen über Callback-Props zurück. Damit ist die
* Komponente in Liste, Detailseite und Setzmodus gleichermaßen nutzbar.
*/
import { onMount } from 'svelte'
import * as maplibregl 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
// — Vites Dependency-Optimierung im Dev-Modus wie der Produktionsbuild —
// bringt den Worker nicht an eine Stelle, an der er seine Nachbardatei
// maplibre-gl-shared.mjs findet. Der Worker startet dann zwar, sein
// Import läuft ins Leere, und er antwortet auf keine Nachricht mehr.
// Folge: GeoJSON-Quellen bleiben dauerhaft im Ladezustand, es wird keine
// Trail-Linie gezeichnet. Rasterkacheln brauchen keinen Worker, deshalb
// erscheint die Karte trotzdem und der Fehler sieht nach fehlenden Daten
// aus.
// ?worker&url bündelt den Worker samt Abhängigkeiten zu einer eigenständigen
// 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)
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type MarkerData = {
id: string
lat: number
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 {
geojson = null,
bounds = null,
markers = [],
status = 'offen',
coordDistances = [],
highlightDistance = null,
onplace,
ontrailhover,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
coordDistances?: number[]
highlightDistance?: number | null
/**
* 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()
// Kachelquelle als Konstante, damit sie sich später leicht gegen eine
// Outdoor-Karte mit Höhenlinien tauschen lässt.
const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const ATTRIBUTION = '&copy; OpenStreetMap-Mitwirkende'
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
const LINE_COLORS = {
offen: '#16a34a',
eingeschraenkt: '#ca8a04',
gesperrt: '#dc2626',
} as const
let container: HTMLDivElement
let map = $state<MapLibreMap | null>(null)
let ready = $state(false)
let markerObjects: Marker[] = []
let highlightMarker: Marker | null = null
let popup: Popup | null = null
onMount(() => {
let cleanupMap: (() => void) | null = null
function build() {
const instance = new maplibregl.Map({
container,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: [TILES],
tileSize: 256,
attribution: ATTRIBUTION,
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
center: FALLBACK_CENTER,
zoom: 11,
})
map = instance
instance.addControl(new maplibregl.NavigationControl(), 'top-right')
instance.addControl(new maplibregl.ScaleControl(), 'bottom-left')
instance.on('load', () => {
ready = true
})
cleanupMap = () => {
// Map.remove() räumt Controls auf, aber keine Marker — die tragen
// eigene move-Listener auf der Karte und werden hier zuerst entfernt.
for (const m of markerObjects) m.remove()
markerObjects = []
highlightMarker?.remove()
highlightMarker = null
popup?.remove()
popup = null
instance.remove()
map = null
ready = false
}
}
// Browser begrenzen WebGL-Kontexte auf rund 8 bis 16. In einer Liste
// mit vielen Trails wären das zu viele — deshalb entsteht die Karte
// erst, wenn ihre Kachel in den Sichtbereich scrollt.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting && !map) build()
}
},
{ rootMargin: '200px' },
)
observer.observe(container)
return () => {
observer.disconnect()
cleanupMap?.()
}
})
/**
* 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
const data = geojson
const color = LINE_COLORS[status] ?? LINE_COLORS.offen
const src = map.getSource('trail') as maplibregl.GeoJSONSource | undefined
if (!data) {
if (src) {
src.setData({ type: 'FeatureCollection', features: [] })
}
return
}
const feature = { type: 'Feature' as const, properties: {}, geometry: data }
if (src) {
src.setData(feature)
map.setPaintProperty('trail-line', 'line-color', color)
} else {
map.addSource('trail', { type: 'geojson', data: feature })
map.addLayer({
id: 'trail-line',
type: 'line',
source: 'trail',
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)
}
})
// Kartenausschnitt auf den Track setzen
$effect(() => {
if (!map || !ready || !bounds) return
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 = []
for (const data of markers) {
const el = document.createElement('button')
el.type = 'button'
el.title = data.label
el.setAttribute('aria-label', data.label)
el.style.cssText = `
width: 18px; height: 18px; border-radius: 9999px;
border: 2px solid white; cursor: pointer; padding: 0;
background: ${data.color};
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()
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'
el.style.outlineOffset = '2px'
})
el.addEventListener('blur', () => {
el.style.outline = 'none'
})
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(instance),
)
}
})
// Stelle aus dem Höhenprofil hervorheben. Die Kopplung läuft über die
// Distanz, nicht über den Index: elevation hat einen Eintrag pro
// Originalpunkt, coordinates nur die vereinfachten.
$effect(() => {
if (!map || !ready) return
highlightMarker?.remove()
highlightMarker = null
const coords = geojson?.coordinates
if (highlightDistance == null || !coords?.length) return
const at = positionAtDistance(coords, coordDistances, highlightDistance)
if (!at) return
const el = document.createElement('div')
el.style.cssText = `
width: 12px; height: 12px; border-radius: 9999px;
background: #0ea5e9; border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
`
highlightMarker = new maplibregl.Marker({ element: el }).setLngLat(at).addTo(map)
})
</script>
<div
bind:this={container}
style:height
class="w-full rounded-lg overflow-hidden border"
></div>
<!--
Popups hängt maplibre selbst in den Karten-Container, deshalb greifen die
Regeln nur global. Bewusst helle Fläche wie das maplibre-Standardpopup —
im Dunkelmodus liegt es trotzdem lesbar über der hellen Karte.
-->
<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>