Créer une carte personnalisée avec Leaflet et markerCluster js

Comment créer une carte personnalisée avec le leaflet et le plugin markerCluster ?

Voici un CodePen d’une carte sur mesure utilisant leaflet, le plugin markerCluster.js  awesome-marker.js ainsi que des données GeoJSON.

See the Pen
Leaflet Interactive Map with custom Cluster Click action
by yuyazz (@yuyazz)
on CodePen.

Une carte complexe

Dans cet exemple, j’exploite deux cartes, rangées dans deux tabs. Elle dispose d’un système de filtres personnalisés, d’un panneau latéral dynamique qui permet d’afficher toutes les données GeoJSON au clic sur les marqueurs.

L’autre particularité ici c’est que les clusters ne se subdivisent pas au zoom maximal. La fonction clusters.on(‘clusterclick’, function() nous permet  d’agir directement sur le cluster pour afficher les données sans avoir à afficher les marqueurs. Cela est utile lorsque plusieurs de vos marqueurs possèdent les mêmes coordonnées et qu’on veut éviter la spirale de marqueurs qui peut vite perdre l’utilisateur.

Les données récupérées dans les clusters sont insérées dans une fonction onEachFeature dans laquelle je supprime les données doublons. Cela me permet d’afficher les « features » en accordéon, en affichant une seule fois le nom de l’établissement par exemple.

La coloration des filtres et pictos sont liés au type de « features » sélectionnées.

Une version mobile améliorée

Dans la version mobile, la carte est centrée, les filtres ainsi que le panneau de données dynamiques apparaissent avec un effet de slide au clic sur les marqueurs ou le bouton de filtrage.

Pour réaliser cette carte, je me suis largement inspiré de ce tutoriel en anglais.

La création des données GeoJSON

Dans mon fichier JS, je charge toutes mes données en les structurant de la manière suivante :

var examenData = {


"type": "FeatureCollection",
"features": [

{
"type": "Feature",
"properties": {
"Examen": "ccl18",
"Prelevement": "2 tubes EDTA 4,5 ml (bouchon violet)",
"Laboratoire": "Service d'Hématologie Biologique / Secteur de cultures",
"Chu": "CHU Estaing",
"Adresse": "1 place Raymond et Lucie Aubrac",
"Cp": "63003 Clermont Ferrand Cedex 1",
"Professeur": "Pr Berger Marc",
"Tel": "Tel : 04 73 75 03 68",
"Fax": "Fax : 04 73 75 06 83",
"Mail": "mberger@chu-clermontferrand.fr",
"Url": "http://www.cetl.net",
"Mode": "Tube"
},
"geometry": {
"type": "Point",
"coordinates": [3.108547, 45.7848475]
}
},

//suite des features

]
}

Chaque feature possède un certain nombre de properties que je vais pouvoir exploiter.

Création de la carte

J’instancie bien sûr mes deux cartes en utilisant une tuile cartoDb. Vous trouverez toutes les tuiles ici.

 var cartoDb = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
minZoom: 6,
maxZoom: 18
});
var cartoDb2 = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
minZoom: 6,
maxZoom: 18
});

//et plus bas dans le code
var map = L.map('map')// ici l'ID du conteneur
.addLayer(cartoDb)
.setView([46.85, 2.3518], 6);
var map2 = L.map('map2')// ici l'ID du deuxième conteneur
.addLayer(cartoDb2)
.setView([46.85, 2.3518], 6);

Création des marqueurs

Comme j’utilise ici le plugin awesome-marker.js, je vais attribuer une couleur à chaque « catégorie » de marqueurs. Mes catégories sont définies par la propriété « Examen » dans mes données GeoJSON.

Ce plugin utilise fontawesome pour l’apparence des icônes, ce qui explique la présence du préfixe. Voici un exemple : on choisit le préfixe de l’icône fontawesome, la couleur de l’icône, celle du marqueur et le type d’icône fontawesome.

  var iduaIcon = L.AwesomeMarkers.icon({
prefix: 'fa',
iconColor: 'white',
markerColor: 'beige',
icon: 'ambulance'
});

Affichage des marqueurs sur la carte

On va ensuite récupérer nos données GeoJSON afin d’afficher nos marqueurs sur la carte, avec la fonction var  L.geoJson(examenData, {}). Pour rappel, examenData est ici le nom de ma variable contenant toutes mes données GeoJSON.

A l’intérieur, on créer notre fonctions filter: elle va nous permettre de cibler précisément la « catégorie » de marqueurs souhaités. Ici par exemple, j’appelle mes marqueurs dont  la « properties » est « examen » et dont la valeur (catégorie) est ccl18.

On appelle ensuite notre fonction onEachFeature : celle-ci agit comme une boucle nous permettant d’aller chercher le contenu de nos marqueurs.

Enfin on créer la fonction pointToLayer qui va placer nos marqueurs sur la carte et grâce à laquelle on choisit l’icône définie plus haut.

/* related filters and makers */
var ccl18 = L.geoJson(examenData, {
filter: function(feature, layer) {
return feature.properties.Examen == "ccl18";
},
onEachFeature: onEachFeature,
pointToLayer: function(feature, latlng) {

return L.marker(latlng, {
icon: ccl18Icon
})
}
})

L’affichage du html dans une div externe

Dans mon projet, plutôt que d’appeler une popup au clic sur le marqueur, je souhaite tout afficher dans une div externe.

Ma fonction onEachFeature contient donc tout le html que je souhaite afficher. En fait il reprend une à une chacune des properties correspondant au marqueur ciblé. Dans cette boucle, le code suivant:

layer.on(‘click’, function() {
$(‘#layer_infos .fill’).html(html);

me permet d’afficher tout mon html grâce à la fonction jquery .html(). J’ai nommé ma variable contenant tout mon html « html » pour faire simple. Voici toute la fonction

    function onEachFeature(feature, layer) {

var html = '';
if (feature.properties.Chu) {
html += '<p class="chu">' + feature.properties.Chu + '</p>';
}
if (feature.properties.Adresse) {
html += '<p class="adress">' + feature.properties.Adresse + '</p>';
}
if (feature.properties.Cp) {
html += '<p class="cp">' + feature.properties.Cp + '</p>';
}
if (feature.properties.Laboratoire) {
html += '<p class="labo">' + feature.properties.Laboratoire + '</p>';
}
if (feature.properties.Professeur) {
html += '<p class="prof">' + feature.properties.Professeur + '</p>';
}
if (feature.properties.Tel) {
html += '<p class="tel">' + feature.properties.Tel + '</p>';
}
if (feature.properties.Fax) {
html += '<p class="fax">' + feature.properties.Fax + '</p>';
}
if (feature.properties.Mail) {
html += '<p class="mail"><a href="mailto:' + feature.properties.Mail + '">' + feature.properties.Mail + '</a></p>';
}
if (feature.properties.Professeur2) {
html += '<p class="prof2">' + feature.properties.Professeur2 + '</p>';
}
if (feature.properties.Tel2) {
html += '<p class="tel">' + feature.properties.Tel2 + '</p>';
}
if (feature.properties.Fax2) {
html += '<p class="fax">' + feature.properties.Fax2 + '</p>';
}
if (feature.properties.Mail2) {
html += '<p class="mail"><a href="mailto:' + feature.properties.Mail2 + '">' + feature.properties.Mail2 + '</a></p>';
}
html += '<div class="pictos">';
if (feature.properties.Examen && feature.properties.Mode =="Tube") {
html += '<span class="tube tube-' + feature.properties.Examen + '"></span>';
html += '<span class="' + feature.properties.Examen + '">' + feature.properties.Examen + '</span>';
}else if(feature.properties.Examen && feature.properties.Mode =="Buvard") {
html += '<span class="buvard buvard-' + feature.properties.Examen + '"></span>';
html += '<span class="' + feature.properties.Examen + '">' + feature.properties.Examen + '</span>';
}else if(feature.properties.Examen){

html += '<span class="circle circle-' + feature.properties.Examen + '"></span>';
html += '<span class="' + feature.properties.Examen + '">' + feature.properties.Examen + '</span>';
}
html += '</div>';
if (feature.properties.Prelevement) {
html += '<p>' + feature.properties.Prelevement + '</p>';
}
if (feature.properties.Envoi) {
html += '<p>' + feature.properties.Envoi + '</p>';
}
if (feature.properties.Renseignement) {
html += '<p class="rt">' + feature.properties.Renseignement + '</p>';
}
if (feature.properties.Url) {
html += '<p class="url"><a href="' + feature.properties.Url + '">' + feature.properties.Url + '</a></p>';
}
if (feature.properties.Doc) {
html += '<p class="doc"><a href="' + feature.properties.Doc + '"><i class="fa fa-file-pdf-o" aria-hidden="true"></i> Télécharger la fiche</a></p>';
}
layer.on('click', function() {
$('#layer_infos .fill').html(html);
if (L.Browser.mobile) {
$('#infos').addClass("slide");
$('#filters').removeClass('slide');
$('.hamburger').text('Sélectionner un examen').fadeIn();
}
})
}

Dans le code final du codePen, vous remarquerez que j’ai volontairement doublé cette fonction pour ma deuxième carte. Je suis obligé de cibler deux ID de div différentes, sinon dans l’inspecteur d’élément, toutes les actions sur la première carte se répercute sur ma deuxième carte. On peut certainement tout condenser, mais au moins comme ça c’est plus clair !

Créer les clusters

Les clusters sont particulièrement utiles lorsque dans une carte, plusieurs marqueurs affichent les mêmes coordonnées. Sans les marqueurs, les marqueurs vont se chevaucher, voir n’afficher que le dernier de la boucle. Grâce aux clusters, on va pouvoir afficher quelque chose de plus lisible avec notamment le nombre de marqueurs contenus à l’intérieur.

  /*cluster for the first map */
var clusters = L.markerClusterGroup({
spiderfyOnMaxZoom: false,
showCoverageOnHover: false,
zoomToBoundsOnClick: true
});

Le plugin markerCluster.js permet d’afficher ce cluster. Au zoom maximal, le cluster se déploie et affiche sous forme de spirale tous les marqueurs contenus à l’intérieur. Le rendu est le suivant :

Cette solution est largement suffisante. Mais si par exemple dans les données GeoJSON la plupart des informations sont communes d’un marqueurs à l’autre, le fait d’avoir deux marqueurs peut vite perdre l’utilisateur. Dans mon projet, mes marqueurs désignent des établissements de soins. J’aurais donc là la même adresse, nom d’hôpital, nom de médecin dans mes deux marqueurs. L’idée est donc de réussir à les regrouper, ou à rendre le cluster directement cliquables, et de n’afficher les données qu’une seule fois.

Rendre les clusters cliquables

Pour rendre le cluster directement cliquable et faire en sorte qu’il ne se subdivise pas en marqueurs, la première chose à faire est d’ajouter les paramètres  :

spiderfyOnMaxZoom: false, :  ici on annule l’effet de toile d’araignée et d’affichage des marqueurs
showCoverageOnHover: false, ici on annule le surlignement en bleu par défaut qui indique la zone de couverture du cluster
zoomToBoundsOnClick: true, ici on garde quand même le zoom automatique au clic sur le cluster

Ensuite, on va utiliser la fonction

clusters.on('clusterclick', function() {})

A l’intérieur on va remettre une fonction onEachFeature, qui va là encore servir de boucle pour remplir tout notre html dans notre div dynamique. Avec

if (a.layer._zoom == 18) {}

je précise qu’une fois le zoom maximal atteint et pas avant, j’affiche mes données.

Comment faire pour ne cibler que les marqueurs contenus dans mon cluster

Au clic sur le cluster, on souhaite n’afficher que les données des marqueurs contenus à l’intérieur du cluster. Vous rencontrerez peut-être le même problème que moi, c’est à dire qu’en cliquant sur le cluster, cela vous affiche les données de tous les marqueurs des la ou les carte. Pour limiter l’affichage aux seuls marqueurs concernés, j’ai procédé de la manière suivante :

On commence par ajouter un paramètre à notre fonction qui va cibler précisément notre cluster

clusters.on('clusterclick', function(a) {})

Ensuite on crée une boucle grâce à :

 for (feat in a.layer._markers) {

On précise que pour chaque marqueur (feat in…_marker) contenu dans la couche (.layer) de notre cluster (a), j’appelle une à une mes properties. Voici la fonction en entier. Je commente encore une fois le code pour expliquer chaque étape.

 clusters.on('clusterclick', function(a) {
if (a.layer._zoom == 18) {


var html = '';
for (feat in a.layer._markers) {

if (a.layer._markers[feat].feature.properties['Chu']) {
var chu = a.layer._markers[feat].feature.properties['Chu'];
if (html.indexOf(chu) == -1) // ici j'enlève les doublons pour n'afficher le nom de l'établissement qu'une seule fois
html += '<p class="chu">' + a.layer._markers[feat].feature.properties['Chu'] + '</p>';
}

if (a.layer._markers[feat].feature.properties['Adresse']) {
var adresse = a.layer._markers[feat].feature.properties['Adresse'];
if (html.indexOf(adresse) == -1)// ici j'enlève les doublons pour n'afficher l'adresse de l'établissement qu'une seule fois
html += '<p class="adress">' + a.layer._markers[feat].feature.properties['Adresse'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Cp']) {
var cp = a.layer._markers[feat].feature.properties['Cp'];
if (html.indexOf(cp) == -1)// ici j'enlève les doublons pour n'afficher le code postal de l'établissement qu'une seule fois
html += '<p class="cp">' + a.layer._markers[feat].feature.properties['Cp'] + '</p>';
}


if (a.layer._markers[feat].feature.properties['Examen'] && a.layer._markers[feat].feature.properties['Mode'] =="Tube") { // ici j'utilise des conditions pour afficher un icône différent en fonction du type d'examen
html += '<div class="pictos"><span class="tube tube-' + a.layer._markers[feat].feature.properties['Examen'] + '"></span><span class="' + a.layer._markers[feat].feature.properties['Examen'] + '">' + a.layer._markers[feat].feature.properties['Examen'] + '</span><i class="fas fa-chevron-down"></i></div>';
}else if (a.layer._markers[feat].feature.properties['Examen'] && a.layer._markers[feat].feature.properties['Mode'] =="Buvard") {
html += '<div class="pictos"><span class="buvard buvard-' + a.layer._markers[feat].feature.properties['Examen'] + '"></span><span class="' + a.layer._markers[feat].feature.properties['Examen'] + '">' + a.layer._markers[feat].feature.properties['Examen'] + '</span><i class="fas fa-chevron-down"></i></div>';
}else if(a.layer._markers[feat].feature.properties['Examen']){
html += '<div class="pictos"><span class="circle-' + a.layer._markers[feat].feature.properties['Examen'] + '"></span><span class="' + a.layer._markers[feat].feature.properties['Examen'] + '">' + a.layer._markers[feat].feature.properties['Examen'] + '</span><i class="fas fa-chevron-down"></i></div>';
}
html += '<div class="pictos-tabs picto-' + a.layer._markers[feat].feature.properties['Examen'] + '">';

// ensuite j'affiche toutes mes données qui peuvent différer d'un marqueur (établissement) à l'autre

if (a.layer._markers[feat].feature.properties['Laboratoire']) {
html += '<p class="labo">' + a.layer._markers[feat].feature.properties['Laboratoire'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Professeur']) {
html += '<p class="prof">' + a.layer._markers[feat].feature.properties['Professeur'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Tel']) {
html += '<p class="tel">' + a.layer._markers[feat].feature.properties['Tel'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Fax']) {
html += '<p class="fax">' + a.layer._markers[feat].feature.properties['Fax'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Mail']) {
html += '<p class="mail"><a href="mailto:' + a.layer._markers[feat].feature.properties['Mail'] + '">' + a.layer._markers[feat].feature.properties['Mail'] + '</a></p>';
}
if (a.layer._markers[feat].feature.properties['Professeur2']) {
html += '<p class="prof2">' + a.layer._markers[feat].feature.properties['Professeur2'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Tel2']) {

html += '<p class="tel">' + a.layer._markers[feat].feature.properties['Tel2'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Fax2']) {
html += '<p class="fax">' + a.layer._markers[feat].feature.properties['Fax2'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Mail2']) {
html += '<p class="mail"><a href="mailto:' + a.layer._markers[feat].feature.properties['Mail2'] + '">' + a.layer._markers[feat].feature.properties['Mail2'] + '</a></p>';
}
if (a.layer._markers[feat].feature.properties['Prelevement']) {
html += '<p>' + a.layer._markers[feat].feature.properties['Prelevement'] + '</p>';
}
if (a.layer._markers[feat].feature.properties['Envoi']) {
html += '<p>' + a.layer._markers[feat].feature.properties['Envoi'] + '</p>';
}

if (a.layer._markers[feat].feature.properties['Url']) {
html += '<p class="url"><a href="' + a.layer._markers[feat].feature.properties['Url'] + '">'+ a.layer._markers[feat].feature.properties['Url'] + '</a></p>';
}
if (a.layer._markers[feat].feature.properties['Doc']) {
html += '<p class="doc"><a target="_blank" href="' + a.layer._markers[feat].feature.properties['Doc'] + '"><i class="fa fa-file-pdf-o" aria-hidden="true"></i> Télécharger la fiche</a></p>';
}
html += '</div>';
}



$('#layer_infos .fill').html(html);
if (L.Browser.mobile) {
$('#infos').addClass("slide");
$('#filters').removeClass('slide');
$('.hamburger').text('Sélectionner un examen').fadeIn();
}
}
})

Créer ses propres fonctions de filtrage

Il me reste enfin à utiliser mes checkbox, qui me servent de filtre pour l’affichage des marqueurs sur la carte.

$("#ccl18").click(function() { //au clic sur la checkbox dont l'ID EST CCL18
if (this.checked) { // si c'est bien celui là qui est cliqué

ccl18.addTo(clusters); // ajoute le sur la carte : on utilise ici addTo(clusters) et non addTo(map) pour que les clusters agissent pour tous les marqueurs.
map.fitBounds(allexamens.getBounds(), { // ici une fonction propre à mon projet : lorsqu'un autre filtre est coché, si je coche celui là, rezoom la carte pour l'afficher en entier
padding: [50, 50]
});



} else {
clusters.removeLayer(ccl18); // si c'est pas cette checkbox ou si je clique sur une autre checkbox, retire ces marqueurs
map.fitBounds(allexamens.getBounds(), { // pareil que précédemment
padding: [50, 50]
});

}
});

 

Vu le nombre de données et de filtres, ajouté au fait que mon projet comprend deux cartes, le code final est extrêmement long ! Prenez ce qui vous semble utile pour créer votre propre carte personnalisée !

Voici le rendu final. Pour une meilleure lisibilité, ouvrez le Codepen en grand !

See the Pen
Leaflet Interactive Map with custom Cluster Click action
by yuyazz (@yuyazz)
on CodePen.