@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.
388 lines (329 loc) • 11.6 kB
text/typescript
// src/TilePreloader.ts
import { TileCache } from './TileCache';
import { latLonToTile } from './utils';
export interface TilePreloadOptions {
/** Rayon de préchargement en nombre de tuiles */
radius?: number;
/** Niveaux de zoom à précharger */
zoomLevels?: number[];
/** Délai entre les préchargements (ms) */
delay?: number;
/** Nombre maximum de téléchargements simultanés */
maxConcurrent?: number;
}
export interface PreloadProgress {
/** Nombre total de tuiles à précharger */
total: number;
/** Nombre de tuiles déjà préchargées */
loaded: number;
/** Nombre de tuiles en erreur */
errors: number;
/** Pourcentage de progression */
progress: number;
}
export class TilePreloader {
private cache: TileCache;
private downloadQueue: Set<string> = new Set();
private currentDownloads: Set<string> = new Set();
private maxConcurrentDownloads: number = 4;
private downloadDelay: number = 50;
private isPreloading: boolean = false;
private preloadAbortController?: AbortController;
constructor(cache: TileCache) {
this.cache = cache;
}
/**
* Précharge les tuiles pour une région donnée
*/
async preloadTilesForRegion(
latitude: number,
longitude: number,
zoom: number,
options: TilePreloadOptions = {}
): Promise<PreloadProgress> {
const {
radius = 2,
zoomLevels = [zoom],
delay = this.downloadDelay,
maxConcurrent = this.maxConcurrentDownloads,
} = options;
// Annuler le préchargement précédent
this.cancelPreloading();
this.downloadDelay = delay;
this.maxConcurrentDownloads = maxConcurrent;
this.isPreloading = true;
this.preloadAbortController = new AbortController();
const tilesToPreload = this.calculateTilesToPreload(
latitude,
longitude,
zoomLevels,
radius
);
const progress: PreloadProgress = {
total: tilesToPreload.length,
loaded: 0,
errors: 0,
progress: 0,
};
// Filtrer les tuiles déjà en cache
const tilesToDownload = tilesToPreload.filter((tileKey) => {
const [z, x, y] = tileKey.replace('tile-', '').split('-').map(Number);
return !this.cache.has(x, y, z);
});
progress.total = tilesToDownload.length;
if (tilesToDownload.length === 0) {
progress.progress = 100;
this.isPreloading = false;
return progress;
}
// Ajouter les tuiles à la queue de téléchargement
tilesToDownload.forEach((tileUrl) => {
this.downloadQueue.add(tileUrl);
});
// Démarrer le téléchargement des tuiles
const downloadPromises: Promise<void>[] = [];
for (let i = 0; i < Math.min(this.maxConcurrentDownloads, tilesToDownload.length); i++) {
downloadPromises.push(this.processDownloadQueue(progress));
}
try {
await Promise.all(downloadPromises);
} catch (error) {
console.warn('Erreur lors du préchargement des tuiles:', error);
} finally {
this.isPreloading = false;
this.downloadQueue.clear();
this.currentDownloads.clear();
}
progress.progress = Math.round((progress.loaded / progress.total) * 100);
return progress;
}
/**
* Précharge les tuiles autour d'un centre donné
*/
async preloadTilesAroundCenter(
centerLat: number,
centerLon: number,
zoom: number,
radius: number = 2,
tileUrlTemplate?: string
): Promise<void> {
if (!tileUrlTemplate) {
throw new Error('Template d\'URL de tuile requis pour le préchargement');
}
const tilesToPreload: string[] = [];
const centerTile = latLonToTile(centerLat, centerLon, zoom);
// Générer les URLs des tuiles dans le rayon spécifié
for (let x = centerTile.x - radius; x <= centerTile.x + radius; x++) {
for (let y = centerTile.y - radius; y <= centerTile.y + radius; y++) {
if (x >= 0 && y >= 0 && x < Math.pow(2, zoom) && y < Math.pow(2, zoom)) {
const tileUrl = this.buildTileUrl(tileUrlTemplate, x, y, zoom);
tilesToPreload.push(tileUrl);
}
}
}
// Précharger les tuiles en parallèle avec limitation
const promises: Promise<void>[] = [];
const semaphore = this.createSemaphore(this.maxConcurrentDownloads);
for (const tileUrl of tilesToPreload) {
const [z, x, y] = tileUrl.replace('tile-', '').split('-').map(Number);
if (!this.cache.has(x, y, z)) {
promises.push(
semaphore.acquire().then(async (release) => {
try {
await this.downloadAndCacheTile(tileUrl);
} finally {
release();
}
})
);
}
}
await Promise.all(promises);
}
/**
* Précharge les tuiles le long d'un itinéraire
*/
async preloadTilesForRoute(
coordinates: [number, number][],
zoom: number,
corridor: number = 1000, // corridor en mètres
tileUrlTemplate?: string
): Promise<void> {
if (!tileUrlTemplate) {
throw new Error('Template d\'URL de tuile requis pour le préchargement');
}
const tilesToPreload = new Set<string>();
// Pour chaque segment de l'itinéraire
for (let i = 0; i < coordinates.length - 1; i++) {
const start = coordinates[i];
const end = coordinates[i + 1];
// Calculer les points le long du segment
const distance = this.calculateDistance(start, end);
const steps = Math.ceil(distance / 100); // un point tous les 100 mètres
for (let step = 0; step <= steps; step++) {
const ratio = step / steps;
const lat = start[1] + (end[1] - start[1]) * ratio;
const lon = start[0] + (end[0] - start[0]) * ratio;
// Calculer le rayon en tuiles basé sur le corridor
const metersPerTile = (40075017 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, zoom + 8);
const radiusInTiles = Math.ceil(corridor / metersPerTile);
// Ajouter les tuiles dans le corridor
const centerTile = latLonToTile(lat, lon, zoom);
for (let x = centerTile.x - radiusInTiles; x <= centerTile.x + radiusInTiles; x++) {
for (let y = centerTile.y - radiusInTiles; y <= centerTile.y + radiusInTiles; y++) {
if (x >= 0 && y >= 0 && x < Math.pow(2, zoom) && y < Math.pow(2, zoom)) {
const tileUrl = this.buildTileUrl(tileUrlTemplate, x, y, zoom);
tilesToPreload.add(tileUrl);
}
}
}
}
}
// Précharger toutes les tuiles collectées
const promises = Array.from(tilesToPreload)
.filter(tileUrl => {
const [z, x, y] = tileUrl.replace('tile-', '').split('-').map(Number);
return !this.cache.has(x, y, z);
})
.map(tileUrl => this.downloadAndCacheTile(tileUrl));
await Promise.all(promises);
}
/**
* Annule le préchargement en cours
*/
cancelPreloading(): void {
if (this.preloadAbortController) {
this.preloadAbortController.abort();
}
this.isPreloading = false;
this.downloadQueue.clear();
this.currentDownloads.clear();
}
/**
* Vérifie si le préchargement est en cours
*/
isCurrentlyPreloading(): boolean {
return this.isPreloading;
}
/**
* Obtient le nombre de tuiles en queue de téléchargement
*/
getQueueSize(): number {
return this.downloadQueue.size;
}
// Méthodes privées
private calculateTilesToPreload(
latitude: number,
longitude: number,
zoomLevels: number[],
radius: number
): string[] {
const tiles: string[] = [];
zoomLevels.forEach((zoom) => {
const centerTile = latLonToTile(latitude, longitude, zoom);
for (let x = centerTile.x - radius; x <= centerTile.x + radius; x++) {
for (let y = centerTile.y - radius; y <= centerTile.y + radius; y++) {
if (x >= 0 && y >= 0 && x < Math.pow(2, zoom) && y < Math.pow(2, zoom)) {
const tileUrl = `tile-${zoom}-${x}-${y}`;
tiles.push(tileUrl);
}
}
}
});
return tiles;
}
private async processDownloadQueue(progress: PreloadProgress): Promise<void> {
while (this.downloadQueue.size > 0 && this.isPreloading) {
const tileUrl = this.downloadQueue.values().next().value;
if (!tileUrl) break;
this.downloadQueue.delete(tileUrl);
if (this.currentDownloads.has(tileUrl)) {
continue;
}
this.currentDownloads.add(tileUrl);
try {
await this.downloadAndCacheTile(tileUrl);
progress.loaded++;
} catch (error) {
progress.errors++;
console.warn(`Erreur de téléchargement pour la tuile ${tileUrl}:`, error);
} finally {
this.currentDownloads.delete(tileUrl);
}
// Délai entre les téléchargements
if (this.downloadDelay > 0) {
await new Promise(resolve => setTimeout(resolve, this.downloadDelay));
}
// Vérifier si le préchargement a été annulé
if (this.preloadAbortController?.signal.aborted) {
break;
}
}
}
private async downloadAndCacheTile(tileUrl: string): Promise<void> {
try {
// Simuler le téléchargement d'une tuile
// Dans une vraie implémentation, ceci ferait un fetch vers l'URL de la tuile
const response = await fetch(tileUrl, {
signal: this.preloadAbortController?.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const tileData = await response.text();
// Extraire les coordonnées de la tuile depuis l'URL
const [z, x, y] = tileUrl.replace('tile-', '').split('-').map(Number);
// Stocker dans le cache
await this.cache.set({ x, y, z, url: tileUrl }, tileData);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return; // Opération annulée
}
throw error;
}
}
private buildTileUrl(template: string, x: number, y: number, z: number): string {
return template
.replace('{x}', x.toString())
.replace('{y}', y.toString())
.replace('{z}', z.toString())
.replace('{s}', ['a', 'b', 'c'][Math.floor(Math.random() * 3)]);
}
private calculateDistance(coord1: [number, number], coord2: [number, number]): number {
const R = 6371000; // Rayon de la Terre en mètres
const lat1Rad = (coord1[1] * Math.PI) / 180;
const lat2Rad = (coord2[1] * Math.PI) / 180;
const deltaLatRad = ((coord2[1] - coord1[1]) * Math.PI) / 180;
const deltaLonRad = ((coord2[0] - coord1[0]) * Math.PI) / 180;
const a =
Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private createSemaphore(maxConcurrent: number) {
let current = 0;
const queue: Array<() => void> = [];
return {
acquire: (): Promise<() => void> => {
return new Promise((resolve) => {
const tryAcquire = () => {
if (current < maxConcurrent) {
current++;
resolve(() => {
current--;
if (queue.length > 0) {
const next = queue.shift();
next?.();
}
});
} else {
queue.push(tryAcquire);
}
};
tryAcquire();
});
},
};
}
}