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