41597f87b5
- 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>
437 lines
16 KiB
Svelte
437 lines
16 KiB
Svelte
<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:
|
||
'© <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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
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>
|