From 8e64933ec9c82ba508e682c0a2e7512a64211dfe Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 24 Oct 2026 08:45:29 +0200 Subject: [PATCH] fix(offline): the vector prefetch takes the vector source and each source's own zooms; driving settings sync only with the add-on on --- client/src/sync/glPrefetcher.ts | 113 +++++++++++++------- client/src/sync/tripSyncManager.ts | 6 +- client/tests/unit/sync/glPrefetcher.test.ts | 40 +++++++ 3 files changed, 121 insertions(+), 38 deletions(-) diff --git a/client/src/sync/glPrefetcher.ts b/client/src/sync/glPrefetcher.ts index c3e574b99..2060c90ca 100644 --- a/client/src/sync/glPrefetcher.ts +++ b/client/src/sync/glPrefetcher.ts @@ -44,10 +44,26 @@ const VECTOR_CACHE = 'gl-map-offline' */ const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703'] +interface StyleSource { + type?: string + url?: string + tiles?: string[] + minzoom?: number + maxzoom?: number +} + +/** One tiled source of the style, with the zooms it actually serves. */ +interface TileSource { + template: string + minZoom: number + maxZoom: number + vector: boolean +} + interface StyleDoc { sprite?: string glyphs?: string - sources?: Record + sources?: Record layers?: { layout?: { 'text-font'?: string[] } }[] } @@ -100,28 +116,67 @@ async function openVectorCache(): Promise { } } -/** Tile coordinates covering the box, low zoom first so a truncated run is still useful. */ -function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] { - const out: [number, number, number][] = [] - for (let z = minZoom; z <= maxZoom; z++) { - // Same screen-widened rectangle as the raster side: the opening fitBounds - // view shows more than the places extent on the non-limiting axis (#2180). - const { minX, maxX, minY, maxY } = tileRange(bbox, z) - for (let x = minX; x <= maxX; x++) { - for (let y = minY; y <= maxY; y++) { - out.push([z, x, y]) - if (out.length >= MAX_VECTOR_TILES) return out +/** + * Tile URLs covering the box, vector sources first and low zoom first within + * each, so a truncated run is still useful. A source is only asked for the + * zooms it serves: OpenFreeMap's shaded relief stops at z6 and answers 404 + * above it. + */ +function enumerateTileUrls(bbox: TileBbox, sources: TileSource[]): string[] { + const out: string[] = [] + for (const source of sources) { + for (let z = Math.max(source.minZoom, VECTOR_MIN_ZOOM); z <= Math.min(source.maxZoom, VECTOR_MAX_ZOOM); z++) { + // Same screen-widened rectangle as the raster side: the opening fitBounds + // view shows more than the places extent on the non-limiting axis (#2180). + const { minX, maxX, minY, maxY } = tileRange(bbox, z) + for (let x = minX; x <= maxX; x++) { + for (let y = minY; y <= maxY; y++) { + out.push(source.template.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y))) + if (out.length >= MAX_VECTOR_TILES) return out + } } } } return out } +/** + * The style's tiled sources, vector first. A source given as a TileJSON + * reference gets its template and zooms from there: the tile path carries a + * planet version, and asking without that segment answers 200 with an empty + * body, so it cannot be guessed. A source without tiles (GeoJSON) is skipped. + */ +async function readTileSources(style: StyleDoc, cache: Cache | null): Promise { + const out: TileSource[] = [] + for (const source of Object.values(style.sources ?? {})) { + let { tiles, minzoom, maxzoom } = source + if (!tiles?.[0] && source.url) { + await warm(cache, source.url) + const tileJson = await fetchJson<{ tiles?: string[]; minzoom?: number; maxzoom?: number }>(source.url) + tiles = tileJson?.tiles + minzoom ??= tileJson?.minzoom + maxzoom ??= tileJson?.maxzoom + } + if (!tiles?.[0]) continue + out.push({ + template: tiles[0], + minZoom: minzoom ?? VECTOR_MIN_ZOOM, + maxZoom: maxzoom ?? VECTOR_MAX_ZOOM, + vector: source.type !== 'raster' && source.type !== 'raster-dem', + }) + } + return out.sort((a, b) => Number(b.vector) - Number(a.vector)) +} + /** * The device-wide half: the style, its TileJSON, the sprite and the glyphs. - * Returns the tile URL template, or null when the style could not be read. + * Returns the vector tile URL template, or null when the style could not be read. */ export async function prefetchStyleAssets(styleUrl: string): Promise { + return (await prefetchStyle(styleUrl))?.[0]?.template ?? null +} + +async function prefetchStyle(styleUrl: string): Promise { const cache = await openVectorCache() const style = await fetchJson(styleUrl) if (!style) return null @@ -148,19 +203,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise(source.url) - if (tileJson?.tiles?.[0]) return tileJson.tiles[0] - } - } - return null + return readTileSources(style, cache) } export interface VectorPrefetchResult { @@ -184,25 +227,21 @@ export async function prefetchVectorForPlaces( const bbox = computeBbox(places) if (!bbox) return { tiles: 0, template: null } - const template = await prefetchStyleAssets(styleUrl) - if (!template || isCancelled()) return { tiles: 0, template } + const sources = await prefetchStyle(styleUrl) + const template = sources?.[0]?.template ?? null + if (!sources?.length || isCancelled()) return { tiles: 0, template } const cache = await openVectorCache() - const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM) + const urls = enumerateTileUrls(bbox, sources) let cursor = 0 let fetched = 0 async function worker(): Promise { - while (cursor < coords.length) { + while (cursor < urls.length) { if (isCancelled() || !navigator.onLine) return - const next = coords[cursor++] - if (!next) return - const [z, x, y] = next - const url = template! - .replace('{z}', String(z)) - .replace('{x}', String(x)) - .replace('{y}', String(y)) + const url = urls[cursor++] + if (!url) return if (await warm(cache, url)) fetched++ } } diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 29e14054d..a9abc1ae1 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise { // first sync is in. Ahead of the writes and rethrowing anything but a 404, it // threw the whole downloaded bundle away for that trip while the run still // reported it stored, so Settings said "N trips ready" over an empty database. - await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional addon, optional cache */ }) + // Asked only while the addon is on, like the tours below: with it off, every + // trip of a dashboard load spent a request on a 404. + if (useAddonStore.getState().isEnabled('roadtrip')) { + await roadtripPreferencesRepo.read(tripId).catch(() => { /* optional cache */ }) + } // Tours, the same way: optional, and never fatal. Without them every Tour // reads as a plain place offline. Asked only while the addon is on, so a diff --git a/client/tests/unit/sync/glPrefetcher.test.ts b/client/tests/unit/sync/glPrefetcher.test.ts index 61d87e0b8..916823e83 100644 --- a/client/tests/unit/sync/glPrefetcher.test.ts +++ b/client/tests/unit/sync/glPrefetcher.test.ts @@ -201,6 +201,46 @@ describe('prefetchVectorForPlaces', () => { }); }); +// OpenFreeMap's liberty style lists its shaded relief (raster, up to z6) before the +// vector planet. Taking the first source used to prefetch that relief at z0 to z14, +// hundreds of 404s, and never the vector tiles the map needs offline. +describe('a style with a raster source ahead of the vector one', () => { + const places = [buildPlace({ lat: 52.52, lng: 13.405 })]; + const RELIEF = 'https://tiles.openfreemap.org/natural_earth/ne2sr/{z}/{x}/{y}.png'; + const LIBERTY = { + sources: { + ne2_shaded: { type: 'raster', tiles: [RELIEF], maxzoom: 6 }, + openmaptiles: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' }, + }, + }; + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn((url: string, init?: { mode?: string }) => { + requested.push({ url, mode: init?.mode }); + if (url === STYLE_URL) return Promise.resolve({ ok: true, json: () => Promise.resolve(LIBERTY), clone: () => ({}) }); + return Promise.resolve(respond(url)); + }), + ); + }); + + it('FE-SYNC-GLPF-015: the template is the vector source, not the first one listed', async () => { + await expect(prefetchStyleAssets(STYLE_URL)).resolves.toBe(TILE_TEMPLATE); + }); + + it('FE-SYNC-GLPF-016: downloads the vector tiles and the relief only at the zooms it serves', async () => { + const result = await prefetchVectorForPlaces(places, STYLE_URL); + expect(result.template).toBe(TILE_TEMPLATE); + const urls = requested.map(r => r.url); + const vectorZooms = new Set(urls.filter(u => u.includes('/planet/')).map(u => Number(u.split('/planet/')[1]!.split('/')[1]))); + expect(Math.max(...vectorZooms)).toBe(14); + const reliefZooms = urls.filter(u => u.includes('/ne2sr/')).map(u => Number(u.split('/ne2sr/')[1]!.split('/')[0])); + expect(reliefZooms.length).toBeGreaterThan(0); + expect(Math.max(...reliefZooms)).toBe(6); + }); +}); + describe('clearVectorCache', () => { it('FE-SYNC-GLPF-012: drops the offline vector basemap', async () => { await clearVectorCache();