/** * 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 10–16, enumerate tile XYZ coordinates within bbox. * 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB). * 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it. * * 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' // ── Constants ───────────────────────────────────────────────────────────────── /** Estimated average tile size in KB (road/transit tiles ~15 KB). */ const AVG_TILE_KB = 15 /** Hard cap: ~50 MB worth of tiles. */ export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413 const DEFAULT_TILE_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' const SUBDOMAINS = ['a', 'b', 'c', 'd'] let _subIdx = 0 function nextSubdomain(): string { return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length] } // ── Tile math ────────────────────────────────────────────────────────────────── /** Longitude → tile X at given zoom. */ export function lngToTileX(lng: number, zoom: number): number { return Math.floor(((lng + 180) / 360) * Math.pow(2, 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, ) } /** 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), } } /** * 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 = 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 total += (maxX - minX + 1) * (maxY - minY + 1) if (total > MAX_TILES) return total } return total } /** * Build the concrete tile URL for given z/x/y from a Leaflet template. * Rotates through subdomains (a–d). */ export function buildTileUrl(template: string, z: number, x: number, y: number): string { return template .replace('{z}', String(z)) .replace('{x}', String(x)) .replace('{y}', String(y)) .replace('{s}', nextSubdomain()) .replace('{r}', '') } /** * 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 */ export async function prefetchTiles( bbox: TileBbox, tileUrlTemplate: string, minZoom = 10, maxZoom = 16, ): Promise { if (!navigator.onLine) return 0 if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0 let fetched = 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) const maxY = latToTileY(bbox.minLat, z) const count = (maxX - minX + 1) * (maxY - minY + 1) if (fetched + count > MAX_TILES) break for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const url = buildTileUrl(tileUrlTemplate, z, x, y) // Fire-and-forget: SW CacheFirst handler stores the response fetch(url, { mode: 'no-cors' }).catch(() => {}) fetched++ } } } return fetched } /** * Full pipeline: compute bbox → guard → prefetch → update syncMeta. * Designed to be called fire-and-forget from tripSyncManager. */ export async function prefetchTilesForTrip( tripId: number, places: Place[], tileUrlTemplate?: string, ): Promise { const template = tileUrlTemplate || DEFAULT_TILE_URL const bbox = computeBbox(places) if (!bbox) return // Size guard: if total tile count across all zooms exceeds cap, skip const estimated = countTiles(bbox, 10, 16) if (estimated > MAX_TILES) { console.warn( `[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`, ) return } const fetched = await prefetchTiles(bbox, template) // 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}: queued ${fetched} tiles for caching`) } }