diff --git a/contao/dca/tl_module.php b/contao/dca/tl_module.php index 0e5296a..3aaa269 100644 --- a/contao/dca/tl_module.php +++ b/contao/dca/tl_module.php @@ -9,7 +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']['eventmanager_map'] = '{title_legend},name,headline,type;{eventmanager_legend},mapShowOrganizations,mapShowExternalOrganizations,mapShowEvents,mapEventColor,mapOrganizationColorScheme,mapCenterMode;{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'; @@ -160,6 +160,83 @@ $GLOBALS['TL_DCA']['tl_module']['fields']['eventListDomId'] = [ 'sql' => ['type' => 'string', 'length' => 128, 'default' => ''], ]; +$GLOBALS['TL_DCA']['tl_module']['palettes']['__selector__'][] = 'mapCenterMode'; +$GLOBALS['TL_DCA']['tl_module']['subpalettes']['mapCenterMode_custom'] = 'mapCenterLat,mapCenterLng,mapCenterZoom'; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowOrganizations'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowExternalOrganizations'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapShowEvents'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'], + 'exclude' => true, + 'inputType' => 'checkbox', + 'eval' => ['tl_class' => 'w50 m12'], + 'sql' => ['type' => 'string', 'length' => 1, 'fixed' => true, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapEventColor'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapEventColor'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 7, 'rgxp' => 'hexcolor', 'colorpicker' => true, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 7, 'default' => '#BC5067'], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapOrganizationColorScheme'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColorScheme'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 1024, 'tl_class' => 'clr long'], + 'sql' => ['type' => 'string', 'length' => 1024, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterMode'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'], + 'exclude' => true, + 'inputType' => 'select', + 'options' => ['markers', 'custom'], + 'reference' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'], + 'eval' => ['submitOnChange' => true, 'mandatory' => true, 'tl_class' => 'clr w50', 'includeBlankOption' => false], + 'sql' => ['type' => 'string', 'length' => 16, 'default' => 'markers'], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterLat'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterLat'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterLng'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterLng'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['maxlength' => 32, 'tl_class' => 'w50'], + 'sql' => ['type' => 'string', 'length' => 32, 'default' => ''], +]; + +$GLOBALS['TL_DCA']['tl_module']['fields']['mapCenterZoom'] = [ + 'label' => &$GLOBALS['TL_LANG']['tl_module']['mapCenterZoom'], + 'exclude' => true, + 'inputType' => 'text', + 'eval' => ['rgxp' => 'digit', 'maxlength' => 2, 'tl_class' => 'w50'], + 'sql' => ['type' => 'smallint', 'unsigned' => true, 'default' => 12], +]; + 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 05ea239..6d9dedd 100644 --- a/contao/languages/de/tl_module.php +++ b/contao/languages/de/tl_module.php @@ -13,3 +13,16 @@ $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['ID des Newsarchivs', ' $GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Eventlisten-ID (DOM)', 'Wählen Sie die ID der Eventliste, die durch das Event-Filter-Modul gefiltert werden soll.']; $GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Anzuzeigende Organisationstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Organisationstyp-Tags. Leer = alle.']; $GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Anzuzeigende Veranstaltungstypen', 'Optional: Begrenzen Sie die im Frontend anzeigbaren Veranstaltungstyp-Tags. Leer = alle.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'] = ['Organisationen anzeigen', 'Wenn aktiviert, werden Organisations-Marker auf der Karte dargestellt.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'] = ['Externe Organisationen anzeigen', 'Wenn aktiviert, werden externe Organisationen (isExternal=1) zusätzlich auf der Karte dargestellt. Standard: nein.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'] = ['Veranstaltungen anzeigen', 'Wenn aktiviert, werden Event- (inkl. Orts-) Marker auf der Karte dargestellt.']; +$GLOBALS['TL_LANG']['tl_module']['mapEventColor'] = ['Event-Farbe (Kreise/Linien)', 'Farbe für Event-Cluster, Event-Punkte und Spiderfy-Verbindungslinien (Hex, z. B. #BC5067).']; +$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColorScheme'] = ['Farbschema (Organisationstypen)', 'Kommagetrennte Farben für Organisationstypen/Tags, z. B. ff6600,77dd33,ff99bb. Markerfarbe richtet sich nach dem ersten Tag.']; +$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'] = ['Karten-Zentrierung', 'Wählen Sie, ob die Karte anhand der Marker oder mit festen Koordinaten zentriert werden soll.']; +$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [ + 'markers' => 'Anhand der Marker (alle Marker sichtbar)', + 'custom' => 'Feste Geodaten + Zoom-Level', +]; +$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).']; diff --git a/contao/languages/en/tl_module.php b/contao/languages/en/tl_module.php index e70d55f..4a69d86 100644 --- a/contao/languages/en/tl_module.php +++ b/contao/languages/en/tl_module.php @@ -13,3 +13,16 @@ $GLOBALS['TL_LANG']['tl_module']['frontendArchiveId'] = ['News archive ID', 'Arc $GLOBALS['TL_LANG']['tl_module']['eventListDomId'] = ['Event list ID (DOM)', 'Select the event list ID that should be filtered by the event filter module.']; $GLOBALS['TL_LANG']['tl_module']['organizationTypeTags'] = ['Displayed organization types', 'Optional: Limit which organization type tags can be selected in the frontend. Empty = all.']; $GLOBALS['TL_LANG']['tl_module']['eventTypeTags'] = ['Displayed event types', 'Optional: Limit which event type tags can be selected in the frontend. Empty = all.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowOrganizations'] = ['Show organizations', 'If enabled, organization markers are rendered on the map.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowExternalOrganizations'] = ['Show external organizations', 'If enabled, external organizations (isExternal=1) are additionally rendered on the map. Default: no.']; +$GLOBALS['TL_LANG']['tl_module']['mapShowEvents'] = ['Show events', 'If enabled, event markers (including related locations) are rendered on the map.']; +$GLOBALS['TL_LANG']['tl_module']['mapEventColor'] = ['Event color (circles/lines)', 'Color for event clusters, event points and spiderfy connector lines (hex, e.g. #BC5067).']; +$GLOBALS['TL_LANG']['tl_module']['mapOrganizationColorScheme'] = ['Color scheme (organization types)', 'Comma-separated colors for organization type tags, e.g. ff6600,77dd33,ff99bb. Marker color follows the first tag.']; +$GLOBALS['TL_LANG']['tl_module']['mapCenterMode'] = ['Map centering', 'Choose whether the map should center by markers or fixed coordinates.']; +$GLOBALS['TL_LANG']['tl_module']['mapCenterMode_options'] = [ + 'markers' => 'By markers (fit all visible markers)', + 'custom' => 'Fixed coordinates + zoom level', +]; +$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).']; diff --git a/contao/templates/frontend/event_map.html.twig b/contao/templates/frontend/event_map.html.twig index 2fceea8..5749aa7 100644 --- a/contao/templates/frontend/event_map.html.twig +++ b/contao/templates/frontend/event_map.html.twig @@ -1,12 +1,87 @@ +{% set tags = mapOrganizationTags|default([]) %} + +
+ + +
+ {% if tags is iterable and tags|length > 0 %} + {% for tag in tags %} + + {% endfor %} + {% endif %} + +
+ +
+ + + + +
+
+
- + diff --git a/public/assets/map-module.js b/public/assets/map-module.js index fff752e..f51c1c2 100644 --- a/public/assets/map-module.js +++ b/public/assets/map-module.js @@ -1,6 +1,111 @@ -import Spiderfy from 'https://cdn.jsdelivr.net/npm/@nazka/map-gl-js-spiderfy@1.2.10/dist/index.modern.js'; +import Spiderfy from './vendor/map-gl-js-spiderfy.js'; const MAP_SELECTOR = '[data-eventmanager-map="1"]'; +const MAPLIBRE_CSS_URL = 'https://maps.mummert.media/libraries/maplibre-gl.css'; +const MAPLIBRE_JS_URL = 'https://maps.mummert.media/libraries/maplibre-gl.js'; +const PMTILES_JS_URL = 'https://maps.mummert.media/libraries/pmtiles.js'; +const GLYPH_FONTSTACK = 'Noto Sans Regular'; +const DEFAULT_CENTER = [13.404954, 52.520008]; +const DEFAULT_ZOOM = 6; +const DEFAULT_EVENT_COLOR = '#BC5067'; +const EVENT_FADE_DURATION_MS = 200; +const EVENT_CLUSTER_LAYER_ID = 'eventmanager-events-clusters'; +const EVENT_CLUSTER_SPIDERFY_LAYER_ID = 'eventmanager-events-clusters-spiderfy'; +const EVENT_UNCLUSTERED_LAYER_ID = 'eventmanager-events-unclustered'; +const SATELLITE_SOURCE_ID = 'eventmanager-satellite-source'; +const SATELLITE_LAYER_ID = 'eventmanager-satellite-layer'; +const SATELLITE_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; +const SATELLITE_ATTRIBUTION = '© Esri, Maxar'; + +let dependenciesPromise = null; + +const ensureStylesheet = (href) => { + if (!href) { + return; + } + + const existing = document.querySelector(`link[rel="stylesheet"][href="${href}"]`); + + if (existing) { + return; + } + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + document.head.appendChild(link); +}; + +const loadScript = (src) => new Promise((resolve, reject) => { + if (!src) { + resolve(); + + return; + } + + const existing = document.querySelector(`script[src="${src}"]`); + + if (existing) { + if (existing.dataset.loaded === '1') { + resolve(); + + return; + } + + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(`Failed to load script: ${src}`)), { once: true }); + + return; + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.addEventListener('load', () => { + script.dataset.loaded = '1'; + resolve(); + }, { once: true }); + script.addEventListener('error', () => reject(new Error(`Failed to load script: ${src}`)), { once: true }); + document.head.appendChild(script); +}); + +const ensureDependencies = async () => { + if (dependenciesPromise) { + return dependenciesPromise; + } + + dependenciesPromise = (async () => { + ensureStylesheet(MAPLIBRE_CSS_URL); + + if (typeof maplibregl === 'undefined') { + await loadScript(MAPLIBRE_JS_URL); + } + + if (typeof pmtiles === 'undefined') { + await loadScript(PMTILES_JS_URL); + } + })(); + + return dependenciesPromise; +}; + +const rewriteGlyphUrl = (url) => { + if (!url) { + return url; + } + + const match = url.match(/\/fonts\/[^/]+\/(\d+-\d+\.pbf)(\?.*)?$/); + + if (!match) { + return url; + } + + const range = match[1]; + const query = match[2] || ''; + const base = url.slice(0, match.index); + + return `${base}/fonts/${encodeURIComponent(GLYPH_FONTSTACK)}/${range}${query}`; +}; const escapeHtml = (value) => { const stringValue = String(value ?? ''); @@ -50,6 +155,7 @@ const parseItems = (container) => { unique.set(`${item.type}:${id}`, { type: item.type, + markerType: String(item.markerType || item.type || ''), id, title: String(item.title || ''), latitude, @@ -64,6 +170,245 @@ const parseItems = (container) => { } }; +const parseCoordinate = (value) => { + const normalized = String(value ?? '').trim().replace(',', '.'); + const parsed = Number(normalized); + + return Number.isFinite(parsed) ? parsed : null; +}; + +const normalizeHexColor = (value, fallback = DEFAULT_EVENT_COLOR) => { + const normalized = String(value || '').trim().toUpperCase(); + + if (/^#[0-9A-F]{6}$/.test(normalized)) { + return normalized; + } + + if (/^[0-9A-F]{6}$/.test(normalized)) { + return `#${normalized}`; + } + + if (/^#[0-9A-F]{3}$/.test(normalized)) { + return `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`; + } + + if (/^[0-9A-F]{3}$/.test(normalized)) { + return `#${normalized[0]}${normalized[0]}${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}`; + } + + return fallback; +}; + +const normalizeHexColorOrNull = (value) => { + const fallback = '__INVALID__'; + const normalized = normalizeHexColor(value, fallback); + + return normalized === fallback ? null : normalized; +}; + +const parseOrganizationColorScheme = (value) => { + const raw = String(value || '').trim(); + + if ('' === raw) { + return []; + } + + return raw + .split(',') + .map((part) => normalizeHexColorOrNull(part)) + .filter((part) => null !== part); +}; + +const buildOrganizationTagColorMap = (container, colors, organizationItems) => { + if (!colors.length) { + return { + byTagId: {}, + fallbackColor: null, + }; + } + + const fallbackColor = colors[0]; + const wrapperId = container.dataset.mapFilterWrapperId || ''; + const wrapper = wrapperId ? document.getElementById(wrapperId) : null; + let orderedTagIds = []; + + if (wrapper) { + orderedTagIds = Array.from(wrapper.querySelectorAll('[data-map-tag-filter]')) + .map((button) => String(button.dataset.mapTagFilter || '').trim()) + .filter((value) => /^\d+$/.test(value)); + } + + if (!orderedTagIds.length) { + const seen = new Set(); + + organizationItems.forEach((item) => { + const firstTagId = String(item?.extra?.organizationTagIds?.[0] || '').trim(); + + if (/^\d+$/.test(firstTagId) && !seen.has(firstTagId)) { + seen.add(firstTagId); + orderedTagIds.push(firstTagId); + } + }); + } + + const byTagId = {}; + + orderedTagIds.forEach((tagId, index) => { + byTagId[tagId] = colors[index] || fallbackColor; + }); + + return { + byTagId, + fallbackColor, + }; +}; + +const applyDefaultProjection = (map) => { + if (!map || typeof map.setProjection !== 'function') { + return; + } + + const hasGlobeProjection = () => { + if (typeof map.getProjection !== 'function') { + return false; + } + + const projection = map.getProjection(); + const projectionName = String(projection?.type || projection?.name || '').toLowerCase(); + + return 'globe' === projectionName; + }; + + let globeApplied = false; + + try { + map.setProjection({ type: 'globe' }); + globeApplied = hasGlobeProjection(); + } catch (error) { + } + + if (!globeApplied) { + try { + map.setProjection('globe'); + globeApplied = hasGlobeProjection(); + } catch (error) { + } + } + + if (!globeApplied) { + return; + } + + if (typeof map.setFog === 'function') { + try { + map.setFog({ + color: 'rgb(186, 210, 235)', + 'high-color': 'rgb(36, 92, 223)', + 'horizon-blend': 0.08, + 'space-color': 'rgb(11, 11, 25)', + 'star-intensity': 0.2, + }); + } catch (error) { + } + } + + if (typeof map.setSky === 'function') { + try { + map.setSky({ + 'sky-color': '#87ceeb', + 'horizon-color': '#ffffff', + 'fog-color': '#dff6ff', + 'fog-ground-blend': 0.2, + 'sky-horizon-blend': 0.4, + }); + } catch (error) { + } + } +}; + +const ensureSatelliteLayer = (map) => { + if (!map || map.getSource(SATELLITE_SOURCE_ID)) { + return; + } + + map.addSource(SATELLITE_SOURCE_ID, { + type: 'raster', + tiles: [SATELLITE_TILE_URL], + tileSize: 256, + attribution: SATELLITE_ATTRIBUTION, + }); + + const markerLayerCandidates = [ + EVENT_CLUSTER_LAYER_ID, + EVENT_CLUSTER_SPIDERFY_LAYER_ID, + EVENT_UNCLUSTERED_LAYER_ID, + 'eventmanager-non-events', + ]; + const beforeId = markerLayerCandidates.find((layerId) => map.getLayer(layerId)); + + map.addLayer({ + id: SATELLITE_LAYER_ID, + type: 'raster', + source: SATELLITE_SOURCE_ID, + layout: { + visibility: 'none', + }, + paint: { + 'raster-opacity': 1, + 'raster-brightness-min': 0, + 'raster-brightness-max': 1, + 'raster-contrast': 0, + 'raster-saturation': 0, + }, + }, beforeId); +}; + +const setSatelliteMode = (map, enabled) => { + if (!map) { + return; + } + + ensureSatelliteLayer(map); + + if (!map.getLayer(SATELLITE_LAYER_ID)) { + return; + } + + map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', enabled ? 'visible' : 'none'); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-opacity-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-brightness-min-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-brightness-max-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-contrast-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-saturation-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + + if (!enabled) { + return; + } + + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-brightness-min', 0); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-brightness-max', 1); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-contrast', 0); + map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-saturation', 0); +}; + +const normalizeTagIds = (value) => { + if (!Array.isArray(value)) { + return []; + } + + const unique = new Set(); + + value.forEach((rawValue) => { + const normalized = String(rawValue ?? '').trim(); + + if (normalized !== '' && /^\d+$/.test(normalized)) { + unique.add(normalized); + } + }); + + return Array.from(unique.values()); +}; + const popupHtmlFor = (item) => { if (item.type === 'event') { const locationTitle = escapeHtml(item.extra.locationTitle || ''); @@ -88,28 +433,346 @@ const toFeature = (item) => ({ properties: { id: item.id, type: item.type, + markerType: item.markerType, + markerTagIds: Array.isArray(item.extra?.organizationTagIds) ? item.extra.organizationTagIds : [], + markerTagLabels: Array.isArray(item.extra?.organizationTagLabels) ? item.extra.organizationTagLabels : [], title: item.title, popupHtml: popupHtmlFor(item), }, }); -const addSimpleMarker = (map, item) => { - const popup = new maplibregl.Popup({ offset: 20 }).setHTML(popupHtmlFor(item)); +const initOrganizationMarkers = (map, organizationItems, organizationTagColorMap) => { + if (!organizationItems.length) { + return { + setActiveTagIds: () => {}, + setAllVisible: () => {}, + }; + } - new maplibregl.Marker() - .setLngLat([item.longitude, item.latitude]) - .setPopup(popup) - .addTo(map); + const markerEntries = organizationItems.map((item) => { + const popupHtml = popupHtmlFor(item); + const primaryTagId = String(item?.extra?.organizationTagIds?.[0] || '').trim(); + const configuredColor = /^\d+$/.test(primaryTagId) + ? (organizationTagColorMap?.byTagId?.[primaryTagId] || organizationTagColorMap?.fallbackColor || null) + : null; + const marker = new maplibregl.Marker(configuredColor ? { color: configuredColor } : undefined) + .setLngLat([item.longitude, item.latitude]); + + if (popupHtml) { + marker.setPopup(new maplibregl.Popup({ offset: 15 }).setHTML(String(popupHtml))); + } + + marker.addTo(map); + + return { + marker, + tagIds: normalizeTagIds(item.extra?.organizationTagIds), + }; + }); + + return { + setActiveTagIds: (activeTagIds) => { + const activeSet = new Set(normalizeTagIds(activeTagIds)); + + markerEntries.forEach((entry) => { + const markerElement = entry.marker.getElement(); + + if (!markerElement) { + return; + } + + const hasTags = entry.tagIds.length > 0; + const isVisible = !hasTags + || entry.tagIds.some((tagId) => activeSet.has(tagId)); + + markerElement.style.display = isVisible ? '' : 'none'; + }); + }, + setAllVisible: () => { + markerEntries.forEach((entry) => { + const markerElement = entry.marker.getElement(); + + if (!markerElement) { + return; + } + + markerElement.style.display = ''; + }); + }, + }; }; -const initEventLayers = (map, eventItems) => { +const bindExternalTagFilters = (container, map, organizationMarkerManager, eventLayerManager) => { + const wrapperId = container.dataset.mapFilterWrapperId || ''; + + if (!wrapperId || !organizationMarkerManager) { + return; + } + + const wrapper = document.getElementById(wrapperId); + + if (!wrapper) { + return; + } + + const tagButtons = Array.from(wrapper.querySelectorAll('[data-map-tag-filter]')); + + const collectActiveTagIds = () => tagButtons + .filter((button) => button.getAttribute('aria-pressed') === 'true') + .map((button) => String(button.dataset.mapTagFilter || '').trim()) + .filter((value) => /^\d+$/.test(value)); + + const setButtonState = (button, isActive) => { + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + button.classList.toggle('is-active', isActive); + }; + + const eventToggleButton = wrapper.querySelector('[data-map-event-toggle="1"]'); + + const setEventButtonState = (isActive) => { + if (!eventToggleButton) { + return; + } + + eventToggleButton.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + eventToggleButton.classList.toggle('is-active', isActive); + }; + + const applyFilter = () => { + if (!tagButtons.length) { + organizationMarkerManager.setAllVisible(); + + return; + } + + organizationMarkerManager.setActiveTagIds(collectActiveTagIds()); + }; + + tagButtons.forEach((button) => { + button.addEventListener('click', () => { + const isActive = button.getAttribute('aria-pressed') === 'true'; + setButtonState(button, !isActive); + applyFilter(); + }); + }); + + const actionAll = wrapper.querySelector('[data-map-filter-action="all"]'); + const actionNone = wrapper.querySelector('[data-map-filter-action="none"]'); + + if (actionAll) { + actionAll.addEventListener('click', () => { + tagButtons.forEach((button) => setButtonState(button, true)); + applyFilter(); + + if (eventLayerManager) { + setEventButtonState(true); + eventLayerManager.setVisible(true); + } + }); + } + + if (actionNone) { + actionNone.addEventListener('click', () => { + tagButtons.forEach((button) => setButtonState(button, false)); + applyFilter(); + + if (eventLayerManager) { + setEventButtonState(false); + eventLayerManager.setVisible(false); + } + }); + } + + const toggleButton = wrapper.querySelector('[data-map-filter-toggle="1"]'); + const filterGroup = wrapper.querySelector('.eventmanager-map-filter__group'); + + if (toggleButton && filterGroup) { + toggleButton.addEventListener('click', () => { + const expanded = toggleButton.getAttribute('aria-expanded') !== 'false'; + const nextExpanded = !expanded; + + toggleButton.setAttribute('aria-expanded', nextExpanded ? 'true' : 'false'); + filterGroup.hidden = !nextExpanded; + toggleButton.textContent = nextExpanded ? 'Bereiche ausblenden' : 'Bereiche anzeigen'; + }); + } + + if (eventToggleButton && eventLayerManager) { + eventToggleButton.addEventListener('click', () => { + const isActive = eventToggleButton.getAttribute('aria-pressed') === 'true'; + const nextActive = !isActive; + + setEventButtonState(nextActive); + eventLayerManager.setVisible(nextActive); + }); + } else if (eventToggleButton) { + eventToggleButton.disabled = true; + eventToggleButton.setAttribute('aria-disabled', 'true'); + } + + const streetStyleButton = wrapper.querySelector('[data-map-style-mode="street"]'); + const satelliteStyleButton = wrapper.querySelector('[data-map-style-mode="satellite"]'); + let currentStyleMode = 'street'; + + const applyStyleButtonState = () => { + if (streetStyleButton) { + streetStyleButton.setAttribute('aria-pressed', 'street' === currentStyleMode ? 'true' : 'false'); + } + + if (satelliteStyleButton) { + satelliteStyleButton.setAttribute('aria-pressed', 'satellite' === currentStyleMode ? 'true' : 'false'); + } + + }; + + const applyMapStyleMode = () => { + setSatelliteMode(map, 'satellite' === currentStyleMode); + applyStyleButtonState(); + }; + + if (streetStyleButton) { + streetStyleButton.addEventListener('click', () => { + currentStyleMode = 'street'; + applyMapStyleMode(); + }); + } + + if (satelliteStyleButton) { + satelliteStyleButton.addEventListener('click', () => { + currentStyleMode = 'satellite'; + applyMapStyleMode(); + }); + } + + applyFilter(); + applyMapStyleMode(); +}; + +const initLocationLayers = (map, locationItems, eventColor) => { + if (!locationItems.length) { + return { + setVisible: () => {}, + }; + } + + const sourceId = 'eventmanager-non-events-source'; + const layerId = 'eventmanager-non-events'; + + map.addSource(sourceId, { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: locationItems.map(toFeature), + }, + }); + + map.addLayer({ + id: layerId, + type: 'circle', + source: sourceId, + paint: { + 'circle-radius': 8, + 'circle-stroke-width': 0, + 'circle-color': eventColor, + 'circle-opacity': 1, + }, + }); + + map.on('click', layerId, (event) => { + const blockingLayers = [ + EVENT_CLUSTER_LAYER_ID, + EVENT_CLUSTER_SPIDERFY_LAYER_ID, + EVENT_UNCLUSTERED_LAYER_ID, + ].filter((id) => map.getLayer(id)); + + if (blockingLayers.length) { + const topEventFeatures = map.queryRenderedFeatures(event.point, { layers: blockingLayers }); + + if (topEventFeatures.length) { + return; + } + } + + const feature = event.features && event.features[0] ? event.features[0] : null; + + if (!feature) { + return; + } + + const popupHtml = feature?.properties?.popupHtml || ''; + const coordinates = event.lngLat ? [event.lngLat.lng, event.lngLat.lat] : null; + + if (!coordinates) { + return; + } + + new maplibregl.Popup({ offset: 15 }) + .setLngLat(coordinates) + .setHTML(String(popupHtml)) + .addTo(map); + }); + + map.on('mouseenter', layerId, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', layerId, () => { + map.getCanvas().style.cursor = ''; + }); + + const setLayerLayoutVisibility = (isVisible) => { + if (!map.getLayer(layerId)) { + return; + } + + map.setLayoutProperty(layerId, 'visibility', isVisible ? 'visible' : 'none'); + }; + + const fadeLayerOpacity = (value) => { + if (!map.getLayer(layerId)) { + return; + } + + map.setPaintProperty(layerId, 'circle-opacity-transition', { duration: EVENT_FADE_DURATION_MS, delay: 0 }); + map.setPaintProperty(layerId, 'circle-opacity', value); + }; + + return { + setVisible: (isVisible) => { + if (isVisible) { + setLayerLayoutVisibility(true); + fadeLayerOpacity(1); + + return; + } + + fadeLayerOpacity(0); + + window.setTimeout(() => { + setLayerLayoutVisibility(false); + }, EVENT_FADE_DURATION_MS + 10); + }, + }; +}; + +const initEventLayers = (map, eventItems, eventColor) => { const sourceId = 'eventmanager-events-source'; - const clusterLayerId = 'eventmanager-events-clusters'; + const clusterLayerId = EVENT_CLUSTER_LAYER_ID; + const clusterSpiderfyLayerId = EVENT_CLUSTER_SPIDERFY_LAYER_ID; const clusterCountLayerId = 'eventmanager-events-cluster-count'; - const unclusteredLayerId = 'eventmanager-events-unclustered'; + const unclusteredLayerId = EVENT_UNCLUSTERED_LAYER_ID; + const spiderfyHitAreaImageId = 'eventmanager-spiderfy-hit-area'; const features = eventItems.map(toFeature); + if (!map.hasImage(spiderfyHitAreaImageId)) { + map.addImage(spiderfyHitAreaImageId, { + width: 1, + height: 1, + data: new Uint8Array([0, 0, 0, 0]), + }); + } + map.addSource(sourceId, { type: 'geojson', data: { @@ -117,7 +780,7 @@ const initEventLayers = (map, eventItems) => { features, }, cluster: true, - clusterMaxZoom: 22, + clusterMaxZoom: 17, clusterRadius: 22, }); @@ -128,7 +791,9 @@ const initEventLayers = (map, eventItems) => { filter: ['has', 'point_count'], paint: { 'circle-radius': 18, - 'circle-opacity': 0.8, + 'circle-opacity': 1, + 'circle-color': eventColor, + 'circle-stroke-width': 0, }, }); @@ -139,7 +804,29 @@ const initEventLayers = (map, eventItems) => { filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count'], - 'text-size': 12, + 'text-size': 16, + 'text-font': [GLYPH_FONTSTACK], + 'text-allow-overlap': true, + 'text-ignore-placement': true, + }, + paint: { + 'text-color': '#ffffff', + 'text-opacity': 1, + }, + }); + + map.addLayer({ + id: clusterSpiderfyLayerId, + type: 'symbol', + source: sourceId, + filter: ['has', 'point_count'], + layout: { + 'icon-image': spiderfyHitAreaImageId, + 'icon-size': 36, + 'icon-allow-overlap': true, + }, + paint: { + 'icon-opacity': 0, }, }); @@ -149,8 +836,10 @@ const initEventLayers = (map, eventItems) => { source: sourceId, filter: ['!', ['has', 'point_count']], paint: { - 'circle-radius': 9, - 'circle-stroke-width': 2, + 'circle-radius': 11, + 'circle-color': eventColor, + 'circle-stroke-width': 0, + 'circle-opacity': 1, }, }); @@ -192,20 +881,7 @@ const initEventLayers = (map, eventItems) => { 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 { + const bindClusterZoomFallback = () => { map.on('click', clusterLayerId, (event) => { const feature = event.features && event.features[0] ? event.features[0] : null; @@ -236,6 +912,60 @@ const initEventLayers = (map, eventItems) => { }); }); }); + }; + + let spiderfyInstance = null; + + if (typeof Spiderfy === 'function') { + try { + const spiderfy = new Spiderfy(map, { + renderMethod: '3D', + minZoomLevel: 0, + zoomIncrement: 0, + closeOnLeafClick: false, + circleSpiralSwitchover: 10, + circleOptions: { + leavesSeparation: 70, + leavesOffset: [0, 0], + }, + spiralOptions: { + legLengthStart: 45, + legLengthFactor: 5, + leavesSeparation: 45, + leavesOffset: [0, 0], + }, + spiderLegsAreHidden: false, + spiderLegsWidth: 2, + spiderLegsColor: eventColor, + spiderLeavesLayout: { + 'text-field': '●', + 'text-size': 18, + 'text-allow-overlap': true, + }, + spiderLeavesPaint: { + 'text-color': eventColor, + 'text-opacity': 1, + }, + onLeafClick: (feature, spiderEvent) => { + const clickCoordinates = spiderEvent?.lngLat + ? [spiderEvent.lngLat.lng, spiderEvent.lngLat.lat] + : null; + + openEventPopup(feature, clickCoordinates); + }, + }); + + spiderfy.applyTo(clusterSpiderfyLayerId); + spiderfyInstance = spiderfy; + + if (map.getLayer(clusterCountLayerId)) { + map.moveLayer(clusterCountLayerId); + } + } catch (error) { + bindClusterZoomFallback(); + } + } else { + bindClusterZoomFallback(); } map.on('mouseenter', clusterLayerId, () => { @@ -246,6 +976,56 @@ const initEventLayers = (map, eventItems) => { map.getCanvas().style.cursor = ''; }); + const setLayerLayoutVisibility = (layerId, isVisible) => { + if (!map.getLayer(layerId)) { + return; + } + + map.setLayoutProperty(layerId, 'visibility', isVisible ? 'visible' : 'none'); + }; + + const fadeLayerOpacity = (layerId, property, value, duration = EVENT_FADE_DURATION_MS) => { + if (!map.getLayer(layerId)) { + return; + } + + map.setPaintProperty(layerId, `${property}-transition`, { duration, delay: 0 }); + map.setPaintProperty(layerId, property, value); + }; + + const eventLayerIds = [ + clusterLayerId, + clusterCountLayerId, + clusterSpiderfyLayerId, + unclusteredLayerId, + ]; + + return { + setVisible: (isVisible) => { + if (isVisible) { + eventLayerIds.forEach((layerId) => setLayerLayoutVisibility(layerId, true)); + + fadeLayerOpacity(clusterLayerId, 'circle-opacity', 1); + fadeLayerOpacity(unclusteredLayerId, 'circle-opacity', 1); + fadeLayerOpacity(clusterCountLayerId, 'text-opacity', 1); + + return; + } + + if (spiderfyInstance && typeof spiderfyInstance.unspiderfyAll === 'function') { + spiderfyInstance.unspiderfyAll(); + } + + fadeLayerOpacity(clusterLayerId, 'circle-opacity', 0); + fadeLayerOpacity(unclusteredLayerId, 'circle-opacity', 0); + fadeLayerOpacity(clusterCountLayerId, 'text-opacity', 0); + + window.setTimeout(() => { + eventLayerIds.forEach((layerId) => setLayerLayoutVisibility(layerId, false)); + }, EVENT_FADE_DURATION_MS + 10); + }, + }; + }; const initSingleMap = (container) => { @@ -254,69 +1034,140 @@ const initSingleMap = (container) => { } const items = parseItems(container); + const centerMode = container.dataset.mapCenterMode === 'custom' ? 'custom' : 'markers'; + const customLat = parseCoordinate(container.dataset.mapCenterLat); + const customLng = parseCoordinate(container.dataset.mapCenterLng); + const customZoom = Number(container.dataset.mapCenterZoom); + const eventColor = normalizeHexColor(container.dataset.mapEventColor, DEFAULT_EVENT_COLOR); + const hasCustomCenter = null !== customLat && null !== customLng; - if (!items.length || typeof maplibregl === 'undefined') { - return; + let initialCenter = DEFAULT_CENTER; + let initialZoom = DEFAULT_ZOOM; + + if ('custom' === centerMode && hasCustomCenter) { + initialCenter = [customLng, customLat]; + initialZoom = Number.isFinite(customZoom) ? customZoom : 12; + } else if (items.length) { + initialCenter = [items[0].longitude, items[0].latitude]; + initialZoom = 12; } container.dataset.mapInitialized = '1'; - if (typeof pmtiles !== 'undefined' && !window.__eventmanagerPmtilesRegistered) { - const protocol = new pmtiles.Protocol(); - maplibregl.addProtocol('pmtiles', protocol.tile); - window.__eventmanagerPmtilesRegistered = true; - } + ensureDependencies() + .then(() => { + if (typeof maplibregl === 'undefined') { + return; + } - 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], - }); + if (typeof pmtiles !== 'undefined' && !window.__eventmanagerPmtilesRegistered) { + const protocol = new pmtiles.Protocol(); + maplibregl.addProtocol('pmtiles', protocol.tile); + window.__eventmanagerPmtilesRegistered = true; + } - map.addControl(new maplibregl.NavigationControl({ visualizePitch: true })); + const style = container.dataset.mapStyle || 'https://maps.mummert.media/metadaten/world-light.json'; + const map = new maplibregl.Map({ + container, + style, + zoom: initialZoom, + pitch: 0, + bearing: 0, + center: initialCenter, + transformRequest: (url, resourceType) => { + if ('Glyphs' === resourceType) { + return { + url: rewriteGlyphUrl(url), + }; + } - const fitBounds = (coordinates) => { - const points = coordinates.filter((coord) => Array.isArray(coord) && coord.length === 2); + return { + url, + }; + }, + }); - if (!points.length) { - return; - } + map.on('style.load', () => { + applyDefaultProjection(map); + }); - if (1 === points.length) { - map.setCenter(points[0]); - map.setZoom(14); + map.on('styleimagemissing', (event) => { + const missingImageId = event?.id || ''; - return; - } + if (!missingImageId || map.hasImage(missingImageId)) { + return; + } - const bounds = new maplibregl.LngLatBounds(); - points.forEach((coord) => bounds.extend(coord)); + map.addImage(missingImageId, { + width: 1, + height: 1, + data: new Uint8Array([0, 0, 0, 0]), + }); + }); - map.fitBounds(bounds, { - padding: 60, - maxZoom: 15, + 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', () => { + 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 allCoordinates = items.map((item) => [item.longitude, item.latitude]); + const organizationColors = parseOrganizationColorScheme(container.dataset.mapOrganizationColors); + const organizationTagColorMap = buildOrganizationTagColorMap(container, organizationColors, organizationItems); + + const organizationMarkerManager = initOrganizationMarkers(map, organizationItems, organizationTagColorMap); + const locationLayerManager = initLocationLayers(map, locationItems, eventColor); + let eventLayerManager = null; + + if (eventItems.length) { + eventLayerManager = initEventLayers(map, eventItems, eventColor); + } + + const combinedEventLayerManager = { + setVisible: (isVisible) => { + locationLayerManager.setVisible(isVisible); + + if (eventLayerManager) { + eventLayerManager.setVisible(isVisible); + } + }, + }; + + bindExternalTagFilters(container, map, organizationMarkerManager, combinedEventLayerManager); + + if ('markers' === centerMode) { + fitBounds(allCoordinates); + } + }); + }) + .catch(() => { + container.dataset.mapInitialized = '0'; }); - }; - - 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 = () => { diff --git a/public/assets/vendor/map-gl-js-spiderfy.js b/public/assets/vendor/map-gl-js-spiderfy.js new file mode 100644 index 0000000..12b1124 --- /dev/null +++ b/public/assets/vendor/map-gl-js-spiderfy.js @@ -0,0 +1 @@ +var e={207:(e,t,r)=>{e.exports=r(452)},659:(e,t,r)=>{const n=r(156),o={};for(const e of Object.keys(n))o[n[e]]=e;const a={rgb:{channels:3,labels:"rgb"},hsl:{channels:3,labels:"hsl"},hsv:{channels:3,labels:"hsv"},hwb:{channels:3,labels:"hwb"},cmyk:{channels:4,labels:"cmyk"},xyz:{channels:3,labels:"xyz"},lab:{channels:3,labels:"lab"},lch:{channels:3,labels:"lch"},hex:{channels:1,labels:["hex"]},keyword:{channels:1,labels:["keyword"]},ansi16:{channels:1,labels:["ansi16"]},ansi256:{channels:1,labels:["ansi256"]},hcg:{channels:3,labels:["h","c","g"]},apple:{channels:3,labels:["r16","g16","b16"]},gray:{channels:1,labels:["gray"]}};e.exports=a;for(const e of Object.keys(a)){if(!("channels"in a[e]))throw new Error("missing channels property: "+e);if(!("labels"in a[e]))throw new Error("missing channel labels property: "+e);if(a[e].labels.length!==a[e].channels)throw new Error("channel and label counts mismatch: "+e);const{channels:t,labels:r}=a[e];delete a[e].channels,delete a[e].labels,Object.defineProperty(a[e],"channels",{value:t}),Object.defineProperty(a[e],"labels",{value:r})}a.rgb.hsl=function(e){const t=e[0]/255,r=e[1]/255,n=e[2]/255,o=Math.min(t,r,n),a=Math.max(t,r,n),i=a-o;let s,l;a===o?s=0:t===a?s=(r-n)/i:r===a?s=2+(n-t)/i:n===a&&(s=4+(t-r)/i),s=Math.min(60*s,360),s<0&&(s+=360);const c=(o+a)/2;return l=a===o?0:c<=.5?i/(a+o):i/(2-a-o),[s,100*l,100*c]},a.rgb.hsv=function(e){let t,r,n,o,a;const i=e[0]/255,s=e[1]/255,l=e[2]/255,c=Math.max(i,s,l),u=c-Math.min(i,s,l),h=function(e){return(c-e)/6/u+.5};return 0===u?(o=0,a=0):(a=u/c,t=h(i),r=h(s),n=h(l),i===c?o=n-r:s===c?o=1/3+t-n:l===c&&(o=2/3+r-t),o<0?o+=1:o>1&&(o-=1)),[360*o,100*a,100*c]},a.rgb.hwb=function(e){const t=e[0],r=e[1];let n=e[2];const o=a.rgb.hsl(e)[0],i=1/255*Math.min(t,Math.min(r,n));return n=1-1/255*Math.max(t,Math.max(r,n)),[o,100*i,100*n]},a.rgb.cmyk=function(e){const t=e[0]/255,r=e[1]/255,n=e[2]/255,o=Math.min(1-t,1-r,1-n);return[100*((1-t-o)/(1-o)||0),100*((1-r-o)/(1-o)||0),100*((1-n-o)/(1-o)||0),100*o]},a.rgb.keyword=function(e){const t=o[e];if(t)return t;let r,a=1/0;for(const t of Object.keys(n)){const o=(s=n[t],((i=e)[0]-s[0])**2+(i[1]-s[1])**2+(i[2]-s[2])**2);o.04045?((t+.055)/1.055)**2.4:t/12.92,r=r>.04045?((r+.055)/1.055)**2.4:r/12.92,n=n>.04045?((n+.055)/1.055)**2.4:n/12.92,[100*(.4124*t+.3576*r+.1805*n),100*(.2126*t+.7152*r+.0722*n),100*(.0193*t+.1192*r+.9505*n)]},a.rgb.lab=function(e){const t=a.rgb.xyz(e);let r=t[0],n=t[1],o=t[2];return r/=95.047,n/=100,o/=108.883,r=r>.008856?r**(1/3):7.787*r+16/116,n=n>.008856?n**(1/3):7.787*n+16/116,o=o>.008856?o**(1/3):7.787*o+16/116,[116*n-16,500*(r-n),200*(n-o)]},a.hsl.rgb=function(e){const t=e[0]/360,r=e[1]/100,n=e[2]/100;let o,a,i;if(0===r)return i=255*n,[i,i,i];o=n<.5?n*(1+r):n+r-n*r;const s=2*n-o,l=[0,0,0];for(let e=0;e<3;e++)a=t+1/3*-(e-1),a<0&&a++,a>1&&a--,i=6*a<1?s+6*(o-s)*a:2*a<1?o:3*a<2?s+(o-s)*(2/3-a)*6:s,l[e]=255*i;return l},a.hsl.hsv=function(e){const t=e[0];let r=e[1]/100,n=e[2]/100,o=r;const a=Math.max(n,.01);return n*=2,r*=n<=1?n:2-n,o*=a<=1?a:2-a,[t,100*(0===n?2*o/(a+o):2*r/(n+r)),(n+r)/2*100]},a.hsv.rgb=function(e){const t=e[0]/60,r=e[1]/100;let n=e[2]/100;const o=Math.floor(t)%6,a=t-Math.floor(t),i=255*n*(1-r),s=255*n*(1-r*a),l=255*n*(1-r*(1-a));switch(n*=255,o){case 0:return[n,l,i];case 1:return[s,n,i];case 2:return[i,n,l];case 3:return[i,s,n];case 4:return[l,i,n];case 5:return[n,i,s]}},a.hsv.hsl=function(e){const t=e[0],r=e[1]/100,n=e[2]/100,o=Math.max(n,.01);let a,i;i=(2-r)*n;const s=(2-r)*o;return a=r*o,a/=s<=1?s:2-s,a=a||0,i/=2,[t,100*a,100*i]},a.hwb.rgb=function(e){const t=e[0]/360;let r=e[1]/100,n=e[2]/100;const o=r+n;let a;o>1&&(r/=o,n/=o);const i=Math.floor(6*t),s=1-n;a=6*t-i,1&i&&(a=1-a);const l=r+a*(s-r);let c,u,h;switch(i){default:case 6:case 0:c=s,u=l,h=r;break;case 1:c=l,u=s,h=r;break;case 2:c=r,u=s,h=l;break;case 3:c=r,u=l,h=s;break;case 4:c=l,u=r,h=s;break;case 5:c=s,u=r,h=l}return[255*c,255*u,255*h]},a.cmyk.rgb=function(e){const t=e[0]/100,r=e[1]/100,n=e[2]/100,o=e[3]/100;return[255*(1-Math.min(1,t*(1-o)+o)),255*(1-Math.min(1,r*(1-o)+o)),255*(1-Math.min(1,n*(1-o)+o))]},a.xyz.rgb=function(e){const t=e[0]/100,r=e[1]/100,n=e[2]/100;let o,a,i;return o=3.2406*t+-1.5372*r+-.4986*n,a=-.9689*t+1.8758*r+.0415*n,i=.0557*t+-.204*r+1.057*n,o=o>.0031308?1.055*o**(1/2.4)-.055:12.92*o,a=a>.0031308?1.055*a**(1/2.4)-.055:12.92*a,i=i>.0031308?1.055*i**(1/2.4)-.055:12.92*i,o=Math.min(Math.max(0,o),1),a=Math.min(Math.max(0,a),1),i=Math.min(Math.max(0,i),1),[255*o,255*a,255*i]},a.xyz.lab=function(e){let t=e[0],r=e[1],n=e[2];return t/=95.047,r/=100,n/=108.883,t=t>.008856?t**(1/3):7.787*t+16/116,r=r>.008856?r**(1/3):7.787*r+16/116,n=n>.008856?n**(1/3):7.787*n+16/116,[116*r-16,500*(t-r),200*(r-n)]},a.lab.xyz=function(e){let t,r,n;r=(e[0]+16)/116,t=e[1]/500+r,n=r-e[2]/200;const o=r**3,a=t**3,i=n**3;return r=o>.008856?o:(r-16/116)/7.787,t=a>.008856?a:(t-16/116)/7.787,n=i>.008856?i:(n-16/116)/7.787,t*=95.047,r*=100,n*=108.883,[t,r,n]},a.lab.lch=function(e){const t=e[0],r=e[1],n=e[2];let o;return o=360*Math.atan2(n,r)/2/Math.PI,o<0&&(o+=360),[t,Math.sqrt(r*r+n*n),o]},a.lch.lab=function(e){const t=e[0],r=e[1],n=e[2]/360*2*Math.PI;return[t,r*Math.cos(n),r*Math.sin(n)]},a.rgb.ansi16=function(e,t=null){const[r,n,o]=e;let i=null===t?a.rgb.hsv(e)[2]:t;if(i=Math.round(i/50),0===i)return 30;let s=30+(Math.round(o/255)<<2|Math.round(n/255)<<1|Math.round(r/255));return 2===i&&(s+=60),s},a.hsv.ansi16=function(e){return a.rgb.ansi16(a.hsv.rgb(e),e[2])},a.rgb.ansi256=function(e){const t=e[0],r=e[1],n=e[2];return t===r&&r===n?t<8?16:t>248?231:Math.round((t-8)/247*24)+232:16+36*Math.round(t/255*5)+6*Math.round(r/255*5)+Math.round(n/255*5)},a.ansi16.rgb=function(e){let t=e%10;if(0===t||7===t)return e>50&&(t+=3.5),t=t/10.5*255,[t,t,t];const r=.5*(1+~~(e>50));return[(1&t)*r*255,(t>>1&1)*r*255,(t>>2&1)*r*255]},a.ansi256.rgb=function(e){if(e>=232){const t=10*(e-232)+8;return[t,t,t]}let t;return e-=16,[Math.floor(e/36)/5*255,Math.floor((t=e%36)/6)/5*255,t%6/5*255]},a.rgb.hex=function(e){const t=(((255&Math.round(e[0]))<<16)+((255&Math.round(e[1]))<<8)+(255&Math.round(e[2]))).toString(16).toUpperCase();return"000000".substring(t.length)+t},a.hex.rgb=function(e){const t=e.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!t)return[0,0,0];let r=t[0];3===t[0].length&&(r=r.split("").map((e=>e+e)).join(""));const n=parseInt(r,16);return[n>>16&255,n>>8&255,255&n]},a.rgb.hcg=function(e){const t=e[0]/255,r=e[1]/255,n=e[2]/255,o=Math.max(Math.max(t,r),n),a=Math.min(Math.min(t,r),n),i=o-a;let s,l;return s=i<1?a/(1-i):0,l=i<=0?0:o===t?(r-n)/i%6:o===r?2+(n-t)/i:4+(t-r)/i,l/=6,l%=1,[360*l,100*i,100*s]},a.hsl.hcg=function(e){const t=e[1]/100,r=e[2]/100,n=r<.5?2*t*r:2*t*(1-r);let o=0;return n<1&&(o=(r-.5*n)/(1-n)),[e[0],100*n,100*o]},a.hsv.hcg=function(e){const t=e[1]/100,r=e[2]/100,n=t*r;let o=0;return n<1&&(o=(r-n)/(1-n)),[e[0],100*n,100*o]},a.hcg.rgb=function(e){const t=e[0]/360,r=e[1]/100,n=e[2]/100;if(0===r)return[255*n,255*n,255*n];const o=[0,0,0],a=t%1*6,i=a%1,s=1-i;let l=0;switch(Math.floor(a)){case 0:o[0]=1,o[1]=i,o[2]=0;break;case 1:o[0]=s,o[1]=1,o[2]=0;break;case 2:o[0]=0,o[1]=1,o[2]=i;break;case 3:o[0]=0,o[1]=s,o[2]=1;break;case 4:o[0]=i,o[1]=0,o[2]=1;break;default:o[0]=1,o[1]=0,o[2]=s}return l=(1-r)*n,[255*(r*o[0]+l),255*(r*o[1]+l),255*(r*o[2]+l)]},a.hcg.hsv=function(e){const t=e[1]/100,r=t+e[2]/100*(1-t);let n=0;return r>0&&(n=t/r),[e[0],100*n,100*r]},a.hcg.hsl=function(e){const t=e[1]/100,r=e[2]/100*(1-t)+.5*t;let n=0;return r>0&&r<.5?n=t/(2*r):r>=.5&&r<1&&(n=t/(2*(1-r))),[e[0],100*n,100*r]},a.hcg.hwb=function(e){const t=e[1]/100,r=t+e[2]/100*(1-t);return[e[0],100*(r-t),100*(1-r)]},a.hwb.hcg=function(e){const t=e[1]/100,r=1-e[2]/100,n=r-t;let o=0;return n<1&&(o=(r-n)/(1-n)),[e[0],100*n,100*o]},a.apple.rgb=function(e){return[e[0]/65535*255,e[1]/65535*255,e[2]/65535*255]},a.rgb.apple=function(e){return[e[0]/255*65535,e[1]/255*65535,e[2]/255*65535]},a.gray.rgb=function(e){return[e[0]/100*255,e[0]/100*255,e[0]/100*255]},a.gray.hsl=function(e){return[0,0,e[0]]},a.gray.hsv=a.gray.hsl,a.gray.hwb=function(e){return[0,100,e[0]]},a.gray.cmyk=function(e){return[0,0,0,e[0]]},a.gray.lab=function(e){return[e[0],0,0]},a.gray.hex=function(e){const t=255&Math.round(e[0]/100*255),r=((t<<16)+(t<<8)+t).toString(16).toUpperCase();return"000000".substring(r.length)+r},a.rgb.gray=function(e){return[(e[0]+e[1]+e[2])/3/255*100]}},734:(e,t,r)=>{const n=r(659),o=r(507),a={};Object.keys(n).forEach((e=>{a[e]={},Object.defineProperty(a[e],"channels",{value:n[e].channels}),Object.defineProperty(a[e],"labels",{value:n[e].labels});const t=o(e);Object.keys(t).forEach((r=>{const n=t[r];a[e][r]=function(e){const t=function(...t){const r=t[0];if(null==r)return r;r.length>1&&(t=r);const n=e(t);if("object"==typeof n)for(let e=n.length,t=0;t1&&(t=r),e(t))};return"conversion"in e&&(t.conversion=e.conversion),t}(n)}))})),e.exports=a},507:(e,t,r)=>{const n=r(659);function o(e,t){return function(r){return t(e(r))}}function a(e,t){const r=[t[e].parent,e];let a=n[t[e].parent][e],i=t[e].parent;for(;t[i].parent;)r.unshift(t[i].parent),a=o(n[t[i].parent][i],a),i=t[i].parent;return a.conversion=r,a}e.exports=function(e){const t=function(e){const t=function(){const e={},t=Object.keys(n);for(let r=t.length,n=0;n{e.exports={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]}},854:(e,t,r)=>{var n=r(156),o=r(872),a=Object.hasOwnProperty,i={};for(var s in n)a.call(n,s)&&(i[n[s]]=s);var l=e.exports={to:{},get:{}};function c(e,t,r){return Math.min(Math.max(t,e),r)}function u(e){var t=Math.round(e).toString(16).toUpperCase();return t.length<2?"0"+t:t}l.get=function(e){var t,r;switch(e.substring(0,3).toLowerCase()){case"hsl":t=l.get.hsl(e),r="hsl";break;case"hwb":t=l.get.hwb(e),r="hwb";break;default:t=l.get.rgb(e),r="rgb"}return t?{model:r,value:t}:null},l.get.rgb=function(e){if(!e)return null;var t,r,o,i=[0,0,0,1];if(t=e.match(/^#([a-f0-9]{6})([a-f0-9]{2})?$/i)){for(o=t[2],t=t[1],r=0;r<3;r++){var s=2*r;i[r]=parseInt(t.slice(s,s+2),16)}o&&(i[3]=parseInt(o,16)/255)}else if(t=e.match(/^#([a-f0-9]{3,4})$/i)){for(o=(t=t[1])[3],r=0;r<3;r++)i[r]=parseInt(t[r]+t[r],16);o&&(i[3]=parseInt(o+o,16)/255)}else if(t=e.match(/^rgba?\(\s*([+-]?\d+)(?=[\s,])\s*(?:,\s*)?([+-]?\d+)(?=[\s,])\s*(?:,\s*)?([+-]?\d+)\s*(?:[,|\/]\s*([+-]?[\d\.]+)(%?)\s*)?\)$/)){for(r=0;r<3;r++)i[r]=parseInt(t[r+1],0);t[4]&&(t[5]?i[3]=.01*parseFloat(t[4]):i[3]=parseFloat(t[4]))}else{if(!(t=e.match(/^rgba?\(\s*([+-]?[\d\.]+)\%\s*,?\s*([+-]?[\d\.]+)\%\s*,?\s*([+-]?[\d\.]+)\%\s*(?:[,|\/]\s*([+-]?[\d\.]+)(%?)\s*)?\)$/)))return(t=e.match(/^(\w+)$/))?"transparent"===t[1]?[0,0,0,0]:a.call(n,t[1])?((i=n[t[1]])[3]=1,i):null:null;for(r=0;r<3;r++)i[r]=Math.round(2.55*parseFloat(t[r+1]));t[4]&&(t[5]?i[3]=.01*parseFloat(t[4]):i[3]=parseFloat(t[4]))}for(r=0;r<3;r++)i[r]=c(i[r],0,255);return i[3]=c(i[3],0,1),i},l.get.hsl=function(e){if(!e)return null;var t=e.match(/^hsla?\(\s*([+-]?(?:\d{0,3}\.)?\d+)(?:deg)?\s*,?\s*([+-]?[\d\.]+)%\s*,?\s*([+-]?[\d\.]+)%\s*(?:[,|\/]\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:[eE][+-]?\d+)?)\s*)?\)$/);if(t){var r=parseFloat(t[4]);return[(parseFloat(t[1])%360+360)%360,c(parseFloat(t[2]),0,100),c(parseFloat(t[3]),0,100),c(isNaN(r)?1:r,0,1)]}return null},l.get.hwb=function(e){if(!e)return null;var t=e.match(/^hwb\(\s*([+-]?\d{0,3}(?:\.\d+)?)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:[eE][+-]?\d+)?)\s*)?\)$/);if(t){var r=parseFloat(t[4]);return[(parseFloat(t[1])%360+360)%360,c(parseFloat(t[2]),0,100),c(parseFloat(t[3]),0,100),c(isNaN(r)?1:r,0,1)]}return null},l.to.hex=function(){var e=o(arguments);return"#"+u(e[0])+u(e[1])+u(e[2])+(e[3]<1?u(Math.round(255*e[3])):"")},l.to.rgb=function(){var e=o(arguments);return e.length<4||1===e[3]?"rgb("+Math.round(e[0])+", "+Math.round(e[1])+", "+Math.round(e[2])+")":"rgba("+Math.round(e[0])+", "+Math.round(e[1])+", "+Math.round(e[2])+", "+e[3]+")"},l.to.rgb.percent=function(){var e=o(arguments),t=Math.round(e[0]/255*100),r=Math.round(e[1]/255*100),n=Math.round(e[2]/255*100);return e.length<4||1===e[3]?"rgb("+t+"%, "+r+"%, "+n+"%)":"rgba("+t+"%, "+r+"%, "+n+"%, "+e[3]+")"},l.to.hsl=function(){var e=o(arguments);return e.length<4||1===e[3]?"hsl("+e[0]+", "+e[1]+"%, "+e[2]+"%)":"hsla("+e[0]+", "+e[1]+"%, "+e[2]+"%, "+e[3]+")"},l.to.hwb=function(){var e=o(arguments),t="";return e.length>=4&&1!==e[3]&&(t=", "+e[3]),"hwb("+e[0]+", "+e[1]+"%, "+e[2]+"%"+t+")"},l.to.keyword=function(e){return i[e.slice(0,3)]}},520:(e,t,r)=>{const n=r(854),o=r(734),a=[].slice,i=["keyword","gray","hex"],s={};for(const e of Object.keys(o))s[a.call(o[e].labels).sort().join("")]=e;const l={};function c(e,t){if(!(this instanceof c))return new c(e,t);if(t&&t in i&&(t=null),t&&!(t in o))throw new Error("Unknown model: "+t);let r,u;if(null==e)this.model="rgb",this.color=[0,0,0],this.valpha=1;else if(e instanceof c)this.model=e.model,this.color=e.color.slice(),this.valpha=e.valpha;else if("string"==typeof e){const t=n.get(e);if(null===t)throw new Error("Unable to parse color from string: "+e);this.model=t.model,u=o[this.model].channels,this.color=t.value.slice(0,u),this.valpha="number"==typeof t.value[u]?t.value[u]:1}else if(e.length>0){this.model=t||"rgb",u=o[this.model].channels;const r=a.call(e,0,u);this.color=p(r,u),this.valpha="number"==typeof e[u]?e[u]:1}else if("number"==typeof e)this.model="rgb",this.color=[e>>16&255,e>>8&255,255&e],this.valpha=1;else{this.valpha=1;const t=Object.keys(e);"alpha"in e&&(t.splice(t.indexOf("alpha"),1),this.valpha="number"==typeof e.alpha?e.alpha:0);const n=t.sort().join("");if(!(n in s))throw new Error("Unable to parse color from object: "+JSON.stringify(e));this.model=s[n];const a=o[this.model].labels,i=[];for(r=0;r0?new c(this.color.concat(Math.max(0,Math.min(1,e))),this.model):this.valpha},red:u("rgb",0,h(255)),green:u("rgb",1,h(255)),blue:u("rgb",2,h(255)),hue:u(["hsl","hsv","hsl","hwb","hcg"],0,(e=>(e%360+360)%360)),saturationl:u("hsl",1,h(100)),lightness:u("hsl",2,h(100)),saturationv:u("hsv",1,h(100)),value:u("hsv",2,h(100)),chroma:u("hcg",1,h(100)),gray:u("hcg",2,h(100)),white:u("hwb",1,h(100)),wblack:u("hwb",2,h(100)),cyan:u("cmyk",0,h(100)),magenta:u("cmyk",1,h(100)),yellow:u("cmyk",2,h(100)),black:u("cmyk",3,h(100)),x:u("xyz",0,h(100)),y:u("xyz",1,h(100)),z:u("xyz",2,h(100)),l:u("lab",0,h(100)),a:u("lab",1),b:u("lab",2),keyword(e){return arguments.length>0?new c(e):o[this.model].keyword(this.color)},hex(e){return arguments.length>0?new c(e):n.to.hex(this.rgb().round().color)},rgbNumber(){const e=this.rgb().color;return(255&e[0])<<16|(255&e[1])<<8|255&e[2]},luminosity(){const e=this.rgb().color,t=[];for(const[r,n]of e.entries()){const e=n/255;t[r]=e<=.03928?e/12.92:((e+.055)/1.055)**2.4}return.2126*t[0]+.7152*t[1]+.0722*t[2]},contrast(e){const t=this.luminosity(),r=e.luminosity();return t>r?(t+.05)/(r+.05):(r+.05)/(t+.05)},level(e){const t=this.contrast(e);return t>=7.1?"AAA":t>=4.5?"AA":""},isDark(){const e=this.rgb().color;return(299*e[0]+587*e[1]+114*e[2])/1e3<128},isLight(){return!this.isDark()},negate(){const e=this.rgb();for(let t=0;t<3;t++)e.color[t]=255-e.color[t];return e},lighten(e){const t=this.hsl();return t.color[2]+=t.color[2]*e,t},darken(e){const t=this.hsl();return t.color[2]-=t.color[2]*e,t},saturate(e){const t=this.hsl();return t.color[1]+=t.color[1]*e,t},desaturate(e){const t=this.hsl();return t.color[1]-=t.color[1]*e,t},whiten(e){const t=this.hwb();return t.color[1]+=t.color[1]*e,t},blacken(e){const t=this.hwb();return t.color[2]+=t.color[2]*e,t},grayscale(){const e=this.rgb().color,t=.3*e[0]+.59*e[1]+.11*e[2];return c.rgb(t,t,t)},fade(e){return this.alpha(this.valpha-this.valpha*e)},opaquer(e){return this.alpha(this.valpha+this.valpha*e)},rotate(e){const t=this.hsl();let r=t.color[0];return r=(r+e)%360,r=r<0?360+r:r,t.color[0]=r,t},mix(e,t){if(!e||!e.rgb)throw new Error('Argument to "mix" was not a Color instance, but rather an instance of '+typeof e);const r=e.rgb(),n=this.rgb(),o=void 0===t?.5:t,a=2*o-1,i=r.alpha()-n.alpha(),s=((a*i==-1?a:(a+i)/(1+a*i))+1)/2,l=1-s;return c.rgb(s*r.red()+l*n.red(),s*r.green()+l*n.green(),s*r.blue()+l*n.blue(),r.alpha()*o+n.alpha()*(1-o))}};for(const e of Object.keys(o)){if(i.includes(e))continue;const t=o[e].channels;c.prototype[e]=function(){if(this.model===e)return new c(this);if(arguments.length>0)return new c(arguments,e);const r="number"==typeof arguments[t]?t:this.valpha;return new c((n=o[this.model][e].raw(this.color),Array.isArray(n)?n:[n]).concat(r),e);var n},c[e]=function(r){return"number"==typeof r&&(r=p(a.call(arguments),t)),new c(r,e)}}function u(e,t,r){e=Array.isArray(e)?e:[e];for(const n of e)(l[n]||(l[n]=[]))[t]=r;return e=e[0],function(n){let o;return arguments.length>0?(r&&(n=r(n)),o=this[e](),o.color[t]=n,o):(o=this[e]().color[t],r&&(o=r(o)),o)}}function h(e){return function(t){return Math.max(0,Math.min(e,t))}}function p(e,t){for(let r=0;r{e.exports=function(e){return!(!e||"string"==typeof e)&&(e instanceof Array||Array.isArray(e)||e.length>=0&&(e.splice instanceof Function||Object.getOwnPropertyDescriptor(e,e.length-1)&&"String"!==e.constructor.name))}},452:e=>{var t=function(e){var t,r=Object.prototype,n=r.hasOwnProperty,o="function"==typeof Symbol?Symbol:{},a=o.iterator||"@@iterator",i=o.asyncIterator||"@@asyncIterator",s=o.toStringTag||"@@toStringTag";function l(e,t,r){return Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}),e[t]}try{l({},"")}catch(e){l=function(e,t,r){return e[t]=r}}function c(e,t,r,n){var o=t&&t.prototype instanceof v?t:v,a=Object.create(o.prototype),i=new P(n||[]);return a._invoke=function(e,t,r){var n=h;return function(o,a){if(n===f)throw new Error("Generator is already running");if(n===d){if("throw"===o)throw a;return C()}for(r.method=o,r.arg=a;;){var i=r.delegate;if(i){var s=j(i,r);if(s){if(s===y)continue;return s}}if("next"===r.method)r.sent=r._sent=r.arg;else if("throw"===r.method){if(n===h)throw n=d,r.arg;r.dispatchException(r.arg)}else"return"===r.method&&r.abrupt("return",r.arg);n=f;var l=u(e,t,r);if("normal"===l.type){if(n=r.done?d:p,l.arg===y)continue;return{value:l.arg,done:r.done}}"throw"===l.type&&(n=d,r.method="throw",r.arg=l.arg)}}}(e,r,i),a}function u(e,t,r){try{return{type:"normal",arg:e.call(t,r)}}catch(e){return{type:"throw",arg:e}}}e.wrap=c;var h="suspendedStart",p="suspendedYield",f="executing",d="completed",y={};function v(){}function g(){}function m(){}var b={};l(b,a,(function(){return this}));var w=Object.getPrototypeOf,O=w&&w(w(_([])));O&&O!==r&&n.call(O,a)&&(b=O);var k=m.prototype=v.prototype=Object.create(b);function L(e){["next","throw","return"].forEach((function(t){l(e,t,(function(e){return this._invoke(t,e)}))}))}function x(e,t){function r(o,a,i,s){var l=u(e[o],e,a);if("throw"!==l.type){var c=l.arg,h=c.value;return h&&"object"==typeof h&&n.call(h,"__await")?t.resolve(h.__await).then((function(e){r("next",e,i,s)}),(function(e){r("throw",e,i,s)})):t.resolve(h).then((function(e){c.value=e,i(c)}),(function(e){return r("throw",e,i,s)}))}s(l.arg)}var o;this._invoke=function(e,n){function a(){return new t((function(t,o){r(e,n,t,o)}))}return o=o?o.then(a,a):a()}}function j(e,r){var n=e.iterator[r.method];if(n===t){if(r.delegate=null,"throw"===r.method){if(e.iterator.return&&(r.method="return",r.arg=t,j(e,r),"throw"===r.method))return y;r.method="throw",r.arg=new TypeError("The iterator does not provide a 'throw' method")}return y}var o=u(n,e.iterator,r.arg);if("throw"===o.type)return r.method="throw",r.arg=o.arg,r.delegate=null,y;var a=o.arg;return a?a.done?(r[e.resultName]=a.value,r.next=e.nextLoc,"return"!==r.method&&(r.method="next",r.arg=t),r.delegate=null,y):a:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,y)}function M(e){var t={tryLoc:e[0]};1 in e&&(t.catchLoc=e[1]),2 in e&&(t.finallyLoc=e[2],t.afterLoc=e[3]),this.tryEntries.push(t)}function S(e){var t=e.completion||{};t.type="normal",delete t.arg,e.completion=t}function P(e){this.tryEntries=[{tryLoc:"root"}],e.forEach(M,this),this.reset(!0)}function _(e){if(e){var r=e[a];if(r)return r.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length)){var o=-1,i=function r(){for(;++o=0;--a){var i=this.tryEntries[a],s=i.completion;if("root"===i.tryLoc)return o("end");if(i.tryLoc<=this.prev){var l=n.call(i,"catchLoc"),c=n.call(i,"finallyLoc");if(l&&c){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--t){var r=this.tryEntries[t];if(r.finallyLoc===e)return this.complete(r.completion,r.afterLoc),S(r),y}},catch:function(e){for(var t=this.tryEntries.length-1;t>=0;--t){var r=this.tryEntries[t];if(r.tryLoc===e){var n=r.completion;if("throw"===n.type){var o=n.arg;S(r)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(e,r,n){return this.delegate={iterator:_(e),resultName:r,nextLoc:n},"next"===this.method&&(this.arg=t),y}},e}(e.exports);try{regeneratorRuntime=t}catch(e){"object"==typeof globalThis?globalThis.regeneratorRuntime=t:Function("r","regeneratorRuntime = r")(t)}},872:(e,t,r)=>{var n=r(195),o=Array.prototype.concat,a=Array.prototype.slice,i=e.exports=function(e){for(var t=[],r=0,i=e.length;r{for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t);var n={};function o(e,t,r){return t in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function a(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var r=0;re.length)&&(t=e.length);for(var r=0,n=new Array(t);rF});var v=r(207);const g={maxLeaves:255,minZoomLevel:0,zoomIncrement:2,closeOnLeafClick:!0,circleSpiralSwitchover:10,circleOptions:{leavesSeparation:50,leavesOffset:[0,0]},spiralOptions:{legLengthStart:25,legLengthFactor:2.2,leavesSeparation:30,leavesOffset:[0,0]},spiderLegsAreHidden:!1,spiderLegsWidth:1,spiderLegsColor:"rgba(100, 100, 100, .7)",spiderLeavesLayout:null,spiderLeavesPaint:null};var m=r(520);function b(e,t){return w.apply(this,arguments)}function w(){return(w=y(v.mark((function e(t,r){var n,o,a;return v.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return n=t.map(r),e.next=3,Promise.all(n);case 3:return o=e.sent,a=o.findIndex((function(e){return e})),e.abrupt("return",t[a]);case 6:case"end":return e.stop()}}),e)})))).apply(this,arguments)}function O(e,t,r){return k.apply(this,arguments)}function k(){return(k=y(v.mark((function e(t,r,n){return v.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.abrupt("return",new Promise((function(e,o){t.getClusterLeaves.toString().includes("sendAsync")?t.getClusterLeaves(r,n,0).then((function(t){e(t)})):t.getClusterLeaves(r,n,0,(function(t,r){t?o(t):e(r)}))})));case 1:case"end":return e.stop()}}),e)})))).apply(this,arguments)}function L(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,n)}return r}function x(e){for(var t=1;tid ?? 0)); $dataElementId = sprintf('%s-data', $containerId); + $filterWrapperId = sprintf('%s-filter', $containerId); + $filterGroupId = sprintf('%s-filter-group', $containerId); + $showOrganizations = '1' === (string) ($model->mapShowOrganizations ?? ''); + $showExternalOrganizations = '1' === (string) ($model->mapShowExternalOrganizations ?? ''); + $showEvents = '1' === (string) ($model->mapShowEvents ?? ''); + $centerMode = (string) ($model->mapCenterMode ?? self::DEFAULT_CENTER_MODE); + $eventColor = $this->normalizeHexColor((string) ($model->mapEventColor ?? self::DEFAULT_EVENT_COLOR)); + $organizationColorScheme = trim((string) ($model->mapOrganizationColorScheme ?? '')); + + if (!in_array($centerMode, ['markers', 'custom'], true)) { + $centerMode = self::DEFAULT_CENTER_MODE; + } $template->set('mapContainerId', $containerId); $template->set('mapDataElementId', $dataElementId); + $template->set('mapFilterWrapperId', $filterWrapperId); + $template->set('mapFilterGroupId', $filterGroupId); $template->set('mapStyleUrl', self::MAP_STYLE_URL); + $template->set('mapCenterMode', $centerMode); + $template->set('mapEventColor', $eventColor); + $template->set('mapOrganizationColorScheme', $organizationColorScheme); + $template->set('mapCenterLat', trim((string) ($model->mapCenterLat ?? ''))); + $template->set('mapCenterLng', trim((string) ($model->mapCenterLng ?? ''))); + $template->set('mapCenterZoom', (int) ($model->mapCenterZoom ?? 12)); $template->set('mapItemsJson', json_encode( - $this->mapModuleDataProvider->getMapItems(), + $this->mapModuleDataProvider->getMapItems($showOrganizations, $showEvents, $showExternalOrganizations), \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR, )); + $template->set('mapOrganizationTags', $this->mapModuleDataProvider->getOrganizationTags()); return $template->getResponse(); } + + private function normalizeHexColor(string $value): string + { + $normalized = strtoupper(trim($value)); + + if (preg_match('/^#[0-9A-F]{6}$/', $normalized)) { + return $normalized; + } + + if (preg_match('/^[0-9A-F]{6}$/', $normalized)) { + return '#'.$normalized; + } + + if (preg_match('/^#[0-9A-F]{3}$/', $normalized)) { + return sprintf( + '#%1$s%1$s%2$s%2$s%3$s%3$s', + $normalized[1], + $normalized[2], + $normalized[3], + ); + } + + if (preg_match('/^[0-9A-F]{3}$/', $normalized)) { + return sprintf( + '#%1$s%1$s%2$s%2$s%3$s%3$s', + $normalized[0], + $normalized[1], + $normalized[2], + ); + } + + return self::DEFAULT_EVENT_COLOR; + } } diff --git a/src/Service/MapModuleDataProvider.php b/src/Service/MapModuleDataProvider.php index 9896fac..a764dbb 100644 --- a/src/Service/MapModuleDataProvider.php +++ b/src/Service/MapModuleDataProvider.php @@ -6,7 +6,9 @@ namespace MummertMedia\EventManagerBundle\Service; use Contao\Config; use Contao\Date; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder; class MapModuleDataProvider @@ -22,113 +24,295 @@ class MapModuleDataProvider } /** - * @return list}> + * @return list}> */ - public function getMapItems(): array + public function getMapItems(bool $includeOrganizations = true, bool $includeEvents = true, bool $includeExternalOrganizations = false): array { + if (!$includeOrganizations && !$includeEvents) { + return []; + } + $locationTable = $this->resolveExistingTable(['tl_location']); $organizationTable = $this->resolveExistingTable(['tl_organization', 'tl_organisation']); + $locationGeoColumns = null !== $locationTable ? $this->resolveGeoColumns($locationTable) : null; - if (null === $locationTable) { + if (null === $locationTable || null === $locationGeoColumns) { return []; } $items = []; $seen = []; + $organizationRows = $includeOrganizations + ? $this->fetchOrganizationRows($organizationTable, $locationTable, $locationGeoColumns, $includeExternalOrganizations) + : []; + $organizationTagMap = $this->fetchOrganizationTagMap(array_values(array_unique(array_map( + static fn (array $row): int => (int) ($row['id'] ?? 0), + $organizationRows, + )))); - foreach ($this->fetchOrganizationRows($organizationTable) as $row) { - $id = (int) ($row['id'] ?? 0); + if ($includeOrganizations) { + foreach ($organizationRows as $row) { + $id = (int) ($row['id'] ?? 0); - if ($id <= 0 || isset($seen['organisation'][$id])) { - continue; + if ($id <= 0 || isset($seen['organisation'][$id])) { + continue; + } + + $locationId = (int) ($row['location_id'] ?? 0); + + if ($locationId > 0) { + $coords = $this->extractCoordinates($row['location_latitude'] ?? null, $row['location_longitude'] ?? null); + + if (null === $coords) { + continue; + } + } else { + $coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null); + } + + if (null === $coords) { + continue; + } + + $seen['organisation'][$id] = true; + $tagData = $organizationTagMap[$id] ?? ['ids' => [], 'labels' => []]; + $items[] = [ + 'type' => 'organisation', + 'markerType' => $this->buildOrganizationMarkerType($tagData['ids']), + 'id' => $id, + 'title' => trim((string) ($row['title'] ?? '')), + 'latitude' => $coords['latitude'], + 'longitude' => $coords['longitude'], + 'extra' => [ + 'organizationTagIds' => $tagData['ids'], + 'organizationTagLabels' => $tagData['labels'], + ], + ]; } - - $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 ($includeEvents) { + foreach ($this->fetchLocationRows($locationTable, $locationGeoColumns) as $row) { + $id = (int) ($row['id'] ?? 0); - if ($id <= 0 || isset($seen['location'][$id])) { - continue; + 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', + 'markerType' => 'location', + 'id' => $id, + 'title' => trim((string) ($row['title'] ?? '')), + 'latitude' => $coords['latitude'], + 'longitude' => $coords['longitude'], + 'extra' => [], + ]; } - $coords = $this->extractCoordinates($row['latitude'] ?? null, $row['longitude'] ?? null); + foreach ($this->fetchEventRows($locationTable, $locationGeoColumns) as $row) { + $id = (int) ($row['event_id'] ?? 0); - if (null === $coords) { - continue; + 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', + 'markerType' => '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)), + ], + ]; } - - $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> + * @return list */ - private function fetchOrganizationRows(?string $table): array + public function getOrganizationTags(): array { - if (null === $table) { + if (!$this->tableExists('tl_tags')) { + return []; + } + + $columns = $this->getColumnMap('tl_tags'); + $labelColumn = isset($columns['title']) ? 'title' : (isset($columns['tag']) ? 'tag' : null); + + if (null === $labelColumn) { return []; } $qb = $this->connection->createQueryBuilder(); $qb - ->select('o.id', 'o.title', 'o.latitude', 'o.longitude') + ->select('t.id', sprintf('t.%s AS label', $labelColumn)) + ->from('tl_tags', 't') + ->orderBy(sprintf('t.%s', $labelColumn), 'ASC'); + + if (isset($columns['invisible'])) { + $qb->andWhere("(t.invisible IS NULL OR t.invisible = '' OR t.invisible = '0')"); + } + + $rows = $qb->executeQuery()->fetchAllAssociative(); + $tags = []; + $seen = []; + + foreach ($rows as $row) { + $id = (int) ($row['id'] ?? 0); + $label = trim((string) ($row['label'] ?? '')); + + if ($id <= 0 || '' === $label || isset($seen[$id])) { + continue; + } + + $seen[$id] = true; + $tags[] = [ + 'id' => $id, + 'label' => $label, + ]; + } + + return $tags; + } + + /** + * @param list $organizationIds + * @return array, labels: list}> + */ + private function fetchOrganizationTagMap(array $organizationIds): array + { + $organizationIds = array_values(array_unique(array_filter($organizationIds, static fn (int $id): bool => $id > 0))); + + if ([] === $organizationIds || !$this->tableExists('tl_tags_rel') || !$this->tableExists('tl_tags')) { + return []; + } + + $rows = $this->connection->executeQuery( + 'SELECT r.pid AS organization_id, r.tag_id, t.tag AS label + FROM tl_tags_rel r + INNER JOIN tl_tags t ON t.id = r.tag_id + WHERE r.ptable = ? AND r.field = ? AND r.pid IN (?) + ORDER BY r.pid ASC, r.tag_id ASC', + ['tl_organization', 'tags', $organizationIds], + [ParameterType::STRING, ParameterType::STRING, ArrayParameterType::INTEGER], + )->fetchAllAssociative(); + + if ([] === $rows) { + $rows = $this->connection->executeQuery( + 'SELECT r.pid AS organization_id, r.tag_id, t.tag AS label + FROM tl_tags_rel r + INNER JOIN tl_tags t ON t.id = r.tag_id + WHERE r.ptable = ? AND r.pid IN (?) + ORDER BY r.pid ASC, r.tag_id ASC', + ['tl_organization', $organizationIds], + [ParameterType::STRING, ArrayParameterType::INTEGER], + )->fetchAllAssociative(); + } + + $map = []; + $seen = []; + + foreach ($rows as $row) { + $organizationId = (int) ($row['organization_id'] ?? 0); + $tagId = (int) ($row['tag_id'] ?? 0); + $label = trim((string) ($row['label'] ?? '')); + + if ($organizationId <= 0 || $tagId <= 0 || '' === $label || isset($seen[$organizationId][$tagId])) { + continue; + } + + $seen[$organizationId][$tagId] = true; + $map[$organizationId]['ids'][] = (string) $tagId; + $map[$organizationId]['labels'][] = $label; + } + + foreach ($map as $organizationId => $tagData) { + $map[$organizationId]['ids'] = array_values(array_unique($tagData['ids'] ?? [])); + $map[$organizationId]['labels'] = array_values(array_unique($tagData['labels'] ?? [])); + } + + return $map; + } + + /** + * @param list $tagIds + */ + private function buildOrganizationMarkerType(array $tagIds): string + { + $firstTagId = (string) ($tagIds[0] ?? ''); + + if ('' !== $firstTagId && ctype_digit($firstTagId) && (int) $firstTagId > 0) { + return sprintf('organisation-tag-%d', (int) $firstTagId); + } + + return 'organisation'; + } + + /** + * @return list> + */ + private function fetchOrganizationRows(?string $table, string $locationTable, array $locationGeoColumns, bool $includeExternalOrganizations): array + { + if (null === $table) { + return []; + } + + $organizationGeoColumns = $this->resolveGeoColumns($table); + $organizationColumns = $this->getColumnMap($table); + $hasLocationReference = isset($organizationColumns['location_id']); + + if (null === $organizationGeoColumns && !$hasLocationReference) { + return []; + } + + $qb = $this->connection->createQueryBuilder(); + $qb + ->select('o.id', 'o.title') ->from($table, 'o') ->orderBy('o.id', 'ASC'); + if (null !== $organizationGeoColumns) { + $qb + ->addSelect(sprintf('o.%s AS latitude', $organizationGeoColumns['latitude'])) + ->addSelect(sprintf('o.%s AS longitude', $organizationGeoColumns['longitude'])); + } + + if ($hasLocationReference) { + $qb + ->addSelect('o.location_id') + ->leftJoin('o', $locationTable, 'ol', 'ol.id = o.location_id') + ->addSelect(sprintf('ol.%s AS location_latitude', $locationGeoColumns['latitude'])) + ->addSelect(sprintf('ol.%s AS location_longitude', $locationGeoColumns['longitude'])); + + $this->applyPublicationConstraints($qb, 'ol', $locationTable); + } + + if (!$includeExternalOrganizations && isset($organizationColumns['isexternal'])) { + $qb->andWhere("(o.isExternal IS NULL OR o.isExternal = '' OR o.isExternal = '0')"); + } + $this->applyPublicationConstraints($qb, 'o', $table); return $qb->executeQuery()->fetchAllAssociative(); @@ -137,11 +321,13 @@ class MapModuleDataProvider /** * @return list> */ - private function fetchLocationRows(string $locationTable): array + private function fetchLocationRows(string $locationTable, array $locationGeoColumns): array { $qb = $this->connection->createQueryBuilder(); $qb - ->select('l.id', 'l.title', 'l.latitude', 'l.longitude') + ->select('l.id', 'l.title') + ->addSelect(sprintf('l.%s AS latitude', $locationGeoColumns['latitude'])) + ->addSelect(sprintf('l.%s AS longitude', $locationGeoColumns['longitude'])) ->from($locationTable, 'l') ->orderBy('l.id', 'ASC'); @@ -153,12 +339,15 @@ class MapModuleDataProvider /** * @return list> */ - private function fetchEventRows(string $locationTable): array + private function fetchEventRows(string $locationTable, array $locationGeoColumns): array { if (!$this->tableExists('tl_calendar_events')) { return []; } + $eventColumns = $this->getColumnMap('tl_calendar_events'); + $today = strtotime('today'); + $qb = $this->connection->createQueryBuilder(); $qb ->select( @@ -166,14 +355,20 @@ class MapModuleDataProvider 'e.title AS event_title', 'e.startDate', 'l.title AS location_title', - 'l.latitude', - 'l.longitude' + sprintf('l.%s AS latitude', $locationGeoColumns['latitude']), + sprintf('l.%s AS longitude', $locationGeoColumns['longitude']) ) ->from('tl_calendar_events', 'e') ->innerJoin('e', $locationTable, 'l', 'l.id = e.location_id') ->andWhere('e.location_id > 0') ->orderBy('e.id', 'ASC'); + if (isset($eventColumns['startdate'])) { + $qb + ->andWhere('e.startDate >= :event_start_date_from') + ->setParameter('event_start_date_from', $today); + } + $this->applyPublicationConstraints($qb, 'e', 'tl_calendar_events'); $this->applyPublicationConstraints($qb, 'l', $locationTable); @@ -220,9 +415,20 @@ class MapModuleDataProvider return null; } + $latitudeFloat = (float) $lat; + $longitudeFloat = (float) $lng; + + if ( + ($latitudeFloat < -90.0 || $latitudeFloat > 90.0) + || ($longitudeFloat < -180.0 || $longitudeFloat > 180.0) + || (0.0 === $latitudeFloat && 0.0 === $longitudeFloat) + ) { + return null; + } + return [ - 'latitude' => (float) $lat, - 'longitude' => (float) $lng, + 'latitude' => $latitudeFloat, + 'longitude' => $longitudeFloat, ]; } @@ -251,6 +457,30 @@ class MapModuleDataProvider return null; } + /** + * @return array{latitude:string,longitude:string}|null + */ + private function resolveGeoColumns(string $table): ?array + { + $columns = $this->getColumnMap($table); + + if (isset($columns['latitude'], $columns['longitude'])) { + return [ + 'latitude' => 'latitude', + 'longitude' => 'longitude', + ]; + } + + if (isset($columns['lat'], $columns['lng'])) { + return [ + 'latitude' => 'lat', + 'longitude' => 'lng', + ]; + } + + return null; + } + private function tableExists(string $table): bool { try {