Files
eventmanager-bundle/public/assets/map-module.js
T
admin.juergenandClaude Opus 5.5 ff8fa50360 Use GeoSN aerial imagery via own proxy instead of Esri for satellite mode
Esri World Imagery (Maxar) is not freely licensed for commercial
projects. Satellite mode now uses the official GeoSN orthophotos
(Saxony, 20 cm) through the cache proxy on maps.mummert.media, so
visitors never connect to Esri or geodienste.sachsen.de.

- raster layer below the first base symbol layer, labels and event
  markers stay on top; Esri road/label overlays removed
- labels recoloured for the aerial view (white text, dark halo),
  POIs, address labels and oneway arrows hidden; originals restored
  when switching back
- attribution "© GeoSN, dl-de/by-2-0"
- bump map-module.js cache buster

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:49:22 +01:00

1449 lines
48 KiB
JavaScript

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_ESM_URL = 'https://maps.mummert.media/libraries/maplibre-gl.mjs';
const MAPLIBRE_LEGACY_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_UNCLUSTERED_LAYER_ID = 'eventmanager-events-unclustered';
// Luftbild: amtliche Digitale Orthophotos des GeoSN (20 cm) über den Cache-Proxy auf maps.mummert.media.
// Besucher rufen nur maps.mummert.media ab, keine Verbindung zu geodienste.sachsen.de.
const SATELLITE_SOURCE_ID = 'eventmanager-luftbild-sn';
const SATELLITE_LAYER_ID = 'eventmanager-luftbild-sn';
const SATELLITE_TILE_URL = 'https://maps.mummert.media/dienste/dop-sn/{z}/{x}/{y}.webp';
const SATELLITE_TILE_SIZE = 512;
const SATELLITE_MIN_ZOOM = 7; // darunter lehnt der Proxy ab
const SATELLITE_MAX_ZOOM = 18; // native Auflösung, darüber skaliert MapLibre
const SATELLITE_BOUNDS = [11.7889, 50.1506, 15.0869, 51.7209]; // Sachsen
const SATELLITE_ATTRIBUTION = '&copy; GeoSN, dl-de/by-2-0';
// Beschriftung im Luftbild lesbar machen (weiße Schrift, dunkler Rand), Unruhe ausblenden
const AERIAL_HIDE_LAYER_IDS = ['pois', 'address_label', 'roads_oneway'];
const AERIAL_KEEP_LAYER_IDS = ['roads_shields'];
const AERIAL_TEXT_PAINT = {
'text-color': '#ffffff',
'text-halo-color': 'rgba(0, 0, 0, .78)',
'text-halo-width': 1.6,
'text-halo-blur': .6,
};
const AERIAL_WATER_TEXT_PAINT = { ...AERIAL_TEXT_PAINT, 'text-color': '#cfe0ff' };
const PROTOMAPS_SOURCE_ID = 'protomaps';
const DEFAULT_BASE_STYLE_URL = 'https://maps.mummert.media/metadaten/world-light.json';
const PROTOMAPS_PM_TILES_URL = 'pmtiles://https://maps.mummert.media/tiles/world.pmtiles';
const PROTOMAPS_ATTRIBUTION = '<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>';
const PROTOMAPS_GLYPHS_URL = 'https://maps.mummert.media/fonts/{fontstack}/{range}.pbf';
const PROTOMAPS_SPRITE_URL = 'https://maps.mummert.media/sprites/v4/light';
const PROTOMAPS_FLAVOR_URL = 'https://maps.mummert.media/metadaten/flavors/bio.json';
const PROTOMAPS_BASEMAPS_ESM_URLS = [
'https://cdn.jsdelivr.net/npm/@protomaps/basemaps@5/+esm',
'https://esm.sh/@protomaps/basemaps@5',
];
const PROTOMAPS_BASEMAPS_UMD_URL = 'https://unpkg.com/@protomaps/basemaps@5/dist/basemaps.js';
let maplibregl = null;
let dependenciesPromise = null;
let basemapsApiPromise = null;
const protomapsStylePromises = new Map();
const getBasemapsApi = async () => {
if (basemapsApiPromise) {
return basemapsApiPromise;
}
basemapsApiPromise = (async () => {
for (const url of PROTOMAPS_BASEMAPS_ESM_URLS) {
try {
const module = await import(url);
const moduleApi = module && 'object' === typeof module
? (module.layers ? module : (module.default && module.default.layers ? module.default : null))
: null;
if (moduleApi && 'function' === typeof moduleApi.layers) {
return moduleApi;
}
} catch (error) {
continue;
}
}
await loadScript(PROTOMAPS_BASEMAPS_UMD_URL);
if (window.basemaps && 'function' === typeof window.basemaps.layers) {
return window.basemaps;
}
throw new Error('Failed to load Protomaps basemaps API');
})();
basemapsApiPromise = basemapsApiPromise.catch((error) => {
basemapsApiPromise = null;
throw error;
});
return basemapsApiPromise;
};
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 (!maplibregl) {
try {
maplibregl = await import(MAPLIBRE_ESM_URL);
} catch (error) {
// Fallback auf den UMD-Build (MapLibre 5), falls der ESM-Build nicht erreichbar ist.
await loadScript(MAPLIBRE_LEGACY_JS_URL);
maplibregl = window.maplibregl || null;
}
}
if (typeof pmtiles === 'undefined') {
await loadScript(PMTILES_JS_URL);
}
})();
return dependenciesPromise;
};
const getProtomapsStyle = async (baseStyleUrl = DEFAULT_BASE_STYLE_URL) => {
const normalizedBaseStyleUrl = String(baseStyleUrl || DEFAULT_BASE_STYLE_URL).trim() || DEFAULT_BASE_STYLE_URL;
if (protomapsStylePromises.has(normalizedBaseStyleUrl)) {
return protomapsStylePromises.get(normalizedBaseStyleUrl);
}
const stylePromise = (async () => {
const baseStyleResponse = await fetch(normalizedBaseStyleUrl, { credentials: 'omit' });
if (!baseStyleResponse.ok) {
throw new Error(`Failed to load base map style: ${baseStyleResponse.status}`);
}
const baseStyle = await baseStyleResponse.json();
const baseSources = baseStyle?.sources && 'object' === typeof baseStyle.sources
? baseStyle.sources
: {};
const protomapsSource = baseSources[PROTOMAPS_SOURCE_ID] && 'object' === typeof baseSources[PROTOMAPS_SOURCE_ID]
? baseSources[PROTOMAPS_SOURCE_ID]
: {
type: 'vector',
attribution: PROTOMAPS_ATTRIBUTION,
url: PROTOMAPS_PM_TILES_URL,
};
try {
const basemapsApi = await getBasemapsApi();
const flavorResponse = await fetch(PROTOMAPS_FLAVOR_URL, { credentials: 'omit' });
if (!flavorResponse.ok) {
throw new Error(`Failed to load Protomaps flavor: ${flavorResponse.status}`);
}
const flavor = await flavorResponse.json();
const generatedLayers = basemapsApi.layers(PROTOMAPS_SOURCE_ID, flavor, { lang: 'de' });
return {
...baseStyle,
version: 8,
glyphs: baseStyle?.glyphs || PROTOMAPS_GLYPHS_URL,
sprite: baseStyle?.sprite || PROTOMAPS_SPRITE_URL,
sources: {
...baseSources,
[PROTOMAPS_SOURCE_ID]: protomapsSource,
},
layers: generatedLayers,
};
} catch (error) {
return baseStyle;
}
})();
const cachedStylePromise = stylePromise.catch((error) => {
protomapsStylePromises.delete(normalizedBaseStyleUrl);
throw error;
});
protomapsStylePromises.set(normalizedBaseStyleUrl, cachedStylePromise);
return cachedStylePromise;
};
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 ?? '');
return stringValue
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
const decodeHtmlEntities = (value) => {
const stringValue = String(value ?? '');
if ('undefined' === typeof document || !document.createElement) {
return stringValue;
}
const textarea = document.createElement('textarea');
textarea.innerHTML = stringValue;
return textarea.value;
};
const sanitizeDisplayText = (value) => escapeHtml(decodeHtmlEntities(value));
const parseItems = (container) => {
const dataElementId = container.dataset.mapDataId || '';
if (!dataElementId) {
return [];
}
const payload = document.getElementById(dataElementId);
if (!payload) {
return [];
}
try {
const parsed = JSON.parse(payload.textContent || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const unique = new Map();
parsed.forEach((item) => {
if (!item || !['event', 'organisation'].includes(item.type)) {
return;
}
const id = Number(item.id || 0);
const latitude = Number(item.latitude);
const longitude = Number(item.longitude);
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return;
}
unique.set(`${item.type}:${id}`, {
type: item.type,
markerType: String(item.markerType || item.type || ''),
id,
title: String(item.title || ''),
latitude,
longitude,
extra: item.extra && typeof item.extra === 'object' ? item.extra : {},
});
});
return Array.from(unique.values());
} catch (error) {
return [];
}
};
const 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 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.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 isBaseSymbolLayer = (layer) => 'symbol' === layer?.type
&& layer.source === PROTOMAPS_SOURCE_ID;
const ensureSatelliteLayer = (map) => {
if (!map || map.getSource(SATELLITE_SOURCE_ID)) {
return;
}
map.addSource(SATELLITE_SOURCE_ID, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tileSize: SATELLITE_TILE_SIZE,
minzoom: SATELLITE_MIN_ZOOM,
maxzoom: SATELLITE_MAX_ZOOM,
bounds: SATELLITE_BOUNDS,
attribution: SATELLITE_ATTRIBUTION,
});
// Unter die erste Beschriftungsebene der Grundkarte: Orts- und Straßennamen
// sowie die Veranstaltungs-Marker bleiben über dem Luftbild.
const firstSymbolLayer = (map.getStyle()?.layers || []).find(isBaseSymbolLayer)
|| (map.getStyle()?.layers || []).find((layer) => 'symbol' === layer.type);
map.addLayer({
id: SATELLITE_LAYER_ID,
type: 'raster',
source: SATELLITE_SOURCE_ID,
layout: {
visibility: 'none',
},
paint: {
'raster-opacity': 0,
'raster-opacity-transition': { duration: EVENT_FADE_DURATION_MS, delay: 0 },
},
}, firstSymbolLayer?.id);
};
const aerialLabelBackups = new WeakMap();
const setAerialLabels = (map, enabled) => {
const layers = (map.getStyle()?.layers || []).filter(isBaseSymbolLayer);
if (!aerialLabelBackups.has(map)) {
aerialLabelBackups.set(map, new Map());
}
const backups = aerialLabelBackups.get(map);
layers.forEach((layer) => {
const layerId = layer.id;
if (!map.getLayer(layerId) || AERIAL_KEEP_LAYER_IDS.includes(layerId)) {
return;
}
if (!backups.has(layerId)) {
const paint = {};
Object.keys(AERIAL_TEXT_PAINT).forEach((property) => {
paint[property] = map.getPaintProperty(layerId, property);
});
backups.set(layerId, {
visibility: map.getLayoutProperty(layerId, 'visibility') || 'visible',
paint,
});
}
const backup = backups.get(layerId);
if (AERIAL_HIDE_LAYER_IDS.includes(layerId)) {
map.setLayoutProperty(layerId, 'visibility', enabled ? 'none' : backup.visibility);
return;
}
const aerialPaint = layerId.startsWith('water') ? AERIAL_WATER_TEXT_PAINT : AERIAL_TEXT_PAINT;
Object.keys(aerialPaint).forEach((property) => {
map.setPaintProperty(layerId, property, enabled ? aerialPaint[property] : backup.paint[property]);
});
});
};
const setSatelliteMode = (map, enabled) => {
if (!map) {
return;
}
ensureSatelliteLayer(map);
if (!map.getLayer(SATELLITE_LAYER_ID)) {
return;
}
if (enabled) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'visible');
}
map.setPaintProperty(SATELLITE_LAYER_ID, 'raster-opacity', enabled ? 1 : 0);
setAerialLabels(map, enabled);
if (!enabled) {
window.setTimeout(() => {
if (map.getLayer(SATELLITE_LAYER_ID) && 0 === map.getPaintProperty(SATELLITE_LAYER_ID, 'raster-opacity')) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', 'none');
}
}, EVENT_FADE_DURATION_MS);
}
};
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 getMarkerImageId = (prefix, color) => `${prefix}-${String(color || '').replace('#', '').toLowerCase() || 'default'}`;
const SVG_MARKER_WIDTH = 68;
const SVG_MARKER_HEIGHT = 104;
const createPinSvgDataUrl = (color, options = {}) => {
const withInnerDot = options.withInnerDot !== false;
const innerDotMarkup = withInnerDot
? '<g transform="translate(8.0, 8.0)"><circle fill="#000000" opacity="0.25" cx="5.5" cy="5.5" r="5.4999962"></circle><circle fill="#FFFFFF" cx="5.5" cy="5.5" r="5.4999962"></circle></g>'
: '';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" display="block" width="27" height="41" viewBox="0 0 27 41"><g fill-rule="nonzero"><g transform="translate(3.0, 29.0)" fill="#000000"><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="10.5" ry="5.25002273"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="10.5" ry="5.25002273"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="9.5" ry="4.77275007"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="8.5" ry="4.29549936"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="7.5" ry="3.81822308"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="6.5" ry="3.34094679"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="5.5" ry="2.86367051"></ellipse><ellipse opacity="0.04" cx="10.5" cy="5.80029008" rx="4.5" ry="2.38636864"></ellipse></g><g fill="${color}"><path d="M27,13.5 C27,19.074644 20.250001,27.000002 14.75,34.500002 C14.016665,35.500004 12.983335,35.500004 12.25,34.500002 C6.7499993,27.000002 0,19.222562 0,13.5 C0,6.0441559 6.0441559,0 13.5,0 C20.955844,0 27,6.0441559 27,13.5 Z"></path></g><g opacity="0.25" fill="#000000"><path d="M13.5,0 C6.0441559,0 0,6.0441559 0,13.5 C0,19.222562 6.7499993,27 12.25,34.5 C13,35.522727 14.016664,35.500004 14.75,34.5 C20.250001,27 27,19.074644 27,13.5 C27,6.0441559 20.955844,0 13.5,0 Z M13.5,1 C20.415404,1 26,6.584596 26,13.5 C26,15.898657 24.495584,19.181431 22.220703,22.738281 C19.945823,26.295132 16.705119,30.142167 13.943359,33.908203 C13.743445,34.180814 13.612715,34.322738 13.5,34.441406 C13.387285,34.322738 13.256555,34.180814 13.056641,33.908203 C10.284481,30.127985 7.4148684,26.314159 5.015625,22.773438 C2.6163816,19.232715 1,15.953538 1,13.5 C1,6.584596 6.584596,1 13.5,1 Z"></path></g>${innerDotMarkup}</g></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
};
const loadSvgImageData = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = SVG_MARKER_WIDTH;
canvas.height = SVG_MARKER_HEIGHT;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Failed to get canvas context for SVG marker'));
return;
}
context.clearRect(0, 0, SVG_MARKER_WIDTH, SVG_MARKER_HEIGHT);
context.drawImage(image, 0, 0, SVG_MARKER_WIDTH, SVG_MARKER_HEIGHT);
resolve(context.getImageData(0, 0, SVG_MARKER_WIDTH, SVG_MARKER_HEIGHT));
};
image.onerror = () => reject(new Error('Failed to load SVG marker image'));
image.src = dataUrl;
});
const ensureMarkerImage = (map, color, prefix = 'eventmanager-marker', options = {}) => {
const imageId = getMarkerImageId(prefix, color);
if (map.hasImage(imageId)) {
return Promise.resolve(imageId);
}
const dataUrl = createPinSvgDataUrl(color, options);
return loadSvgImageData(dataUrl)
.then((imageData) => {
if (!map.hasImage(imageId)) {
map.addImage(imageId, imageData, { pixelRatio: 2 });
}
return imageId;
});
};
const popupHtmlFor = (item) => {
if (item.type === 'event') {
const title = sanitizeDisplayText(item.title || '');
const locationTitle = sanitizeDisplayText(item.extra.locationTitle || '');
const startDate = sanitizeDisplayText(item.extra.startDate || '');
const detailUrl = String(item.extra.detailUrl || '').trim();
const hasDetailUrl = /^(https?:\/\/|\/)/i.test(detailUrl);
const detailLink = hasDetailUrl
? `<div><a href="${escapeHtml(detailUrl)}">mehr Infos</a></div>`
: '';
return [
`<strong>${title}</strong>`,
locationTitle ? `<div>${locationTitle}</div>` : '',
startDate ? `<div>${startDate}</div>` : '',
detailLink,
].join('');
}
if (item.type === 'organisation') {
const title = sanitizeDisplayText(item.title || '');
const detailUrl = String(item.extra?.detailUrl || '').trim();
const hasDetailUrl = /^(https?:\/\/|\/)/i.test(detailUrl);
const detailLink = hasDetailUrl
? `<div><a href="${escapeHtml(detailUrl)}">mehr Infos</a></div>`
: '';
return [
`<strong>${title}</strong>`,
detailLink,
].join('');
}
return `<strong>${sanitizeDisplayText(item.title)}</strong>`;
};
const toFeature = (item) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [item.longitude, item.latitude],
},
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,
latitude: item.latitude,
popupHtml: popupHtmlFor(item),
},
});
const initOrganizationMarkers = (map, organizationItems, organizationColor, openPopup) => {
if (!organizationItems.length) {
return {
setOnlyTagId: () => {},
setAllVisible: () => {},
setVisible: () => {},
};
}
const sortedOrganizationItems = [...organizationItems].sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude);
const markerEntries = sortedOrganizationItems.map((item) => {
const popupHtml = popupHtmlFor(item);
const marker = new maplibregl.Marker(organizationColor ? { color: organizationColor } : undefined)
.setLngLat([item.longitude, item.latitude]);
marker.addTo(map);
const markerElement = marker.getElement();
if (markerElement) {
markerElement.style.zIndex = String(Math.round((90 - item.latitude) * 1000));
markerElement.style.cursor = 'pointer';
if (popupHtml && typeof openPopup === 'function') {
markerElement.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openPopup([item.longitude, item.latitude], String(popupHtml));
});
}
}
return {
marker,
tagIds: normalizeTagIds(item.extra?.organizationTagIds),
};
});
return {
setOnlyTagId: (activeTagId) => {
const normalizedTagId = String(activeTagId ?? '').trim();
const hasActiveTag = /^\d+$/.test(normalizedTagId);
markerEntries.forEach((entry) => {
const markerElement = entry.marker.getElement();
if (!markerElement) {
return;
}
const hasTags = entry.tagIds.length > 0;
const isVisible = !hasActiveTag
|| !hasTags
|| entry.tagIds.includes(normalizedTagId);
markerElement.style.display = isVisible ? '' : 'none';
});
},
setAllVisible: () => {
markerEntries.forEach((entry) => {
const markerElement = entry.marker.getElement();
if (!markerElement) {
return;
}
markerElement.style.display = '';
});
},
setVisible: (isVisible) => {
markerEntries.forEach((entry) => {
const markerElement = entry.marker.getElement();
if (!markerElement) {
return;
}
markerElement.style.display = isVisible ? '' : 'none';
});
},
};
};
const bindExternalTagFilters = (container, map, organizationMarkerManager, eventLayerManager, closePopup) => {
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 initialDisplayModeRaw = String(container.dataset.mapInitialDisplay || '').trim();
const initialTagIdRaw = String(container.dataset.mapInitialTagId || '').trim();
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 canFilterByTag = tagButtons.length > 0;
const canFilterByEvents = !!eventToggleButton;
const canToggleEvents = !!(eventToggleButton && eventLayerManager);
const setEventButtonState = (isActive) => {
if (!eventToggleButton) {
return;
}
eventToggleButton.setAttribute('aria-pressed', isActive ? 'true' : 'false');
eventToggleButton.classList.toggle('is-active', isActive);
};
const availableTagIds = tagButtons
.map((button) => String(button.dataset.mapTagFilter || '').trim())
.filter((tagId) => /^\d+$/.test(tagId));
let activeTagId = null;
let eventsOnly = false;
const updateExclusiveButtonAccessibility = () => {
tagButtons.forEach((button) => {
const isActive = button.getAttribute('aria-pressed') === 'true';
button.setAttribute('aria-disabled', isActive ? 'true' : 'false');
});
if (!eventToggleButton) {
return;
}
if (!canToggleEvents) {
eventToggleButton.setAttribute('aria-disabled', 'true');
return;
}
const isEventActive = eventToggleButton.getAttribute('aria-pressed') === 'true';
eventToggleButton.setAttribute('aria-disabled', isEventActive ? 'true' : 'false');
};
const applyFilter = () => {
if (eventsOnly) {
organizationMarkerManager.setVisible(false);
if (eventLayerManager) {
eventLayerManager.setVisible(true);
}
return;
}
organizationMarkerManager.setVisible(true);
if (activeTagId) {
organizationMarkerManager.setOnlyTagId(activeTagId);
if (eventLayerManager) {
eventLayerManager.setVisible(false);
}
return;
}
organizationMarkerManager.setAllVisible();
if (eventLayerManager) {
eventLayerManager.setVisible(true);
}
};
tagButtons.forEach((button) => {
button.addEventListener('click', () => {
if (button.getAttribute('aria-disabled') === 'true') {
return;
}
const clickedTagId = String(button.dataset.mapTagFilter || '').trim();
if (!/^\d+$/.test(clickedTagId)) {
return;
}
activeTagId = clickedTagId;
eventsOnly = false;
tagButtons.forEach((otherButton) => {
const otherTagId = String(otherButton.dataset.mapTagFilter || '').trim();
setButtonState(otherButton, !!activeTagId && otherTagId === activeTagId);
});
setEventButtonState(false);
updateExclusiveButtonAccessibility();
if (typeof closePopup === 'function') {
closePopup();
}
applyFilter();
});
});
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.classList.toggle('is-expanded', nextExpanded);
toggleButton.classList.toggle('is-collapsed', !nextExpanded);
wrapper.classList.toggle('is-filter-expanded', nextExpanded);
wrapper.classList.toggle('is-filter-collapsed', !nextExpanded);
});
const initiallyExpanded = toggleButton.getAttribute('aria-expanded') !== 'false';
filterGroup.hidden = !initiallyExpanded;
toggleButton.classList.toggle('is-expanded', initiallyExpanded);
toggleButton.classList.toggle('is-collapsed', !initiallyExpanded);
wrapper.classList.toggle('is-filter-expanded', initiallyExpanded);
wrapper.classList.toggle('is-filter-collapsed', !initiallyExpanded);
}
if (eventToggleButton && eventLayerManager) {
eventToggleButton.addEventListener('click', () => {
if (eventToggleButton.getAttribute('aria-disabled') === 'true') {
return;
}
eventsOnly = true;
activeTagId = null;
tagButtons.forEach((button) => setButtonState(button, false));
setEventButtonState(eventsOnly);
updateExclusiveButtonAccessibility();
if (typeof closePopup === 'function') {
closePopup();
}
applyFilter();
});
} 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();
});
}
const applyInitialDisplayMode = () => {
const validModes = new Set(['random', 'events', 'organization_tag']);
const mode = validModes.has(initialDisplayModeRaw) ? initialDisplayModeRaw : 'random';
const randomTagId = canFilterByTag && availableTagIds.length
? availableTagIds[Math.floor(Math.random() * availableTagIds.length)]
: null;
const configuredTagId = canFilterByTag && /^\d+$/.test(initialTagIdRaw) && availableTagIds.includes(initialTagIdRaw)
? initialTagIdRaw
: null;
activeTagId = null;
eventsOnly = false;
if ('events' === mode && canFilterByEvents) {
eventsOnly = true;
} else if ('organization_tag' === mode && canFilterByTag) {
activeTagId = configuredTagId || randomTagId;
} else if ('random' === mode) {
if (canFilterByTag && canFilterByEvents) {
if (randomTagId && Math.random() >= 0.5) {
activeTagId = randomTagId;
} else {
eventsOnly = true;
}
} else if (canFilterByTag) {
activeTagId = randomTagId;
} else if (canFilterByEvents) {
eventsOnly = true;
}
}
tagButtons.forEach((button) => {
const tagId = String(button.dataset.mapTagFilter || '').trim();
setButtonState(button, null !== activeTagId && tagId === activeTagId);
});
setEventButtonState(eventsOnly);
updateExclusiveButtonAccessibility();
};
applyInitialDisplayMode();
updateExclusiveButtonAccessibility();
applyFilter();
applyMapStyleMode();
};
const initEventLayers = (map, eventItems, eventColor, markerImageId, clusterMarkerImageId, openPopup, closePopup) => {
const sourceId = 'eventmanager-events-source';
const clusterLayerId = EVENT_CLUSTER_LAYER_ID;
const unclusteredLayerId = EVENT_UNCLUSTERED_LAYER_ID;
const features = eventItems.map(toFeature);
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features,
},
cluster: true,
clusterMaxZoom: 17,
clusterRadius: 22,
});
map.addLayer({
id: clusterLayerId,
type: 'symbol',
source: sourceId,
filter: ['has', 'point_count'],
layout: {
'icon-image': clusterMarkerImageId,
'icon-size': 1,
'icon-anchor': 'bottom',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'text-field': ['get', 'point_count'],
'text-size': 18,
'text-font': [GLYPH_FONTSTACK],
'text-allow-overlap': true,
'text-ignore-placement': true,
'text-offset': [0, -1.85],
},
paint: {
'icon-opacity': 1,
'icon-translate': [0, 4],
'text-color': '#FFFFFF',
'text-opacity': 1,
'text-translate': [0, 4],
},
});
map.addLayer({
id: unclusteredLayerId,
type: 'symbol',
source: sourceId,
filter: ['!', ['has', 'point_count']],
layout: {
'icon-image': markerImageId,
'icon-size': 1,
'icon-anchor': 'bottom',
'icon-allow-overlap': true,
'icon-ignore-placement': true,
'symbol-sort-key': ['*', -1, ['to-number', ['get', 'latitude'], 0]],
},
paint: {
'icon-opacity': 1,
'icon-translate': [0, 4],
},
});
const openEventPopup = (feature, coordinatesOverride = null) => {
const popupHtml = feature?.properties?.popupHtml || '';
const rawCoords = coordinatesOverride || feature?.geometry?.coordinates || null;
if (!Array.isArray(rawCoords) || rawCoords.length !== 2) {
return;
}
const [lng, lat] = rawCoords.map((coord) => Number(coord));
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
return;
}
if (typeof openPopup === 'function') {
openPopup([lng, lat], String(popupHtml));
}
};
map.on('click', unclusteredLayerId, (event) => {
const feature = event.features && event.features[0] ? event.features[0] : null;
if (!feature) {
return;
}
openEventPopup(feature, event.lngLat ? [event.lngLat.lng, event.lngLat.lat] : null);
});
map.on('mouseenter', unclusteredLayerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', unclusteredLayerId, () => {
map.getCanvas().style.cursor = '';
});
const bindClusterZoomFallback = () => {
map.on('click', clusterLayerId, (event) => {
const feature = event.features && event.features[0] ? event.features[0] : null;
if (!feature) {
return;
}
const clusterId = feature.properties?.cluster_id;
if (undefined === clusterId || null === clusterId) {
return;
}
const source = map.getSource(sourceId);
if (!source || typeof source.getClusterExpansionZoom !== 'function') {
return;
}
source.getClusterExpansionZoom(clusterId, (error, zoom) => {
if (error) {
return;
}
map.easeTo({
center: event.lngLat,
zoom,
});
});
});
};
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: {
'icon-image': markerImageId,
'icon-size': 0.9,
'icon-anchor': 'bottom',
'icon-allow-overlap': true,
},
spiderLeavesPaint: {
'icon-opacity': 1,
'icon-translate': [-3, 9],
},
onLeafClick: (feature, spiderEvent) => {
const clickCoordinates = spiderEvent?.lngLat
? [spiderEvent.lngLat.lng, spiderEvent.lngLat.lat]
: null;
openEventPopup(feature, clickCoordinates);
},
});
spiderfy.applyTo(clusterLayerId);
spiderfyInstance = spiderfy;
if (map.getLayer(clusterLayerId)) {
map.moveLayer(clusterLayerId);
}
} catch (error) {
bindClusterZoomFallback();
}
} else {
bindClusterZoomFallback();
}
map.on('mouseenter', clusterLayerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', clusterLayerId, () => {
map.getCanvas().style.cursor = '';
});
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,
unclusteredLayerId,
];
const clusterFilter = ['has', 'point_count'];
const unclusteredFilter = ['!', ['has', 'point_count']];
const hiddenFilter = ['has', '__eventmanager_hidden__'];
let hideLayersTimeoutId = null;
const setLayerFilter = (layerId, filter) => {
if (!map.getLayer(layerId)) {
return;
}
map.setFilter(layerId, filter);
};
return {
setVisible: (isVisible) => {
if (isVisible) {
if (null !== hideLayersTimeoutId) {
window.clearTimeout(hideLayersTimeoutId);
hideLayersTimeoutId = null;
}
setLayerFilter(clusterLayerId, clusterFilter);
setLayerFilter(unclusteredLayerId, unclusteredFilter);
fadeLayerOpacity(clusterLayerId, 'icon-opacity', 1);
fadeLayerOpacity(clusterLayerId, 'text-opacity', 1);
fadeLayerOpacity(unclusteredLayerId, 'icon-opacity', 1);
return;
}
if (spiderfyInstance) {
if (typeof spiderfyInstance._clearSpiderifiedCluster === 'function') {
spiderfyInstance._clearSpiderifiedCluster();
} else if (typeof spiderfyInstance.unspiderfyAll === 'function') {
spiderfyInstance.unspiderfyAll();
}
}
if (typeof closePopup === 'function') {
closePopup();
}
fadeLayerOpacity(clusterLayerId, 'icon-opacity', 0);
fadeLayerOpacity(clusterLayerId, 'text-opacity', 0);
fadeLayerOpacity(unclusteredLayerId, 'icon-opacity', 0);
hideLayersTimeoutId = window.setTimeout(() => {
setLayerFilter(clusterLayerId, hiddenFilter);
setLayerFilter(unclusteredLayerId, hiddenFilter);
hideLayersTimeoutId = null;
}, EVENT_FADE_DURATION_MS + 10);
},
};
};
const initSingleMap = (container) => {
if (container.dataset.mapInitialized === '1') {
return;
}
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 customPitch = Number(container.dataset.mapPitch);
const eventColor = normalizeHexColor(container.dataset.mapEventColor, DEFAULT_EVENT_COLOR);
const hasCustomCenter = null !== customLat && null !== customLng;
const initialPitch = Number.isFinite(customPitch)
? Math.max(0, Math.min(85, customPitch))
: 0;
let initialCenter = DEFAULT_CENTER;
let initialZoom = DEFAULT_ZOOM;
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';
ensureDependencies()
.then(async () => {
if (!maplibregl) {
return;
}
if (typeof pmtiles !== 'undefined' && !window.__eventmanagerPmtilesRegistered) {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
window.__eventmanagerPmtilesRegistered = true;
}
const baseStyleUrl = container.dataset.mapStyle || DEFAULT_BASE_STYLE_URL;
const style = await getProtomapsStyle(baseStyleUrl);
const map = new maplibregl.Map({
container,
style,
zoom: initialZoom,
pitch: initialPitch,
bearing: 0,
center: initialCenter,
transformRequest: (url, resourceType) => {
if ('Glyphs' === resourceType) {
return {
url: rewriteGlyphUrl(url),
};
}
return {
url,
};
},
});
let activePopup = null;
const closePopup = () => {
if (!activePopup) {
return;
}
activePopup.remove();
activePopup = null;
};
const openPopup = (coordinates, popupHtml) => {
if (!Array.isArray(coordinates) || coordinates.length !== 2 || !popupHtml) {
return;
}
const [lng, lat] = coordinates.map((value) => Number(value));
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
return;
}
closePopup();
const popup = new maplibregl.Popup({ offset: 15 })
.setLngLat([lng, lat])
.setHTML(String(popupHtml))
.addTo(map);
popup.on('close', () => {
if (activePopup === popup) {
activePopup = null;
}
});
activePopup = popup;
};
map.on('style.load', () => {
applyDefaultProjection(map);
});
const resolveMissingStyleImage = (missingImageId) => {
if (!missingImageId || map.hasImage(missingImageId)) {
return;
}
map.addImage(missingImageId, {
width: 1,
height: 1,
data: new Uint8Array([0, 0, 0, 0]),
});
};
if ('function' === typeof map.setMissingStyleImageResolver) {
map.setMissingStyleImageResolver((missingImageId) => resolveMissingStyleImage(missingImageId));
} else {
map.on('styleimagemissing', (event) => resolveMissingStyleImage(event?.id || ''));
}
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', async () => {
applyDefaultProjection(map);
const organizationItems = items
.filter((item) => item.type === 'organisation')
.sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude);
const eventItems = items
.filter((item) => item.type === 'event')
.sort((firstItem, secondItem) => secondItem.latitude - firstItem.latitude);
const allCoordinates = items.map((item) => [item.longitude, item.latitude]);
const organizationColor = normalizeHexColor(
container.dataset.mapOrganizationColor,
eventColor,
);
let eventMarkerImageId = null;
let clusterMarkerImageId = null;
try {
[eventMarkerImageId, clusterMarkerImageId] = await Promise.all([
ensureMarkerImage(map, eventColor, 'eventmanager-event-marker', { withInnerDot: true }),
ensureMarkerImage(map, eventColor, 'eventmanager-event-cluster-marker', { withInnerDot: false }),
]);
} catch (error) {
return;
}
const organizationMarkerManager = initOrganizationMarkers(map, organizationItems, organizationColor, openPopup);
let eventLayerManager = null;
if (eventItems.length) {
eventLayerManager = initEventLayers(map, eventItems, eventColor, eventMarkerImageId, clusterMarkerImageId, openPopup, closePopup);
}
bindExternalTagFilters(container, map, organizationMarkerManager, eventLayerManager, closePopup);
if ('markers' === centerMode) {
fitBounds(allCoordinates);
}
});
})
.catch(() => {
container.dataset.mapInitialized = '0';
});
};
const bootstrapMapModules = () => {
const modules = document.querySelectorAll(MAP_SELECTOR);
modules.forEach((container) => initSingleMap(container));
};
if ('loading' === document.readyState) {
document.addEventListener('DOMContentLoaded', bootstrapMapModules, { once: true });
} else {
bootstrapMapModules();
}