dcrey7's picture
feat: zoom-aware level switching + zoom indicator
20972ee
/**
* 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: "&copy; 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 ? "&#9790;" : "&#9728;";
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 = "&#9728;";
}
// 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>`;
}
})();