From 63c9be68c6018f7c5cad4e7066ddc8e02eef50f7 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 5 Jun 2026 09:37:59 +0200 Subject: [PATCH 1/2] MapLibre-Port Runde 1: Engine-Fundament (flag-gated, Default Leaflet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit map.js: _useGL()/loadMapLibre()/_initMapGL() + engine-neutrale Facade (_mapFlyTo/_mapSetView/_mapGetZoom/_mapResize/_mapGetCenter/_mapPaddedBounds, kapselt [lat,lon]↔[lng,lat]). init() verzweigt auf GL bei Flag 'by_map_gl'/?mapgl=1. Basemap+ Controls+Dark(setStyle)+Scan-Wiring+Crosshair. POI-Layer/Marker = Runde 2. Flag default AUS. --- backend/static/js/pages/map.js | 142 ++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index a44a77d..88617fc 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -16,6 +16,10 @@ window.Page_map = (() => { let _tempMarker = null; let _tileLayer = null; let _usingVector = false; // true wenn Vektor-Basemap (PMTiles) statt OSM-Raster + let _engineGL = false; // true wenn MapLibre GL statt Leaflet (zentrale Karte) + let _maplibreLoaded = false; + let _glLayersReady = false; // GL: POI-Sources/Layer angelegt? + let _pmtilesProtoReg = false; // pmtiles-Protokoll bei MapLibre registriert? let _themeObserver = null; // Standort-Tracking @@ -153,8 +157,14 @@ window.Page_map = (() => { // Alle-Button Initialzustand const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit); - await _loadLeaflet(); - _initMap(); // sofort mit Deutschland-Mitte starten + _engineGL = _useGL(); + if (_engineGL) { + await loadMapLibre(); + _initMapGL(); // MapLibre-GL-Karte (GPU) + } else { + await _loadLeaflet(); + _initMap(); // Leaflet-Raster (Default), sofort mit Deutschland-Mitte starten + } _startLocationTracking(); _loadAll(); _offerResume(); // unterbrochene Aufzeichnung anbieten @@ -656,6 +666,134 @@ window.Page_map = (() => { }); } + // ========================================================== + // MapLibre-GL-Engine (zentrale Karte) — GPU/Worker, performant. + // Flag-gated; Raster-Leaflet bleibt Default. [lng,lat]-Reihenfolge! + // ========================================================== + // Flag: ?mapgl=1/0 → localStorage 'by_map_gl'. Default AUS (bis vollständig portiert). + function _useGL() { + try { + const u = new URLSearchParams(location.search); + if (u.has('mapgl')) localStorage.setItem('by_map_gl', u.get('mapgl') === '0' ? '0' : '1'); + return localStorage.getItem('by_map_gl') === '1'; + } catch (e) { return false; } + } + + function loadMapLibre() { + if (_maplibreLoaded) return Promise.resolve(); + const v = '?v=' + (window.APP_VER || ''); + if (!document.querySelector('link[href*="maplibre-gl.css"]')) { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = '/js/vendor/maplibre-gl.css'; + document.head.appendChild(l); + } + const seq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => { + if ((src.includes('maplibre-gl.js') && window.maplibregl) || + (src.includes('pmtiles.js') && window.pmtiles) || + (src.includes('map-gl-style') && window.MapGLStyle)) return res(); + const s = document.createElement('script'); + s.src = src + v; s.onload = res; s.onerror = rej; + document.head.appendChild(s); + })), Promise.resolve()); + return seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js']).then(() => { + if (!(window.maplibregl && window.pmtiles && window.MapGLStyle)) throw new Error('MapLibre nicht geladen'); + if (!_pmtilesProtoReg) { + const proto = new pmtiles.Protocol(); + maplibregl.addProtocol('pmtiles', proto.tile); + _pmtilesProtoReg = true; + } + _maplibreLoaded = true; + }); + } + + // ---- Engine-neutrale Facade (kapselt [lat,lon]↔[lng,lat] an EINER Stelle) ---- + function _mapFlyTo(lat, lon, zoom, opts) { + if (!_map) return; + if (_engineGL) _map.flyTo({ center: [lon, lat], zoom, duration: opts && opts.duration ? opts.duration * 1000 : 1200 }); + else _map.flyTo([lat, lon], zoom, opts); + } + function _mapSetView(lat, lon, zoom) { + if (!_map) return; + if (_engineGL) _map.jumpTo({ center: [lon, lat], zoom }); + else _map.setView([lat, lon], zoom); + } + function _mapGetZoom() { return _map ? _map.getZoom() : 0; } + function _mapResize() { if (!_map) return; if (_engineGL) _map.resize(); else _map.invalidateSize(); } + function _mapGetCenter() { + if (!_map) return null; + const c = _map.getCenter(); // beide Engines: {lat, lng} + return { lat: c.lat, lon: c.lng }; + } + // Bounds mit 15%-Padding (ersetzt Leaflets bounds.pad(0.15)) → {north,south,east,west} + function _mapPaddedBounds(pad) { + pad = pad == null ? 0.15 : pad; + const b = _map.getBounds(); + let n = b.getNorth(), s = b.getSouth(), e = b.getEast(), w = b.getWest(); + const dLat = (n - s) * pad, dLon = (e - w) * pad; + return { north: n + dLat, south: s - dLat, east: e + dLon, west: w - dLon }; + } + + function _initMapGL() { + const el = document.getElementById('central-map'); + if (!el || !window.maplibregl || _map) return; + _engineGL = true; + const center = _userPos ? [_userPos.lon, _userPos.lat] : [8.6821, 50.1109]; // Frankfurt [lng,lat] + const zoom = _userPos ? 14 : 10; + + _map = new maplibregl.Map({ + container: 'central-map', + style: MapGLStyle.build({ dark: _isDarkMode() }), + center, zoom, attributionControl: false, + maxZoom: 19, dragRotate: false, pitchWithRotate: false, + }); + _map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); + _map.addControl(new maplibregl.ScaleControl()); + _map.addControl(new maplibregl.AttributionControl({ + compact: true, customAttribution: '© OpenStreetMap contributors', + })); + + if (!_userPos) { + _frankfurtTimer = setTimeout(() => _mapFlyTo(50.1109, 8.6821, 14, { duration: 2.5 }), 1200); + } + + // Theme-Wechsel → Style neu setzen (Sources/Layer danach neu anlegen). + _themeObserver = new MutationObserver(() => _onThemeChangeGL()); + _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', _onThemeChangeGL); + + _map.on('load', () => { + _initPoiLayersGL(); + _updateZoomDisplay(); + _scheduleOsmLoad(); + }); + _map.on('moveend', () => { + _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(); + document.getElementById('map-crosshair')?.classList.remove('dragging'); + }); + _map.on('movestart', () => { + document.getElementById('map-crosshair')?.classList.add('dragging'); + }); + + window.addEventListener('resize', _mapResize); + setTimeout(_mapResize, 100); + setTimeout(_mapResize, 600); + } + + function _onThemeChangeGL() { + if (!_map || !_engineGL) return; + _glLayersReady = false; + _map.setStyle(MapGLStyle.build({ dark: _isDarkMode() })); + // setStyle entfernt eigene Sources/Layer → nach Style-Load neu anlegen. + _map.once('styledata', () => { _initPoiLayersGL(); _scheduleOsmLoad(); }); + } + + // POI-Sources/Layer in MapLibre anlegen — wird in Build-Runde 2 gefüllt. + function _initPoiLayersGL() { + if (!_map || !_engineGL || _glLayersReady) return; + _glLayersReady = true; + // (Build-Runde 2: GeoJSON-Sources + Cluster/Symbol-Layer + Icons) + } + // ---------------------------------------------------------- // Standort-Tracking — pulsierender blauer Punkt // ---------------------------------------------------------- From 11922c1d2283a305b3c929206869770729109287 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 5 Jun 2026 09:52:45 +0200 Subject: [PATCH 2/2] MapLibre-Port Runde 2: GL-Marker-Subsystem (map-gl-markers.js) + headless Test-Harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigenständiges Modul: per-Kategorie GeoJSON-Cluster, rasterisierte Phosphor-Icons, Danger-Polygone, Sichtbarkeit, Click→Popup. /maplibre-markers-test zum headless-Verifizieren VOR dem Einbau in map.js (auth-gated). --- backend/main.py | 6 + backend/static/js/map-gl-markers.js | 191 +++++++++++++++++++++ backend/static/js/maplibre-markers-test.js | 64 +++++++ backend/static/maplibre-markers-test.html | 28 +++ 4 files changed, 289 insertions(+) create mode 100644 backend/static/js/map-gl-markers.js create mode 100644 backend/static/js/maplibre-markers-test.js create mode 100644 backend/static/maplibre-markers-test.html 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 = '' + sym.innerHTML + ''; + 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 @@ + + + + + + Ban Yaro — GL-Marker-Subsystem-Test + + + + +
+
GL-Marker-Test
lädt…
+
+ + + + + + +