Der vorige Fix wirkte nur im Dev-Modus. Live blieb die Trail-Linie aus,
obwohl das Worker-Asset ausgeliefert wurde (HTTP 200).
Ursache: Das per ?url eingebundene Asset ist nur eine Rohkopie der
Paketdatei. Deren erste Zeile ist ein relativer Import auf die
Nachbardatei:
import{...}from"./maplibre-gl-shared.mjs"
Diese Datei kopiert der Build nicht mit — unter
_app/immutable/assets/maplibre-gl-shared.mjs antwortet die Produktion mit
404. Der Worker startet dadurch, sein Import scheitert, und er antwortet auf
keine Nachricht mehr. GeoJSON-Quellen bleiben im Ladezustand, es wird keine
Linie gezeichnet. Im Dev-Modus fiel das nicht auf, weil Vite dort aus
node_modules aufloest, wo die shared-Datei danebenliegt.
?worker&url statt ?url laesst Vite den Worker samt Abhaengigkeiten zu einer
eigenstaendigen Datei buendeln.
Verifiziert am Build-Artefakt: Der Worker liegt nun unter
_app/immutable/workers/ und ist 469695 statt 19122 Bytes gross, enthaelt also
den shared-Code; eine Referenz auf maplibre-gl-shared existiert nicht mehr,
womit der 404 zur Laufzeit entfaellt. Ueber "node build" ausgeliefert
antwortet er mit HTTP 200 in voller Groesse. svelte-check ohne Fehler, alle
19 Tests bestehen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
9.4 KiB
Svelte
275 lines
9.4 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 } 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'
|
|
|
|
maplibregl.setWorkerUrl(workerUrl)
|
|
|
|
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
|
|
type MarkerData = {
|
|
id: string
|
|
lat: number
|
|
lng: number
|
|
color: string
|
|
resolved: boolean
|
|
label: string
|
|
}
|
|
|
|
let {
|
|
geojson = null,
|
|
bounds = null,
|
|
markers = [],
|
|
status = 'offen',
|
|
placing = false,
|
|
coordDistances = [],
|
|
highlightDistance = null,
|
|
onplace,
|
|
onmarkerclick,
|
|
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
|
|
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 = '© 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
|
|
|
|
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
|
|
})
|
|
|
|
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.
|
|
for (const m of markerObjects) m.remove()
|
|
markerObjects = []
|
|
highlightMarker?.remove()
|
|
highlightMarker = 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?.()
|
|
}
|
|
})
|
|
|
|
// 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 },
|
|
})
|
|
}
|
|
})
|
|
|
|
// Kartenausschnitt auf den Track setzen
|
|
$effect(() => {
|
|
if (!map || !ready || !bounds) return
|
|
map.fitBounds(bounds, { padding: 40, duration: 400, maxZoom: 16 })
|
|
})
|
|
|
|
// Marker neu aufbauen — bei überschaubaren Mengen einfacher und
|
|
// zuverlässiger als einzelne Aktualisierungen.
|
|
$effect(() => {
|
|
if (!map || !ready) return
|
|
|
|
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);
|
|
`
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
onmarkerclick?.(data.id)
|
|
})
|
|
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(map),
|
|
)
|
|
}
|
|
})
|
|
|
|
// 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 || !coordDistances.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 idx = Math.min(coords.length - 1, best)
|
|
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(coords[idx])
|
|
.addTo(map)
|
|
})
|
|
</script>
|
|
|
|
<div
|
|
bind:this={container}
|
|
style:height
|
|
class="w-full rounded-lg overflow-hidden border"
|
|
class:cursor-crosshair={placing}
|
|
></div>
|
|
|
|
{#if placing}
|
|
<p class="text-sm text-muted-foreground mt-2">
|
|
Klicke auf die Karte, um die Stelle zu markieren.
|
|
</p>
|
|
{/if}
|