/* ============================================================ BAN YARO — Offline-Vektorkacheln Lädt einen Bereich aus der Remote-PMTiles (dach.pmtiles) als einzelne MVT-Tiles in IndexedDB und bedient MapLibre offline daraus über das `byt://`-Protokoll. Plan/Architektur: docs/OFFLINE_MAPS_PLAN.md ============================================================ */ window.MapOffline = (function () { 'use strict'; var DB_NAME = 'by-offline-tiles', STORE = 'tiles', META = 'meta', DB_VER = 1; var MAXZOOM = 14; // unsere pmtiles enden bei z14 (Overzoom darüber) var _db = null, _pm = null; // Hinweis: pmtiles.getZxy() liefert die Tiles BEREITS dekomprimiert (rohe MVT-Protobufs) → // wir speichern/servieren sie direkt, kein gunzip. Dadurch ist die IndexedDB-Größe ~2,5× die // komprimierte pmtiles-Extract-Größe (5 km ≈ ~16 MB statt 6,4 MB) — fürs Handy unkritisch. // ---- IndexedDB ---- function _open() { if (_db) return Promise.resolve(_db); return new Promise(function (res, rej) { var r = indexedDB.open(DB_NAME, DB_VER); r.onupgradeneeded = function () { var d = r.result; if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE); if (!d.objectStoreNames.contains(META)) d.createObjectStore(META); }; r.onsuccess = function () { _db = r.result; res(_db); }; r.onerror = function () { rej(r.error); }; }); } function _req(store, mode, make) { return _open().then(function (d) { return new Promise(function (res, rej) { var tx = d.transaction(store, mode), rq = make(tx.objectStore(store)); tx.oncomplete = function () { res(rq ? rq.result : undefined); }; tx.onerror = function () { rej(tx.error); }; }); }); } var _get = function (k) { return _req(STORE, 'readonly', function (os) { return os.get(k); }); }; var _put = function (k, v) { return _req(STORE, 'readwrite', function (os) { os.put(v, k); }); }; var _del = function (k) { return _req(STORE, 'readwrite', function (os) { os.delete(k); }); }; var _count = function () { return _req(STORE, 'readonly', function (os) { return os.count(); }); }; // Viele Keys in EINER Transaktion löschen (einzelne _del-Transaktionen wären zu langsam). function _delMany(keys) { if (!keys.length) return Promise.resolve(); return _open().then(function (d) { return new Promise(function (res, rej) { var tx = d.transaction(STORE, 'readwrite'), os = tx.objectStore(STORE); keys.forEach(function (k) { os.delete(k); }); tx.oncomplete = function () { res(); }; tx.onerror = function () { rej(tx.error); }; }); }); } var _metaGet = function (k) { return _req(META, 'readonly', function (os) { return os.get(k); }); }; var _metaPut = function (k, v) { return _req(META, 'readwrite', function (os) { os.put(v, k); }); }; // ---- Remote-PMTiles ---- function _pmInst() { if (!_pm) _pm = new pmtiles.PMTiles(MapGLStyle.tilesUrl()); return _pm; } // MVT-Bytes (Uint8Array) für z/x/y — IndexedDB zuerst, sonst remote (online), sonst null. function tile(z, x, y) { return _get(z + '/' + x + '/' + y).then(function (hit) { if (hit) return hit instanceof Uint8Array ? hit : new Uint8Array(hit); return _pmInst().getZxy(z, x, y).then(function (r) { return (r && r.data) ? new Uint8Array(r.data) : null; // getZxy ist bereits dekomprimiert }).catch(function () { _noteRemoteMiss(); // Funkloch-Signal: echter Fetch-Fehler bei aktivem GPS return null; // offline + nicht gespeichert → leeres Tile }); }); } // MapLibre-Protokolle registrieren (idempotent): // byt://t/{z}/{x}/{y} → Vektorkachel (MVT) // byt://f/{fontstack}/{range} → Glyph-PBF (fontstack ggf. URL-encodiert, je nach MapLibre-Version) function registerProtocol() { if (registerProtocol._done || typeof maplibregl === 'undefined') return; registerProtocol._done = true; maplibregl.addProtocol('byt', function (params) { var ret = function (u) { if (!u) return { data: new ArrayBuffer(0) }; return { data: u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) }; }; var t = /byt:\/\/t\/(\d+)\/(\d+)\/(\d+)/.exec(params.url); if (t) return tile(+t[1], +t[2], +t[3]).then(ret); var f = /byt:\/\/f\/([^/]+)\/(\d+-\d+)/.exec(params.url); if (f) return glyph(decodeURIComponent(f[1]), f[2]).then(ret); return Promise.resolve({ data: new ArrayBuffer(0) }); }); } // ---- Slippy-Tile-Mathe ---- function _x(lon, z) { return Math.floor((lon + 180) / 360 * Math.pow(2, z)); } function _y(lat, z) { var r = lat * Math.PI / 180; return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * Math.pow(2, z)); } function _bboxAround(lat, lon, radiusKm) { var dLat = radiusKm / 111, dLon = radiusKm / (111 * Math.cos(lat * Math.PI / 180)); return { south: lat - dLat, west: lon - dLon, north: lat + dLat, east: lon + dLon }; } // Glyphs (Open Sans Regular/Semibold, Latin + Latin-Extended) in IndexedDB persistieren // (Key-Präfix 'f/' im Tiles-Store, kein Schema-Bump) — überlebt App-Updates, anders als der // SW-Cache, der beim Update gepurged wird. Offline serviert übers byt://f/-Protokoll. // KRITISCH: ohne Glyphs lässt MapLibre offline die GANZE Kachel fallen (nicht nur die Labels) → leer. // 0-255 + 256-511 deckt DE/FR/PL/CZ/IT-Sonderzeichen ab. var FONTS = ['Open Sans Regular', 'Open Sans Semibold'], RANGES = ['0-255', '256-511']; function _glyphUrl(font, range) { return '/fonts/' + encodeURIComponent(font) + '/' + range + '.pbf'; } function _cacheGlyphs() { var bytes = 0, jobs = []; FONTS.forEach(function (f) { RANGES.forEach(function (rg) { jobs.push(fetch(_glyphUrl(f, rg)) .then(function (r) { return r.ok ? r.arrayBuffer() : null; }) .then(function (b) { if (!b) return; bytes += b.byteLength; return _put('f/' + f + '/' + rg, new Uint8Array(b)); }) .catch(function () {})); }); }); return Promise.all(jobs).then(function () { return bytes; }); } // Glyph-Bytes für fontstack/range — IndexedDB zuerst, sonst remote (online), sonst null. function glyph(font, range) { return _get('f/' + font + '/' + range).then(function (hit) { if (hit) return hit instanceof Uint8Array ? hit : new Uint8Array(hit); return fetch(_glyphUrl(font, range)) .then(function (r) { return r.ok ? r.arrayBuffer() : null; }) .then(function (b) { return b ? new Uint8Array(b) : null; }) .catch(function () { return null; }); }); } // POI-Marker der Region mitspeichern (Key-Präfix 'p/' im Tiles-Store) — sonst steht die // Offline-Karte ohne Marker da (Gerätetest 2026-06-06). Quelle: /api/osm/pois (liest die lokale // osm_pois-DB, fast=true). Typen = Werte von OSM_LAYER_MAP in pages/map.js (synchron halten). var POI_TYPES = ['waste_basket', 'dog_park', 'drinking_water', 'tierarzt', 'hundesalon', 'shop', 'restaurant', 'bank', 'giftkoeder', 'kotbeutel', 'gefahr', 'parkplatz', 'treffpunkt', 'sonstiges', 'hotel']; // Frische Liste mit Bestand mergen (per id) — eine zweite Region (Urlaubsort) darf die // erste nicht löschen. WICHTIG für Warnungen (René 2026-06-08): Die Server-Antwort ist // für die geladene Bbox AUTORITATIV — Bestands-Einträge INNERHALB der Bbox, die nicht // mehr geliefert werden (aufgehobener Giftköder, gefundener Hund, wegen Meldungen // ausgeblendeter Marker), werden ENTFERNT. fresh == null (Fetch fehlgeschlagen) merged // NIE — sonst würde ein Offline-Refresh den Bestand wegputzen. function _mergeStore(key, fresh, bbox) { if (fresh == null) return Promise.resolve(0); return _get(key).then(function (old) { var seen = {}; fresh.forEach(function (p) { seen[p.id] = true; }); var keep = (old || []).filter(function (p) { if (seen[p.id]) return false; if (bbox && p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east) return false; // in Bbox, nicht mehr da → weg return true; }); var merged = fresh.concat(keep); if (!merged.length && !(old && old.length)) return 0; return _put(key, merged).then(function () { return fresh.length; }); }); } function _cachePois(bbox) { var total = 0; var jobs = POI_TYPES.map(function (type) { var params = new URLSearchParams({ type: type, fast: 'true', south: bbox.south, west: bbox.west, north: bbox.north, east: bbox.east }); return fetch('/api/osm/pois?' + params) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (fresh) { return _mergeStore('p/' + type, fresh, bbox); }) .then(function (n) { total += n; }) .catch(function () {}); }); // Sicherheitsdaten MÜSSEN offline da sein (René 2026-06-07): Giftköder-Alarme // (/api/poison, Radius in m) + vermisste Hunde (/api/lost, Radius in km) — beide anonym. // Fetch-Radius ×√2: der Kreis muss die Bbox-ECKEN abdecken, sonst räumt der // Bbox-Replace in _mergeStore dort fälschlich Bestand weg. var midLat = (bbox.south + bbox.north) / 2, midLon = (bbox.west + bbox.east) / 2; var radiusKm = Math.max( (bbox.north - bbox.south) * 111 / 2, (bbox.east - bbox.west) * 111 * Math.cos(midLat * Math.PI / 180) / 2, 1); jobs.push(fetch('/api/poison?lat=' + midLat + '&lon=' + midLon + '&radius=' + Math.round(radiusKm * 1415)) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (fresh) { return _mergeStore('p/_poison', fresh, bbox); }) .then(function (n) { total += n; }) .catch(function () {})); jobs.push(fetch('/api/lost?lat=' + midLat + '&lon=' + midLon + '&radius_km=' + Math.ceil(radiusKm * 1.42)) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (fresh) { return _mergeStore('p/_lost', fresh, bbox); }) .then(function (n) { total += n; }) .catch(function () {})); return Promise.all(jobs).then(function () { return total; }); } // Warnungen aktuell halten (René 2026-06-08: Giftköder können aufgehoben, Hunde gefunden // sein): max. alle 24 h den 50-km-Umkreis der Position frisch laden — der Bbox-Replace // in _mergeStore räumt Aufgehobenes weg. Bbox (~35 km Halbseite) liegt IM Fetch-Kreis. function _refreshAlerts(lat, lon) { return _metaGet('alertsTs').then(function (ts) { if (ts && Date.now() - ts < 86400000) return; var bb = { south: lat - 0.31, north: lat + 0.31, west: lon - 0.47, east: lon + 0.47 }; return Promise.all([ fetch('/api/poison?lat=' + lat + '&lon=' + lon + '&radius=50000') .then(function (r) { return r.ok ? r.json() : null; }) .then(function (f) { return _mergeStore('p/_poison', f, bb); }) .catch(function () {}), fetch('/api/lost?lat=' + lat + '&lon=' + lon + '&radius_km=50') .then(function (r) { return r.ok ? r.json() : null; }) .then(function (f) { return _mergeStore('p/_lost', f, bb); }) .catch(function () {}), ]).then(function () { return _metaPut('alertsTs', Date.now()); }); }).catch(function () {}); } // Gespeicherte Sicherheits-Alarme ('poison' | 'lost') im Bbox-Ausschnitt — Offline-Fallback. function alerts(kind, bbox) { return _get('p/_' + kind).then(function (list) { if (!list || !list.length) return []; return list.filter(function (p) { return p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east; }); }).catch(function () { return []; }); } // Gespeicherte POIs eines Typs im Bbox-Ausschnitt — Offline-Fallback für die Karten-Marker. function pois(type, bbox) { return _get('p/' + type).then(function (list) { if (!list || !list.length) return []; return list.filter(function (p) { return p.lat >= bbox.south && p.lat <= bbox.north && p.lon >= bbox.west && p.lon <= bbox.east; }); }).catch(function () { return []; }); } // Kachelbreite (km) auf Zoom z bei lat — für Ring↔Radius-Umrechnung. function _tileKm(z, lat) { return 40075 * Math.cos(lat * Math.PI / 180) / Math.pow(2, z); } // Kachel-Liste laden (Concurrency 6) — addiert auf state {done,bytes,stored}, ruft onTick alle 8 Tiles. function _fetchTiles(list, state, onTick) { var i = 0, CONC = 6; function next() { if (i >= list.length) return Promise.resolve(); var t = list[i++], key = t[0] + '/' + t[1] + '/' + t[2]; return _pmInst().getZxy(t[0], t[1], t[2]).then(function (r) { if (r && r.data) { var u = new Uint8Array(r.data); state.bytes += u.byteLength; state.stored++; return _put(key, u); } }).catch(function () {}).then(function () { state.done++; if (onTick && state.done % 8 === 0) onTick(); return next(); }); } var w = []; for (var k = 0; k < CONC; k++) w.push(next()); return Promise.all(w); } // Gespeichertes Gebiet in der Regions-Liste verbuchen ('region' = letztes, Back-Compat). // Gleicher Typ + Name (z.B. Korridor derselben Route erneut geladen) ersetzt den Alt-Eintrag. function _addRegion(region) { return _metaGet('regions').then(function (list) { list = (list || []).filter(function (r) { return !(region.name && r.type === region.type && r.name === region.name); }); list.push(region); if (list.length > 30) list = list.slice(-30); return _metaPut('regions', list); }).then(function () { return _metaPut('region', region); }); } // Gebiet um lat/lon BUDGET-getrieben laden (Modell René 2026-06-07): z14-Ringe um den // Standort expandieren (+ Eltern-Kacheln z10–13), bis budgetMB GESPEICHERTE Bytes erreicht // sind — deckt in der Stadt einen kleineren, auf dem Land einen größeren Bereich ab // (dort sind die Funklöcher). Basis-Zooms 0–9 sind immer dabei (winzig). // opts {budgetMB:5, maxRadiusKm:25, onProgress({bytes,budget,done,radiusKm})}. // Liefert {tiles, bytes, pois, radiusKm}. 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); var kmPerTile = _tileKm(MAXZOOM, lat); var maxRing = Math.max(1, Math.ceil(maxKm / kmPerTile)); var seen = {}, state = { done: 0, bytes: 0, stored: 0 }; var tick = function (radiusKm) { if (opts.onProgress) opts.onProgress({ bytes: state.bytes, budget: budget, done: state.done, radiusKm: radiusKm }); }; // Basis-Zooms 0–9 über die maximale Bbox. var base = [], bb = _bboxAround(lat, lon, maxKm); for (var z = 0; z <= 9; z++) { var x0 = _x(bb.west, z), x1 = _x(bb.east, z), y0 = _y(bb.north, z), y1 = _y(bb.south, z); for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) { var k = z + '/' + x + '/' + y; if (!seen[k]) { seen[k] = 1; base.push([z, x, y]); } } } // Ring r um die Zentrums-Kachel (z14) + zugehörige Eltern z10–13. function ringTiles(r) { var list = []; var push = function (z2, x2, y2) { if (x2 < 0 || y2 < 0 || x2 >= Math.pow(2, z2) || y2 >= Math.pow(2, z2)) return; var k2 = z2 + '/' + x2 + '/' + y2; if (!seen[k2]) { seen[k2] = 1; list.push([z2, x2, y2]); } }; for (var x2 = cx - r; x2 <= cx + r; x2++) for (var y2 = cy - r; y2 <= cy + r; y2++) { if (Math.max(Math.abs(x2 - cx), Math.abs(y2 - cy)) !== r) continue; push(MAXZOOM, x2, y2); for (var pz = 13; pz >= 10; pz--) push(pz, x2 >> (MAXZOOM - pz), y2 >> (MAXZOOM - pz)); } return list; } var coveredRing = 0; function nextRing(r) { if (r > maxRing || state.bytes >= budget) return Promise.resolve(); return _fetchTiles(ringTiles(r), state, function () { tick(r * kmPerTile); }).then(function () { coveredRing = r; tick(r * kmPerTile); return nextRing(r + 1); }); } var radiusKm = 0, poiCount = 0; return _fetchTiles(base, state, function () { tick(0); }) .then(function () { return nextRing(0); }) .then(function () { radiusKm = Math.max(1, Math.round(coveredRing * kmPerTile * 10) / 10); return _cacheGlyphs(); // Glyphs mitcachen (sonst offline kein Render) }) .then(function (gb) { state.bytes += gb; return _cachePois(_bboxAround(lat, lon, radiusKm)); }) .then(function (pc) { poiCount = pc; return _addRegion({ type: opts.type || 'gebiet', 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 }; }); } // ---- Funkloch-Gedächtnis ---------------------------------------------------- // „Wo verliere ich Netz" = Aufenthaltsorte → bleibt KOMPLETT LOKAL (IndexedDB), // 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; // ---- 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', Math.max(0, (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. Nebeneffekte (Runde 3): // 1. ROLLENDES VORAUSLADEN — solange Empfang da ist, alle ~400 m die fehlenden Kacheln // um die aktuelle Position still mitnehmen. EPHEMER: Hatte die Runde KEIN Funkloch, // wird das Vorausgeladene am Ende wieder gelöscht — gespeichert bleibt nur, was // wegen fehlenden Netzes nötig ist + manuell Hinzugefügtes (Modell René 2026-06-08). // 2. NETZ-PROBE — erkennt Funklöcher auch in BEREITS GESPEICHERTEN Gebieten (dort // kommen Kacheln aus IndexedDB → kein Remote-Miss als Signal). var _lastPre = null, _preActive = false; var _preKeys = [], _preBytes = 0, _recHadDeadzone = false; function setGps(pos) { if (!pos) { // Aufzeichnung beendet _gps = null; _lastPre = null; _prunePrefetch(); return; } _gps = pos; _probeNet(pos); 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; }); } // Aktive Netz-Probe (alle ~2 Min bei Aufzeichnung): kleiner Request mit Timeout. // Fehlschlag = Funkloch an dieser Position — unabhängig davon, ob die Kacheln // hier längst lokal liegen. (navigator.onLine lügt bei Schwachempfang.) var _lastProbe = 0; function _probeNet(pos) { var now = Date.now(); if (now - _lastProbe < 120000) return; _lastProbe = now; var p = { lat: pos.lat, lon: pos.lon }; var ctrl = (typeof AbortController !== 'undefined') ? new AbortController() : null; var timer = ctrl && setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 6000); fetch('/api/version', { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }) .then(function (r) { if (!r.ok) throw new Error('probe ' + r.status); }) .catch(function () { _recHadDeadzone = true; markDeadZone(p.lat, p.lon); }) .then(function () { if (timer) clearTimeout(timer); }); } // Vorausgeladenes wieder entfernen, wenn die Runde KEIN Funkloch hatte. function _prunePrefetch() { var keys = _preKeys, bytes = _preBytes, had = _recHadDeadzone; _preKeys = []; _preBytes = 0; _recHadDeadzone = false; if (had || !keys.length) return Promise.resolve(); var chain = Promise.resolve(); keys.forEach(function (k) { chain = chain.then(function () { return _del(k).catch(function () {}); }); }); return chain.then(function () { return _bumpTotal(-bytes); }); } // 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 () { // Nur NEU gespeicherte Keys fürs Prune merken — Bestand bleibt unangetastet. missing.forEach(function (t) { _preKeys.push(t[0] + '/' + t[1] + '/' + t[2]); }); _preBytes += state.bytes; 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); return Math.sqrt(dLat * dLat + dLon * dLon); } function _noteRemoteMiss() { if (!_gps) return; _recHadDeadzone = true; // Vorausgeladenes dieser Runde behalten var now = Date.now(); if (now - _lastZoneNote < 120000) return; // max. 1 Eintrag / 2 Min _lastZoneNote = now; markDeadZone(_gps.lat, _gps.lon); } // Zone manuell „ent-funklochen" (✕ im Offline-Modal): wird nicht mehr automatisch // geladen; bereits geladene Kacheln bleiben bis „Alles löschen"/Eviction. function removeDeadZone(ts) { return _metaGet('deadzones').then(function (zones) { zones = (zones || []).filter(function (z) { return z.ts !== ts; }); return _metaPut('deadzones', zones).then(function () { return zones.length; }); }); } // Funkloch merken (Dedupe: keine zweite Zone im Umkreis von 2 km, Cap 50). function markDeadZone(lat, lon) { return _metaGet('deadzones').then(function (zones) { zones = zones || []; if (zones.some(function (z) { return _distKm(z.lat, z.lon, lat, lon) < 2; })) return; zones.push({ lat: lat, lon: lon, ts: Date.now(), filled: false }); if (zones.length > 50) zones = zones.slice(-50); return _metaPut('deadzones', zones); }).catch(function () {}); } // Funkloch-Zonen nachladen (nur online sinnvoll). Dort wo Netz verfügbar ist, braucht // man keine Offline-Karten — gespeichert wird gezielt da, wo es ausfiel. // opts {lat, lon, maxKm:50}: mit Position werden nur Zonen im Umkreis betrachtet // (Speicher minimal halten) und nächstgelegene zuerst geladen. Gefüllte Zonen werden // VERIFIZIERT (Zentrums-Kachel noch da?) — fängt „Alles löschen" und iOS-Eviction ab: // die Gebiete kommen beim nächsten Start automatisch wieder (Modell René 2026-06-08). // Liefert die Anzahl (neu) gefüllter Zonen. var _autofillActive = false; function autoFillDeadZones(opts) { opts = opts || {}; var maxKm = opts.maxKm || 50; if (_autofillActive || !navigator.onLine) return Promise.resolve(0); _autofillActive = true; var filled = 0, all = null; 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; // expliziter Cap-Abbruch (s.o.) zones = zones || []; // keine Zonen → trotzdem Warnungs-Refresh unten all = zones; var cand = opts.lat == null ? zones.slice() : zones.filter(function (z) { return _distKm(z.lat, z.lon, opts.lat, opts.lon) <= maxKm; }); if (!cand.length) { // Keine Zonen in der Nähe — Warnungen trotzdem frisch halten (24-h-Takt). if (opts.lat != null) return _refreshAlerts(opts.lat, opts.lon).then(function () { return 0; }); return 0; } // Verify: als gefüllt markierte Zonen ohne lokale Zentrums-Kachel wieder öffnen. var verify = Promise.resolve(); cand.forEach(function (z) { verify = verify.then(function () { if (!z.filled) return; return _get(MAXZOOM + '/' + _x(z.lon, MAXZOOM) + '/' + _y(z.lat, MAXZOOM)) .then(function (hit) { if (!hit) z.filled = false; }); }); }); return verify.then(function () { var open = cand.filter(function (z) { return !z.filled; }); if (opts.lat != null) open.sort(function (a, b) { return _distKm(a.lat, a.lon, opts.lat, opts.lon) - _distKm(b.lat, b.lon, opts.lat, opts.lon); }); var chain = Promise.resolve(); open.forEach(function (z) { chain = chain.then(function () { return downloadAround(z.lat, z.lon, { budgetMB: 5, type: 'funkloch' }).then(function (res) { if (res.bytes > 0) { z.filled = true; filled++; } }).catch(function () {}); }); }); return chain.then(function () { return _metaPut('deadzones', all); }) .then(function () { // Warnungen im Umkreis frisch halten (24-h-Takt, Bbox-Replace). if (opts.lat != null) return _refreshAlerts(opts.lat, opts.lon); }) .then(function () { return filled; }); }); }).then(function (n) { _autofillActive = false; return n; }, function () { _autofillActive = false; return 0; }); } // ---- Routen-Korridor --------------------------------------------------------- // Kacheln ±bufferKm um den Track (z14 + Eltern z10–13 + Basis 0–9) + Marker der // Korridor-Bbox — für mehrtägige Unternehmungen entlang einer Route. // track = [{lat,lon},...]; opts {bufferKm:1, capMB:50, name, onProgress({bytes,done,total})}. 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) { 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]); } }; var s = 90, w = 180, n = -90, e = -180; track.forEach(function (p) { if (p.lat < s) s = p.lat; if (p.lat > n) n = p.lat; if (p.lon < w) w = p.lon; if (p.lon > e) e = p.lon; var d = Math.ceil(buffer / _tileKm(MAXZOOM, p.lat)); var cx = _x(p.lon, MAXZOOM), cy = _y(p.lat, MAXZOOM); for (var x = cx - d; x <= cx + d; x++) for (var y = cy - d; y <= cy + d; y++) { push(MAXZOOM, x, y); for (var pz = 13; pz >= 10; pz--) push(pz, x >> (MAXZOOM - pz), y >> (MAXZOOM - pz)); } }); var latMid = (s + n) / 2; var bb = { south: s - buffer / 111, north: n + buffer / 111, west: w - buffer / (111 * Math.cos(latMid * Math.PI / 180)), east: e + buffer / (111 * Math.cos(latMid * Math.PI / 180)) }; for (var z2 = 0; z2 <= 9; z2++) { var x0 = _x(bb.west, z2), x1 = _x(bb.east, z2), y0 = _y(bb.north, z2), y1 = _y(bb.south, z2); for (var x2 = x0; x2 <= x1; x2++) for (var y2 = y0; y2 <= y1; y2++) push(z2, x2, y2); } var state = { done: 0, bytes: 0, stored: 0 }, total = list.length, poiCount = 0; // In 64er-Blöcken laden → zwischen den Blöcken greift der Speicher-Cap. 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); }); } return chunkLoop(0) .then(function () { return _cacheGlyphs(); }) .then(function (gb) { state.bytes += gb; return _cachePois(bb); }) .then(function (pc) { poiCount = pc; 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 }; }); } // STANDORT-GRUNDVERSORGUNG (Renés Modell, Punkt 1): das Gebiet um die aktuelle Position // ist IMMER geladen (~5 MB) — auch nach „Alles löschen", sonst ist die Offline- // Funktionalität am wichtigsten Ort (hier!) weg. Zentrums-Kachel da → nichts zu tun. function ensureHomeArea(lat, lon) { return _get(MAXZOOM + '/' + _x(lon, MAXZOOM) + '/' + _y(lat, MAXZOOM)).then(function (hit) { if (hit) return 0; return _overCap().then(function (over) { if (over) return 0; return downloadAround(lat, lon, { budgetMB: 5, type: 'standort' }) .then(function (res) { return res.bytes > 0 ? 1 : 0; }) .catch(function () { return 0; }); }); }).catch(function () { return 0; }); } // Gespeicherte Routen offline nutzbar halten (René 2026-06-08): beim Start die Korridore // der eigenen Routen in Positionsnähe sicherstellen — Stichproben-Kacheln prüfen, bei // Lücken Korridor (neu) laden. Deckt „Alles löschen" + Eviction ab; preview_track // (~40 Punkte) reicht, der ±1-km-Puffer schluckt die Vereinfachung. Automatischer Pfad → Cap. var _ensureActive = false; function ensureRouteCorridors(routes, opts) { opts = opts || {}; var maxKm = opts.maxKm || 50; if (_ensureActive || !navigator.onLine) return Promise.resolve(0); _ensureActive = true; var done = 0; return _overCap().then(function (over) { if (over) return 0; var cand = (routes || []).filter(function (r) { var t = r.preview_track || r.gps_track; if (!t || t.length < 2) return false; if (opts.lat == null) return true; return _distKm(t[0].lat, t[0].lon, opts.lat, opts.lon) <= maxKm; }); var chain = Promise.resolve(); cand.forEach(function (r) { chain = chain.then(function () { var t = r.preview_track || r.gps_track; var idxs = [0, Math.floor(t.length / 4), Math.floor(t.length / 2), Math.floor(3 * t.length / 4), t.length - 1]; var missing = false, v = Promise.resolve(); idxs.forEach(function (i) { v = v.then(function () { if (missing) return; var p = t[i]; return _get(MAXZOOM + '/' + _x(p.lon, MAXZOOM) + '/' + _y(p.lat, MAXZOOM)) .then(function (hit) { if (!hit) missing = true; }); }); }); return v.then(function () { if (!missing) return; return downloadCorridor(t, { bufferKm: 1, name: r.name || ('route-' + r.id) }) .then(function (res) { if (res.bytes > 0) done++; }) .catch(function () {}); }); }); }); return chain.then(function () { return done; }); }).then(function (n) { _ensureActive = false; return n; }, function () { _ensureActive = false; return 0; }); } // ---- 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 }; }); } // ---- Coverage-Layer ---------------------------------------------------------- // GeoJSON der gespeicherten z14-Kacheln — zeigt auf der Karte, welche Bereiche offline // da sind. properties.kind: 'funkloch' (Kachel liegt in einer Funkloch-Region, wird // anders eingefärbt — Wunsch René 2026-06-08) oder 'manuell'. function _tile2lat(y, z) { var m = Math.PI - 2 * Math.PI * y / Math.pow(2, z); return 180 / Math.PI * Math.atan(0.5 * (Math.exp(m) - Math.exp(-m))); } function coverage() { var re = new RegExp('^' + MAXZOOM + '/(\\d+)/(\\d+)$'); return _metaGet('regions').then(function (regions) { var fz = (regions || []).filter(function (r) { return r.type === 'funkloch'; }); return _req(STORE, 'readonly', function (os) { return os.getAllKeys(); }).then(function (keys) { var feats = []; (keys || []).forEach(function (k) { var m = re.exec(k); if (!m) return; var x = +m[1], y = +m[2], n2 = Math.pow(2, MAXZOOM); var west = x / n2 * 360 - 180, east = (x + 1) / n2 * 360 - 180; var north = _tile2lat(y, MAXZOOM), south = _tile2lat(y + 1, MAXZOOM); var cLat = (north + south) / 2, cLon = (west + east) / 2; var isFz = fz.some(function (r) { return _distKm(r.lat, r.lon, cLat, cLon) <= (r.radiusKm || 5) + 0.8; }); feats.push({ type: 'Feature', properties: { kind: isFz ? 'funkloch' : 'manuell' }, geometry: { type: 'Polygon', coordinates: [[[west, south], [east, south], [east, north], [west, north], [west, south]]] } }); }); return { type: 'FeatureCollection', features: feats }; }); }); } function stats() { return _count().then(function (count) { return _metaGet('regions').then(function (regions) { return _metaGet('totalBytes').then(function (totalBytes) { return _metaGet('deadzones').then(function (deadzones) { return _metaGet('region').then(function (meta) { return { count: count, meta: meta || null, regions: regions || [], totalBytes: totalBytes || 0, deadzones: deadzones || [] }; }); }); }); }); }); } function hasRegion() { return stats().then(function (s) { return s.count > 0; }).catch(function () { return false; }); } // Kachel-Keys eines Umkreises (alle Zooms) ins Keep-Set legen. function _keepRegionKeys(lat, lon, radiusKm, keep) { var bb = _bboxAround(lat, lon, radiusKm); for (var z = 10; z <= MAXZOOM; z++) { var x0 = _x(bb.west, z), x1 = _x(bb.east, z), y0 = _y(bb.north, z), y1 = _y(bb.south, z); for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) keep[z + '/' + x + '/' + y] = 1; } } // Korridor-Keys eines Tracks (±bufferKm, z10–14) ins Keep-Set legen. function _keepCorridorKeys(track, bufferKm, keep) { track.forEach(function (p) { var d = Math.ceil(bufferKm / _tileKm(MAXZOOM, p.lat)); var cx = _x(p.lon, MAXZOOM), cy = _y(p.lat, MAXZOOM); for (var x = cx - d; x <= cx + d; x++) for (var y = cy - d; y <= cy + d; y++) { keep[MAXZOOM + '/' + x + '/' + y] = 1; for (var pz = 13; pz >= 10; pz--) { keep[pz + '/' + (x >> (MAXZOOM - pz)) + '/' + (y >> (MAXZOOM - pz))] = 1; } } }); } // „Alles löschen" — SELEKTIV (René 2026-06-08, spart Vorladezeit): // BLEIBEN: Standort-Gebiete (Regionen type 'standort'), Korridore der übergebenen // Routen-Tracks (opts.keepTracks), der Umkreis von opts.center (5 km), Basis-Zooms 0–9 // (winzig, von allem gebraucht), Marker/Warnungen ('p/') + Glyphs ('f/'). // GEHEN: manuelle Gebiete/Ausschnitte + Funkloch-Kacheln (Zonen bleiben gemerkt, // filled:false → Start-Check lädt Nahe automatisch neu). // Ohne Keep-Kandidaten (alte Signatur/Tests): kompletter Wipe inkl. Basis-Zooms. function clear(opts) { opts = opts || {}; var keep = {}; return _metaGet('regions').then(function (regions) { regions = regions || []; var keptRegions = regions.filter(function (r) { return r.type === 'standort' || r.type === 'korridor'; }); regions.forEach(function (r) { if (r.type === 'standort' && r.radiusKm) _keepRegionKeys(r.lat, r.lon, r.radiusKm, keep); }); if (opts.center) _keepRegionKeys(opts.center.lat, opts.center.lon, 5, keep); (opts.keepTracks || []).forEach(function (t) { if (t && t.length >= 2) _keepCorridorKeys(t, 1, keep); }); var keepBase = Object.keys(keep).length > 0; if (!keepBase) keptRegions = []; // nichts zu behalten → echter Komplett-Wipe return _req(STORE, 'readonly', function (os) { return os.getAllKeys(); }).then(function (keys) { // Komplett-Wipe (nichts zu behalten): alles inkl. Marker/Glyphs (altes Verhalten). if (!keepBase) return _delMany((keys || []).slice()); var doomed = (keys || []).filter(function (k) { var m = /^(\d+)\//.exec(k); if (!m) return false; // 'p/' + 'f/' bleiben if (+m[1] <= 9) return false; // Basis-Zooms behalten return !keep[k]; }); return _delMany(doomed); }).then(function () { return _metaGet('deadzones'); }) .then(function (zones) { return _req(META, 'readwrite', function (os) { os.clear(); }).then(function () { var jobs = []; if (zones && zones.length) { zones.forEach(function (z) { z.filled = false; }); jobs.push(_metaPut('deadzones', zones)); } if (keptRegions.length) { jobs.push(_metaPut('regions', keptRegions)); jobs.push(_metaPut('region', keptRegions[keptRegions.length - 1])); jobs.push(_metaPut('totalBytes', keptRegions.reduce(function (a, r) { return a + (r.bytes || 0); }, 0))); } return Promise.all(jobs); }); }); }); } return { registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor, downloadBbox: downloadBbox, ensureRouteCorridors: ensureRouteCorridors, ensureHomeArea: ensureHomeArea, tile: tile, glyph: glyph, pois: pois, alerts: alerts, coverage: coverage, setGps: setGps, markDeadZone: markDeadZone, removeDeadZone: removeDeadZone, autoFillDeadZones: autoFillDeadZones, stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM, }; })();