banyaro/backend/static/js/map-offline.js
rene 763108fa7c Offline-Karten Runde 3: Puls-Icon, rollendes Vorausladen, Ausschnitt-Download, Speicher-Cap
- 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
2026-06-06 12:34:48 +02:00

569 lines
27 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 _count = function () { return _req(STORE, 'readonly', function (os) { return os.count(); }); };
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. Liefert die Anzahl frischer Einträge.
function _mergeStore(key, fresh) {
if (!fresh || !fresh.length) return Promise.resolve(0);
return _get(key).then(function (old) {
var merged = fresh;
if (old && old.length) {
var seen = {};
fresh.forEach(function (p) { seen[p.id] = true; });
merged = fresh.concat(old.filter(function (p) { return !seen[p.id]; }));
}
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); })
.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.
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 * 1000))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (fresh) { return _mergeStore('p/_poison', fresh); })
.then(function (n) { total += n; })
.catch(function () {}));
jobs.push(fetch('/api/lost?lat=' + midLat + '&lon=' + midLon + '&radius_km=' + Math.ceil(radiusKm))
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (fresh) { return _mergeStore('p/_lost', fresh); })
.then(function (n) { total += n; })
.catch(function () {}));
return Promise.all(jobs).then(function () { return total; });
}
// 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).
function _addRegion(region) {
return _metaGet('regions').then(function (list) {
list = list || [];
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({ 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', (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 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 () { 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;
var now = Date.now();
if (now - _lastZoneNote < 120000) return; // max. 1 Eintrag / 2 Min
_lastZoneNote = now;
markDeadZone(_gps.lat, _gps.lon);
}
// 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 () {});
}
// Offene Funkloch-Zonen budget-getrieben nachladen (nur online sinnvoll).
// Dort wo Netz verfügbar ist, braucht man keine Offline-Karten — gespeichert
// wird gezielt da, wo es ausfiel. Liefert die Anzahl gefüllter Zonen.
var _autofillActive = false;
function autoFillDeadZones() {
if (_autofillActive || !navigator.onLine) return Promise.resolve(0);
_autofillActive = true;
var filled = 0;
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;
var chain = Promise.resolve();
open.forEach(function (z) {
chain = chain.then(function () {
return downloadAround(z.lat, z.lon, { budgetMB: 5 }).then(function (res) {
if (res.bytes > 0) { z.filled = true; filled++; }
}).catch(function () {});
});
});
return chain.then(function () { return _metaPut('deadzones', zones); })
.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); });
}
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 }; });
}
// ---- 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.
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 _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);
feats.push({ type: 'Feature', properties: {}, 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('region').then(function (meta) {
return { count: count, meta: meta || null, regions: regions || [], totalBytes: totalBytes || 0 };
});
});
});
});
}
function hasRegion() { return stats().then(function (s) { return s.count > 0; }).catch(function () { return false; }); }
function clear() {
return _req(STORE, 'readwrite', function (os) { os.clear(); })
.then(function () { return _req(META, 'readwrite', function (os) { os.clear(); }); });
}
return {
registerProtocol: registerProtocol, downloadAround: downloadAround, downloadCorridor: downloadCorridor,
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,
};
})();