/* 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 = `

${p.nom_iris || 'IRIS'}

` + `
${p.nom_commune || ''}${typ ? ' · ' + typ : ''}
`; const popup = L.popup({ maxWidth: 260 }) .setLatLng(e.latlng) .setContent(head + '
Population 2022 : …
') .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 = `
${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(`
${esc(e.mail)}
`); if (e.web) { const url = /^https?:/.test(e.web) ? e.web : 'http://' + e.web; lines.push(`
🔗 Site web
`); } 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 = `
${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();