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>
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
/* 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();
|
||||
Reference in New Issue
Block a user