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

feat: government vector tiles, dept selection, fix section rendering

Browse files

Replace self-hosted PMTiles (867MB) and large GeoJSON with government
vector tile APIs for commune and section levels. Add department selection
workflow for commune/postcode/section levels. Fix section data loading
when switching levels with dept already selected.

- Use openmaptiles.geo.data.gouv.fr admin tiles for communes
- Use openmaptiles.geo.data.gouv.fr cadastre tiles for sections
- On-demand section data loading per department
- Sourcedata event handler for tile re-coloring
- New GeoJSON files for regions and departments
- Updated notebooks and build scripts

.gitignore CHANGED
@@ -4,6 +4,9 @@ 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
 
8
  # ---- Python ----
9
  __pycache__/
 
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__/
app.py CHANGED
@@ -26,10 +26,18 @@ async def index():
26
  return FileResponse(STATIC_DIR / "index.html")
27
 
28
 
 
 
 
 
 
 
 
29
  @app.get("/data/{filename:path}")
30
  async def get_data(filename: str):
31
- """Serve pre-computed JSON data files."""
32
  path = DATA_DIR / filename
33
  if not path.exists() or not path.is_file():
34
  return {"error": "not found"}, 404
35
- return FileResponse(path, media_type="application/json")
 
 
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)
data/aggregated/departements.geojson ADDED
The diff for this file is too large to render. See raw diff
 
data/aggregated/postcodes.geojson CHANGED
The diff for this file is too large to render. See raw diff
 
data/aggregated/regions.geojson ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/01_data_exploration.ipynb DELETED
The diff for this file is too large to render. See raw diff
 
notebooks/01_eda.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/{03_challenge_answers.ipynb → 02_challenge_answers.ipynb} RENAMED
@@ -21,8 +21,7 @@
21
  "5. [Commune Level](#5.-Commune)\n",
22
  "6. [Postcode Level](#6.-Postcode)\n",
23
  "7. [Cadastral Section Level](#7.-Section)\n",
24
- "8. [Top 10 Cities Deliverable](#8.-Top-10-Cities)\n",
25
- "9. [Evaluation Checklist](#9.-Evaluation)"
26
  ]
27
  },
28
  {
@@ -38,6 +37,7 @@
38
  {
39
  "cell_type": "code",
40
  "execution_count": null,
 
41
  "metadata": {},
42
  "outputs": [],
43
  "source": [
@@ -77,6 +77,7 @@
77
  {
78
  "cell_type": "code",
79
  "execution_count": null,
 
80
  "metadata": {},
81
  "outputs": [],
82
  "source": [
@@ -103,6 +104,7 @@
103
  {
104
  "cell_type": "code",
105
  "execution_count": null,
 
106
  "metadata": {},
107
  "outputs": [],
108
  "source": [
@@ -147,6 +149,7 @@
147
  },
148
  {
149
  "cell_type": "markdown",
 
150
  "metadata": {},
151
  "source": [
152
  "---\n",
@@ -158,6 +161,7 @@
158
  {
159
  "cell_type": "code",
160
  "execution_count": null,
 
161
  "metadata": {},
162
  "outputs": [],
163
  "source": [
@@ -189,6 +193,7 @@
189
  },
190
  {
191
  "cell_type": "markdown",
 
192
  "metadata": {},
193
  "source": [
194
  "---\n",
@@ -200,6 +205,7 @@
200
  {
201
  "cell_type": "code",
202
  "execution_count": null,
 
203
  "metadata": {},
204
  "outputs": [],
205
  "source": [
@@ -241,6 +247,7 @@
241
  },
242
  {
243
  "cell_type": "markdown",
 
244
  "metadata": {},
245
  "source": [
246
  "---\n",
@@ -252,6 +259,7 @@
252
  {
253
  "cell_type": "code",
254
  "execution_count": null,
 
255
  "metadata": {},
256
  "outputs": [],
257
  "source": [
@@ -294,6 +302,7 @@
294
  {
295
  "cell_type": "code",
296
  "execution_count": null,
 
297
  "metadata": {},
298
  "outputs": [],
299
  "source": [
@@ -332,6 +341,7 @@
332
  },
333
  {
334
  "cell_type": "markdown",
 
335
  "metadata": {},
336
  "source": [
337
  "---\n",
@@ -343,6 +353,7 @@
343
  {
344
  "cell_type": "code",
345
  "execution_count": null,
 
346
  "metadata": {},
347
  "outputs": [],
348
  "source": [
@@ -392,6 +403,7 @@
392
  },
393
  {
394
  "cell_type": "markdown",
 
395
  "metadata": {},
396
  "source": [
397
  "---\n",
@@ -403,6 +415,7 @@
403
  {
404
  "cell_type": "code",
405
  "execution_count": null,
 
406
  "metadata": {},
407
  "outputs": [],
408
  "source": [
@@ -459,6 +472,7 @@
459
  },
460
  {
461
  "cell_type": "markdown",
 
462
  "metadata": {},
463
  "source": [
464
  "---\n",
@@ -472,6 +486,7 @@
472
  {
473
  "cell_type": "code",
474
  "execution_count": null,
 
475
  "metadata": {},
476
  "outputs": [],
477
  "source": [
@@ -514,6 +529,7 @@
514
  {
515
  "cell_type": "code",
516
  "execution_count": null,
 
517
  "metadata": {},
518
  "outputs": [],
519
  "source": [
@@ -541,17 +557,36 @@
541
  },
542
  {
543
  "cell_type": "markdown",
 
544
  "metadata": {},
545
  "source": [
546
  "---\n",
547
  "## 8. Top 10 Cities Deliverable\n",
548
  "\n",
549
- "Market price per m² by property type for the 10 largest French cities."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  ]
551
  },
552
  {
553
  "cell_type": "code",
554
  "execution_count": null,
 
555
  "metadata": {},
556
  "outputs": [],
557
  "source": [
@@ -585,6 +620,7 @@
585
  {
586
  "cell_type": "code",
587
  "execution_count": null,
 
588
  "metadata": {},
589
  "outputs": [],
590
  "source": [
@@ -609,67 +645,6 @@
609
  "plt.show()"
610
  ]
611
  },
612
- {
613
- "cell_type": "markdown",
614
- "metadata": {},
615
- "source": [
616
- "---\n",
617
- "## 9. Evaluation Checklist\n",
618
- "\n",
619
- "| # | Criterion | Status | Evidence |\n",
620
- "|---|-----------|--------|----------|\n",
621
- "| 1 | Is the colored map loading? | **Yes** | Live app at `localhost:7860`, maps render in <2s |\n",
622
- "| 2 | Is the map usable and not laggy? | **Yes** | Vector tiles + pre-computed JSON = instant transitions |\n",
623
- "| 3 | Is the map refreshing aggregation level on zoom? | **Yes** | 6 zoom breakpoints trigger level switches |\n",
624
- "| 4 | Are all 6 aggregation levels present? | **Yes** | Country, Region, Department, Commune, Postcode, Section |\n",
625
- "| 5 | Are the price estimates plausible? | **Yes** | Validated against DVF Official & RealAdvisor (see Notebook 1 §8) |\n",
626
- "| 6 | Is the data complete or was it subset? | **Complete** | All 97 departments, 33k+ communes, 260k+ sections |\n",
627
- "| 7 | The processing code is clean, clear and reusable | **Yes** | Modular `src/` package: config, downloader, cleaner, aggregator, top_cities |\n",
628
- "| 8 | The architecture is robust and logical | **Yes** | Pipeline: download → clean → aggregate → export → serve |\n",
629
- "| 9 | App is hosted and functional | **Yes** | Docker + Hugging Face Spaces deployment |"
630
- ]
631
- },
632
- {
633
- "cell_type": "code",
634
- "execution_count": null,
635
- "metadata": {},
636
- "outputs": [],
637
- "source": [
638
- "# Architecture summary\n",
639
- "print(\"\"\"\n",
640
- "PROJECT ARCHITECTURE\n",
641
- "====================\n",
642
- "\n",
643
- "┌─────────────────┐ ┌────────────────┐ ┌─────────────────┐\n",
644
- "│ DVF Raw Data │ ───> │ src/cleaner.py │ ───> │ Clean Parquet │\n",
645
- "│ (5 years, ~3GB) │ │ - filter │ │ (~4.6M rows) │\n",
646
- "└─────────────────┘ │ - dedup │ └───────┬─────────┘\n",
647
- " │ - outliers │ │\n",
648
- " └────────────────┘ │\n",
649
- " │\n",
650
- " ┌───────────────────────────────┘\n",
651
- " │\n",
652
- " ┌────┴────────────┐ ┌─────────────────┐\n",
653
- " │ src/aggregator │ ───> │ JSON files │\n",
654
- " │ - WTM │ │ - 6 levels │\n",
655
- " │ - n_eff │ │ - 3 prop types │\n",
656
- " │ - confidence │ └───────┬─────────┘\n",
657
- " └─────────────────┘ │\n",
658
- " │\n",
659
- " ┌─────────────────────────────┘\n",
660
- " │\n",
661
- " ┌────┴────────────┐ ┌─────────────────┐\n",
662
- " │ FastAPI + │ ───> │ MapLibre GL JS │\n",
663
- " │ Static Server │ │ + Vector Tiles │\n",
664
- " └─────────────────┘ └─────────────────┘\n",
665
- "\n",
666
- "METHODOLOGY: Time-Weighted Trimmed Mean (WTM)\n",
667
- "- Temporal decay: λ=0.97/month (half-life ≈23 months)\n",
668
- "- Trim: 20% from each tail (robust to outliers)\n",
669
- "- Quality: Kish's n_eff + composite confidence score\n",
670
- "\"\"\")"
671
- ]
672
- },
673
  {
674
  "cell_type": "markdown",
675
  "metadata": {},
 
21
  "5. [Commune Level](#5.-Commune)\n",
22
  "6. [Postcode Level](#6.-Postcode)\n",
23
  "7. [Cadastral Section Level](#7.-Section)\n",
24
+ "8. [Top 10 Cities Deliverable](#8.-Top-10-Cities)\n"
 
25
  ]
26
  },
27
  {
 
37
  {
38
  "cell_type": "code",
39
  "execution_count": null,
40
+ "id": "ddb44b5b",
41
  "metadata": {},
42
  "outputs": [],
43
  "source": [
 
77
  {
78
  "cell_type": "code",
79
  "execution_count": null,
80
+ "id": "b17b7899",
81
  "metadata": {},
82
  "outputs": [],
83
  "source": [
 
104
  {
105
  "cell_type": "code",
106
  "execution_count": null,
107
+ "id": "67148b23",
108
  "metadata": {},
109
  "outputs": [],
110
  "source": [
 
149
  },
150
  {
151
  "cell_type": "markdown",
152
+ "id": "69a68011",
153
  "metadata": {},
154
  "source": [
155
  "---\n",
 
161
  {
162
  "cell_type": "code",
163
  "execution_count": null,
164
+ "id": "c9882b61",
165
  "metadata": {},
166
  "outputs": [],
167
  "source": [
 
193
  },
194
  {
195
  "cell_type": "markdown",
196
+ "id": "e0c60acc",
197
  "metadata": {},
198
  "source": [
199
  "---\n",
 
205
  {
206
  "cell_type": "code",
207
  "execution_count": null,
208
+ "id": "43dbebd0",
209
  "metadata": {},
210
  "outputs": [],
211
  "source": [
 
247
  },
248
  {
249
  "cell_type": "markdown",
250
+ "id": "17b47e41",
251
  "metadata": {},
252
  "source": [
253
  "---\n",
 
259
  {
260
  "cell_type": "code",
261
  "execution_count": null,
262
+ "id": "1fe214a6",
263
  "metadata": {},
264
  "outputs": [],
265
  "source": [
 
302
  {
303
  "cell_type": "code",
304
  "execution_count": null,
305
+ "id": "4a1a77b6",
306
  "metadata": {},
307
  "outputs": [],
308
  "source": [
 
341
  },
342
  {
343
  "cell_type": "markdown",
344
+ "id": "725801e8",
345
  "metadata": {},
346
  "source": [
347
  "---\n",
 
353
  {
354
  "cell_type": "code",
355
  "execution_count": null,
356
+ "id": "a1fef1bc",
357
  "metadata": {},
358
  "outputs": [],
359
  "source": [
 
403
  },
404
  {
405
  "cell_type": "markdown",
406
+ "id": "0e2d7269",
407
  "metadata": {},
408
  "source": [
409
  "---\n",
 
415
  {
416
  "cell_type": "code",
417
  "execution_count": null,
418
+ "id": "3a0338a5",
419
  "metadata": {},
420
  "outputs": [],
421
  "source": [
 
472
  },
473
  {
474
  "cell_type": "markdown",
475
+ "id": "95cafc9a",
476
  "metadata": {},
477
  "source": [
478
  "---\n",
 
486
  {
487
  "cell_type": "code",
488
  "execution_count": null,
489
+ "id": "2aec53bd",
490
  "metadata": {},
491
  "outputs": [],
492
  "source": [
 
529
  {
530
  "cell_type": "code",
531
  "execution_count": null,
532
+ "id": "3c9c39a2",
533
  "metadata": {},
534
  "outputs": [],
535
  "source": [
 
557
  },
558
  {
559
  "cell_type": "markdown",
560
+ "id": "6f8bdca0",
561
  "metadata": {},
562
  "source": [
563
  "---\n",
564
  "## 8. Top 10 Cities Deliverable\n",
565
  "\n",
566
+ "Market price per m² by property type for the 10 largest French cities by commune population.\n",
567
+ "\n",
568
+ "**Source:** [INSEE Recensement de la population — Population municipale des communes](https://www.data.gouv.fr/datasets/population-municipale-des-communes-france-entiere)\n",
569
+ "\n",
570
+ "| Rank | City | Population | INSEE Code |\n",
571
+ "|------|------|-----------|------------|\n",
572
+ "| 1 | Paris | 2,103,778 | 75056 |\n",
573
+ "| 2 | Marseille | 886,040 | 13055 |\n",
574
+ "| 3 | Lyon | 519,127 | 69123 |\n",
575
+ "| 4 | Toulouse | 514,819 | 31555 |\n",
576
+ "| 5 | Nice | 357,737 | 06088 |\n",
577
+ "| 6 | Nantes | 327,734 | 44109 |\n",
578
+ "| 7 | Montpellier | 310,240 | 34172 |\n",
579
+ "| 8 | Strasbourg* | 293,771 | 67482 |\n",
580
+ "| 9 | Bordeaux | 267,991 | 33063 |\n",
581
+ "| 10 | Lille | 238,246 | 59350 |\n",
582
+ "\n",
583
+ "*\\*Strasbourg (dept 67, Alsace-Moselle) has no DVF data available.*"
584
  ]
585
  },
586
  {
587
  "cell_type": "code",
588
  "execution_count": null,
589
+ "id": "dd01d013",
590
  "metadata": {},
591
  "outputs": [],
592
  "source": [
 
620
  {
621
  "cell_type": "code",
622
  "execution_count": null,
623
+ "id": "97dd0ef5",
624
  "metadata": {},
625
  "outputs": [],
626
  "source": [
 
645
  "plt.show()"
646
  ]
647
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  {
649
  "cell_type": "markdown",
650
  "metadata": {},
notebooks/02_data_exploration.ipynb DELETED
The diff for this file is too large to render. See raw diff
 
scripts/build_postcode_geojson.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Build clean postcode GeoJSON by dissolving commune boundaries by postcode.
3
+
4
+ Downloads commune contours from geo.api.gouv.fr (per department),
5
+ maps each commune to its postcode(s), and dissolves geometries by postcode.
6
+ This produces non-overlapping postcode polygons.
7
+
8
+ Key design decision: each commune is assigned to ONE postcode only (the first
9
+ in sorted order) to prevent overlapping boundaries. For Paris/Lyon/Marseille,
10
+ arrondissement boundaries are used instead of the meta-commune.
11
+ """
12
+
13
+ import json
14
+ import time
15
+ import urllib.request
16
+ import geopandas as gpd
17
+ import pandas as pd
18
+ from shapely.ops import unary_union
19
+ from pathlib import Path
20
+
21
+ OUTPUT_PATH = Path(__file__).parent.parent / "data" / "aggregated" / "postcodes.geojson"
22
+
23
+ # All metropolitan + DOM department codes
24
+ DEPT_CODES = [
25
+ "01","02","03","04","05","06","07","08","09","10",
26
+ "11","12","13","14","15","16","17","18","19","21",
27
+ "22","23","24","25","26","27","28","29","2A","2B",
28
+ "30","31","32","33","34","35","36","37","38","39",
29
+ "40","41","42","43","44","45","46","47","48","49",
30
+ "50","51","52","53","54","55","56","57","58","59",
31
+ "60","61","62","63","64","65","66","67","68","69",
32
+ "70","71","72","73","74","75","76","77","78","79",
33
+ "80","81","82","83","84","85","86","87","88","89",
34
+ "90","91","92","93","94","95",
35
+ "971","972","973","974","976",
36
+ ]
37
+
38
+ # Paris/Lyon/Marseille meta-communes that need arrondissement-level treatment
39
+ ARRONDISSEMENT_CITIES = {
40
+ "75056": "Paris",
41
+ "69123": "Lyon",
42
+ "13055": "Marseille",
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]:
58
+ """Fetch communes with contours for a department from geo.api.gouv.fr."""
59
+ url = (
60
+ f"https://geo.api.gouv.fr/departements/{dept_code}/communes"
61
+ f"?fields=code,nom,codesPostaux,contour&format=geojson&geometry=contour"
62
+ )
63
+ data = fetch_url(url)
64
+ return data.get("features", []) if data else []
65
+
66
+
67
+ def fetch_arrondissements(city_code: str) -> list[dict]:
68
+ """Fetch arrondissement boundaries for Paris/Lyon/Marseille."""
69
+ url = (
70
+ f"https://geo.api.gouv.fr/communes/{city_code}"
71
+ f"?type=arrondissement-municipal"
72
+ f"&fields=code,nom,codesPostaux,contour&format=geojson"
73
+ )
74
+ data = fetch_url(url)
75
+ if data and "features" in data:
76
+ return data["features"]
77
+ # API might return a single object instead of FeatureCollection
78
+ if data and data.get("type") == "Feature":
79
+ return [data]
80
+ return []
81
+
82
+
83
+ def fetch_arrondissements_list(city_code: str) -> list[dict]:
84
+ """Fetch arrondissements for a city as a list of GeoJSON features."""
85
+ # The arrondissements API endpoint
86
+ url = (
87
+ f"https://geo.api.gouv.fr/communes"
88
+ f"?codeParent={city_code}&type=arrondissement-municipal"
89
+ f"&fields=code,nom,codesPostaux,contour&format=geojson&geometry=contour"
90
+ )
91
+ data = fetch_url(url)
92
+ if data and "features" in data:
93
+ return data["features"]
94
+ return []
95
+
96
+
97
+ def build_postcode_geojson():
98
+ """Main pipeline: download communes, dissolve by postcode, export GeoJSON."""
99
+ print("=== Building clean postcode boundaries ===\n")
100
+
101
+ # Step 1: Download all commune features with their postcodes
102
+ all_rows = [] # (postcode, geometry)
103
+ total_communes = 0
104
+ skip_codes = set(ARRONDISSEMENT_CITIES.keys())
105
+
106
+ for i, dept in enumerate(DEPT_CODES):
107
+ print(f"[{i+1}/{len(DEPT_CODES)}] Fetching dept {dept}...", end=" ", flush=True)
108
+ features = fetch_dept_communes(dept)
109
+ print(f"{len(features)} communes")
110
+
111
+ for f in features:
112
+ geom = f.get("geometry")
113
+ props = f.get("properties", {})
114
+ code = props.get("code", "")
115
+ postcodes = props.get("codesPostaux", [])
116
+
117
+ if not geom or not postcodes:
118
+ continue
119
+
120
+ # Skip meta-communes (Paris/Lyon/Marseille) - handle via arrondissements
121
+ if code in skip_codes:
122
+ continue
123
+
124
+ # Assign commune to ONE postcode only (first in sorted order)
125
+ # This prevents overlapping boundaries for multi-postcode communes
126
+ pc = sorted(postcodes)[0]
127
+ all_rows.append({"codePostal": pc, "geometry": geom})
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():
136
+ print(f"Fetching {city_name} arrondissements...", end=" ", flush=True)
137
+ features = fetch_arrondissements_list(city_code)
138
+ print(f"{len(features)} arrondissements")
139
+
140
+ for f in features:
141
+ geom = f.get("geometry")
142
+ props = f.get("properties", {})
143
+ postcodes = props.get("codesPostaux", [])
144
+
145
+ if not geom or not postcodes:
146
+ continue
147
+
148
+ # Each arrondissement typically maps to one postcode
149
+ pc = sorted(postcodes)[0]
150
+ all_rows.append({"codePostal": pc, "geometry": geom})
151
+ total_communes += 1
152
+
153
+ time.sleep(0.3)
154
+
155
+ print(f"\nTotal communes/arrondissements fetched: {total_communes}")
156
+ print(f"Total rows: {len(all_rows)}")
157
+
158
+ # Step 2: Build GeoDataFrame
159
+ print("\nBuilding GeoDataFrame...")
160
+ gdf = gpd.GeoDataFrame.from_features(
161
+ [{"type": "Feature", "geometry": r["geometry"], "properties": {"codePostal": r["codePostal"]}} for r in all_rows],
162
+ crs="EPSG:4326",
163
+ )
164
+ print(f" Shape: {gdf.shape}")
165
+ print(f" Unique postcodes: {gdf['codePostal'].nunique()}")
166
+
167
+ # Step 3: Dissolve by postcode (union geometries from different communes)
168
+ print("\nDissolving by postcode...")
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}...")
178
+ dissolved.to_file(OUTPUT_PATH, driver="GeoJSON")
179
+
180
+ # Post-process: reduce coordinate precision
181
+ print("Post-processing: reducing coordinate precision...")
182
+ with open(OUTPUT_PATH) as f:
183
+ geojson = json.load(f)
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"]:
191
+ feature["geometry"]["coordinates"] = round_coords(feature["geometry"]["coordinates"])
192
+
193
+ with open(OUTPUT_PATH, "w") as f:
194
+ json.dump(geojson, f, separators=(",", ":"))
195
+
196
+ final_size = OUTPUT_PATH.stat().st_size / (1024 * 1024)
197
+ print(f"\nDone! Output: {OUTPUT_PATH}")
198
+ print(f" Features: {len(geojson['features'])}")
199
+ print(f" File size: {final_size:.1f} MB")
200
+
201
+
202
+ if __name__ == "__main__":
203
+ build_postcode_geojson()
scripts/build_sections_pmtiles.sh ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)"
6
+ OUTDIR="$BASEDIR/data/aggregated"
7
+ TMPDIR="$BASEDIR/.tmp_sections"
8
+ ENRICHED="$BASEDIR/.tmp_sections_enriched"
9
+ mkdir -p "$TMPDIR"
10
+ BASE_URL="https://cadastre.data.gouv.fr/data/etalab-cadastre/latest/geojson/departements"
11
+
12
+ # All French departments
13
+ DEPTS="01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 2A 2B 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 971 972 973 974 976"
14
+
15
+ echo "=== Step 1: Downloading section GeoJSON per department ==="
16
+ for dept in $DEPTS; do
17
+ outfile="$TMPDIR/${dept}.geojson"
18
+ if [ -f "$outfile" ]; then
19
+ echo " $dept: exists, skip"
20
+ continue
21
+ fi
22
+ url="${BASE_URL}/${dept}/cadastre-${dept}-sections.json.gz"
23
+ echo -n " $dept: "
24
+ if curl -sL --fail "$url" | gunzip > "$outfile" 2>/dev/null; then
25
+ size=$(du -h "$outfile" | cut -f1)
26
+ echo "$size"
27
+ else
28
+ echo "FAILED (no data?)"
29
+ rm -f "$outfile"
30
+ fi
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"
scripts/download_sections_geo.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download per-department cadastral section GeoJSON from cadastre.data.gouv.fr."""
2
+
3
+ import gzip
4
+ import json
5
+ import urllib.request
6
+ from pathlib import Path
7
+
8
+ OUT_DIR = Path(__file__).resolve().parent.parent / "data" / "aggregated" / "sections_geo"
9
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
10
+
11
+ BASE_URL = "https://cadastre.data.gouv.fr/data/etalab-cadastre/latest/geojson/departements"
12
+
13
+ # All departments (mainland + overseas)
14
+ DEPTS = (
15
+ [f"{i:02d}" for i in range(1, 20)]
16
+ + ["2A", "2B"]
17
+ + [f"{i:02d}" for i in range(21, 96)]
18
+ + ["971", "972", "973", "974", "976"]
19
+ )
20
+
21
+
22
+ def download_dept(code: str) -> None:
23
+ out_path = OUT_DIR / f"{code}.geojson"
24
+ if out_path.exists():
25
+ print(f" {code}: already exists, skipping")
26
+ return
27
+
28
+ url = f"{BASE_URL}/{code}/cadastre-{code}-sections.json.gz"
29
+ print(f" {code}: downloading from {url} ...")
30
+ try:
31
+ resp = urllib.request.urlopen(url, timeout=60)
32
+ raw = gzip.decompress(resp.read())
33
+ # Validate it's proper JSON
34
+ data = json.loads(raw)
35
+ n = len(data.get("features", []))
36
+ with open(out_path, "wb") as f:
37
+ f.write(raw)
38
+ size_mb = out_path.stat().st_size / (1024 * 1024)
39
+ print(f" {code}: {n} sections, {size_mb:.1f} MB")
40
+ except Exception as e:
41
+ print(f" {code}: FAILED - {e}")
42
+
43
+
44
+ def main():
45
+ print(f"Downloading section GeoJSON to {OUT_DIR}")
46
+ for code in DEPTS:
47
+ download_dept(code)
48
+ print("Done!")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
scripts/enrich_sections.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ BASEDIR = Path(__file__).resolve().parent.parent
22
+ 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"
32
+
33
+ if not geo_path.exists():
34
+ print(f" {dept_code}: no GeoJSON, skip")
35
+ return 0
36
+
37
+ with open(geo_path) as f:
38
+ geo = json.load(f)
39
+
40
+ prices = {}
41
+ if price_path.exists():
42
+ with open(price_path) as f:
43
+ prices = json.load(f)
44
+
45
+ matched = 0
46
+ for feature in geo.get("features", []):
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=(",", ":"))
66
+
67
+ total = len(geo.get("features", []))
68
+ print(f" {dept_code}: {matched}/{total} matched")
69
+ return total
70
+
71
+
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}")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
src/config.py CHANGED
@@ -109,7 +109,10 @@ for key, depts_str in _REGION_DEPTS.items():
109
  NO_DVF_DEPARTMENTS = {"57", "67", "68", "976"}
110
 
111
  # ---------------------------------------------------------------------------
112
- # Top 10 cities by population (INSEE code → name)
 
 
 
113
  # ---------------------------------------------------------------------------
114
  TOP_10_CITIES: dict[str, str] = {
115
  "75056": "Paris",
 
109
  NO_DVF_DEPARTMENTS = {"57", "67", "68", "976"}
110
 
111
  # ---------------------------------------------------------------------------
112
+ # Top 10 cities by commune population (INSEE code → name)
113
+ # Source: INSEE Recensement de la population
114
+ # https://www.data.gouv.fr/datasets/population-municipale-des-communes-france-entiere
115
+ # Note: Strasbourg (67482) is in Alsace-Moselle — no DVF data available
116
  # ---------------------------------------------------------------------------
117
  TOP_10_CITIES: dict[str, str] = {
118
  "75056": "Paris",
static/app.js CHANGED
@@ -2,21 +2,26 @@
2
  * French Property Price Map
3
  * Interactive choropleth using MapLibre GL JS + government vector tiles.
4
  * 6 aggregation levels: Country > Region > Department > Commune > Postcode > Section
 
 
 
 
 
 
 
 
 
5
  */
6
 
7
  // ---- Configuration ----
8
- const TILE_SOURCES = {
9
- admin: "https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json",
10
- cadastre: "https://openmaptiles.geo.data.gouv.fr/data/cadastre-dvf.json",
11
- };
12
 
13
  const LEVELS = [
14
- { name: "Country", zoom: [0, 5], source: "france", layer: null, codeField: "code", dataKey: "country", targetZoom: 4, isGeojson: true },
15
- { name: "Region", zoom: [5, 7], source: "admin", layer: "regions", codeField: "code", dataKey: "region", targetZoom: 5.5 },
16
- { name: "Department", zoom: [7, 9], source: "admin", layer: "departements", codeField: "code", dataKey: "department", targetZoom: 8 },
17
- { name: "Commune", zoom: [9, 11], source: "admin", layer: "communes", codeField: "code", dataKey: "commune", targetZoom: 10 },
18
- { name: "Postcode", zoom: [11, 13], source: "postcode", layer: null, codeField: "codePostal",dataKey: "postcode", targetZoom: 12, isGeojson: true },
19
- { name: "Section", zoom: [13, 24], source: "cadastre", layer: "sections", codeField: "id", dataKey: "section", targetZoom: 15 },
20
  ];
21
 
22
  const COLOR_STOPS = [
@@ -158,14 +163,13 @@ const DEPARTMENTS = {
158
 
159
  // ---- State ----
160
  let priceData = {};
161
- let topCities = {};
162
  let sectionCache = {};
163
  let currentType = "tous";
164
- let priceFilter = { min: 0, max: PRICE_SLIDER_MAX };
165
- let exploreCount = 5;
166
- let checkedRegions = new Set(); // empty = all selected
167
- let checkedDepts = new Set(); // empty = all selected
168
- let searchQuery = "";
169
  let map;
170
 
171
  // ---- Color Interpolation ----
@@ -191,7 +195,9 @@ function buildColorScale(data, type) {
191
  const prices = [];
192
  for (const code in data) {
193
  const stats = data[code][type];
194
- if (stats && stats.wtm > 0) prices.push(stats.wtm);
 
 
195
  }
196
  if (prices.length === 0) return { scale: () => GRAY, min: 0, mid: 0, max: 0 };
197
 
@@ -230,6 +236,33 @@ function deptFromCode(code) {
230
  return code.substring(0, 2);
231
  }
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  // ---- Data Loading ----
234
 
235
  async function loadJSON(url) {
@@ -239,26 +272,23 @@ async function loadJSON(url) {
239
  }
240
 
241
  async function loadPriceData() {
242
- const [country, region, department, commune, postcode, cities] = await Promise.all([
243
  loadJSON("/data/prices_country.json"),
244
  loadJSON("/data/prices_region.json"),
245
  loadJSON("/data/prices_department.json"),
246
  loadJSON("/data/prices_commune.json"),
247
  loadJSON("/data/prices_postcode.json"),
248
- loadJSON("/data/top_cities.json"),
249
  ]);
250
  priceData = { country, region, department, commune, postcode };
251
- topCities = cities;
252
  }
253
 
254
- async function loadSectionData(deptCode) {
255
- if (sectionCache[deptCode]) return sectionCache[deptCode];
256
  try {
257
  const data = await loadJSON(`/data/sections/${deptCode}.json`);
258
  sectionCache[deptCode] = data;
259
- return data;
260
- } catch {
261
- return {};
262
  }
263
  }
264
 
@@ -296,28 +326,39 @@ function initMap() {
296
  }
297
 
298
  async function onMapLoad() {
299
- // Add vector tile sources
300
- map.addSource("admin", { type: "vector", url: TILE_SOURCES.admin });
301
- map.addSource("cadastre", { type: "vector", url: TILE_SOURCES.cadastre });
 
 
 
 
 
 
302
 
303
- // Load and add GeoJSON sources for country + postcode
304
- const [franceGeo, postcodeGeo] = await Promise.all([
305
  loadJSON("/data/france.geojson"),
 
 
306
  loadJSON("/data/postcodes.geojson"),
307
  ]);
308
 
309
  map.addSource("france", { type: "geojson", data: franceGeo });
 
 
310
  map.addSource("postcode", { type: "geojson", data: postcodeGeo });
311
 
312
  // Add choropleth fill + outline layers for each level
313
- for (const level of LEVELS) {
 
314
  const fillId = `${level.dataKey}-fill`;
315
  const lineId = `${level.dataKey}-line`;
 
316
 
317
  const layerBase = {
318
  source: level.source,
319
- minzoom: level.zoom[0],
320
- maxzoom: level.zoom[1],
321
  };
322
 
323
  // For vector tile sources, specify source-layer
@@ -346,295 +387,282 @@ async function onMapLoad() {
346
  },
347
  });
348
 
349
- // Hover interactions -> floating tooltip
350
- map.on("mousemove", fillId, (e) => showTooltip(e, level));
351
- map.on("mouseleave", fillId, hideTooltip);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
- // Click-to-drill-down: click a feature to zoom into the next level
354
- map.on("click", fillId, (e) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  if (!e.features || e.features.length === 0) return;
356
  const levelIdx = LEVELS.indexOf(level);
357
  if (levelIdx >= LEVELS.length - 1) return;
358
 
359
  const nextLevel = LEVELS[levelIdx + 1];
 
360
  const lngLat = e.lngLat;
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  let targetZoom = nextLevel.targetZoom;
363
- // For country click, recenter on France
364
  if (level.dataKey === "country") {
365
  map.flyTo({ center: FRANCE_CENTER, zoom: targetZoom, duration: 1000 });
366
- return;
367
- }
368
- // For dense areas, zoom a bit more
369
- const code = e.features[0].properties[level.codeField] || "";
370
- const denseDepts = ["75", "92", "93", "94", "69", "13"];
371
- if (level.dataKey === "department" && denseDepts.includes(code)) {
372
- targetZoom = Math.min(targetZoom + 1.5, nextLevel.zoom[1] - 0.5);
373
  }
374
 
375
- map.flyTo({ center: [lngLat.lng, lngLat.lat], zoom: targetZoom, duration: 1000 });
 
 
 
376
  });
377
  }
378
 
379
- // Invisible commune layer for name lookup at section/postcode zoom
380
  map.addLayer({
381
  id: "commune-query",
382
  type: "fill",
383
- source: "admin",
384
  "source-layer": "communes",
385
- minzoom: 11,
386
- maxzoom: 24,
387
  paint: { "fill-opacity": 0 },
388
  });
389
 
390
- // Initial paint
391
- updateColors();
392
- updateActiveLevel();
393
- updateExploreList();
394
- updateDynamicStat();
395
-
396
- // Re-paint when tiles arrive
397
  map.on("sourcedata", (e) => {
398
- if (e.isSourceLoaded && e.sourceId === "admin") {
399
- updateColors();
400
- }
401
- if (e.isSourceLoaded && e.sourceId === "cadastre" && map.getZoom() >= 13) {
402
- loadVisibleSections();
 
 
 
 
 
 
403
  }
404
  });
405
 
406
- map.on("zoom", () => {
407
- updateActiveLevel();
408
- if (map.getZoom() >= 13) loadVisibleSections();
409
- });
410
  map.on("moveend", () => {
411
- if (map.getZoom() >= 13) loadVisibleSections();
412
- updateExploreList();
413
  updateDynamicStat();
414
  });
415
  }
416
 
417
- // ---- Section Lazy Loading ----
418
-
419
- let _sectionLoadPending = false;
420
-
421
- async function loadVisibleSections() {
422
- if (_sectionLoadPending) return;
423
- _sectionLoadPending = true;
424
-
425
- try {
426
- const deptCodes = new Set();
427
- const features = map.queryRenderedFeatures({ layers: ["section-fill"] });
428
- for (const f of features) {
429
- const id = f.properties.id || "";
430
- const dept = deptFromCode(id);
431
- if (dept && dept.length >= 2) deptCodes.add(dept);
432
- }
433
-
434
- let changed = false;
435
- const loadPromises = [];
436
- for (const dept of deptCodes) {
437
- if (!sectionCache[dept]) {
438
- loadPromises.push(loadSectionData(dept).then(() => { changed = true; }));
439
- }
440
- }
441
- await Promise.all(loadPromises);
442
- if (changed) {
443
- updateSectionColors();
444
- updateExploreList();
445
- updateDynamicStat();
446
- }
447
- } finally {
448
- _sectionLoadPending = false;
449
- }
450
- }
451
-
452
  // ---- Color Updates ----
453
 
454
- function codePassesFilter(code, dataKey) {
455
- // Country: always passes
456
- if (dataKey === "country") return true;
457
- // Region: filter by checked regions
458
- if (dataKey === "region") {
459
- if (checkedRegions.size > 0) return checkedRegions.has(code);
460
- return true;
461
- }
462
- // Department: filter by checked depts, then by checked regions
463
- if (dataKey === "department") {
464
- if (checkedDepts.size > 0) return checkedDepts.has(code);
465
- if (checkedRegions.size > 0) {
466
- const info = DEPARTMENTS[code];
467
- return info && checkedRegions.has(info.region);
468
- }
469
- return true;
470
- }
471
- // Commune, postcode, section: use passesGeoFilter (dept-based)
472
- return passesGeoFilter(code);
473
- }
474
-
475
  function updateColors() {
476
  for (const level of LEVELS) {
477
- if (level.dataKey === "section") {
478
- updateSectionColors();
 
 
 
 
479
  continue;
480
  }
481
 
482
- const data = priceData[level.dataKey];
483
- if (!data || Object.keys(data).length === 0) continue;
484
-
485
  const { scale, min, mid, max } = buildColorScale(data, currentType);
486
- const fillId = `${level.dataKey}-fill`;
487
 
488
- // Country level is special: single feature colored by national price
489
  if (level.dataKey === "country") {
490
  const stats = data.FR && data.FR[currentType];
491
  const color = (stats && stats.wtm > 0) ? scale(stats.wtm) : GRAY;
492
- try {
493
- map.setPaintProperty(fillId, "fill-color", color);
494
- } catch (e) {
495
- console.warn(`Paint ${fillId}:`, e.message);
496
- }
497
- const zoom = map.getZoom();
498
- if (zoom >= level.zoom[0] && zoom < level.zoom[1]) {
499
- updateLegend(min, mid, max);
500
- }
501
  continue;
502
  }
503
 
 
504
  const matchExpr = ["match", ["get", level.codeField]];
505
  let count = 0;
506
 
507
  for (const code in data) {
508
  const stats = data[code][currentType];
509
  if (stats && stats.wtm > 0) {
510
- if (codePassesFilter(code, level.dataKey)) {
511
- matchExpr.push(code, scale(stats.wtm));
512
- } else {
513
- matchExpr.push(code, GRAY);
514
- }
515
  count++;
516
  }
517
  }
518
  matchExpr.push(GRAY);
519
 
520
  if (count > 0) {
521
- try {
522
- map.setPaintProperty(fillId, "fill-color", matchExpr);
523
- } catch (e) {
524
- console.warn(`Paint ${fillId}:`, e.message);
525
- }
526
  }
527
 
528
- const zoom = map.getZoom();
529
- if (zoom >= level.zoom[0] && zoom < level.zoom[1]) {
530
  updateLegend(min, mid, max);
531
  }
532
  }
533
  }
534
 
535
- function updateSectionColors() {
536
- const allSections = {};
537
- for (const dept in sectionCache) {
538
- Object.assign(allSections, sectionCache[dept]);
539
- }
540
- if (Object.keys(allSections).length === 0) return;
541
-
542
- const { scale, min, mid, max } = buildColorScale(allSections, currentType);
543
- const matchExpr = ["match", ["get", "id"]];
544
- let count = 0;
545
-
546
- for (const code in allSections) {
547
- const stats = allSections[code][currentType];
548
- if (stats && stats.wtm > 0) {
549
- matchExpr.push(code, scale(stats.wtm));
550
- count++;
551
- }
552
- }
553
- matchExpr.push(GRAY);
554
-
555
- if (count > 0) {
556
- try {
557
- map.setPaintProperty("section-fill", "fill-color", matchExpr);
558
- } catch (e) {
559
- console.warn("Paint sections:", e.message);
560
- }
561
- }
562
-
563
- if (map.getZoom() >= 13) updateLegend(min, mid, max);
564
- }
565
-
566
  function updateLegend(min, mid, max) {
567
  document.getElementById("legend-min").textContent = formatPrice(min);
568
  document.getElementById("legend-max").textContent = formatPrice(max);
569
  }
570
 
571
- function updateActiveLevel() {
572
- const zoom = map.getZoom();
573
- const radios = document.querySelectorAll('input[name="map-level"]');
574
- radios.forEach((radio, i) => {
575
  const level = LEVELS[i];
576
- if (zoom >= level.zoom[0] && zoom < level.zoom[1]) {
577
- radio.checked = true;
578
- }
579
- });
 
 
 
 
 
 
580
  }
581
 
582
  function updateDynamicStat() {
583
  const labelEl = document.getElementById("stat-label");
584
  const priceEl = document.getElementById("stat-price");
585
  const detailEl = document.getElementById("stat-detail");
586
-
587
  const level = getCurrentLevel();
 
588
 
589
- // If a single department is checked, show its stats
590
- if (checkedDepts.size === 1) {
591
- const deptCode = [...checkedDepts][0];
592
- const deptInfo = DEPARTMENTS[deptCode];
593
- const stats = priceData.department && priceData.department[deptCode] && priceData.department[deptCode][currentType];
594
- labelEl.textContent = deptInfo ? deptInfo.name : `Dept ${deptCode}`;
595
- priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014";
596
- detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  return;
598
  }
599
 
600
- // If a single region is checked, show its stats
601
- if (checkedRegions.size === 1 && checkedDepts.size === 0) {
602
- const regCode = [...checkedRegions][0];
603
- const regInfo = REGIONS[regCode];
604
- const stats = priceData.region && priceData.region[regCode] && priceData.region[regCode][currentType];
605
- labelEl.textContent = regInfo ? regInfo.name : `Region ${regCode}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014";
607
  detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : "";
608
  return;
609
  }
610
 
611
- // Level-aware: compute average from the current level's visible data
612
- const dataKey = level.dataKey;
613
-
614
  if (dataKey === "country") {
615
- // Country level: national average
616
- const country = priceData.country;
617
- if (country && country.FR) {
618
- const stats = country.FR[currentType];
619
- labelEl.textContent = "National Average";
620
- priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014";
621
- detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : "";
622
- } else {
623
- labelEl.textContent = "National Average";
624
- priceEl.textContent = "\u2014";
625
- detailEl.textContent = "";
626
- }
627
  return;
628
  }
629
 
630
- // For region/department/commune/postcode/section: compute weighted average from visible data
631
  const dataSet = priceData[dataKey] || {};
632
  let totalWeightedPrice = 0;
633
  let totalVolume = 0;
634
  let count = 0;
635
 
636
  for (const code in dataSet) {
637
- if (!passesGeoFilter(code)) continue;
638
  const stats = dataSet[code][currentType];
639
  if (!stats || !stats.wtm || !stats.volume) continue;
640
  if (stats.wtm < priceFilter.min || stats.wtm > priceFilter.max) continue;
@@ -643,15 +671,8 @@ function updateDynamicStat() {
643
  count++;
644
  }
645
 
646
- const levelNames = {
647
- region: "Region",
648
- department: "Department",
649
- commune: "Commune",
650
- postcode: "Postcode",
651
- section: "Section",
652
- };
653
  const avgPrice = totalVolume > 0 ? Math.round(totalWeightedPrice / totalVolume) : 0;
654
- labelEl.textContent = `${levelNames[dataKey] || level.name} Average`;
655
  priceEl.textContent = avgPrice > 0 ? formatPrice(avgPrice) : "\u2014";
656
  detailEl.textContent = totalVolume > 0 ? `${count} areas \u00b7 ${totalVolume.toLocaleString("fr-FR")} transactions` : "";
657
  }
@@ -695,33 +716,46 @@ function showTooltip(e, level) {
695
  } catch {}
696
  }
697
 
698
- let stats;
 
699
  if (level.dataKey === "section") {
700
- for (const dept in sectionCache) {
701
- if (sectionCache[dept][code]) {
702
- stats = sectionCache[dept][code][currentType];
703
- break;
704
- }
705
  }
706
  } else {
707
  const data = priceData[level.dataKey];
708
- if (data && data[code]) stats = data[code][currentType];
709
  }
710
 
 
711
  const tooltip = document.getElementById("tooltip");
712
  if (!stats) {
713
  tooltip.classList.add("hidden");
714
  return;
715
  }
716
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  tooltip.classList.remove("hidden");
718
  tooltip.innerHTML = `
719
  <div class="tt-name">${name}</div>
720
  <div class="tt-price">${formatPrice(stats.wtm)}</div>
721
  <div class="tt-details">
722
- <span>Median: ${formatPrice(stats.median)}</span>
723
- <span>${stats.volume.toLocaleString("fr-FR")} transactions</span>
724
- <span>Confidence: ${(stats.confidence * 100).toFixed(0)}%</span>
725
  </div>
726
  `;
727
 
@@ -743,525 +777,475 @@ function hideTooltip() {
743
  document.getElementById("tooltip").classList.add("hidden");
744
  }
745
 
746
- // ---- Checkbox Dropdown Component ----
747
-
748
- function buildCheckboxDropdown(toggleId, menuId, items, checkedSet, onChange) {
749
- const toggle = document.getElementById(toggleId);
750
- const menu = document.getElementById(menuId);
751
- const dropdown = toggle.parentElement;
752
-
753
- menu.innerHTML = "";
754
-
755
- // "All" checkbox
756
- const allItem = document.createElement("label");
757
- allItem.className = "cb-item cb-all";
758
- const allCb = document.createElement("input");
759
- allCb.type = "checkbox";
760
- allCb.checked = checkedSet.size === 0;
761
- allItem.appendChild(allCb);
762
- const allLabel = document.createElement("span");
763
- allLabel.className = "cb-item-label";
764
- allLabel.textContent = "All";
765
- allItem.appendChild(allLabel);
766
- menu.appendChild(allItem);
767
-
768
- const checkboxes = [];
769
-
770
- for (const { code, name, price } of items) {
771
- const item = document.createElement("label");
772
- item.className = "cb-item";
773
- const cb = document.createElement("input");
774
- cb.type = "checkbox";
775
- cb.dataset.code = code;
776
- cb.checked = checkedSet.size === 0 || checkedSet.has(code);
777
- checkboxes.push(cb);
778
- item.appendChild(cb);
779
- const lbl = document.createElement("span");
780
- lbl.className = "cb-item-label";
781
- lbl.textContent = name;
782
- item.appendChild(lbl);
783
- if (price) {
784
- const pr = document.createElement("span");
785
- pr.className = "cb-item-price";
786
- pr.textContent = formatPriceShort(price) + " \u20ac";
787
- item.appendChild(pr);
788
- }
789
- menu.appendChild(item);
790
- }
791
 
792
- function updateToggleLabel() {
793
- if (checkedSet.size === 0) {
794
- toggle.textContent = toggleId === "region-toggle" ? "All Regions" : "All Departments";
795
- } else if (checkedSet.size === 1) {
796
- const code = [...checkedSet][0];
797
- const found = items.find(it => it.code === code);
798
- toggle.textContent = found ? found.name : code;
799
- } else {
800
- toggle.textContent = `${checkedSet.size} selected`;
801
- }
802
- }
803
 
804
- allCb.addEventListener("change", () => {
805
- // Reset to "all" state
806
- checkedSet.clear();
807
- allCb.checked = true;
808
- checkboxes.forEach(cb => { cb.checked = true; });
809
- updateToggleLabel();
810
- onChange();
811
- });
812
 
813
- checkboxes.forEach(cb => {
814
- cb.addEventListener("change", () => {
815
- const isAllState = checkedSet.size === 0;
816
-
817
- if (!cb.checked) {
818
- // Unchecking an item
819
- if (isAllState) {
820
- // Transitioning from "all" → add everything EXCEPT this one
821
- checkboxes.forEach(c => {
822
- if (c !== cb) checkedSet.add(c.dataset.code);
823
- });
824
- } else {
825
- checkedSet.delete(cb.dataset.code);
826
- }
827
- // If nothing left, revert to "all"
828
- if (checkedSet.size === 0) {
829
- allCb.checked = true;
830
- checkboxes.forEach(c => { c.checked = true; });
831
- } else {
832
- allCb.checked = false;
833
- }
834
- } else {
835
- // Checking an item
836
- checkedSet.add(cb.dataset.code);
837
- // If all individually checked, switch back to "all" state
838
- const allChecked = checkboxes.every(c => c.checked);
839
- if (allChecked) {
840
- checkedSet.clear();
841
- allCb.checked = true;
842
- } else {
843
- allCb.checked = false;
844
- }
845
- }
846
- updateToggleLabel();
847
- onChange();
848
- });
849
- });
850
 
851
- // Prevent clicks inside the menu from closing the dropdown
852
- menu.addEventListener("click", (e) => {
853
- e.stopPropagation();
854
- });
 
 
855
 
856
- // Toggle open/close
857
- toggle.addEventListener("click", (e) => {
858
- e.stopPropagation();
859
- // Close other dropdowns
860
- document.querySelectorAll(".cb-dropdown.open").forEach(d => {
861
- if (d !== dropdown) d.classList.remove("open");
862
- });
863
- dropdown.classList.toggle("open");
864
- });
 
 
 
865
 
866
- updateToggleLabel();
867
- }
 
 
 
 
 
 
 
 
 
868
 
869
- // Close dropdowns on outside click
870
- document.addEventListener("click", () => {
871
- document.querySelectorAll(".cb-dropdown.open").forEach(d => d.classList.remove("open"));
872
- });
 
873
 
874
- // ---- Geographic Navigation ----
 
875
 
876
- function populateRegionDropdown() {
877
- const items = Object.entries(REGIONS)
878
- .filter(([code]) => priceData.region && priceData.region[code])
879
- .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr"))
880
- .map(([code, info]) => {
881
- const stats = priceData.region[code] && priceData.region[code][currentType];
882
- return { code, name: info.name, price: stats ? stats.wtm : 0 };
883
- });
884
 
885
- buildCheckboxDropdown("region-toggle", "region-menu", items, checkedRegions, onRegionFilterChange);
886
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
 
888
- function populateDeptDropdown() {
889
- const items = Object.entries(DEPARTMENTS)
890
- .filter(([code, info]) => {
891
- if (checkedRegions.size > 0 && !checkedRegions.has(info.region)) return false;
892
- return priceData.department && priceData.department[code];
893
- })
894
- .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr"))
895
- .map(([code, info]) => {
896
- const stats = priceData.department[code] && priceData.department[code][currentType];
897
- return { code, name: `${code} - ${info.name}`, price: stats ? stats.wtm : 0 };
898
- });
899
 
900
- buildCheckboxDropdown("dept-toggle", "dept-menu", items, checkedDepts, onDeptFilterChange);
 
 
 
 
 
 
 
 
 
 
901
  }
902
 
903
- function onRegionFilterChange() {
904
- populateDeptDropdown();
905
- updateColors();
906
- updateExploreList();
907
- updateDynamicStat();
908
-
909
- // If single region, fly to it
910
- if (checkedRegions.size === 1) {
911
- const code = [...checkedRegions][0];
912
- if (REGIONS[code]) {
913
- map.flyTo({ center: REGIONS[code].center, zoom: 5.5, duration: 1000 });
914
- }
915
  }
916
- }
917
 
918
- function onDeptFilterChange() {
919
- updateColors();
920
- updateExploreList();
921
- updateDynamicStat();
 
 
 
922
 
923
- // If single department, fly to it
924
- if (checkedDepts.size === 1) {
925
- const code = [...checkedDepts][0];
926
- if (DEPARTMENTS[code]) {
 
 
 
927
  const denseDepts = ["75", "92", "93", "94", "69", "13"];
928
  const zoom = denseDepts.includes(code) ? 10.5 : 9;
929
  map.flyTo({ center: DEPARTMENTS[code].center, zoom, duration: 1000 });
930
  }
931
  }
932
- }
933
 
934
- // Helper: check if a code passes the current department/region filter
935
- function passesGeoFilter(code) {
936
- const dept = deptFromCode(code);
937
- if (checkedDepts.size > 0) return checkedDepts.has(dept);
938
- if (checkedRegions.size > 0) {
939
- const deptInfo = DEPARTMENTS[dept];
940
- return deptInfo && checkedRegions.has(deptInfo.region);
941
- }
942
- return true;
943
  }
944
 
945
- // ---- Explore List (Dynamic Top N) ----
946
-
947
- function getCurrentLevel() {
948
- if (!map) return LEVELS[0];
949
- const zoom = map.getZoom();
950
- for (const level of LEVELS) {
951
- if (zoom >= level.zoom[0] && zoom < level.zoom[1]) return level;
952
  }
953
- return LEVELS[LEVELS.length - 1];
954
- }
955
 
956
- function getExploreItems() {
957
- const level = getCurrentLevel();
958
- const query = searchQuery.toLowerCase().trim();
959
- let items = [];
960
 
961
- if (level.dataKey === "country") {
962
- // At country level, show top cities
963
- return getTopCitiesItems();
964
- }
965
 
966
- if (level.dataKey === "region") {
967
- // Show regions ranked by price
968
- const data = priceData.region || {};
969
- for (const code in data) {
970
- const stats = data[code][currentType];
971
- if (!stats || !stats.wtm) continue;
972
- const name = REGIONS[code] ? REGIONS[code].name : code;
973
- if (query && !name.toLowerCase().includes(query) && !code.includes(query)) continue;
974
- items.push({ code, name, price: stats.wtm, volume: stats.volume, sub: `Region ${code}` });
975
  }
976
- } else if (level.dataKey === "department") {
977
- // Show departments, optionally filtered by checked regions
978
- const data = priceData.department || {};
979
- for (const code in data) {
980
- if (checkedRegions.size > 0 && DEPARTMENTS[code] && !checkedRegions.has(DEPARTMENTS[code].region)) continue;
981
- const stats = data[code][currentType];
982
- if (!stats || !stats.wtm) continue;
983
- const name = DEPARTMENTS[code] ? DEPARTMENTS[code].name : code;
984
- if (query && !name.toLowerCase().includes(query) && !code.includes(query)) continue;
985
- items.push({ code, name, price: stats.wtm, volume: stats.volume, sub: `Dept ${code}` });
986
  }
987
- } else if (level.dataKey === "commune") {
988
- // Get visible communes from rendered features + price data
989
- items = getVisibleFeatureItems("commune-fill", "code", "nom", priceData.commune || {});
990
- } else if (level.dataKey === "postcode") {
991
- // Get visible postcodes from rendered features + price data
992
- items = getVisiblePostcodeItems();
993
- } else if (level.dataKey === "section") {
994
- // Get visible sections from rendered features + section cache
995
- items = getVisibleSectionItems();
996
  }
997
 
998
- // Sort by price descending
999
- items.sort((a, b) => b.price - a.price);
1000
- return items.slice(0, exploreCount);
 
 
 
 
 
 
 
 
 
 
 
1001
  }
1002
 
1003
- function getTopCitiesItems() {
1004
- const query = searchQuery.toLowerCase().trim();
1005
- const items = [];
1006
- for (const [cityName, cityData] of Object.entries(topCities)) {
1007
- const stats = cityData[currentType] || cityData.tous;
1008
- if (!stats || !stats.wtm) continue;
1009
- if (query && !cityName.toLowerCase().includes(query)) continue;
1010
- items.push({
1011
- code: cityData.code,
1012
- name: cityName,
1013
- price: stats.wtm,
1014
- volume: stats.volume,
1015
- sub: `Commune ${cityData.code}`,
1016
- isCity: true,
1017
- });
1018
  }
1019
- items.sort((a, b) => b.price - a.price);
1020
- return items.slice(0, exploreCount);
1021
- }
1022
 
1023
- function getVisibleFeatureItems(layerId, codeField, nameField, priceSource) {
1024
- if (!map) return [];
1025
- const query = searchQuery.toLowerCase().trim();
1026
- const seen = new Set();
1027
- const items = [];
 
 
 
1028
 
1029
- try {
1030
- const features = map.queryRenderedFeatures({ layers: [layerId] });
1031
- for (const f of features) {
1032
- const code = f.properties[codeField];
1033
- if (!code || seen.has(code)) continue;
1034
- seen.add(code);
 
 
 
1035
 
1036
- // Filter by checked departments/regions
1037
- if (!passesGeoFilter(code)) continue;
1038
 
1039
- const stats = priceSource[code] && priceSource[code][currentType];
1040
- if (!stats || !stats.wtm) continue;
 
1041
 
1042
- const name = f.properties[nameField] || code;
1043
- if (query && !name.toLowerCase().includes(query) && !code.includes(query)) continue;
 
 
 
1044
 
1045
- const deptCode = deptFromCode(code);
1046
- const deptName = DEPARTMENTS[deptCode] ? DEPARTMENTS[deptCode].name : deptCode;
1047
- items.push({ code, name, price: stats.wtm, volume: stats.volume, sub: deptName });
1048
- }
1049
- } catch {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
 
1051
- return items;
1052
  }
1053
 
1054
- function getVisiblePostcodeItems() {
1055
- if (!map) return [];
1056
- const query = searchQuery.toLowerCase().trim();
1057
- const seen = new Set();
1058
- const items = [];
1059
- const priceSource = priceData.postcode || {};
1060
 
1061
- try {
1062
- const features = map.queryRenderedFeatures({ layers: ["postcode-fill"] });
1063
- for (const f of features) {
1064
- const code = f.properties.codePostal;
1065
- if (!code || seen.has(code)) continue;
1066
- seen.add(code);
1067
 
1068
- if (!passesGeoFilter(code)) continue;
 
 
 
 
 
 
 
 
 
 
1069
 
1070
- const stats = priceSource[code] && priceSource[code][currentType];
1071
- if (!stats || !stats.wtm) continue;
 
 
1072
 
1073
- // Try to get commune name from tiles
1074
- let name = code;
1075
- // For postcodes, we'll use code as primary display
1076
- if (query && !code.includes(query)) continue;
1077
 
1078
- const deptCode = deptFromCode(code);
1079
- const deptName = DEPARTMENTS[deptCode] ? DEPARTMENTS[deptCode].name : deptCode;
1080
- items.push({ code, name: `CP ${code}`, price: stats.wtm, volume: stats.volume, sub: deptName });
1081
- }
1082
- } catch {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1083
 
1084
- return items;
1085
  }
1086
 
1087
- function getVisibleSectionItems() {
1088
- if (!map) return [];
1089
- const query = searchQuery.toLowerCase().trim();
1090
- const seen = new Set();
1091
- const items = [];
1092
-
1093
- try {
1094
- const features = map.queryRenderedFeatures({ layers: ["section-fill"] });
1095
- for (const f of features) {
1096
- const code = f.properties.id;
1097
- if (!code || seen.has(code)) continue;
1098
- seen.add(code);
1099
-
1100
- const deptCode = deptFromCode(code);
1101
- if (!passesGeoFilter(code)) continue;
1102
-
1103
- // Look up in section cache
1104
- const deptData = sectionCache[deptCode];
1105
- if (!deptData || !deptData[code]) continue;
1106
- const stats = deptData[code][currentType];
1107
- if (!stats || !stats.wtm) continue;
1108
-
1109
- if (query && !code.includes(query)) continue;
1110
-
1111
- // Extract section letters from end of code
1112
- let i = code.length - 1;
1113
- while (i >= 0 && /[A-Z]/.test(code[i])) i--;
1114
- const letters = code.slice(i + 1);
1115
-
1116
- const deptName = DEPARTMENTS[deptCode] ? DEPARTMENTS[deptCode].name : deptCode;
1117
- items.push({ code, name: `Section ${letters}`, price: stats.wtm, volume: stats.volume, sub: `${deptName} \u00b7 ${code.substring(0, 5)}` });
1118
- }
1119
- } catch {}
1120
 
1121
- return items;
 
 
1122
  }
1123
 
1124
- function updateExploreList() {
1125
- const container = document.getElementById("explore-list");
1126
- const titleEl = document.getElementById("explore-title");
1127
- const level = getCurrentLevel();
1128
 
1129
- // Update title
1130
- const levelLabels = {
1131
- country: "Top Cities",
1132
- region: "Top Regions",
1133
- department: "Top Departments",
1134
- commune: "Top Communes",
1135
- postcode: "Top Postcodes",
1136
- section: "Top Sections",
1137
- };
1138
- titleEl.textContent = levelLabels[level.dataKey] || "Explore";
1139
-
1140
- const items = getExploreItems();
1141
- container.innerHTML = "";
1142
 
1143
- if (items.length === 0) {
1144
- container.innerHTML = '<div class="explore-empty">Zoom in or pan to see data</div>';
1145
- return;
 
 
 
 
 
 
1146
  }
1147
 
1148
- const maxPrice = Math.max(...items.map(it => it.price));
1149
-
1150
- items.forEach((item, i) => {
1151
- const barPct = maxPrice > 0 ? Math.round((item.price / maxPrice) * 100) : 0;
1152
- const row = document.createElement("div");
1153
- row.className = "explore-row";
1154
- row.innerHTML = `
1155
- <span class="explore-rank">${i + 1}</span>
1156
- <div class="explore-info">
1157
- <div class="explore-name">${item.name}</div>
1158
- <div class="explore-sub">${item.sub || ""}</div>
1159
- </div>
1160
- <span class="explore-price">${formatPriceShort(item.price)}</span>
1161
- <div class="explore-bar-wrap"><div class="explore-bar" style="width:${barPct}%"></div></div>
1162
- `;
1163
- row.addEventListener("click", () => onExploreItemClick(item));
1164
- container.appendChild(row);
1165
- });
1166
  }
1167
 
1168
- function onExploreItemClick(item) {
1169
- const level = getCurrentLevel();
 
1170
 
1171
- if (item.isCity) {
1172
- const coords = getCityCoords(item.code);
1173
- if (coords) map.flyTo({ center: coords, zoom: 10, duration: 1500 });
 
 
 
 
 
 
1174
  return;
1175
  }
1176
 
1177
- if (level.dataKey === "region" && REGIONS[item.code]) {
1178
- map.flyTo({ center: REGIONS[item.code].center, zoom: 7.5, duration: 1000 });
1179
- return;
1180
- }
1181
 
1182
- if (level.dataKey === "department" && DEPARTMENTS[item.code]) {
1183
- const denseDepts = ["75", "92", "93", "94", "69", "13"];
1184
- const zoom = denseDepts.includes(item.code) ? 10.5 : 9;
1185
- map.flyTo({ center: DEPARTMENTS[item.code].center, zoom, duration: 1000 });
1186
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
1187
  }
1188
 
1189
- // For commune/postcode/section: try to find the feature on the map to get coordinates
1190
- const fillLayers = {
1191
- commune: "commune-fill",
1192
- postcode: "postcode-fill",
1193
- section: "section-fill",
1194
- };
1195
- const layerId = fillLayers[level.dataKey];
1196
- if (layerId) {
1197
- try {
1198
- const codeField = LEVELS.find(l => l.dataKey === level.dataKey).codeField;
1199
- const features = map.queryRenderedFeatures({ layers: [layerId] });
1200
- for (const f of features) {
1201
- if (f.properties[codeField] === item.code) {
1202
- // Get approximate center from the feature geometry
1203
- const coords = getFeatureCenter(f);
1204
- if (coords) {
1205
- const nextZoom = Math.min(map.getZoom() + 2, 18);
1206
- map.flyTo({ center: coords, zoom: nextZoom, duration: 1000 });
1207
- return;
1208
- }
1209
- }
1210
- }
1211
- } catch {}
1212
- }
1213
  }
1214
 
1215
- function getFeatureCenter(feature) {
1216
- // Simple centroid approximation from geometry coordinates
1217
- const geom = feature.geometry;
1218
- if (!geom) return null;
1219
-
1220
- let coords;
1221
- if (geom.type === "Polygon") {
1222
- coords = geom.coordinates[0];
1223
- } else if (geom.type === "MultiPolygon") {
1224
- coords = geom.coordinates[0][0];
1225
- } else {
1226
- return null;
1227
- }
 
 
 
 
 
 
1228
 
1229
- if (!coords || coords.length === 0) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1230
 
1231
- let sumLng = 0, sumLat = 0;
1232
- for (const [lng, lat] of coords) {
1233
- sumLng += lng;
1234
- sumLat += lat;
1235
- }
1236
- return [sumLng / coords.length, sumLat / coords.length];
1237
- }
1238
 
1239
- function getCityCoords(code) {
1240
- const coords = {
1241
- "75056": [2.3522, 48.8566],
1242
- "13055": [5.3698, 43.2965],
1243
- "69123": [4.8357, 45.7640],
1244
- "31555": [1.4442, 43.6047],
1245
- "06088": [7.2620, 43.7102],
1246
- "44109": [-1.5536, 47.2184],
1247
- "34172": [3.8767, 43.6108],
1248
- "67482": [7.7521, 48.5734],
1249
- "33063": [-0.5792, 44.8378],
1250
- "59350": [3.0573, 50.6292],
1251
- };
1252
- return coords[code] || null;
1253
  }
1254
 
1255
  // ---- Level Switching ----
1256
 
1257
- function setLevel(levelIdx) {
1258
- const level = LEVELS[levelIdx];
1259
- if (levelIdx <= 1) {
1260
- // Country or Region: recenter on France
1261
- map.flyTo({ center: FRANCE_CENTER, zoom: level.targetZoom, duration: 800 });
1262
- } else {
1263
- map.flyTo({ zoom: level.targetZoom, duration: 800 });
1264
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1265
  }
1266
 
1267
  // ---- Price Filter ----
@@ -1282,6 +1266,7 @@ function syncPriceFromSliders() {
1282
 
1283
  updateSliderTrack();
1284
  updateColors();
 
1285
  }
1286
 
1287
  function syncPriceFromInputs() {
@@ -1300,11 +1285,15 @@ function syncPriceFromInputs() {
1300
 
1301
  updateSliderTrack();
1302
  updateColors();
 
1303
  }
1304
 
1305
  function updateSliderTrack() {
1306
- const pctMin = (priceFilter.min / PRICE_SLIDER_MAX) * 100;
1307
- const pctMax = Math.min((priceFilter.max / PRICE_SLIDER_MAX) * 100, 100);
 
 
 
1308
  const track = document.getElementById("slider-track");
1309
  if (track) {
1310
  track.style.left = pctMin + "%";
@@ -1322,6 +1311,12 @@ document.addEventListener("DOMContentLoaded", () => {
1322
  const isDark = document.body.classList.contains("dark-mode");
1323
  themeBtn.innerHTML = isDark ? "&#9790;" : "&#9728;";
1324
  localStorage.setItem("theme", isDark ? "dark" : "light");
 
 
 
 
 
 
1325
  });
1326
  // Restore saved theme (default: dark)
1327
  const saved = localStorage.getItem("theme");
@@ -1330,15 +1325,27 @@ document.addEventListener("DOMContentLoaded", () => {
1330
  themeBtn.innerHTML = "&#9728;";
1331
  }
1332
 
1333
- // Explore count pills
1334
- document.querySelectorAll(".count-pill").forEach(btn => {
1335
- btn.addEventListener("click", () => {
1336
- document.querySelectorAll(".count-pill").forEach(b => b.classList.remove("active"));
1337
- btn.classList.add("active");
1338
- exploreCount = parseInt(btn.dataset.count);
1339
- updateExploreList();
1340
- });
1341
- });
 
 
 
 
 
 
 
 
 
 
 
 
1342
 
1343
  // Level radio buttons
1344
  document.querySelectorAll('input[name="map-level"]').forEach(radio => {
@@ -1347,15 +1354,8 @@ document.addEventListener("DOMContentLoaded", () => {
1347
  });
1348
  });
1349
 
1350
- // Search
1351
- let searchTimeout;
1352
- document.getElementById("explore-search").addEventListener("input", (e) => {
1353
- clearTimeout(searchTimeout);
1354
- searchTimeout = setTimeout(() => {
1355
- searchQuery = e.target.value;
1356
- updateExploreList();
1357
- }, 200);
1358
- });
1359
 
1360
  // Price range sliders
1361
  document.getElementById("price-min-slider").addEventListener("input", syncPriceFromSliders);
@@ -1374,9 +1374,9 @@ document.addEventListener("DOMContentLoaded", () => {
1374
  (async function main() {
1375
  try {
1376
  await loadPriceData();
1377
- populateRegionDropdown();
1378
- populateDeptDropdown();
1379
  initMap();
 
 
1380
  } catch (err) {
1381
  console.error("Failed to initialize:", err);
1382
  document.body.innerHTML = `<div style="padding:40px;color:#ff6b6b;">
 
2
  * French Property Price Map
3
  * Interactive choropleth using MapLibre GL JS + government vector tiles.
4
  * 6 aggregation levels: Country > Region > Department > Commune > Postcode > Section
5
+ *
6
+ * Data sources:
7
+ * - Country/Region/Department: local GeoJSON (small files)
8
+ * - Commune: government admin vector tiles (openmaptiles.geo.data.gouv.fr)
9
+ * - Postcode: local GeoJSON (4MB, no government tile source)
10
+ * - Section: government cadastre vector tiles (openmaptiles.geo.data.gouv.fr)
11
+ *
12
+ * Commune, Postcode, and Section levels require a department selection first.
13
+ * Only the selected department's data is colored (fast match expressions).
14
  */
15
 
16
  // ---- Configuration ----
 
 
 
 
17
 
18
  const LEVELS = [
19
+ { name: "Country", source: "france", layer: null, codeField: "code", dataKey: "country", targetZoom: 4, isGeojson: true, needsDept: false },
20
+ { name: "Region", source: "regions", layer: null, codeField: "code", dataKey: "region", targetZoom: 5.5, isGeojson: true, needsDept: false },
21
+ { name: "Department", source: "depts", layer: null, codeField: "code", dataKey: "department", targetZoom: 8, isGeojson: true, needsDept: false },
22
+ { name: "Commune", source: "admin-tiles", layer: "communes", codeField: "code", dataKey: "commune", targetZoom: 10, isGeojson: false, needsDept: true },
23
+ { name: "Postcode", source: "postcode", layer: null, codeField: "codePostal", dataKey: "postcode", targetZoom: 12, isGeojson: true, needsDept: true },
24
+ { name: "Section", source: "cadastre-tiles", layer: "sections", codeField: "id", dataKey: "section", targetZoom: 14, isGeojson: false, needsDept: true },
25
  ];
26
 
27
  const COLOR_STOPS = [
 
163
 
164
  // ---- State ----
165
  let priceData = {};
 
166
  let sectionCache = {};
167
  let currentType = "tous";
168
+ let priceFilter = { min: 200, max: PRICE_SLIDER_MAX };
169
+ 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 ----
 
195
  const prices = [];
196
  for (const code in data) {
197
  const stats = data[code][type];
198
+ if (stats && stats.wtm > 0 && stats.wtm >= priceFilter.min && stats.wtm <= priceFilter.max) {
199
+ prices.push(stats.wtm);
200
+ }
201
  }
202
  if (prices.length === 0) return { scale: () => GRAY, min: 0, mid: 0, max: 0 };
203
 
 
236
  return code.substring(0, 2);
237
  }
238
 
239
+ // ---- Data Helpers ----
240
+
241
+ function filterByDept(data, deptCode) {
242
+ if (!data || !deptCode) return {};
243
+ const result = {};
244
+ for (const code in data) {
245
+ if (deptFromCode(code) === deptCode) {
246
+ result[code] = data[code];
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+
252
+ function getDisplayData(dataKey) {
253
+ if (dataKey === "country") return priceData.country;
254
+ if (dataKey === "region") return priceData.region;
255
+ if (dataKey === "department") return priceData.department;
256
+
257
+ // Dept-required levels: return only selected dept's data
258
+ if (!selectedDeptCode) return {};
259
+
260
+ if (dataKey === "commune") return filterByDept(priceData.commune, selectedDeptCode);
261
+ if (dataKey === "postcode") return filterByDept(priceData.postcode, selectedDeptCode);
262
+ if (dataKey === "section") return sectionCache[selectedDeptCode] || {};
263
+ return {};
264
+ }
265
+
266
  // ---- Data Loading ----
267
 
268
  async function loadJSON(url) {
 
272
  }
273
 
274
  async function loadPriceData() {
275
+ const [country, region, department, commune, postcode] = await Promise.all([
276
  loadJSON("/data/prices_country.json"),
277
  loadJSON("/data/prices_region.json"),
278
  loadJSON("/data/prices_department.json"),
279
  loadJSON("/data/prices_commune.json"),
280
  loadJSON("/data/prices_postcode.json"),
 
281
  ]);
282
  priceData = { country, region, department, commune, postcode };
 
283
  }
284
 
285
+ async function loadSectionDataForDept(deptCode) {
286
+ if (sectionCache[deptCode]) return;
287
  try {
288
  const data = await loadJSON(`/data/sections/${deptCode}.json`);
289
  sectionCache[deptCode] = data;
290
+ } catch (e) {
291
+ console.warn(`No section data for dept ${deptCode}`, e);
 
292
  }
293
  }
294
 
 
326
  }
327
 
328
  async function onMapLoad() {
329
+ // Government vector tile sources (streamed per viewport, very fast)
330
+ map.addSource("admin-tiles", {
331
+ type: "vector",
332
+ url: "https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json",
333
+ });
334
+ map.addSource("cadastre-tiles", {
335
+ type: "vector",
336
+ url: "https://openmaptiles.geo.data.gouv.fr/data/cadastre-dvf.json",
337
+ });
338
 
339
+ // Local GeoJSON sources (small static files)
340
+ const [franceGeo, regionsGeo, deptsGeo, postcodeGeo] = await Promise.all([
341
  loadJSON("/data/france.geojson"),
342
+ loadJSON("/data/regions.geojson"),
343
+ loadJSON("/data/departements.geojson"),
344
  loadJSON("/data/postcodes.geojson"),
345
  ]);
346
 
347
  map.addSource("france", { type: "geojson", data: franceGeo });
348
+ map.addSource("regions", { type: "geojson", data: regionsGeo });
349
+ map.addSource("depts", { type: "geojson", data: deptsGeo });
350
  map.addSource("postcode", { type: "geojson", data: postcodeGeo });
351
 
352
  // Add choropleth fill + outline layers for each level
353
+ for (let i = 0; i < LEVELS.length; i++) {
354
+ const level = LEVELS[i];
355
  const fillId = `${level.dataKey}-fill`;
356
  const lineId = `${level.dataKey}-line`;
357
+ const isActive = (i === activeLevel);
358
 
359
  const layerBase = {
360
  source: level.source,
361
+ layout: { visibility: isActive ? "visible" : "none" },
 
362
  };
363
 
364
  // For vector tile sources, specify source-layer
 
387
  },
388
  });
389
 
390
+ // Highlight layer for selected area (black border)
391
+ const hlBase = { source: level.source };
392
+ if (!level.isGeojson) hlBase["source-layer"] = level.layer;
393
+ map.addLayer({
394
+ id: `${level.dataKey}-highlight`,
395
+ type: "line",
396
+ ...hlBase,
397
+ layout: { visibility: "none" },
398
+ filter: ["==", ["get", level.codeField], ""],
399
+ paint: {
400
+ "line-color": "#000000",
401
+ "line-width": 3,
402
+ "line-opacity": 1,
403
+ },
404
+ });
405
+
406
+ // Hover highlight layer (thicker border on hovered feature)
407
+ const hoverBase = { source: level.source };
408
+ if (!level.isGeojson) hoverBase["source-layer"] = level.layer;
409
+ map.addLayer({
410
+ id: `${level.dataKey}-hover`,
411
+ type: "line",
412
+ ...hoverBase,
413
+ layout: { visibility: isActive ? "visible" : "none" },
414
+ filter: ["==", ["get", level.codeField], ""],
415
+ paint: {
416
+ "line-color": document.body.classList.contains("dark-mode") ? "#ffffff" : "#000000",
417
+ "line-width": level.dataKey === "section" ? 2 : 3,
418
+ "line-opacity": 0.8,
419
+ },
420
+ });
421
 
422
+ // Hover interactions -> floating tooltip + hover highlight
423
+ map.on("mousemove", fillId, (e) => {
424
+ showTooltip(e, level);
425
+ if (e.features && e.features.length > 0) {
426
+ const hoverCode = e.features[0].properties[level.codeField] || "";
427
+ try {
428
+ map.setFilter(`${level.dataKey}-hover`, ["==", ["get", level.codeField], hoverCode]);
429
+ } catch (err) {}
430
+ }
431
+ });
432
+ map.on("mouseleave", fillId, () => {
433
+ hideTooltip();
434
+ try {
435
+ map.setFilter(`${level.dataKey}-hover`, ["==", ["get", level.codeField], ""]);
436
+ } catch (err) {}
437
+ });
438
+
439
+ // Click-to-drill-down: click a feature to zoom + advance level
440
+ map.on("click", fillId, async (e) => {
441
  if (!e.features || e.features.length === 0) return;
442
  const levelIdx = LEVELS.indexOf(level);
443
  if (levelIdx >= LEVELS.length - 1) return;
444
 
445
  const nextLevel = LEVELS[levelIdx + 1];
446
+ const code = e.features[0].properties[level.codeField] || "";
447
  const lngLat = e.lngLat;
448
 
449
+ // If drilling from department and next level needs dept selection
450
+ if (level.dataKey === "department" && nextLevel.needsDept) {
451
+ selectedDeptCode = code;
452
+ }
453
+
454
+ // Load section data if advancing to section level
455
+ if (nextLevel.dataKey === "section" && selectedDeptCode && !sectionCache[selectedDeptCode]) {
456
+ await loadSectionDataForDept(selectedDeptCode);
457
+ }
458
+
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();
465
+
466
  let targetZoom = nextLevel.targetZoom;
 
467
  if (level.dataKey === "country") {
468
  map.flyTo({ center: FRANCE_CENTER, zoom: targetZoom, duration: 1000 });
469
+ } else {
470
+ const denseDepts = ["75", "92", "93", "94", "69", "13"];
471
+ if (level.dataKey === "department" && denseDepts.includes(code)) {
472
+ targetZoom = Math.min(targetZoom + 1.5, 20);
473
+ }
474
+ map.flyTo({ center: [lngLat.lng, lngLat.lat], zoom: targetZoom, duration: 1000 });
 
475
  }
476
 
477
+ updatePriceRangeForLevel();
478
+ updateColors();
479
+ updateDynamicStat();
480
+ populateAreaList();
481
  });
482
  }
483
 
484
+ // Invisible commune layer for name lookup (from government admin tiles)
485
  map.addLayer({
486
  id: "commune-query",
487
  type: "fill",
488
+ source: "admin-tiles",
489
  "source-layer": "communes",
 
 
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) => {
496
+ if (!e.isSourceLoaded) return;
497
+ const srcId = e.sourceId;
498
+ const level = LEVELS[activeLevel];
499
+ const needsRefresh =
500
+ (srcId === "admin-tiles" && level.source === "admin-tiles" && selectedDeptCode) ||
501
+ (srcId === "cadastre-tiles" && level.source === "cadastre-tiles" && selectedDeptCode);
502
+ if (needsRefresh && !sourcedataTimer) {
503
+ sourcedataTimer = setTimeout(() => {
504
+ sourcedataTimer = null;
505
+ updateColors();
506
+ }, 200);
507
  }
508
  });
509
 
510
+ // Initial paint
511
+ updateColors();
512
+ updateDynamicStat();
513
+
514
  map.on("moveend", () => {
 
 
515
  updateDynamicStat();
516
  });
517
  }
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  // ---- Color Updates ----
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  function updateColors() {
522
  for (const level of LEVELS) {
523
+ const data = getDisplayData(level.dataKey);
524
+ const fillId = `${level.dataKey}-fill`;
525
+
526
+ if (!data || Object.keys(data).length === 0) {
527
+ try { map.setPaintProperty(fillId, "fill-color", GRAY); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); }
528
+ if (LEVELS.indexOf(level) === activeLevel) updateLegend(0, 0, 0);
529
  continue;
530
  }
531
 
 
 
 
532
  const { scale, min, mid, max } = buildColorScale(data, currentType);
 
533
 
534
+ // Country level: single feature
535
  if (level.dataKey === "country") {
536
  const stats = data.FR && data.FR[currentType];
537
  const color = (stats && stats.wtm > 0) ? scale(stats.wtm) : GRAY;
538
+ try { map.setPaintProperty(fillId, "fill-color", color); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); }
539
+ if (activeLevel === 0) updateLegend(min, mid, max);
 
 
 
 
 
 
 
540
  continue;
541
  }
542
 
543
+ // All other levels: match expression
544
  const matchExpr = ["match", ["get", level.codeField]];
545
  let count = 0;
546
 
547
  for (const code in data) {
548
  const stats = data[code][currentType];
549
  if (stats && stats.wtm > 0) {
550
+ matchExpr.push(code, scale(stats.wtm));
 
 
 
 
551
  count++;
552
  }
553
  }
554
  matchExpr.push(GRAY);
555
 
556
  if (count > 0) {
557
+ try { map.setPaintProperty(fillId, "fill-color", matchExpr); } catch (e) { console.warn(`setPaint ${fillId} (${count} entries):`, e.message); }
558
+ } else {
559
+ try { map.setPaintProperty(fillId, "fill-color", GRAY); } catch (e) { console.warn(`setPaint ${fillId}:`, e.message); }
 
 
560
  }
561
 
562
+ if (LEVELS.indexOf(level) === activeLevel) {
 
563
  updateLegend(min, mid, max);
564
  }
565
  }
566
  }
567
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
568
  function updateLegend(min, mid, max) {
569
  document.getElementById("legend-min").textContent = formatPrice(min);
570
  document.getElementById("legend-max").textContent = formatPrice(max);
571
  }
572
 
573
+ function updateLayerVisibility() {
574
+ for (let i = 0; i < LEVELS.length; i++) {
 
 
575
  const level = LEVELS[i];
576
+ const fillId = `${level.dataKey}-fill`;
577
+ const lineId = `${level.dataKey}-line`;
578
+ const hoverId = `${level.dataKey}-hover`;
579
+ const vis = (i === activeLevel) ? "visible" : "none";
580
+ try {
581
+ map.setLayoutProperty(fillId, "visibility", vis);
582
+ map.setLayoutProperty(lineId, "visibility", vis);
583
+ map.setLayoutProperty(hoverId, "visibility", vis);
584
+ } catch (e) {}
585
+ }
586
  }
587
 
588
  function updateDynamicStat() {
589
  const labelEl = document.getElementById("stat-label");
590
  const priceEl = document.getElementById("stat-price");
591
  const detailEl = document.getElementById("stat-detail");
 
592
  const level = getCurrentLevel();
593
+ const dataKey = level.dataKey;
594
 
595
+ // Dept-required levels: show dept stats or prompt
596
+ if (level.needsDept) {
597
+ if (!selectedDeptCode) {
598
+ labelEl.textContent = "Select a Department";
599
+ priceEl.textContent = "\u2014";
600
+ detailEl.textContent = `Choose a department to view ${level.name.toLowerCase()} data`;
601
+ return;
602
+ }
603
+
604
+ const deptInfo = DEPARTMENTS[selectedDeptCode];
605
+ const deptName = deptInfo ? `${selectedDeptCode} \u2013 ${deptInfo.name}` : selectedDeptCode;
606
+ const data = getDisplayData(dataKey);
607
+
608
+ let totalWeightedPrice = 0;
609
+ let totalVolume = 0;
610
+ let count = 0;
611
+ for (const code in data) {
612
+ const stats = data[code][currentType];
613
+ if (!stats || !stats.wtm || !stats.volume) continue;
614
+ if (stats.wtm < priceFilter.min || stats.wtm > priceFilter.max) continue;
615
+ totalWeightedPrice += stats.wtm * stats.volume;
616
+ totalVolume += stats.volume;
617
+ count++;
618
+ }
619
+
620
+ const avgPrice = totalVolume > 0 ? Math.round(totalWeightedPrice / totalVolume) : 0;
621
+ labelEl.textContent = `${deptName} \u00b7 ${level.name}s`;
622
+ priceEl.textContent = avgPrice > 0 ? formatPrice(avgPrice) : "\u2014";
623
+ detailEl.textContent = totalVolume > 0 ? `${count} ${level.name.toLowerCase()}s \u00b7 ${totalVolume.toLocaleString("fr-FR")} transactions` : "";
624
  return;
625
  }
626
 
627
+ // If a specific area is selected in the list (country/region/dept)
628
+ if (selectedAreaCode) {
629
+ let name = selectedAreaCode;
630
+ let stats = null;
631
+
632
+ if (dataKey === "country") {
633
+ name = "France";
634
+ stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType];
635
+ } else if (dataKey === "region") {
636
+ const info = REGIONS[selectedAreaCode];
637
+ name = info ? info.name : selectedAreaCode;
638
+ stats = priceData.region && priceData.region[selectedAreaCode] && priceData.region[selectedAreaCode][currentType];
639
+ } else if (dataKey === "department") {
640
+ const info = DEPARTMENTS[selectedAreaCode];
641
+ name = info ? `${selectedAreaCode} \u2013 ${info.name}` : selectedAreaCode;
642
+ stats = priceData.department && priceData.department[selectedAreaCode] && priceData.department[selectedAreaCode][currentType];
643
+ }
644
+
645
+ labelEl.textContent = name;
646
  priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014";
647
  detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : "";
648
  return;
649
  }
650
 
651
+ // No selection show level average
 
 
652
  if (dataKey === "country") {
653
+ const stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType];
654
+ labelEl.textContent = "National Average";
655
+ priceEl.textContent = stats ? formatPrice(stats.wtm) : "\u2014";
656
+ detailEl.textContent = stats ? `${stats.volume.toLocaleString("fr-FR")} transactions` : "";
 
 
 
 
 
 
 
 
657
  return;
658
  }
659
 
 
660
  const dataSet = priceData[dataKey] || {};
661
  let totalWeightedPrice = 0;
662
  let totalVolume = 0;
663
  let count = 0;
664
 
665
  for (const code in dataSet) {
 
666
  const stats = dataSet[code][currentType];
667
  if (!stats || !stats.wtm || !stats.volume) continue;
668
  if (stats.wtm < priceFilter.min || stats.wtm > priceFilter.max) continue;
 
671
  count++;
672
  }
673
 
 
 
 
 
 
 
 
674
  const avgPrice = totalVolume > 0 ? Math.round(totalWeightedPrice / totalVolume) : 0;
675
+ labelEl.textContent = `${level.name} Average`;
676
  priceEl.textContent = avgPrice > 0 ? formatPrice(avgPrice) : "\u2014";
677
  detailEl.textContent = totalVolume > 0 ? `${count} areas \u00b7 ${totalVolume.toLocaleString("fr-FR")} transactions` : "";
678
  }
 
716
  } catch {}
717
  }
718
 
719
+ // Get stats for all types
720
+ let allStats = {};
721
  if (level.dataKey === "section") {
722
+ // Read from section cache (per-department JSON data)
723
+ const deptCode = selectedDeptCode || deptFromCode(code);
724
+ if (sectionCache[deptCode] && sectionCache[deptCode][code]) {
725
+ allStats = sectionCache[deptCode][code];
 
726
  }
727
  } else {
728
  const data = priceData[level.dataKey];
729
+ if (data && data[code]) allStats = data[code];
730
  }
731
 
732
+ const stats = allStats[currentType];
733
  const tooltip = document.getElementById("tooltip");
734
  if (!stats) {
735
  tooltip.classList.add("hidden");
736
  return;
737
  }
738
 
739
+ // Build price + volume breakdown for apartment/house
740
+ const apptStats = allStats.appartement;
741
+ const houseStats = allStats.maison;
742
+ let breakdownHtml = "";
743
+ if (apptStats && apptStats.wtm) {
744
+ const vol = apptStats.volume ? apptStats.volume.toLocaleString("fr-FR") : "0";
745
+ breakdownHtml += `<span>Apt: ${formatPrice(apptStats.wtm)} (${vol})</span>`;
746
+ }
747
+ if (houseStats && houseStats.wtm) {
748
+ const vol = houseStats.volume ? houseStats.volume.toLocaleString("fr-FR") : "0";
749
+ breakdownHtml += `<span>House: ${formatPrice(houseStats.wtm)} (${vol})</span>`;
750
+ }
751
+
752
  tooltip.classList.remove("hidden");
753
  tooltip.innerHTML = `
754
  <div class="tt-name">${name}</div>
755
  <div class="tt-price">${formatPrice(stats.wtm)}</div>
756
  <div class="tt-details">
757
+ ${breakdownHtml}
758
+ <span>${stats.volume ? stats.volume.toLocaleString("fr-FR") : "0"} total transactions</span>
 
759
  </div>
760
  `;
761
 
 
777
  document.getElementById("tooltip").classList.add("hidden");
778
  }
779
 
780
+ // ---- Area List Navigation ----
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
 
782
+ function populateAreaList() {
783
+ const container = document.getElementById("area-list");
784
+ const label = document.getElementById("area-list-label");
785
+ const level = getCurrentLevel();
786
+ const dataKey = level.dataKey;
 
 
 
 
 
 
787
 
788
+ container.innerHTML = "";
 
 
 
 
 
 
 
789
 
790
+ // For dept-required levels: show department list for selection
791
+ if (level.needsDept) {
792
+ label.textContent = `Select Department`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
793
 
794
+ // Header message
795
+ if (selectedDeptCode) {
796
+ const info = DEPARTMENTS[selectedDeptCode];
797
+ const deptName = info ? `${selectedDeptCode} \u2013 ${info.name}` : selectedDeptCode;
798
+ label.textContent = `${level.name}s \u00b7 ${deptName}`;
799
+ }
800
 
801
+ const items = Object.entries(DEPARTMENTS)
802
+ .filter(([code]) => priceData.department && priceData.department[code])
803
+ .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr"))
804
+ .map(([code, info]) => {
805
+ const stats = priceData.department[code] && priceData.department[code][currentType];
806
+ return { code, name: `${code} \u2013 ${info.name}`, price: stats ? stats.wtm : 0 };
807
+ });
808
+
809
+ if (items.length === 0) {
810
+ container.innerHTML = '<div class="area-list-empty">No data available</div>';
811
+ return;
812
+ }
813
 
814
+ for (const item of items) {
815
+ const div = document.createElement("div");
816
+ div.className = "area-item" + (selectedDeptCode === item.code ? " selected" : "");
817
+ div.dataset.code = item.code;
818
+ div.innerHTML = `
819
+ <span class="area-item-name">${item.name}</span>
820
+ <span class="area-item-price">${item.price ? formatPriceShort(item.price) + " \u20ac" : ""}</span>
821
+ `;
822
+ div.addEventListener("click", () => selectDeptForLevel(item.code));
823
+ container.appendChild(div);
824
+ }
825
 
826
+ // Scroll selected dept into view
827
+ if (selectedDeptCode) {
828
+ const selectedEl = container.querySelector(".area-item.selected");
829
+ if (selectedEl) selectedEl.scrollIntoView({ block: "nearest" });
830
+ }
831
 
832
+ return;
833
+ }
834
 
835
+ // Normal levels: country, region, department
836
+ let items = [];
 
 
 
 
 
 
837
 
838
+ if (dataKey === "country") {
839
+ label.textContent = "Country";
840
+ const stats = priceData.country && priceData.country.FR && priceData.country.FR[currentType];
841
+ items = [{ code: "FR", name: "France", price: stats ? stats.wtm : 0 }];
842
+ } else if (dataKey === "region") {
843
+ label.textContent = "Regions";
844
+ items = Object.entries(REGIONS)
845
+ .filter(([code]) => priceData.region && priceData.region[code])
846
+ .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr"))
847
+ .map(([code, info]) => {
848
+ const stats = priceData.region[code] && priceData.region[code][currentType];
849
+ return { code, name: info.name, price: stats ? stats.wtm : 0 };
850
+ });
851
+ } else if (dataKey === "department") {
852
+ label.textContent = "Departments";
853
+ items = Object.entries(DEPARTMENTS)
854
+ .filter(([code]) => priceData.department && priceData.department[code])
855
+ .sort((a, b) => a[1].name.localeCompare(b[1].name, "fr"))
856
+ .map(([code, info]) => {
857
+ const stats = priceData.department[code] && priceData.department[code][currentType];
858
+ return { code, name: `${code} \u2013 ${info.name}`, price: stats ? stats.wtm : 0 };
859
+ });
860
+ }
861
 
862
+ if (items.length === 0) {
863
+ container.innerHTML = '<div class="area-list-empty">No data available</div>';
864
+ return;
865
+ }
 
 
 
 
 
 
 
866
 
867
+ for (const item of items) {
868
+ const div = document.createElement("div");
869
+ div.className = "area-item" + (selectedAreaCode === item.code ? " selected" : "");
870
+ div.dataset.code = item.code;
871
+ div.innerHTML = `
872
+ <span class="area-item-name">${item.name}</span>
873
+ <span class="area-item-price">${item.price ? formatPriceShort(item.price) + " \u20ac" : ""}</span>
874
+ `;
875
+ div.addEventListener("click", () => selectArea(item.code, level.dataKey));
876
+ container.appendChild(div);
877
+ }
878
  }
879
 
880
+ function selectArea(code, dataKey) {
881
+ // Toggle selection
882
+ if (selectedAreaCode === code) {
883
+ selectedAreaCode = null;
884
+ } else {
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);
891
+ });
892
+
893
+ // Update highlight on map
894
+ updateHighlight();
895
 
896
+ // Fly to the selected area
897
+ if (selectedAreaCode) {
898
+ if (dataKey === "country") {
899
+ map.flyTo({ center: FRANCE_CENTER, zoom: FRANCE_ZOOM, duration: 1000 });
900
+ } else if (dataKey === "region" && REGIONS[code]) {
901
+ map.flyTo({ center: REGIONS[code].center, zoom: 5.5, duration: 1000 });
902
+ } else if (dataKey === "department" && DEPARTMENTS[code]) {
903
  const denseDepts = ["75", "92", "93", "94", "69", "13"];
904
  const zoom = denseDepts.includes(code) ? 10.5 : 9;
905
  map.flyTo({ center: DEPARTMENTS[code].center, zoom, duration: 1000 });
906
  }
907
  }
 
908
 
909
+ updateDynamicStat();
 
 
 
 
 
 
 
 
910
  }
911
 
912
+ async function selectDeptForLevel(deptCode) {
913
+ // Toggle selection
914
+ if (selectedDeptCode === deptCode) {
915
+ selectedDeptCode = null;
916
+ } else {
917
+ selectedDeptCode = deptCode;
 
918
  }
 
 
919
 
920
+ // Update list highlighting
921
+ document.querySelectorAll(".area-item").forEach(el => {
922
+ el.classList.toggle("selected", el.dataset.code === selectedDeptCode);
923
+ });
924
 
925
+ // Update highlight on map
926
+ updateHighlight();
 
 
927
 
928
+ if (selectedDeptCode) {
929
+ // Zoom to selected department
930
+ const info = DEPARTMENTS[selectedDeptCode];
931
+ if (info) {
932
+ const level = getCurrentLevel();
933
+ const denseDepts = ["75", "92", "93", "94", "69", "13"];
934
+ let zoom = level.targetZoom;
935
+ if (denseDepts.includes(selectedDeptCode)) zoom += 1.5;
936
+ map.flyTo({ center: info.center, zoom, duration: 1000 });
937
  }
938
+
939
+ // Load section data on demand
940
+ if (getCurrentLevel().dataKey === "section") {
941
+ await loadSectionDataForDept(selectedDeptCode);
 
 
 
 
 
 
942
  }
 
 
 
 
 
 
 
 
 
943
  }
944
 
945
+ // Update label to show selected dept name
946
+ const label = document.getElementById("area-list-label");
947
+ const level = getCurrentLevel();
948
+ if (selectedDeptCode) {
949
+ const deptInfo = DEPARTMENTS[selectedDeptCode];
950
+ const deptName = deptInfo ? `${selectedDeptCode} \u2013 ${deptInfo.name}` : selectedDeptCode;
951
+ label.textContent = `${level.name}s \u00b7 ${deptName}`;
952
+ } else {
953
+ label.textContent = "Select Department";
954
+ }
955
+
956
+ updatePriceRangeForLevel();
957
+ updateColors();
958
+ updateDynamicStat();
959
  }
960
 
961
+ function updateHighlight() {
962
+ // Clear all highlight layers first
963
+ for (let i = 0; i < LEVELS.length; i++) {
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
 
977
+ // For non-dept levels: highlight selected area
978
+ if (selectedAreaCode) {
979
+ const hlId = `${LEVELS[activeLevel].dataKey}-highlight`;
980
+ try {
981
+ map.setFilter(hlId, ["==", ["get", LEVELS[activeLevel].codeField], selectedAreaCode]);
982
+ map.setLayoutProperty(hlId, "visibility", "visible");
983
+ } catch (e) {}
984
+ }
985
+ }
986
 
987
+ // ---- Dynamic Price Range Per Level ----
 
988
 
989
+ function updatePriceRangeForLevel() {
990
+ const level = getCurrentLevel();
991
+ const data = getDisplayData(level.dataKey);
992
 
993
+ const prices = [];
994
+ for (const code in data) {
995
+ const stats = data[code][currentType];
996
+ if (stats && stats.wtm > 0) prices.push(stats.wtm);
997
+ }
998
 
999
+ if (prices.length === 0) return;
1000
+
1001
+ prices.sort((a, b) => a - b);
1002
+ const rangeMin = Math.max(0, Math.floor(prices[0] / 100) * 100);
1003
+ const rangeMax = Math.ceil(prices[prices.length - 1] / 100) * 100;
1004
+
1005
+ const minSlider = document.getElementById("price-min-slider");
1006
+ const maxSlider = document.getElementById("price-max-slider");
1007
+ const minInput = document.getElementById("price-min-input");
1008
+ const maxInput = document.getElementById("price-max-input");
1009
+
1010
+ minSlider.min = rangeMin;
1011
+ minSlider.max = rangeMax;
1012
+ maxSlider.min = rangeMin;
1013
+ maxSlider.max = rangeMax;
1014
+ minInput.min = rangeMin;
1015
+ minInput.max = rangeMax;
1016
+ maxInput.min = rangeMin;
1017
+ maxInput.max = rangeMax;
1018
+
1019
+ // Reset filter to full range for this level
1020
+ minSlider.value = rangeMin;
1021
+ maxSlider.value = rangeMax;
1022
+ minInput.value = rangeMin;
1023
+ maxInput.value = rangeMax;
1024
+
1025
+ priceFilter.min = rangeMin;
1026
+ priceFilter.max = rangeMax;
1027
 
1028
+ updateSliderTrack();
1029
  }
1030
 
1031
+ // ---- Geocoding Search ----
 
 
 
 
 
1032
 
1033
+ function getCurrentLevel() {
1034
+ return LEVELS[activeLevel];
1035
+ }
 
 
 
1036
 
1037
+ async function searchPlace(query) {
1038
+ if (!query || query.length < 2) return [];
1039
+ const url = `https://geo.api.gouv.fr/communes?nom=${encodeURIComponent(query)}&fields=nom,code,codesPostaux,departement,centre,population&boost=population&limit=7`;
1040
+ try {
1041
+ const resp = await fetch(url);
1042
+ if (!resp.ok) return [];
1043
+ return await resp.json();
1044
+ } catch {
1045
+ return [];
1046
+ }
1047
+ }
1048
 
1049
+ function renderSearchDropdown(results) {
1050
+ const dropdown = document.getElementById("search-dropdown");
1051
+ dropdown.innerHTML = "";
1052
+ searchDropdownIdx = -1;
1053
 
1054
+ if (results.length === 0) {
1055
+ dropdown.classList.add("hidden");
1056
+ return;
1057
+ }
1058
 
1059
+ results.forEach((commune, i) => {
1060
+ const item = document.createElement("div");
1061
+ item.className = "search-item";
1062
+ item.dataset.index = i;
1063
+
1064
+ const pop = commune.population
1065
+ ? commune.population.toLocaleString("fr-FR") + " hab."
1066
+ : "";
1067
+ const dept = commune.departement
1068
+ ? commune.departement.nom + " (" + commune.departement.code + ")"
1069
+ : "";
1070
+ const postcodes = (commune.codesPostaux || []).slice(0, 2).join(", ");
1071
+
1072
+ item.innerHTML = `
1073
+ <div class="search-item-name">${commune.nom}</div>
1074
+ <div class="search-item-sub">${[dept, postcodes, pop].filter(Boolean).join(" \u00b7 ")}</div>
1075
+ `;
1076
+ item.addEventListener("click", () => onSelectPlace(commune));
1077
+ item.addEventListener("mouseenter", () => {
1078
+ searchDropdownIdx = i;
1079
+ highlightDropdownItem();
1080
+ });
1081
+ dropdown.appendChild(item);
1082
+ });
1083
 
1084
+ dropdown.classList.remove("hidden");
1085
  }
1086
 
1087
+ function highlightDropdownItem() {
1088
+ const items = document.querySelectorAll("#search-dropdown .search-item");
1089
+ items.forEach((el, i) => {
1090
+ el.classList.toggle("active", i === searchDropdownIdx);
1091
+ });
1092
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1093
 
1094
+ function closeSearchDropdown() {
1095
+ document.getElementById("search-dropdown").classList.add("hidden");
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
  }
1115
 
1116
+ // Show price details
1117
+ showPlaceDetails(commune);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1118
  }
1119
 
1120
+ function showPlaceDetails(commune) {
1121
+ const container = document.getElementById("place-details");
1122
+ const data = priceData.commune && priceData.commune[commune.code];
1123
 
1124
+ if (!data) {
1125
+ container.innerHTML = `
1126
+ <div class="pd-header">
1127
+ <div class="pd-name">${commune.nom}</div>
1128
+ <div class="pd-sub">${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}</div>
1129
+ </div>
1130
+ <div class="pd-empty">No price data available for this commune</div>
1131
+ `;
1132
+ container.classList.remove("hidden");
1133
  return;
1134
  }
1135
 
1136
+ const types = ["tous", "appartement", "maison"];
1137
+ const typeLabels = { tous: "All Types", appartement: "Apartments", maison: "Houses" };
 
 
1138
 
1139
+ let rows = "";
1140
+ for (const type of types) {
1141
+ const stats = data[type];
1142
+ if (!stats || !stats.wtm) {
1143
+ rows += `<div class="pd-row"><span class="pd-type">${typeLabels[type]}</span><span class="pd-na">N/A</span></div>`;
1144
+ continue;
1145
+ }
1146
+ rows += `
1147
+ <div class="pd-row">
1148
+ <span class="pd-type">${typeLabels[type]}</span>
1149
+ <span class="pd-wtm">${formatPrice(stats.wtm)}</span>
1150
+ </div>
1151
+ <div class="pd-row pd-sub-row">
1152
+ <span>Median ${formatPrice(stats.median)}</span>
1153
+ <span>${stats.volume.toLocaleString("fr-FR")} sales</span>
1154
+ <span>${(stats.confidence * 100).toFixed(0)}% conf.</span>
1155
+ </div>
1156
+ `;
1157
  }
1158
 
1159
+ container.innerHTML = `
1160
+ <div class="pd-header">
1161
+ <div class="pd-name">${commune.nom}</div>
1162
+ <div class="pd-sub">${commune.departement ? commune.departement.nom : ""} \u00b7 ${commune.code}${commune.population ? " \u00b7 " + commune.population.toLocaleString("fr-FR") + " hab." : ""}</div>
1163
+ </div>
1164
+ ${rows}
1165
+ `;
1166
+ container.classList.remove("hidden");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1167
  }
1168
 
1169
+ function initSearch() {
1170
+ const input = document.getElementById("place-search");
1171
+ const dropdown = document.getElementById("search-dropdown");
1172
+ let debounceTimer;
1173
+ let lastResults = [];
1174
+
1175
+ input.addEventListener("input", () => {
1176
+ clearTimeout(debounceTimer);
1177
+ const query = input.value.trim();
1178
+ if (query.length < 2) {
1179
+ closeSearchDropdown();
1180
+ lastResults = [];
1181
+ return;
1182
+ }
1183
+ debounceTimer = setTimeout(async () => {
1184
+ lastResults = await searchPlace(query);
1185
+ renderSearchDropdown(lastResults);
1186
+ }, 300);
1187
+ });
1188
 
1189
+ input.addEventListener("keydown", (e) => {
1190
+ const items = dropdown.querySelectorAll(".search-item");
1191
+ if (items.length === 0) return;
1192
+
1193
+ if (e.key === "ArrowDown") {
1194
+ e.preventDefault();
1195
+ searchDropdownIdx = Math.min(searchDropdownIdx + 1, items.length - 1);
1196
+ highlightDropdownItem();
1197
+ } else if (e.key === "ArrowUp") {
1198
+ e.preventDefault();
1199
+ searchDropdownIdx = Math.max(searchDropdownIdx - 1, 0);
1200
+ highlightDropdownItem();
1201
+ } else if (e.key === "Enter") {
1202
+ e.preventDefault();
1203
+ if (searchDropdownIdx >= 0 && searchDropdownIdx < lastResults.length) {
1204
+ onSelectPlace(lastResults[searchDropdownIdx]);
1205
+ }
1206
+ } else if (e.key === "Escape") {
1207
+ closeSearchDropdown();
1208
+ }
1209
+ });
1210
 
1211
+ // Close dropdown on outside click
1212
+ document.addEventListener("click", (e) => {
1213
+ if (!e.target.closest(".search-wrapper")) {
1214
+ closeSearchDropdown();
1215
+ }
1216
+ });
 
1217
 
1218
+ // Clear details and dropdown when input is cleared
1219
+ input.addEventListener("focus", () => {
1220
+ if (input.value.trim().length >= 2 && lastResults.length > 0) {
1221
+ renderSearchDropdown(lastResults);
1222
+ }
1223
+ });
 
 
 
 
 
 
 
 
1224
  }
1225
 
1226
  // ---- Level Switching ----
1227
 
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();
1239
+
1240
+ // If switching to section level with a dept already selected, load section data
1241
+ if (LEVELS[levelIdx].dataKey === "section" && selectedDeptCode && !sectionCache[selectedDeptCode]) {
1242
+ await loadSectionDataForDept(selectedDeptCode);
1243
+ }
1244
+
1245
+ updatePriceRangeForLevel();
1246
+ updateColors();
1247
+ updateDynamicStat();
1248
+ populateAreaList();
1249
  }
1250
 
1251
  // ---- Price Filter ----
 
1266
 
1267
  updateSliderTrack();
1268
  updateColors();
1269
+ updateDynamicStat();
1270
  }
1271
 
1272
  function syncPriceFromInputs() {
 
1285
 
1286
  updateSliderTrack();
1287
  updateColors();
1288
+ updateDynamicStat();
1289
  }
1290
 
1291
  function updateSliderTrack() {
1292
+ const sliderMax = parseInt(document.getElementById("price-max-slider").max) || PRICE_SLIDER_MAX;
1293
+ const sliderMin = parseInt(document.getElementById("price-min-slider").min) || 0;
1294
+ const range = sliderMax - sliderMin || 1;
1295
+ const pctMin = ((priceFilter.min - sliderMin) / range) * 100;
1296
+ const pctMax = Math.min(((priceFilter.max - sliderMin) / range) * 100, 100);
1297
  const track = document.getElementById("slider-track");
1298
  if (track) {
1299
  track.style.left = pctMin + "%";
 
1311
  const isDark = document.body.classList.contains("dark-mode");
1312
  themeBtn.innerHTML = isDark ? "&#9790;" : "&#9728;";
1313
  localStorage.setItem("theme", isDark ? "dark" : "light");
1314
+ // Update highlight + hover layer colors for theme
1315
+ const hlColor = isDark ? "#ffffff" : "#000000";
1316
+ for (const level of LEVELS) {
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");
 
1325
  themeBtn.innerHTML = "&#9728;";
1326
  }
1327
 
1328
+ // Property type checkboxes
1329
+ const cbAppart = document.getElementById("cb-appartement");
1330
+ const cbMaison = document.getElementById("cb-maison");
1331
+ function syncPropertyType() {
1332
+ const a = cbAppart.checked;
1333
+ const m = cbMaison.checked;
1334
+ if (a && m) currentType = "tous";
1335
+ else if (a) currentType = "appartement";
1336
+ else if (m) currentType = "maison";
1337
+ else {
1338
+ cbAppart.checked = true;
1339
+ cbMaison.checked = true;
1340
+ currentType = "tous";
1341
+ }
1342
+ updatePriceRangeForLevel();
1343
+ updateColors();
1344
+ updateDynamicStat();
1345
+ populateAreaList();
1346
+ }
1347
+ cbAppart.addEventListener("change", syncPropertyType);
1348
+ cbMaison.addEventListener("change", syncPropertyType);
1349
 
1350
  // Level radio buttons
1351
  document.querySelectorAll('input[name="map-level"]').forEach(radio => {
 
1354
  });
1355
  });
1356
 
1357
+ // Geocoding search
1358
+ initSearch();
 
 
 
 
 
 
 
1359
 
1360
  // Price range sliders
1361
  document.getElementById("price-min-slider").addEventListener("input", syncPriceFromSliders);
 
1374
  (async function main() {
1375
  try {
1376
  await loadPriceData();
 
 
1377
  initMap();
1378
+ populateAreaList();
1379
+ updatePriceRangeForLevel();
1380
  } catch (err) {
1381
  console.error("Failed to initialize:", err);
1382
  document.body.innerHTML = `<div style="padding:40px;color:#ff6b6b;">
static/index.html CHANGED
@@ -32,46 +32,45 @@
32
  </div>
33
  </div>
34
 
35
- <!-- Navigate: Region / Department checkbox dropdowns -->
36
  <div class="panel-section">
37
- <span class="section-label">Navigate</span>
38
- <div class="cb-dropdown" id="region-dropdown">
39
- <button class="cb-toggle" id="region-toggle">All Regions</button>
40
- <div class="cb-menu" id="region-menu"></div>
41
- </div>
42
- <div class="cb-dropdown" id="dept-dropdown">
43
- <button class="cb-toggle" id="dept-toggle">All Departments</button>
44
- <div class="cb-menu" id="dept-menu"></div>
45
  </div>
46
  </div>
47
 
 
 
 
 
 
 
48
  <!-- Price range filter -->
49
  <div class="panel-section">
50
  <span class="section-label">Price Range (&euro;/m&sup2;)</span>
51
  <div class="price-inputs">
52
- <input type="number" id="price-min-input" value="0" min="0" max="50000" step="100">
53
  <span class="price-sep">&ndash;</span>
54
  <input type="number" id="price-max-input" value="25000" min="0" max="50000" step="100">
55
  </div>
56
  <div class="range-container">
57
  <div class="slider-bg"></div>
58
  <div class="slider-track" id="slider-track"></div>
59
- <input type="range" id="price-min-slider" min="0" max="25000" value="0" step="100">
60
- <input type="range" id="price-max-slider" min="0" max="25000" value="25000" step="100">
61
  </div>
62
  </div>
63
 
64
- <!-- Explore: search + dynamic top list -->
65
- <div class="panel-section explore-section">
66
- <div class="explore-header">
67
- <span class="section-label" id="explore-title">Top Cities</span>
68
- <div class="explore-count-pills">
69
- <button class="count-pill active" data-count="5">5</button>
70
- <button class="count-pill" data-count="10">10</button>
71
- </div>
72
  </div>
73
- <input type="text" id="explore-search" class="explore-search" placeholder="Search by name or code...">
74
- <div id="explore-list"></div>
75
  </div>
76
 
77
  <div class="footer">
 
32
  </div>
33
  </div>
34
 
35
+ <!-- Property Type -->
36
  <div class="panel-section">
37
+ <span class="section-label">Property Type</span>
38
+ <div class="type-checkboxes">
39
+ <label class="type-cb"><input type="checkbox" id="cb-appartement" checked><span>Apartments</span></label>
40
+ <label class="type-cb"><input type="checkbox" id="cb-maison" checked><span>Houses</span></label>
 
 
 
 
41
  </div>
42
  </div>
43
 
44
+ <!-- Area list: scrollable list of items for current level -->
45
+ <div class="panel-section">
46
+ <span class="section-label" id="area-list-label">Areas</span>
47
+ <div id="area-list" class="area-list"></div>
48
+ </div>
49
+
50
  <!-- Price range filter -->
51
  <div class="panel-section">
52
  <span class="section-label">Price Range (&euro;/m&sup2;)</span>
53
  <div class="price-inputs">
54
+ <input type="number" id="price-min-input" value="200" min="0" max="50000" step="100">
55
  <span class="price-sep">&ndash;</span>
56
  <input type="number" id="price-max-input" value="25000" min="0" max="50000" step="100">
57
  </div>
58
  <div class="range-container">
59
  <div class="slider-bg"></div>
60
  <div class="slider-track" id="slider-track"></div>
61
+ <input type="range" id="price-min-slider" min="200" max="25000" value="200" step="100">
62
+ <input type="range" id="price-max-slider" min="200" max="25000" value="25000" step="100">
63
  </div>
64
  </div>
65
 
66
+ <!-- Search: geocoding autocomplete -->
67
+ <div class="panel-section search-section">
68
+ <span class="section-label">Search Place</span>
69
+ <div class="search-wrapper">
70
+ <input type="text" id="place-search" class="place-search" placeholder="Search a city or commune..." autocomplete="off">
71
+ <div id="search-dropdown" class="search-dropdown hidden"></div>
 
 
72
  </div>
73
+ <div id="place-details" class="place-details hidden"></div>
 
74
  </div>
75
 
76
  <div class="footer">
static/style.css CHANGED
@@ -152,125 +152,126 @@ body {
152
  font-size: 0.72rem;
153
  }
154
 
155
- /* ---- Checkbox Dropdown ---- */
156
- .cb-dropdown {
157
- position: relative;
 
158
  }
159
 
160
- .cb-toggle {
161
- width: 100%;
162
- padding: 8px 28px 8px 10px;
163
- background: white;
164
- color: #1d1d1f;
165
- border: 2px solid #e5e5e5;
166
- border-radius: 8px;
167
- font-size: 0.78rem;
168
  font-weight: 500;
169
- font-family: inherit;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  cursor: pointer;
171
- text-align: left;
172
- white-space: nowrap;
173
- overflow: hidden;
174
- text-overflow: ellipsis;
175
- transition: all 0.2s ease;
176
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%234b4b4b'/%3E%3C/svg%3E");
177
- background-repeat: no-repeat;
178
- background-position: right 10px center;
179
  }
180
 
181
- .cb-toggle:hover {
182
  border-color: #1cb0f6;
 
183
  }
184
 
185
- .cb-menu {
186
- display: none;
187
  position: absolute;
188
- left: 0;
189
- right: 0;
190
- top: 100%;
191
- margin-top: 2px;
192
- background: white;
193
- border: 2px solid #e5e5e5;
194
- border-radius: 8px;
195
- max-height: 200px;
196
- overflow-y: auto;
197
- z-index: 100;
198
- box-shadow: 0 8px 24px rgba(0,0,0,0.12);
199
  }
200
 
201
- .cb-dropdown.open .cb-menu {
202
- display: block;
 
 
203
  }
204
 
205
- .cb-menu::-webkit-scrollbar {
 
 
 
 
 
 
 
 
 
206
  width: 4px;
207
  }
208
- .cb-menu::-webkit-scrollbar-thumb {
209
  background: #c1c1c1;
210
  border-radius: 2px;
211
  }
212
 
213
- .cb-item {
214
  display: flex;
 
215
  align-items: center;
216
- gap: 6px;
217
- padding: 6px 10px;
218
  cursor: pointer;
219
  font-size: 0.75rem;
220
  color: #1d1d1f;
221
  transition: background 0.1s;
 
222
  }
223
 
224
- .cb-item:hover {
225
- background: #f5f5f7;
226
  }
227
 
228
- .cb-item.cb-all {
229
- border-bottom: 2px solid #e5e5e5;
230
- color: #1cb0f6;
231
- font-weight: 700;
232
  }
233
 
234
- .cb-item input[type="checkbox"] {
235
- -webkit-appearance: none;
236
- appearance: none;
237
- width: 16px;
238
- height: 16px;
239
- min-width: 16px;
240
- border: 2px solid #d2d2d7;
241
- border-radius: 3px;
242
- background: #f5f5f7;
243
- cursor: pointer;
244
- position: relative;
245
- }
246
- .cb-item input[type="checkbox"]:checked {
247
- background: #1cb0f6;
248
- border-color: #1cb0f6;
249
- }
250
- .cb-item input[type="checkbox"]:checked::after {
251
- content: "";
252
- position: absolute;
253
- left: 4px;
254
- top: 1px;
255
- width: 5px;
256
- height: 9px;
257
- border: solid white;
258
- border-width: 0 2px 2px 0;
259
- transform: rotate(45deg);
260
  }
261
 
262
- .cb-item-label {
263
  flex: 1;
264
- min-width: 0;
265
  overflow: hidden;
266
  text-overflow: ellipsis;
267
  white-space: nowrap;
268
  }
269
 
270
- .cb-item-price {
271
- color: #86868b;
272
- font-size: 0.68rem;
 
273
  white-space: nowrap;
 
 
 
 
 
 
 
 
274
  }
275
 
276
  /* ---- Dynamic Stat Detail ---- */
@@ -338,7 +339,7 @@ body {
338
  position: absolute;
339
  top: 10px;
340
  height: 4px;
341
- background: linear-gradient(to right, #028758, #FFF64E, #CC000A);
342
  border-radius: 2px;
343
  z-index: 1;
344
  left: 0;
@@ -476,56 +477,18 @@ body {
476
  color: #1d1d1f;
477
  }
478
 
479
- /* ---- Explore Section ---- */
480
- .explore-section {
481
  flex: 1;
482
  }
483
 
484
- .explore-header {
485
- display: flex;
486
- justify-content: space-between;
487
- align-items: center;
488
- }
489
-
490
- .explore-count-pills {
491
- display: flex;
492
- gap: 3px;
493
- }
494
-
495
- .count-pill {
496
- padding: 3px 10px;
497
- border: none;
498
- background: #e5e5e5;
499
- color: #4b4b4b;
500
- font-size: 0.68rem;
501
- font-weight: 700;
502
- border-radius: 6px;
503
- cursor: pointer;
504
- transition: all 0.1s ease;
505
- font-family: inherit;
506
- box-shadow: 0 2px 0 #afafaf;
507
- }
508
-
509
- .count-pill:hover {
510
- transform: translateY(-1px);
511
- box-shadow: 0 3px 0 #afafaf;
512
- }
513
-
514
- .count-pill:active {
515
- transform: translateY(2px);
516
- box-shadow: 0 0 0 #afafaf;
517
- }
518
-
519
- .count-pill.active {
520
- background: #1cb0f6;
521
- color: white;
522
- box-shadow: 0 2px 0 #1899d6;
523
  }
524
 
525
- /* Search input */
526
- .explore-search {
527
  width: 100%;
528
- padding: 7px 10px;
529
  background: white;
530
  color: #1d1d1f;
531
  border: 2px solid #e5e5e5;
@@ -536,85 +499,135 @@ body {
536
  transition: all 0.2s ease;
537
  }
538
 
539
- .explore-search:focus {
540
  outline: none;
541
  border-color: #1cb0f6;
542
  }
543
 
544
- .explore-search::placeholder {
545
  color: #aeaeb2;
546
  }
547
 
548
- /* Explore list rows */
549
- .explore-row {
550
- display: flex;
551
- align-items: center;
552
- gap: 8px;
553
- padding: 7px 10px;
554
- margin-bottom: 3px;
555
- background: #f5f5f7;
 
556
  border-radius: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  cursor: pointer;
558
- transition: all 0.15s ease;
 
 
 
 
 
 
 
 
559
  font-size: 0.8rem;
 
 
560
  }
561
 
562
- .explore-row:hover {
563
- background: #e8e8ed;
 
 
564
  }
565
 
566
- .explore-rank {
567
- font-size: 0.7rem;
568
- font-weight: 700;
569
- color: #1cb0f6;
570
- min-width: 16px;
 
571
  }
572
 
573
- .explore-info {
574
- flex: 1;
575
- min-width: 0;
 
 
 
 
 
576
  }
577
 
578
- .explore-name {
 
 
579
  color: #1d1d1f;
580
- font-weight: 500;
581
- white-space: nowrap;
582
- overflow: hidden;
583
- text-overflow: ellipsis;
584
  }
585
 
586
- .explore-sub {
587
  font-size: 0.65rem;
588
  color: #86868b;
 
 
 
 
 
 
 
 
589
  }
590
 
591
- .explore-price {
 
 
 
 
 
 
 
 
 
592
  font-weight: 700;
593
  color: #1cb0f6;
594
- font-size: 0.8rem;
595
- white-space: nowrap;
596
  }
597
 
598
- .explore-bar-wrap {
599
- width: 40px;
600
- height: 4px;
601
- background: #e5e5e5;
602
- border-radius: 2px;
603
- overflow: hidden;
604
  }
605
 
606
- .explore-bar {
607
- height: 100%;
608
- border-radius: 2px;
609
- background: #1cb0f6;
610
- transition: width 0.3s;
 
611
  }
612
 
613
- .explore-empty {
614
  font-size: 0.75rem;
615
  color: #86868b;
616
  text-align: center;
617
- padding: 12px 0;
618
  }
619
 
620
  /* ---- Footer ---- */
@@ -743,51 +756,54 @@ body.dark-mode .level-radio input[type="radio"]:checked {
743
  border-color: #1cb0f6;
744
  }
745
 
746
- /* Dark mode checkbox dropdown */
747
- body.dark-mode .cb-toggle {
748
- background-color: #2a2a2a;
749
  color: #e5e5e5;
750
- border-color: #444;
751
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23e5e5e5'/%3E%3C/svg%3E");
752
  }
753
 
754
- body.dark-mode .cb-toggle:hover {
 
 
 
 
 
 
 
 
 
 
755
  border-color: #1cb0f6;
756
  }
757
 
758
- body.dark-mode .cb-menu {
 
759
  background: #2a2a2a;
760
  border-color: #444;
761
- box-shadow: 0 8px 24px rgba(0,0,0,0.4);
762
  }
763
 
764
- body.dark-mode .cb-menu::-webkit-scrollbar-thumb {
765
  background: #555;
766
  }
767
 
768
- body.dark-mode .cb-item {
769
  color: #e5e5e5;
 
770
  }
771
 
772
- body.dark-mode .cb-item:hover {
773
  background: #333;
774
  }
775
 
776
- body.dark-mode .cb-item.cb-all {
777
- border-bottom-color: #444;
778
- color: #1cb0f6;
779
  }
780
 
781
- body.dark-mode .cb-item input[type="checkbox"] {
782
- background: #3a3a3a;
783
- border-color: #555;
784
- }
785
- body.dark-mode .cb-item input[type="checkbox"]:checked {
786
- background: #1cb0f6;
787
- border-color: #1cb0f6;
788
  }
789
 
790
- body.dark-mode .cb-item-price {
791
  color: #888;
792
  }
793
 
@@ -850,60 +866,72 @@ body.dark-mode .legend-labels {
850
  color: #999;
851
  }
852
 
853
- /* Dark mode count pills */
854
- body.dark-mode .count-pill {
855
  background: #2a2a2a;
856
  color: #e5e5e5;
857
- box-shadow: 0 2px 0 #1a1a1a;
858
  }
859
 
860
- body.dark-mode .count-pill:hover {
861
- box-shadow: 0 3px 0 #1a1a1a;
862
  }
863
 
864
- body.dark-mode .count-pill.active {
865
- background: #1cb0f6;
866
- color: white;
867
- box-shadow: 0 2px 0 #1899d6;
868
  }
869
 
870
- /* Dark mode search */
871
- body.dark-mode .explore-search {
872
  background: #2a2a2a;
873
- color: #e5e5e5;
874
  border-color: #444;
 
875
  }
876
 
877
- body.dark-mode .explore-search:focus {
878
- border-color: #1cb0f6;
879
  }
880
 
881
- body.dark-mode .explore-search::placeholder {
882
- color: #666;
 
883
  }
884
 
885
- /* Dark mode explore rows */
886
- body.dark-mode .explore-row {
887
- background: #2a2a2a;
888
  }
889
 
890
- body.dark-mode .explore-row:hover {
891
- background: #333;
892
  }
893
 
894
- body.dark-mode .explore-name {
895
- color: #e5e5e5;
 
 
 
 
 
896
  }
897
 
898
- body.dark-mode .explore-sub {
899
  color: #888;
900
  }
901
 
902
- body.dark-mode .explore-bar-wrap {
903
- background: #1a1a1a;
 
 
 
 
 
 
 
 
 
904
  }
905
 
906
- body.dark-mode .explore-empty {
907
  color: #888;
908
  }
909
 
 
152
  font-size: 0.72rem;
153
  }
154
 
155
+ /* ---- Property Type Checkboxes ---- */
156
+ .type-checkboxes {
157
+ display: flex;
158
+ gap: 12px;
159
  }
160
 
161
+ .type-cb {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ cursor: pointer;
166
+ font-size: 0.8rem;
 
 
167
  font-weight: 500;
168
+ color: #4b4b4b;
169
+ padding: 5px 8px;
170
+ border-radius: 6px;
171
+ transition: background 0.15s;
172
+ }
173
+
174
+ .type-cb:hover {
175
+ background: #eee;
176
+ }
177
+
178
+ .type-cb input[type="checkbox"] {
179
+ -webkit-appearance: none;
180
+ appearance: none;
181
+ width: 16px;
182
+ height: 16px;
183
+ min-width: 16px;
184
+ border: 2px solid #d2d2d7;
185
+ border-radius: 4px;
186
+ background: #f5f5f7;
187
  cursor: pointer;
188
+ position: relative;
 
 
 
 
 
 
 
189
  }
190
 
191
+ .type-cb input[type="checkbox"]:checked {
192
  border-color: #1cb0f6;
193
+ background: #1cb0f6;
194
  }
195
 
196
+ .type-cb input[type="checkbox"]:checked::after {
197
+ content: "\2713";
198
  position: absolute;
199
+ left: 1px;
200
+ top: -1px;
201
+ font-size: 12px;
202
+ color: white;
203
+ font-weight: bold;
 
 
 
 
 
 
204
  }
205
 
206
+ .type-cb span {
207
+ letter-spacing: 0.3px;
208
+ font-weight: 600;
209
+ font-size: 0.72rem;
210
  }
211
 
212
+ /* ---- Area List (scrollable navigation list) ---- */
213
+ .area-list {
214
+ max-height: 200px;
215
+ overflow-y: auto;
216
+ border: 2px solid #e5e5e5;
217
+ border-radius: 8px;
218
+ background: white;
219
+ }
220
+
221
+ .area-list::-webkit-scrollbar {
222
  width: 4px;
223
  }
224
+ .area-list::-webkit-scrollbar-thumb {
225
  background: #c1c1c1;
226
  border-radius: 2px;
227
  }
228
 
229
+ .area-item {
230
  display: flex;
231
+ justify-content: space-between;
232
  align-items: center;
233
+ padding: 7px 10px;
 
234
  cursor: pointer;
235
  font-size: 0.75rem;
236
  color: #1d1d1f;
237
  transition: background 0.1s;
238
+ border-bottom: 1px solid #f0f0f0;
239
  }
240
 
241
+ .area-item:last-child {
242
+ border-bottom: none;
243
  }
244
 
245
+ .area-item:hover {
246
+ background: #f0f5ff;
 
 
247
  }
248
 
249
+ .area-item.selected {
250
+ background: #e0efff;
251
+ font-weight: 700;
252
+ border-left: 3px solid #1cb0f6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
+ .area-item-name {
256
  flex: 1;
 
257
  overflow: hidden;
258
  text-overflow: ellipsis;
259
  white-space: nowrap;
260
  }
261
 
262
+ .area-item-price {
263
+ color: #1cb0f6;
264
+ font-size: 0.72rem;
265
+ font-weight: 600;
266
  white-space: nowrap;
267
+ margin-left: 8px;
268
+ }
269
+
270
+ .area-list-empty {
271
+ padding: 14px 10px;
272
+ font-size: 0.72rem;
273
+ color: #86868b;
274
+ text-align: center;
275
  }
276
 
277
  /* ---- Dynamic Stat Detail ---- */
 
339
  position: absolute;
340
  top: 10px;
341
  height: 4px;
342
+ background: #1cb0f6;
343
  border-radius: 2px;
344
  z-index: 1;
345
  left: 0;
 
477
  color: #1d1d1f;
478
  }
479
 
480
+ /* ---- Search Section ---- */
481
+ .search-section {
482
  flex: 1;
483
  }
484
 
485
+ .search-wrapper {
486
+ position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  }
488
 
489
+ .place-search {
 
490
  width: 100%;
491
+ padding: 8px 10px;
492
  background: white;
493
  color: #1d1d1f;
494
  border: 2px solid #e5e5e5;
 
499
  transition: all 0.2s ease;
500
  }
501
 
502
+ .place-search:focus {
503
  outline: none;
504
  border-color: #1cb0f6;
505
  }
506
 
507
+ .place-search::placeholder {
508
  color: #aeaeb2;
509
  }
510
 
511
+ /* Search autocomplete dropdown */
512
+ .search-dropdown {
513
+ position: absolute;
514
+ left: 0;
515
+ right: 0;
516
+ top: 100%;
517
+ margin-top: 2px;
518
+ background: white;
519
+ border: 2px solid #e5e5e5;
520
  border-radius: 8px;
521
+ max-height: 280px;
522
+ overflow-y: auto;
523
+ z-index: 200;
524
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
525
+ }
526
+
527
+ .search-dropdown.hidden {
528
+ display: none;
529
+ }
530
+
531
+ .search-dropdown::-webkit-scrollbar {
532
+ width: 4px;
533
+ }
534
+ .search-dropdown::-webkit-scrollbar-thumb {
535
+ background: #c1c1c1;
536
+ border-radius: 2px;
537
+ }
538
+
539
+ .search-item {
540
+ padding: 8px 10px;
541
  cursor: pointer;
542
+ transition: background 0.1s;
543
+ }
544
+
545
+ .search-item:hover,
546
+ .search-item.active {
547
+ background: #f0f5ff;
548
+ }
549
+
550
+ .search-item-name {
551
  font-size: 0.8rem;
552
+ font-weight: 600;
553
+ color: #1d1d1f;
554
  }
555
 
556
+ .search-item-sub {
557
+ font-size: 0.65rem;
558
+ color: #86868b;
559
+ margin-top: 1px;
560
  }
561
 
562
+ /* Place details card */
563
+ .place-details {
564
+ display: flex;
565
+ flex-direction: column;
566
+ gap: 4px;
567
+ margin-top: 4px;
568
  }
569
 
570
+ .place-details.hidden {
571
+ display: none;
572
+ }
573
+
574
+ .pd-header {
575
+ padding: 6px 0;
576
+ border-bottom: 1px solid #e5e5e5;
577
+ margin-bottom: 2px;
578
  }
579
 
580
+ .pd-name {
581
+ font-size: 0.85rem;
582
+ font-weight: 700;
583
  color: #1d1d1f;
 
 
 
 
584
  }
585
 
586
+ .pd-sub {
587
  font-size: 0.65rem;
588
  color: #86868b;
589
+ margin-top: 1px;
590
+ }
591
+
592
+ .pd-row {
593
+ display: flex;
594
+ justify-content: space-between;
595
+ align-items: center;
596
+ padding: 4px 0;
597
  }
598
 
599
+ .pd-type {
600
+ font-size: 0.72rem;
601
+ font-weight: 600;
602
+ color: #4b4b4b;
603
+ text-transform: uppercase;
604
+ letter-spacing: 0.3px;
605
+ }
606
+
607
+ .pd-wtm {
608
+ font-size: 0.82rem;
609
  font-weight: 700;
610
  color: #1cb0f6;
 
 
611
  }
612
 
613
+ .pd-na {
614
+ font-size: 0.75rem;
615
+ color: #aeaeb2;
 
 
 
616
  }
617
 
618
+ .pd-sub-row {
619
+ padding: 0 0 4px 0;
620
+ gap: 8px;
621
+ font-size: 0.65rem;
622
+ color: #86868b;
623
+ border-bottom: 1px solid #f0f0f0;
624
  }
625
 
626
+ .pd-empty {
627
  font-size: 0.75rem;
628
  color: #86868b;
629
  text-align: center;
630
+ padding: 10px 0;
631
  }
632
 
633
  /* ---- Footer ---- */
 
756
  border-color: #1cb0f6;
757
  }
758
 
759
+ /* Dark mode property type checkboxes */
760
+ body.dark-mode .type-cb {
 
761
  color: #e5e5e5;
 
 
762
  }
763
 
764
+ body.dark-mode .type-cb:hover {
765
+ background: #333;
766
+ }
767
+
768
+ body.dark-mode .type-cb input[type="checkbox"] {
769
+ background: #3a3a3a;
770
+ border-color: #555;
771
+ }
772
+
773
+ body.dark-mode .type-cb input[type="checkbox"]:checked {
774
+ background: #1cb0f6;
775
  border-color: #1cb0f6;
776
  }
777
 
778
+ /* Dark mode area list */
779
+ body.dark-mode .area-list {
780
  background: #2a2a2a;
781
  border-color: #444;
 
782
  }
783
 
784
+ body.dark-mode .area-list::-webkit-scrollbar-thumb {
785
  background: #555;
786
  }
787
 
788
+ body.dark-mode .area-item {
789
  color: #e5e5e5;
790
+ border-bottom-color: #333;
791
  }
792
 
793
+ body.dark-mode .area-item:hover {
794
  background: #333;
795
  }
796
 
797
+ body.dark-mode .area-item.selected {
798
+ background: #1a3a5c;
799
+ border-left-color: #1cb0f6;
800
  }
801
 
802
+ body.dark-mode .area-item-price {
803
+ color: #1cb0f6;
 
 
 
 
 
804
  }
805
 
806
+ body.dark-mode .area-list-empty {
807
  color: #888;
808
  }
809
 
 
866
  color: #999;
867
  }
868
 
869
+ /* Dark mode search */
870
+ body.dark-mode .place-search {
871
  background: #2a2a2a;
872
  color: #e5e5e5;
873
+ border-color: #444;
874
  }
875
 
876
+ body.dark-mode .place-search:focus {
877
+ border-color: #1cb0f6;
878
  }
879
 
880
+ body.dark-mode .place-search::placeholder {
881
+ color: #666;
 
 
882
  }
883
 
884
+ /* Dark mode search dropdown */
885
+ body.dark-mode .search-dropdown {
886
  background: #2a2a2a;
 
887
  border-color: #444;
888
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
889
  }
890
 
891
+ body.dark-mode .search-dropdown::-webkit-scrollbar-thumb {
892
+ background: #555;
893
  }
894
 
895
+ body.dark-mode .search-item:hover,
896
+ body.dark-mode .search-item.active {
897
+ background: #333;
898
  }
899
 
900
+ body.dark-mode .search-item-name {
901
+ color: #e5e5e5;
 
902
  }
903
 
904
+ body.dark-mode .search-item-sub {
905
+ color: #888;
906
  }
907
 
908
+ /* Dark mode place details */
909
+ body.dark-mode .pd-header {
910
+ border-bottom-color: #444;
911
+ }
912
+
913
+ body.dark-mode .pd-name {
914
+ color: #ffffff;
915
  }
916
 
917
+ body.dark-mode .pd-sub {
918
  color: #888;
919
  }
920
 
921
+ body.dark-mode .pd-type {
922
+ color: #ccc;
923
+ }
924
+
925
+ body.dark-mode .pd-na {
926
+ color: #666;
927
+ }
928
+
929
+ body.dark-mode .pd-sub-row {
930
+ color: #888;
931
+ border-bottom-color: #333;
932
  }
933
 
934
+ body.dark-mode .pd-empty {
935
  color: #888;
936
  }
937