Files
ecoles/app.js
T
Arnaud 5633969d98 Version initiale — carte des établissements scolaires
Carte Leaflet (annuaire Éducation nationale) + API PHP/MySQL :
fiche établissement (effectifs, IPS, population INSEE, DPE ADEME),
GED documents/photos, commentaires par groupe, gestion utilisateurs
et politique de mots de passe. Imports opendata (effectifs, IPS,
population, DPE, IRIS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:35:41 +02:00

822 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Carte des établissements scolaires — filtrage côté client. */
'use strict';
// Couleurs par type d'établissement (utilisées markers + légende).
const TYPE_COLORS = {
'Ecole': '#2a9d8f',
'Collège': '#e9c46a',
'Lycée': '#e76f51',
'Médico-social': '#8e7dbe',
'Service Administratif': '#6c757d',
'EREA': '#457b9d',
'Autre': '#adb5bd',
};
const DEFAULT_COLOR = '#adb5bd';
const colorFor = (t) => TYPE_COLORS[t] || DEFAULT_COLOR;
// Coloration des marqueurs : par type d'établissement (défaut) ou par étiquette DPE.
const DPE_COLORS = {
A: '#319834', B: '#33cc31', C: '#cbfc34', D: '#fbfe06',
E: '#fbcc05', F: '#fc8104', G: '#fc0205',
};
const DPE_NA = '#b8c2cc'; // pas de DPE rattaché
const dpeColor = (l) => DPE_COLORS[String(l || '').toUpperCase()] || DPE_NA;
let colorMode = 'type'; // 'type' | 'dpe'
const dpeByUai = new Map(); // UAI → étiquette énergie (meilleur match), chargé à la demande
// Lieux nommés pour le deep-link ?place=… (centre + zoom).
const PLACES = {
marseille: [43.296, 5.37, 12],
aix: [43.529, 5.447, 13],
paris: [48.857, 2.352, 12],
lyon: [45.758, 4.835, 12],
france: [46.6, 2.4, 6],
};
function markerColorFor(e) {
return colorMode === 'dpe' ? dpeColor(dpeByUai.get(e.id)) : colorFor(e.type);
}
// Options proposées : clé compacte (dans les données) → libellé affiché.
const OPTIONS = [
{ key: 'rest', label: 'Restauration' },
{ key: 'heb', label: 'Internat (hébergement)' },
{ key: 'ulis', label: 'ULIS' },
{ key: 'segp', label: 'SEGPA' },
];
let ALL = []; // tous les enregistrements
let lastSubset = []; // dernier sous-ensemble filtré (pour la coloration DPE)
const BY_ID = new Map(); // UAI → enregistrement (pour ouvrir la fiche)
const TOTAL = () => ALL.length;
// --- Carte ---
const map = L.map('map', { preferCanvas: true }).setView([46.6, 2.4], 6);
// Fonds de carte : « clair » (CartoDB Positron) par défaut pour faire ressortir
// les points, fond OSM standard en alternative.
const baseLight = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 20,
subdomains: 'abcd',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> · © <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const baseOsm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
});
// --- Couche IRIS (contours INSEE/IGN) colorée par densité de population ---
// Chargée par zone visible (WFS Géoplateforme) ; rendue sous les points
// (pane dédié) et non interactive pour ne pas gêner les clics.
const IRIS_MIN_ZOOM = 11;
const IRIS_URL = 'https://data.geopf.fr/wfs/ows';
map.createPane('iris');
map.getPane('iris').style.zIndex = 250; // entre tuiles (200) et points (≥400)
// Échelle de densité (habitants / km²) → palette séquentielle (clair → foncé).
const DENS_BINS = [0, 500, 2000, 5000, 10000, 20000, 40000];
const DENS_COLORS = ['#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8d3c', '#f03b20', '#bd0026'];
function densColor(d) {
if (d == null) return '#cfd6de'; // population inconnue
let i = 0;
while (i < DENS_BINS.length - 1 && d >= DENS_BINS[i + 1]) i++;
return DENS_COLORS[i];
}
// Surface d'un anneau [lng,lat] en m² (formule sphérique), mise en cache.
const EARTH_R = 6378137;
const rad = (x) => x * Math.PI / 180;
function ringArea(ring) {
let a = 0;
for (let i = 0, n = ring.length; i < n; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[(i + 1) % n];
a += rad(x2 - x1) * (2 + Math.sin(rad(y1)) + Math.sin(rad(y2)));
}
return Math.abs(a * EARTH_R * EARTH_R / 2);
}
function featureAreaKm2(f) {
if (f.properties._km2 != null) return f.properties._km2;
const g = f.geometry || {};
let m2 = 0;
const polys = g.type === 'Polygon' ? [g.coordinates]
: g.type === 'MultiPolygon' ? g.coordinates : [];
for (const rings of polys) {
m2 += ringArea(rings[0]);
for (let k = 1; k < rings.length; k++) m2 -= ringArea(rings[k]); // trous
}
return (f.properties._km2 = m2 / 1e6);
}
function featureDensity(f) {
const pop = f.properties._pop;
if (pop == null) return null;
const km2 = featureAreaKm2(f);
return km2 > 0 ? pop / km2 : null;
}
function densityStyle(f) {
const d = featureDensity(f);
return {
color: '#555',
weight: 1,
opacity: 0.7,
fillColor: densColor(d),
fillOpacity: d == null ? 0.15 : 0.6,
};
}
// Échelle « taux d'écoliers publics » (% de la population) → palette verte.
const RATIO_BINS = [0, 4, 6, 8, 10, 13];
const RATIO_COLORS = ['#edf8e9', '#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'];
function ratioColor(p) {
if (p == null) return '#cfd6de';
let i = 0;
while (i < RATIO_BINS.length - 1 && p >= RATIO_BINS[i + 1]) i++;
return RATIO_COLORS[i];
}
// Taux par IRIS = élèves des écoles publiques situées dans l'IRIS / population de l'IRIS.
function ratioStyle(f) {
const pop = f.properties._pop;
const stu = f.properties._students;
const pct = (pop != null && pop > 0 && stu != null) ? stu / pop * 100 : null;
return {
color: '#555',
weight: 1,
opacity: 0.7,
fillColor: ratioColor(pct),
fillOpacity: pct == null ? 0.15 : 0.65,
};
}
let irisMode = 'density'; // 'density' | 'ratio'
const currentStyle = (f) => (irisMode === 'ratio' ? ratioStyle : densityStyle)(f);
const irisLayer = L.geoJSON(null, {
pane: 'iris',
renderer: L.svg({ pane: 'iris' }), // SVG dédié : tracé net, indépendant du canvas des points
interactive: false,
style: currentStyle,
attribution: 'Contours IRIS® © IGN / INSEE',
});
let irisActive = false;
let irisAbort = null;
let irisTimer = null;
let irisFeatures = []; // dernières features chargées (pour le clic)
let suppressIrisClick = false; // évite le popup IRIS quand on clique un établissement
const irisHint = L.control({ position: 'topright' });
irisHint.onAdd = () => {
const d = L.DomUtil.create('div', 'iris-hint');
d.textContent = '🔎 Zoomez pour afficher les contours IRIS';
return d;
};
let irisHintShown = false;
function toggleIrisHint(show) {
if (show && !irisHintShown) { irisHint.addTo(map); irisHintShown = true; }
else if (!show && irisHintShown) { map.removeControl(irisHint); irisHintShown = false; }
}
async function refreshIris() {
if (!irisActive) return;
if (map.getZoom() < IRIS_MIN_ZOOM) {
irisLayer.clearLayers();
irisFeatures = [];
toggleIrisHint(true);
return;
}
toggleIrisHint(false);
const b = map.getBounds();
const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]
.map((n) => n.toFixed(5)).join(',') + ',urn:ogc:def:crs:OGC:1.3:CRS84';
const url = `${IRIS_URL}?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature`
+ '&TYPENAMES=STATISTICALUNITS.IRIS:contours_iris&OUTPUTFORMAT=application/json'
+ `&SRSNAME=CRS:84&COUNT=2000&BBOX=${bbox}`;
try {
if (irisAbort) irisAbort.abort();
irisAbort = new AbortController();
const res = await fetch(url, { signal: irisAbort.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
irisLayer.clearLayers();
irisLayer.addData(data);
irisFeatures = data.features || [];
await styleIris();
} catch (e) {
if (e.name !== 'AbortError') console.warn('IRIS :', e.message);
}
}
// Récupère la population (INSEE 2022) des IRIS visibles → f.properties._pop.
async function loadIrisPops() {
const codes = irisFeatures.map((f) => f.properties.code_iris).filter(Boolean);
if (!codes.length) return;
let pops;
try {
pops = await fetch('api/iris.php?action=batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes }),
}).then((r) => r.json());
} catch {
return;
}
irisFeatures.forEach((f) => {
f.properties._pop = Object.prototype.hasOwnProperty.call(pops, f.properties.code_iris)
? pops[f.properties.code_iris] : null;
});
}
// Compte les écoliers des écoles PUBLIQUES situées dans chaque IRIS visible
// (école rattachée à l'IRIS qui la contient) → f.properties._students.
async function loadIrisStudents() {
irisFeatures.forEach((f) => { f.properties._students = 0; });
const b = map.getBounds();
const ecoles = ALL.filter((e) =>
e.type === 'Ecole' && e.stat === 'Public' && e.lat != null && b.contains([e.lat, e.lon]));
if (!ecoles.length) return;
let eff = {};
try {
eff = await fetch('api/effectifs.php?action=latest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uais: ecoles.map((e) => e.id) }),
}).then((r) => r.json());
} catch {
return;
}
for (const e of ecoles) {
const f = findIris({ lng: e.lon, lat: e.lat });
if (f) f.properties._students += (eff[e.id] || 0);
}
}
// Applique la coloration courante (densité ou taux de scolarisation par IRIS).
async function styleIris() {
if (!irisFeatures.length) return;
await loadIrisPops();
if (irisMode === 'ratio') {
await loadIrisStudents();
irisLayer.setStyle(ratioStyle);
} else {
irisLayer.setStyle(densityStyle);
}
}
// --- IRIS cliquables : clic carte → point-dans-polygone → popup population ---
function pointInRing(lng, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0], yi = ring[i][1];
const xj = ring[j][0], yj = ring[j][1];
if (((yi > lat) !== (yj > lat)) &&
(lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
function polygonContains(lng, lat, rings) {
// rings[0] = contour extérieur, rings[1..] = trous
if (!pointInRing(lng, lat, rings[0])) return false;
for (let k = 1; k < rings.length; k++) {
if (pointInRing(lng, lat, rings[k])) return false;
}
return true;
}
function findIris(latlng) {
const lng = latlng.lng, lat = latlng.lat;
for (const f of irisFeatures) {
const g = f.geometry;
if (!g) continue;
if (g.type === 'Polygon') {
if (polygonContains(lng, lat, g.coordinates)) return f;
} else if (g.type === 'MultiPolygon') {
for (const poly of g.coordinates) {
if (polygonContains(lng, lat, poly)) return f;
}
}
}
return null;
}
const TYP_IRIS = { H: 'Habitat', A: 'Activité', D: 'Divers', Z: 'Non défini' };
map.on('click', (e) => {
if (!irisActive || suppressIrisClick) return;
const f = findIris(e.latlng);
if (!f) return;
const p = f.properties || {};
const typ = TYP_IRIS[p.type_iris] || p.type_iris || '';
const head = `<div class="iris-popup"><h3>${p.nom_iris || 'IRIS'}</h3>`
+ `<div class="meta">${p.nom_commune || ''}${typ ? ' · ' + typ : ''}</div>`;
const popup = L.popup({ maxWidth: 260 })
.setLatLng(e.latlng)
.setContent(head + '<div class="row">Population 2022 : …</div></div>')
.openOn(map);
const fmt = (n) => n == null ? 'n.c.' : n.toLocaleString('fr') + ' hab.';
fetch(`api/iris.php?code=${encodeURIComponent(p.code_iris || '')}&com=${encodeURIComponent(p.code_insee || '')}`)
.then((r) => r.json())
.then((d) => {
const km2 = featureAreaKm2(f);
const dens = (d.iris_pop != null && km2 > 0) ? Math.round(d.iris_pop / km2) : null;
const comLabel = d.com_name || p.nom_commune || 'Commune';
// En mode « taux d'écoliers », _students est calculé par IRIS.
const stu = f.properties._students;
let scoLine = '';
if (stu != null && d.iris_pop != null) {
const tx = d.iris_pop > 0 ? (stu / d.iris_pop * 100).toFixed(1) + ' %' : 'n.c.';
scoLine = `<div class="row"><b>Écoliers publics (IRIS) :</b> ${stu.toLocaleString('fr')} (${tx})</div>`;
}
popup.setContent(head
+ `<div class="row"><b>IRIS :</b> ${fmt(d.iris_pop)}</div>`
+ (dens != null ? `<div class="row">Densité : ${dens.toLocaleString('fr')} hab/km²</div>` : '')
+ scoLine
+ `<div class="row"><b>${esc(comLabel)} :</b> ${fmt(d.com_pop)}</div>`
+ `<div class="uai">Code IRIS : ${p.code_iris || ''} — recensement 2022</div></div>`);
})
.catch(() => popup.setContent(head + '<div class="row">Population indisponible.</div></div>'));
});
// Construit le contenu de la légende selon le mode de coloration courant.
function irisLegendHtml() {
let bins, colors, title, unit;
if (irisMode === 'ratio') {
bins = RATIO_BINS; colors = RATIO_COLORS;
title = 'Écoliers publics / population'; unit = ' %';
} else {
bins = DENS_BINS; colors = DENS_COLORS;
title = 'Densité (hab/km²)'; unit = '';
}
let html = `<strong>${title}</strong>`;
for (let i = 0; i < colors.length; i++) {
const from = bins[i], to = bins[i + 1];
const label = to
? `${from.toLocaleString('fr')}${to.toLocaleString('fr')}${unit}`
: `${from.toLocaleString('fr')}${unit}`;
html += `<div class="row"><span class="swatch" style="background:${colors[i]}"></span>${label}</div>`;
}
return html + '<div class="row"><span class="swatch" style="background:#cfd6de"></span>n.c.</div>';
}
// Contrôle combiné : sélecteur de mode + légende (affiché avec la couche IRIS).
let irisPanelDiv = null;
const irisPanel = L.control({ position: 'bottomleft' });
irisPanel.onAdd = () => {
const div = L.DomUtil.create('div', 'legend iris-legend');
L.DomEvent.disableClickPropagation(div);
irisPanelDiv = div;
renderIrisPanel();
return div;
};
function renderIrisPanel() {
if (!irisPanelDiv) return;
irisPanelDiv.innerHTML = `
<div class="iris-mode">
<label><input type="radio" name="irismode" value="density" ${irisMode === 'density' ? 'checked' : ''}> Densité de population</label>
<label><input type="radio" name="irismode" value="ratio" ${irisMode === 'ratio' ? 'checked' : ''}> Taux d'écoliers publics</label>
</div>
<div class="iris-legend-body">${irisLegendHtml()}</div>`;
irisPanelDiv.querySelectorAll('input[name="irismode"]').forEach((r) =>
r.addEventListener('change', (ev) => {
irisMode = ev.target.value;
renderIrisPanel();
styleIris();
}));
}
map.on('overlayadd', (e) => {
if (e.layer === irisLayer) { irisActive = true; irisPanel.addTo(map); refreshIris(); }
});
map.on('overlayremove', (e) => {
if (e.layer === irisLayer) {
irisActive = false;
irisLayer.clearLayers();
irisFeatures = [];
toggleIrisHint(false);
map.removeControl(irisPanel);
irisPanelDiv = null;
}
});
map.on('moveend', () => { clearTimeout(irisTimer); irisTimer = setTimeout(refreshIris, 400); });
L.control.layers(
{ 'Fond clair': baseLight, 'OpenStreetMap': baseOsm },
{ 'Contours IRIS (INSEE/IGN)': irisLayer },
{ collapsed: false }
).addTo(map);
const cluster = L.markerClusterGroup({
chunkedLoading: true,
spiderfyOnMaxZoom: true,
maxClusterRadius: 50,
disableClusteringAtZoom: 16,
});
map.addLayer(cluster);
// --- Centrage automatique sur la position de l'internaute ---
// (si l'utilisateur l'autorise ; sinon on conserve la vue France par défaut).
let userMarker = null;
map.on('locationfound', (e) => {
if (userMarker) map.removeLayer(userMarker);
userMarker = L.layerGroup([
L.circle(e.latlng, { radius: e.accuracy, color: '#1d3557', weight: 1, fillOpacity: 0.08 }),
L.circleMarker(e.latlng, { radius: 7, color: '#fff', weight: 2, fillColor: '#1d3557', fillOpacity: 1 })
.bindPopup('Vous êtes ici'),
]).addTo(map);
locating = false;
});
map.on('locationerror', (e) => {
// Position refusée ou indisponible : la vue France reste affichée.
console.info('Géolocalisation indisponible :', e.message);
if (locating) alert('Localisation indisponible : ' + e.message);
locating = false;
});
let locating = false;
function locateUser() {
locating = true;
map.locate({ setView: true, maxZoom: 13, enableHighAccuracy: true });
}
// Bouton « Me localiser » sur la carte.
const locateBtn = L.control({ position: 'topleft' });
locateBtn.onAdd = () => {
const div = L.DomUtil.create('div', 'leaflet-bar locate-ctrl');
const a = L.DomUtil.create('a', '', div);
a.href = '#';
a.title = 'Me localiser';
a.setAttribute('role', 'button');
a.setAttribute('aria-label', 'Me localiser');
a.textContent = '📍';
L.DomEvent.on(a, 'click', (ev) => {
L.DomEvent.preventDefault(ev);
L.DomEvent.stopPropagation(ev);
locateUser();
});
return div;
};
locateBtn.addTo(map);
// Centrage automatique au chargement (silencieux si refusé).
locateUser();
locating = false;
// --- Utilitaires ---
const norm = (s) => (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
));
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
function uniqSorted(values) {
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'fr'));
}
// --- Construction des popups (paresseuse) ---
function buildPopup(e) {
const badgeCls = e.etat === 'OUVERT' ? 'ouvert' : 'fermer';
const lines = [];
lines.push(`<h3>${esc(e.nom || 'Établissement')}</h3>`);
lines.push(`<div class="meta">${esc(e.type || '')} · ${esc(e.stat || '')}` +
(e.etat ? ` <span class="badge ${badgeCls}">${esc(e.etat)}</span>` : '') + `</div>`);
if (e.adr) lines.push(`<div class="row">${esc(e.adr)}</div>`);
const ville = [e.cp, e.com].filter(Boolean).join(' ');
if (ville) lines.push(`<div class="row">${esc(ville)}</div>`);
if (e.dep) lines.push(`<div class="row">${esc(e.dep)} · ${esc(e.aca || '')}</div>`);
if (e.circ) lines.push(`<div class="row">Circonscription : ${esc(e.circ)}</div>`);
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
if (opts.length) lines.push(`<div class="row">Options : ${esc(opts.join(' · '))}</div>`);
if (e.tel) lines.push(`<div class="row">📞 ${esc(e.tel)}</div>`);
if (e.mail) lines.push(`<div class="row">✉ <a href="mailto:${esc(e.mail)}">${esc(e.mail)}</a></div>`);
if (e.web) {
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
lines.push(`<div class="row">🔗 <a href="${esc(url)}" target="_blank" rel="noopener">Site web</a></div>`);
}
if (e.id) lines.push(`<div class="uai">UAI : ${esc(e.id)}</div>`);
lines.push(`<button type="button" class="popup-fiche" data-id="${esc(e.id)}">Voir la fiche complète →</button>`);
return `<div class="popup">${lines.join('')}</div>`;
}
function squareIcon(color) {
return L.divIcon({
className: 'sq-marker',
html: `<span style="background:${color}"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
});
}
function markerFor(e) {
if (e._m) return e._m;
const color = markerColorFor(e);
let m;
if (e.stat === 'Privé') {
// Établissement privé : marqueur carré (divIcon).
m = L.marker([e.lat, e.lon], { icon: squareIcon(color) });
} else {
// Public (et autres) : puce ronde.
m = L.circleMarker([e.lat, e.lon], {
radius: 5,
weight: 1,
color: '#fff',
fillColor: color,
fillOpacity: 0.85,
});
}
m.bindPopup(() => buildPopup(e), { maxWidth: 280 });
// Priorité au point d'établissement : on neutralise le popup IRIS sur ce clic.
m.on('click', () => { suppressIrisClick = true; setTimeout(() => { suppressIrisClick = false; }, 60); });
e._m = m;
return m;
}
// Recolore les marqueurs déjà créés selon le mode de coloration courant.
function recolorMarkers() {
ALL.forEach((e) => {
const m = e._m;
if (!m) return;
const c = markerColorFor(e);
if (typeof m.setStyle === 'function') m.setStyle({ fillColor: c }); // puce ronde (public)
else if (typeof m.setIcon === 'function') m.setIcon(squareIcon(c)); // carré (privé)
});
}
// Charge les étiquettes DPE (meilleur match) manquantes pour un lot d'établissements.
// L'API batch est plafonnée à 5000 UAI ⇒ on découpe en tranches.
async function ensureDpeColors(records) {
const missing = [...new Set(
records.map((e) => e.id).filter((id) => id && !dpeByUai.has(id))
)];
if (!missing.length) return;
for (let i = 0; i < missing.length; i += 5000) {
const chunk = missing.slice(i, i + 5000);
let res;
try {
res = await fetch('api/dpe.php?action=latest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uais: chunk }),
}).then((r) => r.json());
} catch {
res = {};
}
// Mémorise une valeur pour CHAQUE UAI demandé (null si pas de DPE) afin
// de ne pas le re-demander à chaque changement de filtre.
chunk.forEach((id) => dpeByUai.set(id, (res && res[id]) ? res[id].dpe : null));
}
}
// --- État des filtres ---
const els = {
search: document.getElementById('search'),
fType: document.getElementById('f-type'),
fStat: document.getElementById('f-stat'),
fEtat: document.getElementById('f-etat'),
reg: document.getElementById('f-reg'),
aca: document.getElementById('f-aca'),
dep: document.getElementById('f-dep'),
circ: document.getElementById('f-circ'),
fOpt: document.getElementById('f-opt'),
reset: document.getElementById('reset'),
count: document.getElementById('count'),
legend: document.getElementById('legend'),
};
function checkedValues(container) {
return [...container.querySelectorAll('input:checked')].map((i) => i.value);
}
function buildChecks(container, values, withColor) {
container.innerHTML = '';
values.forEach((v) => {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = v;
cb.checked = true;
cb.addEventListener('change', applyFilters);
label.appendChild(cb);
if (withColor) {
const sw = document.createElement('span');
sw.className = 'swatch';
sw.style.background = colorFor(v);
label.appendChild(sw);
}
const txt = document.createElement('span');
txt.textContent = v;
label.appendChild(txt);
container.appendChild(label);
});
}
// Cases d'options, décochées par défaut (coché = contrainte « possède l'option »).
function buildOptionChecks(container) {
container.innerHTML = '';
OPTIONS.forEach((o) => {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = o.key;
cb.checked = false;
cb.addEventListener('change', applyFilters);
const txt = document.createElement('span');
txt.textContent = o.label;
label.append(cb, txt);
container.appendChild(label);
});
}
function fillSelect(select, values, allLabel) {
const current = select.value;
select.innerHTML = '';
const opt0 = document.createElement('option');
opt0.value = '';
opt0.textContent = allLabel;
select.appendChild(opt0);
values.forEach((v) => {
const o = document.createElement('option');
o.value = v;
o.textContent = v;
select.appendChild(o);
});
// Conserve la sélection si toujours valide.
select.value = values.includes(current) ? current : '';
}
// Recalcule les selects géographiques en cascade (région → académie → département → circonscription).
function refreshGeoSelects() {
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value;
fillSelect(els.reg, uniqSorted(ALL.map((e) => e.reg)), 'Toutes les régions');
els.reg.value = reg;
let pool = ALL.filter((e) => !els.reg.value || e.reg === els.reg.value);
fillSelect(els.aca, uniqSorted(pool.map((e) => e.aca)), 'Toutes les académies');
els.aca.value = aca;
pool = pool.filter((e) => !els.aca.value || e.aca === els.aca.value);
fillSelect(els.dep, uniqSorted(pool.map((e) => e.dep)), 'Tous les départements');
els.dep.value = dep;
pool = pool.filter((e) => !els.dep.value || e.dep === els.dep.value);
fillSelect(els.circ, uniqSorted(pool.map((e) => e.circ)), 'Toutes les circonscriptions');
els.circ.value = els.circ.value; // déjà conservé par fillSelect
}
// --- Filtrage + rendu ---
function applyFilters() {
const types = new Set(checkedValues(els.fType));
const stats = new Set(checkedValues(els.fStat));
const etats = new Set(checkedValues(els.fEtat));
const reg = els.reg.value, aca = els.aca.value, dep = els.dep.value, circ = els.circ.value;
const opts = checkedValues(els.fOpt); // options requises (logique ET)
const q = norm(els.search.value.trim());
const subset = ALL.filter((e) => {
if (!types.has(e.type)) return false;
if (!stats.has(e.stat)) return false;
if (!etats.has(e.etat)) return false;
if (reg && e.reg !== reg) return false;
if (aca && e.aca !== aca) return false;
if (dep && e.dep !== dep) return false;
if (circ && e.circ !== circ) return false;
for (const k of opts) if (e[k] !== 1) return false;
if (q && !e._s.includes(q)) return false;
return true;
});
lastSubset = subset;
cluster.clearLayers();
cluster.addLayers(subset.map(markerFor));
els.count.textContent =
`${subset.length.toLocaleString('fr')} / ${TOTAL().toLocaleString('fr')} établissements`;
// En coloration DPE, charge les étiquettes du sous-ensemble puis recolore.
if (colorMode === 'dpe') {
ensureDpeColors(subset).then(recolorMarkers);
}
}
function onGeoChange() {
refreshGeoSelects();
applyFilters();
}
function legendBody() {
if (colorMode === 'dpe') {
return '<strong>Étiquette énergie (DPE)</strong>' +
['A', 'B', 'C', 'D', 'E', 'F', 'G'].map((l) =>
`<div class="row"><span class="swatch" style="background:${DPE_COLORS[l]}"></span>${l}</div>`
).join('') +
`<div class="row"><span class="swatch" style="background:${DPE_NA}"></span>Aucun DPE rattaché</div>`;
}
return '<strong>Type d\'établissement</strong>' +
Object.entries(TYPE_COLORS).map(([t, c]) =>
`<div class="row"><span class="swatch" style="background:${c}"></span>${t}</div>`
).join('') +
'<div class="row" style="margin-top:6px"><span class="swatch" style="background:#6c757d"></span>Public (rond)</div>' +
'<div class="row"><span class="swatch" style="background:#6c757d;border-radius:2px"></span>Privé (carré)</div>';
}
function buildLegend() {
els.legend.innerHTML =
`<div class="legend-mode">
<label><input type="radio" name="colormode" value="type" ${colorMode === 'type' ? 'checked' : ''}> Type</label>
<label><input type="radio" name="colormode" value="dpe" ${colorMode === 'dpe' ? 'checked' : ''}> DPE</label>
</div>
<div class="legend-body">${legendBody()}</div>`;
els.legend.querySelectorAll('input[name="colormode"]').forEach((r) =>
r.addEventListener('change', (ev) => {
colorMode = ev.target.value;
buildLegend();
if (colorMode === 'dpe') {
ensureDpeColors(lastSubset).then(recolorMarkers);
} else {
recolorMarkers();
}
}));
}
function resetFilters() {
els.search.value = '';
els.fType.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fStat.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fEtat.querySelectorAll('input').forEach((i) => (i.checked = true));
els.fOpt.querySelectorAll('input').forEach((i) => (i.checked = false));
els.reg.value = els.aca.value = els.dep.value = els.circ.value = '';
refreshGeoSelects();
applyFilters();
}
// --- Démarrage ---
async function init() {
try {
const res = await fetch('api/etablissements.php');
if (!res.ok) throw new Error('HTTP ' + res.status);
ALL = await res.json();
} catch (err) {
els.count.textContent = 'Erreur de chargement des données.';
console.error(err);
return;
}
// Pré-calcul du champ de recherche normalisé + index par UAI.
ALL.forEach((e) => {
e._s = norm((e.nom || '') + ' ' + (e.com || ''));
if (e.id) BY_ID.set(e.id, e);
});
buildChecks(els.fType, uniqSorted(ALL.map((e) => e.type)), true);
buildChecks(els.fStat, uniqSorted(ALL.map((e) => e.stat)), false);
buildChecks(els.fEtat, uniqSorted(ALL.map((e) => e.etat)), false);
buildOptionChecks(els.fOpt);
refreshGeoSelects();
// Deep-link : ?color=dpe&place=marseille (ou ?center=lat,lon&zoom=N).
const params = new URLSearchParams(location.search);
if ((params.get('color') || '').toLowerCase() === 'dpe') colorMode = 'dpe';
buildLegend();
els.search.addEventListener('input', debounce(applyFilters, 250));
[els.reg, els.aca, els.dep].forEach((s) => s.addEventListener('change', onGeoChange));
els.circ.addEventListener('change', applyFilters);
els.reset.addEventListener('click', resetFilters);
// Fiche plein écran : ouverture depuis la popup, recentrage sans rechargement.
Fiche.init({
onLocate: (e) => {
map.setView([e.lat, e.lon], 17, { animate: true });
markerFor(e).openPopup();
},
});
document.getElementById('map').addEventListener('click', (ev) => {
const btn = ev.target.closest('.popup-fiche');
if (!btn) return;
const rec = BY_ID.get(btn.dataset.id);
if (rec) Fiche.open(rec);
});
applyFilters();
// Centrage initial depuis l'URL (après le rendu des marqueurs).
const place = (params.get('place') || '').toLowerCase();
const center = params.get('center');
if (PLACES[place]) {
const [lat, lon, z] = PLACES[place];
map.setView([lat, lon], z);
} else if (center && /^-?\d+\.?\d*,-?\d+\.?\d*$/.test(center)) {
const [lat, lon] = center.split(',').map(Number);
map.setView([lat, lon], Number(params.get('zoom')) || 13);
}
}
init();