226 lines
7.3 KiB
Svelte
226 lines
7.3 KiB
Svelte
<script lang="ts">
|
|
import "leaflet/dist/leaflet.css";
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
|
import type { OrganisationsMapBlockData, OrganisationEntry, ContactEntry } from "$lib/block-types";
|
|
import Icon from "@iconify/svelte";
|
|
import "$lib/iconify-offline";
|
|
import { marked } from "$lib/markdown-safe";
|
|
|
|
let { block }: { block: OrganisationsMapBlockData } = $props();
|
|
|
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
|
|
|
const orgs = $derived(
|
|
(block.organisations ?? []).filter(
|
|
(o): o is OrganisationEntry =>
|
|
typeof o === "object" &&
|
|
o !== null &&
|
|
typeof o.location?.lat === "number" &&
|
|
typeof o.location?.lng === "number",
|
|
),
|
|
);
|
|
|
|
let mapEl: HTMLDivElement;
|
|
let map: import("leaflet").Map | null = null;
|
|
let selectedOrg = $state<OrganisationEntry | null>(null);
|
|
// Keep reference to highlight active marker
|
|
let activeMarker: import("leaflet").CircleMarker | null = null;
|
|
|
|
const descriptionHtml = $derived(
|
|
selectedOrg?.description
|
|
? (marked.parse(selectedOrg.description) as string)
|
|
: "",
|
|
);
|
|
|
|
const contacts = $derived(
|
|
(selectedOrg?.contacts ?? []).filter(
|
|
(c): c is ContactEntry => typeof c === "object" && c !== null && !!c.name,
|
|
),
|
|
);
|
|
|
|
function getLink(org: OrganisationEntry): string | null {
|
|
if (!org.link || typeof org.link === "string") return null;
|
|
return org.link.url ?? null;
|
|
}
|
|
|
|
function getLinkLabel(org: OrganisationEntry): string {
|
|
if (!org.link || typeof org.link === "string") return "Website";
|
|
return org.link.linkName ?? "Website";
|
|
}
|
|
|
|
function closePanel() {
|
|
selectedOrg = null;
|
|
if (activeMarker) {
|
|
activeMarker.setStyle({ fillColor: "#3d6b4f", radius: 8 });
|
|
activeMarker = null;
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
const L = (await import("leaflet")).default;
|
|
|
|
map = L.map(mapEl, { zoomControl: true });
|
|
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
maxZoom: 18,
|
|
}).addTo(map);
|
|
|
|
for (const org of orgs) {
|
|
const { lat, lng } = org.location!;
|
|
const marker = L.circleMarker([lat!, lng!], {
|
|
radius: 8,
|
|
fillColor: "#3d6b4f",
|
|
color: "#fff",
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.9,
|
|
})
|
|
.bindTooltip(org.name ?? "", {
|
|
permanent: false,
|
|
direction: "top",
|
|
className: "orgs-map-tip",
|
|
offset: [0, -6],
|
|
})
|
|
.addTo(map!)
|
|
.on("click", () => {
|
|
if (activeMarker && activeMarker !== marker) {
|
|
activeMarker.setStyle({ fillColor: "#3d6b4f", radius: 8 });
|
|
}
|
|
marker.setStyle({ fillColor: "#1a4030", radius: 11 });
|
|
activeMarker = marker;
|
|
selectedOrg = org;
|
|
});
|
|
}
|
|
|
|
if (orgs.length > 0) {
|
|
const bounds = L.latLngBounds(
|
|
orgs.map((o) => [o.location!.lat!, o.location!.lng!] as [number, number]),
|
|
);
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 11 });
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
map?.remove();
|
|
map = null;
|
|
});
|
|
</script>
|
|
|
|
<div class="not-prose {layoutClasses}" data-block-type="organisations_map">
|
|
{#if block.headline}
|
|
<h2 class="mb-3 text-xl font-bold text-stein-900">{block.headline}</h2>
|
|
{/if}
|
|
|
|
<div
|
|
class="relative h-[320px] overflow-hidden rounded-sm border border-stein-200 sm:h-[clamp(400px,65vh,560px)]"
|
|
>
|
|
<!-- Leaflet map fills the container -->
|
|
<div bind:this={mapEl} class="absolute inset-0"></div>
|
|
|
|
<!-- Detail panel slides in from right (desktop) / bottom (mobile) -->
|
|
{#if selectedOrg}
|
|
<div
|
|
class="absolute bottom-0 left-0 right-0 z-[1000] max-h-[55%] overflow-y-auto border-t border-stein-200 bg-white shadow-lg
|
|
lg:bottom-0 lg:left-auto lg:right-0 lg:top-0 lg:w-72 lg:max-h-full lg:border-t-0 lg:border-l"
|
|
>
|
|
<div class="p-4">
|
|
<div class="mb-3 flex items-start justify-between gap-2">
|
|
<p class="text-sm font-semibold leading-snug text-stein-900">
|
|
{selectedOrg.name}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onclick={closePanel}
|
|
class="shrink-0 text-stein-400 transition-colors hover:text-stein-700"
|
|
aria-label="Schließen"
|
|
>
|
|
<Icon icon="mdi:close" class="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{#if selectedOrg.location?.text}
|
|
<p class="mb-3 flex items-center gap-1 text-xs text-stein-500">
|
|
<Icon icon="mdi:map-marker-outline" class="size-3.5 shrink-0" />
|
|
{selectedOrg.location.text}
|
|
</p>
|
|
{/if}
|
|
|
|
{#if selectedOrg.resolvedLogoSrc}
|
|
<img
|
|
src={selectedOrg.resolvedLogoSrc}
|
|
alt={selectedOrg.name ?? ""}
|
|
class="mb-3 max-h-10 object-contain"
|
|
/>
|
|
{/if}
|
|
|
|
{#if descriptionHtml}
|
|
<div class="markdown prose prose-sm mb-3 max-w-none text-xs text-stein-600">
|
|
{@html descriptionHtml}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if contacts.length > 0}
|
|
<div class="mb-3 space-y-2 border-t border-stein-100 pt-3">
|
|
<p class="text-[0.65rem] font-semibold uppercase tracking-wider text-stein-500">Ansprechpartner</p>
|
|
{#each contacts as c}
|
|
<div class="text-xs">
|
|
<p class="font-semibold text-stein-800">{c.name}</p>
|
|
{#if c.role}
|
|
<p class="text-stein-500">{c.role}</p>
|
|
{/if}
|
|
{#if c.email}
|
|
<a href="mailto:{c.email}" class="mt-0.5 flex items-center gap-1 text-wald-700 hover:underline">
|
|
<Icon icon="mdi:email-outline" class="size-3 shrink-0" />
|
|
<span class="truncate">{c.email}</span>
|
|
</a>
|
|
{/if}
|
|
{#if c.phone}
|
|
<a href="tel:{c.phone.replace(/\s+/g, '')}" class="mt-0.5 flex items-center gap-1 text-wald-700 hover:underline">
|
|
<Icon icon="mdi:phone" class="size-3 shrink-0" />
|
|
{c.phone}
|
|
</a>
|
|
{/if}
|
|
{#if c.note}
|
|
<p class="mt-0.5 italic text-stein-500">{c.note}</p>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if getLink(selectedOrg)}
|
|
<a
|
|
href={getLink(selectedOrg)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="btn-outline btn-sm w-full justify-center no-underline!"
|
|
>
|
|
<Icon icon="mdi:open-in-new" class="size-3.5" />
|
|
{getLinkLabel(selectedOrg)}
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<p class="mt-1.5 text-xs text-stein-400">{orgs.length} Bürgerinitiativen · Marker anklicken für Details</p>
|
|
</div>
|
|
|
|
<style>
|
|
:global(.orgs-map-tip) {
|
|
background: #1c1917;
|
|
border: none;
|
|
border-radius: 3px;
|
|
color: #fafaf9;
|
|
font-size: 0.65rem;
|
|
padding: 2px 6px;
|
|
white-space: nowrap;
|
|
}
|
|
:global(.orgs-map-tip::before) {
|
|
border-top-color: #1c1917 !important;
|
|
}
|
|
</style>
|