Files
TREK/client/src/sync/glPrefetcher.ts
T
Maurice 2ab2dcc3dc fix(offline): the osm-de and stadia prefetch was CSP-blocked and the opening view stayed grey (#2180)
connect-src never listed tile.openstreetmap.de or tiles.stadiamaps.com, so every prefetch fetch was refused before the service worker saw it, while the swallowed error still counted as a cached tile. Both hosts are allowed now and only reached tiles count. The prefetch rectangle is also widened to the screen the fitBounds view fills, on the raster and the vector path alike; the places extent alone left grey bands beside a north-south trip.
2026-09-02 21:06:51 +02:00

222 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Pre-download of a vector basemap, for the trips the user marked for offline.
*
* The raster prefetcher next door walks a {z}/{x}/{y} template and is done. A
* vector style is four different things instead: the style document, the
* TileJSON it points at, the sprite sheet and the glyph ranges its labels are
* set in, and only then the .pbf tiles themselves.
*
* It is cheaper than what it replaces, not more expensive. Measured over the
* Berlin bounding box TREK would compute: raster z10-16 is 2237 requests and
* 34.6 MB and stops being useful above z16, while vector z10-14 is 177 requests
* and 32.1 MB and stays sharp at every zoom above that, because MapLibre scales
* the z14 tile rather than asking for another one.
*
* The style, sprite and glyphs are per device rather than per trip: about
* 980 KB once, not per journey.
*/
import { computeBbox, tileRange, type TileBbox } from './tilePrefetcher'
import type { Place } from '../types'
/** OpenFreeMap serves vector tiles up to z14 and overzooms from there. */
const VECTOR_MAX_ZOOM = 14
// 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
/**
* Hard cap on vector tiles per run. Each is far larger than a raster tile but
* there are far fewer of them; this is roughly the same byte budget.
*/
const MAX_VECTOR_TILES = 2000
/** Workbox runtime cache for the vector basemap (see vite.config.js). */
const VECTOR_CACHE = 'gl-map-offline'
/**
* Latin glyph ranges. A label needs the range its codepoints fall in, and Latin
* text lives in the first few; asking for all 256 ranges would download megabytes
* of CJK nobody on this map is going to render.
*/
const GLYPH_RANGES = ['0-255', '256-511', '512-767', '768-1023', '1024-1279', '1280-1535', '1536-1791', '8192-8447', '8448-8703']
interface StyleDoc {
sprite?: string
glyphs?: string
sources?: Record<string, { url?: string; tiles?: string[] }>
layers?: { layout?: { 'text-font'?: string[] } }[]
}
async function fetchJson<T>(url: string): Promise<T | null> {
try {
// cors, not no-cors: an opaque response cannot be read, and MapLibre would
// find a zero-byte body in the cache. OpenFreeMap sends access-control-allow-origin.
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return null
return (await res.json()) as T
} catch {
return null
}
}
/** Every font stack the style actually asks for, deduped. */
function fontStacks(style: StyleDoc): string[] {
const stacks = new Set<string>()
for (const layer of style.layers ?? []) {
const fonts = layer.layout?.['text-font']
if (fonts?.length) stacks.add(fonts.join(','))
}
return [...stacks]
}
/**
* Warm one URL into the cache, unless it is already there.
*
* Checking Cache Storage directly is much cheaper than letting the request reach
* the Service Worker's handler, so a repeated or resumed run over a warm cache
* costs almost nothing. Same reasoning as the raster side.
*/
async function warm(cache: Cache | null, url: string): Promise<boolean> {
try {
if (cache && (await cache.match(url))) return false
const res = await fetch(url, { mode: 'cors' })
if (!res.ok) return false
await cache?.put(url, res.clone())
return true
} catch {
return false
}
}
async function openVectorCache(): Promise<Cache | null> {
try {
return await caches.open(VECTOR_CACHE)
} catch {
return 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
}
}
}
return out
}
/**
* 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.
*/
export async function prefetchStyleAssets(styleUrl: string): Promise<string | null> {
const cache = await openVectorCache()
const style = await fetchJson<StyleDoc>(styleUrl)
if (!style) return null
await warm(cache, styleUrl)
if (style.sprite) {
// Both densities, both parts: a style asks for whichever matches the screen.
await Promise.all([
warm(cache, `${style.sprite}.json`),
warm(cache, `${style.sprite}.png`),
warm(cache, `${style.sprite}@2x.json`),
warm(cache, `${style.sprite}@2x.png`),
])
}
if (style.glyphs) {
const stacks = fontStacks(style)
await Promise.all(
stacks.flatMap(stack =>
GLYPH_RANGES.map(range =>
warm(cache, style.glyphs!.replace('{fontstack}', encodeURIComponent(stack)).replace('{range}', range)),
),
),
)
}
// 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
}
export interface VectorPrefetchResult {
tiles: number
template: string | null
}
/**
* Pre-download the vector basemap covering a trip's places.
*
* `isCancelled` is checked between tiles so going offline or logging out
* abandons the rest of the queue rather than finishing it in the background.
*/
export async function prefetchVectorForPlaces(
places: Place[],
styleUrl: string,
isCancelled: () => boolean = () => false,
): Promise<VectorPrefetchResult> {
if (!navigator.onLine) return { tiles: 0, template: null }
const bbox = computeBbox(places)
if (!bbox) return { tiles: 0, template: null }
const template = await prefetchStyleAssets(styleUrl)
if (!template || isCancelled()) return { tiles: 0, template }
const cache = await openVectorCache()
const coords = enumerateVectorTiles(bbox, VECTOR_MIN_ZOOM, VECTOR_MAX_ZOOM)
let cursor = 0
let fetched = 0
async function worker(): Promise<void> {
while (cursor < coords.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))
if (await warm(cache, url)) fetched++
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
return { tiles: fetched, template }
}
/** Drop the offline vector basemap, alongside the raster cache. */
export async function clearVectorCache(): Promise<void> {
try {
await caches.delete(VECTOR_CACHE)
} catch {
// Nothing to clear, or storage is unavailable; the caller cannot act on it.
}
}