banyaro/backend/static/js/map-offline.js
rene 2cdb743ce7 Selektives Loeschen: auch Funkloch-Gebiete bleiben + Keep-Set haertung
Rene: Funkloecher + Routen waren nach 'Alles loeschen' weiter weg.
- Funkloch-Regionen jetzt im Keep-Set (geloescht wird NUR Manuelles);
  Zonen behalten ihren Fuellstatus (Komplett-Wipe setzt weiter zurueck)
- Korridor-Migration beim Loeschen: keepTracks=[{name,track}] schreibt
  Tracks in Alt-Eintraege ohne r.track (Bestand vor v1236) bzw. legt
  fehlende Korridor-Regionen an — kein Warten auf Self-Healing
- clear() liefert Summary; Toast zeigt 'behalten: Standort, X Routen,
  Y Funkloch-Gebiete' — Diagnose-Sichtbarkeit fuer Geraetetests
Bump v1237
2026-06-06 13:55:37 +02:00

894 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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/<type>' 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 z1013), 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 09 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 09 ü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 z1013.
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 z1013) — 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 z1013 + Basis 09) + 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); });
}
// Vereinfachten Track (≤60 Punkte) in der Region-Meta ablegen: damit kann clear()
// die Korridor-Keep-Keys SELBST aus IndexedDB bauen — ohne API/Login/GPS
// (Gerätetest René 2026-06-08: leeres Keep-Set = Komplett-Wipe trotz Routen).
var step = Math.max(1, Math.ceil(track.length / 60));
var slim = track.filter(function (p, i) { return i % step === 0; });
if (slim[slim.length - 1] !== track[track.length - 1]) slim.push(track[track.length - 1]);
slim = slim.map(function (p) { return { lat: p.lat, lon: p.lon }; });
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,
track: slim, 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, z1014) 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, FUNKLOCH-Gebiete (automatisch gelernt — wären sonst sofort
// wieder vorzuladen), Korridore der Routen (aus Region-Meta r.track ODER opts.keepTracks),
// der Umkreis von opts.center (5 km), Basis-Zooms 09, Marker/Warnungen ('p/') + Glyphs ('f/').
// GEHEN: nur manuelle Gebiete ('gebiet') + Ausschnitte ('ausschnitt').
// opts.keepTracks: [{name, track}] oder [track] — Tracks werden in die Korridor-Meta
// MIGRIERT (Bestandsdaten vor v1236 hatten keinen track im Eintrag).
// Ohne Keep-Kandidaten (alte Signatur/Tests): kompletter Wipe inkl. Basis-Zooms.
// Liefert {standort, funkloch, korridore} (Anzahl behaltener Gebiete) für den Toast.
function clear(opts) {
opts = opts || {};
var keep = {}, summary = { standort: 0, funkloch: 0, korridore: 0 };
return _metaGet('regions').then(function (regions) {
regions = regions || [];
var keptRegions = regions.filter(function (r) {
return r.type === 'standort' || r.type === 'korridor' || r.type === 'funkloch';
});
// Tracks normalisieren: [{name, track}] oder [track]
var tracks = (opts.keepTracks || []).map(function (t) {
return Array.isArray(t) ? { name: null, track: t } : (t || {});
}).filter(function (o) { return o.track && o.track.length >= 2; });
keptRegions.forEach(function (r) {
if ((r.type === 'standort' || r.type === 'funkloch') && r.radiusKm) {
_keepRegionKeys(r.lat, r.lon, r.radiusKm, keep);
summary[r.type]++;
}
// Korridor-Keep direkt aus der Region-Meta (r.track) — unabhängig von API/Login.
if (r.type === 'korridor') {
if (!(r.track && r.track.length >= 2)) {
// Migration: Track aus keepTracks in den Alt-Eintrag übernehmen (Match per Name,
// sonst erster track-loser Kandidat).
var m = tracks.find(function (o) { return o.name && o.name === r.name; }) || tracks[0];
if (m) { r.track = m.track; }
}
if (r.track && r.track.length >= 2) { _keepCorridorKeys(r.track, 1, keep); summary.korridore++; }
}
});
// keepTracks ohne passenden Meta-Eintrag → trotzdem behalten + Eintrag anlegen.
tracks.forEach(function (o) {
var known = keptRegions.some(function (r) {
return r.type === 'korridor' && r.track && r.track.length &&
_distKm(r.track[0].lat, r.track[0].lon, o.track[0].lat, o.track[0].lon) < 0.3;
});
if (known) return;
_keepCorridorKeys(o.track, 1, keep);
summary.korridore++;
keptRegions.push({ type: 'korridor', name: o.name || null, lat: o.track[0].lat, lon: o.track[0].lon,
track: o.track, tiles: 0, bytes: 0, pois: 0, savedAt: Date.now() });
});
if (opts.center) {
_keepRegionKeys(opts.center.lat, opts.center.lon, 5, keep);
// Standort-ADOPTION: Bestandsdaten haben evtl. keine standort-Region (Gebiet wurde
// vor Runde 6 geladen) — Eintrag synthetisch anlegen, damit Folge-Läufe ihn kennen.
if (!keptRegions.some(function (r) { return r.type === 'standort'; })) {
keptRegions.push({ type: 'standort', lat: opts.center.lat, lon: opts.center.lon,
radiusKm: 5, tiles: 0, bytes: 0, pois: 0, savedAt: Date.now() });
}
summary.standort = Math.max(summary.standort, 1);
}
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) {
// Selektiv: Funkloch-Kacheln bleiben → Füllstatus behalten (Start-Check
// verifiziert ohnehin). Komplett-Wipe: alles ungefüllt → Neu-Laden.
if (!keepBase) 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);
});
}).then(function () { return summary; });
});
}
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,
};
})();