import type { Trip } from "./types"; const TILE_CACHE = "palace-travel-tiles-v1"; const DATA_CACHE = "palace-travel-data-v1"; const ASSET_CACHE = "palace-assets-v2"; const SHELL_CACHE = "palace-ring-v10"; const SUBDOMAINS = ["a", "b", "c"]; function latLngToTile(lat: number, lng: number, zoom: number): { x: number; y: number } { const n = Math.pow(2, zoom); const x = Math.floor(((lng + 180) / 360) * n); const latRad = (lat * Math.PI) / 180; const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n); return { x, y }; } function getTileUrls(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }, zooms: number[]): string[] { const urls: string[] = []; for (const z of zooms) { const topLeft = latLngToTile(bounds.maxLat, bounds.minLng, z); const bottomRight = latLngToTile(bounds.minLat, bounds.maxLng, z); for (let x = topLeft.x; x <= bottomRight.x; x++) { for (let y = topLeft.y; y <= bottomRight.y; y++) { const s = SUBDOMAINS[(x + y) % SUBDOMAINS.length]; urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`); } } } return urls; } function getTripBounds(trip: Trip): { minLat: number; maxLat: number; minLng: number; maxLng: number } { let minLat = 90, maxLat = -90, minLng = 180, maxLng = -180; for (const p of trip.places) { if (p.lat < minLat) minLat = p.lat; if (p.lat > maxLat) maxLat = p.lat; if (p.lng < minLng) minLng = p.lng; if (p.lng > maxLng) maxLng = p.lng; } for (const day of trip.days) { for (const drive of day.drives) { if (drive.route) { for (const [lat, lng] of drive.route) { if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng; } } } } const pad = 0.1; return { minLat: minLat - pad, maxLat: maxLat + pad, minLng: minLng - pad, maxLng: maxLng + pad }; } function extractAssetUrls(html: string): string[] { const urls: string[] = []; const patterns = [ /href="([^"]*\/_astro\/[^"]+)"/g, /src="([^"]*\/_astro\/[^"]+)"/g, /href="([^"]*\/node_modules\/[^"]+)"/g, /src="([^"]*\/node_modules\/[^"]+)"/g, /href="(https:\/\/fonts\.googleapis\.com\/[^"]+)"/g, /href="(https:\/\/unpkg\.com\/[^"]+)"/g, /src="(https:\/\/unpkg\.com\/[^"]+)"/g, // Vite dev mode paths /src="([^"]*\/@vite\/[^"]+)"/g, /src="([^"]*\/@fs\/[^"]+)"/g, ]; for (const re of patterns) { let m; while ((m = re.exec(html)) !== null) { urls.push(m[1]); } } return urls; } export interface DownloadProgress { phase: "data" | "assets" | "tiles"; done: number; total: number; } export async function downloadTripOffline( trip: Trip, onProgress: (p: DownloadProgress) => void ): Promise<{ tileCount: number; dataMb: number }> { const tripId = trip.id; const dataUrls = [ `/travel/api/trips`, `/travel/api/trip/${tripId}/meta.json`, `/travel/api/trip/${tripId}/weather.json`, `/travel/api/trip/${tripId}/pack.json`, `/travel/api/trip/${tripId}/places`, `/travel/api/trip/${tripId}/places/safety.json`, `/travel/api/trip/${tripId}/days`, `/travel/api/trip/${tripId}/pack-state.json`, ]; const dataCache = await caches.open(DATA_CACHE); let dataBytes = 0; for (let i = 0; i < dataUrls.length; i++) { onProgress({ phase: "data", done: i, total: dataUrls.length }); try { const res = await fetch(dataUrls[i]); if (res.ok) { const clone = res.clone(); const blob = await clone.blob(); dataBytes += blob.size; await dataCache.put(dataUrls[i], res); } } catch {} } onProgress({ phase: "data", done: dataUrls.length, total: dataUrls.length }); const shellCache = await caches.open(SHELL_CACHE); const assetCache = await caches.open(ASSET_CACHE); const travelPageRes = await fetch("/travel"); const travelPageHtml = await travelPageRes.clone().text(); await shellCache.put("/travel", travelPageRes); const assetUrls = extractAssetUrls(travelPageHtml); // Also grab all resources already loaded in this page (catches dynamic imports, Vite chunks) if (typeof performance !== "undefined" && performance.getEntriesByType) { const entries = performance.getEntriesByType("resource"); const origin = location.origin; for (const e of entries) { const u = (e as PerformanceResourceTiming).name; if ((u.indexOf(origin) === 0 || u.indexOf("fonts.g") !== -1 || u.indexOf("unpkg.com") !== -1) && !assetUrls.includes(u)) { assetUrls.push(u); } } } const leafletCss = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"; const leafletJs = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"; if (!assetUrls.includes(leafletCss)) assetUrls.push(leafletCss); if (!assetUrls.includes(leafletJs)) assetUrls.push(leafletJs); assetUrls.push("/icon.svg", "/icon-192.png", "/manifest.webmanifest"); for (let i = 0; i < assetUrls.length; i++) { onProgress({ phase: "assets", done: i, total: assetUrls.length }); try { const existing = await assetCache.match(assetUrls[i]); if (existing) continue; const res = await fetch(assetUrls[i]); if (res.ok) { const blob = await res.clone().blob(); dataBytes += blob.size; await assetCache.put(assetUrls[i], res); } } catch {} } onProgress({ phase: "assets", done: assetUrls.length, total: assetUrls.length }); const gfontsRes = await assetCache.match("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300&family=Inter:wght@300;400;500&display=swap"); if (gfontsRes) { const cssText = await gfontsRes.clone().text(); const fontFileUrls: string[] = []; const re = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g; let m; while ((m = re.exec(cssText)) !== null) fontFileUrls.push(m[1]); for (const furl of fontFileUrls) { try { const existing = await assetCache.match(furl); if (existing) continue; const res = await fetch(furl); if (res.ok) await assetCache.put(furl, res); } catch {} } } const bounds = getTripBounds(trip); const tileUrls = getTileUrls(bounds, [8, 9, 10, 11, 12]); const tileCache = await caches.open(TILE_CACHE); const BATCH = 6; let tilesDone = 0; for (let i = 0; i < tileUrls.length; i += BATCH) { const batch = tileUrls.slice(i, i + BATCH); await Promise.allSettled( batch.map(async (url) => { const existing = await tileCache.match(url); if (existing) return; const res = await fetch(url); if (res.ok) await tileCache.put(url, res); }) ); tilesDone += batch.length; onProgress({ phase: "tiles", done: tilesDone, total: tileUrls.length }); } return { tileCount: tileUrls.length, dataMb: Math.round(dataBytes / 1024 / 1024 * 10) / 10, }; } export async function isOfflineReady(tripId: string): Promise { try { const cache = await caches.open(DATA_CACHE); const meta = await cache.match(`/travel/api/trip/${tripId}/meta.json`); return !!meta; } catch { return false; } } export async function clearOfflineData(): Promise { await caches.delete(TILE_CACHE); await caches.delete(DATA_CACHE); await caches.delete(ASSET_CACHE); }