305 lines
9.9 KiB
Svelte
305 lines
9.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { browser } from "$app/environment";
|
|
import { env } from "$env/dynamic/public";
|
|
import type { WindMapBlockData } from "$lib/block-types";
|
|
import type { WindArea } from "$lib/windkarte";
|
|
import WindAreaPanel from "$lib/components/WindAreaPanel.svelte";
|
|
import InfoAccordion from "$lib/components/InfoAccordion.svelte";
|
|
import { useTranslate, T } from "$lib/translations";
|
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
|
import Icon from "@iconify/svelte";
|
|
import "$lib/iconify-offline";
|
|
|
|
const t = useTranslate();
|
|
|
|
let { block }: { block: WindMapBlockData } = $props();
|
|
|
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
|
|
|
const CMS_BASE = (
|
|
env.PUBLIC_CMS_URL || "http://localhost:3000"
|
|
).replace(/\/$/, "");
|
|
|
|
let areas = $state<WindArea[]>([]);
|
|
let selectedArea = $state<WindArea | null>(null);
|
|
let selectedCenter = $state<[number, number] | null>(null);
|
|
let mapReady = $state(false);
|
|
let initialGebietsNr = $state<string | null>(null);
|
|
let resetMap: (() => void) | null = null;
|
|
let hiddenStatuses = $state<string[]>(["rechtskraeftig"]);
|
|
let hiddenRings = $state<string[]>([]);
|
|
let mapActive = $state(false);
|
|
let blockEl: HTMLDivElement;
|
|
|
|
function toggleStatus(status: string) {
|
|
hiddenStatuses = hiddenStatuses.includes(status)
|
|
? hiddenStatuses.filter((s) => s !== status)
|
|
: [...hiddenStatuses, status];
|
|
}
|
|
|
|
function toggleRing(ring: string) {
|
|
hiddenRings = hiddenRings.includes(ring)
|
|
? hiddenRings.filter((r) => r !== ring)
|
|
: [...hiddenRings, ring];
|
|
}
|
|
|
|
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);
|
|
|
|
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=200`, {
|
|
signal: AbortSignal.timeout(6_000),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const allAreas: WindArea[] = data.items ?? [];
|
|
if (slugs.length > 0) {
|
|
const slugSet = new Set(slugs);
|
|
areas = allAreas.filter((a) => slugSet.has(a._slug));
|
|
} else {
|
|
areas = allAreas;
|
|
}
|
|
}
|
|
} catch {
|
|
// Map renders without CMS metadata — GeoJSON tooltips still work
|
|
}
|
|
mapReady = true;
|
|
if (initialGebietsNr) {
|
|
setTimeout(() => blockEl?.scrollIntoView({ behavior: "smooth", block: "start" }), 100);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
bind:this={blockEl}
|
|
class="wind-map-block {layoutClasses}"
|
|
data-block="WindMap"
|
|
data-block-type="wind_map"
|
|
data-block-slug={block._slug}
|
|
>
|
|
{#if block.title || block.subtitle || block.info_text}
|
|
<div class="mb-4">
|
|
{#if block.title}
|
|
<h2 class="text-xl font-semibold text-stein-800">{block.title}</h2>
|
|
{/if}
|
|
{#if block.subtitle}
|
|
<p class="mt-0.5 text-sm text-stein-500">{block.subtitle}</p>
|
|
{/if}
|
|
{#if block.info_text}
|
|
<div class="markdown prose prose-sm prose-zinc max-w-none mt-2 text-stein-600">{@html block.info_text}</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Daten-Disclaimer aus dem CMS-Feld `hinweis_text` -->
|
|
{#if block.hinweis_text}
|
|
<div class="mb-3">
|
|
<InfoAccordion title="Hinweis zu den Daten" body={block.hinweis_text} />
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="mb-3 rounded-lg border border-wald-200 bg-wald-50 px-3 py-1.5 text-xs text-wald-800">
|
|
👉 <strong>Klicke dein Vorranggebiet</strong> auf der Karte, um Details zu sehen und direkt deine Stellungnahme zu schreiben.
|
|
</p>
|
|
|
|
<div class="map-container relative overflow-hidden rounded-xl border border-stein-200 shadow-sm">
|
|
<!-- Reset button -->
|
|
{#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={t(T.windkarte_reset_aria)}
|
|
title={t(T.windkarte_reset_aria)}
|
|
>
|
|
<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}
|
|
{#await MapComponent then Map}
|
|
<Map
|
|
{areas}
|
|
{initialGebietsNr}
|
|
{hiddenStatuses}
|
|
{hiddenRings}
|
|
onselect={handleSelect}
|
|
onready={(ctrl) => { resetMap = ctrl.reset; }}
|
|
/>
|
|
{/await}
|
|
{:else}
|
|
<div class="flex h-full items-center justify-center bg-stein-50 text-stein-400 text-sm">
|
|
{t(T.windkarte_loading)}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Mobile tap-to-activate overlay (touch devices only) -->
|
|
{#if !mapActive}
|
|
<button
|
|
type="button"
|
|
onclick={() => { mapActive = true; }}
|
|
class="map-activate-overlay"
|
|
aria-label={t(T.windkarte_tap_to_activate)}
|
|
>
|
|
<span class="map-activate-label">
|
|
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M9 11l3 3L22 4"/>
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
|
|
</svg>
|
|
{t(T.windkarte_tap_to_activate)}
|
|
</span>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Panel: bottom-sheet on mobile, side-panel on sm+ -->
|
|
<div class="panel-slide {selectedArea ? 'open' : ''}" style="pointer-events: none; z-index: 1001;">
|
|
<WindAreaPanel
|
|
area={selectedArea}
|
|
center={selectedCenter}
|
|
cmsBase={CMS_BASE}
|
|
onclose={() => handleSelect(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-xs text-stein-400">
|
|
{#each [
|
|
{ status: "rechtskraeftig", color: "#2d7a45", label: t(T.windkarte_legend_rechtskraeftig) },
|
|
{ status: "entwurf_2", color: "#b08a52", label: t(T.windkarte_legend_entwurf_2) },
|
|
] as item}
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleStatus(item.status)}
|
|
class="flex items-center gap-1.5 transition-opacity cursor-pointer select-none
|
|
{hiddenStatuses.includes(item.status) ? 'opacity-35' : 'opacity-100'}"
|
|
title={hiddenStatuses.includes(item.status) ? t(T.windkarte_action_show) : t(T.windkarte_action_hide)}
|
|
>
|
|
<span class="inline-block size-2.5 rounded-sm shrink-0" style="background:{item.color}; opacity:.7"></span>
|
|
<span class="{hiddenStatuses.includes(item.status) ? 'line-through' : ''}">{item.label}</span>
|
|
</button>
|
|
{/each}
|
|
<span class="flex items-center gap-x-3 gap-y-1 flex-wrap text-stein-300">
|
|
<span class="text-stein-400">{t(T.windkarte_legend_distances)}</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}
|
|
{#each [{ key: "2km", color: "#5e6fa3", label: "2 km" }, { key: "5km", color: "#8a567a", label: "5 km" }] as ring}
|
|
<button
|
|
type="button"
|
|
onclick={() => toggleRing(ring.key)}
|
|
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity
|
|
{hiddenRings.includes(ring.key) ? 'opacity-35' : 'opacity-100'}"
|
|
title={hiddenRings.includes(ring.key) ? t(T.windkarte_action_show) : t(T.windkarte_action_hide)}
|
|
>
|
|
<span class="dashed-line {hiddenRings.includes(ring.key) ? 'opacity-40' : ''}" style="border-color:{ring.color}"></span>
|
|
<span class="{hiddenRings.includes(ring.key) ? 'line-through' : ''}">{ring.label}</span>
|
|
</button>
|
|
{/each}
|
|
</span>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<style>
|
|
.map-container {
|
|
height: clamp(460px, 75vh, 620px);
|
|
}
|
|
|
|
/* Panel: mobile = bottom sheet, sm+ = right side panel */
|
|
.panel-slide {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 52%;
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* Touch-only overlay — hidden on hover-capable devices (desktop/mouse) */
|
|
.map-activate-overlay {
|
|
display: none;
|
|
}
|
|
@media (hover: none) {
|
|
.map-activate-overlay {
|
|
display: flex;
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 490;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.25);
|
|
cursor: pointer;
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
}
|
|
.map-activate-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.92);
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
|
color: #333735;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
padding: 0.625rem 1rem;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.dashed-line {
|
|
display: inline-block;
|
|
width: 18px;
|
|
height: 0;
|
|
border-top: 2px dashed;
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|