// GL-Marker-Subsystem für die zentrale Karte (MapLibre). Eigenständig + headless // testbar, BEVOR es in map.js verdrahtet wird. Pro Kategorie eine GeoJSON-Source mit // cluster:true → Cluster-Kreise (circle, GPU) + Einzel-POIs (symbol mit Phosphor-Icon). // Faithful zum Leaflet-Look: Kategorie-Farbe + weißes Phosphor-Icon auf Kreis. // Cluster-ZAHLEN brauchen Glyphs → später (Größe kodiert Dichte). Danger-Radien als Polygon. (function () { 'use strict'; var _map = null; var _types = {}; // { key: { color, iconName, danger } } var _dangerRadiusM = 100; var _popupHTML = null; // (props, key) -> htmlString var _popupWire = null; // (props, key, closeFn) -> void var _onClick = null; // (props, key) -> true = Klick behandelt, Popup unterdrücken var _activePopup = null; var _dangerKeys = []; var _clickBound = {}; // Click/Hover-Handler pro Kategorie nur EINMAL binden function _empty() { return { type: 'FeatureCollection', features: [] }; } // POIs ({lat,lon,...}) → GeoJSON-Features ([lng,lat] + flache Properties). function _toFeatures(pois) { return { type: 'FeatureCollection', features: (pois || []).filter(function (p) { return p && p.lat != null && p.lon != null; }) .map(function (p) { var props = {}; Object.keys(p).forEach(function (k) { var v = p[k]; if (v != null && typeof v !== 'object') props[k] = v; }); return { type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [p.lon, p.lat] } }; }), }; } // Kreis-Polygon (Meter-genau) für Danger-Radius. function _circlePolygon(lon, lat, radiusM, steps) { steps = steps || 36; var coords = [], r = radiusM / 6378137, latR = lat * Math.PI / 180, lonR = lon * Math.PI / 180; for (var i = 0; i <= steps; i++) { var brng = i / steps * 2 * Math.PI; var lat2 = Math.asin(Math.sin(latR) * Math.cos(r) + Math.cos(latR) * Math.sin(r) * Math.cos(brng)); var lon2 = lonR + Math.atan2(Math.sin(brng) * Math.sin(r) * Math.cos(latR), Math.cos(r) - Math.sin(latR) * Math.sin(lat2)); coords.push([lon2 * 180 / Math.PI, lat2 * 180 / Math.PI]); } return { type: 'Polygon', coordinates: [coords] }; } // Phosphor-Icon → ImageData. iconOnly=false: weißes Icon auf farbigem Kreis (Marker). // iconOnly=true: nur weißes Icon, transparent + größer (für Cluster-Mitte). function _iconImage(spriteDoc, iconName, color, iconOnly) { return new Promise(function (resolve) { var s = 64, c = document.createElement('canvas'); c.width = c.height = s; var x = c.getContext('2d'); function base() { x.clearRect(0, 0, s, s); if (iconOnly) return; x.beginPath(); x.arc(s / 2, s / 2, s / 2 - 5, 0, Math.PI * 2); x.fillStyle = color; x.fill(); x.lineWidth = 4; x.strokeStyle = 'rgba(52,68,36,0.55)'; x.stroke(); } var ic = s * (iconOnly ? 0.66 : 0.52); var sym = spriteDoc && iconName && spriteDoc.getElementById(iconName); if (!sym) { base(); resolve(x.getImageData(0, 0, s, s)); return; } var vb = sym.getAttribute('viewBox') || '0 0 256 256'; var svg = '' + sym.innerHTML + ''; var im = new Image(); im.onload = function () { base(); x.drawImage(im, (s - ic) / 2, (s - ic) / 2, ic, ic); resolve(x.getImageData(0, 0, s, s)); }; im.onerror = function () { base(); resolve(x.getImageData(0, 0, s, s)); }; im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }); } function _buildIcons() { var doc = null; return fetch('/icons/phosphor.svg').then(function (r) { return r.text(); }) .then(function (txt) { doc = new DOMParser().parseFromString(txt, 'image/svg+xml'); }) .catch(function () { doc = null; }) .then(function () { var keys = Object.keys(_types); return keys.reduce(function (chain, key) { return chain.then(function () { var p = Promise.resolve(); // Marker-Icon (Kreis + weißes Icon) if (!_map.hasImage('poi-' + key)) { p = p.then(function () { return _iconImage(doc, _types[key].iconName, _types[key].color, false); }) .then(function (img) { if (!_map.hasImage('poi-' + key)) _map.addImage('poi-' + key, img, { pixelRatio: 2 }); }); } // Cluster-Icon (nur weißes Icon, für die Cluster-Mitte) if (!_map.hasImage('cli-' + key)) { p = p.then(function () { return _iconImage(doc, _types[key].iconName, _types[key].color, true); }) .then(function (img) { if (!_map.hasImage('cli-' + key)) _map.addImage('cli-' + key, img, { pixelRatio: 2 }); }); } return p; }); }, Promise.resolve()); }); } function _addCategoryLayers() { Object.keys(_types).forEach(function (key) { var src = 'poi-' + key, color = _types[key].color; if (!_map.getSource(src)) { _map.addSource(src, { type: 'geojson', data: _empty(), cluster: true, clusterRadius: 50, clusterMaxZoom: 16 }); } if (!_map.getLayer('cl-' + key)) { _map.addLayer({ id: 'cl-' + key, type: 'circle', source: src, filter: ['has', 'point_count'], paint: { 'circle-color': color, 'circle-opacity': 0.92, 'circle-stroke-color': 'rgba(52,68,36,0.65)', 'circle-stroke-width': 2, 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24], } }); } if (!_map.getLayer('clsym-' + key)) { // Anzahl als weiße Zahl mittig auf dem Cluster (braucht Glyphs aus dem Style). _map.addLayer({ id: 'clsym-' + key, type: 'symbol', source: src, filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Open Sans Regular'], 'text-size': ['step', ['get', 'point_count'], 12, 100, 14, 1000, 16], 'text-allow-overlap': true, 'text-ignore-placement': true, }, paint: { 'text-color': '#ffffff', 'text-halo-color': 'rgba(52,68,36,0.55)', 'text-halo-width': 1 } }); } if (!_map.getLayer('pt-' + key)) { _map.addLayer({ id: 'pt-' + key, type: 'symbol', source: src, filter: ['!', ['has', 'point_count']], layout: { 'icon-image': 'poi-' + key, 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-size': 0.9 } }); } // Click/Hover NUR EINMAL binden (Handler überleben setStyle/Theme-Wechsel, // sind an die Layer-ID gebunden → sonst doppelte Popups nach Theme-Switch). if (!_clickBound[key]) { _clickBound[key] = true; _map.on('click', 'pt-' + key, function (e) { _onPoiClick(e, key); }); _map.on('click', 'cl-' + key, function (e) { var f = e.features[0]; var s = _map.getSource('poi-' + key); if (s && s.getClusterExpansionZoom) { s.getClusterExpansionZoom(f.properties.cluster_id, function (err, z) { if (!err) _map.easeTo({ center: f.geometry.coordinates, zoom: z }); }); } }); _map.on('mouseenter', 'pt-' + key, function () { _map.getCanvas().style.cursor = 'pointer'; }); _map.on('mouseleave', 'pt-' + key, function () { _map.getCanvas().style.cursor = ''; }); } }); // Danger-Radius-Layer (poison/giftkoeder), unter den Markern. if (_dangerKeys.length && !_map.getSource('danger')) { _map.addSource('danger', { type: 'geojson', data: _empty() }); var firstSymbol = 'cl-' + Object.keys(_types)[0]; _map.addLayer({ id: 'danger-fill', type: 'fill', source: 'danger', paint: { 'fill-color': '#DC2626', 'fill-opacity': 0.12 } }, _map.getLayer(firstSymbol) ? firstSymbol : undefined); _map.addLayer({ id: 'danger-line', type: 'line', source: 'danger', paint: { 'line-color': '#DC2626', 'line-width': 2, 'line-opacity': 0.7 } }, _map.getLayer(firstSymbol) ? firstSymbol : undefined); } } function _onPoiClick(e, key) { if (!e.features || !e.features.length) return; var f = e.features[0]; var props = f.properties || {}; if (_onClick && _onClick(props, key) === true) return; // Klick anderweitig behandelt if (_activePopup) { _activePopup.remove(); _activePopup = null; } var html = _popupHTML ? _popupHTML(props, key) : ('' + (props.name || key) + ''); if (!html) return; _activePopup = new maplibregl.Popup({ maxWidth: '260px' }) .setLngLat(f.geometry.coordinates).setHTML(html).addTo(_map); if (_popupWire) { var pop = _activePopup; setTimeout(function () { _popupWire(props, key, function () { pop.remove(); }); }, 50); } } // Danger-Source aus allen aktuell gesetzten Danger-POIs neu aufbauen. var _dangerPois = {}; // { key: [pois] } function _refreshDanger() { if (!_map.getSource('danger')) return; var feats = []; _dangerKeys.forEach(function (k) { (_dangerPois[k] || []).forEach(function (p) { if (p.lat != null && p.lon != null) feats.push({ type: 'Feature', properties: {}, geometry: _circlePolygon(p.lon, p.lat, _dangerRadiusM) }); }); }); _map.getSource('danger').setData({ type: 'FeatureCollection', features: feats }); } // ---- Öffentliche API ---- var API = { // init(map, { types, dangerKeys, dangerRadiusM, popupHTML, popupWire }) → Promise (Icons geladen) init: function (map, opts) { _map = map; opts = opts || {}; _types = opts.types || {}; _dangerKeys = opts.dangerKeys || []; _dangerRadiusM = opts.dangerRadiusM || 100; _popupHTML = opts.popupHTML || null; _popupWire = opts.popupWire || null; _onClick = opts.onClick || null; _addCategoryLayers(); return _buildIcons(); }, // POIs einer Kategorie setzen (ersetzt alle). setLayer: function (key, pois) { var src = _map && _map.getSource('poi-' + key); if (!src) return; src.setData(_toFeatures(pois)); if (_dangerKeys.indexOf(key) !== -1) { _dangerPois[key] = pois || []; _refreshDanger(); } }, clear: function (key) { API.setLayer(key, []); }, setVisible: function (key, on) { if (!_map) return; var vis = on ? 'visible' : 'none'; ['cl-' + key, 'clsym-' + key, 'pt-' + key].forEach(function (id) { if (_map.getLayer(id)) _map.setLayoutProperty(id, 'visibility', vis); }); }, ready: function () { return !!(_map && _map.getSource('poi-' + Object.keys(_types)[0])); }, }; window.MapGLMarkers = API; })();