Routen-Detailkarte: WebGL-Kontext-Leak gefixt → bleibt GL + zoomt auf Route

Eigentliche Ursache von 'Detailkarte zoomt nicht auf die Route': die Karte war
auf dem Gerät gar keine GL-Karte mehr, sondern der Leaflet+OSM-RASTER-Fallback.
Grund: _detailMap (GL-Kontext) wurde beim Schließen des Modals NIE freigegeben —
jede geöffnete Route leakte einen WebGL-Kontext. Nach ~8 wirft MapLibre, und
UI.map.create fällt auf Leaflet+OSM zurück. Genau die Mapnik-Kacheln aus Renés
Screenshots (und die OSM-Attribution, die wir doch loswerden wollten).

Fixes:
- _detailMap modulweit + im onClose des Detail-Modals freigeben.
- routes.js destroy(): _detailMap/_suggestMap/_searchMap + Mini-Maps beim
  Verlassen der Seite freigeben.
- ui.js: Offscreen-Snapshot-Kontext nach 15s Leerlauf freigeben (hielt dauerhaft
  einen Kontext; Cache bleibt → kein Neu-Rendern).
- _fitRouteMap fittet jetzt aufs 'load'/'idle'-Event der Karte (iOS verwirft ein
  fitBounds VOR dem ersten Render) statt nur auf feste Timeouts.

Verifiziert (headless): 12 Detail-Öffnungen in Folge bleiben ALLE GL
(Leaflet:false), GL-Canvas-Zahl bleibt bei 1–2 statt zu wachsen. Vorher leakte
jede Öffnung einen Kontext.
This commit is contained in:
rene 2026-06-05 15:10:12 +02:00
parent d203ab17a8
commit 720971d252
7 changed files with 71 additions and 30 deletions

View file

@ -1 +1 @@
1204 1205

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1204"></script> <script src="/js/boot-early.js?v=1205"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1204"> <link rel="stylesheet" href="/css/design-system.css?v=1205">
<link rel="stylesheet" href="/css/layout.css?v=1204"> <link rel="stylesheet" href="/css/layout.css?v=1205">
<link rel="stylesheet" href="/css/components.css?v=1204"> <link rel="stylesheet" href="/css/components.css?v=1205">
<link rel="stylesheet" href="/css/utilities.css?v=1204"> <link rel="stylesheet" href="/css/utilities.css?v=1205">
<link rel="stylesheet" href="/css/lists.css?v=1204"> <link rel="stylesheet" href="/css/lists.css?v=1205">
</head> </head>
<body> <body>
@ -617,11 +617,11 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1204"></script> <script src="/js/api.js?v=1205"></script>
<script src="/js/ui.js?v=1204"></script> <script src="/js/ui.js?v=1205"></script>
<script src="/js/app.js?v=1204"></script> <script src="/js/app.js?v=1205"></script>
<script src="/js/worlds.js?v=1204"></script> <script src="/js/worlds.js?v=1205"></script>
<script src="/js/offline-indicator.js?v=1204"></script> <script src="/js/offline-indicator.js?v=1205"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1204"></script> <script src="/js/boot.js?v=1205"></script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1204'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '1205'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -89,6 +89,7 @@ window.Page_routes = (() => {
let _viewMode = 'list'; let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route } let _searchLines = new Map(); // routeId → { line, route }
let _detailMap = null; // GL-Karte im Detail-Modal (Kontext beim Schließen freigeben!)
// Mini-Karten auf den Route-Cards // Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map let _miniMaps = new Map(); // routeId → L.map
@ -170,6 +171,14 @@ window.Page_routes = (() => {
} }
function onDogChange() {} function onDogChange() {}
// Beim Verlassen der Seite alle Listen-/Detail-Karten freigeben (WebGL-Kontext-Leak).
// Aktive Navigations-/Aufzeichnungs-Overlays (_navMap/_recMap) bleiben unangetastet.
function destroy() {
[_detailMap, _suggestMap, _searchMap].forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} });
_detailMap = _suggestMap = _searchMap = null;
try { _miniMaps.forEach(m => m.remove && m.remove()); _miniMaps.clear(); } catch (e) {}
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// Render // Render
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -2434,7 +2443,12 @@ window.Page_routes = (() => {
</div> </div>
`; `;
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer }); // onClose: GL-Kontext der Detailkarte freigeben — sonst leakt jede geöffnete Route
// einen WebGL-Kontext. Nach ~8 wirft MapLibre, und UI.map.create fällt auf
// Leaflet+OSM-Raster zurück (genau das Symptom: Detailkarte plötzlich OSM-Raster
// statt GL, und der Zoom passt nicht mehr).
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer,
onClose: () => { if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; } } });
UI.ratingStars({ UI.ratingStars({
containerId: `rk-rating-${route.id}`, containerId: `rk-rating-${route.id}`,
@ -2552,8 +2566,8 @@ window.Page_routes = (() => {
UI.noteModal('route', route.id, label, null); UI.noteModal('route', route.id, label, null);
}); });
// Mini-Map // Mini-Map (modulweite _detailMap → wird beim Schließen im onClose freigegeben)
let _detailMap = null; if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; }
setTimeout(async () => { setTimeout(async () => {
const el = document.getElementById('rk-detail-map'); const el = document.getElementById('rk-detail-map');
if (!el || !track.length) return; if (!el || !track.length) return;
@ -2674,21 +2688,34 @@ window.Page_routes = (() => {
} }
} }
// Karte robust auf die ganze Route fitten — auch wenn der Container beim Erstellen // Karte robust auf die ganze Route fitten.
// noch 0×0 ist (Modal-Animation / spätes Layout auf iOS). Feste Timeouts greifen dort // WICHTIG (iOS): MapLibre verwirft ein fitBounds, das VOR dem ersten Render läuft —
// oft zu früh; der ResizeObserver fittet erneut, SOBALD der Container seine endgültige // die Karte bleibt dann beim Start-Zoom (zoom 14, center=Start) hängen, statt auf die
// Größe hat. Das war der Grund, warum die Detail-/Vorschlag-Karte auf dem Gerät beim // Route zu zoomen. (In Headless-Chromium passiert das nicht, daher fiel es dort nicht
// Start-Zoom (zoom 14, center=Start) hängen blieb statt auf die Route zu zoomen. // auf.) Deshalb fitten wir auf das 'load'/'idle'-Event der Karte — DANN ist sie wirklich
// gerendert und der Fit bleibt. Feste Timeouts + ResizeObserver als Sicherheitsnetz.
function _fitRouteMap(m, el, getBounds, opts) { function _fitRouteMap(m, el, getBounds, opts) {
opts = opts || { padding: [16, 16] }; opts = opts || { padding: [16, 16], maxZoom: 16 };
const fit = () => { try { m.invalidateSize(); m.fitBounds(getBounds(), opts); } catch (e) {} }; let active = true;
const sized = () => !el || (el.clientWidth > 0 && el.clientHeight > 0);
const fit = () => { if (!active) return; try { m.invalidateSize(); m.fitBounds(getBounds(), opts); } catch (e) {} };
const onReady = () => {
if (!active) return;
fit(); fit();
setTimeout(fit, 150); setTimeout(fit, 400); // Erstes Ready-Event mit korrekt vermessenem Container = der gute Fit → danach Schluss,
// damit der Nutzer frei zoomen/pannen kann.
if (sized()) { active = false; try { m.off && m.off('idle', onReady); m.off && m.off('load', onReady); } catch (e) {} }
};
fit();
[120, 350, 700, 1200, 2000].forEach(t => setTimeout(fit, t));
try { m.on('load', onReady); } catch (e) {}
try { m.on('idle', onReady); } catch (e) {}
if (window.ResizeObserver && el) { if (window.ResizeObserver && el) {
const ro = new ResizeObserver(() => { if (el.clientWidth > 0 && el.clientHeight > 0) fit(); }); const ro = new ResizeObserver(() => fit());
ro.observe(el); ro.observe(el);
setTimeout(() => { try { ro.disconnect(); } catch (e) {} }, 4000); setTimeout(() => { try { ro.disconnect(); } catch (e) {} }, 4000);
} }
setTimeout(() => { active = false; }, 4000);
} }
async function _buildDetailMap(el, track) { async function _buildDetailMap(el, track) {
@ -3220,6 +3247,6 @@ window.Page_routes = (() => {
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ---------------------------------------------------------- // ----------------------------------------------------------
return { init, refresh, onDogChange }; return { init, refresh, onDogChange, destroy };
})(); })();

View file

@ -937,7 +937,7 @@ const UI = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// TRACK-VORSCHAU-SNAPSHOT — ein Offscreen-GL-Kontext rendert PNGs (Basemap+Route) // TRACK-VORSCHAU-SNAPSHOT — ein Offscreen-GL-Kontext rendert PNGs (Basemap+Route)
// ---------------------------------------------------------- // ----------------------------------------------------------
let _snapMap = null, _snapReady = null, _snapChain = Promise.resolve(); let _snapMap = null, _snapReady = null, _snapChain = Promise.resolve(), _snapReleaseTimer = null;
const _snapCache = new Map(); // key → data-URL const _snapCache = new Map(); // key → data-URL
const _EMPTY_FC = { type: 'FeatureCollection', features: [] }; const _EMPTY_FC = { type: 'FeatureCollection', features: [] };
@ -1001,15 +1001,29 @@ const UI = (() => {
})); }));
} }
// Offscreen-GL-Kontext nach Leerlauf freigeben — nicht dauerhaft halten, sonst belegt
// er einen der knappen iOS-WebGL-Kontexte und beschleunigt das Limit (Detailkarten
// fielen dann auf Leaflet+OSM-Raster zurück). Der PNG-Cache bleibt → kein Neu-Rendern.
function _releaseSnapMap() {
_snapReleaseTimer = null;
if (_snapMap) { try { _snapMap.remove(); } catch (e) {} _snapMap = null; }
_snapReady = null;
}
function _glSnapshot(track, opts = {}) { function _glSnapshot(track, opts = {}) {
if (!_uiUseGL()) return Promise.resolve(null); // GL aus → SVG-Fallback beim Aufrufer if (!_uiUseGL()) return Promise.resolve(null); // GL aus → SVG-Fallback beim Aufrufer
if (!track || track.length < 2) return Promise.resolve(null); if (!track || track.length < 2) return Promise.resolve(null);
const key = opts.key || ('t' + track.length + ',' + track[0].lat + ',' + track[0].lon + ',' + const key = opts.key || ('t' + track.length + ',' + track[0].lat + ',' + track[0].lon + ',' +
track[track.length - 1].lat + ',' + track[track.length - 1].lon); track[track.length - 1].lat + ',' + track[track.length - 1].lon);
if (_snapCache.has(key)) return Promise.resolve(_snapCache.get(key)); if (_snapCache.has(key)) return Promise.resolve(_snapCache.get(key));
if (_snapReleaseTimer) { clearTimeout(_snapReleaseTimer); _snapReleaseTimer = null; }
// Serielle Verarbeitung am gemeinsamen Offscreen-Kontext. // Serielle Verarbeitung am gemeinsamen Offscreen-Kontext.
const run = _snapChain.then(() => _renderSnap(track, key)).catch(() => null); const run = _snapChain.then(() => _renderSnap(track, key)).catch(() => null);
_snapChain = run.catch(() => {}); _snapChain = run.catch(() => {});
run.then(() => {
if (_snapReleaseTimer) clearTimeout(_snapReleaseTimer);
_snapReleaseTimer = setTimeout(_releaseSnapMap, 15000);
});
return run; return run;
} }

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1204"></script> <script src="/js/landing-init.js?v=1205"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1204'; const VER = '1205';
const CACHE_VERSION = `by-v${VER}`; const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten