From 5844f1ef51572d14fca83683c4f5361c1a4d7999 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 5 Jun 2026 12:33:01 +0200 Subject: [PATCH] =?UTF-8?q?Seitenkarten=20auf=20MapLibre=20GL=20(Facade)?= =?UTF-8?q?=20=E2=80=94=20Runde=201:=20Giftk=C3=B6der=20+=20Verlorene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map-gl-mini.js: Leaflet-kompatible MapLibre-Facade (createMap/svgMarker/circleMarker/ featureGroup-Wrapper mit setView/fitBounds/invalidateSize/addTo/bindPopup/openPopup/on/remove) - ui.js: UI.map.create/svgMarker/leafletMarker branchen auf GL (by_map_gl, Staging-Default), + UI.map.circleMarker/featureGroup, loadMapLibreUI - poison.js/lost.js: window.L-Guards entfernt, L.circleMarker→UI.map.circleMarker --- VERSION | 2 +- backend/static/index.html | 24 ++--- backend/static/js/app.js | 2 +- backend/static/js/map-gl-mini.js | 146 ++++++++++++++++++++++++++++++ backend/static/js/pages/lost.js | 6 +- backend/static/js/pages/poison.js | 6 +- backend/static/js/ui.js | 74 ++++++++++++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 9 files changed, 239 insertions(+), 25 deletions(-) create mode 100644 backend/static/js/map-gl-mini.js diff --git a/VERSION b/VERSION index 51b4bee..54bad47 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1194 \ No newline at end of file +1195 \ No newline at end of file diff --git a/backend/static/index.html b/backend/static/index.html index 7734054..4effee6 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a478b18..c5adc12 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1194'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1195'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/map-gl-mini.js b/backend/static/js/map-gl-mini.js new file mode 100644 index 0000000..fdc34ea --- /dev/null +++ b/backend/static/js/map-gl-mini.js @@ -0,0 +1,146 @@ +// Leaflet-kompatible MapLibre-Facade für die SEITENKARTEN (Giftköder, Verlorene, +// Events, Gassi, Routen). Liefert Wrapper, die die von den Seiten genutzte Leaflet- +// API nachbilden (setView/fitBounds/invalidateSize/addTo/bindPopup/openPopup/on/remove), +// sodass die Seiten fast unverändert auf demselben GL-Style (MapGLStyle) laufen. +// Koordinaten nach außen [lat,lon] (Leaflet-Konvention), intern MapLibre [lng,lat]. +(function () { + 'use strict'; + + function _ll(latlon) { return [latlon[1], latlon[0]]; } // [lat,lon] → [lng,lat] + + // ---- Map-Wrapper ---- + function _wrapMap(map) { + return { + _gl: map, + _isGL: true, + setView: function (latlon, zoom) { map.jumpTo({ center: _ll(latlon), zoom: zoom }); return this; }, + flyTo: function (latlon, zoom, opts) { + map.flyTo({ center: _ll(latlon), zoom: zoom, duration: opts && opts.duration ? opts.duration * 1000 : 1000 }); + return this; + }, + panTo: function (latlon) { map.panTo(_ll(latlon)); return this; }, + fitBounds: function (b, opts) { + var bb = _toBounds(b); + if (bb) { + var pad = 40; + if (opts && opts.padding) pad = Array.isArray(opts.padding) ? opts.padding[0] : opts.padding; + map.fitBounds(bb, { padding: pad, maxZoom: opts && opts.maxZoom, duration: 0 }); + } + return this; + }, + invalidateSize: function () { map.resize(); return this; }, + removeLayer: function (layer) { if (layer && layer.remove) layer.remove(); return this; }, + addLayer: function (layer) { if (layer && layer.addTo) layer.addTo(this); return this; }, + hasLayer: function () { return true; }, + remove: function () { try { map.remove(); } catch (e) {} }, + on: function (ev, fn) { map.on(ev, fn); return this; }, + off: function (ev, fn) { map.off(ev, fn); return this; }, + getZoom: function () { return map.getZoom(); }, + getCenter: function () { var c = map.getCenter(); return { lat: c.lat, lng: c.lng }; }, + }; + } + + // Bounds aus: Array von [lat,lon] | featureGroup-Wrapper (_coords) | Leaflet-Bounds. + function _toBounds(b) { + if (!b) return null; + var coords = null; + if (Array.isArray(b)) coords = b; + else if (b._coords) coords = b._coords; + else if (typeof b.getSouthWest === 'function') { + var sw = b.getSouthWest(), ne = b.getNorthEast(); + return new maplibregl.LngLatBounds([sw.lng, sw.lat], [ne.lng, ne.lat]); + } + if (!coords || !coords.length) return null; + var bb = new maplibregl.LngLatBounds(); + coords.forEach(function (c) { bb.extend(_ll(c)); }); + return bb; + } + + // ---- Marker-Wrapper (HTML-Marker; svgMarker + circleMarker) ---- + function _wrapMarker(lat, lon, el, anchor) { + var m = new maplibregl.Marker({ element: el, anchor: anchor || 'center' }).setLngLat([lon, lat]); + var wrap = { + _gl: m, + _el: el, + addTo: function (mapWrap) { m.addTo(mapWrap && mapWrap._gl ? mapWrap._gl : mapWrap); return this; }, + bindPopup: function (html, opts) { + m.setPopup(new maplibregl.Popup({ maxWidth: (opts && opts.maxWidth ? opts.maxWidth + 'px' : '260px'), closeButton: true, offset: 18 }).setHTML(html)); + return this; + }, + openPopup: function () { var p = m.getPopup(); if (p && !p.isOpen()) m.togglePopup(); return this; }, + closePopup: function () { var p = m.getPopup(); if (p && p.isOpen()) m.togglePopup(); return this; }, + bindTooltip: function (t) { try { el.title = typeof t === 'string' ? t.replace(/<[^>]*>/g, '') : ''; } catch (e) {} return this; }, + on: function (ev, fn) { + if (ev === 'click') el.addEventListener('click', function (e) { e.stopPropagation(); fn(e); }); + return this; + }, + setLatLng: function (latlon) { m.setLngLat([latlon[1], latlon[0]]); return this; }, + setOpacity: function (o) { el.style.opacity = o; return this; }, + remove: function () { try { m.remove(); } catch (e) {} return this; }, + }; + return wrap; + } + + // Element aus HTML-String (für svgMarker mit custom HTML). + function _elFromHtml(html, size, anchorY) { + var wrap = document.createElement('div'); + wrap.innerHTML = html; + var el = wrap.firstElementChild || wrap; + el.style.cursor = 'pointer'; + return el; + } + + window.MapGLMini = { + createMap: function (container, opts) { + opts = opts || {}; + var el = typeof container === 'string' ? document.getElementById(container) : container; + var center = opts.center || [51.1657, 10.4515]; + var map = new maplibregl.Map({ + container: el, + style: MapGLStyle.build({ dark: !!opts.dark }), + center: _ll(center), zoom: opts.zoom != null ? opts.zoom : 6, + attributionControl: false, dragRotate: false, pitchWithRotate: false, maxZoom: 19, + }); + map.touchZoomRotate.disableRotation(); + map.touchPitch.disable(); + try { el.style.touchAction = 'none'; } catch (e) {} + if (opts.zoomControl !== false) map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); + map.addControl(new maplibregl.AttributionControl({ + compact: true, customAttribution: '© OpenStreetMap contributors', + })); + return _wrapMap(map); + }, + + // svgMarker: custom HTML-Icon. opts: { size, anchorY } + svgMarker: function (lat, lon, html, opts) { + opts = opts || {}; + var el = _elFromHtml(html); + // anchorY: Pixel von oben zum Ankerpunkt (Leaflet iconAnchor). 'bottom' wenn anchorY≈size. + var anchor = 'center'; + if (opts.anchorY != null && opts.size) { + anchor = opts.anchorY >= opts.size * 0.8 ? 'bottom' : 'center'; + } + return _wrapMarker(lat, lon, el, anchor); + }, + + circleMarker: function (lat, lon, opts) { + opts = opts || {}; + var r = opts.radius || 8; + var el = document.createElement('div'); + el.style.cssText = 'width:' + (r * 2) + 'px;height:' + (r * 2) + 'px;border-radius:50%;background:' + + (opts.fillColor || opts.color || '#3B82F6') + ';border:' + (opts.weight || 2) + 'px solid ' + + (opts.color || '#fff') + ';opacity:' + (opts.fillOpacity != null ? opts.fillOpacity : 1) + + ';box-shadow:0 1px 4px rgba(0,0,0,.35);cursor:pointer'; + return _wrapMarker(lat, lon, el, 'center'); + }, + + // featureGroup: nur als Bounds-Container (markers = Array von Wrappern mit _gl.getLngLat()). + featureGroup: function (markers) { + var coords = (markers || []).map(function (m) { + var ll = m && m._gl && m._gl.getLngLat ? m._gl.getLngLat() : null; + return ll ? [ll.lat, ll.lng] : null; + }).filter(Boolean); + return { _coords: coords, getBounds: function () { return { _coords: coords }; }, addTo: function () { return this; } }; + }, + }; +})(); diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index cdacd39..cc37969 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -178,9 +178,9 @@ window.Page_lost = (() => { } function _showUserOnMap() { - if (!_map || !window.L || !_userPos) return; + if (!_map || !_userPos) return; if (_userMarker) _map.removeLayer(_userMarker); - _userMarker = L.circleMarker([_userPos.lat, _userPos.lon], { + _userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, { radius : 9, fillColor : '#3498db', color : '#fff', @@ -266,7 +266,7 @@ window.Page_lost = (() => { // KARTEN-MARKER // ---------------------------------------------------------- function _renderMarkers() { - if (!_map || !window.L) return; + if (!_map) return; _markers.forEach(m => _map.removeLayer(m)); _markers = []; diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index acc7e7d..026c273 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -143,9 +143,9 @@ window.Page_poison = (() => { } function _showUserOnMap() { - if (!_map || !window.L || !_userPos) return; + if (!_map || !_userPos) return; if (_userMarker) _map.removeLayer(_userMarker); - _userMarker = L.circleMarker([_userPos.lat, _userPos.lon], { + _userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, { radius : 9, fillColor : '#3498db', color : '#fff', @@ -201,7 +201,7 @@ window.Page_poison = (() => { // KARTEN-MARKER // ---------------------------------------------------------- function _renderMarkers() { - if (!_map || !window.L) return; + if (!_map) return; _markers.forEach(m => _map.removeLayer(m)); _markers = []; diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index 068f34b..60c0caf 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -446,6 +446,20 @@ const UI = (() => { attributionControl = false, darkFilter = false, } = options; + + // MapLibre-GL-Seitenkarte (gleicher Style wie die Hauptkarte) — hinter by_map_gl-Flag. + if (_uiUseGL()) { + try { + await loadMapLibreUI(); + _uiGL = true; + const isDark = document.documentElement.dataset.theme === 'dark'; + return MapGLMini.createMap(containerId, { center, zoom, zoomControl, dark: isDark }); + } catch (e) { + console.warn('GL-Seitenkarte nicht verfügbar — Fallback Leaflet:', e); + } + } + _uiGL = false; + await loadLeaflet(); const m = L.map(containerId, { zoomControl, attributionControl }).setView(center, zoom); @@ -483,6 +497,7 @@ const UI = (() => { // SVG-Marker mit eigenem HTML (z.B. mit Pulse-Animation, Rotation, etc.) svgMarker(lat, lon, html, { size = 32, anchorY = null, className = '' } = {}) { + if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY }); const icon = L.divIcon({ className, html, @@ -492,6 +507,18 @@ const UI = (() => { return L.marker([lat, lon], { icon }); }, + // Engine-neutral: Kreis-Marker (Leaflet L.circleMarker bzw. GL-HTML-Punkt). + circleMarker(lat, lon, opts = {}) { + if (_uiGL && window.MapGLMini) return MapGLMini.circleMarker(lat, lon, opts); + return L.circleMarker([lat, lon], opts); + }, + + // Engine-neutral: FeatureGroup (nur als Bounds-Container für fitBounds genutzt). + featureGroup(markers = []) { + if (_uiGL && window.MapGLMini) return MapGLMini.featureGroup(markers); + return L.featureGroup(markers); + }, + // Feature-Flag-Status der Vektor-Basemap (für Karten, die ihren Basemap-Layer // selbst verwalten, z.B. pages/map.js). vectorEnabled() { return _vectorMapEnabled(); }, @@ -846,7 +873,47 @@ const UI = (() => { } // ---------------------------------------------------------- - // VEKTOR-BASEMAP (protomaps-leaflet + eigene PMTiles) — lazy laden + // MapLibre-GL für Seitenkarten (UI.map) — lazy laden + Facade + // ---------------------------------------------------------- + let _uiGL = false; // ist die aktuell erstellte UI-Karte GL? + let _maplibreUIPromise = null; + + // Gleiche Logik wie pages/map.js _useGL: Staging-Default AN, Prod AUS, by_map_gl überschreibt. + function _uiUseGL() { + try { + const flag = localStorage.getItem('by_map_gl'); + if (flag === '1') return true; + if (flag === '0') return false; + return /(^|\.)staging\.banyaro\.app$/.test(location.hostname); + } catch (e) { return false; } + } + + function loadMapLibreUI() { + if (_maplibreUIPromise) return _maplibreUIPromise; + 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) || + (src.includes('map-gl-mini') && window.MapGLMini)) return res(); + const s = document.createElement('script'); + s.src = src + v; s.onload = res; s.onerror = rej; + document.head.appendChild(s); + })), Promise.resolve()); + _maplibreUIPromise = seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-gl-mini.js']).then(() => { + if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMini)) throw new Error('MapLibre (UI) nicht geladen'); + try { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); } catch (e) { /* evtl. schon registriert */ } + }); + return _maplibreUIPromise; + } + + // ---------------------------------------------------------- + // VEKTOR-BASEMAP (protomaps-leaflet + eigene PMTiles) — lazy laden [DEAKTIVIERT] // ---------------------------------------------------------- let _protomapsPromise = null; function loadProtomaps() { @@ -905,9 +972,10 @@ const UI = (() => { // ---------------------------------------------------------- function leafletMarker({ lat, lon, color = 'var(--c-primary)', icon = '', size = 32, label = '' } = {}) { const inner = label || icon; + const html = `
${inner}
`; + if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY: size / 2 }); const divIcon = L.divIcon({ - className: '', - html: `
${inner}
`, + className: '', html, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); diff --git a/backend/static/landing.html b/backend/static/landing.html index 02c3442..0ebb442 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index b9f2659..652cdcb 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1194'; +const VER = '1195'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten