/** * French Property Price Map * Interactive choropleth using MapLibre GL JS + government vector tiles. * 6 aggregation levels: Country > Region > Department > Commune > Postcode > Section * * Data sources: * - Country/Region/Department: local GeoJSON (small files) * - Commune: government admin vector tiles (openmaptiles.geo.data.gouv.fr) * - Postcode: local GeoJSON (4MB, no government tile source) * - Section: government cadastre vector tiles (openmaptiles.geo.data.gouv.fr) * * Commune, Postcode, and Section levels require a department selection first. * Only the selected department's data is colored (fast match expressions). */ // ---- Configuration ---- const LEVELS = [ { name: "Country", source: "france", layer: null, codeField: "code", dataKey: "country", targetZoom: 4, isGeojson: true, needsDept: false }, { name: "Region", source: "regions", layer: null, codeField: "code", dataKey: "region", targetZoom: 5.5, isGeojson: true, needsDept: false }, { name: "Department", source: "depts", layer: null, codeField: "code", dataKey: "department", targetZoom: 8, isGeojson: true, needsDept: false }, { name: "Commune", source: "admin-tiles", layer: "communes", codeField: "code", dataKey: "commune", targetZoom: 8.5, isGeojson: false, needsDept: true }, { name: "Postcode", source: "postcode", layer: null, codeField: "codePostal", dataKey: "postcode", targetZoom: 8.5, isGeojson: true, needsDept: true }, { name: "Section", source: "cadastre-tiles", layer: "sections", codeField: "id", dataKey: "section", targetZoom: 11, isGeojson: false, needsDept: true }, ]; const COLOR_STOPS = [ [2, 135, 88], [255, 246, 78], [204, 0, 10], ]; const GRAY = "rgba(80, 80, 80, 0.6)"; const FRANCE_CENTER = [2.3, 46.7]; const FRANCE_ZOOM = 4; const PRICE_SLIDER_MAX = 25000; // ---- Geographic Reference Data ---- const REGIONS = { "84": { name: "Auvergne-Rh\u00f4ne-Alpes", center: [4.5, 45.45] }, "27": { name: "Bourgogne-Franche-Comt\u00e9", center: [5.0, 47.0] }, "53": { name: "Bretagne", center: [-3.0, 48.2] }, "24": { name: "Centre-Val de Loire", center: [1.5, 47.5] }, "94": { name: "Corse", center: [9.1, 42.0] }, "44": { name: "Grand Est", center: [5.5, 48.6] }, "32": { name: "Hauts-de-France", center: [2.8, 49.9] }, "11": { name: "\u00cele-de-France", center: [2.5, 48.85] }, "28": { name: "Normandie", center: [-0.3, 49.0] }, "75": { name: "Nouvelle-Aquitaine", center: [0.5, 45.5] }, "76": { name: "Occitanie", center: [2.0, 43.6] }, "52": { name: "Pays de la Loire", center: [-1.0, 47.5] }, "93": { name: "Provence-Alpes-C\u00f4te d'Azur", center: [5.8, 43.9] }, "01": { name: "Guadeloupe", center: [-61.5, 16.2] }, "02": { name: "Martinique", center: [-61.0, 14.6] }, "03": { name: "Guyane", center: [-53.0, 4.0] }, "04": { name: "La R\u00e9union", center: [55.5, -21.1] }, "06": { name: "Mayotte", center: [45.2, -12.8] }, }; const DEPARTMENTS = { "01": { name: "Ain", region: "84", center: [5.35, 46.2] }, "02": { name: "Aisne", region: "32", center: [3.55, 49.5] }, "03": { name: "Allier", region: "84", center: [3.2, 46.4] }, "04": { name: "Alpes-de-Haute-Provence", region: "93", center: [6.25, 44.1] }, "05": { name: "Hautes-Alpes", region: "93", center: [6.25, 44.65] }, "06": { name: "Alpes-Maritimes", region: "93", center: [7.1, 43.9] }, "07": { name: "Ard\u00e8che", region: "84", center: [4.4, 44.75] }, "08": { name: "Ardennes", region: "44", center: [4.65, 49.6] }, "09": { name: "Ari\u00e8ge", region: "76", center: [1.5, 42.9] }, "10": { name: "Aube", region: "44", center: [4.1, 48.3] }, "11": { name: "Aude", region: "76", center: [2.4, 43.1] }, "12": { name: "Aveyron", region: "76", center: [2.7, 44.3] }, "13": { name: "Bouches-du-Rh\u00f4ne", region: "93", center: [5.05, 43.5] }, "14": { name: "Calvados", region: "28", center: [-0.4, 49.1] }, "15": { name: "Cantal", region: "84", center: [2.65, 45.05] }, "16": { name: "Charente", region: "75", center: [0.15, 45.7] }, "17": { name: "Charente-Maritime", region: "75", center: [-0.8, 45.85] }, "18": { name: "Cher", region: "24", center: [2.5, 47.05] }, "19": { name: "Corr\u00e8ze", region: "75", center: [1.85, 45.35] }, "21": { name: "C\u00f4te-d'Or", region: "27", center: [4.5, 47.4] }, "22": { name: "C\u00f4tes-d'Armor", region: "53", center: [-3.05, 48.45] }, "23": { name: "Creuse", region: "75", center: [2.05, 46.1] }, "24": { name: "Dordogne", region: "75", center: [0.75, 45.15] }, "25": { name: "Doubs", region: "27", center: [6.35, 47.15] }, "26": { name: "Dr\u00f4me", region: "84", center: [5.15, 44.7] }, "27": { name: "Eure", region: "28", center: [1.15, 49.1] }, "28": { name: "Eure-et-Loir", region: "24", center: [1.4, 48.3] }, "29": { name: "Finist\u00e8re", region: "53", center: [-4.1, 48.25] }, "2A": { name: "Corse-du-Sud", region: "94", center: [8.95, 41.85] }, "2B": { name: "Haute-Corse", region: "94", center: [9.2, 42.35] }, "30": { name: "Gard", region: "76", center: [4.15, 44.0] }, "31": { name: "Haute-Garonne", region: "76", center: [1.2, 43.3] }, "32": { name: "Gers", region: "76", center: [0.45, 43.7] }, "33": { name: "Gironde", region: "75", center: [-0.6, 44.85] }, "34": { name: "H\u00e9rault", region: "76", center: [3.4, 43.6] }, "35": { name: "Ille-et-Vilaine", region: "53", center: [-1.7, 48.1] }, "36": { name: "Indre", region: "24", center: [1.55, 46.75] }, "37": { name: "Indre-et-Loire", region: "24", center: [0.7, 47.25] }, "38": { name: "Is\u00e8re", region: "84", center: [5.75, 45.25] }, "39": { name: "Jura", region: "27", center: [5.75, 46.75] }, "40": { name: "Landes", region: "75", center: [-0.75, 44.0] }, "41": { name: "Loir-et-Cher", region: "24", center: [1.3, 47.6] }, "42": { name: "Loire", region: "84", center: [4.05, 45.7] }, "43": { name: "Haute-Loire", region: "84", center: [3.65, 45.15] }, "44": { name: "Loire-Atlantique", region: "52", center: [-1.75, 47.35] }, "45": { name: "Loiret", region: "24", center: [2.15, 47.9] }, "46": { name: "Lot", region: "76", center: [1.65, 44.6] }, "47": { name: "Lot-et-Garonne", region: "75", center: [0.45, 44.35] }, "48": { name: "Loz\u00e8re", region: "76", center: [3.5, 44.5] }, "49": { name: "Maine-et-Loire", region: "52", center: [-0.55, 47.4] }, "50": { name: "Manche", region: "28", center: [-1.3, 48.9] }, "51": { name: "Marne", region: "44", center: [3.95, 48.95] }, "52": { name: "Haute-Marne", region: "44", center: [5.3, 48.1] }, "53": { name: "Mayenne", region: "52", center: [-0.75, 48.15] }, "54": { name: "Meurthe-et-Moselle", region: "44", center: [6.15, 48.8] }, "55": { name: "Meuse", region: "44", center: [5.35, 49.0] }, "56": { name: "Morbihan", region: "53", center: [-2.8, 47.75] }, "57": { name: "Moselle", region: "44", center: [6.65, 49.05] }, "58": { name: "Ni\u00e8vre", region: "27", center: [3.5, 47.1] }, "59": { name: "Nord", region: "32", center: [3.2, 50.45] }, "60": { name: "Oise", region: "32", center: [2.4, 49.4] }, "61": { name: "Orne", region: "28", center: [0.1, 48.55] }, "62": { name: "Pas-de-Calais", region: "32", center: [2.3, 50.5] }, "63": { name: "Puy-de-D\u00f4me", region: "84", center: [3.05, 45.7] }, "64": { name: "Pyr\u00e9n\u00e9es-Atlantiques", region: "75", center: [-0.75, 43.25] }, "65": { name: "Hautes-Pyr\u00e9n\u00e9es", region: "76", center: [0.15, 43.0] }, "66": { name: "Pyr\u00e9n\u00e9es-Orientales", region: "76", center: [2.5, 42.6] }, "67": { name: "Bas-Rhin", region: "44", center: [7.55, 48.65] }, "68": { name: "Haut-Rhin", region: "44", center: [7.25, 47.85] }, "69": { name: "Rh\u00f4ne", region: "84", center: [4.6, 45.9] }, "70": { name: "Haute-Sa\u00f4ne", region: "27", center: [6.15, 47.65] }, "71": { name: "Sa\u00f4ne-et-Loire", region: "27", center: [4.45, 46.65] }, "72": { name: "Sarthe", region: "52", center: [0.2, 47.95] }, "73": { name: "Savoie", region: "84", center: [6.45, 45.5] }, "74": { name: "Haute-Savoie", region: "84", center: [6.35, 46.05] }, "75": { name: "Paris", region: "11", center: [2.35, 48.86] }, "76": { name: "Seine-Maritime", region: "28", center: [1.0, 49.6] }, "77": { name: "Seine-et-Marne", region: "11", center: [2.95, 48.6] }, "78": { name: "Yvelines", region: "11", center: [1.85, 48.8] }, "79": { name: "Deux-S\u00e8vres", region: "75", center: [-0.35, 46.55] }, "80": { name: "Somme", region: "32", center: [2.25, 49.9] }, "81": { name: "Tarn", region: "76", center: [2.15, 43.8] }, "82": { name: "Tarn-et-Garonne", region: "76", center: [1.3, 44.05] }, "83": { name: "Var", region: "93", center: [6.25, 43.5] }, "84": { name: "Vaucluse", region: "93", center: [5.15, 44.05] }, "85": { name: "Vend\u00e9e", region: "52", center: [-1.25, 46.65] }, "86": { name: "Vienne", region: "75", center: [0.45, 46.6] }, "87": { name: "Haute-Vienne", region: "75", center: [1.25, 45.9] }, "88": { name: "Vosges", region: "44", center: [6.35, 48.2] }, "89": { name: "Yonne", region: "27", center: [3.55, 47.85] }, "90": { name: "Territoire de Belfort", region: "27", center: [6.9, 47.65] }, "91": { name: "Essonne", region: "11", center: [2.25, 48.55] }, "92": { name: "Hauts-de-Seine", region: "11", center: [2.24, 48.84] }, "93": { name: "Seine-Saint-Denis", region: "11", center: [2.48, 48.91] }, "94": { name: "Val-de-Marne", region: "11", center: [2.47, 48.78] }, "95": { name: "Val-d'Oise", region: "11", center: [2.17, 49.08] }, "971": { name: "Guadeloupe", region: "01", center: [-61.55, 16.2] }, "972": { name: "Martinique", region: "02", center: [-61.0, 14.65] }, "973": { name: "Guyane", region: "03", center: [-53.2, 4.0] }, "974": { name: "La R\u00e9union", region: "04", center: [55.55, -21.1] }, "976": { name: "Mayotte", region: "06", center: [45.15, -12.8] }, }; // ---- State ---- let priceData = {}; let sectionCache = {}; let currentType = "tous"; let priceFilter = { min: 200, max: PRICE_SLIDER_MAX }; let searchDropdownIdx = -1; // keyboard nav index for search dropdown let activeLevel = 0; // index into LEVELS — controlled by radio buttons only let selectedAreaCode = null; // code of area selected in the area list (country/region/dept) let selectedDeptCode = null; // department selected for commune/postcode/section levels let searchedCommuneCode = null; // commune code from search (for highlighting) let map; // ---- Color Interpolation ---- function lerpColor(t) { t = Math.max(0, Math.min(1, t)); let r, g, b; if (t <= 0.5) { const s = t * 2; r = COLOR_STOPS[0][0] + s * (COLOR_STOPS[1][0] - COLOR_STOPS[0][0]); g = COLOR_STOPS[0][1] + s * (COLOR_STOPS[1][1] - COLOR_STOPS[0][1]); b = COLOR_STOPS[0][2] + s * (COLOR_STOPS[1][2] - COLOR_STOPS[0][2]); } else { const s = (t - 0.5) * 2; r = COLOR_STOPS[1][0] + s * (COLOR_STOPS[2][0] - COLOR_STOPS[1][0]); g = COLOR_STOPS[1][1] + s * (COLOR_STOPS[2][1] - COLOR_STOPS[1][1]); b = COLOR_STOPS[1][2] + s * (COLOR_STOPS[2][2] - COLOR_STOPS[1][2]); } return `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`; } function buildColorScale(data, type) { const prices = []; for (const code in data) { const stats = data[code][type]; if (stats && stats.wtm > 0 && stats.wtm >= priceFilter.min && stats.wtm <= priceFilter.max) { prices.push(stats.wtm); } } if (prices.length === 0) return { scale: () => GRAY, min: 0, mid: 0, max: 0 }; prices.sort((a, b) => a - b); const min = prices[Math.floor(prices.length * 0.05)]; const max = prices[Math.floor(prices.length * 0.95)]; const mid = prices[Math.floor(prices.length * 0.5)]; function scale(val) { if (val < priceFilter.min || val > priceFilter.max) return GRAY; if (val <= min) return lerpColor(0); if (val >= max) return lerpColor(1); if (val <= mid) return lerpColor(0.5 * (val - min) / (mid - min)); return lerpColor(0.5 + 0.5 * (val - mid) / (max - mid)); } return { scale, min, mid, max }; } function formatPrice(val) { if (!val || val === 0) return "N/A"; return Math.round(val).toLocaleString("fr-FR") + " \u20ac/m\u00b2"; } function formatPriceShort(val) { if (!val || val === 0) return "N/A"; return Math.round(val).toLocaleString("fr-FR"); } // ---- Helper: department code from commune/postcode/section code ---- function deptFromCode(code) { if (!code) return ""; if (code.startsWith("97") && code.length > 3) return code.substring(0, 3); if (code.startsWith("2A") || code.startsWith("2B")) return code.substring(0, 2); return code.substring(0, 2); } // ---- Data Helpers ---- function filterByDept(data, deptCode) { if (!data || !deptCode) return {}; const result = {}; for (const code in data) { if (deptFromCode(code) === deptCode) { result[code] = data[code]; } } return result; } function getDisplayData(dataKey) { if (dataKey === "country") return priceData.country; if (dataKey === "region") return priceData.region; if (dataKey === "department") return priceData.department; // Dept-required levels: return only selected dept's data if (!selectedDeptCode) return {}; if (dataKey === "commune") return filterByDept(priceData.commune, selectedDeptCode); if (dataKey === "postcode") return filterByDept(priceData.postcode, selectedDeptCode); if (dataKey === "section") return sectionCache[selectedDeptCode] || {}; return {}; } // ---- Data Loading ---- async function loadJSON(url) { const resp = await fetch(url); if (!resp.ok) throw new Error(`Failed: ${url} (${resp.status})`); return resp.json(); } async function loadPriceData() { const [country, region, department, commune, postcode] = await Promise.all([ loadJSON("/data/prices_country.json"), loadJSON("/data/prices_region.json"), loadJSON("/data/prices_department.json"), loadJSON("/data/prices_commune.json"), loadJSON("/data/prices_postcode.json"), ]); priceData = { country, region, department, commune, postcode }; } async function loadSectionDataForDept(deptCode) { if (sectionCache[deptCode]) return; try { const data = await loadJSON(`/data/sections/${deptCode}.json`); sectionCache[deptCode] = data; } catch (e) { console.warn(`No section data for dept ${deptCode}`, e); } } // ---- Map Initialization ---- function initMap() { map = new maplibregl.Map({ container: "map", style: { version: 8, sources: { "osm-bright": { type: "raster", tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], tileSize: 256, attribution: "© OpenStreetMap contributors", }, }, layers: [{ id: "osm-base", type: "raster", source: "osm-bright", paint: { "raster-opacity": 0.25, "raster-saturation": -0.8 }, }], }, center: FRANCE_CENTER, zoom: FRANCE_ZOOM, maxBounds: [[-10, 40], [15, 52]], }); map.addControl(new maplibregl.NavigationControl(), "top-right"); map.addControl(new maplibregl.ScaleControl(), "bottom-right"); map.on("load", onMapLoad); } async function onMapLoad() { // Government vector tile sources (streamed per viewport, very fast) map.addSource("admin-tiles", { type: "vector", url: "https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json", }); map.addSource("cadastre-tiles", { type: "vector", url: "https://openmaptiles.geo.data.gouv.fr/data/cadastre-dvf.json", }); // Local GeoJSON sources (small static files) const [franceGeo, regionsGeo, deptsGeo, postcodeGeo] = await Promise.all([ loadJSON("/data/france.geojson"), loadJSON("/data/regions.geojson"), loadJSON("/data/departements.geojson"), loadJSON("/data/postcodes.geojson"), ]); map.addSource("france", { type: "geojson", data: franceGeo }); map.addSource("regions", { type: "geojson", data: regionsGeo }); map.addSource("depts", { type: "geojson", data: deptsGeo }); map.addSource("postcode", { type: "geojson", data: postcodeGeo }); // Add choropleth fill + outline layers for each level for (let i = 0; i < LEVELS.length; i++) { const level = LEVELS[i]; const fillId = `${level.dataKey}-fill`; const lineId = `${level.dataKey}-line`; const isActive = (i === activeLevel); const layerBase = { source: level.source, layout: { visibility: isActive ? "visible" : "none" }, }; // For vector tile sources, specify source-layer if (!level.isGeojson) { layerBase["source-layer"] = level.layer; } map.addLayer({ id: fillId, type: "fill", ...layerBase, paint: { "fill-color": GRAY, "fill-opacity": level.dataKey === "country" ? 0.5 : 0.7, }, }); map.addLayer({ id: lineId, type: "line", ...layerBase, paint: { "line-color": "#fff", "line-opacity": level.dataKey === "country" ? 0.6 : 0.25, "line-width": level.dataKey === "section" ? 0.3 : (level.dataKey === "country" ? 1.5 : 0.8), }, }); // Highlight layer for selected area (black border) const hlBase = { source: level.source }; if (!level.isGeojson) hlBase["source-layer"] = level.layer; map.addLayer({ id: `${level.dataKey}-highlight`, type: "line", ...hlBase, layout: { visibility: "none" }, filter: ["==", ["get", level.codeField], ""], paint: { "line-color": "#000000", "line-width": 3, "line-opacity": 1, }, }); // Hover highlight layer (thicker border on hovered feature) const hoverBase = { source: level.source }; if (!level.isGeojson) hoverBase["source-layer"] = level.layer; map.addLayer({ id: `${level.dataKey}-hover`, type: "line", ...hoverBase, layout: { visibility: isActive ? "visible" : "none" }, filter: ["==", ["get", level.codeField], ""], paint: { "line-color": document.body.classList.contains("dark-mode") ? "#ffffff" : "#000000", "line-width": level.dataKey === "section" ? 2 : 3, "line-opacity": 0.8, }, }); // Hover interactions -> floating tooltip + hover highlight map.on("mousemove", fillId, (e) => { showTooltip(e, level); if (e.features && e.features.length > 0) { const hoverCode = e.features[0].properties[level.codeField] || ""; try { map.setFilter(`${level.dataKey}-hover`, ["==", ["get", level.codeField], hoverCode]); } catch (err) {} } }); map.on("mouseleave", fillId, () => { hideTooltip(); try { map.setFilter(`${level.dataKey}-hover`, ["==", ["get", level.codeField], ""]); } catch (err) {} }); // Click-to-drill-down: click a feature to zoom + advance level map.on("click", fillId, async (e) => { if (!e.features || e.features.length === 0) return; const levelIdx = LEVELS.indexOf(level); if (levelIdx >= LEVELS.length - 1) return; const nextLevel = LEVELS[levelIdx + 1]; const code = e.features[0].properties[level.codeField] || ""; const lngLat = e.lngLat; // If drilling from department and next level needs dept selection if (level.dataKey === "department" && nextLevel.needsDept) { selectedDeptCode = code; } // Load section data if advancing to section level if (nextLevel.dataKey === "section" && selectedDeptCode && !sectionCache[selectedDeptCode]) { await loadSectionDataForDept(selectedDeptCode); } // Advance to next level activeLevel = levelIdx + 1; selectedAreaCode = null; searchedCommuneCode = null; document.querySelectorAll('input[name="map-level"]')[activeLevel].checked = true; updateLayerVisibility(); updateHighlight(); let targetZoom = nextLevel.targetZoom; if (level.dataKey === "country") { map.flyTo({ center: FRANCE_CENTER, zoom: targetZoom, duration: 1000 }); } else { const denseDepts = ["75", "92", "93", "94", "69", "13"]; if (level.dataKey === "department" && denseDepts.includes(code)) { targetZoom = Math.min(targetZoom + 1.5, 20); } map.flyTo({ center: [lngLat.lng, lngLat.lat], zoom: targetZoom, duration: 1000 }); } updatePriceRangeForLevel(); updateColors(); updateDynamicStat(); populateAreaList(); }); } // Invisible commune layer for name lookup (from government admin tiles) map.addLayer({ id: "commune-query", type: "fill", source: "admin-tiles", "source-layer": "communes", paint: { "fill-opacity": 0 }, }); // Detailed department boundary from vector tiles (used at commune/postcode/section levels) // This replaces the simplified local GeoJSON boundary for dept-required levels map.addLayer({ id: "dept-vector-highlight", type: "line", source: "admin-tiles", "source-layer": "departements", layout: { visibility: "none" }, filter: ["==", ["get", "code"], ""], paint: { "line-color": document.body.classList.contains("dark-mode") ? "#ffffff" : "#000000", "line-width": 3, "line-opacity": 1, }, }); // Re-apply colors when vector tiles finish loading (they stream per viewport) let sourcedataTimer = null; map.on("sourcedata", (e) => { if (!e.isSourceLoaded) return; const srcId = e.sourceId; const level = LEVELS[activeLevel]; const needsRefresh = (srcId === "admin-tiles" && level.source === "admin-tiles" && selectedDeptCode) || (srcId === "cadastre-tiles" && level.source === "cadastre-tiles" && selectedDeptCode); if (needsRefresh && !sourcedataTimer) { sourcedataTimer = setTimeout(() => { sourcedataTimer = null; updateColors(); }, 200); } }); // Initial paint updateColors(); updateDynamicStat(); map.on("moveend", () => { updateDynamicStat(); }); // Update zoom indicator const zoomEl = document.getElementById("zoom-indicator"); function updateZoomIndicator() { zoomEl.textContent = "Zoom: " + map.getZoom().toFixed(1); } map.on("zoom", updateZoomIndicator); updateZoomIndicator(); } // ---- Color Updates ---- function updateColors() { for (const level of LEVELS) { const data = getDisplayData(level.dataKey); const fillId = `${level.dataKey}-fill`; if (!data || Object.keys(data).length === 0) { try { map.setPaintProperty(fillId, "fill-color", GRAY); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); } if (LEVELS.indexOf(level) === activeLevel) updateLegend(0, 0, 0); continue; } const { scale, min, mid, max } = buildColorScale(data, currentType); // Country level: single feature if (level.dataKey === "country") { const stats = data.FR && data.FR[currentType]; const color = (stats && stats.wtm > 0) ? scale(stats.wtm) : GRAY; try { map.setPaintProperty(fillId, "fill-color", color); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); } if (activeLevel === 0) updateLegend(min, mid, max); continue; } // All other levels: match expression const matchExpr = ["match", ["get", level.codeField]]; let count = 0; for (const code in data) { const stats = data[code][currentType]; if (stats && stats.wtm > 0) { matchExpr.push(code, scale(stats.wtm)); count++; } } matchExpr.push(GRAY); if (count > 0) { try { map.setPaintProperty(fillId, "fill-color", matchExpr); } catch (e) { console.warn(`setPaint ${fillId} (${count} entries):`, e.message); } } else { try { map.setPaintProperty(fillId, "fill-color", GRAY); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); } } if (LEVELS.indexOf(level) === activeLevel) { updateLegend(min, mid, max); } } } function updateLegend(min, mid, max) { document.getElementById("legend-min").textContent = formatPrice(min); document.getElementById("legend-max").textContent = formatPrice(max); } function updateLayerVisibility() { for (let i = 0; i < LEVELS.length; i++) { const level = LEVELS[i]; const fillId = `${level.dataKey}-fill`; const lineId = `${level.dataKey}-line`; const hoverId = `${level.dataKey}-hover`; const vis = (i === activeLevel) ? "visible" : "none"; try { map.setLayoutProperty(fillId, "visibility", vis); map.setLayoutProperty(lineId, "visibility", vis); map.setLayoutProperty(hoverId, "visibility", vis); } catch (e) {} } } function updateDynamicStat() { const labelEl = document.getElementById("stat-label"); const priceEl = document.getElementById("stat-price"); const detailEl = document.getElementById("stat-detail"); const level = getCurrentLevel(); const dataKey = level.dataKey; // Dept-required levels: show dept stats or prompt if (level.needsDept) { if (!selectedDeptCode) { labelEl.textContent = "Select a Department"; priceEl.textContent = "\u2014"; detailEl.textContent = `Choose a department to view ${level.name.toLowerCase()} data`; return; } const deptInfo = DEPARTMENTS[selectedDeptCode]; const deptName = deptInfo ? `${selectedDeptCode} \u2013 ${deptInfo.name}` : selectedDeptCode; const data = getDisplayData(dataKey); let totalWeightedPrice = 0; let totalVolume = 0; let count = 0; for (const code in data) { const stats = data[code][currentType]; if (!stats || !stats.wtm || !stats.volume) continue; if (stats.wtm < priceFilter.min || stats.wtm > priceFilter.max) continue; totalWeightedPrice += stats.wtm * stats.volume; totalVolume += stats.volume; count++; } const avgPrice = totalVolume > 0 ? Math.round(totalWeightedPrice / totalVolume) : 0; labelEl.textContent = `${deptName} \u00b7 ${level.name}s`; priceEl.textContent = avgPrice > 0 ? formatPrice(avgPrice) : "\u2014"; detailEl.textContent = totalVolume > 0 ? `${count} ${level.name.toLowerCase()}s \u00b7 ${totalVolume.toLocaleString("fr-FR")} transactions` : ""; return; } // If a specific area is selected in the list (country/region/dept) if (selectedAreaCode) { let name = selectedAreaCode; let stats = null; if (dataKey === "country") { name = "France"; stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType]; } else if (dataKey === "region") { const info = REGIONS[selectedAreaCode]; name = info ? info.name : selectedAreaCode; stats = priceData.region && priceData.region[selectedAreaCode] && priceData.region[selectedAreaCode][currentType]; } else if (dataKey === "department") { const info = DEPARTMENTS[selectedAreaCode]; name = info ? `${selectedAreaCode} \u2013 ${info.name}` : selectedAreaCode; stats = priceData.department && priceData.department[selectedAreaCode] && priceData.department[selectedAreaCode][currentType]; } labelEl.textContent = name; priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014"; detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : ""; return; } // No selection — show level average if (dataKey === "country") { const stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType]; labelEl.textContent = "National Average"; priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014"; detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : ""; return; } const dataSet = priceData[dataKey] || {}; let totalWeightedPrice = 0; let totalVolume = 0; let count = 0; for (const code in dataSet) { const stats = dataSet[code][currentType]; if (!stats || !stats.wtm || !stats.volume) continue; if (stats.wtm < priceFilter.min || stats.wtm > priceFilter.max) continue; totalWeightedPrice += stats.wtm * stats.volume; totalVolume += stats.volume; count++; } const avgPrice = totalVolume > 0 ? Math.round(totalWeightedPrice / totalVolume) : 0; labelEl.textContent = `${level.name} Average`; priceEl.textContent = avgPrice > 0 ? formatPrice(avgPrice) : "\u2014"; detailEl.textContent = totalVolume > 0 ? `${count} areas \u00b7 ${totalVolume.toLocaleString("fr-FR")} transactions` : ""; } // ---- Hover Tooltip (on map) ---- function showTooltip(e, level) { if (!e.features || e.features.length === 0) return; map.getCanvas().style.cursor = "pointer"; const props = e.features[0].properties; const code = props[level.codeField]; let name = props.nom || code; // Country level if (level.dataKey === "country") { name = "France"; } // Postcode level: show postcode + commune name from query layer if (level.dataKey === "postcode") { name = "Code postal " + code; try { const communeFeatures = map.queryRenderedFeatures(e.point, { layers: ["commune-query"] }); if (communeFeatures.length > 0 && communeFeatures[0].properties.nom) { name = communeFeatures[0].properties.nom + " \u00b7 " + code; } } catch {} } // Section level: look up commune name from invisible query layer if (level.dataKey === "section") { try { const communeFeatures = map.queryRenderedFeatures(e.point, { layers: ["commune-query"] }); if (communeFeatures.length > 0 && communeFeatures[0].properties.nom) { let i = code.length - 1; while (i >= 0 && /[A-Z]/.test(code[i])) i--; const letters = code.slice(i + 1); name = communeFeatures[0].properties.nom + " \u00b7 Section " + letters; } } catch {} } // Get stats for all types let allStats = {}; if (level.dataKey === "section") { // Read from section cache (per-department JSON data) const deptCode = selectedDeptCode || deptFromCode(code); if (sectionCache[deptCode] && sectionCache[deptCode][code]) { allStats = sectionCache[deptCode][code]; } } else { const data = priceData[level.dataKey]; if (data && data[code]) allStats = data[code]; } const stats = allStats[currentType]; const tooltip = document.getElementById("tooltip"); if (!stats) { tooltip.classList.add("hidden"); return; } // Build price + volume breakdown for apartment/house const apptStats = allStats.appartement; const houseStats = allStats.maison; let breakdownHtml = ""; if (apptStats && apptStats.wtm) { const vol = apptStats.volume ? apptStats.volume.toLocaleString("fr-FR") : "0"; breakdownHtml += `Apt: ${formatPrice(apptStats.wtm)} (${vol})`; } if (houseStats && houseStats.wtm) { const vol = houseStats.volume ? houseStats.volume.toLocaleString("fr-FR") : "0"; breakdownHtml += `House: ${formatPrice(houseStats.wtm)} (${vol})`; } tooltip.classList.remove("hidden"); tooltip.innerHTML = `
${name}
${formatPrice(stats.wtm)}
${breakdownHtml} ${stats.volume ? stats.volume.toLocaleString("fr-FR") : "0"} total transactions
`; const x = e.originalEvent.clientX; const y = e.originalEvent.clientY; let left = x + 15; let top = y - 10; if (left + 240 > window.innerWidth) left = x - 255; if (top + 100 > window.innerHeight) top = y - 110; if (top < 5) top = 5; tooltip.style.left = left + "px"; tooltip.style.top = top + "px"; } function hideTooltip() { map.getCanvas().style.cursor = ""; document.getElementById("tooltip").classList.add("hidden"); } // ---- Area List Navigation ---- function populateAreaList() { const container = document.getElementById("area-list"); const label = document.getElementById("area-list-label"); const level = getCurrentLevel(); const dataKey = level.dataKey; container.innerHTML = ""; // For dept-required levels: show department list for selection if (level.needsDept) { label.textContent = `Select Department`; // Header message if (selectedDeptCode) { const info = DEPARTMENTS[selectedDeptCode]; const deptName = info ? `${selectedDeptCode} \u2013 ${info.name}` : selectedDeptCode; label.textContent = `${level.name}s \u00b7 ${deptName}`; } const items = Object.entries(DEPARTMENTS) .filter(([code]) => priceData.department && priceData.department[code]) .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr")) .map(([code, info]) => { const stats = priceData.department[code] && priceData.department[code][currentType]; return { code, name: `${code} \u2013 ${info.name}`, price: stats ? stats.wtm : 0 }; }); if (items.length === 0) { container.innerHTML = '
No data available
'; return; } for (const item of items) { const div = document.createElement("div"); div.className = "area-item" + (selectedDeptCode === item.code ? " selected" : ""); div.dataset.code = item.code; div.innerHTML = ` ${item.name} ${item.price ? formatPriceShort(item.price) + " \u20ac" : ""} `; div.addEventListener("click", () => selectDeptForLevel(item.code)); container.appendChild(div); } // Scroll selected dept into view if (selectedDeptCode) { const selectedEl = container.querySelector(".area-item.selected"); if (selectedEl) selectedEl.scrollIntoView({ block: "nearest" }); } return; } // Normal levels: country, region, department let items = []; if (dataKey === "country") { label.textContent = "Country"; const stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType]; items = [{ code: "FR", name: "France", price: stats ? stats.wtm : 0 }]; } else if (dataKey === "region") { label.textContent = "Regions"; items = Object.entries(REGIONS) .filter(([code]) => priceData.region && priceData.region[code]) .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr")) .map(([code, info]) => { const stats = priceData.region[code] && priceData.region[code][currentType]; return { code, name: info.name, price: stats ? stats.wtm : 0 }; }); } else if (dataKey === "department") { label.textContent = "Departments"; items = Object.entries(DEPARTMENTS) .filter(([code]) => priceData.department && priceData.department[code]) .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr")) .map(([code, info]) => { const stats = priceData.department[code] && priceData.department[code][currentType]; return { code, name: `${code} \u2013 ${info.name}`, price: stats ? stats.wtm : 0 }; }); } if (items.length === 0) { container.innerHTML = '
No data available
'; return; } for (const item of items) { const div = document.createElement("div"); div.className = "area-item" + (selectedAreaCode === item.code ? " selected" : ""); div.dataset.code = item.code; div.innerHTML = ` ${item.name} ${item.price ? formatPriceShort(item.price) + " \u20ac" : ""} `; div.addEventListener("click", () => selectArea(item.code, level.dataKey)); container.appendChild(div); } } function selectArea(code, dataKey) { // Toggle selection if (selectedAreaCode === code) { selectedAreaCode = null; } else { selectedAreaCode = code; } // When selecting a department from the dept-level list, also set selectedDeptCode // so that switching to commune/postcode/section shows data for that dept if (dataKey === "department") { selectedDeptCode = selectedAreaCode; // null if deselected, code if selected } // Update list highlighting document.querySelectorAll(".area-item").forEach(el => { el.classList.toggle("selected", el.dataset.code === selectedAreaCode); }); // Update highlight on map updateHighlight(); // Fly to the selected area if (selectedAreaCode) { if (dataKey === "country") { map.flyTo({ center: FRANCE_CENTER, zoom: FRANCE_ZOOM, duration: 1000 }); } else if (dataKey === "region" && REGIONS[code]) { map.flyTo({ center: REGIONS[code].center, zoom: 5.5, duration: 1000 }); } else if (dataKey === "department" && DEPARTMENTS[code]) { const denseDepts = ["75", "92", "93", "94", "69", "13"]; const zoom = denseDepts.includes(code) ? 10.5 : 9; map.flyTo({ center: DEPARTMENTS[code].center, zoom, duration: 1000 }); } } updateDynamicStat(); } async function selectDeptForLevel(deptCode) { // Toggle selection if (selectedDeptCode === deptCode) { selectedDeptCode = null; } else { selectedDeptCode = deptCode; } searchedCommuneCode = null; // Clear search highlight when manually selecting dept // Update list highlighting document.querySelectorAll(".area-item").forEach(el => { el.classList.toggle("selected", el.dataset.code === selectedDeptCode); }); // Update highlight on map updateHighlight(); if (selectedDeptCode) { // Zoom to selected department const info = DEPARTMENTS[selectedDeptCode]; if (info) { const level = getCurrentLevel(); const denseDepts = ["75", "92", "93", "94", "69", "13"]; let zoom = level.targetZoom; if (denseDepts.includes(selectedDeptCode)) zoom += 1.5; map.flyTo({ center: info.center, zoom, duration: 1000 }); } // Load section data on demand if (getCurrentLevel().dataKey === "section") { await loadSectionDataForDept(selectedDeptCode); } } // Update label to show selected dept name const label = document.getElementById("area-list-label"); const level = getCurrentLevel(); if (selectedDeptCode) { const deptInfo = DEPARTMENTS[selectedDeptCode]; const deptName = deptInfo ? `${selectedDeptCode} \u2013 ${deptInfo.name}` : selectedDeptCode; label.textContent = `${level.name}s \u00b7 ${deptName}`; } else { label.textContent = "Select Department"; } updatePriceRangeForLevel(); updateColors(); updateDynamicStat(); } function updateHighlight() { // Clear all highlight layers first for (let i = 0; i < LEVELS.length; i++) { const hlId = `${LEVELS[i].dataKey}-highlight`; try { map.setLayoutProperty(hlId, "visibility", "none"); } catch (e) {} } try { map.setLayoutProperty("dept-vector-highlight", "visibility", "none"); } catch (e) {} // For dept-required levels: show dept boundary + optional searched commune if (LEVELS[activeLevel].needsDept && selectedDeptCode) { // Use the detailed vector-tile dept boundary (not the simplified local GeoJSON) try { map.setFilter("dept-vector-highlight", ["==", ["get", "code"], selectedDeptCode]); map.setLayoutProperty("dept-vector-highlight", "visibility", "visible"); } catch (e) {} // Also highlight the searched commune if at commune level if (searchedCommuneCode && LEVELS[activeLevel].dataKey === "commune") { try { map.setFilter("commune-highlight", ["==", ["get", "code"], searchedCommuneCode]); map.setLayoutProperty("commune-highlight", "visibility", "visible"); } catch (e) {} } return; } // For non-dept levels: highlight selected area if (selectedAreaCode) { const hlId = `${LEVELS[activeLevel].dataKey}-highlight`; try { map.setFilter(hlId, ["==", ["get", LEVELS[activeLevel].codeField], selectedAreaCode]); map.setLayoutProperty(hlId, "visibility", "visible"); } catch (e) {} } } // ---- Dynamic Price Range Per Level ---- function updatePriceRangeForLevel() { const level = getCurrentLevel(); const data = getDisplayData(level.dataKey); const prices = []; for (const code in data) { const stats = data[code][currentType]; if (stats && stats.wtm > 0) prices.push(stats.wtm); } if (prices.length === 0) return; prices.sort((a, b) => a - b); const rangeMin = Math.max(0, Math.floor(prices[0] / 100) * 100); const rangeMax = Math.ceil(prices[prices.length - 1] / 100) * 100; const minSlider = document.getElementById("price-min-slider"); const maxSlider = document.getElementById("price-max-slider"); const minInput = document.getElementById("price-min-input"); const maxInput = document.getElementById("price-max-input"); minSlider.min = rangeMin; minSlider.max = rangeMax; maxSlider.min = rangeMin; maxSlider.max = rangeMax; minInput.min = rangeMin; minInput.max = rangeMax; maxInput.min = rangeMin; maxInput.max = rangeMax; // Reset filter to full range for this level minSlider.value = rangeMin; maxSlider.value = rangeMax; minInput.value = rangeMin; maxInput.value = rangeMax; priceFilter.min = rangeMin; priceFilter.max = rangeMax; updateSliderTrack(); } // ---- Geocoding Search ---- function getCurrentLevel() { return LEVELS[activeLevel]; } async function searchPlace(query) { if (!query || query.length < 2) return []; const url = `https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(query)}&fields=nom,code,codesPostaux,departement,centre,population&boost=population&limit=7`; try { const resp = await fetch(url); if (!resp.ok) return []; return await resp.json(); } catch { return []; } } function renderSearchDropdown(results) { const dropdown = document.getElementById("search-dropdown"); dropdown.innerHTML = ""; searchDropdownIdx = -1; if (results.length === 0) { dropdown.classList.add("hidden"); return; } results.forEach((commune, i) => { const item = document.createElement("div"); item.className = "search-item"; item.dataset.index = i; const pop = commune.population ? commune.population.toLocaleString("fr-FR") + " hab." : ""; const dept = commune.departement ? commune.departement.nom + " (" + commune.departement.code + ")" : ""; const postcodes = (commune.codesPostaux || []).slice(0, 2).join(", "); item.innerHTML = `
${commune.nom}
${[dept, postcodes, pop].filter(Boolean).join(" \u00b7 ")}
`; item.addEventListener("click", () => onSelectPlace(commune)); item.addEventListener("mouseenter", () => { searchDropdownIdx = i; highlightDropdownItem(); }); dropdown.appendChild(item); }); dropdown.classList.remove("hidden"); } function highlightDropdownItem() { const items = document.querySelectorAll("#search-dropdown .search-item"); items.forEach((el, i) => { el.classList.toggle("active", i === searchDropdownIdx); }); } function closeSearchDropdown() { document.getElementById("search-dropdown").classList.add("hidden"); searchDropdownIdx = -1; } async function onSelectPlace(commune) { closeSearchDropdown(); const input = document.getElementById("place-search"); input.value = commune.nom; // Determine department code from the commune const deptCode = commune.departement ? commune.departement.code : deptFromCode(commune.code); // Switch to commune level and set the department const communeLevelIdx = 3; // Commune is index 3 in LEVELS activeLevel = communeLevelIdx; selectedDeptCode = deptCode; selectedAreaCode = null; searchedCommuneCode = commune.code; // Update radio button to reflect commune level document.querySelectorAll('input[name="map-level"]')[activeLevel].checked = true; // Load section data in the background (for potential drill-down) loadSectionDataForDept(deptCode); // Update all map visuals updateLayerVisibility(); updateHighlight(); updatePriceRangeForLevel(); updateColors(); updateDynamicStat(); populateAreaList(); // Fly to the commune if (commune.centre && commune.centre.coordinates) { const [lon, lat] = commune.centre.coordinates; let zoom = 12; if (commune.population > 500000) zoom = 11; else if (commune.population > 100000) zoom = 11.5; else if (commune.population > 20000) zoom = 12; map.flyTo({ center: [lon, lat], zoom, duration: 1500 }); } // Show price details showPlaceDetails(commune); } function showPlaceDetails(commune) { const container = document.getElementById("place-details"); const data = priceData.commune && priceData.commune[commune.code]; if (!data) { container.innerHTML = `
${commune.nom}
${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}
No price data available for this commune
`; container.classList.remove("hidden"); return; } const types = ["tous", "appartement", "maison"]; const typeLabels = { tous: "All Types", appartement: "Apartments", maison: "Houses" }; let rows = ""; for (const type of types) { const stats = data[type]; if (!stats || !stats.wtm) { rows += `
${typeLabels[type]}N/A
`; continue; } rows += `
${typeLabels[type]} ${formatPrice(stats.wtm)}
Median ${formatPrice(stats.median)} ${stats.volume.toLocaleString("fr-FR")} sales ${(stats.confidence * 100).toFixed(0)}% conf.
`; } container.innerHTML = `
${commune.nom}
${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}${commune.population ? " \u00b7 " + commune.population.toLocaleString("fr-FR") + " hab." : ""}
${rows} `; container.classList.remove("hidden"); } function initSearch() { const input = document.getElementById("place-search"); const dropdown = document.getElementById("search-dropdown"); let debounceTimer; let lastResults = []; input.addEventListener("input", () => { clearTimeout(debounceTimer); const query = input.value.trim(); if (query.length < 2) { closeSearchDropdown(); lastResults = []; // When input is cleared completely, reset search state if (query.length === 0) { searchedCommuneCode = null; document.getElementById("place-details").classList.add("hidden"); updateHighlight(); } return; } debounceTimer = setTimeout(async () => { lastResults = await searchPlace(query); renderSearchDropdown(lastResults); }, 300); }); input.addEventListener("keydown", (e) => { const items = dropdown.querySelectorAll(".search-item"); if (items.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); searchDropdownIdx = Math.min(searchDropdownIdx + 1, items.length - 1); highlightDropdownItem(); } else if (e.key === "ArrowUp") { e.preventDefault(); searchDropdownIdx = Math.max(searchDropdownIdx - 1, 0); highlightDropdownItem(); } else if (e.key === "Enter") { e.preventDefault(); if (searchDropdownIdx >= 0 && searchDropdownIdx < lastResults.length) { onSelectPlace(lastResults[searchDropdownIdx]); } } else if (e.key === "Escape") { closeSearchDropdown(); } }); // Close dropdown on outside click document.addEventListener("click", (e) => { if (!e.target.closest(".search-wrapper")) { closeSearchDropdown(); } }); // Clear details and dropdown when input is cleared input.addEventListener("focus", () => { if (input.value.trim().length >= 2 && lastResults.length > 0) { renderSearchDropdown(lastResults); } }); } // ---- Level Switching ---- async function setLevel(levelIdx) { activeLevel = levelIdx; selectedAreaCode = null; // Clear area selection searchedCommuneCode = null; // Clear search highlight // Note: selectedDeptCode is intentionally NOT cleared when switching levels. // This preserves the department context so switching between levels // (e.g. Commune → Dept → back to Commune) keeps the dept selection. // Hide search details panel when switching levels document.getElementById("place-details").classList.add("hidden"); updateLayerVisibility(); updateHighlight(); // If switching to section level with a dept already selected, load section data if (LEVELS[levelIdx].dataKey === "section" && selectedDeptCode && !sectionCache[selectedDeptCode]) { await loadSectionDataForDept(selectedDeptCode); } // Adjust zoom when switching levels: // - Country/Region/Department: all at zoom 4 (national view), only boundaries change // - Commune/Postcode/Section: zoom to selected dept center at level's targetZoom const level = LEVELS[levelIdx]; if (!level.needsDept) { map.flyTo({ center: FRANCE_CENTER, zoom: FRANCE_ZOOM, duration: 1000 }); } else if (selectedDeptCode) { const info = DEPARTMENTS[selectedDeptCode]; if (info) { const denseDepts = ["75", "92", "93", "94", "69", "13"]; let zoom = level.targetZoom; if (denseDepts.includes(selectedDeptCode)) zoom += 1.5; map.flyTo({ center: info.center, zoom, duration: 1000 }); } } updatePriceRangeForLevel(); updateColors(); updateDynamicStat(); populateAreaList(); } // ---- Price Filter ---- function syncPriceFromSliders() { let minVal = parseInt(document.getElementById("price-min-slider").value) || 0; let maxVal = parseInt(document.getElementById("price-max-slider").value) || PRICE_SLIDER_MAX; if (minVal > maxVal) { const tmp = minVal; minVal = maxVal; maxVal = tmp; } priceFilter.min = minVal; priceFilter.max = maxVal; document.getElementById("price-min-input").value = minVal; document.getElementById("price-max-input").value = maxVal; updateSliderTrack(); updateColors(); updateDynamicStat(); } function syncPriceFromInputs() { let minVal = parseInt(document.getElementById("price-min-input").value) || 0; let maxVal = parseInt(document.getElementById("price-max-input").value) || PRICE_SLIDER_MAX; if (minVal > maxVal) { const tmp = minVal; minVal = maxVal; maxVal = tmp; } priceFilter.min = minVal; priceFilter.max = maxVal; document.getElementById("price-min-slider").value = Math.min(minVal, PRICE_SLIDER_MAX); document.getElementById("price-max-slider").value = Math.min(maxVal, PRICE_SLIDER_MAX); updateSliderTrack(); updateColors(); updateDynamicStat(); } function updateSliderTrack() { const sliderMax = parseInt(document.getElementById("price-max-slider").max) || PRICE_SLIDER_MAX; const sliderMin = parseInt(document.getElementById("price-min-slider").min) || 0; const range = sliderMax - sliderMin || 1; const pctMin = ((priceFilter.min - sliderMin) / range) * 100; const pctMax = Math.min(((priceFilter.max - sliderMin) / range) * 100, 100); const track = document.getElementById("slider-track"); if (track) { track.style.left = pctMin + "%"; track.style.width = (pctMax - pctMin) + "%"; } } // ---- Event Listeners ---- document.addEventListener("DOMContentLoaded", () => { // Theme toggle const themeBtn = document.getElementById("theme-toggle"); themeBtn.addEventListener("click", () => { document.body.classList.toggle("dark-mode"); const isDark = document.body.classList.contains("dark-mode"); themeBtn.innerHTML = isDark ? "☾" : "☀"; localStorage.setItem("theme", isDark ? "dark" : "light"); // Update highlight + hover layer colors for theme const hlColor = isDark ? "#ffffff" : "#000000"; for (const level of LEVELS) { try { map.setPaintProperty(`${level.dataKey}-highlight`, "line-color", hlColor); } catch (e) {} try { map.setPaintProperty(`${level.dataKey}-hover`, "line-color", hlColor); } catch (e) {} } try { map.setPaintProperty("dept-vector-highlight", "line-color", hlColor); } catch (e) {} }); // Restore saved theme (default: dark) const saved = localStorage.getItem("theme"); if (saved === "light") { document.body.classList.remove("dark-mode"); themeBtn.innerHTML = "☀"; } // Property type checkboxes const cbAppart = document.getElementById("cb-appartement"); const cbMaison = document.getElementById("cb-maison"); function syncPropertyType() { const a = cbAppart.checked; const m = cbMaison.checked; if (a && m) currentType = "tous"; else if (a) currentType = "appartement"; else if (m) currentType = "maison"; else { cbAppart.checked = true; cbMaison.checked = true; currentType = "tous"; } updatePriceRangeForLevel(); updateColors(); updateDynamicStat(); populateAreaList(); } cbAppart.addEventListener("change", syncPropertyType); cbMaison.addEventListener("change", syncPropertyType); // Level radio buttons document.querySelectorAll('input[name="map-level"]').forEach(radio => { radio.addEventListener("change", () => { setLevel(parseInt(radio.value)); }); }); // Geocoding search initSearch(); // Price range sliders document.getElementById("price-min-slider").addEventListener("input", syncPriceFromSliders); document.getElementById("price-max-slider").addEventListener("input", syncPriceFromSliders); // Price range number inputs document.getElementById("price-min-input").addEventListener("change", syncPriceFromInputs); document.getElementById("price-max-input").addEventListener("change", syncPriceFromInputs); // Initialize slider track updateSliderTrack(); }); // ---- Boot ---- (async function main() { try { await loadPriceData(); initMap(); populateAreaList(); updatePriceRangeForLevel(); } catch (err) { console.error("Failed to initialize:", err); document.body.innerHTML = `

Failed to load data

${err.message}

`; } })();