/* 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: '© OpenStreetMap · © CARTO ',
}).addTo(map);
const baseOsm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap ',
});
// --- 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 = `
')
.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 = `Écoliers publics (IRIS) : ${stu.toLocaleString('fr')} (${tx})
`;
}
popup.setContent(head
+ `IRIS : ${fmt(d.iris_pop)}
`
+ (dens != null ? `Densité : ${dens.toLocaleString('fr')} hab/km²
` : '')
+ scoLine
+ `${esc(comLabel)} : ${fmt(d.com_pop)}
`
+ `Code IRIS : ${p.code_iris || ''} — recensement 2022
`);
})
.catch(() => popup.setContent(head + 'Population indisponible.
'));
});
// 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 = `${title} `;
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 += ` ${label}
`;
}
return html + ' n.c.
';
}
// 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 = `
Densité de population
Taux d'écoliers publics
${irisLegendHtml()}
`;
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) => (
{ '&': '&', '<': '<', '>': '>', '"': '"' }[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(`${esc(e.nom || 'Établissement')} `);
lines.push(`${esc(e.type || '')} · ${esc(e.stat || '')}` +
(e.etat ? ` ${esc(e.etat)} ` : '') + `
`);
if (e.adr) lines.push(`${esc(e.adr)}
`);
const ville = [e.cp, e.com].filter(Boolean).join(' ');
if (ville) lines.push(`${esc(ville)}
`);
if (e.dep) lines.push(`${esc(e.dep)} · ${esc(e.aca || '')}
`);
if (e.circ) lines.push(`Circonscription : ${esc(e.circ)}
`);
const opts = OPTIONS.filter((o) => e[o.key] === 1).map((o) => o.label);
if (opts.length) lines.push(`Options : ${esc(opts.join(' · '))}
`);
if (e.tel) lines.push(`📞 ${esc(e.tel)}
`);
if (e.mail) lines.push(``);
if (e.web) {
const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web;
lines.push(``);
}
if (e.id) lines.push(`UAI : ${esc(e.id)}
`);
lines.push(``);
return ``;
}
function squareIcon(color) {
return L.divIcon({
className: 'sq-marker',
html: ` `,
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 'Étiquette énergie (DPE) ' +
['A', 'B', 'C', 'D', 'E', 'F', 'G'].map((l) =>
` ${l}
`
).join('') +
` Aucun DPE rattaché
`;
}
return 'Type d\'établissement ' +
Object.entries(TYPE_COLORS).map(([t, c]) =>
` ${t}
`
).join('') +
' Public (rond)
' +
' Privé (carré)
';
}
function buildLegend() {
els.legend.innerHTML =
`
Type
DPE
${legendBody()}
`;
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();