// Offline tile cache — download OSM tiles to IndexedDB, serve locally // Exposes window.TileCache const TileCache = {}; const DB_NAME = "baldav_tiles"; const STORE = "tiles"; let _db = null; function openDB() { if (_db) return Promise.resolve(_db); return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => req.result.createObjectStore(STORE); req.onsuccess = () => { _db = req.result; resolve(_db); }; req.onerror = () => reject(req.error); }); } function putTile(key, blob) { return openDB().then(db => new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).put(blob, key); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); })); } function getTile(key) { return openDB().then(db => new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readonly"); const req = tx.objectStore(STORE).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error); })); } // Count cached tiles TileCache.count = async function() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readonly"); const req = tx.objectStore(STORE).count(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); }; // Clear all cached tiles TileCache.clear = async function() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, "readwrite"); tx.objectStore(STORE).clear(); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); }; // Calculate tile coordinates for a bounding box at a given zoom function getTileRange(bounds, zoom) { const n = Math.pow(2, zoom); const toTile = (lat, lon) => ({ x: Math.floor((lon + 180) / 360 * n), y: Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * n), }); const sw = toTile(bounds.getSouth(), bounds.getWest()); const ne = toTile(bounds.getNorth(), bounds.getEast()); return { xMin: Math.min(sw.x, ne.x), xMax: Math.max(sw.x, ne.x), yMin: Math.min(sw.y, ne.y), yMax: Math.max(sw.y, ne.y), }; } // Count tiles needed for area + zoom range TileCache.countTilesForArea = function(bounds, zoomMin, zoomMax) { let total = 0; for (let z = zoomMin; z <= zoomMax; z++) { const r = getTileRange(bounds, z); total += (r.xMax - r.xMin + 1) * (r.yMax - r.yMin + 1); } return total; }; // Download tiles for current map bounds // onProgress(downloaded, total) callback TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) { const tiles = []; for (let z = zoomMin; z <= zoomMax; z++) { const r = getTileRange(bounds, z); for (let x = r.xMin; x <= r.xMax; x++) { for (let y = r.yMin; y <= r.yMax; y++) { tiles.push({ z, x, y }); } } } let done = 0; const PARALLEL = 12; async function downloadOne(tile) { const key = `${tile.z}/${tile.x}/${tile.y}`; // Skip if already cached const existing = await getTile(key); if (existing) { done++; if (onProgress) onProgress(done, tiles.length); return; } const url = `https://a.tile.openstreetmap.org/${key}.png`; try { const resp = await fetch(url); if (resp.ok) { const blob = await resp.blob(); await putTile(key, blob); } } catch { /* skip failed tiles */ } done++; if (onProgress) onProgress(done, tiles.length); } // Process in parallel batches for (let i = 0; i < tiles.length; i += PARALLEL) { const batch = tiles.slice(i, i + PARALLEL); await Promise.all(batch.map(downloadOne)); } return done; }; // Custom Leaflet TileLayer that checks IndexedDB first TileCache.createLayer = function() { return L.TileLayer.extend({ options: { // Keep fewer off-screen tile rings and don't reload tiles mid-zoom: on a high-DPI // screen the compositor's raster-tile budget (cc/tiles "tile memory limits exceeded") // is filled by the map tiles themselves, so trimming retained tiles keeps content drawing. keepBuffer: 1, // default 2 updateWhenZooming: false, // defer tile loads until the zoom gesture settles }, createTile: function(coords, done) { const tile = document.createElement("img"); const key = `${coords.z}/${coords.x}/${coords.y}`; const url = `https://a.tile.openstreetmap.org/${key}.png`; const show = (blob) => { tile.src = URL.createObjectURL(blob); done(null, tile); }; // Fetch ONCE: cache and display from the same response. The old code loaded the // display from the URL and cached via a SEPARATE fetch — on a flaky mobile // link that second request often failed, so tiles showed but were never cached, // and re-zooming re-fetched them. One fetch → every shown tile is also cached. const fetchAndCache = () => { fetch(url).then(async resp => { if (!resp.ok) throw new Error("http " + resp.status); const blob = await resp.blob(); putTile(key, blob).catch(() => {}); show(blob); }).catch(() => { // fetch blocked/failed → last resort plain (no caching) tile.src = url; tile.onload = () => done(null, tile); tile.onerror = () => done(null, tile); }); }; getTile(key).then(blob => { if (blob) { show(blob); } else if (TileCache.offlineMode) { done(null, tile); } // offline + not cached → empty else { fetchAndCache(); } }).catch(() => { if (TileCache.offlineMode) { done(null, tile); return; } fetchAndCache(); }); return tile; } }); }; // Offline mode — when true, tiles only served from cache, never fetched from network TileCache.offlineMode = false; TileCache.setOfflineMode = function(val) { TileCache.offlineMode = !!val; localStorage.setItem("baldav_offline_maps", val ? "1" : "0"); }; TileCache.loadOfflineMode = function() { TileCache.offlineMode = localStorage.getItem("baldav_offline_maps") === "1"; }; TileCache.loadOfflineMode(); // Import tiles from a SQLite file (packed by tools/pack_tiles.js) into IndexedDB // path: filesystem path to the .db file // onProgress(done, total): callback TileCache.importFromSqlite = async function(path, onProgress) { const invoke = window.__TAURI__.core.invoke; const BATCH = 100; let offset = 0; let total = 0; let imported = 0; while (true) { const raw = await invoke("import_tiles", { path, offset, batchSize: BATCH }); const resp = JSON.parse(raw); total = resp.total; for (const [key, b64] of resp.tiles) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); await putTile(key, new Blob([bytes], { type: "image/png" })); imported++; } if (onProgress) onProgress(imported, total); if (!resp.has_more) break; offset += BATCH; } return imported; }; window.TileCache = TileCache; console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);