feat(windkarte): Stellungnahme-Kriterien per Windvorranggebiet
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m38s

Jedes Windvorranggebiet kann jetzt 1–n text_fragment-Einträge als
Stellungnahme-Kriterien referenzieren. Das Panel lädt diese lazy beim
Öffnen und zeigt sie als aufklappbare Accordion-Sektion.

Außerdem: alle hardcodierten Strings im Karten-Block (Panel, Map,
Block) durch t()-Aufrufe ersetzt; 7 neue Translation-Keys ergänzt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-20 14:22:35 +02:00
parent a3f545d727
commit 8e43169b23
6 changed files with 99 additions and 14 deletions
+70 -8
View File
@@ -1,16 +1,18 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import type { WindArea } from "$lib/windkarte";
import type { WindArea, TextFragment } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
let {
area,
center,
cmsBase,
onclose,
}: {
area: WindArea | null;
center: [number, number] | null;
cmsBase: string;
onclose: () => void;
} = $props();
@@ -34,6 +36,33 @@
return null;
}
let kriterien = $state<TextFragment[]>([]);
let loadingKriterien = $state(false);
let expandedSlug = $state<string | null>(null);
$effect(() => {
const slugs = (area?.stellungnahme_kriterien ?? [])
.map((k) => (typeof k === "string" ? k : k._slug))
.filter(Boolean);
kriterien = [];
expandedSlug = null;
if (slugs.length === 0) return;
loadingKriterien = true;
Promise.all(
slugs.map((slug) =>
fetch(`${cmsBase}/api/content/text_fragment/${slug}`)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null),
),
).then((results) => {
kriterien = results.filter(Boolean) as TextFragment[];
loadingKriterien = false;
});
});
let copied = $state(false);
async function copyLink() {
await navigator.clipboard.writeText(location.href);
@@ -49,10 +78,10 @@
const exportInfo = $derived(
area
? [
`Gebiet ${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `Fläche: ${area.flaeche_ha.toFixed(1)} ha` : null,
area.anlagen_geplant != null ? `Geplante Anlagen: ${area.anlagen_geplant}` : null,
area.hoehe_max_m != null ? `Max. Nabenhöhe: ${area.hoehe_max_m} m` : null,
`${area.gebiets_nr}${area.bezeichnung ? ` ${area.bezeichnung}` : ''}`,
area.flaeche_ha != null ? `${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha` : null,
area.anlagen_geplant != null ? `${t(T.windkarte_label_anlagen_geplant)}: ${area.anlagen_geplant}` : null,
area.hoehe_max_m != null ? `${t(T.windkarte_label_hoehe_max)}: ${area.hoehe_max_m} m` : null,
]
.filter(Boolean)
.join('\n')
@@ -136,6 +165,39 @@
</div>
{/if}
<!-- Stellungnahme-Kriterien -->
{#if loadingKriterien}
<div class="flex items-center gap-2 text-xs text-stein-400">
<Icon icon="mdi:loading" class="size-3.5 animate-spin" />
<span>{t(T.windkarte_kriterien_loading)}</span>
</div>
{:else if kriterien.length > 0}
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_kriterien)}</p>
<div class="divide-y divide-stein-100 rounded-lg border border-stein-100 overflow-hidden">
{#each kriterien as k (k._slug)}
<div>
<button
onclick={() => { expandedSlug = expandedSlug === k._slug ? null : k._slug; }}
class="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm font-medium text-stein-700 hover:bg-stein-50 transition-colors"
>
<span class="min-w-0 truncate">{k.title ?? k._slug}</span>
<Icon
icon={expandedSlug === k._slug ? "mdi:chevron-up" : "mdi:chevron-down"}
class="size-4 shrink-0 text-stein-400"
/>
</button>
{#if expandedSlug === k._slug && k.text}
<div class="px-3 pb-3 pt-1 text-sm text-stein-700 leading-relaxed markdown prose prose-sm prose-zinc max-w-none border-t border-stein-100">
{@html k.text}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Stellungnahme -->
{#if stellungnahmeHref(area.stellungnahme)}
<a
@@ -171,12 +233,12 @@
<!-- Export-Downloads -->
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">Geodaten-Export ({area.gebiets_nr})</p>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-stein-500">{t(T.windkarte_label_geodaten_export, { nr: area.gebiets_nr })}</p>
<div class="flex gap-2">
<a
href="/api/vorranggebiete.geojson?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`GeoJSON herunterladen\n${exportInfo}`}
title={`${t(T.windkarte_action_download_geojson)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
@@ -185,7 +247,7 @@
<a
href="/api/vorranggebiete.kml?id={encodeURIComponent(area.gebiets_nr)}"
download
title={`KML herunterladen\n${exportInfo}`}
title={`${t(T.windkarte_action_download_kml)}\n${exportInfo}`}
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 no-underline hover:bg-stein-50 transition-colors whitespace-nowrap"
>
<Icon icon="mdi:download" class="size-3.5 shrink-0" />
+7 -4
View File
@@ -2,6 +2,7 @@
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import type { WindArea } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
let {
areas,
@@ -19,6 +20,8 @@
hiddenRings?: string[];
} = $props();
const t = useTranslate();
// Mirror prop into plain variable so Leaflet event handlers read current value.
let _hidden: string[] = [];
$effect(() => {
@@ -262,11 +265,11 @@
? `${nrLabel} ${area.bezeichnung}`
: nrLabel;
const lines: string[] = [];
if (area.flaeche_ha != null) lines.push(`Fläche: ${area.flaeche_ha.toFixed(1)} ha`);
if (area.anlagen_geplant != null) lines.push(`Geplante Anlagen: ${area.anlagen_geplant}`);
if (area.hoehe_max_m != null) lines.push(`Max. Nabenhöhe: ${area.hoehe_max_m} m`);
if (area.flaeche_ha != null) lines.push(`${t(T.windkarte_label_flaeche)}: ${area.flaeche_ha.toFixed(1)} ha`);
if (area.anlagen_geplant != null) lines.push(`${t(T.windkarte_label_anlagen_geplant)}: ${area.anlagen_geplant}`);
if (area.hoehe_max_m != null) lines.push(`${t(T.windkarte_label_hoehe_max)}: ${area.hoehe_max_m} m`);
if (area.gemeinden && area.gemeinden.length > 0) {
lines.push(`Gemeinden: ${area.gemeinden.slice(0, 3).join(', ')}${area.gemeinden.length > 3 ? '…' : ''}`);
lines.push(`${t(T.windkarte_label_gemeinden)}: ${area.gemeinden.slice(0, 3).join(', ')}${area.gemeinden.length > 3 ? '…' : ''}`);
}
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const tooltipHtml =
@@ -168,6 +168,7 @@ let areas = $state<WindArea[]>([]);
<WindAreaPanel
area={selectedArea}
center={selectedCenter}
cmsBase={CMS_BASE}
onclose={() => handleSelect(null)}
/>
</div>
@@ -184,7 +185,7 @@ let areas = $state<WindArea[]>([]);
onclick={() => toggleStatus(item.status)}
class="flex items-center gap-1.5 transition-opacity cursor-pointer select-none
{hiddenStatuses.includes(item.status) ? 'opacity-35' : 'opacity-100'}"
title={hiddenStatuses.includes(item.status) ? 'Einblenden' : 'Ausblenden'}
title={hiddenStatuses.includes(item.status) ? t(T.windkarte_action_show) : t(T.windkarte_action_hide)}
>
<span class="inline-block size-2.5 rounded-sm shrink-0" style="background:{item.color}; opacity:.7"></span>
<span class="{hiddenStatuses.includes(item.status) ? 'line-through' : ''}">{item.label}</span>
@@ -204,7 +205,7 @@ let areas = $state<WindArea[]>([]);
onclick={() => toggleRing(ring.key)}
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity
{hiddenRings.includes(ring.key) ? 'opacity-35' : 'opacity-100'}"
title={hiddenRings.includes(ring.key) ? 'Einblenden (alle Gebiete)' : 'Ausblenden'}
title={hiddenRings.includes(ring.key) ? t(T.windkarte_action_show) : t(T.windkarte_action_hide)}
>
<span class="dashed-line {hiddenRings.includes(ring.key) ? 'opacity-40' : ''}" style="border-color:{ring.color}"></span>
<span class="{hiddenRings.includes(ring.key) ? 'line-through' : ''}">{ring.label}</span>
@@ -66,6 +66,9 @@
"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\"/>"
},
"loading": {
"body": "<path fill=\"currentColor\" d=\"M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8\"/>"
},
"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\"/>"
},
+7
View File
@@ -211,6 +211,13 @@ const TRANSLATION_KEYS = [
"windkarte_label_investor",
"windkarte_label_gemeinden",
"windkarte_label_notizen",
"windkarte_label_kriterien",
"windkarte_kriterien_loading",
"windkarte_label_geodaten_export",
"windkarte_action_download_geojson",
"windkarte_action_download_kml",
"windkarte_action_show",
"windkarte_action_hide",
"windkarte_stellungnahme_link",
"windkarte_action_copy_link",
"windkarte_action_copied",
+9
View File
@@ -1,3 +1,11 @@
export type TextFragment = {
_slug: string;
id?: string;
title?: string;
text?: string;
tags?: Array<{ _slug: string; name?: string } | string>;
};
export type WindArea = {
_slug: string;
gebiets_nr: string;
@@ -9,6 +17,7 @@ export type WindArea = {
investor?: string;
gemeinden?: string[];
stellungnahme?: { _slug: string; slug?: string } | string | null;
stellungnahme_kriterien?: Array<{ _slug: string } | string> | null;
notizen?: string;
geometry?: GeoJSON.Geometry;
};