OdoKeep est sur l’App Store. Téléchargez-la gratuitement.

Tous les articles

Données géographiques

Remplacer les requêtes géospatiales par des fichiers JSON et Cloudflare R2

Une grille partagée, des fichiers JSON sur Cloudflare R2 et un cache qui distingue les données manquantes d’une carte vide.

Mis à jour le

Dans cet article

Chaque utilisateur qui cherchait du carburant à proximité dans OdoKeep posait une question différente aux mêmes données.

La position changeait, ainsi que le rayon et le carburant. La liste des stations restait commune à tous et était reconstruite chaque jour.

Cela suffisait pour revoir l'endroit où exécuter la recherche.

OdoKeep est un carnet de véhicules en React Native. Les prix proviennent de sources nationales au Portugal, en Espagne, en France, en Italie et en Autriche. Le module de tuiles documente environ 46 000 stations, un nombre qui évolue avec les sources. À l'origine, l'application demandait au backend les stations proches et les effectifs par enseigne dans chaque pays.

Aujourd'hui, elle lit des fichiers JSON sur Cloudflare R2 via un domaine public personnalisé. Supabase conserve les données privées du garage. Le processus de publication lit aussi les métadonnées d'attribution et signale son état. Ce qui a disparu, c'est la nécessité de consulter la base pour chaque recherche de prix depuis un téléphone.

Un fichier par pays aurait été simple à publier et à filtrer. Mais une recherche locale ne devrait pas télécharger toutes les stations du pays. Il fallait partitionner selon l'usage de l'écran.

Une grille commune au téléphone et au publicateur

Le découpage utilise des cellules d'un demi-degré. lib/fuel-prices/tiles.ts fournit le calcul partagé :

const TILE_DEGREES = 0.5;

function tileKey(lat: number, lon: number): string {
  return `${Math.floor(lat / TILE_DEGREES)}_${Math.floor(lon / TILE_DEGREES)}`;
}

Lisbonne, à 38.72, -9.14, tombe dans 77_-19. Avec une coordonnée négative, Math.floor compte : tronquer placerait les points à l'ouest de Greenwich dans la mauvaise cellule.

L'organisation publiée tient en quelques lignes :

fuel/
  index.json
  sources.json
  brands/
    PT.json
    ES.json
    ...
  tiles/
    77_-19.json
    77_-18.json
    ...

Une tuile contient les coordonnées, l'identité, l'adresse, les prix, les horaires fournis et les dates de mise à jour des stations. Elle ne contient pas leur distance à l'utilisateur. La distance appartient à une recherche, pas à une station.

Charger un rectangle, puis filtrer par distance

Le client calcule les cellules couvrant le rectangle englobant le cercle recherché. L'amplitude de latitude vient approximativement du rayon divisé par les kilomètres par degré. Celle de longitude est corrigée par le cosinus de la latitude, car les degrés de longitude se resserrent vers le nord.

Le rectangle est volontairement un peu large. Une tuile d'angle peut être chargée sans contenir de station dans le cercle. Après lecture, la distance haversine élimine les stations hors rayon et trie les autres. Le calcul garde le même rayon terrestre que la requête remplacée.

Le nombre de fichiers justifie la taille choisie. Un demi-degré mesure environ 56 km de haut, avec une largeur moindre aux latitudes couvertes. Le rayon par défaut de 10 km représente 20 km de diamètre. Les tests échantillonnent la zone documentée et vérifient au plus quatre cellules pour ce rayon, neuf pour le plafond de 30 km.

Le diamètre est facile à oublier. « Une recherche de 30 km tient dans une cellule de 56 km » semble raisonnable jusqu'à ce qu'on dessine le cercle.

Une grille plus grossière réduirait le maximum de requêtes, mais imposerait de gros fichiers aux recherches ordinaires. Ici, les grands rayons demandent davantage de requêtes pour que les petits puissent utiliser de petits morceaux.

L'index réduit le coût des zones vides. Il ne liste que les cellules occupées, avec la taille de grille et la date de construction. Une recherche côtière peut écarter les cellules connues comme vides avant de les demander. Le client rejette un index utilisant une autre grille plutôt que de mal interpréter ses clés.

Les effectifs par enseigne sont également calculés une fois par pays. Changer d'enseigne n'exige plus une agrégation nationale en base.

Après chargement, changer de carburant, d'enseigne ou de rayon peut réutiliser les stations déjà présentes. Le réseau n'intervient plus dans chaque réglage de la vue.

Vide et échec ne disent pas la même chose

Le cache considère ses entrées valides pendant six heures. Il garde les données décodées en mémoire, persiste des copies pour les prochains lancements et partage une promesse en cours par clé. Deux consommateurs simultanés utilisent donc la même requête. Si le réseau échoue, une ancienne copie encore exploitable peut servir.

La décision principale a été de donner trois résultats à la récupération :

type TileOutcome =
  | { status: "ok"; stations: TileStation[] }
  | { status: "empty" }
  | { status: "failed" };

Une cellule vide n'est pas une cellule impossible à télécharger.

Supposons une recherche sur deux cellules peuplées. L'une répond, l'autre renvoie un 503 sans copie en cache. Retourner uniquement la première réduirait silencieusement la surface recherchée. Cette liste pourrait ensuite être triée et mise en cache comme une réponse complète.

stationsAround renvoie un échec dès qu'une tuile nécessaire échoue. La couche supérieure peut reprendre une recherche précédente en cache ou expliquer l'indisponibilité. Elle ne transforme pas une partie manquante de la carte en résultat complet.

Le traitement actuel du 404 est plus limité : il devient un résultat vide, mémorisé cinq minutes. Cette courte durée compte pendant la publication, quand une cellule peut être temporairement absente. L'index évite de nombreuses requêtes de ce type, mais un 404 n'offre pas la certitude d'un jeu de données versionné et cohérent.

Publier un ensemble complet, les tuiles avant l'index

Le constructeur TypeScript exige tous les pays configurés et refuse de publier si un adaptateur échoue. Sans cela, une panne nationale deviendrait une nouvelle liste dont un pays aurait disparu. Mieux vaut conserver la dernière construction complète.

La GitHub Action planifiée utilise Bun. Le chemin de génération repose sur des modules intégrés et des sources du projet, sans installer les dépendances de toute l'application mobile. Il produit un répertoire ; son envoi est une étape séparée.

Les JSON sont compressés et servis avec le bon encodage. gzip -n retire l'horodatage de l'en-tête compressé : une même entrée ne change pas de contenu binaire simplement parce qu'elle est compressée un autre jour. Les objets portent une directive de cache public d'une heure.

Les tuiles sont envoyées avant l'index. Publier l'index d'abord annoncerait des clés qu'un envoi interrompu pourrait ne jamais fournir.

Cet ordre ne rend pas le déploiement atomique. Les fichiers sont remplacés sur place ; un client peut voir plusieurs générations à la fois. Le nettoyage peut aussi supprimer une vieille tuile alors qu'un client conserve son ancien index. Pour cette consultation de prix, l'implémentation accepte cette cohérence limitée et la courte durée des absences mémorisées. Une vraie instantanée cohérente demanderait des chemins par génération et un pointeur changé seulement après disponibilité de tous les fichiers.

Dans quels cas cette architecture convient

R2 est intéressant ici parce que sa tarification publiée ne facture pas la bande passante sortante vers internet. Stockage et opérations restent compris dans le modèle tarifaire R2, et la configuration du cache compte toujours. Un domaine personnalisé peut utiliser le cache Cloudflare, comme l'indique la documentation des buckets publics. Je n'ai pas de facture avant et après à joindre : le changement concret est la disparition des lectures et du trafic sortant de base pour ces recherches.

Publier quotidiennement ne rend pas les prix exacts à la minute. L'heure de mise à jour de la source reste séparée, et l'attribution accompagne les données. Les sources nationales ont des rythmes et des conditions de réutilisation différents. Servir un fichier plutôt qu'une ligne n'y change rien.

Le modèle convient à des données publiques, partagées, géographiquement bornées et bien plus souvent lues que modifiées. Je choisirais autrement pour des données privées, une autorisation par utilisateur ou des prix en temps réel dépendant de chaque transaction.

Le téléphone connaissait déjà position, rayon et filtres. Une portion locale réutilisable lui permet de terminer la recherche sans demander sans cesse au serveur de retrouver les mêmes stations.

Vous pouvez essayer ce navigateur de prix dans OdoKeep, à côté du carnet de véhicules qu'il complète. L'application iOS permet de le tester sur vos trajets.