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
+164 -2
View File
@@ -12,8 +12,11 @@
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1",
"@turf/buffer": "^7.3.5",
"@types/leaflet": "^1.9.21",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"leaflet": "^1.9.4",
"marked": "^17.0.2",
"sanitize-html": "^2.13.1",
"sharp": "^0.34.5"
@@ -2136,6 +2139,121 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@turf/bbox": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.5.tgz",
"integrity": "sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/buffer": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/buffer/-/buffer-7.3.5.tgz",
"integrity": "sha512-TGls3nYtWzviKHT00XVBfHKa7Z2oIZKqiHN7R0xErGwMSAR7YhxVROhxq/iyIsWZjl1SlPwweZZIxWILQuxpZA==",
"license": "MIT",
"dependencies": {
"@turf/bbox": "7.3.5",
"@turf/center": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/jsts": "^2.7.1",
"@turf/meta": "7.3.5",
"@turf/projection": "7.3.5",
"@types/geojson": "^7946.0.10",
"d3-geo": "1.7.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/center": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/center/-/center-7.3.5.tgz",
"integrity": "sha512-eub5/Kfdmn89ZqwCONHI7astmTDEtN5M6+JfOkgoSyhKKFhUJYNxUyH1F/vCtIP7j1K369Vs4L9TYiuGapvIKQ==",
"license": "MIT",
"dependencies": {
"@turf/bbox": "7.3.5",
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/clone": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/clone/-/clone-7.3.5.tgz",
"integrity": "sha512-qfIaHj3410QEcTpiCRnTzhq8YrUp2gWrUIPLBAEFykopNxJkq1du1VrRzvuAo36ap2UV7KppkS6wGNypbcxswQ==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/helpers": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz",
"integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/jsts": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@turf/jsts/-/jsts-2.7.2.tgz",
"integrity": "sha512-zAezGlwWHPyU0zxwcX2wQY3RkRpwuoBmhhNE9HY9kWhFDkCxZ3aWK5URKwa/SWKJbj9aztO+8vtdiBA28KVJFg==",
"license": "(EDL-1.0 OR EPL-1.0)",
"dependencies": {
"jsts": "2.7.1"
}
},
"node_modules/@turf/meta": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz",
"integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==",
"license": "MIT",
"dependencies": {
"@turf/helpers": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@turf/projection": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/@turf/projection/-/projection-7.3.5.tgz",
"integrity": "sha512-G4bejYKT0vCQZryMhEoS9aLmP7ThDg6nb3zi3wPzELiTrGNOd2YgkWVheQDGCk4hcqEIWZc9fI2alaRSSkkLVw==",
"license": "MIT",
"dependencies": {
"@turf/clone": "7.3.5",
"@turf/helpers": "7.3.5",
"@turf/meta": "7.3.5",
"@types/geojson": "^7946.0.10",
"tslib": "^2.8.1"
},
"funding": {
"url": "https://opencollective.com/turf"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -2149,6 +2267,21 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"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/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@@ -2570,6 +2703,21 @@
"node": ">= 8"
}
},
"node_modules/d3-array": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-geo": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.7.1.tgz",
"integrity": "sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "1"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3200,6 +3348,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsts": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz",
"integrity": "sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg==",
"license": "(EDL-1.0 OR EPL-1.0)",
"engines": {
"node": ">= 12"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -3210,6 +3367,12 @@
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -4369,8 +4532,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "4.41.0",
+3
View File
@@ -41,8 +41,11 @@
"@fontsource-variable/rubik": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@iconify/svelte": "^5.2.1",
"@turf/buffer": "^7.3.5",
"@types/leaflet": "^1.9.21",
"blurhash": "^2.0.5",
"class-variance-authority": "^0.7.1",
"leaflet": "^1.9.4",
"marked": "^17.0.2",
"sanitize-html": "^2.13.1",
"sharp": "^0.34.5"
+8
View File
@@ -421,3 +421,11 @@ export type CalendarBlockData = Omit<
items?: (string | CalendarItemData)[];
layout?: BlockLayout;
};
export interface WindMapBlockData {
_type?: "wind_map";
_slug?: string;
name?: string;
title?: string;
subtitle?: string;
}
+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>
+6 -3
View File
@@ -66,6 +66,12 @@
"printer-outline": {
"body": "<path fill=\"currentColor\" d=\"M19 8c1.66 0 3 1.34 3 3v6h-4v4H6v-4H2v-6c0-1.66 1.34-3 3-3h1V3h12v5zM8 5v3h8V5zm8 14v-4H8v4zm2-4h2v-4c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v4h2v-2h12zm1-3.5c0 .55-.45 1-1 1s-1-.45-1-1s.45-1 1-1s1 .45 1 1\"/>"
},
"file-document-edit-outline": {
"body": "<path fill=\"currentColor\" d=\"M8 12h8v2H8zm2 8H6V4h7v5h5v3.1l2-2V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4zm-2-2h4.1l.9-.9V16H8zm12.2-5c.1 0 .3.1.4.2l1.3 1.3c.2.2.2.6 0 .8l-1 1l-2.1-2.1l1-1c.1-.1.2-.2.4-.2m0 3.9L14.1 23H12v-2.1l6.1-6.1z\"/>"
},
"file-document-outline": {
"body": "<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>"
},
"map-marker-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 6.5A2.5 2.5 0 0 1 14.5 9a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 9.5 9A2.5 2.5 0 0 1 12 6.5M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7m0 2a5 5 0 0 0-5 5c0 1 0 3 5 9.71C17 12 17 10 17 9a5 5 0 0 0-5-5\"/>"
},
@@ -126,9 +132,6 @@
"home-outline": {
"body": "<path fill=\"currentColor\" d=\"m12 5.69l5 4.5V18h-2v-6H9v6H7v-7.81zM12 3L2 12h3v8h6v-6h2v6h6v-8h3\"/>"
},
"file-document-outline": {
"body": "<path fill=\"currentColor\" d=\"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm0 2h7v5h5v11H6zm2 8v2h8v-2zm0 4v2h5v-2z\"/>"
},
"share-variant-outline": {
"body": "<path fill=\"currentColor\" d=\"M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91s-1.31-2.92-2.92-2.92M18 4c.55 0 1 .45 1 1s-.45 1-1 1s-1-.45-1-1s.45-1 1-1M6 13c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m12 7c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1\"/>"
},
+13
View File
@@ -0,0 +1,13 @@
export type WindArea = {
_slug: string;
name: string;
bezeichnung?: string;
status?: "rechtskraeftig" | "entwurf_2" | "entwurf_3";
flaeche_ha?: number;
anlagen_geplant?: number;
hoehe_max_m?: number;
investor?: string;
gemeinden?: string[];
stellungnahme?: { _slug: string; slug?: string } | string | null;
notizen?: string;
};