Der Kopf traegt jetzt Name, Zustand und Kennzahlen: Der Status sitzt als farbiges Dropdown direkt hinter dem Namen und nennt darunter, wann und von wem er gemeldet wurde. Laenge und Hoehenmeter stehen mit Icons daneben. Karte und Hoehenprofil links, Marker, Kommentare und Versionen rechts daneben in einer Spalte. Die Reiter entfallen - alles ist gleichzeitig zu sehen. Versionen sind Archiv und deshalb eingeklappt; der GPX-Upload sitzt dort statt im Kopf. Marker lassen sich jetzt auch loeschen und auch ueber das Hoehenprofil setzen: Die angeklickte Stelle wird auf dieselbe Linie zurueckgerechnet, die ein Klick auf der Karte trifft. Die Flag-Icons wurden bisher gespeichert, aber nirgends gezeichnet. Sie erscheinen jetzt in der Karte, im Popup, in der Markerliste und in der Verwaltung. Erlaubt ist jeder Name von lucide.dev - moeglich macht das ein Glob ueber die Icon-Dateien, der jedes Icon zu einem eigenen, erst bei Bedarf geladenen Chunk macht. Die Liste im Dialog sind nur Vorschlaege. Die Karte kommt von CARTO statt von OSM direkt: zurueckhaltend gezeichnet, sodass die Trail-Linie darueber steht, und mit einer echten dunklen Fassung. Das vorherige Abdunkeln der OSM-Kacheln ergab nur ein dunkleres Bild, keine dunkle Karte. In der Kachelvorschau der Liste sind Zoom, Massstab und Quellenangabe abgeschaltet - dort ist die Karte Bild, nicht Werkzeug; die grosse Karte traegt die Angabe weiterhin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
485 lines
17 KiB
Svelte
485 lines
17 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 { mount, onMount, unmount } 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'
|
|
import FlagIcon from './FlagIcon.svelte'
|
|
import { trailStatusColor } from '$lib/trailStatus'
|
|
import { env } from '$env/dynamic/public'
|
|
import { mode } from 'mode-watcher'
|
|
|
|
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
|
|
/** Icon-Name des Flag-Typs */
|
|
icon?: 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,
|
|
controls = true,
|
|
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
|
|
/**
|
|
* Zoom-Knöpfe, Maßstab und Quellenangabe. In der Kachelvorschau
|
|
* abgeschaltet — dort ist die Karte Bild, nicht Werkzeug. Die
|
|
* Quellenangabe zu OpenStreetMap trägt die große Karte.
|
|
*/
|
|
controls?: boolean
|
|
height?: string
|
|
} = $props()
|
|
|
|
/**
|
|
* Basiskarten von CARTO statt der Standard-OSM-Kacheln: Sie brauchen
|
|
* keinen Schlüssel, sind zurückhaltend gezeichnet — die Trail-Linie liegt
|
|
* dadurch deutlich sichtbarer darüber — und es gibt sie in einer echten
|
|
* dunklen Fassung. Ein nachträglich abgedunkeltes OSM-Bild wäre nur ein
|
|
* dunkleres Bild, keine dunkle Karte.
|
|
*
|
|
* Mehrere Subdomains, weil Browser die Verbindungen pro Host begrenzen.
|
|
*/
|
|
// Der Schlüssel muss den Browser erreichen — er kommt deshalb aus den
|
|
// PUBLIC_-Variablen. Über $env/dynamic/public statt static, weil die
|
|
// Kacheln auch ohne Schlüssel ausgeliefert werden: Fehlt die Variable,
|
|
// soll der Build nicht abbrechen.
|
|
const CARTO_KEY = env.PUBLIC_CARTO_API_KEY ?? ''
|
|
const KEY_PARAM = CARTO_KEY ? `?key=${CARTO_KEY}` : ''
|
|
|
|
const cartoTiles = (style: 'light_all' | 'dark_all') =>
|
|
['a', 'b', 'c'].map(
|
|
(sub) => `https://${sub}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${KEY_PARAM}`,
|
|
)
|
|
|
|
const TILES_LIGHT = cartoTiles('light_all')
|
|
const TILES_DARK = cartoTiles('dark_all')
|
|
const ATTRIBUTION = '© OpenStreetMap-Mitwirkende, © CARTO'
|
|
|
|
const isDark = $derived(mode.current === 'dark')
|
|
|
|
// Fallback, wenn ein Trail noch keine Version hat: Hersbruck.
|
|
const FALLBACK_CENTER: [number, number] = [11.4318, 49.5089]
|
|
|
|
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
|
|
|
|
/**
|
|
* MapLibre will für einen Marker ein DOM-Element, Icons kommen aber als
|
|
* Svelte-Komponenten. `mount` schlägt die Brücke — dafür müssen die
|
|
* Instanzen von Hand wieder abgeräumt werden, sonst bleiben sie beim
|
|
* Neuaufbau der Marker hängen.
|
|
*/
|
|
let iconInstances: Record<string, unknown>[] = []
|
|
|
|
function mountIcon(target: HTMLElement, name: string | undefined, size: string) {
|
|
iconInstances.push(mount(FlagIcon, { target, props: { name, class: size } }))
|
|
}
|
|
|
|
function clearIcons() {
|
|
for (const inst of iconInstances) unmount(inst)
|
|
iconInstances = []
|
|
}
|
|
|
|
onMount(() => {
|
|
let cleanupMap: (() => void) | null = null
|
|
|
|
function build() {
|
|
const instance = new maplibregl.Map({
|
|
container,
|
|
style: {
|
|
version: 8,
|
|
sources: {
|
|
osm: {
|
|
type: 'raster',
|
|
tiles: isDark ? TILES_DARK : TILES_LIGHT,
|
|
tileSize: 256,
|
|
attribution: ATTRIBUTION,
|
|
},
|
|
},
|
|
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
|
},
|
|
center: FALLBACK_CENTER,
|
|
zoom: 11,
|
|
attributionControl: controls ? undefined : false,
|
|
})
|
|
map = instance
|
|
|
|
if (controls) {
|
|
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
|
|
clearIcons()
|
|
|
|
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 = trailStatusColor(status)
|
|
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)
|
|
}
|
|
})
|
|
|
|
/** Beim Themenwechsel die Kachelquelle austauschen. */
|
|
$effect(() => {
|
|
if (!map || !ready) return
|
|
|
|
const src = map.getSource('osm') as maplibregl.RasterTileSource | undefined
|
|
src?.setTiles(isDark ? TILES_DARK : TILES_LIGHT)
|
|
})
|
|
|
|
// 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 icon = document.createElement('span')
|
|
icon.className = 'trail-popup-icon'
|
|
icon.style.color = data.color
|
|
mountIcon(icon, data.icon, 'size-4')
|
|
head.appendChild(icon)
|
|
|
|
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 = []
|
|
clearIcons()
|
|
|
|
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: 26px; height: 26px; border-radius: 9999px;
|
|
border: 2px solid white; cursor: pointer; padding: 0;
|
|
display: grid; place-items: center; color: white;
|
|
background: ${data.color};
|
|
opacity: ${data.resolved ? '0.35' : '1'};
|
|
box-shadow: 0 1px 4px rgba(0,0,0,.4);
|
|
`
|
|
mountIcon(el, data.icon, 'size-3.5')
|
|
// 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-icon) {
|
|
display: inline-flex;
|
|
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>
|