diff --git a/src/lib/components/WindAreaPanel.svelte b/src/lib/components/WindAreaPanel.svelte
index 98cd4b6..64c4511 100644
--- a/src/lib/components/WindAreaPanel.svelte
+++ b/src/lib/components/WindAreaPanel.svelte
@@ -5,9 +5,11 @@
let {
area,
+ center,
onclose,
}: {
area: WindArea | null;
+ center: [number, number] | null;
onclose: () => void;
} = $props();
@@ -28,6 +30,22 @@
if (s._slug) return `/post/${s._slug}`;
return null;
}
+
+ let copied = $state(false);
+ async function copyLink() {
+ await navigator.clipboard.writeText(location.href);
+ copied = true;
+ setTimeout(() => (copied = false), 2000);
+ }
+
+ $derived: osmHref: center
+ ? `https://www.openstreetmap.org/?mlat=${center[0].toFixed(5)}&mlon=${center[1].toFixed(5)}#map=14/${center[0].toFixed(5)}/${center[1].toFixed(5)}`
+ : null;
+
+ function osmHref(c: [number, number] | null): string | null {
+ if (!c) return null;
+ return `https://www.openstreetmap.org/?mlat=${c[0].toFixed(5)}&mlon=${c[1].toFixed(5)}#map=14/${c[0].toFixed(5)}/${c[1].toFixed(5)}`;
+ }
{#if area}
@@ -121,6 +139,28 @@
Stellungnahme-Vorlage noch nicht verknüpft
{/if}
+
+
+
+
+ {#if osmHref(center)}
+
+
+ In OSM öffnen
+
+ {/if}
+
{/if}
diff --git a/src/lib/components/WindkarteMap.svelte b/src/lib/components/WindkarteMap.svelte
index ca19e49..dc9fb66 100644
--- a/src/lib/components/WindkarteMap.svelte
+++ b/src/lib/components/WindkarteMap.svelte
@@ -7,17 +7,23 @@
areas,
geojsonUrl,
onselect,
+ onready = null,
+ initialGebietsNr = null,
}: {
areas: WindArea[];
geojsonUrl: string;
- onselect: (area: WindArea | null) => void;
+ onselect: (area: WindArea | null, center: [number, number] | null) => void;
+ onready?: ((ctrl: { reset: () => void }) => void) | null;
+ initialGebietsNr?: string | null;
} = $props();
let mapEl: HTMLDivElement;
let map: import("leaflet").Map | null = null;
let bufferLayer: import("leaflet").LayerGroup | null = null;
+ let labelLayer: import("leaflet").LayerGroup | null = null;
let selectedSlug = $state(null);
let geojsonLayer: import("leaflet").GeoJSON | null = null;
+ let buffering = $state(false);
const COLORS: Record = {
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
@@ -25,60 +31,116 @@
entwurf_3: { fill: "#436e85", stroke: "#264150" },
};
- function style(status: string, selected: boolean) {
+ 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.45 : 0.25,
+ 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";
+ (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!);
- const distances = [
- { m: 200, label: "200 m", color: "#e35651", dash: "4 4" },
- { m: 600, label: "600 m", color: "#b08a52", dash: "6 3" },
- { m: 1000, label: "1000 m", color: "#436e85", dash: "8 4" },
- ];
- for (const { m, color, dash } of distances) {
+ 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!);
}
+ 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 (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: 14,
+ });
+ }
}
onMount(async () => {
const L = await import("leaflet");
- // Fix Leaflet default icon path issue with Vite bundling
- 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;
+ 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 }).setView([50.62, 10.42], 9);
+ 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://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
+ L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
- '© OpenStreetMap © CARTO | Geodaten: © GDI-Th, dl-de/by-2-0',
- subdomains: "abcd",
- maxZoom: 16,
+ '© OpenStreetMap-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();
+ });
+
try {
const geoRes = await fetch(geojsonUrl);
const geo: GeoJSON.FeatureCollection = await geoRes.json();
+ const layerMap = new Map();
+
geojsonLayer = L.geoJSON(geo, {
style: (f) => {
const s = f?.properties?.status ?? "rechtskraeftig";
@@ -88,39 +150,76 @@
const cmsArea = byNr[feature.properties?.name] ?? null;
if (cmsArea) feature.properties.cms = cmsArea;
- layer.on("click", async () => {
- const name = feature.properties?.name;
- const prev = selectedSlug;
- selectedSlug = name ?? null;
+ const name = feature.properties?.name ?? "";
+ layerMap.set(name, { feature, layer });
- // Re-style all layers
- geojsonLayer?.eachLayer((l) => {
- const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
- if (!f) return;
- const s = f.properties?.status ?? "rechtskraeftig";
- const sel = f.properties?.name === selectedSlug;
- (l as import("leaflet").Path).setStyle(style(s, sel));
- });
-
- if (cmsArea) {
- onselect(cmsArea);
- await drawBuffer(L, feature);
- } else {
- onselect(null);
- bufferLayer?.clearLayers();
+ // Hover highlight
+ layer.on("mouseover", () => {
+ if (feature.properties?.name !== selectedSlug) {
+ const s = feature.properties?.status ?? "rechtskraeftig";
+ (layer as import("leaflet").Path).setStyle(style(s, false, true));
}
-
- // Zoom to feature if first click or switching areas
- if (prev !== name) {
- map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), { padding: [60, 60] });
+ });
+ layer.on("mouseout", () => {
+ if (feature.properties?.name !== selectedSlug) {
+ const s = feature.properties?.status ?? "rechtskraeftig";
+ (layer as import("leaflet").Path).setStyle(style(s, false, false));
}
});
- // Tooltip with name
- const label = feature.properties?.bezeichnung ?? feature.properties?.name ?? "";
- layer.bindTooltip(label, { sticky: true, className: "windkarte-tooltip" });
+ layer.on("click", async (e) => {
+ L.DomEvent.stopPropagation(e);
+ await selectFeature(L, feature, layer, cmsArea);
+ });
+
+ // Hover tooltip
+ const hoverLabel = feature.properties?.bezeichnung ?? name;
+ layer.bindTooltip(hoverLabel, { sticky: true, className: "windkarte-tooltip" });
+
+ // Permanent label via divIcon marker at polygon center
+ const nr = cmsArea?.gebiets_nr ?? name;
+ if (nr && labelLayer) {
+ const center = (layer as import("leaflet").Polygon).getBounds().getCenter();
+ L.marker(center, {
+ icon: L.divIcon({
+ html: `${nr}`,
+ className: "windkarte-label-wrap",
+ iconSize: [0, 0],
+ iconAnchor: [0, 0],
+ }),
+ interactive: false,
+ keyboard: false,
+ }).addTo(labelLayer);
+ }
},
}).addTo(map);
+
+ // 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(".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, true);
+ }
+ }
} catch (e) {
console.error("GeoJSON laden fehlgeschlagen:", e);
}
@@ -134,6 +233,17 @@
+{#if buffering}
+
+{/if}
+
diff --git a/src/lib/components/blocks/WindkarteBlock.svelte b/src/lib/components/blocks/WindkarteBlock.svelte
index bdd96bf..5bd543d 100644
--- a/src/lib/components/blocks/WindkarteBlock.svelte
+++ b/src/lib/components/blocks/WindkarteBlock.svelte
@@ -16,21 +16,37 @@
let areas = $state([]);
let selectedArea = $state(null);
+ let selectedCenter = $state<[number, number] | null>(null);
let mapReady = $state(false);
+ let initialGebietsNr = $state(null);
+ let resetMap: (() => void) | null = null;
- // Dynamic import: WindkarteMap needs window/document
const MapComponent = browser
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
: null;
+ function handleSelect(area: WindArea | null, center: [number, number] | null = null) {
+ selectedArea = area;
+ selectedCenter = center;
+ if (browser) {
+ if (area) {
+ history.replaceState(null, "", location.pathname + location.search + "#" + encodeURIComponent(area.gebiets_nr));
+ } else {
+ history.replaceState(null, "", location.pathname + location.search);
+ }
+ }
+ }
+
onMount(async () => {
+ const hash = window.location.hash.slice(1);
+ if (hash) initialGebietsNr = decodeURIComponent(hash);
+
try {
const slugs = (block.areas ?? [])
.map((a) => (typeof a === "string" ? a : a._slug))
.filter(Boolean);
if (slugs.length > 0) {
- // Fetch only the explicitly referenced areas in parallel
const results = await Promise.all(
slugs.map((slug) =>
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
@@ -40,7 +56,6 @@
);
areas = results.filter((r): r is WindArea => r !== null);
} else {
- // Fallback: all published areas
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
signal: AbortSignal.timeout(6_000),
});
@@ -68,16 +83,30 @@
{/if}
-
+
+
+ {#if resetMap}
+
+ {/if}
+
{#if browser && MapComponent && mapReady}
{#await MapComponent then Map}
{/if}
-
-
@@ -107,6 +135,57 @@
2. Entwurf 2026
-
Geodaten: © GDI-Th, dl-de/by-2-0
+
+ Abstände:
+ {#each [{ color: "#e35651", label: "200 m" }, { color: "#b08a52", label: "600 m" }, { color: "#436e85", label: "1 km" }] as ring}
+
+
+ {ring.label}
+
+ {/each}
+
+
+