feat(windkarte): Karten-UX Verbesserungen
Deploy / verify (push) Successful in 54s
Deploy / deploy (push) Successful in 1m7s

- Polygon-Labels ab Zoom 13 mit zoom-proportionaler Schriftgröße
- Hover-Highlight, Deselect per Hintergrundklick
- Mobile Bottom-Sheet / Desktop Side-Panel (CSS-only)
- Share-Link via URL-Hash (#W-10), auto-select on load
- Share-Button + OSM-Link im Panel
- Reset-Button auf Karte
- Buffer-Spinner während Turf-Berechnung
- Leaflet-Attribution nach links (kollidierte mit Panel)
- fitBounds mit asymmetrischem Padding + maxZoom 14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-13 10:24:56 +02:00
parent 6fef86fc6b
commit 0e28a7e334
3 changed files with 308 additions and 58 deletions
+175 -44
View File
@@ -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<string | null>(null);
let geojsonLayer: import("leaflet").GeoJSON | null = null;
let buffering = $state(false);
const COLORS: Record<string, { fill: string; stroke: string }> = {
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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a> | Geodaten: © GDI-Th, dl-de/by-2-0',
subdomains: "abcd",
maxZoom: 16,
'&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();
});
try {
const geoRes = await fetch(geojsonUrl);
const geo: GeoJSON.FeatureCollection = await geoRes.json();
const layerMap = new Map<string, { feature: GeoJSON.Feature; layer: import("leaflet").Layer }>();
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: `<span class="windkarte-label">${nr}</span>`,
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<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, true);
}
}
} catch (e) {
console.error("GeoJSON laden fehlgeschlagen:", e);
}
@@ -134,6 +233,17 @@
<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;
@@ -148,4 +258,25 @@
: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>