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.
This commit is contained in:
Maurice
2026-09-02 19:00:35 +02:00
committed by Maurice
parent fc6843528c
commit 2ab2dcc3dc
6 changed files with 215 additions and 26 deletions
+6 -7
View File
@@ -15,7 +15,7 @@
* The style, sprite and glyphs are per device rather than per trip: about
* 980 KB once, not per journey.
*/
import { computeBbox, lngToTileX, latToTileY, type TileBbox } from './tilePrefetcher'
import { computeBbox, tileRange, type TileBbox } from './tilePrefetcher'
import type { Place } from '../types'
/** OpenFreeMap serves vector tiles up to z14 and overzooms from there. */
@@ -104,12 +104,11 @@ async function openVectorCache(): Promise<Cache | null> {
function enumerateVectorTiles(bbox: TileBbox, minZoom: number, maxZoom: number): [number, number, number][] {
const out: [number, number, number][] = []
for (let z = minZoom; z <= maxZoom; z++) {
const x0 = lngToTileX(bbox.minLng, z)
const x1 = lngToTileX(bbox.maxLng, z)
const y0 = latToTileY(bbox.maxLat, z)
const y1 = latToTileY(bbox.minLat, z)
for (let x = Math.min(x0, x1); x <= Math.max(x0, x1); x++) {
for (let y = Math.min(y0, y1); y <= Math.max(y0, y1); y++) {
// 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
}
+84 -19
View File
@@ -7,7 +7,9 @@
* 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).
* 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.
@@ -55,6 +57,12 @@ export const TILE_CONCURRENCY = 6
/** 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
/**
@@ -79,18 +87,27 @@ function subdomainFor(x: number, y: number): string {
// ── 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(((lng + 180) / 360) * Math.pow(2, zoom))
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 {
const n = Math.pow(2, zoom)
const latRad = (lat * Math.PI) / 180
return Math.floor(
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n,
)
return Math.floor(tileYFor(lat, zoom))
}
/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */
@@ -138,6 +155,56 @@ export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox |
}
}
/**
* 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.
@@ -145,10 +212,7 @@ export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox |
export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number {
let total = 0
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y
const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
total += (maxX - minX + 1) * (maxY - minY + 1)
if (total > MAX_TILES) return total
}
@@ -188,10 +252,7 @@ function enumerateTiles(
const coords: Array<[number, number, number]> = []
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z)
const maxY = latToTileY(bbox.minLat, z)
const { minX, maxX, minY, maxY } = tileRange(bbox, z)
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > MAX_TILES) break
@@ -226,7 +287,8 @@ async function openTileCache(): Promise<Cache | null> {
* (background sync) leave the promise floating.
*
* Returns the number of tiles actually fetched — tiles already in the cache are
* skipped without touching the network.
* skipped without touching the network, and a request the browser refused is not
* counted at all.
*/
export async function prefetchTiles(
bbox: TileBbox,
@@ -259,9 +321,12 @@ export async function prefetchTiles(
if (cache && (await cache.match(url))) continue
// The SW CacheFirst handler stores the response.
await fetch(url, { mode: 'no-cors' }).catch(() => {})
fetched++
// 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++
}
}
@@ -156,6 +156,22 @@ describe('prefetchVectorForPlaces', () => {
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-014: reaches as far as the screen the map opens on, not just the places extent', async () => {
// Berlin + Munich: fitBounds is height-limited, so the opening view shows far
// more longitude than the places extent, and that band was grey offline (#2180).
await prefetchVectorForPlaces([
buildPlace({ lat: 52.52, lng: 13.405 }),
buildPlace({ lat: 48.14, lng: 11.58 }),
], STYLE_URL);
const xs = [...cache.store.keys()]
.filter(u => u.endsWith('.pbf') && u.includes('/planet/'))
.map(u => u.split('/planet/')[1]!.split('/'))
.filter(parts => Number(parts[1]) === 7)
.map(parts => Number(parts[2]));
expect((Math.max(...xs) - Math.min(...xs) + 1) * 256).toBeGreaterThanOrEqual(window.innerWidth);
});
it('FE-SYNC-GLPF-008: does nothing offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true });
await expect(prefetchVectorForPlaces(places, STYLE_URL)).resolves.toEqual({ tiles: 0, template: null });
@@ -561,3 +561,88 @@ describe('prefetch covers the opening zoom (#2180)', () => {
expect(urls.some(u => u.includes('/7/'))).toBe(true);
});
});
describe('prefetch covers the opening viewport (#2180)', () => {
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
const tileCoords = () =>
vi.mocked(fetch).mock.calls
.map(call => /\/(\d+)\/(\d+)\/(\d+)\.png$/.exec(String(call[0])))
.filter((m): m is RegExpExecArray => m !== null)
.map(m => ({ z: Number(m[1]), x: Number(m[2]), y: Number(m[3]) }));
const spanPx = (values: number[]) => (Math.max(...values) - Math.min(...values) + 1) * 256;
it('reaches as far as the screen the map opens on, not just the places extent', async () => {
// Berlin + Munich: fitBounds is limited by the north-south span, so the map
// opens with several times the trip's longitude in view. The places extent
// is two tile columns wide at that zoom, and everything beside it was grey.
const bbox = computeBbox([
buildPlace({ lat: 52.52, lng: 13.405 }),
buildPlace({ lat: 48.14, lng: 11.58 }),
])!;
await prefetchTiles(bbox, tmpl, 7, 7);
const opening = tileCoords().filter(t => t.z === 7);
expect(spanPx(opening.map(t => t.x))).toBeGreaterThanOrEqual(window.innerWidth);
expect(spanPx(opening.map(t => t.y))).toBeGreaterThanOrEqual(window.innerHeight);
});
it('still covers the places extent corner to corner', async () => {
// The screen is a widening, not a replacement: past the opening zoom the
// bbox is the larger of the two and stays the thing being prefetched.
const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 };
await prefetchTiles(bbox, tmpl, 9, 9);
const urls = vi.mocked(fetch).mock.calls.map(c => String(c[0]));
expect(urls).toContain(buildTileUrl(tmpl, 9, lngToTileX(2.0, 9), latToTileY(49.0, 9)));
expect(urls).toContain(buildTileUrl(tmpl, 9, lngToTileX(3.0, 9), latToTileY(48.0, 9)));
});
it('never asks for a tile outside the grid', async () => {
// Widening a world-spanning bbox at z0 would otherwise run x from -2 to 3,
// and every one of those URLs is a 404 stored as an opaque cache entry.
const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 };
await prefetchTiles(bbox, tmpl, 0, 2);
// The whole grid at z0..z2 and not one tile more.
expect(tileCoords()).toHaveLength(1 + 4 + 16);
for (const { z, x, y } of tileCoords()) {
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThan(Math.pow(2, z));
expect(y).toBeLessThan(Math.pow(2, z));
}
});
});
describe('prefetchTiles: a refused tile is not a cached tile (#2180)', () => {
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
it('counts nothing when every request is refused', async () => {
// What a CSP block looks like from here. Counting these is how a prefetch
// that stored not one tile still reported "cached N tiles" to the console.
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
const fetched = await prefetchTiles(bbox, tmpl, 10, 11);
expect(vi.mocked(fetch)).toHaveBeenCalled();
expect(fetched).toBe(0);
});
it('counts the tiles that did go through', async () => {
let calls = 0;
vi.stubGlobal('fetch', vi.fn(() => (++calls % 2 === 1
? Promise.resolve({ ok: true })
: Promise.reject(new TypeError('Failed to fetch')))));
const fetched = await prefetchTiles(bbox, tmpl, 10, 11);
expect(fetched).toBeGreaterThan(0);
expect(fetched).toBeLessThan(calls);
});
});
@@ -198,6 +198,11 @@ export function applyGlobalMiddleware(
// retired the a/b/c/d shards (#1733). The sharded hosts stay listed
// for tile templates users saved before that.
"https://tile.openstreetmap.org", "https://*.tile.openstreetmap.org",
// The other two raster presets TREK ships. `mode: 'no-cors'` relaxes
// CORS, not CSP, so without these the tile prefetch is refused in the
// document and never reaches the Service Worker that would cache it
// (#2180). routing.openstreetmap.de below is a different host.
"https://tile.openstreetmap.de", "https://tiles.stadiamaps.com",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
@@ -33,6 +33,25 @@ describe('global CSP: OpenStreetMap tile hosts (#1733)', () => {
});
});
describe('global CSP: the other shipped raster presets (#2180)', () => {
it('allows tile.openstreetmap.de', async () => {
// The prefetcher fetches tiles with mode 'no-cors', which relaxes CORS and
// nothing else: a host missing here is refused in the document, so the
// Service Worker never sees the request and caches no tile at all.
expect(await connectSrcSources()).toContain('https://tile.openstreetmap.de');
});
it('allows tiles.stadiamaps.com', async () => {
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
});
it('keeps the routing host, which is a different host and covers nothing here', async () => {
// routing.openstreetmap.de was on the list all along and looks close enough
// to hide the gap: a CSP source matches a host, not a suffix of one.
expect(await connectSrcSources()).toContain('https://routing.openstreetmap.de/');
});
});
describe('global CSP: script-src', () => {
it("allows 'wasm-unsafe-eval' so the WASM decoders keep running", async () => {
expect(await directiveSources('script-src')).toContain("'wasm-unsafe-eval'");