feat(windkarte): Leaflet-Block für Windvorranggebiete SWT
Deploy / verify (push) Successful in 58s
Deploy / deploy (push) Successful in 1m18s

Neuer CMS-Block `wind_map` — platzierbar auf jeder Seite per Row-Content.
Fetcht wind_area-Einträge + GeoJSON-Asset client-side, rendert Leaflet-Karte
(CartoDB Light) mit farbcodierten Polygonen nach Planungsstatus. Klick auf
Gebiet öffnet Slide-in-Panel mit Kenndaten, Gemeinden, Stellungnahme-Link
und Puffer-Ringen (200/600/1000 m via Turf). Erweiterbar für 2. Entwurf 2026.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-13 09:20:32 +02:00
parent cdc2a79950
commit 39d7d70c8b
9 changed files with 570 additions and 5 deletions
+4
View File
@@ -18,6 +18,7 @@
import OpnFormBlock from "./blocks/OpnFormBlock.svelte";
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import StrommixBlock from "./blocks/StrommixBlock.svelte";
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
@@ -40,6 +41,7 @@
OpnFormBlockData,
DeadlineBannerBlockData,
LiveStrommixBlockData,
WindMapBlockData,
} from "$lib/block-types";
let {
@@ -88,6 +90,8 @@
<DeadlineBannerBlock block={block as DeadlineBannerBlockData} />
{:else if type === "live_strommix"}
<StrommixBlock block={block as LiveStrommixBlockData} />
{:else if type === "wind_map"}
<WindkarteBlock block={block as WindMapBlockData} />
{:else}
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
Unbekannter Block: <code>{type}</code>
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import type { WindArea } from "$lib/windkarte";
let {
area,
onclose,
}: {
area: WindArea | null;
onclose: () => void;
} = $props();
const STATUS_LABEL: Record<string, string> = {
rechtskraeftig: "Rechtskräftig",
entwurf_2: "2. Entwurf 2026",
entwurf_3: "3. Entwurf",
};
const STATUS_CLASS: Record<string, string> = {
rechtskraeftig: "bg-wald-100 text-wald-800",
entwurf_2: "bg-erde-100 text-erde-800",
entwurf_3: "bg-himmel-100 text-himmel-800",
};
function stellungnahmeHref(s: WindArea["stellungnahme"]): string | null {
if (!s) return null;
if (typeof s === "string") return `/post/${s}`;
if (s._slug) return `/post/${s._slug}`;
return null;
}
</script>
{#if area}
<div class="pointer-events-auto flex h-full flex-col overflow-y-auto bg-stein-0 shadow-xl">
<!-- Header -->
<div class="flex items-start justify-between gap-3 border-b border-stein-200 px-5 py-4">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-semibold text-stein-800">{area.name}</span>
{#if area.status}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_CLASS[area.status] ?? 'bg-stein-100 text-stein-600'}">
{STATUS_LABEL[area.status] ?? area.status}
</span>
{/if}
</div>
{#if area.bezeichnung}
<p class="mt-0.5 text-sm text-stein-500">{area.bezeichnung}</p>
{/if}
</div>
<button
onclick={onclose}
class="shrink-0 rounded-md p-1.5 text-stein-400 hover:bg-stein-100 hover:text-stein-700 transition-colors"
aria-label="Panel schließen"
>
<Icon icon="mdi:close" class="size-5" />
</button>
</div>
<!-- Body -->
<div class="flex-1 space-y-5 px-5 py-4 text-sm">
<!-- Kenndaten -->
<div class="grid grid-cols-2 gap-3">
{#if area.flaeche_ha != null}
<div class="rounded-lg bg-stein-50 p-3">
<p class="text-xs text-stein-500">Fläche</p>
<p class="mt-0.5 font-semibold text-stein-800">{area.flaeche_ha.toFixed(1)} ha</p>
</div>
{/if}
{#if area.anlagen_geplant != null}
<div class="rounded-lg bg-stein-50 p-3">
<p class="text-xs text-stein-500">Geplante Anlagen</p>
<p class="mt-0.5 font-semibold text-stein-800">{area.anlagen_geplant}</p>
</div>
{/if}
{#if area.hoehe_max_m != null}
<div class="rounded-lg bg-stein-50 p-3">
<p class="text-xs text-stein-500">Max. Nabenhöhe</p>
<p class="mt-0.5 font-semibold text-stein-800">{area.hoehe_max_m} m</p>
</div>
{/if}
{#if area.investor}
<div class="rounded-lg bg-stein-50 p-3 col-span-2">
<p class="text-xs text-stein-500">Investor / Betreiber</p>
<p class="mt-0.5 font-semibold text-stein-800">{area.investor}</p>
</div>
{/if}
</div>
<!-- Gemeinden -->
{#if area.gemeinden && area.gemeinden.length > 0}
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">Betroffene Gemeinden</p>
<div class="flex flex-wrap gap-1.5">
{#each area.gemeinden as g}
<span class="rounded-full bg-himmel-100 px-2.5 py-0.5 text-xs font-medium text-himmel-800">{g}</span>
{/each}
</div>
</div>
{/if}
<!-- Notizen -->
{#if area.notizen}
<div>
<p class="mb-1.5 text-xs font-medium uppercase tracking-wide text-stein-500">Hinweise</p>
<p class="text-stein-700 leading-relaxed">{area.notizen}</p>
</div>
{/if}
<!-- Stellungnahme -->
{#if stellungnahmeHref(area.stellungnahme)}
<a
href={stellungnahmeHref(area.stellungnahme)}
class="flex items-center gap-2 rounded-lg border border-wald-300 bg-wald-50 px-4 py-3 text-sm font-medium text-wald-700 hover:bg-wald-100 transition-colors"
>
<Icon icon="mdi:file-document-edit-outline" class="size-4 shrink-0" />
Zur Stellungnahme-Vorlage
</a>
{:else}
<div class="rounded-lg border border-dashed border-stein-200 px-4 py-3 text-xs text-stein-400">
<Icon icon="mdi:file-document-outline" class="inline size-3.5 mr-1" />
Stellungnahme-Vorlage noch nicht verknüpft
</div>
{/if}
</div>
</div>
{/if}
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import type { WindArea } from "$lib/windkarte";
let {
areas,
geojsonUrl,
onselect,
}: {
areas: WindArea[];
geojsonUrl: string;
onselect: (area: WindArea | null) => void;
} = $props();
let mapEl: HTMLDivElement;
let map: import("leaflet").Map | null = null;
let bufferLayer: import("leaflet").LayerGroup | null = null;
let selectedSlug = $state<string | null>(null);
let geojsonLayer: import("leaflet").GeoJSON | null = null;
const COLORS: Record<string, { fill: string; stroke: string }> = {
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
entwurf_2: { fill: "#b08a52", stroke: "#6e5530" },
entwurf_3: { fill: "#436e85", stroke: "#264150" },
};
function style(status: string, selected: boolean) {
const c = COLORS[status] ?? COLORS.rechtskraeftig;
return {
color: c.stroke,
fillColor: c.fill,
weight: selected ? 2.5 : 1.5,
fillOpacity: selected ? 0.45 : 0.25,
opacity: 1,
};
}
async function drawBuffer(L: typeof import("leaflet"), feature: GeoJSON.Feature) {
const { buffer } = await import("@turf/buffer");
if (bufferLayer) bufferLayer.clearLayers();
else bufferLayer = L.layerGroup().addTo(map!);
const distances = [
{ m: 200, label: "200 m", color: "#e35651", dash: "4 4" },
{ m: 600, label: "600 m", color: "#b08a52", dash: "6 3" },
{ m: 1000, label: "1000 m", color: "#436e85", dash: "8 4" },
];
for (const { m, color, dash } of distances) {
const buffered = buffer(feature, m / 1000, { units: "kilometers" });
if (!buffered) continue;
L.geoJSON(buffered, {
style: { color, fillColor: color, fillOpacity: 0.05, weight: 1.5, dashArray: dash },
}).addTo(bufferLayer!);
}
}
onMount(async () => {
const L = await import("leaflet");
// Fix Leaflet default icon path issue with Vite bundling
const iconUrl = new URL("leaflet/dist/images/marker-icon.png", import.meta.url).href;
const icon2xUrl = new URL("leaflet/dist/images/marker-icon-2x.png", import.meta.url).href;
const shadowUrl = new URL("leaflet/dist/images/marker-shadow.png", import.meta.url).href;
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl: icon2xUrl, shadowUrl });
map = L.map(mapEl, { zoomControl: true }).setView([50.62, 10.42], 9);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a> | Geodaten: © GDI-Th, dl-de/by-2-0',
subdomains: "abcd",
maxZoom: 16,
}).addTo(map);
const byName = Object.fromEntries(areas.map((a) => [a.name, a]));
try {
const geoRes = await fetch(geojsonUrl);
const geo: GeoJSON.FeatureCollection = await geoRes.json();
geojsonLayer = L.geoJSON(geo, {
style: (f) => {
const s = f?.properties?.status ?? "rechtskraeftig";
return style(s, false);
},
onEachFeature: (feature, layer) => {
const cmsArea = byName[feature.properties?.name] ?? null;
if (cmsArea) feature.properties.cms = cmsArea;
layer.on("click", async () => {
const name = feature.properties?.name;
const prev = selectedSlug;
selectedSlug = name ?? null;
// Re-style all layers
geojsonLayer?.eachLayer((l) => {
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
if (!f) return;
const s = f.properties?.status ?? "rechtskraeftig";
const sel = f.properties?.name === selectedSlug;
(l as import("leaflet").Path).setStyle(style(s, sel));
});
if (cmsArea) {
onselect(cmsArea);
await drawBuffer(L, feature);
} else {
onselect(null);
bufferLayer?.clearLayers();
}
// Zoom to feature if first click or switching areas
if (prev !== name) {
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), { padding: [60, 60] });
}
});
// Tooltip with name
const label = feature.properties?.bezeichnung ?? feature.properties?.name ?? "";
layer.bindTooltip(label, { sticky: true, className: "windkarte-tooltip" });
},
}).addTo(map);
} catch (e) {
console.error("GeoJSON laden fehlgeschlagen:", e);
}
});
onDestroy(() => {
map?.remove();
map = null;
});
</script>
<div bind:this={mapEl} class="h-full w-full"></div>
<style>
:global(.windkarte-tooltip) {
background: white;
border: 1px solid #d5d8d6;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
color: #333735;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
}
:global(.windkarte-tooltip::before) {
display: none;
}
</style>
@@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from "svelte";
import { browser } from "$app/environment";
import { env } from "$env/dynamic/public";
import type { WindMapBlockData } from "$lib/block-types";
import type { WindArea } from "$lib/windkarte";
import WindAreaPanel from "$lib/components/WindAreaPanel.svelte";
let { block }: { block: WindMapBlockData } = $props();
const CMS_BASE = (
env.PUBLIC_CMS_URL || "http://localhost:3000"
).replace(/\/$/, "");
const GEOJSON_URL = `${CMS_BASE}/api/assets/geodaten/windvorranggebiete_swt_2012.geojson`;
let areas = $state<WindArea[]>([]);
let selectedArea = $state<WindArea | null>(null);
let mapReady = $state(false);
// Dynamic import: WindkarteMap needs window/document
const MapComponent = browser
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
: null;
onMount(async () => {
try {
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
signal: AbortSignal.timeout(6_000),
});
if (res.ok) {
const data = await res.json();
areas = data.items ?? [];
}
} catch {
// Map renders without CMS metadata — GeoJSON tooltips still work
}
mapReady = true;
});
</script>
<div class="wind-map-block">
{#if block.title || block.subtitle}
<div class="mb-4">
{#if block.title}
<h2 class="text-xl font-semibold text-stein-800">{block.title}</h2>
{/if}
{#if block.subtitle}
<p class="mt-0.5 text-sm text-stein-500">{block.subtitle}</p>
{/if}
</div>
{/if}
<div
class="relative overflow-hidden rounded-xl border border-stein-200 shadow-sm"
style="height: 520px;"
>
{#if browser && MapComponent && mapReady}
{#await MapComponent then Map}
<Map
{areas}
geojsonUrl={GEOJSON_URL}
onselect={(area) => { selectedArea = area; }}
/>
{/await}
{:else}
<div class="flex h-full items-center justify-center bg-stein-50 text-stein-400 text-sm">
Karte wird geladen …
</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'}"
>
<WindAreaPanel
area={selectedArea}
onclose={() => { selectedArea = null; }}
/>
</div>
</div>
<!-- Legend -->
<div class="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-xs text-stein-400">
<span class="flex items-center gap-1.5">
<span class="inline-block size-2.5 rounded-sm" style="background:#2d7a45; opacity:.7"></span>
Rechtskräftig (2012)
</span>
<span class="flex items-center gap-1.5">
<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>
</div>
</div>