// 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; }; // ---- Online tile healer --------------------------------------------------- // The on-demand loader (createTile) never retries a tile whose fetch failed: // Leaflet marks it loaded-with-error and won't re-request until the tile is // re-created (pan out+back / zoom / redraw). So blanks from a bad moment stay // blank even after the network recovers. The healer fixes that: periodically, // when OSM is reachable, it re-fetches MISSING tiles (current zoom only) for the // visible area and — during recording / track review — along the ridden route, // caches them, and redraws so the holes fill. Respects offline mode. Gentle to // OSM (one-tile probe, throttled fetches, capped per cycle) per their tile usage // policy — we never bulk-download. let _healTimer = null, _healMap = null, _healLayer = null, _healPointsFn = null; let _healing = false, _healCursor = 0, _onlineHandler = null; const HEAL_INTERVAL_MS = 30000, HEAL_THROTTLE_MS = 300; const HEAL_MAX_FETCH = 40; // tiles fetched per cycle (≈1.3/s — modest) const HEAL_MAX_SCAN = 500; // IndexedDB lookups per cycle (bounds CPU on long routes) function _sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // One small tile tells us OSM is reachable (navigator.onLine gates first — cheap). TileCache.probeOsm = async function() { if (!navigator.onLine) return false; try { const c = new AbortController(); const t = setTimeout(() => c.abort(), 6000); const r = await fetch("https://a.tile.openstreetmap.org/0/0/0.png", { cache: "no-store", signal: c.signal }); clearTimeout(t); return r.ok; } catch { return false; } }; function _lonLatToTile(lat, lon, z) { const n = Math.pow(2, z); return { 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), }; } // Tiles covering a polyline at zoom z (+1 ring around each point so the road's // tiles, not just the exact centre, are covered), de-duplicated. function _tileKeysForLatLngs(latlngs, z) { const seen = new Set(); for (const ll of latlngs) { const lat = ll[0], lon = ll[1]; if (lat == null || lon == null) continue; const t = _lonLatToTile(lat, lon, z); for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) seen.add(`${z}/${t.x + dx}/${t.y + dy}`); } return [...seen]; } function _tileKeysForBounds(bounds, z) { const r = getTileRange(bounds, z); const keys = []; for (let x = r.xMin; x <= r.xMax; x++) for (let y = r.yMin; y <= r.yMax; y++) keys.push(`${z}/${x}/${y}`); return keys; } async function _healCycle() { if (_healing || TileCache.offlineMode || !_healMap) return; _healing = true; try { if (!(await TileCache.probeOsm())) return; const z = _healMap.getZoom(); // Visible tiles first (fills what you actually see), then the ridden route // from a rotating cursor so a long track trickles across cycles, not in a burst. let keys = _tileKeysForBounds(_healMap.getBounds(), z); const pts = _healPointsFn && _healPointsFn(); if (pts && pts.length) { const routeKeys = _tileKeysForLatLngs(pts, z); if (_healCursor >= routeKeys.length) _healCursor = 0; keys = keys.concat(routeKeys.slice(_healCursor), routeKeys.slice(0, _healCursor)); } let fetched = 0, scanned = 0, advanced = 0; for (const key of keys) { if (fetched >= HEAL_MAX_FETCH || scanned >= HEAL_MAX_SCAN) break; scanned++; advanced++; if (await getTile(key)) continue; // already cached try { const r = await fetch(`https://a.tile.openstreetmap.org/${key}.png`, { cache: "no-store" }); if (r.ok) { await putTile(key, await r.blob()); fetched++; } } catch { /* leave for a later cycle */ } await _sleep(HEAL_THROTTLE_MS); } if (pts && pts.length) _healCursor += advanced; // continue the route next cycle if (fetched && _healLayer && _healLayer.redraw) _healLayer.redraw(); } finally { _healing = false; } } // Start healing for a map. layer = the tile layer to redraw once holes fill. // pointsFn() → [[lat,lon],…] of the ridden route (recording / track review), or // null to heal only the visible area (e.g. the roads screen). TileCache.startHealer = function(map, layer, pointsFn) { TileCache.stopHealer(); _healMap = map; _healLayer = layer; _healPointsFn = pointsFn || null; _healCursor = 0; _healTimer = setInterval(_healCycle, HEAL_INTERVAL_MS); setTimeout(_healCycle, 3000); // first pass soon after the map opens _onlineHandler = () => _healCycle(); // and immediately when the link returns window.addEventListener("online", _onlineHandler); if (map && map.on) map.on("unload", TileCache.stopHealer); }; TileCache.stopHealer = function() { if (_healTimer) { clearInterval(_healTimer); _healTimer = null; } if (_onlineHandler) { window.removeEventListener("online", _onlineHandler); _onlineHandler = null; } _healMap = _healLayer = _healPointsFn = null; _healCursor = 0; }; window.TileCache = TileCache; console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);