// 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 = 4; 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://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({ createTile: function(coords, done) { const tile = document.createElement("img"); const key = `${coords.z}/${coords.x}/${coords.y}`; getTile(key).then(blob => { if (blob) { tile.src = URL.createObjectURL(blob); done(null, tile); } else { // Fallback to network tile.crossOrigin = ""; tile.src = `https://tile.openstreetmap.org/${key}.png`; // Cache after download tile.onload = () => { fetch(tile.src).then(r => r.blob()).then(b => putTile(key, b)).catch(() => {}); done(null, tile); }; tile.onerror = () => done(new Error("tile failed"), tile); } }).catch(() => { tile.src = `https://tile.openstreetmap.org/${key}.png`; tile.onload = () => done(null, tile); tile.onerror = () => done(new Error("tile failed"), tile); }); return tile; } }); }; window.TileCache = TileCache; console.log("[tilecache.js] window.TileCache ready");