diff --git a/client/src/sync/glPrefetcher.ts b/client/src/sync/glPrefetcher.ts index baa01307d..c3e574b99 100644 --- a/client/src/sync/glPrefetcher.ts +++ b/client/src/sync/glPrefetcher.ts @@ -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 { 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 } diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index c9ac770f7..db8a4d2d9 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -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 { * (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++ } } diff --git a/client/tests/unit/sync/glPrefetcher.test.ts b/client/tests/unit/sync/glPrefetcher.test.ts index 8a411250c..61d87e0b8 100644 --- a/client/tests/unit/sync/glPrefetcher.test.ts +++ b/client/tests/unit/sync/glPrefetcher.test.ts @@ -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 }); diff --git a/client/tests/unit/sync/tilePrefetcher.test.ts b/client/tests/unit/sync/tilePrefetcher.test.ts index 0fb9a886e..0bb43da56 100644 --- a/client/tests/unit/sync/tilePrefetcher.test.ts +++ b/client/tests/unit/sync/tilePrefetcher.test.ts @@ -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); + }); +}); diff --git a/server/src/middleware/globalMiddleware.ts b/server/src/middleware/globalMiddleware.ts index ca8c2bea8..8658015a9 100644 --- a/server/src/middleware/globalMiddleware.ts +++ b/server/src/middleware/globalMiddleware.ts @@ -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/", diff --git a/server/tests/unit/middleware/globalMiddleware.csp.test.ts b/server/tests/unit/middleware/globalMiddleware.csp.test.ts index 1f32385a3..25e043bcd 100644 --- a/server/tests/unit/middleware/globalMiddleware.csp.test.ts +++ b/server/tests/unit/middleware/globalMiddleware.csp.test.ts @@ -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'");