From 763108fa7cfef5bb3cb79fe46d922d6e8ed13062 Mon Sep 17 00:00:00 2001
From: rene
Date: Sat, 6 Jun 2026 12:34:48 +0200
Subject: [PATCH] Offline-Karten Runde 3: Puls-Icon, rollendes Vorausladen,
Ausschnitt-Download, Speicher-Cap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Offline-Indikator: pulsierendes 32px-Icon oben rechts (unter Kopfzeilen-Hoehe)
statt Leiste ueber die volle Breite — verdeckte '<- Zurueck' in der
Routennavigation (Geraetetest Rene)
- Rollendes Vorausladen: setGps laedt alle ~400m still fehlende z14+-2-Kacheln
um die Position — deckt den Weg schon beim ERSTEN Funkloch-Besuch ab
- Bereichsauswahl light: 'Sichtbaren Ausschnitt speichern' im Offline-Modal
(downloadBbox, Cap 40 MB, Zu-gross-Schutz)
- Speicher-Cap 250 MB als Soft-Guard fuer automatische Pfade + totalBytes-Zaehler
+ navigator.storage.persist() best-effort; echte LRU vertagt (Refcounting noetig)
- Auto-OSM-Raster-Prefetch entfernt (manueller Leaflet-Pfad bleibt)
- Logik-Tests (Node-Stubs) fuer Bbox/Cap/Throttle/persist bestanden
Bump v1229
---
VERSION | 2 +-
backend/static/css/components.css | 23 ++++-
backend/static/index.html | 24 ++---
backend/static/js/app.js | 2 +-
backend/static/js/map-offline.js | 126 ++++++++++++++++++++++++-
backend/static/js/offline-indicator.js | 6 +-
backend/static/js/pages/map.js | 32 ++++++-
backend/static/landing.html | 2 +-
backend/static/sw.js | 2 +-
docs/OFFLINE_MAPS_PLAN.md | 35 ++++---
10 files changed, 214 insertions(+), 40 deletions(-)
diff --git a/VERSION b/VERSION
index 1fdcf1c..c31e0ca 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1228
\ No newline at end of file
+1229
\ No newline at end of file
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 5b2a7dd..c74c888 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -7307,12 +7307,27 @@ svg.empty-state-icon {
pointer-events: none;
letter-spacing: 0.01em;
}
-/* Eingeklappt (5s nach Offline-Gang, boot.js): schmale Icon-Leiste statt 2-Zeilen-Banner —
- das volle Banner verdeckte die Karten-Steuerung oben (Gerätetest iOS 2026-06-06). */
+/* Eingeklappt (5s nach Offline-Gang, boot.js): kleines pulsierendes Icon oben rechts —
+ die Leiste über die volle Breite verdeckte Nav-Elemente (z.B. „← Zurück" in der
+ Routennavigation, Gerätetest 2026-06-07). Sitzt UNTERHALB der Kopfzeilen-Höhe,
+ damit es Buttons (Zentrieren, Legende) nie überlagert. */
#offline-banner.collapsed {
- padding: calc(env(safe-area-inset-top, 0px) + 2px) 16px 2px;
+ top: calc(env(safe-area-inset-top, 0px) + 54px);
+ left: auto;
+ right: 8px;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border-radius: 50%;
+ box-shadow: 0 2px 10px rgba(0,0,0,.35);
+ animation: by-offline-pulse 2s ease-in-out infinite;
+}
+#offline-banner.collapsed #offline-banner-text { display: none; }
+#offline-banner.collapsed #offline-queue-badge { display: none !important; }
+@keyframes by-offline-pulse {
+ 0%, 100% { opacity: 0.55; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.1); }
}
-#offline-banner.collapsed #offline-banner-text { display: none; }
/* ------------------------------------------------------------
STREAK-WIDGET (Welcome-Seite)
diff --git a/backend/static/index.html b/backend/static/index.html
index ab2e972..1e0e49a 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -86,14 +86,14 @@
Ban Yaro
-
+
-
-
-
-
-
+
+
+
+
+
@@ -612,11 +612,11 @@
-
-
-
-
-
+
+
+
+
+
@@ -626,7 +626,7 @@
-
+
diff --git a/backend/static/js/app.js b/backend/static/js/app.js
index 468016a..41835b0 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 = '1228'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
+const APP_VER = '1229'; // ← 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-offline.js b/backend/static/js/map-offline.js
index 127c286..92d6e88 100644
--- a/backend/static/js/map-offline.js
+++ b/backend/static/js/map-offline.js
@@ -234,6 +234,7 @@ window.MapOffline = (function () {
function downloadAround(lat, lon, opts) {
if (typeof opts === 'number') opts = {}; // alte Signatur (lat, lon, radiusKm) → Default-Budget
opts = opts || {};
+ _persistStorage();
var budget = (opts.budgetMB || 5) * 1048576;
var maxKm = opts.maxRadiusKm || 25;
var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM);
@@ -293,6 +294,7 @@ window.MapOffline = (function () {
return _addRegion({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: state.stored,
bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
})
+ .then(function () { return _bumpTotal(state.bytes); })
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, radiusKm: radiusKm }; });
}
@@ -301,7 +303,73 @@ window.MapOffline = (function () {
// wird nie hochgeladen. Signal = echte Tile-Fetch-Fehler bei aktivem GPS
// (NICHT navigator.onLine — das lügt bei Captive-Portal/Schwachempfang).
var _gps = null, _lastZoneNote = 0;
- function setGps(pos) { _gps = pos; } // {lat,lon} während aktiver Aufzeichnung, sonst null
+
+ // ---- Speicher-Cap (Soft-Guard für die AUTOMATISCHEN Pfade) -------------------
+ // Manuelle Downloads bleiben immer möglich; Vorausladen + Funkloch-Autofill stoppen
+ // über dem Cap. totalBytes wird bei jedem Download mitgezählt; clear() setzt zurück.
+ var CAP_MB = 250;
+ function _bumpTotal(bytes) {
+ if (!bytes) return Promise.resolve();
+ return _metaGet('totalBytes')
+ .then(function (t) { return _metaPut('totalBytes', (t || 0) + bytes); })
+ .catch(function () {});
+ }
+ function _overCap() {
+ return _metaGet('totalBytes')
+ .then(function (t) { return (t || 0) > CAP_MB * 1048576; })
+ .catch(function () { return false; });
+ }
+
+ // Persistenten Speicher anfragen (best-effort, idempotent) — härtet IndexedDB gegen
+ // Eviction bei Speicherdruck. Safari/iOS ignoriert es teils, schadet aber nicht.
+ function _persistStorage() {
+ if (_persistStorage._done) return;
+ _persistStorage._done = true;
+ try {
+ if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(function () {});
+ } catch (e) {}
+ }
+
+ // {lat,lon} während aktiver Aufzeichnung, sonst null. Nebeneffekt (Runde 3):
+ // ROLLENDES VORAUSLADEN — solange Empfang da ist, alle ~400 m die fehlenden Kacheln
+ // um die aktuelle Position still mitnehmen. Deckt den Weg + die Anfahrt ab, BEVOR
+ // man ins Funkloch läuft (greift schon beim ersten Besuch, anders als das Gedächtnis).
+ var _lastPre = null, _preActive = false;
+ function setGps(pos) {
+ _gps = pos;
+ if (!pos) { _lastPre = null; return; }
+ if (_preActive || !navigator.onLine) return;
+ if (_lastPre && _distKm(_lastPre.lat, _lastPre.lon, pos.lat, pos.lon) < 0.4) return;
+ _preActive = true;
+ var p = { lat: pos.lat, lon: pos.lon };
+ _overCap().then(function (over) {
+ if (over) return;
+ return _prefetchRing(p.lat, p.lon, 2).then(function () { _lastPre = p; });
+ }).catch(function () {})
+ .then(function () { _preActive = false; });
+ }
+
+ // z14-Kacheln ±n um lat/lon (+ Eltern z10–13) — NUR fehlende, still, ohne Region-Eintrag.
+ function _prefetchRing(lat, lon, n) {
+ var cx = _x(lon, MAXZOOM), cy = _y(lat, MAXZOOM), seen = {}, list = [];
+ for (var x = cx - n; x <= cx + n; x++) for (var y = cy - n; y <= cy + n; y++) {
+ list.push([MAXZOOM, x, y]);
+ for (var pz = 13; pz >= 10; pz--) {
+ var px = x >> (MAXZOOM - pz), py = y >> (MAXZOOM - pz), k = pz + '/' + px + '/' + py;
+ if (!seen[k]) { seen[k] = 1; list.push([pz, px, py]); }
+ }
+ }
+ var missing = [], chain = Promise.resolve();
+ list.forEach(function (t) {
+ chain = chain.then(function () {
+ return _get(t[0] + '/' + t[1] + '/' + t[2]).then(function (hit) { if (!hit) missing.push(t); });
+ });
+ });
+ var state = { done: 0, bytes: 0, stored: 0 };
+ return chain
+ .then(function () { return missing.length ? _fetchTiles(missing, state, null) : null; })
+ .then(function () { return _bumpTotal(state.bytes); });
+ }
function _distKm(aLat, aLon, bLat, bLon) {
var dLat = (bLat - aLat) * 111, dLon = (bLon - aLon) * 111 * Math.cos(aLat * Math.PI / 180);
@@ -335,7 +403,11 @@ window.MapOffline = (function () {
if (_autofillActive || !navigator.onLine) return Promise.resolve(0);
_autofillActive = true;
var filled = 0;
- return _metaGet('deadzones').then(function (zones) {
+ return _overCap().then(function (over) {
+ if (over) return null; // Speicher-Cap erreicht → kein automatisches Nachladen mehr
+ return _metaGet('deadzones');
+ }).then(function (zones) {
+ if (zones === null) return 0;
zones = zones || [];
var open = zones.filter(function (z) { return !z.filled; });
if (!open.length) return 0;
@@ -360,6 +432,7 @@ window.MapOffline = (function () {
function downloadCorridor(track, opts) {
opts = opts || {};
if (!track || track.length < 2) return Promise.reject(new Error('Kein GPS-Track'));
+ _persistStorage();
var buffer = opts.bufferKm || 1, cap = (opts.capMB || 50) * 1048576;
var seen = {}, list = [];
var push = function (z, x, y) {
@@ -403,6 +476,47 @@ window.MapOffline = (function () {
return _addRegion({ type: 'korridor', name: opts.name || null, lat: track[0].lat, lon: track[0].lon,
tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
})
+ .then(function () { return _bumpTotal(state.bytes); })
+ .then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; });
+ }
+
+ // ---- Bereichsauswahl: sichtbaren Karten-Ausschnitt komplett speichern ---------
+ // bbox = {south,west,north,east} (z.B. aktueller Viewport). Zu-groß-Schutz über
+ // Kachelzahl, Abbruch-Cap über capMB. opts {capMB:40, name, onProgress({bytes,done,total})}.
+ function downloadBbox(bbox, opts) {
+ opts = opts || {};
+ _persistStorage();
+ var cap = (opts.capMB || 40) * 1048576;
+ var seen = {}, list = [];
+ var push = function (z, x, y) {
+ if (x < 0 || y < 0 || x >= Math.pow(2, z) || y >= Math.pow(2, z)) return;
+ var k = z + '/' + x + '/' + y;
+ if (!seen[k]) { seen[k] = 1; list.push([z, x, y]); }
+ };
+ for (var z = 0; z <= MAXZOOM; z++) {
+ var x0 = _x(bbox.west, z), x1 = _x(bbox.east, z), y0 = _y(bbox.north, z), y1 = _y(bbox.south, z);
+ if (z === MAXZOOM && (x1 - x0 + 1) * (y1 - y0 + 1) > 4000) {
+ return Promise.reject(new Error('Bereich zu groß — bitte weiter reinzoomen.'));
+ }
+ for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) push(z, x, y);
+ }
+ var state = { done: 0, bytes: 0, stored: 0 }, total = list.length, poiCount = 0;
+ function chunkLoop(idx) {
+ if (idx >= list.length || state.bytes >= cap) return Promise.resolve();
+ return _fetchTiles(list.slice(idx, idx + 64), state, function () {
+ if (opts.onProgress) opts.onProgress({ bytes: state.bytes, done: state.done, total: total });
+ }).then(function () { return chunkLoop(idx + 64); });
+ }
+ var midLat = (bbox.south + bbox.north) / 2, midLon = (bbox.west + bbox.east) / 2;
+ return chunkLoop(0)
+ .then(function () { return _cacheGlyphs(); })
+ .then(function (gb) { state.bytes += gb; return _cachePois(bbox); })
+ .then(function (pc) {
+ poiCount = pc;
+ return _addRegion({ type: 'ausschnitt', name: opts.name || null, lat: midLat, lon: midLon,
+ tiles: state.stored, bytes: state.bytes, pois: poiCount, savedAt: Date.now() });
+ })
+ .then(function () { return _bumpTotal(state.bytes); })
.then(function () { return { tiles: state.stored, bytes: state.bytes, pois: poiCount, capped: state.bytes >= cap }; });
}
@@ -432,8 +546,10 @@ window.MapOffline = (function () {
function stats() {
return _count().then(function (count) {
return _metaGet('regions').then(function (regions) {
- return _metaGet('region').then(function (meta) {
- return { count: count, meta: meta || null, regions: regions || [] };
+ return _metaGet('totalBytes').then(function (totalBytes) {
+ return _metaGet('region').then(function (meta) {
+ return { count: count, meta: meta || null, regions: regions || [], totalBytes: totalBytes || 0 };
+ });
});
});
});
@@ -446,7 +562,7 @@ window.MapOffline = (function () {
return {
registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor,
- tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage,
+ downloadBbox: downloadBbox, tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage,
setGps: setGps, markDeadZone: markDeadZone, autoFillDeadZones: autoFillDeadZones,
stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM,
};
diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js
index cb6bf6e..54a06be 100644
--- a/backend/static/js/offline-indicator.js
+++ b/backend/static/js/offline-indicator.js
@@ -417,9 +417,9 @@ window.OfflineIndicator = (() => {
function init() {
refresh();
_prefetchPages();
- // OSM-Raster-Prefetch nur für die Leaflet-Karte — die GL-Karte (byt://-Vektorkacheln)
- // nutzt das Raster nicht. Komplett-Entfernung wenn Flag dauerhaft AN (OFFLINE_MAPS_PLAN.md).
- if (!_offlineTilesMode()) _prefetchTiles();
+ // Automatischer OSM-Raster-Prefetch ENTFERNT (2026-06-07): Flag ist auf allen deployten
+ // Hosts AN, die GL-Karte nutzt das Raster nicht. _prefetchTiles bleibt nur noch für den
+ // manuellen „Fehlende nachladen"-Pfad im Leaflet-Modus (localhost / by_map_gl=0).
_prefetchData();
_bindLongPress();
diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js
index e62f653..e179322 100644
--- a/backend/static/js/pages/map.js
+++ b/backend/static/js/pages/map.js
@@ -2190,6 +2190,34 @@ window.Page_map = (() => {
}
}
+ // Bereichsauswahl: den SICHTBAREN Karten-Ausschnitt komplett speichern (z.B. fürs
+ // Urlaubsziel: hinzoomen/-schieben, speichern). Cap 40 MB, Zu-groß-Schutz in MapOffline.
+ async function _downloadViewport() {
+ if (!_map || !window.MapOffline) return;
+ const btn = document.getElementById('map-offline-btn');
+ if (btn?.classList.contains('loading')) return;
+ const p = _mapPaddedBounds(0.02);
+ btn?.classList.add('loading');
+ _setOsmStatus('Offline: 0 MB…');
+ try {
+ const res = await MapOffline.downloadBbox(
+ { south: p.south, west: p.west, north: p.north, east: p.east },
+ { capMB: 40, onProgress: pr => {
+ _setOsmStatus(`Offline: ${(pr.bytes / 1048576).toFixed(1)} MB (${Math.round(pr.done / pr.total * 100)} %)…`);
+ } });
+ _setOsmStatus('');
+ UI.toast.success(`Ausschnitt offline gespeichert — ${res.pois || 0} Marker, ${(res.bytes / 1048576).toFixed(1)} MB.`
+ + `${res.capped ? ' (40-MB-Limit erreicht)' : ''}`);
+ window.OfflineIndicator?.refresh();
+ if (_covOn) _setCoverage(true);
+ } catch (e) {
+ _setOsmStatus('');
+ UI.toast.error(e?.message?.includes('zu groß') ? e.message : 'Offline-Download fehlgeschlagen — bitte erneut versuchen.');
+ } finally {
+ btn?.classList.remove('loading');
+ }
+ }
+
// ----------------------------------------------------------
// Offline-Bereiche-Layer (gespeicherte z14-Kacheln) + Verwaltungs-Modal
// ----------------------------------------------------------
@@ -2226,7 +2254,7 @@ window.Page_map = (() => {
let s = { regions: [] };
try { s = await MapOffline.stats(); } catch (e) {}
const regions = s.regions || [];
- const totalBytes = regions.reduce((a, r) => a + (r.bytes || 0), 0);
+ const totalBytes = s.totalBytes || regions.reduce((a, r) => a + (r.bytes || 0), 0);
const totalPois = regions.reduce((a, r) => a + (r.pois || 0), 0);
UI.modal.open({
title: '🗺️ Offline-Karten',
@@ -2238,6 +2266,7 @@ window.Page_map = (() => {
+
${regions.length ? `` : ''}
@@ -2245,6 +2274,7 @@ window.Page_map = (() => {
footer: ``,
});
document.getElementById('off-dl')?.addEventListener('click', () => { UI.modal.close(); _downloadVectorRegion(); });
+ document.getElementById('off-bbox')?.addEventListener('click', () => { UI.modal.close(); _downloadViewport(); });
document.getElementById('off-cov')?.addEventListener('click', async () => { UI.modal.close(); await _setCoverage(!_covOn); });
document.getElementById('off-clear')?.addEventListener('click', async e => {
const btn = e.currentTarget;
diff --git a/backend/static/landing.html b/backend/static/landing.html
index c662fdf..d2f572d 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 c0390b0..27b8baf 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 = '1228';
+const VER = '1229';
const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
diff --git a/docs/OFFLINE_MAPS_PLAN.md b/docs/OFFLINE_MAPS_PLAN.md
index c440341..d7e7af1 100644
--- a/docs/OFFLINE_MAPS_PLAN.md
+++ b/docs/OFFLINE_MAPS_PLAN.md
@@ -67,17 +67,30 @@ nach bestandenen Gerätetests Runde 1+2). localhost = Leaflet/AUS.
bestanden) — er lag im bereits gespeicherten Gebiet. Nach dem Speichern werden die
gespeicherten Bereiche jetzt blau auf der Routen-Detailkarte eingeblendet (`_detailMap._gl`).
-**🔲 Offen (Runde 3):**
-- **Gerätetest Runde 2** (Budget-Download, Funkloch-Lernen auf echter Gassi-Runde, Korridor,
- Coverage-Layer) → dann Prod-Freigabe-Entscheidung (BY.offlineTiles-Default erweitern analog `by_map_gl`).
-- **Rollendes Vorausladen beim Aufzeichnen** (fortlaufend um die aktuelle Position cachen, solange
- Empfang da — deckt den Weg schon beim ersten Mal ab; Akku-/Datensparsamkeit beachten).
-- **Bereichsauswahl** (Karten-Ausschnitt/Rechteck als Download-Gebiet) — Korridor deckt den
- Hauptfall ab, Rest nach Bedarf.
-- **Speicher-Cap + LRU** über alles (alte Gebiete fliegen automatisch raus) + optional
- `navigator.storage.persist()`.
-- Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles` + `map.js _cacheTiles`) komplett
- entfernen, wenn Flag dauerhaft AN (auch Prod).
+**✅ Runde 3 (2026-06-07):**
+- **Offline-Indikator = pulsierendes Icon** oben rechts (32 px, unterhalb der Kopfzeilen-Höhe) statt
+ Banner über die volle Breite — verdeckte Nav-Elemente, z.B. „← Zurück" in der Routennavigation
+ (Gerätetest René). Vollbanner weiterhin 5 s beim Offline-Gang, dann Icon.
+- **Rollendes Vorausladen beim Aufzeichnen:** `setGps()` lädt alle ~400 m still die FEHLENDEN
+ z14±2-Kacheln (+Eltern) um die Position, solange online — deckt Weg + Anfahrt schon beim ERSTEN
+ Besuch ab (das Funkloch-Gedächtnis greift erst ab dem 2.). Kein Region-Eintrag, kein UI.
+- **Bereichsauswahl light:** Modal-Button „Sichtbaren Ausschnitt speichern" → `downloadBbox(viewport,
+ {capMB:40})` mit Zu-groß-Schutz (>4000 z14-Kacheln → „bitte reinzoomen").
+- **Speicher-Cap 250 MB (Soft-Guard):** `totalBytes`-Zähler in Meta; AUTOMATISCHE Pfade (Vorausladen,
+ Funkloch-Autofill) stoppen über dem Cap, manuelle bleiben; `navigator.storage.persist()` best-effort.
+ Echte LRU-Eviction bewusst vertagt (Kacheln werden regionsübergreifend geteilt → Eviction braucht
+ Refcounting; bei ~8 MB/Gebiet kein Druck).
+- **Auto-OSM-Raster-Prefetch entfernt** (offline-indicator init); `_prefetchTiles`/`_cacheTiles`
+ bleiben nur für den manuellen Leaflet-Pfad (localhost / by_map_gl=0).
+- Logik per Node-Stub-Tests verifiziert (Bbox, Zu-groß, Cap, Prefetch-Throttle, persist).
+ Achtung Node 21+: eingebautes `navigator`-Global schluckt `global.navigator=`-Stubs —
+ `Object.defineProperty(globalThis, 'navigator', …)` verwenden.
+
+**🔲 Offen (Backlog):**
+- Echte LRU-Eviction (Refcounting/Region-Zuordnung der Kacheln), wenn Nutzer real ans Cap kommen.
+- Rechteck-Zeichnen als präzisere Bereichsauswahl (Viewport-Variante deckt den Hauptfall ab).
+- POIs auch beim rollenden Vorausladen (aktuell nur Kacheln; Giftköder kommen aus dem
+ localStorage-Fallback der letzten Online-Position).
## Ziel
GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**.