Aller au contenu

« MediaWiki:Carte-osm.js » : différence entre les versions

De WikiColombes
Cartes OpenStreetMap : script des modeles Carte OSM et Point carte (Leaflet 1.9.4 heberge sur le serveur du wiki)
 
Cartes : outil Deplacer pour les utilisateurs connectes (fiche du lieu, clic sur la carte, enregistrement dans le wikicode)
Ligne 18 : Ligne 18 :
  * Fond de carte : tuiles OpenStreetMap standard, attribution obligatoire.
  * Fond de carte : tuiles OpenStreetMap standard, attribution obligatoire.
  * Styles : bloc « Cartes OpenStreetMap » de MediaWiki:Common.css.
  * Styles : bloc « Cartes OpenStreetMap » de MediaWiki:Common.css.
*
* Les utilisateurs connectés peuvent déplacer un lieu depuis sa fiche (bouton
* « Déplacer ») : le script réécrit alors les coordonnées de son {{Point carte}}.
  */
  */
( function () {
( function () {
Ligne 123 : Ligne 126 :


function lirePoints( racine ) {
function lirePoints( racine ) {
var points = [], sections = [], index = {}, courante = null;
var points = [], sections = [], index = {}, courante = null, rangPoint = 0;
racine.querySelectorAll( 'h2, h3, .point-carte' ).forEach( function ( el ) {
racine.querySelectorAll( 'h2, h3, .point-carte' ).forEach( function ( el ) {
if ( !el.classList.contains( 'point-carte' ) ) {
if ( !el.classList.contains( 'point-carte' ) ) {
Ligne 129 : Ligne 132 :
return;
return;
}
}
var lat = parseFloat( el.getAttribute( 'data-lat' ) ),
var rangIci = rangPoint++,    // n-ième {{Point carte}} de la page, valide ou non
lat = parseFloat( el.getAttribute( 'data-lat' ) ),
lon = parseFloat( el.getAttribute( 'data-lon' ) );
lon = parseFloat( el.getAttribute( 'data-lon' ) );
if ( isNaN( lat ) || isNaN( lon ) ) {
if ( isNaN( lat ) || isNaN( lon ) ) {
Ligne 147 : Ligne 151 :
adresse: el.getAttribute( 'data-adresse' ) || '',
adresse: el.getAttribute( 'data-adresse' ) || '',
section: index[ cle ],
section: index[ cle ],
el: el
el: el,
rang: rangIci
};
};
p.nom = lireNom( li, el );
p.nom = lireNom( li, el );
Ligne 165 : Ligne 170 :
conteneur.classList.add( 'carte-osm-prete' );
conteneur.classList.add( 'carte-osm-prete' );
conteneur.innerHTML = '';
conteneur.innerHTML = '';
var cfg = mw.config.get( [ 'wgUserName', 'wgIsProbablyEditable', 'wgAction', 'wgRevisionId', 'wgCurRevisionId', 'wgPageName', 'wgScript' ] ),
peutDeplacer = !!cfg.wgUserName && !!cfg.wgIsProbablyEditable && cfg.wgAction === 'view' &&
!!cfg.wgRevisionId && cfg.wgRevisionId === cfg.wgCurRevisionId;


/* légende et filtres, au-dessus de la carte */
/* légende et filtres, au-dessus de la carte */
Ligne 258 : Ligne 267 :
'<span class="carte-osm-nom">' + nom + '</span>' +
'<span class="carte-osm-nom">' + nom + '</span>' +
'<span class="carte-osm-detail">Enseignement ' + p.statut +
'<span class="carte-osm-detail">Enseignement ' + p.statut +
( p.adresse ? ' · ' + echapper( p.adresse ) : '' ) + '</span></span></li>';
( p.adresse ? ' · ' + echapper( p.adresse ) : '' ) + '</span>' +
( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-rang="' + p.rang + '">Déplacer</button>' : '' ) +
'</span></li>';
} );
} );
html += '</ul>';
html += '</ul>';
Ligne 357 : Ligne 368 :
} );
} );
}
}
e.popup.getElement().querySelectorAll( '.carte-osm-deplacer' ).forEach( function ( b ) {
b.addEventListener( 'click', function () {
var vise = Number( b.getAttribute( 'data-rang' ) );
g.membres.forEach( function ( p ) {
if ( p.rang === vise ) {
commencerDeplacement( p );
}
} );
} );
} );
} );
} );
} );
} );
Ligne 362 : Ligne 383 :


carte.on( 'zoomend', dessiner );
carte.on( 'zoomend', dessiner );
/* ---------- déplacer un lieu (utilisateurs connectés) ----------
  « Déplacer » dans la fiche d'un lieu, puis un clic sur la carte à l'endroit
  voulu ; le repère provisoire s'ajuste à la souris avant d'enregistrer.
  L'enregistrement réécrit les coordonnées du n-ième {{Point carte}} du
  wikicode : c'est une modification ordinaire de la page (historique,
  résumé, annulation possible). Si la page a changé depuis son affichage,
  rien n'est écrit. */
var bandeau = null, enCours = null;
function afficherBandeau( html ) {
if ( !bandeau ) {
bandeau = L.DomUtil.create( 'div', 'carte-osm-bandeau', fond );
bandeau.setAttribute( 'role', 'status' );
L.DomEvent.disableClickPropagation( bandeau );
L.DomEvent.disableScrollPropagation( bandeau );
}
bandeau.innerHTML = html;
bandeau.hidden = false;
var fermer = bandeau.querySelector( '.carte-osm-annuler' );
if ( fermer ) {
fermer.addEventListener( 'click', annuler );
}
return bandeau;
}
function arreterDeplacement() {
if ( enCours && enCours.repere ) {
carte.removeLayer( enCours.repere );
}
carte.off( 'click', viser );
fond.classList.remove( 'carte-osm-visee' );
enCours = null;
}
function annuler() {
arreterDeplacement();
if ( bandeau ) {
bandeau.hidden = true;
}
}
function commencerDeplacement( p ) {
arreterDeplacement();
carte.closePopup();
enCours = { p: p, repere: null };
fond.classList.add( 'carte-osm-visee' );
afficherBandeau( 'Cliquez sur la carte à l’emplacement de <b>' + echapper( nomComplet( p ) ) + '</b>. ' +
'<button type="button" class="carte-osm-annuler">Annuler</button>' );
carte.on( 'click', viser );
}
function viser( e ) {
if ( !enCours ) {
return;
}
carte.off( 'click', viser );
fond.classList.remove( 'carte-osm-visee' );
var p = enCours.p;
enCours.repere = L.marker( e.latlng, {
draggable: true,
zIndexOffset: 1000,
title: 'Nouvelle position : ' + nomComplet( p ),
icon: L.divIcon( {
className: 'carte-osm-icone',
html: '<span class="carte-osm-marque carte-osm-nouvelle">' + pastillesDe( p ) + '</span>',
iconSize: null
} )
} ).addTo( carte );
afficherBandeau( 'Nouvelle position de <b>' + echapper( nomComplet( p ) ) + '</b>&nbsp;: ' +
'<span class="carte-osm-coord"></span> ' +
'<span class="carte-osm-aide">— faites glisser le repère pour l’ajuster.</span> ' +
'<button type="button" class="carte-osm-enregistrer">Enregistrer</button> ' +
'<button type="button" class="carte-osm-annuler">Annuler</button>' );
function coord() {
var ll = enCours.repere.getLatLng();
bandeau.querySelector( '.carte-osm-coord' ).textContent = ll.lat.toFixed( 6 ) + ', ' + ll.lng.toFixed( 6 );
}
coord();
enCours.repere.on( 'drag dragend', coord );
bandeau.querySelector( '.carte-osm-enregistrer' ).addEventListener( 'click', enregistrer );
}
function enregistrer() {
var p = enCours.p,
ll = enCours.repere.getLatLng(),
lat = ll.lat.toFixed( 6 ),
lon = ll.lng.toFixed( 6 ),
ancienLat = parseFloat( p.el.getAttribute( 'data-lat' ) ),
ancienLon = parseFloat( p.el.getAttribute( 'data-lon' ) );
enCours.repere.dragging.disable();
afficherBandeau( 'Enregistrement…' );
mw.loader.using( 'mediawiki.api' ).then( function () {
return new mw.Api().edit( cfg.wgPageName, function ( revision ) {
var n = 0, ok = false;
var texte = revision.content.replace( /(\{\{\s*[Pp]oint[ _]carte\s*\|)([^|}]*)\|([^|}]*)/g,
function ( tout, debut, la, lo ) {
if ( n++ !== p.rang ) {
return tout;
}
// garde-fou : le modèle visé doit porter les coordonnées affichées
if ( Math.abs( parseFloat( la ) - ancienLat ) > 1e-7 || Math.abs( parseFloat( lo ) - ancienLon ) > 1e-7 ) {
return tout;
}
ok = true;
return debut + lat + '|' + lon;
} );
if ( !ok ) {
return $.Deferred().reject( 'carte-decalee' ).promise();
}
return {
text: texte,
summary: 'Carte : nouvelle position pour ' + nomComplet( p ) + ' (' + lat + ', ' + lon + ')',
minor: true
};
} );
} ).then( function () {
p.lat = parseFloat( lat );
p.lon = parseFloat( lon );
p.el.setAttribute( 'data-lat', lat );
p.el.setAttribute( 'data-lon', lon );
limites = L.latLngBounds( donnees.points.map( function ( q ) {
return [ q.lat, q.lon ];
} ) );
arreterDeplacement();
dessiner();
afficherBandeau( '✓ Position de <b>' + echapper( nomComplet( p ) ) + '</b> enregistrée. ' +
'<a href="' + echapper( cfg.wgScript + '?title=' + encodeURIComponent( cfg.wgPageName ) + '&action=history' ) + '">Historique</a> ' +
'<button type="button" class="carte-osm-annuler">Fermer</button>' );
}, function ( code ) {
var message = code === 'carte-decalee' ?
'La page a changé depuis son affichage : rechargez-la, puis recommencez.' :
'L’enregistrement a échoué (' + echapper( code ) + '). Les coordonnées peuvent aussi se corriger dans le wikicode.';
if ( enCours && enCours.repere ) {
enCours.repere.dragging.enable();
}
afficherBandeau( message + ' <button type="button" class="carte-osm-annuler">Fermer</button>' );
} );
}
if ( peutDeplacer ) {
document.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Escape' && enCours ) {
annuler();
}
} );
}


/* ---------- « situer sur la carte » depuis la liste ---------- */
/* ---------- « situer sur la carte » depuis la liste ---------- */

Version du 29 septembre 2026 à 10:29

/* Cartes OpenStreetMap de WikiColombes.
 *
 * Mode d'emploi, côté wikicode :
 *   - {{Carte OSM}} place la carte dans la page ;
 *   - {{Point carte|latitude|longitude|niveau=…|statut=…|adresse=…}}, posé à côté
 *     d'un nom dans une liste, y ajoute ce lieu.
 * Le script lit tous les points de la page, les range sous le titre de section
 * (niveau 3, à défaut 2) qui les précède, et fabrique la légende à partir de ces
 * titres. Ajouter une ligne à la liste suffit donc à l'ajouter à la carte.
 *
 * Niveaux : M maternelle, É élémentaire, P primaire (couleur des écoles),
 * C collège, L lycée ; plusieurs niveaux se séparent par des espaces (« P C L »).
 * Sans niveau, le lieu reçoit une pastille neutre : la carte sert aussi hors écoles.
 * statut=privé dessine une pastille carrée au lieu d'une ronde.
 *
 * Bibliothèque : Leaflet 1.9.4, hébergée sur le serveur du wiki
 * (/ressources/leaflet/1.9.4/) pour ne dépendre d'aucun CDN tiers.
 * Fond de carte : tuiles OpenStreetMap standard, attribution obligatoire.
 * Styles : bloc « Cartes OpenStreetMap » de MediaWiki:Common.css.
 *
 * Les utilisateurs connectés peuvent déplacer un lieu depuis sa fiche (bouton
 * « Déplacer ») : le script réécrit alors les coordonnées de son {{Point carte}}.
 */
( function () {
	'use strict';

	if ( mw.carteOsm ) {
		return;                 // déjà chargé (aperçu de modification, etc.)
	}
	mw.carteOsm = {};

	var LEAFLET = '/ressources/leaflet/1.9.4/';
	var TUILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
	var ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">les contributeurs d’OpenStreetMap</a>';

	var NIVEAUX = {
		M: { nom: 'École maternelle', famille: 'ecole' },
		'É': { nom: 'École élémentaire', famille: 'ecole' },
		P: { nom: 'École primaire', famille: 'ecole' },
		C: { nom: 'Collège', famille: 'college' },
		L: { nom: 'Lycée', famille: 'lycee' },
		'•': { nom: 'Lieu', famille: 'lieu' }       // point sans niveau : pastille neutre
	};
	var ORDRE = [ 'M', 'É', 'P', 'C', 'L', '•' ];
	var HAUTEUR_PX = 26;        // hauteur d'un marqueur (pastille de 22 px + liseré)
	var MARGE_PX = 3;           // écart minimal entre deux marqueurs voisins
	var reduit = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;

	/* ---------- utilitaires ---------- */

	function echapper( s ) {
		return String( s ).replace( /[&<>"']/g, function ( c ) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
		} );
	}

	function lireNiveaux( texte ) {
		var vus = [];
		String( texte || '' ).toUpperCase().split( /[\s,;\/]+/ ).forEach( function ( n ) {
			if ( n === 'E' ) {
				n = 'É';
			}
			if ( NIVEAUX[ n ] && vus.indexOf( n ) === -1 ) {
				vus.push( n );
			}
		} );
		vus.sort( function ( a, b ) {
			return ORDRE.indexOf( a ) - ORDRE.indexOf( b );
		} );
		return vus.length ? vus : [ '•' ];
	}

	function pastille( niveau, statut ) {
		return '<span class="carte-osm-pastille carte-osm-' + NIVEAUX[ niveau ].famille +
			( statut === 'privé' ? ' carte-osm-prive' : '' ) + '" aria-hidden="true">' + niveau + '</span>';
	}

	function pastillesDe( p ) {
		return p.niveaux.map( function ( n ) {
			return pastille( n, p.statut );
		} ).join( '' );
	}

	function texteTitre( h ) {
		var cible = h.querySelector( '.mw-headline' ) || h;
		return cible.textContent.replace( /\s*\([^)]*\)\s*$/, '' ).trim();
	}

	/* Le nom d'un lieu : la page liée (titre complet), sinon le texte de la ligne. */
	function lireNom( li, el ) {
		var liens = li ? li.querySelectorAll( 'a' ) : [],
			lien = null, i;
		for ( i = 0; i < liens.length; i++ ) {
			if ( !liens[ i ].closest( '.reference, .point-carte-voir, .mw-cite-backlink' ) ) {
				lien = liens[ i ];
				break;
			}
		}
		if ( lien ) {
			return {
				court: lien.textContent.trim(),
				complet: ( lien.getAttribute( 'title' ) || lien.textContent )
					.replace( /\s*\(page inexistante\)$/, '' ).trim(),
				lien: lien.getAttribute( 'href' ),
				rouge: lien.classList.contains( 'new' )
			};
		}
		var copie = ( li || el.parentNode ).cloneNode( true );
		copie.querySelectorAll( 'sup, ul, ol, .point-carte-voir' ).forEach( function ( x ) {
			x.remove();
		} );
		var texte = copie.textContent.replace( /\s*\(.*$/, '' ).trim();
		return { court: texte, complet: texte, lien: null, rouge: false };
	}

	/* Sans lien, « Guy de Maupassant » devient « Lycée Guy de Maupassant ». */
	function nomComplet( p ) {
		if ( p.nom.lien || p.niveaux[ 0 ] === '•' ||
			/^(École|Ecole|Collège|Lycée|Groupe|Institut|Cité)\b/i.test( p.nom.complet ) ) {
			return p.nom.complet;
		}
		return NIVEAUX[ p.niveaux[ p.niveaux.length - 1 ] ].nom + ' ' + p.nom.complet;
	}

	/* ---------- lecture de la page ---------- */

	function lirePoints( racine ) {
		var points = [], sections = [], index = {}, courante = null, rangPoint = 0;
		racine.querySelectorAll( 'h2, h3, .point-carte' ).forEach( function ( el ) {
			if ( !el.classList.contains( 'point-carte' ) ) {
				courante = texteTitre( el );
				return;
			}
			var rangIci = rangPoint++,     // n-ième {{Point carte}} de la page, valide ou non
				lat = parseFloat( el.getAttribute( 'data-lat' ) ),
				lon = parseFloat( el.getAttribute( 'data-lon' ) );
			if ( isNaN( lat ) || isNaN( lon ) ) {
				return;
			}
			var cle = courante || 'Autres lieux',
				li = el.closest( 'li' );
			if ( !index[ cle ] ) {
				index[ cle ] = { nom: cle, points: [], actif: true };
				sections.push( index[ cle ] );
			}
			var p = {
				lat: lat,
				lon: lon,
				niveaux: lireNiveaux( el.getAttribute( 'data-niveau' ) ),
				statut: /priv/i.test( el.getAttribute( 'data-statut' ) || '' ) ? 'privé' : 'public',
				adresse: el.getAttribute( 'data-adresse' ) || '',
				section: index[ cle ],
				el: el,
				rang: rangIci
			};
			p.nom = lireNom( li, el );
			index[ cle ].points.push( p );
			points.push( p );
		} );
		return { points: points, sections: sections };
	}

	/* ---------- construction d'une carte ---------- */

	function construire( conteneur, racine ) {
		var donnees = lirePoints( racine );
		if ( !donnees.points.length ) {
			return;
		}
		conteneur.classList.add( 'carte-osm-prete' );
		conteneur.innerHTML = '';

		var cfg = mw.config.get( [ 'wgUserName', 'wgIsProbablyEditable', 'wgAction', 'wgRevisionId', 'wgCurRevisionId', 'wgPageName', 'wgScript' ] ),
			peutDeplacer = !!cfg.wgUserName && !!cfg.wgIsProbablyEditable && cfg.wgAction === 'view' &&
				!!cfg.wgRevisionId && cfg.wgRevisionId === cfg.wgCurRevisionId;

		/* légende et filtres, au-dessus de la carte */
		var legende = document.createElement( 'div' );
		legende.className = 'carte-osm-legende';
		legende.setAttribute( 'role', 'group' );
		legende.setAttribute( 'aria-label', 'Lieux affichés sur la carte' );
		donnees.sections.forEach( function ( s ) {
			var etiquette = document.createElement( 'label' ),
				caseACocher = document.createElement( 'input' );
			caseACocher.type = 'checkbox';
			caseACocher.checked = true;
			caseACocher.setAttribute( 'aria-label', s.nom + ' (' + s.points.length + ')' );
			caseACocher.addEventListener( 'change', function () {
				s.actif = caseACocher.checked;
				dessiner();
			} );
			s.caseACocher = caseACocher;
			etiquette.appendChild( caseACocher );
			etiquette.insertAdjacentHTML( 'beforeend',
				'<span class="carte-osm-puces">' + pastillesDe( s.points[ 0 ] ) + '</span>' +
				'<span>' + echapper( s.nom ) + '</span>' +
				'<span class="carte-osm-compte">' + s.points.length + '</span>' );
			legende.appendChild( etiquette );
		} );

		var fond = document.createElement( 'div' );
		fond.className = 'carte-osm-fond';
		fond.setAttribute( 'aria-label', conteneur.getAttribute( 'data-titre' ) || 'Carte' );
		conteneur.appendChild( legende );
		conteneur.appendChild( fond );

		var carte = L.map( fond, {
			zoomControl: false,
			scrollWheelZoom: false,       // la molette fait défiler la page tant qu'on n'a pas cliqué la carte
			minZoom: 12,
			maxZoom: 19,
			zoomAnimation: !reduit,
			fadeAnimation: !reduit,
			markerZoomAnimation: !reduit
		} );
		L.tileLayer( TUILES, { maxZoom: 19, attribution: ATTRIBUTION } ).addTo( carte );
		carte.attributionControl.setPrefix( '<a href="https://leafletjs.com/">Leaflet</a>' );
		L.control.zoom( { zoomInTitle: 'Zoom avant', zoomOutTitle: 'Zoom arrière' } ).addTo( carte );

		var limites = L.latLngBounds( donnees.points.map( function ( p ) {
			return [ p.lat, p.lon ];
		} ) );
		function cadrer() {
			carte.fitBounds( limites, { padding: [ 24, 24 ] } );
		}

		var ToutVoir = L.Control.extend( {
			options: { position: 'topleft' },
			onAdd: function () {
				var bouton = L.DomUtil.create( 'button', 'carte-osm-toutvoir' );
				bouton.type = 'button';
				bouton.textContent = 'Tout voir';
				bouton.title = 'Revenir à la vue d’ensemble';
				L.DomEvent.disableClickPropagation( bouton );
				L.DomEvent.on( bouton, 'click', cadrer );
				return bouton;
			}
		} );
		new ToutVoir().addTo( carte );

		carte.on( 'focus click', function () {
			carte.scrollWheelZoom.enable();
		} );
		carte.on( 'blur mouseout', function () {
			carte.scrollWheelZoom.disable();
		} );

		/* ---------- regroupement des lieux trop proches à l'échelle affichée ---------- */

		var calque = L.layerGroup().addTo( carte ),
			groupes = [];

		function rang( p ) {
			return ORDRE.indexOf( p.niveaux[ 0 ] ) * 2 + ( p.statut === 'privé' ? 1 : 0 );
		}

		function bulle( g ) {
			var html = '<div class="carte-osm-bulle"><ul>';
			g.membres.slice().sort( function ( a, b ) {
				return rang( a ) - rang( b ) || nomComplet( a ).localeCompare( nomComplet( b ), 'fr' );
			} ).forEach( function ( p ) {
				var nom = echapper( nomComplet( p ) );
				if ( p.nom.lien ) {
					nom = '<a href="' + echapper( p.nom.lien ) + '"' + ( p.nom.rouge ? ' class="new"' : '' ) + '>' + nom + '</a>';
				}
				html += '<li><span class="carte-osm-puces">' + pastillesDe( p ) + '</span><span>' +
					'<span class="carte-osm-nom">' + nom + '</span>' +
					'<span class="carte-osm-detail">Enseignement ' + p.statut +
					( p.adresse ? ' · ' + echapper( p.adresse ) : '' ) + '</span>' +
					( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-rang="' + p.rang + '">Déplacer</button>' : '' ) +
					'</span></li>';
			} );
			html += '</ul>';
			if ( g.etendu ) {
				html += '<button type="button" class="carte-osm-zoom">Zoomer sur ces ' + g.membres.length + ' lieux</button>';
			}
			return html + '</div>';
		}

		/* Un groupe = les lieux qui se chevaucheraient à l'écran. Son marqueur aligne
		   une pastille par niveau présent, d'où une largeur variable : on compare
		   donc les boîtes réelles des marqueurs, pas seulement leurs centres, et on
		   fusionne tant qu'il reste un chevauchement. */
		function majGroupe( g ) {
			var x = 0, y = 0, vues = {};
			g.membres.forEach( function ( p ) {
				x += p.px.x / g.membres.length;
				y += p.px.y / g.membres.length;
			} );
			g.px = L.point( x, y );
			g.puces = [];
			ORDRE.forEach( function ( n ) {
				[ 'public', 'privé' ].forEach( function ( st ) {
					g.membres.forEach( function ( p ) {
						if ( p.statut === st && p.niveaux.indexOf( n ) !== -1 && !vues[ n + st ] ) {
							vues[ n + st ] = true;
							g.puces.push( pastille( n, st ) );
						}
					} );
				} );
			} );
			g.largeur = g.puces.length * 24 + 2;
		}

		function chevauchent( a, b ) {
			return Math.abs( a.px.x - b.px.x ) < ( a.largeur + b.largeur ) / 2 + MARGE_PX &&
				Math.abs( a.px.y - b.px.y ) < HAUTEUR_PX + MARGE_PX;
		}

		function dessiner() {
			calque.clearLayers();
			groupes = donnees.points.filter( function ( p ) {
				return p.section.actif;
			} ).map( function ( p ) {
				p.px = carte.latLngToLayerPoint( [ p.lat, p.lon ] );
				var g = { membres: [ p ] };
				majGroupe( g );
				return g;
			} );
			var i, j, fusion = true;
			while ( fusion ) {
				fusion = false;
				for ( i = 0; i < groupes.length && !fusion; i++ ) {
					for ( j = i + 1; j < groupes.length; j++ ) {
						if ( chevauchent( groupes[ i ], groupes[ j ] ) ) {
							groupes[ i ].membres = groupes[ i ].membres.concat( groupes[ j ].membres );
							majGroupe( groupes[ i ] );
							groupes.splice( j, 1 );
							fusion = true;
							break;
						}
					}
				}
			}

			groupes.forEach( function ( g ) {
				g.limites = L.latLngBounds( g.membres.map( function ( p ) {
					return [ p.lat, p.lon ];
				} ) );
				g.etendu = g.membres.length > 1 &&
					carte.distance( g.limites.getSouthWest(), g.limites.getNorthEast() ) > 5;
				var puces = g.puces,
					noms = g.membres.map( nomComplet ).join( ', ' ),
					html = '<span class="carte-osm-marque">' + puces.join( '' ) +
						( g.membres.length > puces.length ? '<span class="carte-osm-nombre">' + g.membres.length + '</span>' : '' ) +
						'</span>';
				g.marque = L.marker( carte.layerPointToLatLng( g.px ), {
					icon: L.divIcon( { className: 'carte-osm-icone', html: html, iconSize: null } ),
					title: noms,
					keyboard: true,
					riseOnHover: true
				} ).bindPopup( bulle( g ), { maxWidth: 300, minWidth: 200, maxHeight: 280 } ).addTo( calque );
				var icone = g.marque.getElement();
				if ( icone ) {
					icone.setAttribute( 'aria-label', noms );
				}
				g.marque.on( 'popupopen', function ( e ) {
					var fermer = e.popup.getElement().querySelector( '.leaflet-popup-close-button' ),
						bouton = e.popup.getElement().querySelector( '.carte-osm-zoom' );
					if ( fermer ) {
						fermer.setAttribute( 'aria-label', 'Fermer' );
						fermer.title = 'Fermer';
					}
					if ( bouton ) {
						bouton.addEventListener( 'click', function () {
							carte.closePopup();
							carte.fitBounds( g.limites, { padding: [ 48, 48 ], maxZoom: 18 } );
						} );
					}
					e.popup.getElement().querySelectorAll( '.carte-osm-deplacer' ).forEach( function ( b ) {
						b.addEventListener( 'click', function () {
							var vise = Number( b.getAttribute( 'data-rang' ) );
							g.membres.forEach( function ( p ) {
								if ( p.rang === vise ) {
									commencerDeplacement( p );
								}
							} );
						} );
					} );
				} );
			} );
		}

		carte.on( 'zoomend', dessiner );

		/* ---------- déplacer un lieu (utilisateurs connectés) ----------
		   « Déplacer » dans la fiche d'un lieu, puis un clic sur la carte à l'endroit
		   voulu ; le repère provisoire s'ajuste à la souris avant d'enregistrer.
		   L'enregistrement réécrit les coordonnées du n-ième {{Point carte}} du
		   wikicode : c'est une modification ordinaire de la page (historique,
		   résumé, annulation possible). Si la page a changé depuis son affichage,
		   rien n'est écrit. */

		var bandeau = null, enCours = null;

		function afficherBandeau( html ) {
			if ( !bandeau ) {
				bandeau = L.DomUtil.create( 'div', 'carte-osm-bandeau', fond );
				bandeau.setAttribute( 'role', 'status' );
				L.DomEvent.disableClickPropagation( bandeau );
				L.DomEvent.disableScrollPropagation( bandeau );
			}
			bandeau.innerHTML = html;
			bandeau.hidden = false;
			var fermer = bandeau.querySelector( '.carte-osm-annuler' );
			if ( fermer ) {
				fermer.addEventListener( 'click', annuler );
			}
			return bandeau;
		}

		function arreterDeplacement() {
			if ( enCours && enCours.repere ) {
				carte.removeLayer( enCours.repere );
			}
			carte.off( 'click', viser );
			fond.classList.remove( 'carte-osm-visee' );
			enCours = null;
		}

		function annuler() {
			arreterDeplacement();
			if ( bandeau ) {
				bandeau.hidden = true;
			}
		}

		function commencerDeplacement( p ) {
			arreterDeplacement();
			carte.closePopup();
			enCours = { p: p, repere: null };
			fond.classList.add( 'carte-osm-visee' );
			afficherBandeau( 'Cliquez sur la carte à l’emplacement de <b>' + echapper( nomComplet( p ) ) + '</b>. ' +
				'<button type="button" class="carte-osm-annuler">Annuler</button>' );
			carte.on( 'click', viser );
		}

		function viser( e ) {
			if ( !enCours ) {
				return;
			}
			carte.off( 'click', viser );
			fond.classList.remove( 'carte-osm-visee' );
			var p = enCours.p;
			enCours.repere = L.marker( e.latlng, {
				draggable: true,
				zIndexOffset: 1000,
				title: 'Nouvelle position : ' + nomComplet( p ),
				icon: L.divIcon( {
					className: 'carte-osm-icone',
					html: '<span class="carte-osm-marque carte-osm-nouvelle">' + pastillesDe( p ) + '</span>',
					iconSize: null
				} )
			} ).addTo( carte );
			afficherBandeau( 'Nouvelle position de <b>' + echapper( nomComplet( p ) ) + '</b>&nbsp;: ' +
				'<span class="carte-osm-coord"></span> ' +
				'<span class="carte-osm-aide">— faites glisser le repère pour l’ajuster.</span> ' +
				'<button type="button" class="carte-osm-enregistrer">Enregistrer</button> ' +
				'<button type="button" class="carte-osm-annuler">Annuler</button>' );
			function coord() {
				var ll = enCours.repere.getLatLng();
				bandeau.querySelector( '.carte-osm-coord' ).textContent = ll.lat.toFixed( 6 ) + ', ' + ll.lng.toFixed( 6 );
			}
			coord();
			enCours.repere.on( 'drag dragend', coord );
			bandeau.querySelector( '.carte-osm-enregistrer' ).addEventListener( 'click', enregistrer );
		}

		function enregistrer() {
			var p = enCours.p,
				ll = enCours.repere.getLatLng(),
				lat = ll.lat.toFixed( 6 ),
				lon = ll.lng.toFixed( 6 ),
				ancienLat = parseFloat( p.el.getAttribute( 'data-lat' ) ),
				ancienLon = parseFloat( p.el.getAttribute( 'data-lon' ) );
			enCours.repere.dragging.disable();
			afficherBandeau( 'Enregistrement…' );
			mw.loader.using( 'mediawiki.api' ).then( function () {
				return new mw.Api().edit( cfg.wgPageName, function ( revision ) {
					var n = 0, ok = false;
					var texte = revision.content.replace( /(\{\{\s*[Pp]oint[ _]carte\s*\|)([^|}]*)\|([^|}]*)/g,
						function ( tout, debut, la, lo ) {
							if ( n++ !== p.rang ) {
								return tout;
							}
							// garde-fou : le modèle visé doit porter les coordonnées affichées
							if ( Math.abs( parseFloat( la ) - ancienLat ) > 1e-7 || Math.abs( parseFloat( lo ) - ancienLon ) > 1e-7 ) {
								return tout;
							}
							ok = true;
							return debut + lat + '|' + lon;
						} );
					if ( !ok ) {
						return $.Deferred().reject( 'carte-decalee' ).promise();
					}
					return {
						text: texte,
						summary: 'Carte : nouvelle position pour ' + nomComplet( p ) + ' (' + lat + ', ' + lon + ')',
						minor: true
					};
				} );
			} ).then( function () {
				p.lat = parseFloat( lat );
				p.lon = parseFloat( lon );
				p.el.setAttribute( 'data-lat', lat );
				p.el.setAttribute( 'data-lon', lon );
				limites = L.latLngBounds( donnees.points.map( function ( q ) {
					return [ q.lat, q.lon ];
				} ) );
				arreterDeplacement();
				dessiner();
				afficherBandeau( '✓ Position de <b>' + echapper( nomComplet( p ) ) + '</b> enregistrée. ' +
					'<a href="' + echapper( cfg.wgScript + '?title=' + encodeURIComponent( cfg.wgPageName ) + '&action=history' ) + '">Historique</a> ' +
					'<button type="button" class="carte-osm-annuler">Fermer</button>' );
			}, function ( code ) {
				var message = code === 'carte-decalee' ?
					'La page a changé depuis son affichage : rechargez-la, puis recommencez.' :
					'L’enregistrement a échoué (' + echapper( code ) + '). Les coordonnées peuvent aussi se corriger dans le wikicode.';
				if ( enCours && enCours.repere ) {
					enCours.repere.dragging.enable();
				}
				afficherBandeau( message + ' <button type="button" class="carte-osm-annuler">Fermer</button>' );
			} );
		}

		if ( peutDeplacer ) {
			document.addEventListener( 'keydown', function ( e ) {
				if ( e.key === 'Escape' && enCours ) {
					annuler();
				}
			} );
		}

		/* ---------- « situer sur la carte » depuis la liste ---------- */

		donnees.points.forEach( function ( p ) {
			if ( p.el.nextElementSibling && p.el.nextElementSibling.classList.contains( 'point-carte-voir' ) ) {
				return;
			}
			var bouton = document.createElement( 'button' );
			bouton.type = 'button';
			bouton.className = 'point-carte-voir';
			bouton.textContent = '📍';
			bouton.title = 'Situer sur la carte';
			bouton.setAttribute( 'aria-label', 'Situer ' + nomComplet( p ) + ' sur la carte' );
			bouton.addEventListener( 'click', function () {
				if ( !p.section.actif ) {
					p.section.actif = true;
					p.section.caseACocher.checked = true;
				}
				conteneur.scrollIntoView( { behavior: reduit ? 'auto' : 'smooth', block: 'center' } );
				var cible = L.latLng( p.lat, p.lon ), zoom = Math.max( carte.getZoom(), 17 );
				function ouvrir() {
					dessiner();
					groupes.forEach( function ( g ) {
						if ( g.membres.indexOf( p ) !== -1 ) {
							g.marque.openPopup();
						}
					} );
				}
				if ( carte.getZoom() === zoom && carte.getCenter().distanceTo( cible ) < 1 ) {
					ouvrir();
				} else {
					carte.once( 'moveend', ouvrir );
					carte.setView( cible, zoom, { animate: !reduit } );
				}
			} );
			p.el.insertAdjacentElement( 'afterend', bouton );
		} );

		/* une carte dans une section repliée (vue mobile) naît sans dimensions */
		function montrer() {
			carte.invalidateSize();
			cadrer();
			dessiner();
		}
		if ( fond.offsetWidth && fond.offsetHeight ) {
			montrer();
		}
		if ( window.ResizeObserver ) {
			var avant = fond.offsetWidth;
			new ResizeObserver( function () {
				if ( fond.offsetWidth && !avant ) {
					montrer();
				} else if ( fond.offsetWidth ) {
					carte.invalidateSize();
				}
				avant = fond.offsetWidth;
			} ).observe( fond );
		}
	}

	/* ---------- chargement de Leaflet, puis des cartes de la page ---------- */

	var leaflet = null;
	function chargerLeaflet() {
		if ( !leaflet ) {
			mw.loader.load( LEAFLET + 'leaflet.css', 'text/css' );
			leaflet = window.L ? Promise.resolve() : mw.loader.getScript( LEAFLET + 'leaflet.js' );
		}
		return leaflet;
	}

	mw.hook( 'wikipage.content' ).add( function ( $contenu ) {
		var racine = $contenu && $contenu[ 0 ],
			cartes = racine ? racine.querySelectorAll( '.carte-osm:not(.carte-osm-prete)' ) : [];
		if ( !cartes.length ) {
			return;
		}
		chargerLeaflet().then( function () {
			cartes.forEach( function ( conteneur ) {
				construire( conteneur, racine );
			} );
		}, function () {
			cartes.forEach( function ( conteneur ) {
				var repli = conteneur.querySelector( '.carte-osm-repli' );
				if ( repli ) {
					repli.textContent = 'La carte n’a pas pu être chargée.';
				}
			} );
		} );
	} );
}() );