Files
windwiderstand/src/lib/components/WindkarteMap.svelte
T
Peter Meier 8e43169b23
Deploy / verify (push) Successful in 56s
Deploy / deploy (push) Successful in 1m38s
feat(windkarte): Stellungnahme-Kriterien per Windvorranggebiet
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>
2026-05-20 14:22:35 +02:00

386 lines
14 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import type { WindArea } from "$lib/windkarte";
import { useTranslate, T } from "$lib/translations";
let {
areas,
onselect,
onready = null,
initialGebietsNr = null,
hiddenStatuses = [],
hiddenRings = [],
}: {
areas: WindArea[];
onselect: (area: WindArea | null, center: [number, number] | null) => void;
onready?: ((ctrl: { reset: () => void }) => void) | null;
initialGebietsNr?: string | null;
hiddenStatuses?: string[];
hiddenRings?: string[];
} = $props();
const t = useTranslate();
// Mirror prop into plain variable so Leaflet event handlers read current value.
let _hidden: string[] = [];
$effect(() => {
_hidden = hiddenStatuses;
if (!geojsonLayer) return;
restyleAll(selectedSlug);
// Deselect if selected area's status was just hidden.
if (selectedSlug) {
geojsonLayer.eachLayer((l) => {
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
if (f?.properties?.name === selectedSlug) {
const s = f.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) {
selectedSlug = null;
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map?.hasLayer(ring2kmLayer)) map.removeLayer(ring2kmLayer);
if (ring5kmLayer && map?.hasLayer(ring5kmLayer)) map.removeLayer(ring5kmLayer);
}
}
});
}
});
let mapEl: HTMLDivElement;
let map: import("leaflet").Map | null = null;
let bufferLayer: import("leaflet").LayerGroup | null = null;
let ring2kmLayer: import("leaflet").LayerGroup | null = null;
let ring5kmLayer: import("leaflet").LayerGroup | null = null;
let labelLayer: import("leaflet").LayerGroup | null = null;
let selectedSlug = $state<string | null>(null);
let geojsonLayer: import("leaflet").GeoJSON | null = null;
let buffering = $state(false);
let _hiddenRings: string[] = [];
$effect(() => {
_hiddenRings = hiddenRings;
if (!map) return;
for (const [key, layer] of [["2km", ring2kmLayer], ["5km", ring5kmLayer]] as [string, import("leaflet").LayerGroup | null][]) {
if (!layer) continue;
if (_hiddenRings.includes(key)) {
if (map.hasLayer(layer)) map.removeLayer(layer);
} else {
if (!map.hasLayer(layer)) map.addLayer(layer);
}
}
});
const COLORS: Record<string, { fill: string; stroke: string }> = {
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
entwurf_2: { fill: "#b08a52", stroke: "#6e5530" },
entwurf_2_voraussichtlich:{ fill: "#d4752a", stroke: "#8a4a18" },
entwurf_3: { fill: "#436e85", stroke: "#264150" },
};
function style(status: string, selected: boolean, hovered = false) {
const c = COLORS[status] ?? COLORS.rechtskraeftig;
return {
color: c.stroke,
fillColor: c.fill,
weight: selected ? 2.5 : 1.5,
fillOpacity: selected ? 0.55 : hovered ? 0.42 : 0.25,
opacity: 1,
};
}
function restyleAll(sel: string | null) {
geojsonLayer?.eachLayer((l) => {
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
if (!f) return;
const s = f.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) {
(l as import("leaflet").Path).setStyle({ opacity: 0, fillOpacity: 0, weight: 0 });
} else {
(l as import("leaflet").Path).setStyle(style(s, f.properties?.name === sel));
}
});
}
async function drawBuffer(L: typeof import("leaflet"), feature: GeoJSON.Feature) {
buffering = true;
const { buffer } = await import("@turf/buffer");
if (bufferLayer) bufferLayer.clearLayers();
else bufferLayer = L.layerGroup().addTo(map!);
for (const { m, color, dash } of [
{ m: 200, color: "#e35651", dash: "4 4" },
{ m: 600, color: "#b08a52", dash: "6 3" },
{ m: 1000, color: "#436e85", dash: "8 4" },
]) {
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!);
}
// 2km / 5km — always compute on selection, show/hide controlled by hiddenRings toggle
if (!ring2kmLayer) ring2kmLayer = L.layerGroup();
else ring2kmLayer.clearLayers();
if (!ring5kmLayer) ring5kmLayer = L.layerGroup();
else ring5kmLayer.clearLayers();
const b2 = buffer(feature, 2, { units: "kilometers" });
if (b2) L.geoJSON(b2, { style: { color: "#5e6fa3", fillColor: "#5e6fa3", fillOpacity: 0.05, weight: 1.5, dashArray: "10 5" } }).addTo(ring2kmLayer);
const b5 = buffer(feature, 5, { units: "kilometers" });
if (b5) L.geoJSON(b5, { style: { color: "#8a567a", fillColor: "#8a567a", fillOpacity: 0.05, weight: 1.5, dashArray: "14 6" } }).addTo(ring5kmLayer);
if (!_hiddenRings.includes("2km") && !map!.hasLayer(ring2kmLayer)) map!.addLayer(ring2kmLayer);
if (!_hiddenRings.includes("5km") && !map!.hasLayer(ring5kmLayer)) map!.addLayer(ring5kmLayer);
buffering = false;
}
async function selectFeature(
L: typeof import("leaflet"),
feature: GeoJSON.Feature,
layer: import("leaflet").Layer,
cmsArea: WindArea | null,
zoom = true
) {
const name = feature.properties?.name;
const prev = selectedSlug;
selectedSlug = name ?? null;
restyleAll(selectedSlug);
const bounds = (layer as import("leaflet").Polygon).getBounds();
const c = bounds.getCenter();
const center: [number, number] = [c.lat, c.lng];
if (cmsArea) {
onselect(cmsArea, center);
await drawBuffer(L, feature);
} else {
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
}
if (zoom && prev !== name) {
const desktop = window.innerWidth >= 640;
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), {
paddingTopLeft: [40, 40],
paddingBottomRight: [desktop ? 320 : 40, desktop ? 40 : 300],
maxZoom: 11,
});
}
}
onMount(async () => {
const L = await import("leaflet");
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, attributionControl: false }).setView([50.62, 10.42], 10);
L.control.attribution({ position: "bottomleft" }).addTo(map);
labelLayer = L.layerGroup(); // not added to map yet — shown only at zoom >= 13
onready?.({ reset: () => map!.setView([50.62, 10.42], 10) });
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende | Geodaten: © GDI-Th, dl-de/by-2-0',
maxZoom: 19,
}).addTo(map);
const byNr = Object.fromEntries(areas.map((a) => [a.gebiets_nr, a]));
// Deselect on background map click
map.on("click", () => {
if (!selectedSlug) return;
selectedSlug = null;
restyleAll(null);
onselect(null, null);
bufferLayer?.clearLayers();
if (ring2kmLayer && map!.hasLayer(ring2kmLayer)) map!.removeLayer(ring2kmLayer);
if (ring5kmLayer && map!.hasLayer(ring5kmLayer)) map!.removeLayer(ring5kmLayer);
});
try {
// Build FeatureCollection directly from CMS areas (geometry embedded)
const geo: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: areas
.filter((a) => a.geometry)
.map((a) => ({
type: "Feature" as const,
properties: {
name: a.gebiets_nr,
bezeichnung: a.bezeichnung,
status: a.status ?? "rechtskraeftig",
},
geometry: a.geometry!,
})),
};
const layerMap = new Map<string, { feature: GeoJSON.Feature; layer: import("leaflet").Layer }>();
geojsonLayer = L.geoJSON(geo, {
style: (f) => {
const s = f?.properties?.status ?? "rechtskraeftig";
return style(s, false);
},
onEachFeature: (feature, layer) => {
const p = feature.properties ?? {};
const area = byNr[p.name] ?? { _slug: "", gebiets_nr: p.name ?? "", status: p.status, bezeichnung: p.bezeichnung };
feature.properties.cms = area;
const name = feature.properties?.name ?? "";
layerMap.set(name, { feature, layer });
// Hover highlight
layer.on("mouseover", () => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
if (feature.properties?.name !== selectedSlug) {
(layer as import("leaflet").Path).setStyle(style(s, false, true));
}
});
layer.on("mouseout", () => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
if (feature.properties?.name !== selectedSlug) {
(layer as import("leaflet").Path).setStyle(style(s, false, false));
}
});
layer.on("click", async (e) => {
const s = feature.properties?.status ?? "rechtskraeftig";
if (_hidden.includes(s)) return;
L.DomEvent.stopPropagation(e);
await selectFeature(L, feature, layer, area);
});
// Hover tooltip
const nrLabel = area.gebiets_nr ?? p.name ?? "";
const title = area.bezeichnung
? `${nrLabel} ${area.bezeichnung}`
: nrLabel;
const lines: string[] = [];
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(`${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 =
`<div class="font-semibold">${esc(title)}</div>` +
(lines.length ? `<div class="mt-0.5 text-xs opacity-90">${lines.map(esc).join('<br/>')}</div>` : '');
layer.bindTooltip(tooltipHtml, { sticky: true, className: "windkarte-tooltip" });
// Permanent label via divIcon marker at polygon center
const nr = area.gebiets_nr ?? p.name;
if (nr && labelLayer) {
const center = (layer as import("leaflet").Polygon).getBounds().getCenter();
L.marker(center, {
icon: L.divIcon({
html: `<span class="windkarte-label">${nr}</span>`,
className: "windkarte-label-wrap",
iconSize: [0, 0],
iconAnchor: [0, 0],
}),
interactive: false,
keyboard: false,
}).addTo(labelLayer);
}
},
}).addTo(map);
restyleAll(selectedSlug);
// Labels: show from zoom 13, scale font proportionally with zoom
const syncLabels = () => {
if (!labelLayer || !map) return;
const z = map.getZoom();
if (z >= 13) {
if (!map.hasLayer(labelLayer)) map.addLayer(labelLayer);
const fontSize = Math.max(8, Math.min(26, Math.round(9 * Math.pow(2, z - 13))));
labelLayer.eachLayer((l) => {
const el = (l as import("leaflet").Marker).getElement();
const span = el?.querySelector<HTMLElement>(".windkarte-label");
if (span) span.style.fontSize = fontSize + "px";
});
} else {
if (map.hasLayer(labelLayer)) map.removeLayer(labelLayer);
}
};
map.on("zoomend", syncLabels);
syncLabels();
// Auto-select from hash
if (initialGebietsNr) {
const entry = layerMap.get(initialGebietsNr);
if (entry) {
await selectFeature(L, entry.feature, entry.layer, byNr[initialGebietsNr] ?? null);
}
}
} catch (e) {
console.error("Karte laden fehlgeschlagen:", e);
}
});
onDestroy(() => {
map?.remove();
map = null;
});
</script>
<div bind:this={mapEl} class="h-full w-full"></div>
{#if buffering}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center z-[600]">
<div class="rounded-full bg-white/90 p-2.5 shadow-md">
<svg class="size-5 animate-spin text-stein-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8h4z"/>
</svg>
</div>
</div>
{/if}
<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;
}
:global(.windkarte-label-wrap) {
background: none !important;
border: none !important;
box-shadow: none !important;
}
:global(.windkarte-label) {
background: rgba(255, 255, 255, 0.6);
opacity: 0.75;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0,0,0,0.12);
color: #1a1a1a;
display: inline-block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 1px 4px;
pointer-events: none;
position: absolute;
transform: translate(-50%, -50%);
white-space: nowrap;
}
</style>