diff --git a/backend/main.py b/backend/main.py
index 328f63f..f30c1f6 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -447,6 +447,12 @@ async def maplibre_perf_test():
# Wegwerf-Perf-Test: MapLibre GPU + 600 Cluster-Marker auf DACH-Basemap (Handy-Test).
return FileResponse(os.path.join(STATIC_DIR, "maplibre-perf-test.html"), media_type="text/html")
+
+@app.get("/maplibre-markers-test")
+async def maplibre_markers_test():
+ # Headless-Proof für map-gl-markers.js (Cluster/Icons/Danger/Toggle/Popup, ohne Auth).
+ return FileResponse(os.path.join(STATIC_DIR, "maplibre-markers-test.html"), media_type="text/html")
+
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
diff --git a/backend/static/js/map-gl-markers.js b/backend/static/js/map-gl-markers.js
new file mode 100644
index 0000000..abdf84a
--- /dev/null
+++ b/backend/static/js/map-gl-markers.js
@@ -0,0 +1,191 @@
+// 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 _activePopup = null;
+ var _dangerKeys = [];
+
+ 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 (weiß) auf farbigem Kreis → ImageData für map.addImage.
+ function _iconImage(spriteDoc, iconName, color) {
+ 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);
+ 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 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 = '';
+ var im = new Image();
+ im.onload = function () { base(); var ic = s * 0.52; 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 () {
+ if (_map.hasImage('poi-' + key)) return;
+ return _iconImage(doc, _types[key].iconName, _types[key].color).then(function (img) {
+ if (!_map.hasImage('poi-' + key)) _map.addImage('poi-' + key, img, { pixelRatio: 2 });
+ });
+ });
+ }, 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('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: Einzel-POI → Popup; Cluster → reinzoomen
+ _map.on('click', 'pt-' + key, function (e) { _onPoiClick(e, key); });
+ _map.on('click', 'cl-' + key, function (e) {
+ var f = e.features[0];
+ _map.getSource(src).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 (_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;
+ _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, '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;
+})();
diff --git a/backend/static/js/maplibre-markers-test.js b/backend/static/js/maplibre-markers-test.js
new file mode 100644
index 0000000..8173de5
--- /dev/null
+++ b/backend/static/js/maplibre-markers-test.js
@@ -0,0 +1,64 @@
+// Headless-Proof für map-gl-markers.js: 4 Kategorien, ~490 Fake-Marker, Cluster,
+// Phosphor-Icons, Danger-Radien, Sichtbarkeits-Toggle, Popups — alles ohne App/Auth.
+(function () {
+ 'use strict';
+ var st = document.getElementById('status');
+ function set(t) { if (st) st.textContent = t; }
+
+ var TYPES = {
+ restaurant: { color: '#F97316', iconName: 'fork-knife' },
+ freilauf: { color: '#22C55E', iconName: 'dog' },
+ tierarzt: { color: '#EF4444', iconName: 'first-aid' },
+ poison: { color: '#DC2626', iconName: 'skull', danger: true },
+ };
+ var COUNTS = { restaurant: 250, freilauf: 150, tierarzt: 80, poison: 8 };
+
+ function genPois(n, seedStart) {
+ var feats = [], seed = seedStart;
+ function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
+ for (var i = 0; i < n; i++) {
+ feats.push({ lat: 48.03 + rnd() * 0.24, lon: 11.40 + rnd() * 0.36, name: 'POI ' + i, source: 'osm' });
+ }
+ return feats;
+ }
+
+ try {
+ var proto = new pmtiles.Protocol();
+ maplibregl.addProtocol('pmtiles', proto.tile);
+ var map = new maplibregl.Map({
+ container: 'map', style: MapGLStyle.build({ dark: false }),
+ center: [11.576, 48.137], zoom: 12, dragRotate: false,
+ });
+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
+ map.addControl(new maplibregl.ScaleControl());
+
+ map.on('error', function (e) { set('⚠️ ' + (e && e.error ? e.error.message : 'Fehler')); if (e && e.error) console.error(e.error); });
+
+ map.on('load', function () {
+ MapGLMarkers.init(map, {
+ types: TYPES, dangerKeys: ['poison'], dangerRadiusM: 100,
+ popupHTML: function (p, key) { return '' + (p.name || key) + '
' + key + '
'; },
+ popupWire: function (p, key, close) { document.getElementById('mp-x') && document.getElementById('mp-x').addEventListener('click', close); },
+ }).then(function () {
+ var seeds = { restaurant: 11, freilauf: 77, tierarzt: 123, poison: 200 };
+ Object.keys(TYPES).forEach(function (k) { MapGLMarkers.setLayer(k, genPois(COUNTS[k], seeds[k])); });
+ // Toggle-Buttons
+ var box = document.getElementById('toggles');
+ Object.keys(TYPES).forEach(function (k) {
+ var b = document.createElement('button'); b.textContent = k; b.dataset.on = '1';
+ b.style.borderColor = TYPES[k].color;
+ b.addEventListener('click', function () {
+ var on = b.dataset.on === '1' ? false : true;
+ b.dataset.on = on ? '1' : '0'; b.classList.toggle('off', !on);
+ MapGLMarkers.setVisible(k, on);
+ });
+ box.appendChild(b);
+ });
+ var total = COUNTS.restaurant + COUNTS.freilauf + COUNTS.tierarzt + COUNTS.poison;
+ set('✅ ' + total + ' Marker (4 Kat.) — Cluster + Phosphor-Icons + Danger-Radius + Toggle + Popup');
+ }).catch(function (e) { set('❌ init: ' + (e && e.message ? e.message : e)); console.error(e); });
+ });
+ } catch (e) {
+ set('❌ ' + (e && e.message ? e.message : e));
+ }
+})();
diff --git a/backend/static/maplibre-markers-test.html b/backend/static/maplibre-markers-test.html
new file mode 100644
index 0000000..c2c4c31
--- /dev/null
+++ b/backend/static/maplibre-markers-test.html
@@ -0,0 +1,28 @@
+
+
+