chore: remove offline XYZ tile prefetcher and cache
Some checks are pending
Security Scan / scout (push) Waiting to run

- Remove tilePrefetcher.ts and its test file
- Remove tile prefetch calls from tripSyncManager
- Remove map tiles toggle from OfflineTab settings
- Remove cacheTiles preference from offlinePrefs
- Map tiles offline caching is no longer needed with AMap
This commit is contained in:
jubnl 2026-07-11 18:29:17 +08:00
parent a956a06792
commit 1922251fb3
6 changed files with 34 additions and 573 deletions

View File

@ -15,10 +15,9 @@ import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
import { tripsApi } from '../../api/client' import { tripsApi } from '../../api/client'
import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager' import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
import { clearTileCache } from '../../sync/tilePrefetcher'
import { isEffectivelyOffline } from '../../sync/networkMode' import { isEffectivelyOffline } from '../../sync/networkMode'
import { import {
getOfflinePrefs, setCacheTiles, setConflictStrategy, getOfflinePrefs, setConflictStrategy,
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange,
type ConflictStrategy, type ConflictStrategy,
} from '../../sync/offlinePrefs' } from '../../sync/offlinePrefs'
@ -147,13 +146,6 @@ export default function OfflineTab(): React.ReactElement {
} }
} }
async function handleToggleTiles() {
const next = !prefs.cacheTiles
setCacheTiles(next)
// Turning tiles off reclaims the bulk tile storage straight away.
if (!next) await clearTileCache()
}
async function handleToggleTrip(tripId: number) { async function handleToggleTrip(tripId: number) {
const next = !isTripOfflineEnabled(tripId) const next = !isTripOfflineEnabled(tripId)
setTripOfflineEnabled(tripId, next) setTripOfflineEnabled(tripId, next)
@ -287,19 +279,9 @@ export default function OfflineTab(): React.ReactElement {
</Section> </Section>
)} )}
{/* What to store offline */} {/* Per-trip offline toggles */}
<Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row
label={t('settings.offline.storage.tiles')}
hint={t('settings.offline.storage.tilesHint')}
control={<ToggleSwitch on={prefs.cacheTiles} onToggle={handleToggleTiles} label={t('settings.offline.storage.tiles')} />}
/>
{allTrips.length > 0 && ( {allTrips.length > 0 && (
<div style={{ borderTop: '1px solid var(--border-secondary, #e5e7eb)', paddingTop: 16 }}> <Section title={t('settings.offline.storage.title')} icon={MapIcon}>
<div style={{ fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', marginBottom: 8 }} className="text-content">
{t('settings.offline.storage.tripsTitle')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{allTrips.map((trip) => { {allTrips.map((trip) => {
const on = isTripOfflineEnabled(trip.id) const on = isTripOfflineEnabled(trip.id)
@ -318,10 +300,8 @@ export default function OfflineTab(): React.ReactElement {
) )
})} })}
</div> </div>
</div>
)}
</div>
</Section> </Section>
)}
{/* Cache stats + list + clear */} {/* Cache stats + list + clear */}
<Section title={t('settings.offline.cache.title')} icon={Database}> <Section title={t('settings.offline.cache.title')} icon={Database}>

View File

@ -3,13 +3,10 @@
* HOW sync conflicts are resolved (discussion #1135, asks 2 and 3). * HOW sync conflicts are resolved (discussion #1135, asks 2 and 3).
* *
* These live in localStorage rather than the server user-settings because they * These live in localStorage rather than the server user-settings because they
* are inherently per-device: how much storage a phone should spend on map tiles, * are inherently per-device: how much storage a phone should spend or which
* or which trips to keep on this particular device, has nothing to do with the * trips to keep on this particular device has nothing to do with the account
* account and everything to do with the hardware in the user's hand. * and everything to do with the hardware in the user's hand.
* *
* cacheTiles global on/off for pre-downloading map tiles. Off keeps
* the cache to trip data + documents only ("not the whole
* world map"). See tripSyncManager / clearTileCache.
* disabledTripIds trips the user explicitly excluded from offline storage. * disabledTripIds trips the user explicitly excluded from offline storage.
* Everything else that is date-eligible is cached. * Everything else that is date-eligible is cached.
* conflictStrategy what to do when an offline edit collides with a newer * conflictStrategy what to do when an offline edit collides with a newer
@ -20,7 +17,6 @@
export type ConflictStrategy = 'ask' | 'mine' | 'server' export type ConflictStrategy = 'ask' | 'mine' | 'server'
export interface OfflinePrefs { export interface OfflinePrefs {
cacheTiles: boolean
disabledTripIds: number[] disabledTripIds: number[]
conflictStrategy: ConflictStrategy conflictStrategy: ConflictStrategy
} }
@ -28,7 +24,6 @@ export interface OfflinePrefs {
const STORAGE_KEY = 'trek_offline_prefs' const STORAGE_KEY = 'trek_offline_prefs'
const DEFAULTS: OfflinePrefs = { const DEFAULTS: OfflinePrefs = {
cacheTiles: true,
disabledTripIds: [], disabledTripIds: [],
conflictStrategy: 'ask', conflictStrategy: 'ask',
} }
@ -40,9 +35,8 @@ function read(): OfflinePrefs {
try { try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
if (!raw) return { ...DEFAULTS } if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<OfflinePrefs> const parsed = JSON.parse(raw) as Partial<OfflinePrefs & { cacheTiles?: boolean }>
return { return {
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [], disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask', conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
} }
@ -62,11 +56,6 @@ export function getOfflinePrefs(): OfflinePrefs {
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] } return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds] }
} }
export function setCacheTiles(on: boolean): void {
if (_prefs.cacheTiles === on) return
write({ ..._prefs, cacheTiles: on })
}
export function setConflictStrategy(strategy: ConflictStrategy): void { export function setConflictStrategy(strategy: ConflictStrategy): void {
if (_prefs.conflictStrategy === strategy) return if (_prefs.conflictStrategy === strategy) return
write({ ..._prefs, conflictStrategy: strategy }) write({ ..._prefs, conflictStrategy: strategy })

View File

@ -1,233 +0,0 @@
/**
* 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 1016, 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 (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
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 (ad).
*/
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,
awaitAll = false,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
let fetched = 0
// When awaitAll is set (the "prepare for offline" path), we wait for every tile
// request to settle so the caller's progress bar only completes once the tiles
// are actually downloaded into the SW cache — not merely dispatched.
const inflight: Promise<unknown>[] = []
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)
// SW CacheFirst handler stores the response. Fire-and-forget unless the
// caller asked to await completion.
const p = fetch(url, { mode: 'no-cors' }).catch(() => {})
if (awaitAll) inflight.push(p)
fetched++
}
}
}
if (awaitAll && inflight.length) await Promise.allSettled(inflight)
return fetched
}
/**
* 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('map-tiles')
} catch {
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
}
}
/**
* 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,
awaitAll = false,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
const bbox = computeBbox(places)
if (!bbox) 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).
const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll)
// 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`)
}
}

View File

@ -29,10 +29,8 @@ import {
clearTripData, clearTripData,
enforceBlobBudget, enforceBlobBudget,
} from '../db/offlineDb' } from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate' import { isAuthed } from './authGate'
import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs' import { isTripOfflineEnabled } from './offlinePrefs'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types' import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@ -189,17 +187,10 @@ export const tripSyncManager = {
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}) tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
// Cache file blobs + map tiles in background (don't block syncAll) // Cache file blobs in background (don't block syncAll)
const cacheTiles = getOfflinePrefs().cacheTiles
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
for (const trip of toSync) { for (const trip of toSync) {
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
cacheFilesForTrip(files).catch(console.error) cacheFilesForTrip(files).catch(console.error)
if (cacheTiles) {
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
}
} }
} finally { } finally {
_syncing = false _syncing = false
@ -248,17 +239,6 @@ export const tripSyncManager = {
await cacheFilesForTrip(files).catch(console.error) await cacheFilesForTrip(files).catch(console.error)
} }
// 3) Map tiles — awaited, and only when the user opted to store them.
if (getOfflinePrefs().cacheTiles) {
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
i = 0
for (const trip of toSync) {
onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title })
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error)
}
}
onProgress?.({ phase: 'done', current: total, total }) onProgress?.({ phase: 'done', current: total, total })
return total return total
} finally { } finally {

View File

@ -1,9 +1,9 @@
/** /**
* offlinePrefs unit tests device-local "what to store offline" + conflict strategy. * offlinePrefs unit tests device-local trip offline settings + conflict strategy.
*/ */
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { import {
getOfflinePrefs, setCacheTiles, setConflictStrategy, getOfflinePrefs, setConflictStrategy,
isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs, isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs,
} from '../../../src/sync/offlinePrefs' } from '../../../src/sync/offlinePrefs'
@ -13,20 +13,13 @@ beforeEach(() => {
}) })
describe('offlinePrefs', () => { describe('offlinePrefs', () => {
it('defaults to tiles on, no disabled trips, ask strategy', () => { it('defaults to no disabled trips and ask strategy', () => {
const p = getOfflinePrefs() const p = getOfflinePrefs()
expect(p.cacheTiles).toBe(true)
expect(p.disabledTripIds).toEqual([]) expect(p.disabledTripIds).toEqual([])
expect(p.conflictStrategy).toBe('ask') expect(p.conflictStrategy).toBe('ask')
expect(isTripOfflineEnabled(5)).toBe(true) expect(isTripOfflineEnabled(5)).toBe(true)
}) })
it('toggles tile caching and persists it', () => {
setCacheTiles(false)
expect(getOfflinePrefs().cacheTiles).toBe(false)
expect(JSON.parse(localStorage.getItem('trek_offline_prefs')!).cacheTiles).toBe(false)
})
it('disables and re-enables a single trip', () => { it('disables and re-enables a single trip', () => {
setTripOfflineEnabled(7, false) setTripOfflineEnabled(7, false)
expect(isTripOfflineEnabled(7)).toBe(false) expect(isTripOfflineEnabled(7)).toBe(false)
@ -51,10 +44,10 @@ describe('offlinePrefs', () => {
it('notifies subscribers and stops after unsubscribe', () => { it('notifies subscribers and stops after unsubscribe', () => {
let n = 0 let n = 0
const unsub = onOfflinePrefsChange(() => { n++ }) const unsub = onOfflinePrefsChange(() => { n++ })
setCacheTiles(false) setConflictStrategy('mine')
expect(n).toBe(1) expect(n).toBe(1)
unsub() unsub()
setCacheTiles(true) setConflictStrategy('server')
expect(n).toBe(1) expect(n).toBe(1)
}) })
}) })

View File

@ -1,248 +0,0 @@
/**
* tilePrefetcher unit tests.
*
* Covers: bbox computation, tile math, URL building, size guard,
* offline/no-SW guard, syncMeta update.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import 'fake-indexeddb/auto';
import {
computeBbox,
lngToTileX,
latToTileY,
buildTileUrl,
countTiles,
prefetchTiles,
prefetchTilesForTrip,
MAX_TILES,
type TileBbox,
} from '../../../src/sync/tilePrefetcher';
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
import { buildPlace } from '../../helpers/factories';
beforeEach(async () => {
await clearAll();
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
Object.defineProperty(navigator, 'serviceWorker', {
value: { controller: {} },
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ── bbox computation ──────────────────────────────────────────────────────────
describe('computeBbox', () => {
it('returns null when no places have coordinates', () => {
const places = [buildPlace({ lat: null, lng: null })];
expect(computeBbox(places)).toBeNull();
});
it('expands single-point bbox to at least 0.1° span', () => {
const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
const bbox = computeBbox([place])!;
expect(bbox.maxLat - bbox.minLat).toBeGreaterThan(0.09);
expect(bbox.maxLng - bbox.minLng).toBeGreaterThan(0.09);
});
it('computes multi-point bbox with padding', () => {
const places = [
buildPlace({ lat: 48.8566, lng: 2.3522 }), // Paris
buildPlace({ lat: 51.5074, lng: -0.1278 }), // London
];
const bbox = computeBbox(places, 0.1)!;
// Padded bbox should extend beyond raw points
expect(bbox.minLat).toBeLessThan(48.8566);
expect(bbox.maxLat).toBeGreaterThan(51.5074);
expect(bbox.minLng).toBeLessThan(-0.1278);
expect(bbox.maxLng).toBeGreaterThan(2.3522);
});
it('clamps to valid Mercator lat bounds', () => {
const places = [buildPlace({ lat: 85.0, lng: 0 })];
const bbox = computeBbox(places, 0.5)!;
expect(bbox.maxLat).toBeLessThanOrEqual(85.0511);
});
});
// ── tile math ─────────────────────────────────────────────────────────────────
describe('lngToTileX', () => {
it('returns 0 for lng=-180 at any zoom', () => {
expect(lngToTileX(-180, 10)).toBe(0);
});
it('returns max tile for lng=180 at zoom 1', () => {
// At zoom 1: 2^1 = 2 tiles, lng=180 → x = floor(360/360 * 2) = floor(2) = 2
// But tile range is 0..1, so this is the "overflow" edge — that's fine
expect(lngToTileX(180, 1)).toBe(2);
});
it('increases with more easterly longitude', () => {
const x1 = lngToTileX(0, 10);
const x2 = lngToTileX(10, 10);
expect(x2).toBeGreaterThan(x1);
});
});
describe('latToTileY', () => {
it('returns smaller y for higher latitude (north = top)', () => {
const yNorth = latToTileY(60, 10);
const ySouth = latToTileY(10, 10);
expect(yNorth).toBeLessThan(ySouth);
});
it('equator is roughly half the tile grid', () => {
const yEq = latToTileY(0, 1);
// zoom 1 → 2 rows, equator ≈ row 1
expect(yEq).toBe(1);
});
});
// ── URL building ───────────────────────────────────────────────────────────────
describe('buildTileUrl', () => {
it('replaces {z}, {x}, {y}, {r} correctly', () => {
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
const url = buildTileUrl(tmpl, 10, 500, 300);
expect(url).toBe('https://tile.example.com/10/500/300.png');
});
it('replaces {s} with a subdomain character', () => {
const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png';
const url = buildTileUrl(tmpl, 10, 0, 0);
expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/);
});
it('removes {r} (retina placeholder)', () => {
const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png';
const url = buildTileUrl(tmpl, 10, 0, 0);
expect(url).toBe('https://tiles.example.com/10/0/0.png');
});
});
// ── countTiles ────────────────────────────────────────────────────────────────
describe('countTiles', () => {
it('returns more tiles at higher zoom levels', () => {
const bbox: TileBbox = { minLat: 48.7, maxLat: 49.0, minLng: 2.2, maxLng: 2.5 };
const low = countTiles(bbox, 10, 10);
const high = countTiles(bbox, 12, 12);
expect(high).toBeGreaterThan(low);
});
it('stops counting after exceeding MAX_TILES', () => {
// Very large bbox — should hit cap quickly at high zooms
const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 };
const count = countTiles(bbox, 10, 16);
expect(count).toBeGreaterThan(MAX_TILES);
});
});
// ── prefetchTiles guards ───────────────────────────────────────────────────────
describe('prefetchTiles — offline guard', () => {
it('returns 0 and does not fetch when offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
expect(count).toBe(0);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('returns 0 when no service worker controller', async () => {
Object.defineProperty(navigator, 'serviceWorker', {
value: { controller: null },
configurable: true,
});
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
expect(count).toBe(0);
});
});
describe('prefetchTiles — normal operation', () => {
it('fetches tiles and returns count', async () => {
const bbox: TileBbox = { minLat: 48.84, maxLat: 48.87, minLng: 2.33, maxLng: 2.37 };
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
expect(count).toBeGreaterThan(0);
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
it('stops at zoom level where cap is exceeded', async () => {
// Use a very small MAX_TILES override by using a huge bbox
const bbox: TileBbox = { minLat: -80, maxLat: 80, minLng: -170, maxLng: 170 };
// This bbox at zoom 10 alone has thousands of tiles — should trigger early stop
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 16);
expect(count).toBeLessThanOrEqual(MAX_TILES);
});
});
// ── prefetchTilesForTrip ──────────────────────────────────────────────────────
describe('prefetchTilesForTrip', () => {
it('no-ops when no places have coordinates', async () => {
const places = [buildPlace({ lat: null, lng: null })];
await prefetchTilesForTrip(1, places);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('updates syncMeta tilesBbox after prefetch', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
const places = [
buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
const meta = await offlineDb.syncMeta.get(1);
expect(meta!.tilesBbox).not.toBeNull();
expect(meta!.tilesBbox).toHaveLength(4);
});
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
// ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
// it. The old guard skipped the whole trip; now we keep what fits.
const places = [
buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
// Previously this skipped entirely; now it prefetches a clamped subset.
const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
const places = [
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
});
// ── cap coherence ───────────────────────────────────────────────────────────────
describe('MAX_TILES budget', () => {
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
expect(MAX_TILES).toBe(12288);
});
});