feat: OrganisationsMapBlock — Karte der Bürgerinitiativen
Deploy / verify (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m13s

Neuer Block-Typ `organisations_map`: Leaflet-Karte mit CircleMarkern für alle
Organisationen die ein location.lat/lng haben. Klick auf Marker öffnet
Detail-Panel (Desktop: rechte Sidebar / Mobile: Bottom Sheet) mit Name,
Standort, Beschreibung und Website-Link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-04 12:03:26 +02:00
parent 88b2df0c05
commit c9ba88ab80
5 changed files with 207 additions and 3 deletions
+1
View File
@@ -48,6 +48,7 @@ const EXTRA_ICONS = [
'telegram',
'import-contacts',
'arrow-top-right',
'open-in-new',
'card-account-details-outline',
'qrcode',
'sort-alphabetical-ascending',
+8
View File
@@ -334,6 +334,14 @@ export interface OrganisationsBlockData {
layout?: BlockLayout;
}
export interface OrganisationsMapBlockData {
_type?: "organisations_map";
_slug?: string;
headline?: string;
organisations?: (string | OrganisationEntry)[];
layout?: BlockLayout;
}
export interface ContactEntry {
_type?: "contact";
_slug?: string;
+4
View File
@@ -22,6 +22,7 @@
import DeadlineBannerBlock from "./blocks/DeadlineBannerBlock.svelte";
import WindkarteBlock from "./blocks/WindkarteBlock.svelte";
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
@@ -49,6 +50,7 @@
LiveStrommixBlockData,
WindMapBlockData,
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
} from "$lib/block-types";
let {
@@ -113,6 +115,8 @@
<WindkarteBlock block={block as WindMapBlockData} />
{:else if type === "adressbuch"}
<AdressbuchBlock block={block as AdressbuchBlockData} />
{:else if type === "organisations_map"}
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
@@ -0,0 +1,191 @@
<script lang="ts">
import "leaflet/dist/leaflet.css";
import { onMount, onDestroy } from "svelte";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { OrganisationsMapBlockData, OrganisationEntry } 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)
: "",
);
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 overflow-hidden rounded-sm border border-stein-200"
style="height: 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 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>
+3 -3
View File
@@ -135,9 +135,6 @@
"youtube": {
"body": "<path fill=\"currentColor\" d=\"m10 15l5.19-3L10 9zm11.56-7.83c.13.47.22 1.1.28 1.9c.07.8.1 1.49.1 2.09L22 12c0 2.19-.16 3.8-.44 4.83c-.25.9-.83 1.48-1.73 1.73c-.47.13-1.33.22-2.65.28c-1.3.07-2.49.1-3.59.1L12 19c-4.19 0-6.8-.16-7.83-.44c-.9-.25-1.48-.83-1.73-1.73c-.13-.47-.22-1.1-.28-1.9c-.07-.8-.1-1.49-.1-2.09L2 12c0-2.19.16-3.8.44-4.83c.25-.9.83-1.48 1.73-1.73c.47-.13 1.33-.22 2.65-.28c1.3-.07 2.49-.1 3.59-.1L12 5c4.19 0 6.8.16 7.83.44c.9.25 1.48.83 1.73 1.73\"/>"
},
"check": {
"body": "<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>"
},
"clock-alert-outline": {
"body": "<path fill=\"currentColor\" d=\"M11 7v6l5.2 3.1l.8-1.2l-4.5-2.7V7zm9 5v6h2v-6zm0 8v2h2v-2zm-2 0c-1.7 1.3-3.7 2-6 2c-5.5 0-10-4.5-10-10S6.5 2 12 2c4.8 0 8.9 3.4 9.8 8h-2.1c-.9-3.4-4-6-7.7-6c-4.4 0-8 3.6-8 8s3.6 8 8 8c2.4 0 4.5-1.1 6-2.7z\"/>"
},
@@ -147,6 +144,9 @@
"content-save-outline": {
"body": "<path fill=\"currentColor\" d=\"M17 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2V7zm2 16H5V5h11.17L19 7.83zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3s3-1.34 3-3s-1.34-3-3-3M6 6h9v4H6z\"/>"
},
"check": {
"body": "<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>"
},
"check-circle": {
"body": "<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>"
},