Tiles: heal blank tiles online (periodic OSM-reachability check + backfill)

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.
This commit is contained in:
4aiman 2026-10-19 20:07:05 +03:00
commit 3f092cced0
5 changed files with 148 additions and 6 deletions

View file

@ -250,6 +250,7 @@ function renderScreen(name, fromPopstate) {
// Release screen wake lock and compass listener when leaving recording screen
if (window._screenLock) { try { window._screenLock.release(); } catch {} window._screenLock = null; }
if (GPS.recording._compassCleanup) { GPS.recording._compassCleanup(); GPS.recording._compassCleanup = null; }
try { TileCache.stopHealer(); } catch {} // leaving a map screen stops online tile-healing; map screens restart it
let prevScreen = state.screen;
state.screen = name;
@ -4632,7 +4633,9 @@ async function renderRecordingInline(container) {
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along the ridden route whenever OSM is reachable.
TileCache.startHealer(map, tileLayer, () => (GPS.recording.points || []).map(p => [p.lat, p.lon]));
// Force recalc after flex layout settles — fixes black screen on init
setTimeout(() => map.invalidateSize(), 200);
@ -5620,7 +5623,9 @@ async function showSessionDetail(sessionId) {
}, { passive: false });
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
// Heal blank tiles along this track's route when OSM is reachable.
TileCache.startHealer(map, tileLayer, () => points.map(p => [p.lat, p.lon]));
addDarkMapControl(map); // dark-tiles toggle on the track view
POI.attach(map); // POI overlay + category filter
{ let nl = null; let rn = async () => { nl = await drawNogoAreas(map, nl, rn); }; rn(); } // impassable-spot circles
@ -6669,7 +6674,8 @@ async function showComparisonView(currentId, compareId, currentSession, currentP
mapEl._leafletMap = map;
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
// Build speed lookup for older track
let olderCoords = olderPoints.map(p => [p.lat, p.lon]);
@ -8674,7 +8680,8 @@ async function showRoadsMap() {
let map = L.map(mapEl, { preferCanvas: true, attributionControl: false }).setView([47.42, 40.08], 13); // canvas: many road polylines vs SVG compositor tile-memory limit
addZoomDisplay(map);
let CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
let tileLayer = new CachedLayer({ maxZoom: 19 }).addTo(map);
TileCache.startHealer(map, tileLayer, null); // heal visible tiles when OSM is reachable
addDarkMapControl(map); // dark-tiles toggle on the roads map
POI.attach(map); // POI overlay + category filter

View file

@ -216,5 +216,113 @@ TileCache.importFromSqlite = async function(path, onProgress) {
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);