UNPKG

@chauffleet/expo-custom-map

Version:

Open source custom map library for Expo/React Native. Use your own tiles without Google Maps, Mapbox, or API keys. Created by ChaufFleet.

354 lines (303 loc) 9.7 kB
// src/TileCache.ts import { TileInfo, CacheStats } from './types'; // Import conditionnel d'AsyncStorage let AsyncStorage: any; try { AsyncStorage = require('@react-native-async-storage/async-storage').default; } catch (error) { // AsyncStorage n'est pas disponible (environnement Node.js, etc.) console.warn('AsyncStorage not available, cache will be memory-only'); AsyncStorage = undefined; } interface CachedTile { url: string; data: string; size: number; timestamp: number; accessCount: number; lastAccess: number; } export class TileCache { private cache = new Map<string, CachedTile>(); private maxSizeMB: number; private maxAge: number; private persistent: boolean; private storagePrefix = '@expo-custom-map/tiles/'; constructor(maxSizeMB: number = 100, maxAge: number = 7 * 24 * 60 * 60 * 1000, persistent: boolean = true) { this.maxSizeMB = maxSizeMB; this.maxAge = maxAge; // Vérifier si nous sommes dans un environnement React Native const isReactNativeEnvironment = typeof window !== 'undefined' && (window as any).navigator?.product === 'ReactNative'; this.persistent = persistent && isReactNativeEnvironment; if (this.persistent) { this.loadFromStorage().catch((error) => { console.warn('Erreur lors du chargement du cache de tuiles:', error); // En cas d'erreur, désactiver la persistance pour cette session this.persistent = false; }); } } /** * Créer un cache en mode mémoire uniquement (sans persistance) * Utile pour les environnements de test ou non-React Native */ static createMemoryOnlyCache(maxSizeMB: number = 100, maxAge: number = 7 * 24 * 60 * 60 * 1000): TileCache { return new TileCache(maxSizeMB, maxAge, false); } /** * Générer une clé de cache pour une tuile */ private getTileKey(x: number, y: number, z: number): string { return `${z}-${x}-${y}`; } /** * Calculer la taille actuelle du cache en MB */ private getCurrentSizeMB(): number { let totalSize = 0; for (const tile of this.cache.values()) { totalSize += tile.size; } return totalSize / (1024 * 1024); } /** * Nettoyer le cache selon la politique LRU */ private cleanup(): void { const now = Date.now(); const maxSizeBytes = this.maxSizeMB * 1024 * 1024; // Supprimer les tuiles expirées for (const [key, tile] of this.cache.entries()) { if (now - tile.timestamp > this.maxAge) { this.cache.delete(key); if (this.persistent) { AsyncStorage.removeItem(this.storagePrefix + key).catch(() => {}); } } } // Si toujours trop gros, supprimer les moins récemment utilisées let currentSize = 0; for (const tile of this.cache.values()) { currentSize += tile.size; } if (currentSize > maxSizeBytes) { const sortedTiles = Array.from(this.cache.entries()).sort( ([, a], [, b]) => a.lastAccess - b.lastAccess ); for (const [key, tile] of sortedTiles) { this.cache.delete(key); currentSize -= tile.size; if (this.persistent) { AsyncStorage.removeItem(this.storagePrefix + key).catch(() => {}); } if (currentSize <= maxSizeBytes * 0.8) { // Nettoyer jusqu'à 80% pour éviter le thrashing break; } } } } /** * Charger le cache depuis le stockage persistant */ private async loadFromStorage(): Promise<void> { if (!this.persistent) { return; } try { // Vérifier si AsyncStorage est disponible if (typeof AsyncStorage === 'undefined') { throw new Error('AsyncStorage is not available'); } const keys = await AsyncStorage.getAllKeys(); const tileKeys = keys.filter((key: string) => key.startsWith(this.storagePrefix)); const items = await AsyncStorage.multiGet(tileKeys); for (const [key, value] of items) { if (value) { try { const cachedTile: CachedTile = JSON.parse(value); const tileKey = key.replace(this.storagePrefix, ''); // Vérifier si la tuile n'est pas expirée if (Date.now() - cachedTile.timestamp <= this.maxAge) { this.cache.set(tileKey, cachedTile); } else { // Supprimer la tuile expirée AsyncStorage.removeItem(key).catch(() => {}); } } catch (error) { // Supprimer les données corrompues AsyncStorage.removeItem(key).catch(() => {}); } } } } catch (error) { console.warn('Erreur lors du chargement du cache de tuiles:', error); } } /** * Sauvegarder une tuile dans le stockage persistant */ private async saveToStorage(key: string, tile: CachedTile): Promise<void> { if (!this.persistent) return; try { // Vérifier si AsyncStorage est disponible if (typeof AsyncStorage === 'undefined') { return; } await AsyncStorage.setItem( this.storagePrefix + key, JSON.stringify(tile) ); } catch (error) { console.warn('Erreur lors de la sauvegarde de la tuile:', error); } } /** * Ajouter une tuile au cache */ async set(tile: TileInfo, data: string): Promise<void> { const key = this.getTileKey(tile.x, tile.y, tile.z); const size = data.length; const now = Date.now(); const cachedTile: CachedTile = { url: tile.url, data, size, timestamp: now, accessCount: 1, lastAccess: now, }; this.cache.set(key, cachedTile); // Sauvegarder de manière asynchrone if (this.persistent) { this.saveToStorage(key, cachedTile).catch(() => {}); } // Nettoyer si nécessaire this.cleanup(); } /** * Récupérer une tuile du cache */ get(x: number, y: number, z: number): string | null { const key = this.getTileKey(x, y, z); const tile = this.cache.get(key); if (tile) { // Mettre à jour les statistiques d'accès tile.accessCount++; tile.lastAccess = Date.now(); return tile.data; } return null; } /** * Vérifier si une tuile existe dans le cache */ has(x: number, y: number, z: number): boolean { const key = this.getTileKey(x, y, z); return this.cache.has(key); } /** * Supprimer une tuile du cache */ async delete(x: number, y: number, z: number): Promise<void> { const key = this.getTileKey(x, y, z); this.cache.delete(key); if (this.persistent) { try { await AsyncStorage.removeItem(this.storagePrefix + key); } catch (error) { console.warn('Erreur lors de la suppression de la tuile:', error); } } } /** * Vider tout le cache */ async clear(): Promise<void> { this.cache.clear(); if (this.persistent) { try { const keys = await AsyncStorage.getAllKeys(); const tileKeys = keys.filter((key: string) => key.startsWith(this.storagePrefix)); await AsyncStorage.multiRemove(tileKeys); } catch (error) { console.warn('Erreur lors de la suppression du cache:', error); } } } /** * Obtenir les statistiques du cache */ getStats(): CacheStats { const currentSizeMB = this.getCurrentSizeMB(); let totalRequests = 0; let totalHits = 0; for (const tile of this.cache.values()) { totalRequests += tile.accessCount; totalHits += tile.accessCount; } return { size: this.cache.size, currentSizeMB: Math.round(currentSizeMB * 100) / 100, maxSizeMB: this.maxSizeMB, hitRate: totalRequests > 0 ? totalHits / totalRequests : 0, }; } /** * Définir la taille maximale du cache */ setMaxSize(maxSizeMB: number): void { this.maxSizeMB = maxSizeMB; this.cleanup(); } /** * Définir l'âge maximum des tuiles */ setMaxAge(maxAge: number): void { this.maxAge = maxAge; this.cleanup(); } /** * Précharger une zone de tuiles */ async preloadArea( bounds: { minX: number; maxX: number; minY: number; maxY: number; z: number }, urlTemplate: string, onProgress?: (loaded: number, total: number) => void ): Promise<void> { const { minX, maxX, minY, maxY, z } = bounds; const total = (maxX - minX + 1) * (maxY - minY + 1); let loaded = 0; const promises: Promise<void>[] = []; for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { if (!this.has(x, y, z)) { const url = urlTemplate .replace('{x}', x.toString()) .replace('{y}', y.toString()) .replace('{z}', z.toString()); const promise = fetch(url) .then(response => response.text()) .then(data => { const tile: TileInfo = { x, y, z, url }; return this.set(tile, data); }) .then(() => { loaded++; onProgress?.(loaded, total); }) .catch(error => { console.warn(`Erreur lors du préchargement de la tuile ${x},${y},${z}:`, error); loaded++; onProgress?.(loaded, total); }); promises.push(promise); } else { loaded++; onProgress?.(loaded, total); } } } await Promise.all(promises); } }