fix(offline): the vector prefetch takes the vector source and each source's own zooms; driving settings sync only with the add-on on

This commit is contained in:
Maurice
2026-10-24 08:45:29 +02:00
parent 890473ebd5
commit 5e36e38b72
3 changed files with 121 additions and 38 deletions
+76 -37
View File
@@ -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<string, { url?: string; tiles?: string[] }>
sources?: Record<string, StyleSource>
layers?: { layout?: { 'text-font'?: string[] } }[]
}
@@ -100,28 +116,67 @@ async function openVectorCache(): Promise<Cache | null> {
}
}
/** 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<TileSource[]> {
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<string | null> {
return (await prefetchStyle(styleUrl))?.[0]?.template ?? null
}
async function prefetchStyle(styleUrl: string): Promise<TileSource[] | null> {
const cache = await openVectorCache()
const style = await fetchJson<StyleDoc>(styleUrl)
if (!style) return null
@@ -148,19 +203,7 @@ export async function prefetchStyleAssets(styleUrl: string): Promise<string | nu
)
}
// The vector source is a TileJSON reference, and the tile path it hands back
// carries a planet version. Asking without that segment answers 200 with an
// empty body, so the template has to come from the TileJSON rather than be
// guessed.
for (const source of Object.values(style.sources ?? {})) {
if (source.tiles?.[0]) return source.tiles[0]
if (source.url) {
await warm(cache, source.url)
const tileJson = await fetchJson<{ tiles?: string[] }>(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<void> {
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++
}
}
+5 -1
View File
@@ -149,7 +149,11 @@ async function syncTrip(tripId: number): Promise<void> {
// 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
@@ -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();