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>