diff --git a/config/services.yaml b/config/services.yaml
index 5e370e1..b10c90e 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -14,3 +14,5 @@ services:
tags:
- { name: contao.callback, table: tl_organization, target: config.onbeforesubmit, method: onBeforeSubmit }
- { name: contao.callback, table: tl_location, target: config.onbeforesubmit, method: onBeforeSubmit }
+
+ MummertMedia\EventManagerBundle\Service\MapModuleDataProvider: ~
diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php
index 48a80de..0e5296a 100644
--- a/contao/dca/tl_module.php
+++ b/contao/dca/tl_module.php
@@ -9,6 +9,7 @@ use Contao\StringUtil;
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_organizations'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['member_events'] = '{title_legend},name,headline,type;{eventmanager_legend},editPage;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_filter'] = '{title_legend},name,headline,type;{eventmanager_legend},cal_calendar,eventListDomId;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
+$GLOBALS['TL_DCA']['tl_module']['palettes']['eventmanager_map'] = '{title_legend},name,headline,type;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['organization_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,logoFolder,organizationTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
$GLOBALS['TL_DCA']['tl_module']['palettes']['event_edit'] = '{title_legend},name,headline,type;{eventmanager_legend},listPage,eventFolder,termsPage,frontendAuthorId,frontendArchiveId,eventTypeTags;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';
diff --git a/contao/languages/de/modules.php b/contao/languages/de/modules.php
index 084d473..0ce7570 100644
--- a/contao/languages/de/modules.php
+++ b/contao/languages/de/modules.php
@@ -10,4 +10,5 @@ $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['Meine Organisationen', 'L
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Organisation bearbeiten', 'Bearbeitungsformular für eine Organisation.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['Meine Veranstaltungen', 'Listet Veranstaltungen der zugeordneten Organisationen auf.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Eventfilter', 'Filter für kommende Veranstaltungen (Tags, Orte, Veranstalter).'];
+$GLOBALS['TL_LANG']['FMD']['eventmanager_map'] = ['Eventkarte', 'Zeigt Veranstaltungsorte, Organisationen und Events auf einer Karte an.'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Veranstaltung bearbeiten', 'Bearbeitungsformular für eine Veranstaltung.'];
diff --git a/contao/languages/en/modules.php b/contao/languages/en/modules.php
index fc33698..7276418 100644
--- a/contao/languages/en/modules.php
+++ b/contao/languages/en/modules.php
@@ -10,4 +10,5 @@ $GLOBALS['TL_LANG']['FMD']['member_organizations'] = ['My organizations', 'Lists
$GLOBALS['TL_LANG']['FMD']['organization_edit'] = ['Edit organization', 'Edit form for one organization.'];
$GLOBALS['TL_LANG']['FMD']['member_events'] = ['My events', 'Lists events of the member organizations.'];
$GLOBALS['TL_LANG']['FMD']['event_filter'] = ['Event filter', 'Filters upcoming events (tags, locations, organizers).'];
+$GLOBALS['TL_LANG']['FMD']['eventmanager_map'] = ['Event map', 'Displays locations, organizations and events on a map.'];
$GLOBALS['TL_LANG']['FMD']['event_edit'] = ['Edit event', 'Edit form for one event.'];
diff --git a/contao/templates/frontend/event_map.html.twig b/contao/templates/frontend/event_map.html.twig
new file mode 100644
index 0000000..2fceea8
--- /dev/null
+++ b/contao/templates/frontend/event_map.html.twig
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/public/assets/map-module.js b/public/assets/map-module.js
new file mode 100644
index 0000000..fff752e
--- /dev/null
+++ b/public/assets/map-module.js
@@ -0,0 +1,331 @@
+import Spiderfy from 'https://cdn.jsdelivr.net/npm/@nazka/map-gl-js-spiderfy@1.2.10/dist/index.modern.js';
+
+const MAP_SELECTOR = '[data-eventmanager-map="1"]';
+
+const escapeHtml = (value) => {
+ const stringValue = String(value ?? '');
+
+ return stringValue
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+};
+
+const parseItems = (container) => {
+ const dataElementId = container.dataset.mapDataId || '';
+
+ if (!dataElementId) {
+ return [];
+ }
+
+ const payload = document.getElementById(dataElementId);
+
+ if (!payload) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(payload.textContent || '[]');
+
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+
+ const unique = new Map();
+
+ parsed.forEach((item) => {
+ if (!item || !['event', 'location', 'organisation'].includes(item.type)) {
+ return;
+ }
+
+ const id = Number(item.id || 0);
+ const latitude = Number(item.latitude);
+ const longitude = Number(item.longitude);
+
+ if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
+ return;
+ }
+
+ unique.set(`${item.type}:${id}`, {
+ type: item.type,
+ id,
+ title: String(item.title || ''),
+ latitude,
+ longitude,
+ extra: item.extra && typeof item.extra === 'object' ? item.extra : {},
+ });
+ });
+
+ return Array.from(unique.values());
+ } catch (error) {
+ return [];
+ }
+};
+
+const popupHtmlFor = (item) => {
+ if (item.type === 'event') {
+ const locationTitle = escapeHtml(item.extra.locationTitle || '');
+ const startDate = escapeHtml(item.extra.startDate || '');
+
+ return [
+ `${escapeHtml(item.title)}`,
+ locationTitle ? `${locationTitle}
` : '',
+ startDate ? `${startDate}
` : '',
+ ].join('');
+ }
+
+ return `${escapeHtml(item.title)}`;
+};
+
+const toFeature = (item) => ({
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [item.longitude, item.latitude],
+ },
+ properties: {
+ id: item.id,
+ type: item.type,
+ title: item.title,
+ popupHtml: popupHtmlFor(item),
+ },
+});
+
+const addSimpleMarker = (map, item) => {
+ const popup = new maplibregl.Popup({ offset: 20 }).setHTML(popupHtmlFor(item));
+
+ new maplibregl.Marker()
+ .setLngLat([item.longitude, item.latitude])
+ .setPopup(popup)
+ .addTo(map);
+};
+
+const initEventLayers = (map, eventItems) => {
+ const sourceId = 'eventmanager-events-source';
+ const clusterLayerId = 'eventmanager-events-clusters';
+ const clusterCountLayerId = 'eventmanager-events-cluster-count';
+ const unclusteredLayerId = 'eventmanager-events-unclustered';
+
+ const features = eventItems.map(toFeature);
+
+ map.addSource(sourceId, {
+ type: 'geojson',
+ data: {
+ type: 'FeatureCollection',
+ features,
+ },
+ cluster: true,
+ clusterMaxZoom: 22,
+ clusterRadius: 22,
+ });
+
+ map.addLayer({
+ id: clusterLayerId,
+ type: 'circle',
+ source: sourceId,
+ filter: ['has', 'point_count'],
+ paint: {
+ 'circle-radius': 18,
+ 'circle-opacity': 0.8,
+ },
+ });
+
+ map.addLayer({
+ id: clusterCountLayerId,
+ type: 'symbol',
+ source: sourceId,
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': ['get', 'point_count'],
+ 'text-size': 12,
+ },
+ });
+
+ map.addLayer({
+ id: unclusteredLayerId,
+ type: 'circle',
+ source: sourceId,
+ filter: ['!', ['has', 'point_count']],
+ paint: {
+ 'circle-radius': 9,
+ 'circle-stroke-width': 2,
+ },
+ });
+
+ const openEventPopup = (feature, coordinatesOverride = null) => {
+ const popupHtml = feature?.properties?.popupHtml || '';
+ const rawCoords = coordinatesOverride || feature?.geometry?.coordinates || null;
+
+ if (!Array.isArray(rawCoords) || rawCoords.length !== 2) {
+ return;
+ }
+
+ const [lng, lat] = rawCoords.map((coord) => Number(coord));
+
+ if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
+ return;
+ }
+
+ new maplibregl.Popup({ offset: 15 })
+ .setLngLat([lng, lat])
+ .setHTML(String(popupHtml))
+ .addTo(map);
+ };
+
+ map.on('click', unclusteredLayerId, (event) => {
+ const feature = event.features && event.features[0] ? event.features[0] : null;
+
+ if (!feature) {
+ return;
+ }
+
+ openEventPopup(feature, event.lngLat ? [event.lngLat.lng, event.lngLat.lat] : null);
+ });
+
+ map.on('mouseenter', unclusteredLayerId, () => {
+ map.getCanvas().style.cursor = 'pointer';
+ });
+
+ map.on('mouseleave', unclusteredLayerId, () => {
+ map.getCanvas().style.cursor = '';
+ });
+
+ if (typeof Spiderfy === 'function') {
+ const spiderfy = new Spiderfy(map, {
+ closeOnLeafClick: true,
+ onLeafClick: (feature, spiderEvent) => {
+ const clickCoordinates = spiderEvent?.lngLat
+ ? [spiderEvent.lngLat.lng, spiderEvent.lngLat.lat]
+ : null;
+
+ openEventPopup(feature, clickCoordinates);
+ },
+ });
+
+ spiderfy.applyTo(clusterLayerId);
+ } else {
+ map.on('click', clusterLayerId, (event) => {
+ const feature = event.features && event.features[0] ? event.features[0] : null;
+
+ if (!feature) {
+ return;
+ }
+
+ const clusterId = feature.properties?.cluster_id;
+
+ if (undefined === clusterId || null === clusterId) {
+ return;
+ }
+
+ const source = map.getSource(sourceId);
+
+ if (!source || typeof source.getClusterExpansionZoom !== 'function') {
+ return;
+ }
+
+ source.getClusterExpansionZoom(clusterId, (error, zoom) => {
+ if (error) {
+ return;
+ }
+
+ map.easeTo({
+ center: event.lngLat,
+ zoom,
+ });
+ });
+ });
+ }
+
+ map.on('mouseenter', clusterLayerId, () => {
+ map.getCanvas().style.cursor = 'pointer';
+ });
+
+ map.on('mouseleave', clusterLayerId, () => {
+ map.getCanvas().style.cursor = '';
+ });
+
+};
+
+const initSingleMap = (container) => {
+ if (container.dataset.mapInitialized === '1') {
+ return;
+ }
+
+ const items = parseItems(container);
+
+ if (!items.length || typeof maplibregl === 'undefined') {
+ return;
+ }
+
+ container.dataset.mapInitialized = '1';
+
+ if (typeof pmtiles !== 'undefined' && !window.__eventmanagerPmtilesRegistered) {
+ const protocol = new pmtiles.Protocol();
+ maplibregl.addProtocol('pmtiles', protocol.tile);
+ window.__eventmanagerPmtilesRegistered = true;
+ }
+
+ const style = container.dataset.mapStyle || 'https://maps.mummert.media/metadaten/world-light.json';
+ const map = new maplibregl.Map({
+ container,
+ style,
+ zoom: 12,
+ pitch: 0,
+ bearing: 0,
+ center: [items[0].longitude, items[0].latitude],
+ });
+
+ map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
+
+ const fitBounds = (coordinates) => {
+ const points = coordinates.filter((coord) => Array.isArray(coord) && coord.length === 2);
+
+ if (!points.length) {
+ return;
+ }
+
+ if (1 === points.length) {
+ map.setCenter(points[0]);
+ map.setZoom(14);
+
+ return;
+ }
+
+ const bounds = new maplibregl.LngLatBounds();
+ points.forEach((coord) => bounds.extend(coord));
+
+ map.fitBounds(bounds, {
+ padding: 60,
+ maxZoom: 15,
+ });
+ };
+
+ map.on('load', () => {
+ const nonEventItems = items.filter((item) => item.type !== 'event');
+ const eventItems = items.filter((item) => item.type === 'event');
+ const allCoordinates = items.map((item) => [item.longitude, item.latitude]);
+
+ nonEventItems.forEach((item) => {
+ addSimpleMarker(map, item);
+ });
+
+ if (eventItems.length) {
+ initEventLayers(map, eventItems);
+ }
+
+ fitBounds(allCoordinates);
+ });
+};
+
+const bootstrapMapModules = () => {
+ const modules = document.querySelectorAll(MAP_SELECTOR);
+ modules.forEach((container) => initSingleMap(container));
+};
+
+if ('loading' === document.readyState) {
+ document.addEventListener('DOMContentLoaded', bootstrapMapModules, { once: true });
+} else {
+ bootstrapMapModules();
+}
diff --git a/src/Controller/Frontend/EventMapController.php b/src/Controller/Frontend/EventMapController.php
new file mode 100644
index 0000000..90a9fe8
--- /dev/null
+++ b/src/Controller/Frontend/EventMapController.php
@@ -0,0 +1,40 @@
+id ?? 0));
+ $dataElementId = sprintf('%s-data', $containerId);
+
+ $template->set('mapContainerId', $containerId);
+ $template->set('mapDataElementId', $dataElementId);
+ $template->set('mapStyleUrl', self::MAP_STYLE_URL);
+ $template->set('mapItemsJson', json_encode(
+ $this->mapModuleDataProvider->getMapItems(),
+ \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR,
+ ));
+
+ return $template->getResponse();
+ }
+}
diff --git a/src/Service/MapModuleDataProvider.php b/src/Service/MapModuleDataProvider.php
new file mode 100644
index 0000000..9896fac
--- /dev/null
+++ b/src/Service/MapModuleDataProvider.php
@@ -0,0 +1,286 @@
+>
+ */
+ private array $tableColumns = [];
+
+ public function __construct(
+ private readonly Connection $connection,
+ ) {
+ }
+
+ /**
+ * @return list}>
+ */
+ public function getMapItems(): array
+ {
+ $locationTable = $this->resolveExistingTable(['tl_location']);
+ $organizationTable = $this->resolveExistingTable(['tl_organization', 'tl_organisation']);
+
+ if (null === $locationTable) {
+ return [];
+ }
+
+ $items = [];
+ $seen = [];
+
+ foreach ($this->fetchOrganizationRows($organizationTable) as $row) {
+ $id = (int) ($row['id'] ?? 0);
+
+ if ($id <= 0 || isset($seen['organisation'][$id])) {
+ continue;
+ }
+
+ $coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null);
+
+ if (null === $coords) {
+ continue;
+ }
+
+ $seen['organisation'][$id] = true;
+ $items[] = [
+ 'type' => 'organisation',
+ 'id' => $id,
+ 'title' => trim((string) ($row['title'] ?? '')),
+ 'latitude' => $coords['latitude'],
+ 'longitude' => $coords['longitude'],
+ 'extra' => [],
+ ];
+ }
+
+ foreach ($this->fetchLocationRows($locationTable) as $row) {
+ $id = (int) ($row['id'] ?? 0);
+
+ if ($id <= 0 || isset($seen['location'][$id])) {
+ continue;
+ }
+
+ $coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null);
+
+ if (null === $coords) {
+ continue;
+ }
+
+ $seen['location'][$id] = true;
+ $items[] = [
+ 'type' => 'location',
+ 'id' => $id,
+ 'title' => trim((string) ($row['title'] ?? '')),
+ 'latitude' => $coords['latitude'],
+ 'longitude' => $coords['longitude'],
+ 'extra' => [],
+ ];
+ }
+
+ foreach ($this->fetchEventRows($locationTable) as $row) {
+ $id = (int) ($row['event_id'] ?? 0);
+
+ if ($id <= 0 || isset($seen['event'][$id])) {
+ continue;
+ }
+
+ $coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null);
+
+ if (null === $coords) {
+ continue;
+ }
+
+ $seen['event'][$id] = true;
+ $items[] = [
+ 'type' => 'event',
+ 'id' => $id,
+ 'title' => trim((string) ($row['event_title'] ?? '')),
+ 'latitude' => $coords['latitude'],
+ 'longitude' => $coords['longitude'],
+ 'extra' => [
+ 'locationTitle' => trim((string) ($row['location_title'] ?? '')),
+ 'startDate' => $this->formatStartDate((int) ($row['startDate'] ?? 0)),
+ ],
+ ];
+ }
+
+ return $items;
+ }
+
+ /**
+ * @return list>
+ */
+ private function fetchOrganizationRows(?string $table): array
+ {
+ if (null === $table) {
+ return [];
+ }
+
+ $qb = $this->connection->createQueryBuilder();
+ $qb
+ ->select('o.id', 'o.title', 'o.latitude', 'o.longitude')
+ ->from($table, 'o')
+ ->orderBy('o.id', 'ASC');
+
+ $this->applyPublicationConstraints($qb, 'o', $table);
+
+ return $qb->executeQuery()->fetchAllAssociative();
+ }
+
+ /**
+ * @return list>
+ */
+ private function fetchLocationRows(string $locationTable): array
+ {
+ $qb = $this->connection->createQueryBuilder();
+ $qb
+ ->select('l.id', 'l.title', 'l.latitude', 'l.longitude')
+ ->from($locationTable, 'l')
+ ->orderBy('l.id', 'ASC');
+
+ $this->applyPublicationConstraints($qb, 'l', $locationTable);
+
+ return $qb->executeQuery()->fetchAllAssociative();
+ }
+
+ /**
+ * @return list>
+ */
+ private function fetchEventRows(string $locationTable): array
+ {
+ if (!$this->tableExists('tl_calendar_events')) {
+ return [];
+ }
+
+ $qb = $this->connection->createQueryBuilder();
+ $qb
+ ->select(
+ 'e.id AS event_id',
+ 'e.title AS event_title',
+ 'e.startDate',
+ 'l.title AS location_title',
+ 'l.latitude',
+ 'l.longitude'
+ )
+ ->from('tl_calendar_events', 'e')
+ ->innerJoin('e', $locationTable, 'l', 'l.id = e.location_id')
+ ->andWhere('e.location_id > 0')
+ ->orderBy('e.id', 'ASC');
+
+ $this->applyPublicationConstraints($qb, 'e', 'tl_calendar_events');
+ $this->applyPublicationConstraints($qb, 'l', $locationTable);
+
+ return $qb->executeQuery()->fetchAllAssociative();
+ }
+
+ private function applyPublicationConstraints(QueryBuilder $qb, string $alias, string $table): void
+ {
+ $columns = $this->getColumnMap($table);
+ $now = time();
+
+ if (isset($columns['published'])) {
+ $qb
+ ->andWhere(sprintf('%s.published = :%s_published', $alias, $alias))
+ ->setParameter(sprintf('%s_published', $alias), '1');
+ }
+
+ if (isset($columns['start'])) {
+ $qb
+ ->andWhere(sprintf('(%1$s.start IS NULL OR %1$s.start = 0 OR %1$s.start <= :%1$s_start_now)', $alias))
+ ->setParameter(sprintf('%s_start_now', $alias), $now);
+ }
+
+ if (isset($columns['stop'])) {
+ $qb
+ ->andWhere(sprintf('(%1$s.stop IS NULL OR %1$s.stop = 0 OR %1$s.stop > :%1$s_stop_now)', $alias))
+ ->setParameter(sprintf('%s_stop_now', $alias), $now);
+ }
+ }
+
+ /**
+ * @return array{latitude:float,longitude:float}|null
+ */
+ private function extractCoordinates(mixed $latitude, mixed $longitude): ?array
+ {
+ if (null === $latitude || null === $longitude) {
+ return null;
+ }
+
+ $lat = trim(str_replace(',', '.', (string) $latitude));
+ $lng = trim(str_replace(',', '.', (string) $longitude));
+
+ if ('' === $lat || '' === $lng || !is_numeric($lat) || !is_numeric($lng)) {
+ return null;
+ }
+
+ return [
+ 'latitude' => (float) $lat,
+ 'longitude' => (float) $lng,
+ ];
+ }
+
+ private function formatStartDate(int $timestamp): string
+ {
+ if ($timestamp <= 0) {
+ return '';
+ }
+
+ $format = (string) Config::get('dateFormat');
+
+ return Date::parse('' !== $format ? $format : 'd.m.Y', $timestamp);
+ }
+
+ /**
+ * @param list $candidates
+ */
+ private function resolveExistingTable(array $candidates): ?string
+ {
+ foreach ($candidates as $candidate) {
+ if ($this->tableExists($candidate)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
+ private function tableExists(string $table): bool
+ {
+ try {
+ return $this->connection->createSchemaManager()->tablesExist([$table]);
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function getColumnMap(string $table): array
+ {
+ if (isset($this->tableColumns[$table])) {
+ return $this->tableColumns[$table];
+ }
+
+ $columns = [];
+
+ try {
+ foreach ($this->connection->createSchemaManager()->listTableColumns($table) as $name => $column) {
+ $columns[strtolower((string) $name)] = true;
+ }
+ } catch (\Throwable) {
+ $columns = [];
+ }
+
+ $this->tableColumns[$table] = $columns;
+
+ return $columns;
+ }
+}