/* ============================================================ 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(); }); }; // ---- 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 () { 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 _tileList(lat, lon, radiusKm) { var dLat = radiusKm / 111, dLon = radiusKm / (111 * Math.cos(lat * Math.PI / 180)); var w = lon - dLon, e = lon + dLon, s = lat - dLat, n = lat + dLat, list = []; for (var z = 0; z <= MAXZOOM; z++) { var x0 = _x(w, z), x1 = _x(e, z), y0 = _y(n, z), y1 = _y(s, z); for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) list.push([z, x, y]); } return list; } // 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; }); }); } // Bereich um lat/lon (radiusKm, Default 5) herunterladen + in IndexedDB ablegen. // onProgress({done,total,bytes}). Liefert {tiles,bytes}. function downloadAround(lat, lon, radiusKm, onProgress) { radiusKm = radiusKm || 5; var list = _tileList(lat, lon, radiusKm), total = list.length, done = 0, bytes = 0, stored = 0, i = 0, CONC = 6; function next() { if (i >= total) 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); bytes += u.byteLength; stored++; return _put(key, u); } }).catch(function () {}).then(function () { done++; if (onProgress && (done % 8 === 0 || done === total)) onProgress({ done: done, total: total, bytes: bytes }); return next(); }); } var w = []; for (var k = 0; k < CONC; k++) w.push(next()); return Promise.all(w) .then(function () { return _cacheGlyphs(); }) // Glyphs mitcachen (sonst offline kein Render) .then(function (gb) { bytes += gb; return _req(META, 'readwrite', function (os) { os.put({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: stored, bytes: bytes, savedAt: Date.now() }, 'region'); }); }) .then(function () { return { tiles: stored, bytes: bytes }; }); } function stats() { return _count().then(function (count) { return _req(META, 'readonly', function (os) { return os.get('region'); }) .then(function (meta) { return { count: count, meta: meta || null }; }); }); } 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, tile: tile, glyph: glyph, stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM, }; })();