diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php index 44a1509..98d450a 100644 --- a/contao/dca/tl_module.php +++ b/contao/dca/tl_module.php @@ -162,7 +162,7 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['eventListDomId'] = [ $GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'mapCenterMode'; $GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'mapInitialDisplay'; -$GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapCenterMode_custom'] = 'mapCenterLat,mapCenterLng,mapCenterZoom'; +$GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapCenterMode_custom'] = 'mapCenterLat,mapCenterLng,mapCenterZoom,mapPitch'; $GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapInitialDisplay_organization_tag'] = 'mapInitialOrganizationTagId'; $GLOBALS['TL_DCA']['tl_module']['fields']['mapShowOrganizations'] = [ @@ -271,6 +271,14 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterZoom'] = [ 'sql' => ['type' => 'smallint', 'unsigned' => true, 'default' => 12], ]; +$GLOBALS['TL_DCA']['tl_module']['fields']['mapPitch'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapPitch'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'digit', 'maxlength' => 2, 'tl_class' => 'w50'], + 'sql' => ['type' => 'smallint', 'unsigned' => true, 'default' => 0], +]; + if (isset($GLOBALS['TL_DCA']['tl_module']['fields']['list_layout'])) { $GLOBALS['TL_DCA']['tl_module']['fields']['list_layout']['options_callback'] = static function (): array { $options = Controller::getTemplateGroup('list_'); diff --git a/contao/languages/de/tl_module.php b/contao/languages/de/tl_module.php index 385e12a..117f140 100644 --- a/contao/languages/de/tl_module.php +++ b/contao/languages/de/tl_module.php @@ -33,3 +33,4 @@ $GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [ $GLOBALS['TL_LANG']['tl_module']['mapCenterLat'] = ['Breitengrad (Center)', 'Breitengrad für die feste Kartenzentrierung, z. B. 51.0538']; $GLOBALS['TL_LANG']['tl_module']['mapCenterLng'] = ['Längengrad (Center)', 'Längengrad für die feste Kartenzentrierung, z. B. 13.3080']; $GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'] = ['Zoom-Level (Center)', 'Zoom-Level für die feste Kartenzentrierung (z. B. 12).']; +$GLOBALS['TL_LANG']['tl_module']['mapPitch'] = ['Pitch (Neigung)', 'Kartenneigung in Grad (0-85). Standard bei leerer Angabe: 0.']; diff --git a/contao/languages/en/tl_module.php b/contao/languages/en/tl_module.php index 740cf87..3f10262 100644 --- a/contao/languages/en/tl_module.php +++ b/contao/languages/en/tl_module.php @@ -33,3 +33,4 @@ $GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [ $GLOBALS['TL_LANG']['tl_module']['mapCenterLat'] = ['Center latitude', 'Latitude for fixed map centering, e.g. 51.0538']; $GLOBALS['TL_LANG']['tl_module']['mapCenterLng'] = ['Center longitude', 'Longitude for fixed map centering, e.g. 13.3080']; $GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'] = ['Center zoom level', 'Zoom level for fixed map centering (e.g. 12).']; +$GLOBALS['TL_LANG']['tl_module']['mapPitch'] = ['Pitch', 'Map pitch in degrees (0-85). Default when empty: 0.']; diff --git a/contao/templates/frontend/event_map.html.twig b/contao/templates/frontend/event_map.html.twig index 8b7f177..4b182bb 100644 --- a/contao/templates/frontend/event_map.html.twig +++ b/contao/templates/frontend/event_map.html.twig @@ -81,6 +81,7 @@ data-map-center-lat="{{ mapCenterLat|default('')|e('html_attr') }}" data-map-center-lng="{{ mapCenterLng|default('')|e('html_attr') }}" data-map-center-zoom="{{ mapCenterZoom|default(12)|e('html_attr') }}" + data-map-pitch="{{ mapPitch|default(0)|e('html_attr') }}" > - + diff --git a/public/assets/map-module.js b/public/assets/map-module.js index 2208f02..5c0028f 100644 --- a/public/assets/map-module.js +++ b/public/assets/map-module.js @@ -118,6 +118,21 @@ const escapeHtml = (value) => { .replace(/'/g, '''); }; +const decodeHtmlEntities = (value) => { + const stringValue = String(value ?? ''); + + if ('undefined' === typeof document || !document.createElement) { + return stringValue; + } + + const textarea = document.createElement('textarea'); + textarea.innerHTML = stringValue; + + return textarea.value; +}; + +const sanitizeDisplayText = (value) => escapeHtml(decodeHtmlEntities(value)); + const parseItems = (container) => { const dataElementId = container.dataset.mapDataId || ''; @@ -406,17 +421,24 @@ const ensureMarkerImage = (map, color, prefix = 'eventmanager-marker', options = const popupHtmlFor = (item) => { if (item.type === 'event') { - const locationTitle = escapeHtml(item.extra.locationTitle || ''); - const startDate = escapeHtml(item.extra.startDate || ''); + const title = sanitizeDisplayText(item.title || ''); + const locationTitle = sanitizeDisplayText(item.extra.locationTitle || ''); + const startDate = sanitizeDisplayText(item.extra.startDate || ''); + const detailUrl = String(item.extra.detailUrl || '').trim(); + const hasDetailUrl = /^(https?:\/\/|\/)/i.test(detailUrl); + const detailLink = hasDetailUrl + ? `
mehr Infos
` + : ''; return [ - `${escapeHtml(item.title)}`, + `${title}`, locationTitle ? `
${locationTitle}
` : '', startDate ? `
${startDate}
` : '', + detailLink, ].join(''); } - return `${escapeHtml(item.title)}`; + return `${sanitizeDisplayText(item.title)}`; }; const toFeature = (item) => ({ @@ -432,6 +454,7 @@ const toFeature = (item) => ({ markerTagIds: Array.isArray(item.extra?.organizationTagIds) ? item.extra.organizationTagIds : [], markerTagLabels: Array.isArray(item.extra?.organizationTagLabels) ? item.extra.organizationTagLabels : [], title: item.title, + latitude: item.latitude, popupHtml: popupHtmlFor(item), }, }); @@ -445,7 +468,9 @@ const initOrganizationMarkers = (map, organizationItems, organizationColor) => { }; } - const markerEntries = organizationItems.map((item) => { + const sortedOrganizationItems = [...organizationItems].sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude); + + const markerEntries = sortedOrganizationItems.map((item) => { const popupHtml = popupHtmlFor(item); const marker = new maplibregl.Marker(organizationColor ? { color: organizationColor } : undefined) .setLngLat([item.longitude, item.latitude]); @@ -456,6 +481,12 @@ const initOrganizationMarkers = (map, organizationItems, organizationColor) => { marker.addTo(map); + const markerElement = marker.getElement(); + + if (markerElement) { + markerElement.style.zIndex = String(Math.round((90 - item.latitude) * 1000)); + } + return { marker, tagIds: normalizeTagIds(item.extra?.organizationTagIds), @@ -776,6 +807,7 @@ const initLocationLayers = (map, locationItems, markerImageId) => { 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true, + 'symbol-sort-key': ['*', -1, ['to-number', ['get', 'latitude'], 0]], }, paint: { 'icon-opacity': 1, @@ -915,6 +947,7 @@ const initEventLayers = (map, eventItems, eventColor, markerImageId, clusterMark 'icon-anchor': 'bottom', 'icon-allow-overlap': true, 'icon-ignore-placement': true, + 'symbol-sort-key': ['*', -1, ['to-number', ['get', 'latitude'], 0]], }, paint: { 'icon-opacity': 1, @@ -1132,8 +1165,12 @@ const initSingleMap = (container) => { const customLat = parseCoordinate(container.dataset.mapCenterLat); const customLng = parseCoordinate(container.dataset.mapCenterLng); const customZoom = Number(container.dataset.mapCenterZoom); + const customPitch = Number(container.dataset.mapPitch); const eventColor = normalizeHexColor(container.dataset.mapEventColor, DEFAULT_EVENT_COLOR); const hasCustomCenter = null !== customLat && null !== customLng; + const initialPitch = Number.isFinite(customPitch) + ? Math.max(0, Math.min(85, customPitch)) + : 0; let initialCenter = DEFAULT_CENTER; let initialZoom = DEFAULT_ZOOM; @@ -1165,7 +1202,7 @@ const initSingleMap = (container) => { container, style, zoom: initialZoom, - pitch: 0, + pitch: initialPitch, bearing: 0, center: initialCenter, transformRequest: (url, resourceType) => { @@ -1227,9 +1264,15 @@ const initSingleMap = (container) => { map.on('load', async () => { applyDefaultProjection(map); - const organizationItems = items.filter((item) => item.type === 'organisation'); - const locationItems = items.filter((item) => item.type === 'location'); - const eventItems = items.filter((item) => item.type === 'event'); + const organizationItems = items + .filter((item) => item.type === 'organisation') + .sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude); + const locationItems = items + .filter((item) => item.type === 'location') + .sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude); + const eventItems = items + .filter((item) => item.type === 'event') + .sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude); const allCoordinates = items.map((item) => [item.longitude, item.latitude]); const organizationColor = normalizeHexColor( container.dataset.mapOrganizationColor, diff --git a/src/Controller/Frontend/EventMapController.php b/src/Controller/Frontend/EventMapController.php index ece12ee..7f0e1e5 100644 --- a/src/Controller/Frontend/EventMapController.php +++ b/src/Controller/Frontend/EventMapController.php @@ -47,6 +47,7 @@ class EventMapController extends AbstractFrontendModuleController $initialDisplay = (string) ($model->mapInitialDisplay ?? self::DEFAULT_INITIAL_DISPLAY); $initialOrganizationTagId = (int) ($model->mapInitialOrganizationTagId ?? 0); $centerMode = (string) ($model->mapCenterMode ?? self::DEFAULT_CENTER_MODE); + $mapPitch = $this->normalizePitch($model->mapPitch ?? 0); $eventColor = $this->normalizeHexColor((string) ($model->mapEventColor ?? self::DEFAULT_EVENT_COLOR)); $organizationColor = $this->normalizeHexColor((string) ($model->mapOrganizationColor ?? $eventColor), $eventColor); @@ -77,6 +78,7 @@ class EventMapController extends AbstractFrontendModuleController $template->set('mapCenterLat', trim((string) ($model->mapCenterLat ?? ''))); $template->set('mapCenterLng', trim((string) ($model->mapCenterLng ?? ''))); $template->set('mapCenterZoom', (int) ($model->mapCenterZoom ?? 12)); + $template->set('mapPitch', $mapPitch); $template->set('mapItemsJson', json_encode( $this->mapModuleDataProvider->getMapItems($showOrganizations, $showEvents, $showExternalOrganizations, $selectedOrganizationTagIds), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR, @@ -118,4 +120,23 @@ class EventMapController extends AbstractFrontendModuleController return $fallback; } + + private function normalizePitch(mixed $value): int + { + if (!is_scalar($value)) { + return 0; + } + + $parsedPitch = (int) round((float) str_replace(',', '.', (string) $value)); + + if ($parsedPitch < 0) { + return 0; + } + + if ($parsedPitch > 85) { + return 85; + } + + return $parsedPitch; + } } diff --git a/src/Service/MapModuleDataProvider.php b/src/Service/MapModuleDataProvider.php index 78a200a..e69a242 100644 --- a/src/Service/MapModuleDataProvider.php +++ b/src/Service/MapModuleDataProvider.php @@ -4,12 +4,15 @@ declare(strict_types=1); namespace MummertMedia\EventManagerBundle\Service; -use Contao\Config; -use Contao\Date; +use Contao\CalendarEventsModel; +use Contao\CoreBundle\Framework\ContaoFramework; +use Contao\CoreBundle\Routing\ContentUrlGenerator; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; +use Symfony\Component\Routing\Exception\ExceptionInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class MapModuleDataProvider { @@ -20,6 +23,8 @@ class MapModuleDataProvider public function __construct( private readonly Connection $connection, + private readonly ContaoFramework $framework, + private readonly ContentUrlGenerator $urlGenerator, ) { } @@ -126,7 +131,12 @@ class MapModuleDataProvider 'longitude' => $coords['longitude'], 'extra' => [ 'locationTitle' => trim((string) ($row['location_title'] ?? '')), - 'startDate' => $this->formatStartDate((int) ($row['startDate'] ?? 0)), + 'startDate' => $this->formatStartDateTime( + (int) ($row['startDate'] ?? 0), + (string) ($row['addTime'] ?? ''), + (int) ($row['startTime'] ?? 0), + ), + 'detailUrl' => $this->resolveEventDetailUrl($id), ], ]; } @@ -351,6 +361,8 @@ class MapModuleDataProvider 'e.id AS event_id', 'e.title AS event_title', 'e.startDate', + 'e.addTime', + 'e.startTime', 'l.title AS location_title', sprintf('l.%s AS latitude', $locationGeoColumns['latitude']), sprintf('l.%s AS longitude', $locationGeoColumns['longitude']) @@ -429,15 +441,40 @@ class MapModuleDataProvider ]; } - private function formatStartDate(int $timestamp): string + private function formatStartDateTime(int $startDate, string $addTime, int $startTime): string { - if ($timestamp <= 0) { + if ($startDate <= 0) { return ''; } - $format = (string) Config::get('dateFormat'); + $formattedDate = date('d.m.Y', $startDate); - return Date::parse('' !== $format ? $format : 'd.m.Y', $timestamp); + if ('1' !== $addTime || $startTime <= 0) { + return $formattedDate; + } + + return sprintf('%s %s Uhr', $formattedDate, date('H:i', $startTime)); + } + + private function resolveEventDetailUrl(int $eventId): string + { + if ($eventId <= 0) { + return ''; + } + + $eventModel = $this->framework + ->getAdapter(CalendarEventsModel::class) + ->findById($eventId); + + if (null === $eventModel) { + return ''; + } + + try { + return (string) $this->urlGenerator->generate($eventModel, [], UrlGeneratorInterface::ABSOLUTE_PATH); + } catch (ExceptionInterface) { + return ''; + } } /**