baldav/frontend/js/tilecache.js
4aiman 06cc77fdec Maps: trim tile retention, simplify big tracks by zoom, thin POIs when zoomed out
Canvas (prev commit) made the big-track map self-recover but the Chromium
compositor still hit 'tile memory limits exceeded' ~46x and 10k segments
still janked. Three cheap levers:
- tilecache: keepBuffer 2->1 and updateWhenZooming:false - fewer retained
  off-screen tiles in the compositor's raster budget on a high-DPI screen.
- track/roads polylines: smoothFactor 2.5 (Douglas-Peucker in pixels) - a
  10k-point track draws far fewer segments zoomed out, full detail zoomed in.
- POI: cap markers by zoom (z>=16:500, 15:200, 14:80, 13:30) instead of a
  flat 500 - a handful near min-zoom, everything only when zoomed in close.
2026-09-29 15:49:02 +03:00

220 lines
7.2 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;
};
window.TileCache = TileCache;
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);