diff --git a/contao/templates/frontend/event_map.html.twig b/contao/templates/frontend/event_map.html.twig index 9e2adb0..2e17f7f 100644 --- a/contao/templates/frontend/event_map.html.twig +++ b/contao/templates/frontend/event_map.html.twig @@ -112,4 +112,4 @@ - + diff --git a/public/assets/map-module.js b/public/assets/map-module.js index c81a3cf..3a0f5f8 100644 --- a/public/assets/map-module.js +++ b/public/assets/map-module.js @@ -21,8 +21,60 @@ const SATELLITE_ROADS_SOURCE_ID = 'eventmanager-satellite-roads-source'; const SATELLITE_ROADS_LAYER_ID = 'eventmanager-satellite-roads-layer'; const SATELLITE_ROADS_TILE_URL = 'https://services.arcgisonline.com/ArcGIS/rest/services/Reference/World_Transportation/MapServer/tile/{z}/{y}/{x}'; const SATELLITE_ATTRIBUTION = '© Esri, Maxar'; +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 = 'Protomaps © OpenStreetMap'; +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 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) { @@ -94,6 +146,69 @@ const ensureDependencies = async () => { 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; @@ -1152,7 +1267,7 @@ const initSingleMap = (container) => { container.dataset.mapInitialized = '1'; ensureDependencies() - .then(() => { + .then(async () => { if (typeof maplibregl === 'undefined') { return; } @@ -1163,7 +1278,8 @@ const initSingleMap = (container) => { window.__eventmanagerPmtilesRegistered = true; } - const style = container.dataset.mapStyle || 'https://maps.mummert.media/metadaten/world-light.json'; + const baseStyleUrl = container.dataset.mapStyle || DEFAULT_BASE_STYLE_URL; + const style = await getProtomapsStyle(baseStyleUrl); const map = new maplibregl.Map({ container, style,