feat: Kartenkomponente mit MapLibre und Höhenprofil

This commit is contained in:
Daniel Michelberger 2026-08-06 18:18:02 +02:00
parent 616694a7f5
commit 060c4434b9
4 changed files with 500 additions and 1 deletions

View file

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-svelte": "^0.503.0", "lucide-svelte": "^0.503.0",
"maplibre-gl": "^6.2.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"
@ -821,6 +822,92 @@
"svelte": "^5" "svelte": "^5"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.2.1",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz",
"integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -1869,6 +1956,12 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -2755,6 +2848,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -3153,6 +3252,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -3583,6 +3688,18 @@
"node": "^22.14.0 || >=24.0.0" "node": "^22.14.0 || >=24.0.0"
} }
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@ -3939,6 +4056,38 @@
"license": "ISC", "license": "ISC",
"optional": true "optional": true
}, },
"node_modules/maplibre-gl": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.2.0.tgz",
"integrity": "sha512-PaNYtxWmYgIdDHshXsnU3Pho+H9IPme9H6dTjZbFWNazi+Q5QgKgIlu2GiSGVtOZ77/Fz3n5/1/kGM6ETzu0Lg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.2.1",
"@maplibre/mlt": "^1.1.12",
"@maplibre/vt-pbf": "^4.3.2",
"@types/geojson": "^7946.0.16",
"earcut": "^3.2.3",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4020,7 +4169,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@ -4359,6 +4507,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -4656,6 +4810,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -4743,6 +4909,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prebuild-install": { "node_modules/prebuild-install": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@ -4794,6 +4966,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@ -4815,6 +4993,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/rc": { "node_modules/rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@ -4892,6 +5076,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/retry": { "node_modules/retry": {
"version": "0.12.0", "version": "0.12.0",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
@ -5657,6 +5850,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",

View file

@ -43,6 +43,7 @@
"dependencies": { "dependencies": {
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-svelte": "^0.503.0", "lucide-svelte": "^0.503.0",
"maplibre-gl": "^6.2.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"pocketbase": "^0.26.0", "pocketbase": "^0.26.0",
"tippy.js": "^6.3.7" "tippy.js": "^6.3.7"

View file

@ -0,0 +1,92 @@
<script lang="ts">
/**
* Höhenprofil als SVG-Fläche. Beim Überfahren meldet die Komponente den
* Index des Punktes; die Elternkomponente reicht ihn an die Karte weiter,
* damit dort dieselbe Stelle hervorgehoben wird.
*/
let {
elevation,
onhover,
height = '120px',
}: {
elevation: { d: number; ele: number }[]
onhover?: (index: number | null) => void
height?: string
} = $props()
// Internes Koordinatensystem; das SVG skaliert über viewBox mit.
const W = 1000
const H = 200
const stats = $derived.by(() => {
if (elevation.length < 2) return null
const eles = elevation.map((p) => p.ele)
const min = Math.min(...eles)
const max = Math.max(...eles)
const totalD = elevation[elevation.length - 1].d || 1
// Bei flachen Strecken wäre die Spanne 0 und die Division ungültig.
const span = max - min || 1
return { min, max, totalD, span }
})
const path = $derived.by(() => {
if (!stats) return ''
const { min, totalD, span } = stats
const pts = elevation.map((p) => {
const x = (p.d / totalD) * W
const y = H - ((p.ele - min) / span) * H
return `${x.toFixed(1)},${y.toFixed(1)}`
})
// Fläche schließen: runter zur Grundlinie und zurück zum Anfang
return `M ${pts.join(' L ')} L ${W},${H} L 0,${H} Z`
})
let hoverX = $state<number | null>(null)
function handleMove(e: MouseEvent) {
if (!elevation.length) return
const rect = (e.currentTarget as SVGElement).getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
const idx = Math.round(ratio * (elevation.length - 1))
const clamped = Math.max(0, Math.min(elevation.length - 1, idx))
hoverX = ratio * W
onhover?.(clamped)
}
function handleLeave() {
hoverX = null
onhover?.(null)
}
</script>
{#if stats}
<div class="w-full" style:height>
<svg
viewBox="0 0 {W} {H}"
preserveAspectRatio="none"
class="w-full h-full"
role="img"
aria-label="Höhenprofil"
onmousemove={handleMove}
onmouseleave={handleLeave}
>
<path d={path} fill="rgb(14 165 233 / 0.2)" stroke="rgb(14 165 233)" stroke-width="2" vector-effect="non-scaling-stroke" />
{#if hoverX !== null}
<line x1={hoverX} y1="0" x2={hoverX} y2={H} stroke="rgb(14 165 233)" stroke-width="1" vector-effect="non-scaling-stroke" />
{/if}
</svg>
</div>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>{Math.round(stats.min)} m</span>
<span>{(stats.totalD / 1000).toFixed(1)} km</span>
<span>{Math.round(stats.max)} m</span>
</div>
{:else}
<p class="text-sm text-muted-foreground">Keine Höhendaten vorhanden.</p>
{/if}

View file

@ -0,0 +1,207 @@
<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'
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,
highlightIndex = null,
onplace,
onmarkerclick,
height = '420px',
}: {
geojson?: LineGeoJSON | null
bounds?: [[number, number], [number, number]] | null
markers?: MarkerData[]
status?: 'offen' | 'eingeschraenkt' | 'gesperrt'
placing?: boolean
highlightIndex?: 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 = '&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
onMount(() => {
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 })
})
return () => {
instance.remove()
map = null
}
})
// 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)
})
markerObjects.push(
new maplibregl.Marker({ element: el })
.setLngLat([data.lng, data.lat])
.addTo(map),
)
}
})
// Stelle aus dem Höhenprofil hervorheben
$effect(() => {
if (!map || !ready) return
highlightMarker?.remove()
highlightMarker = null
const coords = geojson?.coordinates
if (highlightIndex == null || !coords?.length) return
const idx = Math.max(0, Math.min(coords.length - 1, highlightIndex))
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}