feat: Kartenkomponente mit MapLibre und Höhenprofil
This commit is contained in:
parent
616694a7f5
commit
060c4434b9
4 changed files with 500 additions and 1 deletions
201
frontend/package-lock.json
generated
201
frontend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.503.0",
|
||||
"maplibre-gl": "^6.2.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pocketbase": "^0.26.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
|
|
@ -821,6 +822,92 @@
|
|||
"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": {
|
||||
"version": "1.5.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
|
|
@ -2755,6 +2848,12 @@
|
|||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
|
|
@ -3153,6 +3252,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
|
|
@ -3583,6 +3688,18 @@
|
|||
"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": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
|
@ -3939,6 +4056,38 @@
|
|||
"license": "ISC",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -4020,7 +4169,6 @@
|
|||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
|
|
@ -4359,6 +4507,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
|
|
@ -4656,6 +4810,18 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -4743,6 +4909,12 @@
|
|||
"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": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
|
|
@ -4794,6 +4966,12 @@
|
|||
"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": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
|
|
@ -4815,6 +4993,12 @@
|
|||
"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": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
|
|
@ -4892,6 +5076,15 @@
|
|||
"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": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
|
|
@ -5657,6 +5850,12 @@
|
|||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.503.0",
|
||||
"maplibre-gl": "^6.2.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pocketbase": "^0.26.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
|
|
|
|||
92
frontend/src/lib/components/ElevationProfile.svelte
Normal file
92
frontend/src/lib/components/ElevationProfile.svelte
Normal 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}
|
||||
207
frontend/src/lib/components/TrailMap.svelte
Normal file
207
frontend/src/lib/components/TrailMap.svelte
Normal 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 = '© 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}
|
||||
Loading…
Reference in a new issue