Spaces:
Sleeping
Sleeping
| /** | |
| * 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 += `<span>Apt: ${formatPrice(apptStats.wtm)} (${vol})</span>`; | |
| } | |
| if (houseStats && houseStats.wtm) { | |
| const vol = houseStats.volume ? houseStats.volume.toLocaleString("fr-FR") : "0"; | |
| breakdownHtml += `<span>House: ${formatPrice(houseStats.wtm)} (${vol})</span>`; | |
| } | |
| tooltip.classList.remove("hidden"); | |
| tooltip.innerHTML = ` | |
| <div class="tt-name">${name}</div> | |
| <div class="tt-price">${formatPrice(stats.wtm)}</div> | |
| <div class="tt-details"> | |
| ${breakdownHtml} | |
| <span>${stats.volume ? stats.volume.toLocaleString("fr-FR") : "0"} total transactions</span> | |
| </div> | |
| `; | |
| 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 = '<div class="area-list-empty">No data available</div>'; | |
| 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 = ` | |
| <span class="area-item-name">${item.name}</span> | |
| <span class="area-item-price">${item.price ? formatPriceShort(item.price) + " \u20ac" : ""}</span> | |
| `; | |
| 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 = '<div class="area-list-empty">No data available</div>'; | |
| 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 = ` | |
| <span class="area-item-name">${item.name}</span> | |
| <span class="area-item-price">${item.price ? formatPriceShort(item.price) + " \u20ac" : ""}</span> | |
| `; | |
| 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 = ` | |
| <div class="search-item-name">${commune.nom}</div> | |
| <div class="search-item-sub">${[dept, postcodes, pop].filter(Boolean).join(" \u00b7 ")}</div> | |
| `; | |
| 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 = ` | |
| <div class="pd-header"> | |
| <div class="pd-name">${commune.nom}</div> | |
| <div class="pd-sub">${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}</div> | |
| </div> | |
| <div class="pd-empty">No price data available for this commune</div> | |
| `; | |
| 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 += `<div class="pd-row"><span class="pd-type">${typeLabels[type]}</span><span class="pd-na">N/A</span></div>`; | |
| continue; | |
| } | |
| rows += ` | |
| <div class="pd-row"> | |
| <span class="pd-type">${typeLabels[type]}</span> | |
| <span class="pd-wtm">${formatPrice(stats.wtm)}</span> | |
| </div> | |
| <div class="pd-row pd-sub-row"> | |
| <span>Median ${formatPrice(stats.median)}</span> | |
| <span>${stats.volume.toLocaleString("fr-FR")} sales</span> | |
| <span>${(stats.confidence * 100).toFixed(0)}% conf.</span> | |
| </div> | |
| `; | |
| } | |
| container.innerHTML = ` | |
| <div class="pd-header"> | |
| <div class="pd-name">${commune.nom}</div> | |
| <div class="pd-sub">${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}${commune.population ? " \u00b7 " + commune.population.toLocaleString("fr-FR") + " hab." : ""}</div> | |
| </div> | |
| ${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 = `<div style="padding:40px;color:#ff6b6b;"> | |
| <h2>Failed to load data</h2><p>${err.message}</p></div>`; | |
| } | |
| })(); | |