Aller au contenu

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

De WikiColombes
Cartes : outil Deplacer pour les utilisateurs connectes (fiche du lieu, clic sur la carte, enregistrement dans le wikicode)
Cartes : cartes d ensemble (parametre sources), pictogramme par theme, points dans les tableaux, Deplacer corrige l article source
 
Ligne 1 : Ligne 1 :
/* Cartes OpenStreetMap de WikiColombes.
/* Cartes OpenStreetMap de WikiColombes.
  *
  *
  * Mode d'emploi, côté wikicode :
  * Côté wikicode :
  *  - {{Carte OSM}} place la carte dans la page ;
  *  - {{Carte OSM}} place une carte dans la page ;
  *  - {{Point carte|latitude|longitude|niveau=…|statut=…|adresse=…}}, posé à côté
  *  - {{Point carte|latitude|longitude|…}}, posé à côté d'un nom, dans une liste
  *    d'un nom dans une liste, y ajoute ce lieu.
  *    ou dans la première colonne d'un tableau, 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),
  * Deux sortes de cartes :
* C collège, L lycée ; plusieurs niveaux se séparent par des espaces (« P C L »).
*  - la carte d'un article montre les points de cet article ; sa légende reprend
  * Sans niveau, le lieu reçoit une pastille neutre : la carte sert aussi hors écoles.
*    les titres de section (niveau 3, à défaut 2) qui les précèdent ;
  * statut=privé dessine une pastille carrée au lieu d'une ronde.
*  - une carte d'ensemble ({{Carte OSM|sources=Article 1; Article 2}}) ne contient
*    aucun point : elle lit ceux des articles cités, à chaque affichage. Rien n'est
*    recopié, donc rien n'est à synchroniser ; sa légende propose un thème par
*    article, nommé par le paramètre « thème » de la carte de cet article.
*
* Pastilles : pour un établissement scolaire (niveau=…), une lettre par niveau —
* M maternelle, É élémentaire, P primaire en bleu, C collège en orange, L lycée en
* vert ; pour tout autre lieu, le pictogramme du lieu (symbole=…) ou, à défaut,
* celui de son thème. Carré : établissement privé ou lieu associatif.
  *
* 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}}
  * dans l'article où il est défini, même depuis une carte d'ensemble.
  *
  *
  * Bibliothèque : Leaflet 1.9.4, hébergée sur le serveur du wiki
  * Bibliothèque : Leaflet 1.9.4, hébergée sur le serveur du wiki
Ligne 18 : Ligne 27 :
  * 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 39 : Ligne 45 :
P: { nom: 'École primaire', famille: 'ecole' },
P: { nom: 'École primaire', famille: 'ecole' },
C: { nom: 'Collège', famille: 'college' },
C: { nom: 'Collège', famille: 'college' },
L: { nom: 'Lycée', famille: 'lycee' },
L: { nom: 'Lycée', famille: 'lycee' }
'•': { nom: 'Lieu', famille: 'lieu' }      // point sans niveau : pastille neutre
};
};
var ORDRE = [ 'M', 'É', 'P', 'C', 'L', '•' ];
var ORDRE = [ 'M', 'É', 'P', 'C', 'L' ];
var HAUTEUR_PX = 26;        // hauteur d'un marqueur (pastille de 22 px + liseré)
var HAUTEUR_PX = 26;        // hauteur d'un marqueur (pastille de 22 px + liseré)
var MARGE_PX = 3;          // écart minimal entre deux marqueurs voisins
var MARGE_PX = 3;          // écart minimal entre deux marqueurs voisins
var reduit = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
var reduit = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
var compteur = 0;          // identifiant unique de chaque point, toutes cartes confondues


/* ---------- utilitaires ---------- */
/* ---------- utilitaires ---------- */
Ligne 53 : Ligne 59 :
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
} );
} );
}
function simplifier( s ) {
return String( s ).toLowerCase().normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ).replace( /[^a-z0-9]+/g, ' ' ).trim();
}
}


Ligne 65 : Ligne 75 :
}
}
} );
} );
vus.sort( function ( a, b ) {
return vus.sort( function ( a, b ) {
return ORDRE.indexOf( a ) - ORDRE.indexOf( b );
return ORDRE.indexOf( a ) - ORDRE.indexOf( b );
} );
} );
return vus.length ? vus : [ '•' ];
}
}


function pastille( niveau, statut ) {
/* Les pastilles d'un point : lettres de niveau pour une école, sinon un pictogramme. */
return '<span class="carte-osm-pastille carte-osm-' + NIVEAUX[ niveau ].famille +
function pucesDe( p ) {
( statut === 'privé' ? ' carte-osm-prive' : '' ) + '" aria-hidden="true">' + niveau + '</span>';
var carre = p.statut !== 'public';
if ( p.niveaux.length ) {
return p.niveaux.map( function ( n ) {
return { texte: n, classe: 'carte-osm-' + NIVEAUX[ n ].famille, carre: carre, rang: ORDRE.indexOf( n ) };
} );
}
return [ { texte: p.symbole || '•', classe: 'carte-osm-symbole', carre: carre, rang: 10 } ];
}
 
function htmlPuce( u ) {
return '<span class="carte-osm-pastille ' + u.classe + ( u.carre ? ' carte-osm-prive' : '' ) +
'" aria-hidden="true">' + echapper( u.texte ) + '</span>';
}
 
/* Réunit des pastilles sans doublon, dans un ordre stable. */
function fusionnerPuces( listes, ignorerForme ) {
var vues = {}, res = [];
listes.forEach( function ( liste ) {
liste.forEach( function ( u ) {
var cle = u.texte + '|' + u.classe + ( ignorerForme ? '' : '|' + u.carre );
if ( !vues[ cle ] ) {
vues[ cle ] = true;
res.push( u );
}
} );
} );
return res.sort( function ( a, b ) {
return a.rang - b.rang || ( a.carre === b.carre ? 0 : a.carre ? 1 : -1 );
} );
}
}


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


Ligne 87 : Ligne 122 :
}
}


/* Le nom d'un lieu : la page liée (titre complet), sinon le texte de la ligne. */
function lienValable( a ) {
function lireNom( li, el ) {
return !a.closest( '.reference, .point-carte-voir, .mw-cite-backlink, sup' ) &&
var liens = li ? li.querySelectorAll( 'a' ) : [],
!a.classList.contains( 'external' ) && !a.classList.contains( 'mw-file-description' );
lien = null, i;
}
for ( i = 0; i < liens.length; i++ ) {
 
if ( !liens[ i ].closest( '.reference, .point-carte-voir, .mw-cite-backlink' ) ) {
/* Le nom d'un lieu : le lien le plus proche qui le précède dans la même cellule
lien = liens[ i ];
  (ou le même élément de liste) ; à défaut, le premier lien de la ligne ; à défaut,
break;
  le texte. Du lien, on garde le titre de la page quand il contient le texte
  affiché (« Ambroise Paré » → « École maternelle Ambroise Paré »), sinon le texte
  (« Médiathèque de la Marine », lien vers « Médiathèques de Colombes »). */
function lireNom( el ) {
var force = ( el.getAttribute( 'data-nom' ) || '' ).trim(),
cellule = el.closest( 'td, th, li' ) || el.parentNode,
ligne = el.closest( 'tr, li' ) || cellule,
lien = null;
Array.prototype.forEach.call( cellule.querySelectorAll( 'a' ), function ( a ) {
if ( lienValable( a ) && ( a.compareDocumentPosition( el ) & Node.DOCUMENT_POSITION_FOLLOWING ) ) {
lien = a;
}
}
} );
if ( !lien ) {
lien = Array.prototype.filter.call( ligne.querySelectorAll( 'a' ), lienValable )[ 0 ] || null;
}
}
if ( lien ) {
if ( lien ) {
var texte = lien.textContent.trim(),
titre = ( lien.getAttribute( 'title' ) || texte ).replace( /\s*\(page inexistante\)$/, '' ).trim();
return {
return {
court: lien.textContent.trim(),
complet: force || ( simplifier( titre ).indexOf( simplifier( texte ) ) !== -1 ? titre : texte ),
complet: ( lien.getAttribute( 'title' ) || lien.textContent )
.replace( /\s*\(page inexistante\)$/, '' ).trim(),
lien: lien.getAttribute( 'href' ),
lien: lien.getAttribute( 'href' ),
rouge: lien.classList.contains( 'new' )
rouge: lien.classList.contains( 'new' ),
force: !!force
};
};
}
}
var copie = ( li || el.parentNode ).cloneNode( true );
var copie = cellule.cloneNode( true );
copie.querySelectorAll( 'sup, ul, ol, .point-carte-voir' ).forEach( function ( x ) {
copie.querySelectorAll( 'sup, ul, ol, .point-carte-voir' ).forEach( function ( x ) {
x.remove();
x.remove();
} );
} );
var texte = copie.textContent.replace( /\s*\(.*$/, '' ).trim();
var brut = copie.textContent.replace( /\s*\(.*$/, '' ).trim();
return { court: texte, complet: texte, lien: null, rouge: false };
return { complet: force || brut, lien: null, rouge: false, force: !!force };
}
}


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


/* ---------- lecture de la page ---------- */
function detail( p, ensemble ) {
var morceaux = [];
if ( ensemble && p.theme ) {
morceaux.push( p.theme.nom );
}
if ( p.niveaux.length ) {
morceaux.push( 'Enseignement ' + p.statut );
} else if ( p.statut === 'associatif' ) {
morceaux.push( 'Lieu associatif' );
} else if ( p.statut === 'privé' ) {
morceaux.push( 'Lieu privé' );
}
if ( p.adresse ) {
morceaux.push( p.adresse );
}
return morceaux.map( echapper ).join( ' · ' );
}


function lirePoints( racine ) {
/* ---------- lecture des points d'une page ---------- */
var points = [], sections = [], index = {}, courante = null, rangPoint = 0;
 
/* racine : le contenu d'une page (celle qu'on lit, ou une page source chargée par
  l'API) ; source : son titre, ou null pour la page affichée. Le rang d'un point
  est son numéro d'ordre parmi les {{Point carte}} de sa page : c'est lui qui
  désigne le modèle à réécrire quand on déplace le lieu. */
function lirePoints( racine, source, theme ) {
var points = [], sections = [], index = {}, courante = null, rang = 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 132 : Ligne 203 :
return;
return;
}
}
var rangIci = rangPoint++,     // n-ième {{Point carte}} de la page, valide ou non
var rangIci = rang++,
lat = parseFloat( el.getAttribute( 'data-lat' ) ),
lat = parseFloat( el.getAttribute( 'data-lat' ) ),
lon = parseFloat( el.getAttribute( 'data-lon' ) );
lon = parseFloat( el.getAttribute( 'data-lon' ) );
Ligne 138 : Ligne 209 :
return;
return;
}
}
var cle = courante || 'Autres lieux',
var statut = ( el.getAttribute( 'data-statut' ) || '' ).toLowerCase(),
li = el.closest( 'li' );
cle = courante || 'Autres lieux';
if ( !index[ cle ] ) {
if ( !index[ cle ] ) {
index[ cle ] = { nom: cle, points: [], actif: true };
index[ cle ] = { nom: cle, points: [], actif: true };
Ligne 145 : Ligne 216 :
}
}
var p = {
var p = {
id: compteur++,
lat: lat,
lat: lat,
lon: lon,
lon: lon,
niveaux: lireNiveaux( el.getAttribute( 'data-niveau' ) ),
niveaux: lireNiveaux( el.getAttribute( 'data-niveau' ) ),
statut: /priv/i.test( el.getAttribute( 'data-statut' ) || '' ) ? 'privé' : 'public',
statut: /priv/.test( statut ) ? 'privé' : /assoc/.test( statut ) ? 'associatif' : 'public',
adresse: el.getAttribute( 'data-adresse' ) || '',
adresse: el.getAttribute( 'data-adresse' ) || '',
symbole: el.getAttribute( 'data-symbole' ) || ( theme && theme.symbole ) || '',
section: index[ cle ],
section: index[ cle ],
theme: theme,
source: source,
el: el,
el: el,
rang: rangIci
rang: rangIci
};
};
p.nom = lireNom( li, el );
p.nom = lireNom( el );
index[ cle ].points.push( p );
index[ cle ].points.push( p );
points.push( p );
points.push( p );
} );
} );
return { points: points, sections: sections };
return { points: points, sections: sections };
}
function themeDe( racine, titre ) {
var c = racine.querySelector( '.carte-osm' );
return {
nom: ( c && c.getAttribute( 'data-theme' ) ) || titre,
symbole: ( c && c.getAttribute( 'data-symbole' ) ) || '',
page: titre,
points: [],
actif: true
};
}
/* Charge une page source par l'API et en lit les points. */
function chargerSource( titre ) {
return mw.loader.using( 'mediawiki.api' ).then( function () {
return new mw.Api().get( {
action: 'parse',
page: titre,
prop: 'text',
redirects: 1,
disableeditsection: 1,
disablelimitreport: 1,
formatversion: 2
} );
} ).then( function ( r ) {
var doc = new DOMParser().parseFromString( r.parse.text, 'text/html' ),
theme = themeDe( doc, r.parse.title ),
lus = lirePoints( doc, r.parse.title, theme );
theme.points = lus.points;
return theme;
}, function () {
return { nom: titre, page: titre, points: [], actif: true, erreur: true };
} );
}
}


Ligne 164 : Ligne 273 :


function construire( conteneur, racine ) {
function construire( conteneur, racine ) {
var donnees = lirePoints( racine );
var sources = ( conteneur.getAttribute( 'data-sources' ) || '' ).split( /\s*;\s*/ ).map( function ( s ) {
if ( !donnees.points.length ) {
return s.trim();
} ).filter( Boolean );
 
if ( !sources.length ) {
var themeLocal = themeDe( racine, mw.config.get( 'wgTitle' ) ),
lus = lirePoints( racine, null, themeLocal );
if ( lus.points.length ) {
dessinerCarte( conteneur, lus.points, lus.sections, false, [] );
}
return;
return;
}
}
conteneur.classList.add( 'carte-osm-prete' );
$.when.apply( $, sources.map( chargerSource ) ).then( function () {
var themes = Array.prototype.slice.call( arguments ),
points = [], manquants = [];
themes.forEach( function ( t ) {
if ( t.erreur || !t.points.length ) {
manquants.push( t.page );
}
points = points.concat( t.points );
t.points.forEach( function ( p ) {
p.section = t;      // dans une carte d'ensemble, la légende filtre par thème
} );
} );
themes = themes.filter( function ( t ) {
return t.points.length;
} );
if ( points.length ) {
dessinerCarte( conteneur, points, themes, true, manquants );
} else {
var repli = conteneur.querySelector( '.carte-osm-repli' );
if ( repli ) {
repli.textContent = 'Aucun lieu trouvé dans les articles sources.';
}
}
} );
}
/* points : les lieux ; filtres : les entrées de la légende (sections d'un article,
  ou thèmes d'une carte d'ensemble) ; ensemble : vrai pour une carte d'ensemble. */
function dessinerCarte( conteneur, points, filtres, ensemble, manquants ) {
conteneur.classList.add( 'carte-osm-prete' );
conteneur.classList.add( 'carte-osm-prete' );
conteneur.innerHTML = '';
conteneur.innerHTML = '';
Ligne 180 : Ligne 327 :
legende.setAttribute( 'role', 'group' );
legende.setAttribute( 'role', 'group' );
legende.setAttribute( 'aria-label', 'Lieux affichés sur la carte' );
legende.setAttribute( 'aria-label', 'Lieux affichés sur la carte' );
donnees.sections.forEach( function ( s ) {
filtres.forEach( function ( s ) {
var etiquette = document.createElement( 'label' ),
var etiquette = document.createElement( 'label' ),
caseACocher = document.createElement( 'input' );
caseACocher = document.createElement( 'input' ),
echantillon = ensemble ?
fusionnerPuces( s.points.map( pucesDe ), true ).slice( 0, 5 ) :
pucesDe( s.points[ 0 ] );
caseACocher.type = 'checkbox';
caseACocher.type = 'checkbox';
caseACocher.checked = true;
caseACocher.checked = true;
Ligne 193 : Ligne 343 :
etiquette.appendChild( caseACocher );
etiquette.appendChild( caseACocher );
etiquette.insertAdjacentHTML( 'beforeend',
etiquette.insertAdjacentHTML( 'beforeend',
'<span class="carte-osm-puces">' + pastillesDe( s.points[ 0 ] ) + '</span>' +
'<span class="carte-osm-puces">' + echantillon.map( htmlPuce ).join( '' ) + '</span>' +
'<span>' + echapper( s.nom ) + '</span>' +
'<span>' + echapper( s.nom ) + '</span>' +
'<span class="carte-osm-compte">' + s.points.length + '</span>' );
'<span class="carte-osm-compte">' + s.points.length + '</span>' );
legende.appendChild( etiquette );
legende.appendChild( etiquette );
} );
} );
if ( ensemble && points.some( function ( p ) {
return p.statut !== 'public';
} ) ) {
legende.insertAdjacentHTML( 'beforeend', '<span class="carte-osm-note">' +
'<span class="carte-osm-pastille carte-osm-symbole carte-osm-prive" aria-hidden="true"></span>' +
'carré : privé ou associatif</span>' );
}
if ( manquants.length ) {
legende.insertAdjacentHTML( 'beforeend', '<span class="carte-osm-note">Sans lieu ou introuvable : ' +
manquants.map( echapper ).join( ', ' ) + '</span>' );
}


var fond = document.createElement( 'div' );
var fond = document.createElement( 'div' );
Ligne 218 : Ligne 379 :
L.control.zoom( { zoomInTitle: 'Zoom avant', zoomOutTitle: 'Zoom arrière' } ).addTo( carte );
L.control.zoom( { zoomInTitle: 'Zoom avant', zoomOutTitle: 'Zoom arrière' } ).addTo( carte );


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


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


Ligne 258 : Ligne 423 :
var html = '<div class="carte-osm-bulle"><ul>';
var html = '<div class="carte-osm-bulle"><ul>';
g.membres.slice().sort( function ( a, b ) {
g.membres.slice().sort( function ( a, b ) {
return rang( a ) - rang( b ) || nomComplet( a ).localeCompare( nomComplet( b ), 'fr' );
return ordre( a ) - ordre( b ) || nomComplet( a ).localeCompare( nomComplet( b ), 'fr' );
} ).forEach( function ( p ) {
} ).forEach( function ( p ) {
var nom = echapper( nomComplet( p ) );
var nom = echapper( nomComplet( p ) ), ligne = detail( p, ensemble );
if ( p.nom.lien ) {
if ( p.nom.lien ) {
nom = '<a href="' + echapper( p.nom.lien ) + '"' + ( p.nom.rouge ? ' class="new"' : '' ) + '>' + nom + '</a>';
nom = '<a href="' + echapper( p.nom.lien ) + '"' + ( p.nom.rouge ? ' class="new"' : '' ) + '>' + nom + '</a>';
Ligne 266 : Ligne 431 :
html += '<li><span class="carte-osm-puces">' + pastillesDe( p ) + '</span><span>' +
html += '<li><span class="carte-osm-puces">' + pastillesDe( p ) + '</span><span>' +
'<span class="carte-osm-nom">' + nom + '</span>' +
'<span class="carte-osm-nom">' + nom + '</span>' +
'<span class="carte-osm-detail">Enseignement ' + p.statut +
( ligne ? '<span class="carte-osm-detail">' + ligne + '</span>' : '' ) +
( p.adresse ? ' · ' + echapper( p.adresse ) : '' ) + '</span>' +
( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-id="' + p.id + '">Déplacer</button>' : '' ) +
( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-rang="' + p.rang + '">Déplacer</button>' : '' ) +
'</span></li>';
'</span></li>';
} );
} );
Ligne 279 : Ligne 443 :


/* Un groupe = les lieux qui se chevaucheraient à l'écran. Son marqueur aligne
/* 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
  leurs pastilles, d'où une largeur variable : on compare donc les boîtes
  donc les boîtes réelles des marqueurs, pas seulement leurs centres, et on
  réelles des marqueurs, pas seulement leurs centres, et on fusionne tant
  fusionne tant qu'il reste un chevauchement. */
  qu'il reste un chevauchement. */
function majGroupe( g ) {
function majGroupe( g ) {
var x = 0, y = 0, vues = {};
var x = 0, y = 0;
g.membres.forEach( function ( p ) {
g.membres.forEach( function ( p ) {
x += p.px.x / g.membres.length;
x += p.px.x / g.membres.length;
Ligne 289 : Ligne 453 :
} );
} );
g.px = L.point( x, y );
g.px = L.point( x, y );
g.puces = [];
g.puces = fusionnerPuces( g.membres.map( pucesDe ), false );
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;
g.largeur = g.puces.length * 24 + 2;
}
}
Ligne 310 : Ligne 464 :
function dessiner() {
function dessiner() {
calque.clearLayers();
calque.clearLayers();
groupes = donnees.points.filter( function ( p ) {
groupes = points.filter( function ( p ) {
return p.section.actif;
return p.section.actif;
} ).map( function ( p ) {
} ).map( function ( p ) {
Ligne 340 : Ligne 494 :
g.etendu = g.membres.length > 1 &&
g.etendu = g.membres.length > 1 &&
carte.distance( g.limites.getSouthWest(), g.limites.getNorthEast() ) > 5;
carte.distance( g.limites.getSouthWest(), g.limites.getNorthEast() ) > 5;
var puces = g.puces,
var noms = g.membres.map( nomComplet ).join( ', ' ),
noms = g.membres.map( nomComplet ).join( ', ' ),
html = '<span class="carte-osm-marque">' + g.puces.map( htmlPuce ).join( '' ) +
html = '<span class="carte-osm-marque">' + puces.join( '' ) +
( g.membres.length > g.puces.length ? '<span class="carte-osm-nombre">' + g.membres.length + '</span>' : '' ) +
( g.membres.length > puces.length ? '<span class="carte-osm-nombre">' + g.membres.length + '</span>' : '' ) +
'</span>';
'</span>';
g.marque = L.marker( carte.layerPointToLatLng( g.px ), {
g.marque = L.marker( carte.layerPointToLatLng( g.px ), {
Ligne 356 : Ligne 509 :
}
}
g.marque.on( 'popupopen', function ( e ) {
g.marque.on( 'popupopen', function ( e ) {
var fermer = e.popup.getElement().querySelector( '.leaflet-popup-close-button' ),
var element = e.popup.getElement(),
bouton = e.popup.getElement().querySelector( '.carte-osm-zoom' );
fermer = element.querySelector( '.leaflet-popup-close-button' ),
bouton = element.querySelector( '.carte-osm-zoom' );
if ( fermer ) {
if ( fermer ) {
fermer.setAttribute( 'aria-label', 'Fermer' );
fermer.setAttribute( 'aria-label', 'Fermer' );
Ligne 368 : Ligne 522 :
} );
} );
}
}
e.popup.getElement().querySelectorAll( '.carte-osm-deplacer' ).forEach( function ( b ) {
element.querySelectorAll( '.carte-osm-deplacer' ).forEach( function ( b ) {
b.addEventListener( 'click', function () {
b.addEventListener( 'click', function () {
var vise = Number( b.getAttribute( 'data-rang' ) );
var vise = Number( b.getAttribute( 'data-id' ) );
g.membres.forEach( function ( p ) {
g.membres.forEach( function ( p ) {
if ( p.rang === vise ) {
if ( p.id === vise ) {
commencerDeplacement( p );
commencerDeplacement( p );
}
}
Ligne 387 : Ligne 541 :
  « Déplacer » dans la fiche d'un lieu, puis un clic sur la carte à l'endroit
  « 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.
  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
  L'enregistrement réécrit les coordonnées du n-ième {{Point carte}} de
  wikicode : c'est une modification ordinaire de la page (historique,
  l'article où le lieu est défini — la page affichée, ou l'article source
  résumé, annulation possible). Si la page a changé depuis son affichage,
  d'une carte d'ensemble : c'est une modification ordinaire de cet article
  rien n'est écrit. */
  (historique, résumé, annulation possible). Si l'article a changé depuis son
  affichage, rien n'est écrit. */


var bandeau = null, enCours = null;
var bandeau = null, enCours = null;
Ligne 469 : Ligne 624 :
function enregistrer() {
function enregistrer() {
var p = enCours.p,
var p = enCours.p,
page = p.source || cfg.wgPageName,
ll = enCours.repere.getLatLng(),
ll = enCours.repere.getLatLng(),
lat = ll.lat.toFixed( 6 ),
lat = ll.lat.toFixed( 6 ),
Ligne 477 : Ligne 633 :
afficherBandeau( 'Enregistrement…' );
afficherBandeau( 'Enregistrement…' );
mw.loader.using( 'mediawiki.api' ).then( function () {
mw.loader.using( 'mediawiki.api' ).then( function () {
return new mw.Api().edit( cfg.wgPageName, function ( revision ) {
return new mw.Api().edit( page, function ( revision ) {
var n = 0, ok = false;
var n = 0, ok = false;
var texte = revision.content.replace( /(\{\{\s*[Pp]oint[ _]carte\s*\|)([^|}]*)\|([^|}]*)/g,
var texte = revision.content.replace( /(\{\{\s*[Pp]oint[ _]carte\s*\|)([^|}]*)\|([^|}]*)/g,
Ligne 505 : Ligne 661 :
p.el.setAttribute( 'data-lat', lat );
p.el.setAttribute( 'data-lat', lat );
p.el.setAttribute( 'data-lon', lon );
p.el.setAttribute( 'data-lon', lon );
limites = L.latLngBounds( donnees.points.map( function ( q ) {
calculerLimites();
return [ q.lat, q.lon ];
} ) );
arreterDeplacement();
arreterDeplacement();
dessiner();
dessiner();
afficherBandeau( '✓ Position de <b>' + echapper( nomComplet( p ) ) + '</b> enregistrée. ' +
afficherBandeau( '✓ Position de <b>' + echapper( nomComplet( p ) ) + '</b> enregistrée' +
'<a href="' + echapper( cfg.wgScript + '?title=' + encodeURIComponent( cfg.wgPageName ) + '&action=history' ) + '">Historique</a> ' +
( p.source ? ' dans « ' + echapper( p.source ) + ' »' : '' ) + '. ' +
'<a href="' + echapper( cfg.wgScript + '?title=' + encodeURIComponent( page ) + '&action=history' ) + '">Historique</a> ' +
'<button type="button" class="carte-osm-annuler">Fermer</button>' );
'<button type="button" class="carte-osm-annuler">Fermer</button>' );
}, function ( code ) {
}, function ( code ) {
var message = code === 'carte-decalee' ?
var message = code === 'carte-decalee' ?
'La page a changé depuis son affichage : rechargez-la, puis recommencez.' :
'L’article a changé depuis son affichage : rechargez la page, puis recommencez.' :
'L’enregistrement a échoué (' + echapper( code ) + '). Les coordonnées peuvent aussi se corriger dans le wikicode.';
'L’enregistrement a échoué (' + echapper( code ) + '). Les coordonnées peuvent aussi se corriger dans le wikicode.';
if ( enCours && enCours.repere ) {
if ( enCours && enCours.repere ) {
Ligne 532 : Ligne 687 :
}
}


/* ---------- « situer sur la carte » depuis la liste ---------- */
/* ---------- « situer sur la carte » depuis la page (points de la page seulement) ---------- */


donnees.points.forEach( function ( p ) {
points.forEach( function ( p ) {
if ( p.el.nextElementSibling && p.el.nextElementSibling.classList.contains( 'point-carte-voir' ) ) {
if ( p.source || ( p.el.nextElementSibling && p.el.nextElementSibling.classList.contains( 'point-carte-voir' ) ) ) {
return;
return;
}
}

Dernière version du 29 septembre 2026 à 22:22

/* Cartes OpenStreetMap de WikiColombes.
 *
 * Côté wikicode :
 *   - {{Carte OSM}} place une carte dans la page ;
 *   - {{Point carte|latitude|longitude|…}}, posé à côté d'un nom, dans une liste
 *     ou dans la première colonne d'un tableau, y ajoute ce lieu.
 *
 * Deux sortes de cartes :
 *   - la carte d'un article montre les points de cet article ; sa légende reprend
 *     les titres de section (niveau 3, à défaut 2) qui les précèdent ;
 *   - une carte d'ensemble ({{Carte OSM|sources=Article 1; Article 2}}) ne contient
 *     aucun point : elle lit ceux des articles cités, à chaque affichage. Rien n'est
 *     recopié, donc rien n'est à synchroniser ; sa légende propose un thème par
 *     article, nommé par le paramètre « thème » de la carte de cet article.
 *
 * Pastilles : pour un établissement scolaire (niveau=…), une lettre par niveau —
 * M maternelle, É élémentaire, P primaire en bleu, C collège en orange, L lycée en
 * vert ; pour tout autre lieu, le pictogramme du lieu (symbole=…) ou, à défaut,
 * celui de son thème. Carré : établissement privé ou lieu associatif.
 *
 * 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}}
 * dans l'article où il est défini, même depuis une carte d'ensemble.
 *
 * 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' }
	};
	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;
	var compteur = 0;           // identifiant unique de chaque point, toutes cartes confondues

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

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

	function simplifier( s ) {
		return String( s ).toLowerCase().normalize( 'NFD' ).replace( /[̀-ͯ]/g, '' ).replace( /[^a-z0-9]+/g, ' ' ).trim();
	}

	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 );
			}
		} );
		return vus.sort( function ( a, b ) {
			return ORDRE.indexOf( a ) - ORDRE.indexOf( b );
		} );
	}

	/* Les pastilles d'un point : lettres de niveau pour une école, sinon un pictogramme. */
	function pucesDe( p ) {
		var carre = p.statut !== 'public';
		if ( p.niveaux.length ) {
			return p.niveaux.map( function ( n ) {
				return { texte: n, classe: 'carte-osm-' + NIVEAUX[ n ].famille, carre: carre, rang: ORDRE.indexOf( n ) };
			} );
		}
		return [ { texte: p.symbole || '•', classe: 'carte-osm-symbole', carre: carre, rang: 10 } ];
	}

	function htmlPuce( u ) {
		return '<span class="carte-osm-pastille ' + u.classe + ( u.carre ? ' carte-osm-prive' : '' ) +
			'" aria-hidden="true">' + echapper( u.texte ) + '</span>';
	}

	/* Réunit des pastilles sans doublon, dans un ordre stable. */
	function fusionnerPuces( listes, ignorerForme ) {
		var vues = {}, res = [];
		listes.forEach( function ( liste ) {
			liste.forEach( function ( u ) {
				var cle = u.texte + '|' + u.classe + ( ignorerForme ? '' : '|' + u.carre );
				if ( !vues[ cle ] ) {
					vues[ cle ] = true;
					res.push( u );
				}
			} );
		} );
		return res.sort( function ( a, b ) {
			return a.rang - b.rang || ( a.carre === b.carre ? 0 : a.carre ? 1 : -1 );
		} );
	}

	function pastillesDe( p ) {
		return pucesDe( p ).map( htmlPuce ).join( '' );
	}

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

	function lienValable( a ) {
		return !a.closest( '.reference, .point-carte-voir, .mw-cite-backlink, sup' ) &&
			!a.classList.contains( 'external' ) && !a.classList.contains( 'mw-file-description' );
	}

	/* Le nom d'un lieu : le lien le plus proche qui le précède dans la même cellule
	   (ou le même élément de liste) ; à défaut, le premier lien de la ligne ; à défaut,
	   le texte. Du lien, on garde le titre de la page quand il contient le texte
	   affiché (« Ambroise Paré » → « École maternelle Ambroise Paré »), sinon le texte
	   (« Médiathèque de la Marine », lien vers « Médiathèques de Colombes »). */
	function lireNom( el ) {
		var force = ( el.getAttribute( 'data-nom' ) || '' ).trim(),
			cellule = el.closest( 'td, th, li' ) || el.parentNode,
			ligne = el.closest( 'tr, li' ) || cellule,
			lien = null;
		Array.prototype.forEach.call( cellule.querySelectorAll( 'a' ), function ( a ) {
			if ( lienValable( a ) && ( a.compareDocumentPosition( el ) & Node.DOCUMENT_POSITION_FOLLOWING ) ) {
				lien = a;
			}
		} );
		if ( !lien ) {
			lien = Array.prototype.filter.call( ligne.querySelectorAll( 'a' ), lienValable )[ 0 ] || null;
		}
		if ( lien ) {
			var texte = lien.textContent.trim(),
				titre = ( lien.getAttribute( 'title' ) || texte ).replace( /\s*\(page inexistante\)$/, '' ).trim();
			return {
				complet: force || ( simplifier( titre ).indexOf( simplifier( texte ) ) !== -1 ? titre : texte ),
				lien: lien.getAttribute( 'href' ),
				rouge: lien.classList.contains( 'new' ),
				force: !!force
			};
		}
		var copie = cellule.cloneNode( true );
		copie.querySelectorAll( 'sup, ul, ol, .point-carte-voir' ).forEach( function ( x ) {
			x.remove();
		} );
		var brut = copie.textContent.replace( /\s*\(.*$/, '' ).trim();
		return { complet: force || brut, lien: null, rouge: false, force: !!force };
	}

	/* Sans lien, « Guy de Maupassant » devient « Lycée Guy de Maupassant ». */
	function nomComplet( p ) {
		if ( p.nom.lien || p.nom.force || !p.niveaux.length ||
			/^(É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;
	}

	function detail( p, ensemble ) {
		var morceaux = [];
		if ( ensemble && p.theme ) {
			morceaux.push( p.theme.nom );
		}
		if ( p.niveaux.length ) {
			morceaux.push( 'Enseignement ' + p.statut );
		} else if ( p.statut === 'associatif' ) {
			morceaux.push( 'Lieu associatif' );
		} else if ( p.statut === 'privé' ) {
			morceaux.push( 'Lieu privé' );
		}
		if ( p.adresse ) {
			morceaux.push( p.adresse );
		}
		return morceaux.map( echapper ).join( ' · ' );
	}

	/* ---------- lecture des points d'une page ---------- */

	/* racine : le contenu d'une page (celle qu'on lit, ou une page source chargée par
	   l'API) ; source : son titre, ou null pour la page affichée. Le rang d'un point
	   est son numéro d'ordre parmi les {{Point carte}} de sa page : c'est lui qui
	   désigne le modèle à réécrire quand on déplace le lieu. */
	function lirePoints( racine, source, theme ) {
		var points = [], sections = [], index = {}, courante = null, rang = 0;
		racine.querySelectorAll( 'h2, h3, .point-carte' ).forEach( function ( el ) {
			if ( !el.classList.contains( 'point-carte' ) ) {
				courante = texteTitre( el );
				return;
			}
			var rangIci = rang++,
				lat = parseFloat( el.getAttribute( 'data-lat' ) ),
				lon = parseFloat( el.getAttribute( 'data-lon' ) );
			if ( isNaN( lat ) || isNaN( lon ) ) {
				return;
			}
			var statut = ( el.getAttribute( 'data-statut' ) || '' ).toLowerCase(),
				cle = courante || 'Autres lieux';
			if ( !index[ cle ] ) {
				index[ cle ] = { nom: cle, points: [], actif: true };
				sections.push( index[ cle ] );
			}
			var p = {
				id: compteur++,
				lat: lat,
				lon: lon,
				niveaux: lireNiveaux( el.getAttribute( 'data-niveau' ) ),
				statut: /priv/.test( statut ) ? 'privé' : /assoc/.test( statut ) ? 'associatif' : 'public',
				adresse: el.getAttribute( 'data-adresse' ) || '',
				symbole: el.getAttribute( 'data-symbole' ) || ( theme && theme.symbole ) || '',
				section: index[ cle ],
				theme: theme,
				source: source,
				el: el,
				rang: rangIci
			};
			p.nom = lireNom( el );
			index[ cle ].points.push( p );
			points.push( p );
		} );
		return { points: points, sections: sections };
	}

	function themeDe( racine, titre ) {
		var c = racine.querySelector( '.carte-osm' );
		return {
			nom: ( c && c.getAttribute( 'data-theme' ) ) || titre,
			symbole: ( c && c.getAttribute( 'data-symbole' ) ) || '',
			page: titre,
			points: [],
			actif: true
		};
	}

	/* Charge une page source par l'API et en lit les points. */
	function chargerSource( titre ) {
		return mw.loader.using( 'mediawiki.api' ).then( function () {
			return new mw.Api().get( {
				action: 'parse',
				page: titre,
				prop: 'text',
				redirects: 1,
				disableeditsection: 1,
				disablelimitreport: 1,
				formatversion: 2
			} );
		} ).then( function ( r ) {
			var doc = new DOMParser().parseFromString( r.parse.text, 'text/html' ),
				theme = themeDe( doc, r.parse.title ),
				lus = lirePoints( doc, r.parse.title, theme );
			theme.points = lus.points;
			return theme;
		}, function () {
			return { nom: titre, page: titre, points: [], actif: true, erreur: true };
		} );
	}

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

	function construire( conteneur, racine ) {
		var sources = ( conteneur.getAttribute( 'data-sources' ) || '' ).split( /\s*;\s*/ ).map( function ( s ) {
			return s.trim();
		} ).filter( Boolean );

		if ( !sources.length ) {
			var themeLocal = themeDe( racine, mw.config.get( 'wgTitle' ) ),
				lus = lirePoints( racine, null, themeLocal );
			if ( lus.points.length ) {
				dessinerCarte( conteneur, lus.points, lus.sections, false, [] );
			}
			return;
		}
		conteneur.classList.add( 'carte-osm-prete' );
		$.when.apply( $, sources.map( chargerSource ) ).then( function () {
			var themes = Array.prototype.slice.call( arguments ),
				points = [], manquants = [];
			themes.forEach( function ( t ) {
				if ( t.erreur || !t.points.length ) {
					manquants.push( t.page );
				}
				points = points.concat( t.points );
				t.points.forEach( function ( p ) {
					p.section = t;       // dans une carte d'ensemble, la légende filtre par thème
				} );
			} );
			themes = themes.filter( function ( t ) {
				return t.points.length;
			} );
			if ( points.length ) {
				dessinerCarte( conteneur, points, themes, true, manquants );
			} else {
				var repli = conteneur.querySelector( '.carte-osm-repli' );
				if ( repli ) {
					repli.textContent = 'Aucun lieu trouvé dans les articles sources.';
				}
			}
		} );
	}

	/* points : les lieux ; filtres : les entrées de la légende (sections d'un article,
	   ou thèmes d'une carte d'ensemble) ; ensemble : vrai pour une carte d'ensemble. */
	function dessinerCarte( conteneur, points, filtres, ensemble, manquants ) {
		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' );
		filtres.forEach( function ( s ) {
			var etiquette = document.createElement( 'label' ),
				caseACocher = document.createElement( 'input' ),
				echantillon = ensemble ?
					fusionnerPuces( s.points.map( pucesDe ), true ).slice( 0, 5 ) :
					pucesDe( s.points[ 0 ] );
			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">' + echantillon.map( htmlPuce ).join( '' ) + '</span>' +
				'<span>' + echapper( s.nom ) + '</span>' +
				'<span class="carte-osm-compte">' + s.points.length + '</span>' );
			legende.appendChild( etiquette );
		} );
		if ( ensemble && points.some( function ( p ) {
			return p.statut !== 'public';
		} ) ) {
			legende.insertAdjacentHTML( 'beforeend', '<span class="carte-osm-note">' +
				'<span class="carte-osm-pastille carte-osm-symbole carte-osm-prive" aria-hidden="true"></span>' +
				'carré : privé ou associatif</span>' );
		}
		if ( manquants.length ) {
			legende.insertAdjacentHTML( 'beforeend', '<span class="carte-osm-note">Sans lieu ou introuvable : ' +
				manquants.map( echapper ).join( ', ' ) + '</span>' );
		}

		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;
		function calculerLimites() {
			limites = L.latLngBounds( points.map( function ( q ) {
				return [ q.lat, q.lon ];
			} ) );
		}
		calculerLimites();
		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 ordre( p ) {
			return ( p.niveaux.length ? ORDRE.indexOf( p.niveaux[ 0 ] ) : 10 ) * 2 + ( p.statut === 'public' ? 0 : 1 );
		}

		function bulle( g ) {
			var html = '<div class="carte-osm-bulle"><ul>';
			g.membres.slice().sort( function ( a, b ) {
				return ordre( a ) - ordre( b ) || nomComplet( a ).localeCompare( nomComplet( b ), 'fr' );
			} ).forEach( function ( p ) {
				var nom = echapper( nomComplet( p ) ), ligne = detail( p, ensemble );
				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>' +
					( ligne ? '<span class="carte-osm-detail">' + ligne + '</span>' : '' ) +
					( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-id="' + p.id + '">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
		   leurs pastilles, 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;
			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 = fusionnerPuces( g.membres.map( pucesDe ), false );
			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 = 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 noms = g.membres.map( nomComplet ).join( ', ' ),
					html = '<span class="carte-osm-marque">' + g.puces.map( htmlPuce ).join( '' ) +
						( g.membres.length > g.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 element = e.popup.getElement(),
						fermer = element.querySelector( '.leaflet-popup-close-button' ),
						bouton = element.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 } );
						} );
					}
					element.querySelectorAll( '.carte-osm-deplacer' ).forEach( function ( b ) {
						b.addEventListener( 'click', function () {
							var vise = Number( b.getAttribute( 'data-id' ) );
							g.membres.forEach( function ( p ) {
								if ( p.id === 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}} de
		   l'article où le lieu est défini — la page affichée, ou l'article source
		   d'une carte d'ensemble : c'est une modification ordinaire de cet article
		   (historique, résumé, annulation possible). Si l'article 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,
				page = p.source || cfg.wgPageName,
				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( page, 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 );
				calculerLimites();
				arreterDeplacement();
				dessiner();
				afficherBandeau( '✓ Position de <b>' + echapper( nomComplet( p ) ) + '</b> enregistrée' +
					( p.source ? ' dans « ' + echapper( p.source ) + ' »' : '' ) + '. ' +
					'<a href="' + echapper( cfg.wgScript + '?title=' + encodeURIComponent( page ) + '&action=history' ) + '">Historique</a> ' +
					'<button type="button" class="carte-osm-annuler">Fermer</button>' );
			}, function ( code ) {
				var message = code === 'carte-decalee' ?
					'L’article a changé depuis son affichage : rechargez la page, 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 page (points de la page seulement) ---------- */

		points.forEach( function ( p ) {
			if ( p.source || ( 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.';
				}
			} );
		} );
	} );
}() );