dcrey7 commited on
Commit
92d554e
·
1 Parent(s): 9eba1e1

fix: search integration, detailed boundaries, cleanup dead files

Browse files

- Search now auto-sets department and switches to commune level
- Department selection preserved across level switches (was being cleared)
- Department highlight uses detailed vector tiles instead of simplified GeoJSON
- Postcode boundaries regenerated with finer tolerance (0.003→0.0005)
- Added gzip compression middleware for large files
- Removed dead files: prices_section.json, sections.pmtiles, communes.geojson
- Removed unused PMTiles script tag and library
- Cleaned up .gitignore for temp build directories

.gitattributes CHANGED
@@ -1 +1,2 @@
1
  data/aggregated/prices_commune.json filter=lfs diff=lfs merge=lfs -text
 
 
1
  data/aggregated/prices_commune.json filter=lfs diff=lfs merge=lfs -text
2
+ data/aggregated/postcodes.geojson filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,13 +1,17 @@
1
  # ---- Data (raw and intermediate - too large to commit) ----
2
  data/raw/
3
  data/processed/
 
4
  # NOTE: data/aggregated/ is NOT ignored - those JSON files are needed by the app
5
- # The monolithic section file is replaced by per-department splits in sections/
6
  data/aggregated/prices_section.json
7
- # PMTiles replaced by government vector tiles (openmaptiles.geo.data.gouv.fr)
8
  data/aggregated/sections.pmtiles
9
  data/aggregated/communes.geojson
10
 
 
 
 
 
11
  # ---- Python ----
12
  __pycache__/
13
  *.py[cod]
 
1
  # ---- Data (raw and intermediate - too large to commit) ----
2
  data/raw/
3
  data/processed/
4
+ data/geo/
5
  # NOTE: data/aggregated/ is NOT ignored - those JSON files are needed by the app
6
+ # Dead files that should not be committed:
7
  data/aggregated/prices_section.json
 
8
  data/aggregated/sections.pmtiles
9
  data/aggregated/communes.geojson
10
 
11
+ # ---- Temp build artifacts ----
12
+ .tmp_sections/
13
+ .tmp_sections_enriched/
14
+
15
  # ---- Python ----
16
  __pycache__/
17
  *.py[cod]
app.py CHANGED
@@ -8,6 +8,7 @@ Designed for Hugging Face Spaces Docker deployment on port 7860.
8
  from pathlib import Path
9
 
10
  from fastapi import FastAPI
 
11
  from fastapi.responses import FileResponse
12
  from fastapi.staticfiles import StaticFiles
13
 
@@ -16,9 +17,7 @@ DATA_DIR = ROOT / "data" / "aggregated"
16
  STATIC_DIR = ROOT / "static"
17
 
18
  app = FastAPI(title="French Property Prices", docs_url=None, redoc_url=None)
19
-
20
- # Serve static files (JS, CSS)
21
- app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
22
 
23
 
24
  @app.get("/")
@@ -26,18 +25,9 @@ async def index():
26
  return FileResponse(STATIC_DIR / "index.html")
27
 
28
 
29
- MEDIA_TYPES = {
30
- ".json": "application/json",
31
- ".geojson": "application/geo+json",
32
- ".pmtiles": "application/octet-stream",
33
- }
34
-
35
 
36
- @app.get("/data/{filename:path}")
37
- async def get_data(filename: str):
38
- """Serve pre-computed data files (JSON, GeoJSON, PMTiles)."""
39
- path = DATA_DIR / filename
40
- if not path.exists() or not path.is_file():
41
- return {"error": "not found"}, 404
42
- media = MEDIA_TYPES.get(path.suffix, "application/octet-stream")
43
- return FileResponse(path, media_type=media)
 
8
  from pathlib import Path
9
 
10
  from fastapi import FastAPI
11
+ from fastapi.middleware.gzip import GZipMiddleware
12
  from fastapi.responses import FileResponse
13
  from fastapi.staticfiles import StaticFiles
14
 
 
17
  STATIC_DIR = ROOT / "static"
18
 
19
  app = FastAPI(title="French Property Prices", docs_url=None, redoc_url=None)
20
+ app.add_middleware(GZipMiddleware, minimum_size=1000)
 
 
21
 
22
 
23
  @app.get("/")
 
25
  return FileResponse(STATIC_DIR / "index.html")
26
 
27
 
28
+ # Serve static files (JS, CSS)
29
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
 
 
 
 
30
 
31
+ # Serve data files (JSON, GeoJSON, PMTiles) - StaticFiles handles range requests
32
+ # which is required for PMTiles (HTTP range-based tile fetching)
33
+ app.mount("/data", StaticFiles(directory=DATA_DIR), name="data")
 
 
 
 
 
data/aggregated/postcodes.geojson CHANGED
The diff for this file is too large to render. See raw diff
 
data/geo/contours-codes-postaux.geojson DELETED
The diff for this file is too large to render. See raw diff
 
data/geo/france_outline.geojson DELETED
The diff for this file is too large to render. See raw diff
 
scripts/build_postcode_geojson.py CHANGED
@@ -43,15 +43,21 @@ ARRONDISSEMENT_CITIES = {
43
  }
44
 
45
 
46
- def fetch_url(url: str) -> dict | None:
47
- """Fetch JSON from a URL."""
48
- try:
49
- req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
50
- with urllib.request.urlopen(req, timeout=30) as resp:
51
- return json.loads(resp.read().decode())
52
- except Exception as e:
53
- print(f" WARNING: Failed to fetch {url}: {e}")
54
- return None
 
 
 
 
 
 
55
 
56
 
57
  def fetch_dept_communes(dept_code: str) -> list[dict]:
@@ -128,8 +134,7 @@ def build_postcode_geojson():
128
  total_communes += 1
129
 
130
  # Be polite to the API
131
- if i % 10 == 9:
132
- time.sleep(0.5)
133
 
134
  # Step 1b: Fetch arrondissements for Paris/Lyon/Marseille
135
  for city_code, city_name in ARRONDISSEMENT_CITIES.items():
@@ -169,9 +174,9 @@ def build_postcode_geojson():
169
  dissolved = gdf.dissolve(by="codePostal", aggfunc="first").reset_index()
170
  print(f" Dissolved features: {len(dissolved)}")
171
 
172
- # Step 4: Simplify geometries for web (tolerance ~330m, fine for zoom 11-13)
173
  print("\nSimplifying geometries...")
174
- dissolved["geometry"] = dissolved["geometry"].simplify(tolerance=0.003, preserve_topology=True)
175
 
176
  # Step 5: Export
177
  print(f"\nExporting to {OUTPUT_PATH}...")
@@ -184,7 +189,7 @@ def build_postcode_geojson():
184
 
185
  def round_coords(coords):
186
  if isinstance(coords[0], (int, float)):
187
- return [round(c, 4) for c in coords]
188
  return [round_coords(c) for c in coords]
189
 
190
  for feature in geojson["features"]:
 
43
  }
44
 
45
 
46
+ def fetch_url(url: str, retries: int = 3) -> dict | None:
47
+ """Fetch JSON from a URL with retries."""
48
+ for attempt in range(retries):
49
+ try:
50
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
51
+ with urllib.request.urlopen(req, timeout=60) as resp:
52
+ return json.loads(resp.read().decode())
53
+ except Exception as e:
54
+ if attempt < retries - 1:
55
+ wait = 2 ** attempt
56
+ print(f" retry {attempt+1} in {wait}s...", end=" ", flush=True)
57
+ time.sleep(wait)
58
+ else:
59
+ print(f" WARNING: Failed to fetch {url}: {e}")
60
+ return None
61
 
62
 
63
  def fetch_dept_communes(dept_code: str) -> list[dict]:
 
134
  total_communes += 1
135
 
136
  # Be polite to the API
137
+ time.sleep(0.3)
 
138
 
139
  # Step 1b: Fetch arrondissements for Paris/Lyon/Marseille
140
  for city_code, city_name in ARRONDISSEMENT_CITIES.items():
 
174
  dissolved = gdf.dissolve(by="codePostal", aggfunc="first").reset_index()
175
  print(f" Dissolved features: {len(dissolved)}")
176
 
177
+ # Step 4: Simplify geometries for web (tolerance ~55m, smooth at zoom 12-13)
178
  print("\nSimplifying geometries...")
179
+ dissolved["geometry"] = dissolved["geometry"].simplify(tolerance=0.0005, preserve_topology=True)
180
 
181
  # Step 5: Export
182
  print(f"\nExporting to {OUTPUT_PATH}...")
 
189
 
190
  def round_coords(coords):
191
  if isinstance(coords[0], (int, float)):
192
+ return [round(c, 5) for c in coords]
193
  return [round_coords(c) for c in coords]
194
 
195
  for feature in geojson["features"]:
scripts/build_sections_pmtiles.sh CHANGED
@@ -1,5 +1,5 @@
1
  #!/bin/bash
2
- # Download all per-department cadastral section GeoJSON, enrich with price data, and build PMTiles
3
  set -e
4
 
5
  BASEDIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -31,29 +31,37 @@ for dept in $DEPTS; do
31
  done
32
 
33
  echo ""
34
- echo "=== Step 2: Enriching GeoJSON with price data ==="
35
- python3 "$BASEDIR/scripts/enrich_sections.py"
36
 
37
  echo ""
38
- echo "=== Step 3: Building PMTiles with tippecanoe ==="
39
  echo "Input files: $(ls "$ENRICHED"/*.geojson 2>/dev/null | wc -l)"
40
 
41
  # Use main disk for tippecanoe temp files (not /tmp which may be tmpfs)
42
  export TMPDIR="$TMPDIR"
43
 
 
 
 
 
 
 
44
  tippecanoe \
45
  -o "$OUTDIR/sections.pmtiles" \
46
- -Z0 -z14 \
47
  -l sections \
 
 
 
 
 
48
  --no-feature-limit \
49
- --no-tile-size-limit \
50
- --drop-densest-as-needed \
51
- --extend-zooms-if-still-dropping \
52
  --force \
53
  "$ENRICHED"/*.geojson
54
 
55
  echo ""
56
  ls -lh "$OUTDIR/sections.pmtiles"
57
- echo "=== Done! Cleaning up ==="
58
 
59
- rm -rf "$TMPDIR" "$ENRICHED"
 
1
  #!/bin/bash
2
+ # Download all per-department cadastral section GeoJSON, enrich with price buckets, and build optimized PMTiles
3
  set -e
4
 
5
  BASEDIR="$(cd "$(dirname "$0")/.." && pwd)"
 
31
  done
32
 
33
  echo ""
34
+ echo "=== Step 2: Enriching GeoJSON with price bucket data ==="
35
+ uv run python "$BASEDIR/scripts/enrich_sections.py"
36
 
37
  echo ""
38
+ echo "=== Step 3: Building optimized PMTiles with tippecanoe ==="
39
  echo "Input files: $(ls "$ENRICHED"/*.geojson 2>/dev/null | wc -l)"
40
 
41
  # Use main disk for tippecanoe temp files (not /tmp which may be tmpfs)
42
  export TMPDIR="$TMPDIR"
43
 
44
+ # Key optimizations vs the old 867MB build:
45
+ # -y: only include bucket + price properties (strips cadastre metadata)
46
+ # --coalesce-densest-as-needed: merge dense areas at low zoom (vs drop-densest which removes them)
47
+ # --detect-shared-borders: consistent simplification at polygon edges
48
+ # --simplification=10: aggressive simplification at low zoom
49
+ # --no-tiny-polygon-reduction: keep all polygons for choropleth coverage
50
  tippecanoe \
51
  -o "$OUTDIR/sections.pmtiles" \
52
+ -Z4 -z14 \
53
  -l sections \
54
+ -y id -y bt -y ba -y bm -y pt -y pa -y pm \
55
+ --coalesce-densest-as-needed \
56
+ --detect-shared-borders \
57
+ --simplification=10 \
58
+ --no-tiny-polygon-reduction \
59
  --no-feature-limit \
 
 
 
60
  --force \
61
  "$ENRICHED"/*.geojson
62
 
63
  echo ""
64
  ls -lh "$OUTDIR/sections.pmtiles"
65
+ echo "=== Done! Cleaning up temp files ==="
66
 
67
+ rm -rf "$BASEDIR/.tmp_sections" "$BASEDIR/.tmp_sections_enriched"
scripts/enrich_sections.py CHANGED
@@ -1,17 +1,15 @@
1
  """
2
- Enrich section GeoJSON files with pre-computed price data.
3
 
4
  For each department:
5
  1. Load the section GeoJSON (geometry)
6
  2. Load the section price JSON (prices)
7
- 3. Join prices into feature properties with short keys
8
- 4. Write enriched GeoJSON
9
-
10
- Short property keys to minimize tile size:
11
- pt/pa/pm = wtm price (tous/appt/maison)
12
- vt/va/vm = volume
13
- mt/ma/mm = median
14
- ct/ca/cm = confidence
15
  """
16
 
17
  import json
@@ -23,9 +21,56 @@ PRICE_DIR = BASEDIR / "data" / "aggregated" / "sections"
23
  GEO_DIR = BASEDIR / ".tmp_sections"
24
  OUT_DIR = BASEDIR / ".tmp_sections_enriched"
25
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- def enrich_department(dept_code: str) -> int:
28
- """Enrich one department's section GeoJSON with price data. Returns feature count."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  geo_path = GEO_DIR / f"{dept_code}.geojson"
30
  price_path = PRICE_DIR / f"{dept_code}.json"
31
  out_path = OUT_DIR / f"{dept_code}.geojson"
@@ -47,19 +92,28 @@ def enrich_department(dept_code: str) -> int:
47
  props = feature.get("properties", {})
48
  section_id = props.get("id", "")
49
 
 
 
 
50
  if section_id in prices:
51
  p = prices[section_id]
52
  matched += 1
53
 
54
- for suffix, type_key in [("t", "tous"), ("a", "appartement"), ("m", "maison")]:
55
  stats = p.get(type_key)
56
- if stats:
57
- props[f"p{suffix}"] = round(stats.get("wtm", 0), 1)
58
- props[f"v{suffix}"] = stats.get("volume", 0)
59
- props[f"m{suffix}"] = round(stats.get("median", 0), 1)
60
- props[f"c{suffix}"] = round(stats.get("confidence", 0), 3)
61
-
62
- feature["properties"] = props
 
 
 
 
 
 
63
 
64
  with open(out_path, "w") as f:
65
  json.dump(geo, f, separators=(",", ":"))
@@ -72,16 +126,19 @@ def enrich_department(dept_code: str) -> int:
72
  def main():
73
  OUT_DIR.mkdir(parents=True, exist_ok=True)
74
 
 
 
 
75
  # Get all department codes from GeoJSON files
76
  dept_codes = sorted(p.stem for p in GEO_DIR.glob("*.geojson"))
77
  if not dept_codes:
78
  print(f"No GeoJSON files found in {GEO_DIR}")
79
  sys.exit(1)
80
 
81
- print(f"Enriching {len(dept_codes)} departments...")
82
  total = 0
83
  for code in dept_codes:
84
- total += enrich_department(code)
85
 
86
  print(f"\nDone: {total} total features enriched")
87
  print(f"Output: {OUT_DIR}")
 
1
  """
2
+ Enrich section GeoJSON files with pre-computed price color buckets.
3
 
4
  For each department:
5
  1. Load the section GeoJSON (geometry)
6
  2. Load the section price JSON (prices)
7
+ 3. Assign color buckets based on GLOBAL decile boundaries
8
+ 4. Write enriched GeoJSON with minimal properties
9
+
10
+ Properties added (short keys to minimize tile size):
11
+ bt/ba/bm = color bucket 1-10 (tous/appt/maison), 0 = no data
12
+ pt/pa/pm = raw wtm price rounded to int (for tooltip/filtering)
 
 
13
  """
14
 
15
  import json
 
21
  GEO_DIR = BASEDIR / ".tmp_sections"
22
  OUT_DIR = BASEDIR / ".tmp_sections_enriched"
23
 
24
+ NUM_BUCKETS = 10
25
+ TYPES = [("tous", "t"), ("appartement", "a"), ("maison", "m")]
26
+
27
+
28
+ def compute_decile_boundaries(prices: list[float]) -> list[float]:
29
+ """Compute NUM_BUCKETS-1 boundaries for decile-based bucketing."""
30
+ if not prices:
31
+ return []
32
+ prices = sorted(prices)
33
+ n = len(prices)
34
+ return [prices[min(int(n * i / NUM_BUCKETS), n - 1)] for i in range(1, NUM_BUCKETS)]
35
+
36
 
37
+ def assign_bucket(price: float, boundaries: list[float]) -> int:
38
+ """Assign a price to bucket 1-NUM_BUCKETS. Returns 0 if no data."""
39
+ if not boundaries or price <= 0:
40
+ return 0
41
+ for i, b in enumerate(boundaries):
42
+ if price <= b:
43
+ return i + 1
44
+ return NUM_BUCKETS
45
+
46
+
47
+ def compute_global_boundaries() -> dict[str, list[float]]:
48
+ """Read all department price files and compute global decile boundaries."""
49
+ print("Computing global decile boundaries from all departments...")
50
+ all_prices = {suffix: [] for _, suffix in TYPES}
51
+
52
+ price_files = sorted(PRICE_DIR.glob("*.json"))
53
+ for pf in price_files:
54
+ with open(pf) as f:
55
+ data = json.load(f)
56
+ for section_id, section_data in data.items():
57
+ for type_key, suffix in TYPES:
58
+ stats = section_data.get(type_key)
59
+ if stats and stats.get("wtm", 0) > 0:
60
+ all_prices[suffix].append(stats["wtm"])
61
+
62
+ boundaries = {}
63
+ for type_key, suffix in TYPES:
64
+ bounds = compute_decile_boundaries(all_prices[suffix])
65
+ boundaries[suffix] = bounds
66
+ print(f" {type_key}: {len(all_prices[suffix])} values, "
67
+ f"deciles: {[round(b) for b in bounds]}")
68
+
69
+ return boundaries
70
+
71
+
72
+ def enrich_department(dept_code: str, boundaries: dict) -> int:
73
+ """Enrich one department's section GeoJSON with bucket data."""
74
  geo_path = GEO_DIR / f"{dept_code}.geojson"
75
  price_path = PRICE_DIR / f"{dept_code}.json"
76
  out_path = OUT_DIR / f"{dept_code}.geojson"
 
92
  props = feature.get("properties", {})
93
  section_id = props.get("id", "")
94
 
95
+ # Keep only the id property, strip everything else
96
+ new_props = {"id": section_id}
97
+
98
  if section_id in prices:
99
  p = prices[section_id]
100
  matched += 1
101
 
102
+ for type_key, suffix in TYPES:
103
  stats = p.get(type_key)
104
+ if stats and stats.get("wtm", 0) > 0:
105
+ wtm = stats["wtm"]
106
+ new_props[f"b{suffix}"] = assign_bucket(wtm, boundaries[suffix])
107
+ new_props[f"p{suffix}"] = round(wtm)
108
+ else:
109
+ new_props[f"b{suffix}"] = 0
110
+ new_props[f"p{suffix}"] = 0
111
+ else:
112
+ for _, suffix in TYPES:
113
+ new_props[f"b{suffix}"] = 0
114
+ new_props[f"p{suffix}"] = 0
115
+
116
+ feature["properties"] = new_props
117
 
118
  with open(out_path, "w") as f:
119
  json.dump(geo, f, separators=(",", ":"))
 
126
  def main():
127
  OUT_DIR.mkdir(parents=True, exist_ok=True)
128
 
129
+ # Compute global boundaries first
130
+ boundaries = compute_global_boundaries()
131
+
132
  # Get all department codes from GeoJSON files
133
  dept_codes = sorted(p.stem for p in GEO_DIR.glob("*.geojson"))
134
  if not dept_codes:
135
  print(f"No GeoJSON files found in {GEO_DIR}")
136
  sys.exit(1)
137
 
138
+ print(f"\nEnriching {len(dept_codes)} departments...")
139
  total = 0
140
  for code in dept_codes:
141
+ total += enrich_department(code, boundaries)
142
 
143
  print(f"\nDone: {total} total features enriched")
144
  print(f"Output: {OUT_DIR}")
static/app.js CHANGED
@@ -170,6 +170,7 @@ let searchDropdownIdx = -1; // keyboard nav index for search dropdown
170
  let activeLevel = 0; // index into LEVELS — controlled by radio buttons only
171
  let selectedAreaCode = null; // code of area selected in the area list (country/region/dept)
172
  let selectedDeptCode = null; // department selected for commune/postcode/section levels
 
173
  let map;
174
 
175
  // ---- Color Interpolation ----
@@ -459,6 +460,7 @@ async function onMapLoad() {
459
  // Advance to next level
460
  activeLevel = levelIdx + 1;
461
  selectedAreaCode = null;
 
462
  document.querySelectorAll('input[name="map-level"]')[activeLevel].checked = true;
463
  updateLayerVisibility();
464
  updateHighlight();
@@ -490,6 +492,22 @@ async function onMapLoad() {
490
  paint: { "fill-opacity": 0 },
491
  });
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  // Re-apply colors when vector tiles finish loading (they stream per viewport)
494
  let sourcedataTimer = null;
495
  map.on("sourcedata", (e) => {
@@ -885,6 +903,12 @@ function selectArea(code, dataKey) {
885
  selectedAreaCode = code;
886
  }
887
 
 
 
 
 
 
 
888
  // Update list highlighting
889
  document.querySelectorAll(".area-item").forEach(el => {
890
  el.classList.toggle("selected", el.dataset.code === selectedAreaCode);
@@ -916,6 +940,7 @@ async function selectDeptForLevel(deptCode) {
916
  } else {
917
  selectedDeptCode = deptCode;
918
  }
 
919
 
920
  // Update list highlighting
921
  document.querySelectorAll(".area-item").forEach(el => {
@@ -964,13 +989,23 @@ function updateHighlight() {
964
  const hlId = `${LEVELS[i].dataKey}-highlight`;
965
  try { map.setLayoutProperty(hlId, "visibility", "none"); } catch (e) {}
966
  }
 
967
 
968
- // For dept-required levels: highlight the selected dept on the department highlight layer
969
  if (LEVELS[activeLevel].needsDept && selectedDeptCode) {
 
970
  try {
971
- map.setFilter("department-highlight", ["==", ["get", "code"], selectedDeptCode]);
972
- map.setLayoutProperty("department-highlight", "visibility", "visible");
973
  } catch (e) {}
 
 
 
 
 
 
 
 
974
  return;
975
  }
976
 
@@ -1096,19 +1131,43 @@ function closeSearchDropdown() {
1096
  searchDropdownIdx = -1;
1097
  }
1098
 
1099
- function onSelectPlace(commune) {
1100
  closeSearchDropdown();
1101
 
1102
  const input = document.getElementById("place-search");
1103
  input.value = commune.nom;
1104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  // Fly to the commune
1106
  if (commune.centre && commune.centre.coordinates) {
1107
  const [lon, lat] = commune.centre.coordinates;
1108
- let zoom = 13;
1109
  if (commune.population > 500000) zoom = 11;
1110
- else if (commune.population > 100000) zoom = 12;
1111
- else if (commune.population > 20000) zoom = 12.5;
1112
 
1113
  map.flyTo({ center: [lon, lat], zoom, duration: 1500 });
1114
  }
@@ -1178,6 +1237,12 @@ function initSearch() {
1178
  if (query.length < 2) {
1179
  closeSearchDropdown();
1180
  lastResults = [];
 
 
 
 
 
 
1181
  return;
1182
  }
1183
  debounceTimer = setTimeout(async () => {
@@ -1228,11 +1293,14 @@ function initSearch() {
1228
  async function setLevel(levelIdx) {
1229
  activeLevel = levelIdx;
1230
  selectedAreaCode = null; // Clear area selection
 
1231
 
1232
- // If switching to a non-dept level, clear dept selection
1233
- if (!LEVELS[levelIdx].needsDept) {
1234
- selectedDeptCode = null;
1235
- }
 
 
1236
 
1237
  updateLayerVisibility();
1238
  updateHighlight();
@@ -1317,6 +1385,7 @@ document.addEventListener("DOMContentLoaded", () => {
1317
  try { map.setPaintProperty(`${level.dataKey}-highlight`, "line-color", hlColor); } catch (e) {}
1318
  try { map.setPaintProperty(`${level.dataKey}-hover`, "line-color", hlColor); } catch (e) {}
1319
  }
 
1320
  });
1321
  // Restore saved theme (default: dark)
1322
  const saved = localStorage.getItem("theme");
 
170
  let activeLevel = 0; // index into LEVELS — controlled by radio buttons only
171
  let selectedAreaCode = null; // code of area selected in the area list (country/region/dept)
172
  let selectedDeptCode = null; // department selected for commune/postcode/section levels
173
+ let searchedCommuneCode = null; // commune code from search (for highlighting)
174
  let map;
175
 
176
  // ---- Color Interpolation ----
 
460
  // Advance to next level
461
  activeLevel = levelIdx + 1;
462
  selectedAreaCode = null;
463
+ searchedCommuneCode = null;
464
  document.querySelectorAll('input[name="map-level"]')[activeLevel].checked = true;
465
  updateLayerVisibility();
466
  updateHighlight();
 
492
  paint: { "fill-opacity": 0 },
493
  });
494
 
495
+ // Detailed department boundary from vector tiles (used at commune/postcode/section levels)
496
+ // This replaces the simplified local GeoJSON boundary for dept-required levels
497
+ map.addLayer({
498
+ id: "dept-vector-highlight",
499
+ type: "line",
500
+ source: "admin-tiles",
501
+ "source-layer": "departements",
502
+ layout: { visibility: "none" },
503
+ filter: ["==", ["get", "code"], ""],
504
+ paint: {
505
+ "line-color": document.body.classList.contains("dark-mode") ? "#ffffff" : "#000000",
506
+ "line-width": 3,
507
+ "line-opacity": 1,
508
+ },
509
+ });
510
+
511
  // Re-apply colors when vector tiles finish loading (they stream per viewport)
512
  let sourcedataTimer = null;
513
  map.on("sourcedata", (e) => {
 
903
  selectedAreaCode = code;
904
  }
905
 
906
+ // When selecting a department from the dept-level list, also set selectedDeptCode
907
+ // so that switching to commune/postcode/section shows data for that dept
908
+ if (dataKey === "department") {
909
+ selectedDeptCode = selectedAreaCode; // null if deselected, code if selected
910
+ }
911
+
912
  // Update list highlighting
913
  document.querySelectorAll(".area-item").forEach(el => {
914
  el.classList.toggle("selected", el.dataset.code === selectedAreaCode);
 
940
  } else {
941
  selectedDeptCode = deptCode;
942
  }
943
+ searchedCommuneCode = null; // Clear search highlight when manually selecting dept
944
 
945
  // Update list highlighting
946
  document.querySelectorAll(".area-item").forEach(el => {
 
989
  const hlId = `${LEVELS[i].dataKey}-highlight`;
990
  try { map.setLayoutProperty(hlId, "visibility", "none"); } catch (e) {}
991
  }
992
+ try { map.setLayoutProperty("dept-vector-highlight", "visibility", "none"); } catch (e) {}
993
 
994
+ // For dept-required levels: show dept boundary + optional searched commune
995
  if (LEVELS[activeLevel].needsDept && selectedDeptCode) {
996
+ // Use the detailed vector-tile dept boundary (not the simplified local GeoJSON)
997
  try {
998
+ map.setFilter("dept-vector-highlight", ["==", ["get", "code"], selectedDeptCode]);
999
+ map.setLayoutProperty("dept-vector-highlight", "visibility", "visible");
1000
  } catch (e) {}
1001
+
1002
+ // Also highlight the searched commune if at commune level
1003
+ if (searchedCommuneCode && LEVELS[activeLevel].dataKey === "commune") {
1004
+ try {
1005
+ map.setFilter("commune-highlight", ["==", ["get", "code"], searchedCommuneCode]);
1006
+ map.setLayoutProperty("commune-highlight", "visibility", "visible");
1007
+ } catch (e) {}
1008
+ }
1009
  return;
1010
  }
1011
 
 
1131
  searchDropdownIdx = -1;
1132
  }
1133
 
1134
+ async function onSelectPlace(commune) {
1135
  closeSearchDropdown();
1136
 
1137
  const input = document.getElementById("place-search");
1138
  input.value = commune.nom;
1139
 
1140
+ // Determine department code from the commune
1141
+ const deptCode = commune.departement ? commune.departement.code : deptFromCode(commune.code);
1142
+
1143
+ // Switch to commune level and set the department
1144
+ const communeLevelIdx = 3; // Commune is index 3 in LEVELS
1145
+ activeLevel = communeLevelIdx;
1146
+ selectedDeptCode = deptCode;
1147
+ selectedAreaCode = null;
1148
+ searchedCommuneCode = commune.code;
1149
+
1150
+ // Update radio button to reflect commune level
1151
+ document.querySelectorAll('input[name="map-level"]')[activeLevel].checked = true;
1152
+
1153
+ // Load section data in the background (for potential drill-down)
1154
+ loadSectionDataForDept(deptCode);
1155
+
1156
+ // Update all map visuals
1157
+ updateLayerVisibility();
1158
+ updateHighlight();
1159
+ updatePriceRangeForLevel();
1160
+ updateColors();
1161
+ updateDynamicStat();
1162
+ populateAreaList();
1163
+
1164
  // Fly to the commune
1165
  if (commune.centre && commune.centre.coordinates) {
1166
  const [lon, lat] = commune.centre.coordinates;
1167
+ let zoom = 12;
1168
  if (commune.population > 500000) zoom = 11;
1169
+ else if (commune.population > 100000) zoom = 11.5;
1170
+ else if (commune.population > 20000) zoom = 12;
1171
 
1172
  map.flyTo({ center: [lon, lat], zoom, duration: 1500 });
1173
  }
 
1237
  if (query.length < 2) {
1238
  closeSearchDropdown();
1239
  lastResults = [];
1240
+ // When input is cleared completely, reset search state
1241
+ if (query.length === 0) {
1242
+ searchedCommuneCode = null;
1243
+ document.getElementById("place-details").classList.add("hidden");
1244
+ updateHighlight();
1245
+ }
1246
  return;
1247
  }
1248
  debounceTimer = setTimeout(async () => {
 
1293
  async function setLevel(levelIdx) {
1294
  activeLevel = levelIdx;
1295
  selectedAreaCode = null; // Clear area selection
1296
+ searchedCommuneCode = null; // Clear search highlight
1297
 
1298
+ // Note: selectedDeptCode is intentionally NOT cleared when switching levels.
1299
+ // This preserves the department context so switching between levels
1300
+ // (e.g. Commune → Dept → back to Commune) keeps the dept selection.
1301
+
1302
+ // Hide search details panel when switching levels
1303
+ document.getElementById("place-details").classList.add("hidden");
1304
 
1305
  updateLayerVisibility();
1306
  updateHighlight();
 
1385
  try { map.setPaintProperty(`${level.dataKey}-highlight`, "line-color", hlColor); } catch (e) {}
1386
  try { map.setPaintProperty(`${level.dataKey}-hover`, "line-color", hlColor); } catch (e) {}
1387
  }
1388
+ try { map.setPaintProperty("dept-vector-highlight", "line-color", hlColor); } catch (e) {}
1389
  });
1390
  // Restore saved theme (default: dark)
1391
  const saved = localStorage.getItem("theme");