feat(windkarte): Karten-UX Verbesserungen
- 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:
@@ -5,9 +5,11 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
area,
|
area,
|
||||||
|
center,
|
||||||
onclose,
|
onclose,
|
||||||
}: {
|
}: {
|
||||||
area: WindArea | null;
|
area: WindArea | null;
|
||||||
|
center: [number, number] | null;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -28,6 +30,22 @@
|
|||||||
if (s._slug) return `/post/${s._slug}`;
|
if (s._slug) return `/post/${s._slug}`;
|
||||||
return null;
|
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)}`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if area}
|
{#if area}
|
||||||
@@ -121,6 +139,28 @@
|
|||||||
Stellungnahme-Vorlage noch nicht verknüpft
|
Stellungnahme-Vorlage noch nicht verknüpft
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onclick={copyLink}
|
||||||
|
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Icon icon={copied ? "mdi:check" : "mdi:link-variant"} class="size-3.5 shrink-0" />
|
||||||
|
{copied ? "Kopiert!" : "Link kopieren"}
|
||||||
|
</button>
|
||||||
|
{#if osmHref(center)}
|
||||||
|
<a
|
||||||
|
href={osmHref(center)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-stein-200 px-2 py-2 text-xs font-medium text-stein-600 hover:bg-stein-50 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Icon icon="mdi:map-outline" class="size-3.5 shrink-0" />
|
||||||
|
In OSM öffnen
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,17 +7,23 @@
|
|||||||
areas,
|
areas,
|
||||||
geojsonUrl,
|
geojsonUrl,
|
||||||
onselect,
|
onselect,
|
||||||
|
onready = null,
|
||||||
|
initialGebietsNr = null,
|
||||||
}: {
|
}: {
|
||||||
areas: WindArea[];
|
areas: WindArea[];
|
||||||
geojsonUrl: string;
|
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();
|
} = $props();
|
||||||
|
|
||||||
let mapEl: HTMLDivElement;
|
let mapEl: HTMLDivElement;
|
||||||
let map: import("leaflet").Map | null = null;
|
let map: import("leaflet").Map | null = null;
|
||||||
let bufferLayer: import("leaflet").LayerGroup | null = null;
|
let bufferLayer: import("leaflet").LayerGroup | null = null;
|
||||||
|
let labelLayer: import("leaflet").LayerGroup | null = null;
|
||||||
let selectedSlug = $state<string | null>(null);
|
let selectedSlug = $state<string | null>(null);
|
||||||
let geojsonLayer: import("leaflet").GeoJSON | null = null;
|
let geojsonLayer: import("leaflet").GeoJSON | null = null;
|
||||||
|
let buffering = $state(false);
|
||||||
|
|
||||||
const COLORS: Record<string, { fill: string; stroke: string }> = {
|
const COLORS: Record<string, { fill: string; stroke: string }> = {
|
||||||
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
|
rechtskraeftig: { fill: "#2d7a45", stroke: "#1a4d28" },
|
||||||
@@ -25,60 +31,116 @@
|
|||||||
entwurf_3: { fill: "#436e85", stroke: "#264150" },
|
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;
|
const c = COLORS[status] ?? COLORS.rechtskraeftig;
|
||||||
return {
|
return {
|
||||||
color: c.stroke,
|
color: c.stroke,
|
||||||
fillColor: c.fill,
|
fillColor: c.fill,
|
||||||
weight: selected ? 2.5 : 1.5,
|
weight: selected ? 2.5 : 1.5,
|
||||||
fillOpacity: selected ? 0.45 : 0.25,
|
fillOpacity: selected ? 0.55 : hovered ? 0.42 : 0.25,
|
||||||
opacity: 1,
|
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) {
|
async function drawBuffer(L: typeof import("leaflet"), feature: GeoJSON.Feature) {
|
||||||
|
buffering = true;
|
||||||
const { buffer } = await import("@turf/buffer");
|
const { buffer } = await import("@turf/buffer");
|
||||||
if (bufferLayer) bufferLayer.clearLayers();
|
if (bufferLayer) bufferLayer.clearLayers();
|
||||||
else bufferLayer = L.layerGroup().addTo(map!);
|
else bufferLayer = L.layerGroup().addTo(map!);
|
||||||
|
|
||||||
const distances = [
|
for (const { m, color, dash } of [
|
||||||
{ m: 200, label: "200 m", color: "#e35651", dash: "4 4" },
|
{ m: 200, color: "#e35651", dash: "4 4" },
|
||||||
{ m: 600, label: "600 m", color: "#b08a52", dash: "6 3" },
|
{ m: 600, color: "#b08a52", dash: "6 3" },
|
||||||
{ m: 1000, label: "1000 m", color: "#436e85", dash: "8 4" },
|
{ m: 1000, color: "#436e85", dash: "8 4" },
|
||||||
];
|
]) {
|
||||||
for (const { m, color, dash } of distances) {
|
|
||||||
const buffered = buffer(feature, m / 1000, { units: "kilometers" });
|
const buffered = buffer(feature, m / 1000, { units: "kilometers" });
|
||||||
if (!buffered) continue;
|
if (!buffered) continue;
|
||||||
L.geoJSON(buffered, {
|
L.geoJSON(buffered, {
|
||||||
style: { color, fillColor: color, fillOpacity: 0.05, weight: 1.5, dashArray: dash },
|
style: { color, fillColor: color, fillOpacity: 0.05, weight: 1.5, dashArray: dash },
|
||||||
}).addTo(bufferLayer!);
|
}).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 () => {
|
onMount(async () => {
|
||||||
const L = await import("leaflet");
|
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 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 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 shadowUrl = new URL("leaflet/dist/images/marker-shadow.png", import.meta.url).href;
|
|
||||||
L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl: icon2xUrl, shadowUrl });
|
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:
|
attribution:
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a> | Geodaten: © GDI-Th, dl-de/by-2-0',
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende | Geodaten: © GDI-Th, dl-de/by-2-0',
|
||||||
subdomains: "abcd",
|
maxZoom: 19,
|
||||||
maxZoom: 16,
|
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
const byNr = Object.fromEntries(areas.map((a) => [a.gebiets_nr, a]));
|
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 {
|
try {
|
||||||
const geoRes = await fetch(geojsonUrl);
|
const geoRes = await fetch(geojsonUrl);
|
||||||
const geo: GeoJSON.FeatureCollection = await geoRes.json();
|
const geo: GeoJSON.FeatureCollection = await geoRes.json();
|
||||||
|
|
||||||
|
const layerMap = new Map<string, { feature: GeoJSON.Feature; layer: import("leaflet").Layer }>();
|
||||||
|
|
||||||
geojsonLayer = L.geoJSON(geo, {
|
geojsonLayer = L.geoJSON(geo, {
|
||||||
style: (f) => {
|
style: (f) => {
|
||||||
const s = f?.properties?.status ?? "rechtskraeftig";
|
const s = f?.properties?.status ?? "rechtskraeftig";
|
||||||
@@ -88,39 +150,76 @@
|
|||||||
const cmsArea = byNr[feature.properties?.name] ?? null;
|
const cmsArea = byNr[feature.properties?.name] ?? null;
|
||||||
if (cmsArea) feature.properties.cms = cmsArea;
|
if (cmsArea) feature.properties.cms = cmsArea;
|
||||||
|
|
||||||
layer.on("click", async () => {
|
const name = feature.properties?.name ?? "";
|
||||||
const name = feature.properties?.name;
|
layerMap.set(name, { feature, layer });
|
||||||
const prev = selectedSlug;
|
|
||||||
selectedSlug = name ?? null;
|
|
||||||
|
|
||||||
// Re-style all layers
|
// Hover highlight
|
||||||
geojsonLayer?.eachLayer((l) => {
|
layer.on("mouseover", () => {
|
||||||
const f = (l as import("leaflet").Path & { feature?: GeoJSON.Feature }).feature;
|
if (feature.properties?.name !== selectedSlug) {
|
||||||
if (!f) return;
|
const s = feature.properties?.status ?? "rechtskraeftig";
|
||||||
const s = f.properties?.status ?? "rechtskraeftig";
|
(layer as import("leaflet").Path).setStyle(style(s, false, true));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Zoom to feature if first click or switching areas
|
layer.on("mouseout", () => {
|
||||||
if (prev !== name) {
|
if (feature.properties?.name !== selectedSlug) {
|
||||||
map!.fitBounds((layer as import("leaflet").Polygon).getBounds(), { padding: [60, 60] });
|
const s = feature.properties?.status ?? "rechtskraeftig";
|
||||||
|
(layer as import("leaflet").Path).setStyle(style(s, false, false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tooltip with name
|
layer.on("click", async (e) => {
|
||||||
const label = feature.properties?.bezeichnung ?? feature.properties?.name ?? "";
|
L.DomEvent.stopPropagation(e);
|
||||||
layer.bindTooltip(label, { sticky: true, className: "windkarte-tooltip" });
|
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);
|
}).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) {
|
} catch (e) {
|
||||||
console.error("GeoJSON laden fehlgeschlagen:", e);
|
console.error("GeoJSON laden fehlgeschlagen:", e);
|
||||||
}
|
}
|
||||||
@@ -134,6 +233,17 @@
|
|||||||
|
|
||||||
<div bind:this={mapEl} class="h-full w-full"></div>
|
<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>
|
<style>
|
||||||
:global(.windkarte-tooltip) {
|
:global(.windkarte-tooltip) {
|
||||||
background: white;
|
background: white;
|
||||||
@@ -148,4 +258,25 @@
|
|||||||
:global(.windkarte-tooltip::before) {
|
:global(.windkarte-tooltip::before) {
|
||||||
display: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -16,21 +16,37 @@
|
|||||||
|
|
||||||
let areas = $state<WindArea[]>([]);
|
let areas = $state<WindArea[]>([]);
|
||||||
let selectedArea = $state<WindArea | null>(null);
|
let selectedArea = $state<WindArea | null>(null);
|
||||||
|
let selectedCenter = $state<[number, number] | null>(null);
|
||||||
let mapReady = $state(false);
|
let mapReady = $state(false);
|
||||||
|
let initialGebietsNr = $state<string | null>(null);
|
||||||
|
let resetMap: (() => void) | null = null;
|
||||||
|
|
||||||
// Dynamic import: WindkarteMap needs window/document
|
|
||||||
const MapComponent = browser
|
const MapComponent = browser
|
||||||
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
|
? import("$lib/components/WindkarteMap.svelte").then((m) => m.default)
|
||||||
: null;
|
: 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 () => {
|
onMount(async () => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
if (hash) initialGebietsNr = decodeURIComponent(hash);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const slugs = (block.areas ?? [])
|
const slugs = (block.areas ?? [])
|
||||||
.map((a) => (typeof a === "string" ? a : a._slug))
|
.map((a) => (typeof a === "string" ? a : a._slug))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (slugs.length > 0) {
|
if (slugs.length > 0) {
|
||||||
// Fetch only the explicitly referenced areas in parallel
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
slugs.map((slug) =>
|
slugs.map((slug) =>
|
||||||
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
|
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
|
||||||
@@ -40,7 +56,6 @@
|
|||||||
);
|
);
|
||||||
areas = results.filter((r): r is WindArea => r !== null);
|
areas = results.filter((r): r is WindArea => r !== null);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: all published areas
|
|
||||||
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
|
const res = await fetch(`${CMS_BASE}/api/content/wind_area?limit=100`, {
|
||||||
signal: AbortSignal.timeout(6_000),
|
signal: AbortSignal.timeout(6_000),
|
||||||
});
|
});
|
||||||
@@ -68,16 +83,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div class="map-container relative overflow-hidden rounded-xl border border-stein-200 shadow-sm">
|
||||||
class="relative overflow-hidden rounded-xl border border-stein-200 shadow-sm"
|
<!-- Reset button -->
|
||||||
style="height: 520px;"
|
{#if resetMap}
|
||||||
>
|
<button
|
||||||
|
onclick={() => { resetMap?.(); handleSelect(null); }}
|
||||||
|
class="pointer-events-auto absolute top-2.5 right-2.5 z-[500] rounded-md bg-white/90 p-1.5 shadow-md text-stein-500 hover:text-stein-800 hover:bg-white transition-colors"
|
||||||
|
aria-label="Übersicht zurücksetzen"
|
||||||
|
title="Übersicht zurücksetzen"
|
||||||
|
>
|
||||||
|
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
|
<path d="M3 3v5h5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if browser && MapComponent && mapReady}
|
{#if browser && MapComponent && mapReady}
|
||||||
{#await MapComponent then Map}
|
{#await MapComponent then Map}
|
||||||
<Map
|
<Map
|
||||||
{areas}
|
{areas}
|
||||||
geojsonUrl={GEOJSON_URL}
|
geojsonUrl={GEOJSON_URL}
|
||||||
onselect={(area) => { selectedArea = area; }}
|
{initialGebietsNr}
|
||||||
|
onselect={handleSelect}
|
||||||
|
onready={(ctrl) => { resetMap = ctrl.reset; }}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -86,13 +115,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Sliding panel -->
|
<!-- Panel: bottom-sheet on mobile, side-panel on sm+ -->
|
||||||
<div
|
<div class="panel-slide {selectedArea ? 'open' : ''}" style="pointer-events: none; z-index: 500;">
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 z-[500] w-72 transition-transform duration-300 ease-in-out {selectedArea ? 'translate-x-0' : 'translate-x-full'}"
|
|
||||||
>
|
|
||||||
<WindAreaPanel
|
<WindAreaPanel
|
||||||
area={selectedArea}
|
area={selectedArea}
|
||||||
onclose={() => { selectedArea = null; }}
|
center={selectedCenter}
|
||||||
|
onclose={() => handleSelect(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +135,57 @@
|
|||||||
<span class="inline-block size-2.5 rounded-sm" style="background:#b08a52; opacity:.7"></span>
|
<span class="inline-block size-2.5 rounded-sm" style="background:#b08a52; opacity:.7"></span>
|
||||||
2. Entwurf 2026
|
2. Entwurf 2026
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-auto">Geodaten: © GDI-Th, dl-de/by-2-0</span>
|
<span class="flex items-center gap-x-3 gap-y-1 flex-wrap text-stein-300">
|
||||||
|
<span class="text-stein-400">Abstände:</span>
|
||||||
|
{#each [{ color: "#e35651", label: "200 m" }, { color: "#b08a52", label: "600 m" }, { color: "#436e85", label: "1 km" }] as ring}
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<span class="dashed-line" style="border-color:{ring.color}"></span>
|
||||||
|
{ring.label}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.map-container {
|
||||||
|
height: clamp(360px, 58vh, 580px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel: mobile = bottom sheet, sm+ = right side panel */
|
||||||
|
.panel-slide {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 72%;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
.panel-slide.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.panel-slide {
|
||||||
|
top: 0;
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: auto;
|
||||||
|
width: 18rem;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
.panel-slide.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashed-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 18px;
|
||||||
|
height: 0;
|
||||||
|
border-top: 2px dashed;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user