feat(windkarte): Leaflet-Block für Windvorranggebiete SWT
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:
Generated
+164
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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>
|
||||
@@ -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\"/>"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user