feat: OrganisationsMapBlock — Karte der Bürgerinitiativen
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:
@@ -48,6 +48,7 @@ const EXTRA_ICONS = [
|
||||
'telegram',
|
||||
'import-contacts',
|
||||
'arrow-top-right',
|
||||
'open-in-new',
|
||||
'card-account-details-outline',
|
||||
'qrcode',
|
||||
'sort-alphabetical-ascending',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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\"/>"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user