5633969d98
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>
822 lines
31 KiB
JavaScript
822 lines
31 KiB
JavaScript
/* 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) => (
|
||
{ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
|