fix(offline): cache osm-de and stadia tiles, prefetch down to the opening zoom (#2180)

This commit is contained in:
Maurice
2026-09-01 17:45:08 +02:00
committed by Maurice
parent 1e6f1fe95c
commit 5decdcfa13
5 changed files with 78 additions and 6 deletions
+4 -1
View File
@@ -20,7 +20,10 @@ import type { Place } from '../types'
/** OpenFreeMap serves vector tiles up to z14 and overzooms from there. */
const VECTOR_MAX_ZOOM = 14
const VECTOR_MIN_ZOOM = 10
// Zoom 0, not 10: MapLibre can only overzoom upward, so a wide trip opening at
// its fitBounds zoom (z6–9) rendered nothing offline with a z10 floor (#2180).
// The low zooms cost a few dozen tiles at most.
const VECTOR_MIN_ZOOM = 0
/** Requests in flight, kept low for the same reason the raster side keeps it low. */
const CONCURRENCY = 6
+6 -3
View File
@@ -4,7 +4,10 @@
*
* Algorithm:
* 1. Compute bbox from trip's place coordinates + padding.
* 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox.
* 2. For zooms 0–16, enumerate tile XYZ coordinates within bbox. The low
* zooms cost next to nothing (a handful of tiles) but are what the trip
* map actually opens at for a multi-city bbox — a z10 floor left the
* fitBounds view empty offline (#2180).
* 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB).
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it,
* at most TILE_CONCURRENCY at a time.
@@ -228,7 +231,7 @@ async function openTileCache(): Promise<Cache | null> {
export async function prefetchTiles(
bbox: TileBbox,
tileUrlTemplate: string,
minZoom = 10,
minZoom = 0,
maxZoom = 16,
cartoKey?: string,
): Promise<number> {
@@ -362,7 +365,7 @@ export async function prefetchTilesForTrip(
// tile providers that don't send CORS headers. To stop the browser evicting
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template, 10, 16, cartoKey)
const fetched = await prefetchTiles(bbox, template, 0, 16, cartoKey)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
+4 -2
View File
@@ -139,7 +139,7 @@ describe('prefetchStyleAssets', () => {
describe('prefetchVectorForPlaces', () => {
const places = [buildPlace({ lat: 52.52, lng: 13.405 })];
it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z10 to z14', async () => {
it('FE-SYNC-GLPF-007: downloads the tiles covering the trip, z0 to z14', async () => {
const result = await prefetchVectorForPlaces(places, STYLE_URL);
expect(result.template).toBe(TILE_TEMPLATE);
expect(result.tiles).toBeGreaterThan(0);
@@ -151,7 +151,9 @@ describe('prefetchVectorForPlaces', () => {
);
// z14 is the last one OpenFreeMap serves; MapLibre scales it beyond that
// rather than asking for another, which is why this stops where raster could not.
expect([...zooms].sort((a, b) => a - b)).toEqual([10, 11, 12, 13, 14]);
// The floor is z0: MapLibre cannot overzoom downward, so the fitBounds view
// of a wide trip needs the low zooms cached too (#2180).
expect([...zooms].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]);
});
it('FE-SYNC-GLPF-008: does nothing offline', async () => {
@@ -519,3 +519,45 @@ describe('the GL style document is not served stale (#1924)', () => {
expect(config).toMatch(/cacheName:\s*'gl-map-styles'/);
});
});
describe('shipped raster presets reach the map-tiles cache (#2180)', () => {
const config = readFileSync(resolve(process.cwd(), 'vite.config.js'), 'utf8');
// First-match semantics, like the #1924 harness above, but capturing the
// cacheName too: a preset whose tiles land in some other cache is as offline-
// dead as one that matches nothing.
const rules = [...config.matchAll(/urlPattern:\s*(\/[^\n]*?\/i),\s*\n\s*handler:\s*'([A-Za-z]+)',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'([^']+)'/g)].map(m => ({
pattern: new RegExp(m[1].slice(1, m[1].lastIndexOf('/')), 'i'),
handler: m[2],
cacheName: m[3],
}));
const firstMatch = (url: string) => rules.find(r => r.pattern.test(url));
it('OpenStreetMap DE tiles are cached — the preset used to match no rule at all', () => {
const rule = firstMatch('https://tile.openstreetmap.de/13/4091/2722.png');
expect(rule?.handler).toBe('CacheFirst');
expect(rule?.cacheName).toBe('map-tiles');
});
it('Stadia tiles are cached the same way', () => {
const rule = firstMatch('https://tiles.stadiamaps.com/tiles/alidade_smooth/13/4091/2722.png');
expect(rule?.handler).toBe('CacheFirst');
expect(rule?.cacheName).toBe('map-tiles');
});
it('still refuses look-alike hosts', () => {
expect(firstMatch('https://tile.openstreetmap.de.example.com/13/4091/2722.png')).toBeUndefined();
});
});
describe('prefetch covers the opening zoom (#2180)', () => {
it('fetches the low zooms too, so a wide trip has tiles at its fitBounds view', async () => {
// A ~3° multi-city bbox opens around z7 — the old z10 floor left that view
// completely uncached offline.
const bbox: TileBbox = { minLat: 47.0, maxLat: 50.0, minLng: 6.0, maxLng: 9.0 };
await prefetchTiles(bbox, 'https://tile.example.com/{z}/{x}/{y}.png');
const urls = vi.mocked(fetch).mock.calls.map(c => String(c[0]));
expect(urls.some(u => u.includes('/0/'))).toBe(true);
expect(urls.some(u => u.includes('/7/'))).toBe(true);
});
});
+22
View File
@@ -92,6 +92,28 @@ export default defineConfig(({ mode }) => ({
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap DE — a shipped preset that matched no rule at all, so
// "Store map tiles offline" fetched thousands of tiles and stored none
// of them (#2180). Same cache, same limits as the rules above.
urlPattern: /^https:\/\/tile\.openstreetmap\.de\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Stadia Smooth — the other shipped raster preset with the same hole (#2180).
urlPattern: /^https:\/\/tiles\.stadiamaps\.com\/tiles\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// The GL style DOCUMENT, for both providers. It has to be matched
// before the tile rules below, because Workbox takes the first route