feat: add frontend map module with maplibre and spiderfy
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user