/** * 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 = `
${err.message}