The on-demand loader never retries a tile whose fetch failed, so holes from a bad signal stayed blank even after the network recovered. Add a healer: every 30s, when OSM is reachable, re-fetch MISSING tiles (current zoom) for the visible area and along the ridden route, cache them, and redraw. Gentle to OSM (one-tile probe, 300ms throttle, <=40 fetches/cycle); respects offline mode. Wired on record/detail/compare/roads maps, stopped on screen change.
328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
// 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 <img> 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 <img> (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);
|