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} { selectedArea = area; }} + {initialGebietsNr} + onselect={handleSelect} + onready={(ctrl) => { resetMap = ctrl.reset; }} /> {/await} {:else} @@ -86,13 +115,12 @@
{/if} - -
+ +
{ selectedArea = null; }} + center={selectedCenter} + onclose={() => handleSelect(null)} />
@@ -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} +
+ +