Files
windwiderstand/src/lib/components/WindkarteMap.svelte
T
Peter Meier 41597f87b5
Deploy / verify (push) Successful in 1m6s
Deploy / deploy (push) Successful in 1m18s
feat(windkarte): InfoAccordion, anlagen-Hinweis, Tooltip ohne Icons
- Disclaimer aus CMS-Feld (hinweis_text) via InfoAccordion statt Hardcode
- info_text-Feld für Block-Fließtext ergänzt
- anlagen_geplant_hinweis-Feld in WindArea + Panel-Anzeige
- Tooltip-Icons entfernt (square-outline/wind-turbine/map-marker inline SVGs)
- Panel: square-outline → ruler-square für Fläche
- Iconify-Subset neu generiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 12:35:29 +02:00

437 lines
16 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 = [],
fitBoundsMaxZoom = 11,
fitBoundsPadding = null,
}: {
areas: WindArea[];
onselect: (area: WindArea | null, center: [number, number] | null) => void;
onready?: ((ctrl: { reset: () => void }) => void) | null;
initialGebietsNr?: string | null;
hiddenStatuses?: string[];
hiddenRings?: string[];
fitBoundsMaxZoom?: number;
/** Wenn gesetzt: symmetrisches [x,y]-Padding für fitBounds (z.B. Mini-Map
* ohne Panel). Default null = panel-aware Padding (großer Bottom-/Side-
* Bereich für Detail-Drawer in der vollwertigen Karte). */
fitBoundsPadding?: [number, number] | null;
} = $props();
const t = useTranslate();
// Mirror prop into plain variable so Leaflet event handlers read current value.
let _hidden: string[] = [];
$effect(() => {
_hidden = hiddenStatuses;
syncLabelsFn?.();
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 labelLayers: Record<string, import("leaflet").LayerGroup> = {};
let syncLabelsFn: (() => void) | 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;
const pad: { paddingTopLeft: [number, number]; paddingBottomRight: [number, number] } =
fitBoundsPadding
? {
paddingTopLeft: fitBoundsPadding,
paddingBottomRight: fitBoundsPadding,
}
: {
paddingTopLeft: [40, 40],
paddingBottomRight: [desktop ? 320 : 40, desktop ? 40 : 300],
};
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), {
...pad,
maxZoom: fitBoundsMaxZoom,
});
}
}
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);
// One label LayerGroup per status — lets syncLabels respect hiddenStatuses
labelLayers = {
rechtskraeftig: L.layerGroup(),
entwurf_2: L.layerGroup(),
entwurf_2_voraussichtlich: L.layerGroup(),
entwurf_3: L.layerGroup(),
};
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 — labels rendered bold via <strong>, an info glyph
// gets appended after the count when the editor supplied a hint
// string so users know there's more context in the side panel.
const nrLabel = area.gebiets_nr ?? p.name ?? "";
const title = area.bezeichnung
? `${nrLabel} ${area.bezeichnung}`
: nrLabel;
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const row = (label: string, value: string, suffix = "") =>
`<strong>${esc(label)}:</strong> ${esc(value)}${suffix}`;
const lines: string[] = [];
if (area.flaeche_ha != null) {
lines.push(row(t(T.windkarte_label_flaeche), `${area.flaeche_ha.toFixed(1)} ha`));
}
if (area.anlagen_geplant != null) {
const hint = area.anlagen_geplant_hinweis ? " *" : "";
lines.push(row(t(T.windkarte_label_anlagen_geplant), `${area.anlagen_geplant}`, hint));
}
if (area.gemeinden && area.gemeinden.length > 0) {
lines.push(row(t(T.windkarte_label_gemeinden), area.gemeinden.join(', ')));
}
const tooltipHtml =
`<div class="text-sm font-bold pb-1 mb-1 border-b border-stein-200/60">${esc(title)}</div>` +
(lines.length ? `<div class="text-xs opacity-90 leading-relaxed">${lines.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;
const featStatus = (feature?.properties?.status ?? "rechtskraeftig") as string;
const targetGroup = labelLayers[featStatus] ?? labelLayers.rechtskraeftig;
if (nr && targetGroup) {
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(targetGroup);
}
},
}).addTo(map);
restyleAll(selectedSlug);
// Labels: show from zoom 13, scale font proportionally with zoom.
// Per-status LayerGroup so hidden statuses also hide their labels.
const syncLabels = () => {
if (!map) return;
const z = map.getZoom();
const show = z >= 13;
const fontSize = Math.max(8, Math.min(26, Math.round(9 * Math.pow(2, z - 13))));
for (const [status, layer] of Object.entries(labelLayers)) {
const visible = show && !_hidden.includes(status);
if (visible) {
if (!map.hasLayer(layer)) map.addLayer(layer);
layer.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(layer)) map.removeLayer(layer);
}
}
};
syncLabelsFn = syncLabels;
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: 13px;
font-weight: 500;
padding: 8px 12px;
min-width: 220px;
max-width: 360px;
white-space: normal !important;
line-height: 1.45;
}
:global(.windkarte-tooltip::before) {
display: none;
}
/* Auf Touch-Geräten Tooltip ausblenden: Touch öffnet direkt das
Detail-Panel, der Tooltip bricht sonst links aus dem Karten-Bereich. */
@media (hover: none) {
:global(.windkarte-tooltip) {
display: none !important;
}
}
: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>