Files
TREK/client/src/sync/tilePrefetcher.ts
T

557 lines
22 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.
/**
* Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's
* bounding box so maps render offline.
*
* Algorithm:
* 1. Compute bbox from trip's place coordinates + padding.
* 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). The rectangle is widened to the
* screen that view fills, or the map opens with grey either side of the
* places.
* 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.
*
* The throttle is the important part: a wide trip enumerates thousands of
* tiles, and dispatching them all at once buries every request the app itself
* makes behind them in the Service Worker's fetch queue — the UI then sits on
* a blank screen until the burst drains.
*
* Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional
* {s} (subdomain) and {r} (retina suffix).
*/
import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
import { isStoragePersisted } from './persistentStorage'
import { isOfflineForbiddenTemplate, isVectorStyle, normalizeTileUrl, resolveTileUrl, withTileApiKey } from '../utils/tileUrl'
import { OFM_DARK, OFM_POSITRON } from '../constants/mapDefaults'
import { clearVectorCache, prefetchStyleAssets, prefetchVectorForPlaces } from './glPrefetcher'
import { isNativeIos } from '../native/platform'
import { isIosDevice } from '../utils/iosDevice'
import { openCacheStorage } from './openCacheStorage'
// ── Constants ─────────────────────────────────────────────────────────────────
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
const AVG_TILE_KB = 15
/**
* 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
/**
* The tile budget on an iPhone or iPad. WebKit reads the origin's Cache Storage
* before the service worker can answer the app's first request, and that read
* grows with the number of entries: a full cache of 12,288 tiles held a home
* screen app on a black screen for about 8 s on every cold start, about 0.7 ms
* an entry (#3008). 2,048 keeps that near a second and still covers the zooms a
* trip map opens at.
*/
export const IOS_MAX_TILES = 2048
/** How many tiles a prefetch may hold here: the iOS budget in Safari and its home screen apps. */
export function tileBudget(): number {
return isIosDevice() && !isNativeIos() ? IOS_MAX_TILES : MAX_TILES
}
/**
* Tile requests in flight at once.
*
* Low on purpose: these are background downloads competing with the live app
* for the Service Worker's fetch handler, the connection pool and the origin's
* IndexedDB queue (Workbox writes an expiration record per cached tile).
*/
export const TILE_CONCURRENCY = 6
/**
* Deepest zoom prefetched when the browser refused persistent storage. Keeps the
* padded quota cost of one trip in the low hundreds of megabytes rather than the
* tens of gigabytes a full z16 run bills (#2228).
*/
export const UNPERSISTED_MAX_ZOOM = 12
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
const TILE_CACHE = 'map-tiles'
/** Leaflet draws raster tiles at 256 px, which is what makes a screen N tiles wide. */
const TILE_PX = 256
/** Map size to assume where there is no window (SSR, plain-node callers). */
const FALLBACK_MAP_PX = { width: 1024, height: 768 }
const DEFAULT_TILE_URL = OFM_POSITRON
/**
* Must stay identical to Leaflet's `subdomains` default ('abc'), because the
* index is taken modulo the list length: a fourth entry here shifts the host
* for most tiles away from the one the TileLayer will ask for, so the prefetch
* fills the cache under URLs the map never requests. It also produced the dead
* d.tile.openstreetmap.org lookups in #1733.
*/
const SUBDOMAINS = ['a', 'b', 'c']
/**
* Pick the subdomain from the tile coordinates, the way Leaflet does.
*
* It has to be a pure function of x/y: a rotating counter hands the same tile a
* different host on every run, which both defeats the cache lookup below and
* stores the tile once per host.
*/
function subdomainFor(x: number, y: number): string {
return SUBDOMAINS[Math.abs(x + y) % SUBDOMAINS.length]
}
// ── Tile math ──────────────────────────────────────────────────────────────────
/** Longitude → fractional tile X, i.e. where in the tile grid a coordinate sits. */
function tileXFor(lng: number, zoom: number): number {
return ((lng + 180) / 360) * Math.pow(2, zoom)
}
/** Latitude → fractional tile Y (Web Mercator, y increases southward). */
function tileYFor(lat: number, zoom: number): number {
const latRad = (lat * Math.PI) / 180
return (
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * Math.pow(2, zoom)
)
}
/** Longitude → tile X at given zoom. */
export function lngToTileX(lng: number, zoom: number): number {
return Math.floor(tileXFor(lng, zoom))
}
/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */
export function latToTileY(lat: number, zoom: number): number {
return Math.floor(tileYFor(lat, zoom))
}
/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */
function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] {
if (max - min < minSpan) {
const mid = (min + max) / 2
return [mid - minSpan / 2, mid + minSpan / 2]
}
return [min, max]
}
// ── Public types ──────────────────────────────────────────────────────────────
export interface TileBbox {
minLat: number
maxLat: number
minLng: number
maxLng: number
}
// ── Core logic ────────────────────────────────────────────────────────────────
/**
* Compute the bounding box for a list of places with optional padding.
* Returns null if no places have coordinates.
*/
export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null {
const valid = places.filter(p => p.lat !== null && p.lng !== null)
if (valid.length === 0) return null
const lats = valid.map(p => p.lat as number)
const lngs = valid.map(p => p.lng as number)
const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats))
const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs))
const latPad = (rawMaxLat - rawMinLat) * paddingFraction
const lngPad = (rawMaxLng - rawMinLng) * paddingFraction
return {
minLat: Math.max(-85.0511, rawMinLat - latPad),
maxLat: Math.min(85.0511, rawMaxLat + latPad),
minLng: Math.max(-180, rawMinLng - lngPad),
maxLng: Math.min(180, rawMaxLng + lngPad),
}
}
/**
* The map column is narrower than the window, so taking the window overstates
* it. That is the safe direction: it only ever widens the rectangle below.
*/
function mapSizePx(): { width: number; height: number } {
if (typeof window === 'undefined') return FALLBACK_MAP_PX
return {
width: window.innerWidth || FALLBACK_MAP_PX.width,
height: window.innerHeight || FALLBACK_MAP_PX.height,
}
}
/**
* Tile rectangle to prefetch at one zoom: the bbox, widened to the screen the
* trip map opens on.
*
* fitBounds fills the container, so the opening view is only as tight as the
* bbox on the axis that limits it: a north-south trip opens with several times
* the bbox's longitude in view. Prefetching the places extent alone left that
* view grey to the left and right of the trip (#2180). From the zoom where the
* bbox outgrows the screen the widening adds nothing, so it costs a few dozen
* tiles at the low zooms and nothing at the detail ones.
*/
export function tileRange(
bbox: TileBbox,
zoom: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
const { width, height } = mapSizePx()
const west = tileXFor(bbox.minLng, zoom)
const east = tileXFor(bbox.maxLng, zoom)
const north = tileYFor(bbox.maxLat, zoom)
const south = tileYFor(bbox.minLat, zoom)
// The camera centres the bbox in projected space, the way computeMapViewport does.
const centerX = (west + east) / 2
const centerY = (north + south) / 2
const halfScreenX = width / 2 / TILE_PX
const halfScreenY = height / 2 / TILE_PX
const last = Math.pow(2, zoom) - 1
const clamp = (value: number) => Math.max(0, Math.min(last, Math.floor(value)))
return {
minX: clamp(Math.min(west, centerX - halfScreenX)),
maxX: clamp(Math.max(east, centerX + halfScreenX)),
minY: clamp(Math.min(north, centerY - halfScreenY)),
maxY: clamp(Math.max(south, centerY + halfScreenY)),
}
}
/**
* Count tiles that would be fetched across the zoom range for a bbox.
* Used to enforce the size guard without actually fetching.
*/
export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number {
let total = 0
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
total += (maxX - minX + 1) * (maxY - minY + 1)
if (total > tileBudget()) return total
}
return total
}
/**
* Build the concrete tile URL for given z/x/y from a Leaflet template.
* Rotates through subdomains (a–c).
*
* The template is normalized first: this function is also reached with a raw
* admin default rather than the value from the settings store, so the OSM
* sharding rewrite has to happen here too.
*
* The CARTO key is appended here as well, and it has to be: the Workbox cache is
* keyed on the whole URL including the query, so a tile prefetched without the
* key is a tile the map never asks for.
*/
export function buildTileUrl(template: string, z: number, x: number, y: number, cartoKey?: string): string {
return withTileApiKey(normalizeTileUrl(template), cartoKey)
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
.replace('{s}', subdomainFor(x, y))
.replace('{r}', '')
}
/**
* Enumerate the tile coordinates to prefetch, lowest zoom first, stopping at
* the zoom level whose tiles would push the total past the budget (tileBudget).
*/
function enumerateTiles(
bbox: TileBbox,
minZoom: number,
maxZoom: number,
): Array<[z: number, x: number, y: number]> {
const coords: Array<[number, number, number]> = []
const budget = tileBudget()
for (let z = minZoom; z <= maxZoom; z++) {
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > budget) break
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) coords.push([z, x, y])
}
}
return coords
}
/** Open the tile cache, or null where Cache Storage isn't available. */
async function openTileCache(): Promise<Cache | null> {
return openCacheStorage(TILE_CACHE)
}
/**
* Prefetch tiles for a bbox into the Service Worker cache.
* Stops at the zoom level where the size cap would be exceeded.
* No-ops when:
* - offline
* - no active Service Worker (tiles won't be cached anyway)
* - total tile count exceeds MAX_TILES before even starting zoom 10
*
* Resolves once every tile has been dealt with, so callers that need the tiles
* on disk ("prepare for offline") can simply await it. Callers that don't
* (background sync) leave the promise floating.
*
* Returns the number of tiles actually fetched — tiles already in the cache are
* skipped without touching the network, and a request the browser refused is not
* counted at all.
*/
export async function prefetchTiles(
bbox: TileBbox,
tileUrlTemplate: string,
minZoom = 0,
maxZoom = 16,
cartoKey?: string,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
const coords = enumerateTiles(bbox, minZoom, maxZoom)
if (coords.length === 0) return 0
// Checking Cache Storage from here is far cheaper than letting the request
// reach the SW's CacheFirst handler, so a resumed or repeated prefetch over a
// warm cache costs almost nothing.
const cache = await openTileCache()
let cursor = 0
let fetched = 0
async function worker(): Promise<void> {
while (cursor < coords.length) {
// Going offline or logging out mid-run abandons the rest of the queue.
if (!navigator.onLine || !isAuthed()) return
const [z, x, y] = coords[cursor++]
const url = buildTileUrl(tileUrlTemplate, z, x, y, cartoKey)
if (cache && (await cache.match(url))) continue
// The SW CacheFirst handler stores the response. A rejected request (a CSP
// refusal, a provider that is down, DNS) leaves nothing in the cache and
// must not count towards the tally the caller logs as tiles cached: that
// is what let a fully blocked prefetch still report success (#2180).
const reached = await fetch(url, { mode: 'no-cors' }).then(() => true, () => false)
if (reached) fetched++
}
}
await Promise.all(
Array.from({ length: Math.min(TILE_CONCURRENCY, coords.length) }, worker),
)
return fetched
}
const TRIM_KEY = 'trek_tile_trim_at'
const TRIM_EVERY_MS = 24 * 60 * 60 * 1000
/**
* Brings a tile cache on an iPhone or iPad back within IOS_MAX_TILES, oldest
* tiles first (Cache Storage lists keys in the order they were stored). The
* service worker's own limit is the same on every platform, so a cache filled
* before this budget, or by browsing, still grows past it there (#3008).
*
* Reading the keys of a large cache is itself slow on iOS, so this runs at most
* once a day and only when called after the app is up. Returns how many tiles
* it removed.
*/
export async function trimTileCacheForIos(now = Date.now()): Promise<number> {
if (!isIosDevice() || isNativeIos()) return 0
try {
const last = Number(localStorage.getItem(TRIM_KEY) || 0)
if (now - last < TRIM_EVERY_MS) return 0
localStorage.setItem(TRIM_KEY, String(now))
} catch {
return 0
}
try {
const cache = await openTileCache()
if (!cache) return 0
const keys = await cache.keys()
const surplus = keys.slice(0, Math.max(0, keys.length - IOS_MAX_TILES))
for (const request of surplus) await cache.delete(request)
return surplus.length
} catch {
// Cache Storage refused: the tiles stay as they are and tomorrow tries again.
return 0
}
}
/** Trims the iOS tile cache once the app has started, never in the way of its first screens. */
export function scheduleTileCacheTrim(delayMs = 15_000): void {
if (!isIosDevice() || isNativeIos()) return
window.setTimeout(() => {
void trimTileCacheForIos()
}, delayMs)
}
/**
* Drop the pre-downloaded map-tile cache. Called when the user turns off
* "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real
* "whole world map" concern — is reclaimed immediately.
*/
export async function clearTileCache(): Promise<void> {
try {
if (typeof caches !== 'undefined') await caches.delete(TILE_CACHE)
} catch {
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
}
// The vector basemap lives in its own runtime cache and has to go with it,
// or "clear offline maps" leaves the larger half on disk.
await clearVectorCache()
// Drop the recorded bboxes too, otherwise prefetchTilesForTrip would consider
// these trips done and never refill the cache we just emptied.
try {
const metas = await offlineDb.syncMeta.toArray()
await Promise.all(
metas
.filter(m => m.tilesBbox !== null)
.map(m => upsertSyncMeta({ ...m, tilesBbox: null })),
)
} catch (err) {
console.error('[tilePrefetch] failed to reset tile bboxes:', err)
}
}
/** Same bbox to within ~1 m — i.e. the trip's places haven't moved. */
function sameBbox(a: [number, number, number, number], b: TileBbox): boolean {
return (
Math.abs(a[0] - b.minLng) < 1e-5 &&
Math.abs(a[1] - b.minLat) < 1e-5 &&
Math.abs(a[2] - b.maxLng) < 1e-5 &&
Math.abs(a[3] - b.maxLat) < 1e-5
)
}
/** The OpenFreeMap styles the offline fallback draws; they share one tile set. */
const OFFLINE_BASEMAPS: readonly string[] = [OFM_POSITRON, OFM_DARK]
/**
* The offline basemap is a best-effort extra: a failed request leaves its tile
* uncached and the next sync tries again, so it stops neither the raster run
* nor the sync.
*/
function basemapFailed(): void {}
/**
* The trip's region of the offline basemap: the light style's tiles, style,
* sprite and glyphs, and the dark style's own style, sprite and glyphs on top,
* whose tiles are the same ones.
*/
async function prefetchOfflineBasemap(places: Place[], template: string, cancelled: () => boolean): Promise<void> {
const light = template === OFM_DARK ? OFM_DARK : OFM_POSITRON
const other = light === OFM_DARK ? OFM_POSITRON : OFM_DARK
await prefetchVectorForPlaces(places, light, cancelled).catch(basemapFailed)
if (!cancelled()) await prefetchStyleAssets(other, computeBbox(places)).catch(basemapFailed)
}
/**
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
* Designed to be called fire-and-forget from tripSyncManager.
*
* Set `force` to prefetch even when this bbox was already covered by an earlier
* run — the "prepare for offline" path does, so the user gets a guarantee
* rather than a promise based on our own bookkeeping.
*/
export async function prefetchTilesForTrip(
tripId: number,
places: Place[],
tileUrlTemplate?: string,
force = false,
cartoKey?: string,
): Promise<void> {
// Resolved rather than taken raw, so a keyless CARTO template pre-downloads the
// basemap the map will actually draw instead of a few thousand watermarks.
const template = resolveTileUrl(tileUrlTemplate, DEFAULT_TILE_URL, cartoKey)
const bbox = computeBbox(places)
if (!bbox) return
// Unchanged bbox → the tiles are already there. Skipping outright keeps a
// routine login from re-walking thousands of tile URLs just to find them all
// cached.
const existing = await offlineDb.syncMeta.get(tripId)
if (!force && existing?.tilesBbox && sameBbox(existing.tilesBbox, bbox)) return
// The default basemap is a vector style, and walking a {z}/{x}/{y} template
// over one would fetch nothing the map ever asks for. A user who configured
// their own raster template keeps the path below unchanged.
const cancelled = () => !navigator.onLine || !isAuthed()
// Whatever the map is set to, the trip's region of the offline basemap goes
// along: offline, a Mapbox map or one the device cannot serve switches to it
// (components/Map/offlineBasemap.ts).
await prefetchOfflineBasemap(places, template, cancelled)
if (isVectorStyle(template)) {
const { tiles } = OFFLINE_BASEMAPS.includes(template)
? { tiles: 0 }
: await prefetchVectorForPlaces(places, template, cancelled)
const meta = await offlineDb.syncMeta.get(tripId)
if (meta) {
await upsertSyncMeta({
...meta,
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
})
}
if (tiles > 0) console.info(`[tilePrefetch] trip ${tripId}: cached ${tiles} vector tiles`)
return
}
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
// their lower zooms cached instead of being skipped entirely.
//
// 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. We
// keep no-cors deliberately: switching to cors would break self-hosted/custom
// 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).
//
// When that request was refused, the padding stops being an accounting detail:
// a full run bills this origin tens of gigabytes it is not exempt from, and
// the browser answers by evicting the whole bucket, including the precached
// app shell, which then never comes back on its own. So cap the depth instead.
// The shallow zooms are the ones the trip map actually opens at and cost a few
// dozen tiles (#2180); the deep ones are the volume, and they are what the
// user loses rather than the ability to start the app at all (#2228).
const maxZoom = isStoragePersisted() ? 16 : UNPERSISTED_MAX_ZOOM
// Not for a host whose tiles may not be stored (OSM's tile usage policy), and
// not inside the iOS app, whose WebView runs no service worker to serve them:
// the offline basemap above stands in there.
const fetched = isOfflineForbiddenTemplate(template) || isNativeIos()
? 0
: await prefetchTiles(bbox, template, 0, maxZoom, cartoKey)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
if (meta) {
await upsertSyncMeta({
...meta,
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
})
}
if (fetched > 0) {
console.info(`[tilePrefetch] trip ${tripId}: cached ${fetched} tiles`)
}
}