feat(windkarte): Karten-UX Verbesserungen
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m7s

- Polygon-Labels ab Zoom 13 mit zoom-proportionaler Schriftgröße
- Hover-Highlight, Deselect per Hintergrundklick
- Mobile Bottom-Sheet / Desktop Side-Panel (CSS-only)
- Share-Link via URL-Hash (#W-10), auto-select on load
- Share-Button + OSM-Link im Panel
- Reset-Button auf Karte
- Buffer-Spinner während Turf-Berechnung
- Leaflet-Attribution nach links (kollidierte mit Panel)
- fitBounds mit asymmetrischem Padding + maxZoom 14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-13 10:24:56 +02:00
parent 6fef86fc6b
commit 0e28a7e334
3 changed files with 308 additions and 58 deletions
+93 -14
View File
@@ -16,21 +16,37 @@
let areas = $state<WindArea[]>([]);
let selectedArea = $state<WindArea | null>(null);
let selectedCenter = $state<[number, number] | null>(null);
let mapReady = $state(false);
let initialGebietsNr = $state<string | null>(null);
let resetMap: (() => void) | null = null;
// Dynamic import: WindkarteMap needs window/document
const MapComponent = browser
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
: null;
function handleSelect(area: WindArea | null, center: [number, number] | null = null) {
selectedArea = area;
selectedCenter = center;
if (browser) {
if (area) {
history.replaceState(null, "", location.pathname + location.search + "#" + encodeURIComponent(area.gebiets_nr));
} else {
history.replaceState(null, "", location.pathname + location.search);
}
}
}
onMount(async () => {
const hash = window.location.hash.slice(1);
if (hash) initialGebietsNr = decodeURIComponent(hash);
try {
const slugs = (block.areas ?? [])
.map((a) => (typeof a === "string" ? a : a._slug))
.filter(Boolean);
if (slugs.length > 0) {
// Fetch only the explicitly referenced areas in parallel
const results = await Promise.all(
slugs.map((slug) =>
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
@@ -40,7 +56,6 @@
);
areas = results.filter((r): r is WindArea => r !== null);
} else {
// Fallback: all published areas
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
signal: AbortSignal.timeout(6_000),
});
@@ -68,16 +83,30 @@
</div>
{/if}
<div
class="relative overflow-hidden rounded-xl border border-stein-200 shadow-sm"
style="height: 520px;"
>
<div class="map-container relative overflow-hidden rounded-xl border border-stein-200 shadow-sm">
<!-- Reset button -->
{#if resetMap}
<button
onclick={() => { resetMap?.(); handleSelect(null); }}
class="pointer-events-auto absolute top-2.5 right-2.5 z-[500] rounded-md bg-white/90 p-1.5 shadow-md text-stein-500 hover:text-stein-800 hover:bg-white transition-colors"
aria-label="Übersicht zurücksetzen"
title="Übersicht zurücksetzen"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
</button>
{/if}
{#if browser && MapComponent && mapReady}
{#await MapComponent then Map}
<Map
{areas}
geojsonUrl={GEOJSON_URL}
onselect={(area) => { selectedArea = area; }}
{initialGebietsNr}
onselect={handleSelect}
onready={(ctrl) => { resetMap = ctrl.reset; }}
/>
{/await}
{:else}
@@ -86,13 +115,12 @@
</div>
{/if}
<!-- Sliding panel -->
<div
class="pointer-events-none absolute inset-y-0 right-0 z-[500] w-72 transition-transform duration-300 ease-in-out {selectedArea ? 'translate-x-0' : 'translate-x-full'}"
>
<!-- Panel: bottom-sheet on mobile, side-panel on sm+ -->
<div class="panel-slide {selectedArea ? 'open' : ''}" style="pointer-events: none; z-index: 500;">
<WindAreaPanel
area={selectedArea}
onclose={() => { selectedArea = null; }}
center={selectedCenter}
onclose={() => handleSelect(null)}
/>
</div>
</div>
@@ -107,6 +135,57 @@
<span class="inline-block size-2.5 rounded-sm" style="background:#b08a52; opacity:.7"></span>
2. Entwurf 2026
</span>
<span class="ml-auto">Geodaten: © GDI-Th, dl-de/by-2-0</span>
<span class="flex items-center gap-x-3 gap-y-1 flex-wrap text-stein-300">
<span class="text-stein-400">Abstände:</span>
{#each [{ color: "#e35651", label: "200 m" }, { color: "#b08a52", label: "600 m" }, { color: "#436e85", label: "1 km" }] as ring}
<span class="flex items-center gap-1.5">
<span class="dashed-line" style="border-color:{ring.color}"></span>
{ring.label}
</span>
{/each}
</span>
</div>
</div>
<style>
.map-container {
height: clamp(360px, 58vh, 580px);
}
/* Panel: mobile = bottom sheet, sm+ = right side panel */
.panel-slide {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 72%;
transform: translateY(100%);
transition: transform 300ms ease-in-out;
}
.panel-slide.open {
transform: translateY(0);
pointer-events: auto !important;
}
@media (min-width: 640px) {
.panel-slide {
top: 0;
left: auto;
right: 0;
bottom: 0;
height: auto;
width: 18rem;
transform: translateX(100%);
}
.panel-slide.open {
transform: translateX(0);
}
}
.dashed-line {
display: inline-block;
width: 18px;
height: 0;
border-top: 2px dashed;
vertical-align: middle;
}
</style>