feat: add frontend map module with maplibre and spiderfy
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import Spiderfy from 'https://cdn.jsdelivr.net/npm/@nazka/map-gl-js-spiderfy@1.2.10/dist/index.modern.js';
|
||||
|
||||
const MAP_SELECTOR = '[data-eventmanager-map="1"]';
|
||||
|
||||
const escapeHtml = (value) => {
|
||||
const stringValue = String(value ?? '');
|
||||
|
||||
return stringValue
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
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', 'location', '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,
|
||||
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 popupHtmlFor = (item) => {
|
||||
if (item.type === 'event') {
|
||||
const locationTitle = escapeHtml(item.extra.locationTitle || '');
|
||||
const startDate = escapeHtml(item.extra.startDate || '');
|
||||
|
||||
return [
|
||||
`<strong>${escapeHtml(item.title)}</strong>`,
|
||||
locationTitle ? `<div>${locationTitle}</div>` : '',
|
||||
startDate ? `<div>${startDate}</div>` : '',
|
||||
].join('');
|
||||
}
|
||||
|
||||
return `<strong>${escapeHtml(item.title)}</strong>`;
|
||||
};
|
||||
|
||||
const toFeature = (item) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [item.longitude, item.latitude],
|
||||
},
|
||||
properties: {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
popupHtml: popupHtmlFor(item),
|
||||
},
|
||||
});
|
||||
|
||||
const addSimpleMarker = (map, item) => {
|
||||
const popup = new maplibregl.Popup({ offset: 20 }).setHTML(popupHtmlFor(item));
|
||||
|
||||
new maplibregl.Marker()
|
||||
.setLngLat([item.longitude, item.latitude])
|
||||
.setPopup(popup)
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
const initEventLayers = (map, eventItems) => {
|
||||
const sourceId = 'eventmanager-events-source';
|
||||
const clusterLayerId = 'eventmanager-events-clusters';
|
||||
const clusterCountLayerId = 'eventmanager-events-cluster-count';
|
||||
const unclusteredLayerId = 'eventmanager-events-unclustered';
|
||||
|
||||
const features = eventItems.map(toFeature);
|
||||
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
},
|
||||
cluster: true,
|
||||
clusterMaxZoom: 22,
|
||||
clusterRadius: 22,
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: clusterLayerId,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-radius': 18,
|
||||
'circle-opacity': 0.8,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: clusterCountLayerId,
|
||||
type: 'symbol',
|
||||
source: sourceId,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count'],
|
||||
'text-size': 12,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: unclusteredLayerId,
|
||||
type: 'circle',
|
||||
source: sourceId,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 9,
|
||||
'circle-stroke-width': 2,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
new maplibregl.Popup({ offset: 15 })
|
||||
.setLngLat([lng, lat])
|
||||
.setHTML(String(popupHtml))
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
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 = '';
|
||||
});
|
||||
|
||||
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 {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
map.on('mouseenter', clusterLayerId, () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
});
|
||||
|
||||
map.on('mouseleave', clusterLayerId, () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
const initSingleMap = (container) => {
|
||||
if (container.dataset.mapInitialized === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = parseItems(container);
|
||||
|
||||
if (!items.length || typeof maplibregl === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
container.dataset.mapInitialized = '1';
|
||||
|
||||
if (typeof pmtiles !== 'undefined' && !window.__eventmanagerPmtilesRegistered) {
|
||||
const protocol = new pmtiles.Protocol();
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile);
|
||||
window.__eventmanagerPmtilesRegistered = true;
|
||||
}
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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 = () => {
|
||||
const modules = document.querySelectorAll(MAP_SELECTOR);
|
||||
modules.forEach((container) => initSingleMap(container));
|
||||
};
|
||||
|
||||
if ('loading' === document.readyState) {
|
||||
document.addEventListener('DOMContentLoaded', bootstrapMapModules, { once: true });
|
||||
} else {
|
||||
bootstrapMapModules();
|
||||
}
|
||||
Reference in New Issue
Block a user