From 68e991c0539a1868bd9ebb3eaf75d89e0c2ea557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Mummert?= Date: Tue, 24 Feb 2026 18:24:10 +0100 Subject: [PATCH] feat: add frontend map module with maplibre and spiderfy --- config/services.yaml | 2 + contao/dca/tl_module.php | 1 + contao/languages/de/modules.php | 1 + contao/languages/en/modules.php | 1 + contao/templates/frontend/event_map.html.twig | 17 + public/assets/map-module.js | 331 ++++++++++++++++++ .../Frontend/EventMapController.php | 40 +++ src/Service/MapModuleDataProvider.php | 286 +++++++++++++++ 8 files changed, 679 insertions(+) create mode 100644 contao/templates/frontend/event_map.html.twig create mode 100644 public/assets/map-module.js create mode 100644 src/Controller/Frontend/EventMapController.php create mode 100644 src/Service/MapModuleDataProvider.php 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; + } +}