fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)

Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.
This commit is contained in:
jubnl
2026-06-15 07:53:12 +02:00
committed by GitHub
parent 4188f67ab7
commit 0a794583d7
3 changed files with 54 additions and 20 deletions
+17 -12
View File
@@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */ /** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
const AVG_TILE_KB = 15 const AVG_TILE_KB = 15
/** Hard cap: ~50 MB worth of tiles. */ /**
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413 * Hard cap on prefetched tiles (~180 MB).
*
* MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in
* client/vite.config.js (kept equal). If this budget exceeds the SW cache size,
* the LRU evicts freshly-prefetched tiles on arrival and the offline map goes
* blank — which is exactly the bug this value was raised (from ~3413) to fix.
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
const DEFAULT_TILE_URL = const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
@@ -177,15 +184,13 @@ export async function prefetchTilesForTrip(
const bbox = computeBbox(places) const bbox = computeBbox(places)
if (!bbox) return if (!bbox) return
// Size guard: if total tile count across all zooms exceeds cap, skip // Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
const estimated = countTiles(bbox, 10, 16) // once MAX_TILES is reached, so large (region / road-trip) bboxes still get
if (estimated > MAX_TILES) { // their lower zooms cached instead of being skipped entirely.
console.warn( //
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`, // NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each
) // for quota accounting, so the real on-disk budget is far below 180 MB. That
return // (audit H8) and navigator.storage.persist() (M6) are tracked separately.
}
const fetched = await prefetchTiles(bbox, template) const fetched = await prefetchTiles(bbox, template)
// Update syncMeta with bbox and tile count // Update syncMeta with bbox and tile count
+31 -6
View File
@@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => {
expect(meta!.tilesBbox).toHaveLength(4); expect(meta!.tilesBbox).toHaveLength(4);
}); });
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => { it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 }); await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
// Places far apart → huge bbox → estimate > MAX_TILES // ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
// it. The old guard skipped the whole trip; now we keep what fits.
const places = [ const places = [
buildPlace({ trip_id: 1, lat: -60, lng: -170 }), buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
buildPlace({ trip_id: 1, lat: 60, lng: 170 }), buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
]; ];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png'); await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
// No fetches should have been made // Previously this skipped entirely; now it prefetches a clamped subset.
expect(vi.mocked(fetch)).not.toHaveBeenCalled(); const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
const places = [
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
});
// ── cap coherence ───────────────────────────────────────────────────────────────
describe('MAX_TILES budget', () => {
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
expect(MAX_TILES).toBe(12288);
}); });
}); });
+6 -2
View File
@@ -15,21 +15,25 @@ export default defineConfig({
runtimeCaching: [ runtimeCaching: [
{ {
// Carto map tiles (default provider) // Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
// (both are 12288) so prefetched tiles aren't evicted on arrival.
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
cacheName: 'map-tiles', cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] }, cacheableResponse: { statuses: [0, 200] },
}, },
}, },
{ {
// OpenStreetMap tiles (fallback / alternative) // OpenStreetMap tiles (fallback / alternative)
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
cacheName: 'map-tiles', cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 }, expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] }, cacheableResponse: { statuses: [0, 200] },
}, },
}, },