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 : cartes d ensemble (parametre sources), pictogramme par theme, points dans les tableaux, Deplacer corrige l article source
 
(Une version intermédiaire par le même utilisateur non affichée)
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 36 : 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 50 : 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 62 : 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 84 : 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 120 : 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;
 
/* 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 129 : Ligne 203 :
return;
return;
}
}
var lat = parseFloat( el.getAttribute( 'data-lat' ) ),
var rangIci = rang++,
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 ) ) {
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 141 : 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 ],
el: el
theme: theme,
source: source,
el: el,
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 159 : 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 = '';
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 171 : 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 184 : 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 209 : 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 242 : 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 249 : 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 257 : 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></span></li>';
( peutDeplacer ? '<button type="button" class="carte-osm-deplacer" data-id="' + p.id + '">Déplacer</button>' : '' ) +
'</span></li>';
} );
} );
html += '</ul>';
html += '</ul>';
Ligne 268 : 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 278 : 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 299 : 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 329 : 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 345 : 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 357 : Ligne 522 :
} );
} );
}
}
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 );
}
} );
} );
} );
} );
} );
} );
} );
Ligne 363 : Ligne 538 :
carte.on( 'zoomend', dessiner );
carte.on( 'zoomend', dessiner );


/* ---------- « situer sur la carte » depuis la liste ---------- */
/* ---------- 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) ---------- */


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