L'essentiel d'une appli carto, c'est toujours la même plomberie. Et si on la décrivait au lieu de la recoder ?
À chaque nouvelle carte interactive, le même rituel : recâbler la gestion des couches, la recherche, le panneau de détail, l'état dans l'URL, les fonds de carte, la légende, les pages légales… Du code déjà écrit, que je recopiais puis retouchais à chaque fois. Dès la deuxième appli, j'en ai fait une librairie config-driven : leaflet-atlas. On lui passe un objet de configuration, elle fabrique l'application.
Leaflet en deux mots. Leaflet est une librairie JavaScript open source de cartographie interactive, légère et sans dépendance. Elle affiche des fonds de carte (tuiles), des marqueurs, des popups et des données GeoJSON, et laisse à l'application le soin d'assembler le reste. C'est cette base que
leaflet-atlashabille d'une couche de configuration.
Le constat : une plomberie qui se répète d'une carte à l'autre
Décomposons une appli carto typique. Sous la carte elle-même, on retrouve presque toujours les mêmes briques :
- les couches : fonds de carte interchangeables, groupes de données activables ;
- la recherche : un champ qui filtre les entités sur une ou plusieurs propriétés ;
- le détail : cliquer sur une entité ouvre un panneau ou une popup formatée ;
- l'état dans l'URL : un hash qui permet de partager une vue précise ;
- la légende et les styles : couleurs par catégorie, symbologie (icônes/formes selon le type d'entité) ;
- les pages légales : mentions, attributions, licences des données.
Ces briques reviennent à chaque projet, avec le même coût : du temps de développement et de maintenance. La vraie spécificité d'une carte est rarement dans cette plomberie, mais dans quelles données, quels styles, quelles interactions métier.
L'approche config-driven
L'idée de leaflet-atlas : décrire l'application plutôt que la coder. On instancie une MapApp avec un objet qui déclare chaque brique :
import { MapApp } from 'leaflet-atlas';
new MapApp({
map, // centre, zoom, options Leaflet
baseLayers, // fonds de carte disponibles
layerGroups, // groupes de données activables
styles, // symbologie par couche / catégorie
searchableProps, // propriétés indexées pour la recherche
tooltips, // contenu des infobulles
detailBuilders, // rendu du panneau de détail par type d'entité
legalPages, // mentions, attributions
shortcuts, // raccourcis clavier
onReady, // hook d'échappement (voir plus bas)
});

Tout ce qui était de la plomberie devient une propriété déclarative. Ajouter une couche, c'est une entrée dans layerGroups ; rendre une propriété cherchable, c'est l'ajouter à searchableProps. Le code de l'application se réduit à sa configuration, lisible, versionnable, sans logique d'orchestration à maintenir dans l'application (elle vit dans la librairie).
Voici la configuration minimale qui affiche une carte avec une couche GeoJSON (le conteneur HTML est un simple <div id="map"></div>) :
import { MapApp } from 'leaflet-atlas';
new MapApp({
map: { elementId: 'map', center: [46.6, 2.5], zoom: 6 },
baseLayers: {
OpenStreetMap: {
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
options: { attribution: '© OpenStreetMap contributors' },
},
},
layerGroups: [
{
group: 'Points d\'intérêt',
layers: [
{ id: 'cities', label: 'Villes', file: 'data/cities.geojson', active: true },
],
},
],
styles: {
cities: { radius: 6, color: '#e63946', fillColor: '#e63946', fillOpacity: 0.8 },
},
});
Trois options illustrent ensuite le principe :
detailBuilders: une fonction qui reçoit un objethelperset renvoie, pour chaque couche, un builder. Ce builder transforme lespropertiesd'une entité en panneau de détail. On décrit le rendu, pas le câblage de l'événement de clic ni l'ouverture du panneau.
detailBuilders: (helpers) => ({
cities: (props) =>
helpers.buildDetail(props.name, 'cities', [
{
label: 'Général',
rows: [
['Pays', props.country],
['Population', props.population?.toLocaleString()],
].filter(([, v]) => v != null),
},
]),
}),
Le helper buildDetail échappe automatiquement les chaînes des lignes, ce qui évite d'injecter du HTML venu des données.
Deux autres options suivent la même logique :
searchableProps: la liste des propriétés indexées. La barre de recherche, son filtrage et la mise en évidence des résultats sont fournis ; on déclare juste sur quoi chercher.legalPages: les mentions et attributions des données (Licence Ouverte, la licence de réutilisation des données publiques, source…), affichées dans un panneau dédié, ce qui demande sinon du code spécifique.
L'état dans l'URL, gratuitement
leaflet-atlas synchronise la vue (centre, zoom), les couches actives, le fond de carte et l'entité sélectionnée dans le hash de l'URL, sous la forme #lat=50.63000&lng=3.06000&z=12&sel=cities,3 (les couches et le fond n'y figurent que s'ils diffèrent des valeurs par défaut). Partager une carte, c'est alors copier un lien : l'autre arrive exactement sur la même vue. Cette fonctionnalité demande du code d'orchestration que l'on met souvent de côté dans un projet maison. En config-driven, elle est active par défaut, sans code supplémentaire à écrire.
Quand la config ne suffit pas : le hook onReady
Une librairie « tout en config » ne peut pas anticiper tous les besoins réels. Reste à savoir comment s'en sortir quand un cas non prévu se présente.
Exemple concret avec ChronoMEL, mon application qui affiche 90 ans d'orthophotos (des photographies aériennes redressées) de la Métropole Européenne de Lille. leaflet-atlas ne gère nativement que des fonds XYZ (des tuiles d'images adressées par niveau de zoom, colonne et ligne : {z}/{x}/{y}). Or ChronoMEL affiche des orthophotos servies en WMS (Web Map Service, un protocole où le serveur génère l'image de la carte à la demande), reprojetées à la volée par GeoServer, un serveur cartographique, vers la projection de Leaflet. Aucune option de config ne couvrait ce cas. Sans porte de sortie, il aurait fallu forker la librairie, exactement ce que l'approche déclarative était censée éviter.
La parade : le hook onReady(app), appelé une fois toutes les couches chargées, qui donne accès à l'instance Leaflet sous-jacente :
import L from 'leaflet';
onReady: (app) => {
const map = app.getMap(); // l'instance L.Map de Leaflet
// tout ce que la config ne couvre pas :
L.tileLayer.wms(GEOSERVER_URL, { layers: '1930_ortho', version: '1.3.0' }) // GEOSERVER_URL : l'adresse du serveur WMS
.setZIndex(250) // au-dessus du fond de carte, sous les couches vectorielles
.addTo(map);
// + contrôles temporels, frise, raccourcis spécifiques…
}
La config gère ce qui est commun ; le hook ouvre ce qui est spécifique sans casser le modèle. ChronoMEL a pu rester une application de leaflet-atlas au lieu de devenir un fork.
Le hook a aussi servi, sur ChronoMEL, à brancher le comparateur à rideau vendorisé (copié et adapté directement dans le dépôt plutôt qu'installé comme dépendance), la frise temporelle et les repères datés et les raccourcis clavier spécifiques, toute la couche « chrono » qui fait la singularité du projet, posée par-dessus la base déclarative sans la modifier.
Le pattern « framework + escape hatch »
Ce hook illustre un pattern de conception classique : les frameworks déclaratifs proposent souvent une porte de sortie impérative, une échappatoire. On la retrouve dans les ref de React pour toucher au DOM, dans unsafe en Rust ou dans les requêtes SQL brutes d'un ORM. Le principe est constant : couvrir le cas général par la déclaration, mais ne jamais enfermer l'utilisateur quand il sort du cas général.

Chaque extrême a son coût. Le tout-config est rigide : dès qu'un besoin sort du cadre prévu, on est bloqué, ou condamné à forker. Le tout-code est répétitif : on réécrit la plomberie à chaque fois, c'est le problème de départ. Le bon dosage, c'est le déclaratif par défaut et l'impératif accessible : la config pour ce qui se ressemble, le hook pour ce qui est unique. Concevoir une librairie, c'est en grande partie placer correctement cette frontière.
Pourquoi une librairie, et pas un simple template ?
La tentation, face à de la plomberie répétée, est de fabriquer un boilerplate : un dépôt de départ qu'on clone et qu'on adapte. C'est une bonne base de départ, mais elle règle le problème une fois et le rouvre à chaque évolution.
Un template est figé au moment du clone : une correction de bug dans la gestion des couches ne profitera jamais aux cartes déjà créées, et chaque projet diverge un peu plus de la base commune. Au fil des cartes, on obtient autant de plomberies légèrement différentes, exactement le point de départ, en pire.
Une librairie versionnée inverse la logique : la plomberie vit à un seul endroit, les corrections et améliorations se propagent par une montée de version, et les cartes restent de la pure configuration. Le revers : une montée de version peut aussi apporter des changements d'API, surtout tant que la librairie reste en 0.x. Le code spécifique à chaque projet se limite à ce qui est réellement spécifique.
C'est le même raisonnement que pour n'importe quelle dépendance : on extrait ce qui est commun, stable et réutilisable, et on l'isole derrière une API claire. On retrouve la même logique côté backend, avec factoriser plutôt que recopier du CRUD.
Limites et roadmap
leaflet-atlas est encore jeune : au moment de l'écriture, la dernière version est la 0.2.0, et l'API peut évoluer d'une version 0.x à l'autre. Côté périmètre, les couches sont des fichiers GeoJSON chargés dans le navigateur et les fonds gérés nativement sont des fonds XYZ ; le reste passe par onReady. La surface de config s'élargit au fil des besoins rencontrés. Les prochains chantiers, sans échéance fixée : un support WMS de première classe (pour éviter de passer par onReady sur le cas rencontré avec ChronoMEL), davantage de fonds vectoriels, et l'enrichissement de la documentation des detailBuilders.
Essayer
npm install leaflet-atlas leaflet suffit pour démarrer (leaflet s'installe à côté), puis on importe MapApp et les feuilles de style (leaflet-atlas/css et leaflet/dist/leaflet.css). Une balise <script> en CDN fonctionne aussi : le README donne les URL et la librairie s'expose alors via le global LeafletAtlas. Pour démarrer sans repartir de zéro, le template geopages-template fournit un projet Vite/TypeScript prêt à l'emploi, qui s'appuie sur leaflet-atlas et leaflet : cloner, npm install, npm run dev, déclarer ses couches dans src/config.ts, et déployer sur GitHub Pages.
Contribuer
Le code de leaflet-atlas est sur github.com/rlespinasse/leaflet-atlas, avec un guide de contribution qui explique comment lancer le projet en local, en mode watch, lancer les tests et le lint. Une remarque, une issue, une PR sur un des chantiers listés ci-dessus : tout est bon à prendre.
Conclusion

Recoder la même plomberie à chaque carte, c'est du temps qu'on ne passe pas sur ce qui compte : les données et leur lecture. leaflet-atlas déplace cet effort vers une configuration déclarative, versionnée une fois pour toutes au lieu d'être reclonée à chaque projet, et le hook onReady évite de se retrouver coincé hors du cadre. Décrire plutôt que recoder, sans renoncer à reprendre la main quand il le faut : le même arbitrage vaut pour n'importe quelle librairie qui se veut déclarative.
L'arbitrage suivant est du même ordre, un cran plus bas : garder une micro-dépendance ou la réécrire.