Aller au contenu

MediaWiki:Carte-osm.js

De WikiColombes
Version datée du 29 septembre 2026 à 09:55 par Joebrable (discussion | contributions) (Cartes OpenStreetMap : script des modeles Carte OSM et Point carte (Leaflet 1.9.4 heberge sur le serveur du wiki))
(diff) ← Version précédente | Voir la version actuelle (diff) | Version suivante → (diff)

Note : après avoir publié vos modifications, il se peut que vous deviez forcer le rechargement complet du cache de votre navigateur pour voir les changements.

  • Firefox / Safari : maintenez la touche Maj (Shift) en cliquant sur le bouton Actualiser ou appuyez sur Ctrl + F5 ou Ctrl + R (⌘ + R sur un Mac).
  • Google Chrome : appuyez sur Ctrl + Maj + R (⌘ + Shift + R sur un Mac).
  •  Edge : maintenez la touche Ctrl en cliquant sur le bouton Actualiser ou pressez Ctrl + F5.
/* 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.
 */
( 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;
		racine.querySelectorAll( 'h2, h3, .point-carte' ).forEach( function ( el ) {
			if ( !el.classList.contains( 'point-carte' ) ) {
				courante = texteTitre( el );
				return;
			}
			var 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
			};
			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 = '';

		/* 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></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 } );
						} );
					}
				} );
			} );
		}

		carte.on( 'zoomend', dessiner );

		/* ---------- « 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.';
				}
			} );
		} );
	} );
}() );