Bonjour @Loue
Dans notre service, nous utilisons une autre solution open-source, https://leafletjs.com/, pour la construction d’outils cartographiques, intégré dans custom widget Grist.
Présentation Leaflet
Leaflet (https://leafletjs.com/) est une bibliothèque JavaScript conçue avec un accent majeur sur la simplicité , la performance et l’ergonomie mobile , elle ne pèse qu’environ 42 Ko. Elle offre toutes les fonctionnalités de base nécessaires à la cartographie moderne tout en restant extrêmement légère, fluide et compatible avec tous les navigateurs de bureau et mobiles (gestion native du tactile).
L’API modulaire de Leaflet offre de nombreuses fonctionnalités
Consulter l’API : Documentation - Leaflet - a JavaScript library for interactive maps
- Chargement et affichage des images de fond de carte provenant de fournisseurs (OpenStreetMap, Mapbox, Stamen, etc.).
- Support WMS (web map service)
- Couches raster & canvas
- Support du format Geojson
- Vecteurs & marqueurs personnalisé
- Composants IHM (popup, tooltip, contrôle de carte, gestion des events carto…)
De plus, L’API de Leaflet a été pensée pour être minimaliste mais extensible. Ainsi pour des besoins avancés, sa structure orientée objet permet d’ajouter des plugins tiers qui étendent l’API
Consulter les plugins : Plugins - Leaflet - a JavaScript library for interactive maps)
Exemple d’utilisation de Leaflet dans un custom widget Grist
Quelques captures illustrant un cas d’usage, d’une cartographie interactive sur la composition de l’éducation prioritaire (réseaux REP/REP+ de l’Académie d’Orléans-Tours). La carte affiche à la fois les têtes de réseau collèges et leurs écoles rattachées.
Guide technique
Étape 1 : Chargement de la librairie (HTML, JS & CSS)
Pour implémenter Leaflet, il faut d’abord créer une instance de carte attachée à un conteneur HTML, généralement une simple div dans le DOM <div id="mapCanvas">, pour ensuite on peut charger un fond de carte, comme par exemple des tuiles officielles de l’IGN.
On commence par charger la librairie depuis un CDN
<!-- Chargement des dépendances Leaflet -->
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
</head>
<!-- Ajout d'un conteneur div pour la carte-->
<body>
<div class="chart-card" id="mapCard">
<div id="mapCanvas"></div>
</div>
</body>
Étape 2 : Initialiser la carte et le fond de plan
C’est ici que Leaflet s’active. On crée l’instance de la carte, on définit les coordonnées géographiques et le niveau de zoom de départ, pour ensuite y ajouter un fond de carte (ici, on utilise un fond de carte de l’IGN fourni par leur service WTMS)
// Variable globale pour stocker l'instance de la carte Leaflet
let map = null;
function initMap(containerId) {
// Instanciation de la carte
map = L.map(containerId, { preferCanvas: true }).setView([47.4, 1.7], 8);
// Ajout du fond de carte depuis le service WMTS de l'IGN
L.tileLayer('https://data.geopf.fr/wmts?' +
'REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&STYLE=normal' +
'&TILEMATRIXSET=PM&FORMAT=image/png' +
'&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2' +
'&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}', {
minZoom: 0,
maxZoom: 19,
attribution: 'Carte © <a href="https://www.ign.fr/">IGN</a>',
className: 'smooth-gray-tile', // Hack CSS pour passer en fond gris
opacity: 0.9
}).addTo(map);
}
// Appel de la fonction une fois le DOM chargé
document.addEventListener('DOMContentLoaded', () => {
initMap('mapCanvas');
});
Et voila le hack style CSS pour passage du fond de carte en gris
.smooth-gray-tile {
filter: grayscale(100%) opacity(70%) brightness(120%);
}
Étape 3 : Organiser les éléments graphiques (marqueurs, lignes) avec les FeatureGroups
Pour éviter que la carte devienne un chaos ingérable lors des rafraîchissements de filtres, on crée des conteneurs virtuels appelés FeatureGroup. Ils permettent de vider ou manipuler un ensemble de marqueurs par itération de boucle.
let markersGroup = null;
let linesGroup = null;
function setupLayers() {
// On crée les groupes et on les attache directement à la carte
markersGroup = L.featureGroup().addTo(map);
linesGroup = L.featureGroup().addTo(map);
}
function clearMap() {
// Pratique pour réinitialiser la carte avant un nouveau filtre
markersGroup.clearLayers();
linesGroup.clearLayers();
}
Étape 4 : Dessiner des marqueurs personnalisées pour chaque structure et des liaisons entre collège (tête de réseau) et écoles rattachées
Au lieu d’utiliser les traditionnelles épingles bleues par défaut, on va utiliser des L.divIcon : des marqueurs générés à partir de simples classes CSS (ronds ou carrés de couleur selon le type de réseau REP/REP+). Puis on trace des lignes (L.polyline) pour relier visuellement chaque école à son collège de rattachement.
Pour ce faire, on va placer les marqueurs en utilisant des L.divIcon pour personnaliser l’aspect visuel via des classes CSS. Chaque marqueur sera stocker la collection markersGroup. Puis on trace les liaisons avec L.polyline, et chaque ligne sera stocker dans une autre collection linesGroup.
function drawData(collegesList, schoolsList) {
clearMap(); // Nettoyage des anciens calques
const bounds = []; // Stocker toutes les coordonnées pour ajuster le zoom
// Dessiner les têtes de réseau (collèges)
collegesList.forEach(college => {
const coords = [parseFloat(college.geo.Y), parseFloat(college.geo.X)];
bounds.push(coords);
const collegeIcon = L.divIcon({
className: 'college',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
// Création du marqueur avec Popup d'information
L.marker(coords, { icon: collegeIcon })
.bindPopup(`<div class="popup-title">🏫 ${college.nom}</div>`)
.addTo(markersGroup);
});
// Dessiner les Écoles et tracer les lignes de liaison
schoolsList.forEach(school => {
const schoolCoords = [parseFloat(school.geo.Y), parseFloat(school.geo.X)];
bounds.push(schoolCoords);
const schoolIcon = L.divIcon({
className: 'school',
iconSize: [10, 10],
iconAnchor: [5, 5]
});
L.marker(schoolCoords, { icon: schoolIcon })
.bindPopup(`<div class="popup-title">🎓 ${school.nom}</div>`)
.addTo(markersGroup);
// Si l'école possède un collège cible, on trace une ligne pointillée
if (school.collegeCoordsTarget) {
L.polyline([schoolCoords, school.collegeCoordsTarget], {
color: '#FF8A26',
weight: 1.5,
dashArray: '4, 4',
opacity: 0.95
}).addTo(linesGroup);
}
});
// Ajustement automatique du zoom pour englober tout le monde
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [30, 30] });
}
}
Étape 5 : Injecter des couches de frontières dynamiques (GeoJSON) selon le niveau de zoom de l’utilisateur
Pour donner du contexte géographique (comme les limites de communes ou départements), on charge des fichiers GeoJSON de manière asynchrone. L’astuce ici est de changer de frontière selon le niveau de zoom de l’utilisateur.
Autrement dit, pour rendre la carte vivante, on charge des données géométriques au format GeoJSON (les contours des départements et des communes) en fonction du niveau de zoom de l’utilisateur pour adapter le niveau de détail.
- Avec un zoom important; on affiche les frontières des communes depuis la source :
https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions/centre-val-de-loire/communes-centre-val-de-loire.geojson
- Avec un zoom faible, on affiche les frontières des départements depuis la source :
https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions/centre-val-de-loire/departements-centre-val-de-loire.geojson
Voila un exemple de code pour charger dynamiquement un layer Geojson dans une carte.
let geoJsonLayer = null;
// Seuil de bascule
const ZOOM_THRESHOLD = 12;
// Frontières des départements (GeoJson)
const URL_GEOJSON_DEPT = 'https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions/centre-val-de-loire/departements-centre-val-de-loire.geojson';
// Frontières des communes (GeoJson)
const URL_GEOJSON_COMMUNES = 'https://raw.githubusercontent.com/gregoiredavid/france-geojson/master/regions/centre-val-de-loire/communes-centre-val-de-loire.geojson';
async function updateBorders() {
const currentZoom = map.getZoom();
// Si zoom élevé -> Communes, sinon -> Départements
const url = (currentZoom >= ZOOM_THRESHOLD)
? URL_GEOJSON_COMMUNES
: URL_GEOJSON_DEPT ;
// Suppression de l'ancienne couche si elle existe
if (geoJsonLayer) map.removeLayer(geoJsonLayer);
try {
const response = await fetch(url);
const geoJSONData = await response.json();
// Ajout de la couche GeoJSON stylisée sur la carte
geoJsonLayer = L.geoJSON(geoJSONData, {
style: {
color: "#666",
weight: (targetType === 'communes') ? 2 : 2.5,
opacity: 0.85,
fillOpacity: 0.055
},
interactive: false // Stop event click propagation
}).addTo(map);
// Assure que les marqueurs & lignes restent au-dessus des frontières
linesGroup.bringToFront();
markersGroup.bringToFront();
} catch (error) {
console.error("Erreur de chargement des frontières GeoJSON :", error);
}
}
// Écouter le changement de zoom pour mettre à jour les frontières
map.on('zoomend', updateBorders);
Étape 6 : Ajouter une commande d’action personnalisé : exemple « Back to Home »
Par défaut, Leaflet propose les boutons pour ajuster le niveau de zoom . Pour ajouter vos propres commandes, comme le bouton de retour à la vue initiale ou un menu d’export, il faut créer un composant de contrôle personnalisé en étendant la classe L.Control.
L.Control.HomeButton = L.Control.extend({
onAdd: function () {
// Création d'un conteneur avec les classes CSS Leaflet
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-home');
container.innerHTML = `
<button class="btn-home-toggle" id="btnHomeMap" title="Vue globale de la région">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
</button>`;
// Gestion de la propagation du clic
L.DomEvent.disableClickPropagation(container);
return container;
}
});
Une fois le composant créé, il suffit de l’ajouter dans l’une des quatre zones prévues par Leaflet (topleft, topright, bottomleft, bottomright) lors de l’initialisation de la carte.
function initMap(containerId) {
// ... initialisation de la carte ...
// Ajout du bouton personnalisé en haut à gauche
new L.Control.HomeButton({ position: 'topleft' }).addTo(map);
}
Maintenant que le bouton est physiquement présent dans le DOM, l’orchestrateur de votre application va lier un écouteur d’événement click à l’identifiant du bouton (#btnHomeMap) pour modifier les coordonnées de la carte.
function addHomeButtonListener() {
const btnHome = document.getElementById('btnHomeMap');
if (btnHome) {
btnHome.addEventListener('click', () => {
// map est l'instance globale de votre carte Leaflet
if (map) {
// setView repositionne la carte aux coordonnées [Latitude, Longitude] initiales et au niveau de zoom souhaité
map.setView([47.4, 1.7], 8);
}
});
}
}
Et de quoi donner du style à vos boutons.
/* Espacement pour détacher le bouton du contrôle de zoom supérieur */
.leaflet-control-home {
margin-top: 12px !important;
margin-left: 10px !important;
}
/* Style du bouton interne */
.btn-home-toggle {
background: #ffffff;
border: none;
border-radius: 4px; /* Correspond au radius standard de Leaflet */
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #111827;
transition: background 0.2s ease;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
}
/* Effet de survol */
.btn-home-toggle:hover {
background: #f4f5f7;
}
Étape 7 : Ajouter une commande pour exporter la carte en image ou PDF
Pour réaliser cette export, on va utiliser la solution html2canvas et jspdf, que l’on charge dans la page html depuis un CDN.
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
On se donne une fonction chargée de « photographier » la carte. Son rôle est de préparer le terrain pour qu’aucun bouton d’interface (zoom, menu) ne soit présent pendant l’export.
async function _captureCanvas() {
// 1. Sélection des éléments d'interface à masquer temporairement
const zoomControl = document.querySelector('.leaflet-control-zoom');
const homeControl = document.querySelector('.leaflet-control-home');
const menuControl = document.querySelector('.custom-map-menu');
const legendControl = document.querySelector('.leaflet-control-legend');
if (zoomControl) zoomControl.style.display = 'none';
if (homeControl) homeControl.style.display = 'none';
if (menuControl) menuControl.style.display = 'none';
// Fixer la taille de la légende pour éviter les sauts de ligne intempestifs sur la photo
let originalLegendStyle = '';
if (legendControl && !legendControl.classList.contains('legend-hidden')) {
originalLegendStyle = legendControl.style.cssText;
legendControl.style.width = '240px';
legendControl.style.maxWidth = 'none';
legendControl.style.minWidth = '240px';
}
try {
const mapInstance = MapManager.getMapInstance();
if (mapInstance) {
mapInstance.invalidateSize({ animate: false });
}
await new Promise(resolve => setTimeout(resolve, 800));
const canvas = await html2canvas(DOM.mapCard, {
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
backgroundColor: '#ffffff'
});
return canvas;
} finally {
if (zoomControl) zoomControl.style.display = '';
if (homeControl) homeControl.style.display = '';
if (menuControl) menuControl.style.display = '';
if (legendControl && originalLegendStyle) {
legendControl.style.cssText = originalLegendStyle;
}
}
}
Voila la fonction pour générer et télécharger l’image au format PNG
async function _exportToPng() {
try {
const canvas = await _captureCanvas();
const link = document.createElement('a');
link.download = `carte-education-prioritaire-${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
} catch (error) {
console.error('Erreur export PNG:', error);
}
}
Et la fonction pour un export au format PDF
async function _exportToPdf() {
try {
const canvas = await _captureCanvas();
const imgData = canvas.toDataURL('image/png');
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('l', 'mm', 'a4');
pdf.text("Ma carte", 10, 12);
pdf.addImage(imgData, 'PNG', 10, 18, 277, 182);
pdf.save(`carte-education-prioritaire-${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (error) {
console.error('Erreur export PDF:', error);
}
}
Il suffit maintenant d’ajouter un contrôle de menu, mapMenuDropdown, pour ajouter ces actions dans la carte. Dans cet exemple, on ajoute 4 commandes dans le menu, dont celles d’export en image et en pdf. Lors de l’initialisation de votre carte, vous instanciez ce nouveau menu en choisissant son coin d’ancrage (par exemple, en haut à droite).
L.Control.MapMenu = L.Control.extend({
onAdd: function () {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-map-menu');
container.innerHTML = `
<button class="menu-toggle" id="btnMapMenu" title="Menu cartographique">
⚙️
</button>
<div class="menu-dropdown" id="mapMenuDropdown">
<button id="btnToggleLegend">👁️ Masquer la légende</button>
<button id="btnToggleGrayscale">🌈 Désactiver le filtre gris</button>
<hr>
<button id="btnExportPngMap">📷 Exporter en Image (PNG)</button>
<button id="btnExportPdfMap">📄 Exporter en PDF (A4)</button>
</div>
`;
L.DomEvent.disableClickPropagation(container);
return container;
}
});
function initMap() {
// ... initialisation classique de votre carte map ...
// Ajout du menu déroulant en haut à droite
new L.Control.MapMenu({ position: 'topright' }).addTo(map);
}
function addMenuListeners() {
const btnMapMenu = document.getElementById('btnMapMenu');
const mapMenuDropdown = document.getElementById('mapMenuDropdown');
const btnExportPngMap = document.getElementById('btnExportPngMap');
const btnExportPdfMap = document.getElementById('btnExportPdfMap');
if (btnMapMenu && mapMenuDropdown) {
btnMapMenu.addEventListener('click', () => {
mapMenuDropdown.classList.toggle('is-open');
});
}
if (btnExportPngMap) {
btnExportPngMap.addEventListener('click', () => {
mapMenuDropdown.classList.remove('is-open');
setTimeout(_exportToPng, 150);
});
}
if (btnExportPdfMap) {
btnExportPdfMap.addEventListener('click', () => {
mapMenuDropdown.classList.remove('is-open');
setTimeout(_exportToPdf, 150);
});
}
}
Et le style CSS pour l’effet dropdown
/* Conteneur du menu */
.custom-map-menu {
position: relative;
}
/* Le panneau déroulant (Masqué par défaut) */
.menu-dropdown {
display: none; /* Cacher le menu */
position: absolute;
top: 42px;
right: 0;
background: #ffffff;
border: 1px solid #e4e7ec;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.07);
padding: 6px;
flex-direction: column;
gap: 2px;
min-width: 240px;
z-index: 1000;
}
/* La classe magique activée par le JavaScript (.is-open) */
.menu-dropdown.is-open {
display: flex; /* Afficher le menu sous forme de liste */
}
/* Style des boutons internes du menu */
.menu-dropdown button {
background: none;
border: none;
text-align: left;
padding: 8px 12px;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
color: #111827;
transition: background 0.2s ease;
}
.menu-dropdown button:hover {
background: #f4f5f7;
}
Enjoy !