feat: add frontend map module with maplibre and spiderfy

This commit is contained in:
Jürgen Mummert
2026-02-24 18:24:10 +01:00
parent 8aa09e893b
commit 68e991c053
8 changed files with 679 additions and 0 deletions
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Controller\Frontend;
use Contao\CoreBundle\Controller\FrontendModule\AbstractFrontendModuleController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsFrontendModule;
use Contao\CoreBundle\Twig\FragmentTemplate;
use Contao\ModuleModel;
use MummertMedia\EventManagerBundle\Service\MapModuleDataProvider;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsFrontendModule(type: 'eventmanager_map', category: 'eventmanager', template: 'frontend/event_map')]
class EventMapController extends AbstractFrontendModuleController
{
private const MAP_STYLE_URL = 'https://maps.mummert.media/metadaten/world-light.json';
public function __construct(
private readonly MapModuleDataProvider $mapModuleDataProvider,
) {
}
protected function getResponse(FragmentTemplate $template, ModuleModel $model, Request $request): Response
{
$containerId = sprintf('eventmanager-map-%d', (int) ($model->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();
}
}
+286
View File
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace MummertMedia\EventManagerBundle\Service;
use Contao\Config;
use Contao\Date;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
class MapModuleDataProvider
{
/**
* @var array<string,array<string,true>>
*/
private array $tableColumns = [];
public function __construct(
private readonly Connection $connection,
) {
}
/**
* @return list<array{type:string,id:int,title:string,latitude:float,longitude:float,extra:array<string,mixed>}>
*/
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<array<string,mixed>>
*/
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<array<string,mixed>>
*/
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<array<string,mixed>>
*/
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<string> $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<string,true>
*/
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;
}
}