Spaces:
Sleeping
Sleeping
feat: add interactive map frontend and deployment config
Browse filesMapLibre GL JS choropleth with 4 zoom-based levels,
FastAPI backend, Dockerfile for HF Spaces.
- .dockerignore +28 -0
- Dockerfile +21 -0
- README.md +48 -0
- app.py +35 -0
- requirements-app.txt +2 -0
- static/app.js +404 -0
- static/index.html +89 -0
- static/style.css +225 -0
.dockerignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
data/raw/
|
| 2 |
+
data/processed/
|
| 3 |
+
data/aggregated/prices_section.json
|
| 4 |
+
notebooks/
|
| 5 |
+
tests/
|
| 6 |
+
src/
|
| 7 |
+
explore.data.gouv.fr/
|
| 8 |
+
data-gouv-skill/
|
| 9 |
+
datagouv-mcp/
|
| 10 |
+
stats-explorer-datagouv/
|
| 11 |
+
updates/
|
| 12 |
+
.claude/
|
| 13 |
+
.mcp/
|
| 14 |
+
.venv/
|
| 15 |
+
.git/
|
| 16 |
+
.gitattributes
|
| 17 |
+
__pycache__/
|
| 18 |
+
*.pyc
|
| 19 |
+
*.md
|
| 20 |
+
*.txt
|
| 21 |
+
!requirements-app.txt
|
| 22 |
+
ml_challenge.txt
|
| 23 |
+
pyproject.toml
|
| 24 |
+
uv.lock
|
| 25 |
+
.python-version
|
| 26 |
+
.env
|
| 27 |
+
.env.*
|
| 28 |
+
main.py
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HF Spaces requirement: run as user 1000
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
WORKDIR /home/user/app
|
| 6 |
+
|
| 7 |
+
# Install only the app dependencies (not the full pipeline)
|
| 8 |
+
COPY requirements-app.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements-app.txt
|
| 10 |
+
|
| 11 |
+
# Copy application code
|
| 12 |
+
COPY app.py .
|
| 13 |
+
COPY static/ static/
|
| 14 |
+
COPY data/aggregated/ data/aggregated/
|
| 15 |
+
|
| 16 |
+
# HF Spaces requires port 7860
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
|
| 19 |
+
USER user
|
| 20 |
+
|
| 21 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: French Property Prices
|
| 3 |
+
emoji: 🏠
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# French Property Prices - Interactive Map
|
| 12 |
+
|
| 13 |
+
Interactive choropleth map showing residential property prices per m² across France, aggregated at 6 geographic levels.
|
| 14 |
+
|
| 15 |
+
## Data
|
| 16 |
+
|
| 17 |
+
- **Source**: DVF (Demandes de Valeurs Foncières) geolocalized data from [data.gouv.fr](https://www.data.gouv.fr/datasets/demandes-de-valeurs-foncieres-geolocalisees/)
|
| 18 |
+
- **Period**: 2020-2025 (~4.6M transactions)
|
| 19 |
+
- **Property types**: Residential only (Appartement + Maison)
|
| 20 |
+
- **Coverage**: 97 departments, 33,000+ communes, 260,000+ cadastral sections
|
| 21 |
+
|
| 22 |
+
## Methodology
|
| 23 |
+
|
| 24 |
+
**Time-Weighted Trimmed Mean (WTM)**:
|
| 25 |
+
1. Exponential decay weighting: `weight = 0.97^months_since_reference` (half-life ~23 months)
|
| 26 |
+
2. Sort transactions by price/m², trim 20% from each tail by cumulative weight
|
| 27 |
+
3. Weighted average of the remaining middle 60%
|
| 28 |
+
|
| 29 |
+
**Effective Sample Size**: Kish's ESS = (Σw)² / Σ(w²) to account for weight inequality.
|
| 30 |
+
|
| 31 |
+
**Confidence Score**: Composite of volume component (log-scaled n_eff) and stability component (1 - IQR/median).
|
| 32 |
+
|
| 33 |
+
## Aggregation Levels
|
| 34 |
+
|
| 35 |
+
| Level | Codes | Tile Source |
|
| 36 |
+
|-------|-------|-------------|
|
| 37 |
+
| Region | 17 | decoupage-administratif |
|
| 38 |
+
| Department | 97 | decoupage-administratif |
|
| 39 |
+
| Commune | 33,244 | decoupage-administratif |
|
| 40 |
+
| Section | 260,219 | cadastre-dvf |
|
| 41 |
+
|
| 42 |
+
## Tech Stack
|
| 43 |
+
|
| 44 |
+
- **Pipeline**: Python + Polars + NumPy
|
| 45 |
+
- **Frontend**: MapLibre GL JS + D3.js color scaling
|
| 46 |
+
- **Backend**: FastAPI (static file serving)
|
| 47 |
+
- **Tiles**: French government vector tiles (openmaptiles.geo.data.gouv.fr)
|
| 48 |
+
- **Deployment**: Docker on Hugging Face Spaces
|
app.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application serving the interactive property price map.
|
| 3 |
+
|
| 4 |
+
Serves static frontend files and pre-computed JSON price data.
|
| 5 |
+
Designed for Hugging Face Spaces Docker deployment on port 7860.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.responses import FileResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parent
|
| 15 |
+
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("/")
|
| 25 |
+
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")
|
requirements-app.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0
|
| 2 |
+
uvicorn[standard]>=0.32.0
|
static/app.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* French Property Price Map
|
| 3 |
+
* Interactive choropleth using MapLibre GL JS + government vector tiles.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// ---- Configuration ----
|
| 7 |
+
const TILE_SOURCES = {
|
| 8 |
+
admin: "https://openmaptiles.geo.data.gouv.fr/data/decoupage-administratif.json",
|
| 9 |
+
cadastre: "https://openmaptiles.geo.data.gouv.fr/data/cadastre-dvf.json",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const LEVELS = [
|
| 13 |
+
{ name: "Region", zoom: [0, 6], source: "admin", layer: "regions", codeField: "code", dataKey: "region" },
|
| 14 |
+
{ name: "Department", zoom: [6, 8], source: "admin", layer: "departements", codeField: "code", dataKey: "department" },
|
| 15 |
+
{ name: "Commune", zoom: [8, 11], source: "admin", layer: "communes", codeField: "code", dataKey: "commune" },
|
| 16 |
+
{ name: "Section", zoom: [11, 24], source: "cadastre", layer: "sections", codeField: "id", dataKey: "section" },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
const COLORS = { low: "#028758", mid: "#FFF64E", high: "#CC000A" };
|
| 20 |
+
const GRAY = "rgba(100, 100, 100, 0.4)";
|
| 21 |
+
const FRANCE_CENTER = [2.3, 46.7];
|
| 22 |
+
const FRANCE_ZOOM = 5.5;
|
| 23 |
+
|
| 24 |
+
// ---- State ----
|
| 25 |
+
let priceData = {}; // { region: {...}, department: {...}, ... }
|
| 26 |
+
let topCities = {};
|
| 27 |
+
let sectionCache = {}; // { "75": {...}, "69": {...} } - lazy loaded
|
| 28 |
+
let currentType = "tous";
|
| 29 |
+
let currentLevel = null;
|
| 30 |
+
let map;
|
| 31 |
+
|
| 32 |
+
// ---- Data Loading ----
|
| 33 |
+
|
| 34 |
+
async function loadJSON(url) {
|
| 35 |
+
const resp = await fetch(url);
|
| 36 |
+
if (!resp.ok) throw new Error(`Failed to load ${url}: ${resp.status}`);
|
| 37 |
+
return resp.json();
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
async function loadPriceData() {
|
| 41 |
+
const [country, region, department, commune, postcode, cities] = await Promise.all([
|
| 42 |
+
loadJSON("/data/prices_country.json"),
|
| 43 |
+
loadJSON("/data/prices_region.json"),
|
| 44 |
+
loadJSON("/data/prices_department.json"),
|
| 45 |
+
loadJSON("/data/prices_commune.json"),
|
| 46 |
+
loadJSON("/data/prices_postcode.json"),
|
| 47 |
+
loadJSON("/data/top_cities.json"),
|
| 48 |
+
]);
|
| 49 |
+
priceData = { country, region, department, commune, postcode };
|
| 50 |
+
topCities = cities;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function loadSectionData(deptCode) {
|
| 54 |
+
if (sectionCache[deptCode]) return sectionCache[deptCode];
|
| 55 |
+
try {
|
| 56 |
+
const data = await loadJSON(`/data/sections/${deptCode}.json`);
|
| 57 |
+
sectionCache[deptCode] = data;
|
| 58 |
+
return data;
|
| 59 |
+
} catch {
|
| 60 |
+
return {};
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// ---- Color Scale ----
|
| 65 |
+
|
| 66 |
+
function buildColorScale(data, type) {
|
| 67 |
+
const prices = [];
|
| 68 |
+
for (const code in data) {
|
| 69 |
+
const stats = data[code][type];
|
| 70 |
+
if (stats && stats.wtm > 0) prices.push(stats.wtm);
|
| 71 |
+
}
|
| 72 |
+
if (prices.length === 0) return { scale: () => GRAY, min: 0, mid: 0, max: 0 };
|
| 73 |
+
|
| 74 |
+
prices.sort((a, b) => a - b);
|
| 75 |
+
const min = prices[Math.floor(prices.length * 0.05)];
|
| 76 |
+
const max = prices[Math.floor(prices.length * 0.95)];
|
| 77 |
+
const mid = prices[Math.floor(prices.length * 0.5)];
|
| 78 |
+
|
| 79 |
+
const scale = d3.scaleLinear()
|
| 80 |
+
.domain([min, mid, max])
|
| 81 |
+
.range([COLORS.low, COLORS.mid, COLORS.high])
|
| 82 |
+
.clamp(true);
|
| 83 |
+
|
| 84 |
+
return { scale, min, mid, max };
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function formatPrice(val) {
|
| 88 |
+
if (!val || val === 0) return "N/A";
|
| 89 |
+
return Math.round(val).toLocaleString("fr-FR") + " \u20ac/m\u00b2";
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ---- Map Initialization ----
|
| 93 |
+
|
| 94 |
+
function initMap() {
|
| 95 |
+
map = new maplibregl.Map({
|
| 96 |
+
container: "map",
|
| 97 |
+
style: {
|
| 98 |
+
version: 8,
|
| 99 |
+
sources: {
|
| 100 |
+
"osm-bright": {
|
| 101 |
+
type: "raster",
|
| 102 |
+
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
|
| 103 |
+
tileSize: 256,
|
| 104 |
+
attribution: "© OpenStreetMap contributors",
|
| 105 |
+
},
|
| 106 |
+
},
|
| 107 |
+
layers: [{
|
| 108 |
+
id: "osm-base",
|
| 109 |
+
type: "raster",
|
| 110 |
+
source: "osm-bright",
|
| 111 |
+
paint: { "raster-opacity": 0.3, "raster-saturation": -0.8 },
|
| 112 |
+
}],
|
| 113 |
+
},
|
| 114 |
+
center: FRANCE_CENTER,
|
| 115 |
+
zoom: FRANCE_ZOOM,
|
| 116 |
+
maxBounds: [[-10, 40], [15, 52]],
|
| 117 |
+
});
|
| 118 |
+
|
| 119 |
+
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
| 120 |
+
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
|
| 121 |
+
|
| 122 |
+
map.on("load", onMapLoad);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
async function onMapLoad() {
|
| 126 |
+
// Add tile sources
|
| 127 |
+
map.addSource("admin", { type: "vector", url: TILE_SOURCES.admin });
|
| 128 |
+
map.addSource("cadastre", { type: "vector", url: TILE_SOURCES.cadastre });
|
| 129 |
+
|
| 130 |
+
// Add layers for each level
|
| 131 |
+
for (const level of LEVELS) {
|
| 132 |
+
const fillId = `${level.dataKey}-fill`;
|
| 133 |
+
const lineId = `${level.dataKey}-line`;
|
| 134 |
+
|
| 135 |
+
map.addLayer({
|
| 136 |
+
id: fillId,
|
| 137 |
+
type: "fill",
|
| 138 |
+
source: level.source,
|
| 139 |
+
"source-layer": level.layer,
|
| 140 |
+
minzoom: level.zoom[0],
|
| 141 |
+
maxzoom: level.zoom[1],
|
| 142 |
+
paint: {
|
| 143 |
+
"fill-color": GRAY,
|
| 144 |
+
"fill-opacity": 0.75,
|
| 145 |
+
},
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
map.addLayer({
|
| 149 |
+
id: lineId,
|
| 150 |
+
type: "line",
|
| 151 |
+
source: level.source,
|
| 152 |
+
"source-layer": level.layer,
|
| 153 |
+
minzoom: level.zoom[0],
|
| 154 |
+
maxzoom: level.zoom[1],
|
| 155 |
+
paint: {
|
| 156 |
+
"line-color": "#fff",
|
| 157 |
+
"line-opacity": 0.3,
|
| 158 |
+
"line-width": level.dataKey === "section" ? 0.3 : 0.8,
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
// Hover interactions
|
| 163 |
+
map.on("mousemove", fillId, (e) => onHover(e, level));
|
| 164 |
+
map.on("mouseleave", fillId, onHoverEnd);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// Initial paint
|
| 168 |
+
updateColors();
|
| 169 |
+
updateLevelIndicator();
|
| 170 |
+
renderTopCities();
|
| 171 |
+
|
| 172 |
+
map.on("zoom", () => {
|
| 173 |
+
updateLevelIndicator();
|
| 174 |
+
// Load section data for visible departments when zoomed in
|
| 175 |
+
if (map.getZoom() >= 11) loadVisibleSections();
|
| 176 |
+
});
|
| 177 |
+
map.on("moveend", () => {
|
| 178 |
+
if (map.getZoom() >= 11) loadVisibleSections();
|
| 179 |
+
});
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// ---- Section Lazy Loading ----
|
| 183 |
+
|
| 184 |
+
async function loadVisibleSections() {
|
| 185 |
+
const bounds = map.getBounds();
|
| 186 |
+
// Figure out which departments are visible based on commune data
|
| 187 |
+
const deptCodes = new Set();
|
| 188 |
+
const features = map.queryRenderedFeatures({ layers: ["section-fill"] });
|
| 189 |
+
for (const f of features) {
|
| 190 |
+
const id = f.properties.id || "";
|
| 191 |
+
let dept;
|
| 192 |
+
if (id.startsWith("97") && id.length > 2) {
|
| 193 |
+
dept = id.substring(0, 3);
|
| 194 |
+
} else if (id.startsWith("2A") || id.startsWith("2B")) {
|
| 195 |
+
dept = id.substring(0, 2);
|
| 196 |
+
} else {
|
| 197 |
+
dept = id.substring(0, 2);
|
| 198 |
+
}
|
| 199 |
+
if (dept) deptCodes.add(dept);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
let changed = false;
|
| 203 |
+
for (const dept of deptCodes) {
|
| 204 |
+
if (!sectionCache[dept]) {
|
| 205 |
+
await loadSectionData(dept);
|
| 206 |
+
changed = true;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
if (changed) updateSectionColors();
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
// ---- Color Updates ----
|
| 213 |
+
|
| 214 |
+
function updateColors() {
|
| 215 |
+
for (const level of LEVELS) {
|
| 216 |
+
if (level.dataKey === "section") {
|
| 217 |
+
updateSectionColors();
|
| 218 |
+
continue;
|
| 219 |
+
}
|
| 220 |
+
const data = priceData[level.dataKey];
|
| 221 |
+
if (!data) continue;
|
| 222 |
+
|
| 223 |
+
const { scale, min, mid, max } = buildColorScale(data, currentType);
|
| 224 |
+
const fillId = `${level.dataKey}-fill`;
|
| 225 |
+
|
| 226 |
+
const matchExpr = ["match", ["get", level.codeField]];
|
| 227 |
+
let hasEntries = false;
|
| 228 |
+
|
| 229 |
+
for (const code in data) {
|
| 230 |
+
const stats = data[code][currentType];
|
| 231 |
+
if (stats && stats.wtm > 0) {
|
| 232 |
+
matchExpr.push(code, scale(stats.wtm));
|
| 233 |
+
hasEntries = true;
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
matchExpr.push(GRAY);
|
| 237 |
+
|
| 238 |
+
if (hasEntries) {
|
| 239 |
+
map.setPaintProperty(fillId, "fill-color", matchExpr);
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// Update legend for current visible level
|
| 243 |
+
const zoom = map.getZoom();
|
| 244 |
+
if (zoom >= level.zoom[0] && zoom < level.zoom[1]) {
|
| 245 |
+
updateLegend(min, mid, max);
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function updateSectionColors() {
|
| 251 |
+
// Merge all loaded section data
|
| 252 |
+
const allSections = {};
|
| 253 |
+
for (const dept in sectionCache) {
|
| 254 |
+
Object.assign(allSections, sectionCache[dept]);
|
| 255 |
+
}
|
| 256 |
+
if (Object.keys(allSections).length === 0) return;
|
| 257 |
+
|
| 258 |
+
const { scale } = buildColorScale(allSections, currentType);
|
| 259 |
+
const matchExpr = ["match", ["get", "id"]];
|
| 260 |
+
let hasEntries = false;
|
| 261 |
+
|
| 262 |
+
for (const code in allSections) {
|
| 263 |
+
const stats = allSections[code][currentType];
|
| 264 |
+
if (stats && stats.wtm > 0) {
|
| 265 |
+
matchExpr.push(code, scale(stats.wtm));
|
| 266 |
+
hasEntries = true;
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
matchExpr.push(GRAY);
|
| 270 |
+
|
| 271 |
+
if (hasEntries) {
|
| 272 |
+
map.setPaintProperty("section-fill", "fill-color", matchExpr);
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function updateLegend(min, mid, max) {
|
| 277 |
+
document.getElementById("legend-min").textContent = formatPrice(min);
|
| 278 |
+
document.getElementById("legend-mid").textContent = formatPrice(mid);
|
| 279 |
+
document.getElementById("legend-max").textContent = formatPrice(max);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
function updateLevelIndicator() {
|
| 283 |
+
const zoom = map.getZoom();
|
| 284 |
+
let levelName = "Country";
|
| 285 |
+
for (const level of LEVELS) {
|
| 286 |
+
if (zoom >= level.zoom[0] && zoom < level.zoom[1]) {
|
| 287 |
+
levelName = level.name;
|
| 288 |
+
break;
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
document.getElementById("current-level").textContent = levelName;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// ---- Hover ----
|
| 295 |
+
|
| 296 |
+
function onHover(e, level) {
|
| 297 |
+
if (!e.features || e.features.length === 0) return;
|
| 298 |
+
map.getCanvas().style.cursor = "pointer";
|
| 299 |
+
|
| 300 |
+
const props = e.features[0].properties;
|
| 301 |
+
const code = props[level.codeField];
|
| 302 |
+
const name = props.nom || props.code || code;
|
| 303 |
+
|
| 304 |
+
let stats;
|
| 305 |
+
if (level.dataKey === "section") {
|
| 306 |
+
// Look up in section cache
|
| 307 |
+
for (const dept in sectionCache) {
|
| 308 |
+
if (sectionCache[dept][code]) {
|
| 309 |
+
stats = sectionCache[dept][code][currentType];
|
| 310 |
+
break;
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
} else {
|
| 314 |
+
const data = priceData[level.dataKey];
|
| 315 |
+
if (data && data[code]) {
|
| 316 |
+
stats = data[code][currentType];
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
const infoEl = document.getElementById("hover-info");
|
| 321 |
+
if (!stats) {
|
| 322 |
+
infoEl.classList.add("hidden");
|
| 323 |
+
return;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
infoEl.classList.remove("hidden");
|
| 327 |
+
document.getElementById("hover-name").textContent = `${name} (${code})`;
|
| 328 |
+
document.getElementById("hover-wtm").textContent = formatPrice(stats.wtm);
|
| 329 |
+
document.getElementById("hover-median").textContent = formatPrice(stats.median);
|
| 330 |
+
document.getElementById("hover-iqr").textContent = `${formatPrice(stats.q1)} - ${formatPrice(stats.q3)}`;
|
| 331 |
+
document.getElementById("hover-volume").textContent = stats.volume.toLocaleString("fr-FR") + " transactions";
|
| 332 |
+
document.getElementById("hover-confidence").textContent = (stats.confidence * 100).toFixed(1) + "%";
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function onHoverEnd() {
|
| 336 |
+
map.getCanvas().style.cursor = "";
|
| 337 |
+
document.getElementById("hover-info").classList.add("hidden");
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
// ---- Top Cities Panel ----
|
| 341 |
+
|
| 342 |
+
function renderTopCities() {
|
| 343 |
+
const container = document.getElementById("cities-list");
|
| 344 |
+
container.innerHTML = "";
|
| 345 |
+
|
| 346 |
+
const sorted = Object.entries(topCities)
|
| 347 |
+
.filter(([, d]) => d.tous)
|
| 348 |
+
.sort((a, b) => (b[1].tous.wtm || 0) - (a[1].tous.wtm || 0));
|
| 349 |
+
|
| 350 |
+
for (const [cityName, cityData] of sorted) {
|
| 351 |
+
const stats = cityData[currentType] || cityData.tous;
|
| 352 |
+
const row = document.createElement("div");
|
| 353 |
+
row.className = "city-row";
|
| 354 |
+
row.innerHTML = `
|
| 355 |
+
<span class="city-name">${cityName}</span>
|
| 356 |
+
<span class="city-price">${formatPrice(stats.wtm)}</span>
|
| 357 |
+
`;
|
| 358 |
+
row.addEventListener("click", () => {
|
| 359 |
+
// Fly to city (approximate coordinates)
|
| 360 |
+
const cityCoords = getCityCoords(cityData.code);
|
| 361 |
+
if (cityCoords) {
|
| 362 |
+
map.flyTo({ center: cityCoords, zoom: 12, duration: 1500 });
|
| 363 |
+
}
|
| 364 |
+
});
|
| 365 |
+
container.appendChild(row);
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
function getCityCoords(code) {
|
| 370 |
+
const coords = {
|
| 371 |
+
"75056": [2.3522, 48.8566], // Paris
|
| 372 |
+
"13055": [5.3698, 43.2965], // Marseille
|
| 373 |
+
"69123": [4.8357, 45.7640], // Lyon
|
| 374 |
+
"31555": [1.4442, 43.6047], // Toulouse
|
| 375 |
+
"06088": [7.2620, 43.7102], // Nice
|
| 376 |
+
"44109": [-1.5536, 47.2184], // Nantes
|
| 377 |
+
"34172": [3.8767, 43.6108], // Montpellier
|
| 378 |
+
"67482": [7.7521, 48.5734], // Strasbourg
|
| 379 |
+
"33063": [-0.5792, 44.8378], // Bordeaux
|
| 380 |
+
"59350": [3.0573, 50.6292], // Lille
|
| 381 |
+
};
|
| 382 |
+
return coords[code] || null;
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
// ---- Event Listeners ----
|
| 386 |
+
|
| 387 |
+
document.getElementById("type-select").addEventListener("change", (e) => {
|
| 388 |
+
currentType = e.target.value;
|
| 389 |
+
updateColors();
|
| 390 |
+
renderTopCities();
|
| 391 |
+
});
|
| 392 |
+
|
| 393 |
+
// ---- Boot ----
|
| 394 |
+
|
| 395 |
+
(async function main() {
|
| 396 |
+
try {
|
| 397 |
+
await loadPriceData();
|
| 398 |
+
initMap();
|
| 399 |
+
} catch (err) {
|
| 400 |
+
console.error("Failed to initialize:", err);
|
| 401 |
+
document.body.innerHTML = `<div style="padding:40px;color:#ff6b6b;">
|
| 402 |
+
<h2>Failed to load data</h2><p>${err.message}</p></div>`;
|
| 403 |
+
}
|
| 404 |
+
})();
|
static/index.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>French Property Prices - Interactive Map</title>
|
| 7 |
+
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="app">
|
| 12 |
+
<div id="map"></div>
|
| 13 |
+
|
| 14 |
+
<!-- Info panel -->
|
| 15 |
+
<div id="panel">
|
| 16 |
+
<h1>France Property Prices</h1>
|
| 17 |
+
<p class="subtitle">Residential price per m² (EUR)</p>
|
| 18 |
+
|
| 19 |
+
<!-- Property type selector -->
|
| 20 |
+
<div class="controls">
|
| 21 |
+
<label>Type:</label>
|
| 22 |
+
<select id="type-select">
|
| 23 |
+
<option value="tous">All residential</option>
|
| 24 |
+
<option value="appartement">Appartement</option>
|
| 25 |
+
<option value="maison">Maison</option>
|
| 26 |
+
</select>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<!-- Current level indicator -->
|
| 30 |
+
<div class="level-indicator">
|
| 31 |
+
<span id="current-level">Country</span>
|
| 32 |
+
<span class="zoom-hint">Zoom to explore</span>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<!-- Legend -->
|
| 36 |
+
<div id="legend">
|
| 37 |
+
<div class="legend-bar"></div>
|
| 38 |
+
<div class="legend-labels">
|
| 39 |
+
<span id="legend-min"></span>
|
| 40 |
+
<span id="legend-mid"></span>
|
| 41 |
+
<span id="legend-max"></span>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<!-- Hover info -->
|
| 46 |
+
<div id="hover-info" class="hidden">
|
| 47 |
+
<h3 id="hover-name"></h3>
|
| 48 |
+
<div class="stat-row">
|
| 49 |
+
<span class="stat-label">WTM:</span>
|
| 50 |
+
<span id="hover-wtm" class="stat-value"></span>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="stat-row">
|
| 53 |
+
<span class="stat-label">Median:</span>
|
| 54 |
+
<span id="hover-median" class="stat-value"></span>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="stat-row">
|
| 57 |
+
<span class="stat-label">Q1-Q3:</span>
|
| 58 |
+
<span id="hover-iqr" class="stat-value"></span>
|
| 59 |
+
</div>
|
| 60 |
+
<div class="stat-row">
|
| 61 |
+
<span class="stat-label">Volume:</span>
|
| 62 |
+
<span id="hover-volume" class="stat-value"></span>
|
| 63 |
+
</div>
|
| 64 |
+
<div class="stat-row">
|
| 65 |
+
<span class="stat-label">Confidence:</span>
|
| 66 |
+
<span id="hover-confidence" class="stat-value"></span>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- Top 10 cities -->
|
| 71 |
+
<div id="top-cities">
|
| 72 |
+
<h2>Top 10 Cities</h2>
|
| 73 |
+
<div id="cities-list"></div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
<div class="footer">
|
| 77 |
+
<p>Source: DVF (data.gouv.fr) 2020-2025</p>
|
| 78 |
+
<p>Method: Time-weighted trimmed mean</p>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
| 84 |
+
<script src="https://unpkg.com/d3-scale@4/dist/d3-scale.min.js"></script>
|
| 85 |
+
<script src="https://unpkg.com/d3-interpolate@3/dist/d3-interpolate.min.js"></script>
|
| 86 |
+
<script src="https://unpkg.com/d3-color@3/dist/d3-color.min.js"></script>
|
| 87 |
+
<script src="/static/app.js"></script>
|
| 88 |
+
</body>
|
| 89 |
+
</html>
|
static/style.css
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
margin: 0;
|
| 3 |
+
padding: 0;
|
| 4 |
+
box-sizing: border-box;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
body {
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 9 |
+
background: #1a1a2e;
|
| 10 |
+
color: #e0e0e0;
|
| 11 |
+
overflow: hidden;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#app {
|
| 15 |
+
display: flex;
|
| 16 |
+
height: 100vh;
|
| 17 |
+
width: 100vw;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#map {
|
| 21 |
+
flex: 1;
|
| 22 |
+
height: 100%;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* Side panel */
|
| 26 |
+
#panel {
|
| 27 |
+
width: 320px;
|
| 28 |
+
height: 100vh;
|
| 29 |
+
overflow-y: auto;
|
| 30 |
+
background: #16213e;
|
| 31 |
+
padding: 20px;
|
| 32 |
+
display: flex;
|
| 33 |
+
flex-direction: column;
|
| 34 |
+
gap: 16px;
|
| 35 |
+
border-left: 1px solid #0f3460;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
#panel h1 {
|
| 39 |
+
font-size: 1.3rem;
|
| 40 |
+
color: #fff;
|
| 41 |
+
margin: 0;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.subtitle {
|
| 45 |
+
font-size: 0.85rem;
|
| 46 |
+
color: #888;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Controls */
|
| 50 |
+
.controls {
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
gap: 10px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.controls label {
|
| 57 |
+
font-size: 0.85rem;
|
| 58 |
+
color: #aaa;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.controls select {
|
| 62 |
+
flex: 1;
|
| 63 |
+
padding: 6px 10px;
|
| 64 |
+
background: #0f3460;
|
| 65 |
+
color: #e0e0e0;
|
| 66 |
+
border: 1px solid #1a1a5e;
|
| 67 |
+
border-radius: 4px;
|
| 68 |
+
font-size: 0.85rem;
|
| 69 |
+
cursor: pointer;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* Level indicator */
|
| 73 |
+
.level-indicator {
|
| 74 |
+
display: flex;
|
| 75 |
+
justify-content: space-between;
|
| 76 |
+
align-items: center;
|
| 77 |
+
padding: 8px 12px;
|
| 78 |
+
background: #0f3460;
|
| 79 |
+
border-radius: 6px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
#current-level {
|
| 83 |
+
font-weight: 600;
|
| 84 |
+
color: #53d8fb;
|
| 85 |
+
font-size: 0.95rem;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.zoom-hint {
|
| 89 |
+
font-size: 0.75rem;
|
| 90 |
+
color: #666;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/* Legend */
|
| 94 |
+
#legend {
|
| 95 |
+
padding: 10px 0;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.legend-bar {
|
| 99 |
+
height: 12px;
|
| 100 |
+
border-radius: 6px;
|
| 101 |
+
background: linear-gradient(to right, #028758, #FFF64E, #CC000A);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.legend-labels {
|
| 105 |
+
display: flex;
|
| 106 |
+
justify-content: space-between;
|
| 107 |
+
font-size: 0.75rem;
|
| 108 |
+
color: #888;
|
| 109 |
+
margin-top: 4px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Hover info */
|
| 113 |
+
#hover-info {
|
| 114 |
+
padding: 12px;
|
| 115 |
+
background: #0f3460;
|
| 116 |
+
border-radius: 6px;
|
| 117 |
+
border-left: 3px solid #53d8fb;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#hover-info.hidden {
|
| 121 |
+
display: none;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#hover-info h3 {
|
| 125 |
+
font-size: 0.95rem;
|
| 126 |
+
color: #fff;
|
| 127 |
+
margin-bottom: 8px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.stat-row {
|
| 131 |
+
display: flex;
|
| 132 |
+
justify-content: space-between;
|
| 133 |
+
padding: 2px 0;
|
| 134 |
+
font-size: 0.85rem;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.stat-label {
|
| 138 |
+
color: #888;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.stat-value {
|
| 142 |
+
color: #e0e0e0;
|
| 143 |
+
font-weight: 500;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* Top 10 cities */
|
| 147 |
+
#top-cities {
|
| 148 |
+
flex: 1;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
#top-cities h2 {
|
| 152 |
+
font-size: 1rem;
|
| 153 |
+
color: #fff;
|
| 154 |
+
margin-bottom: 10px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.city-row {
|
| 158 |
+
display: flex;
|
| 159 |
+
justify-content: space-between;
|
| 160 |
+
align-items: center;
|
| 161 |
+
padding: 6px 10px;
|
| 162 |
+
margin-bottom: 4px;
|
| 163 |
+
background: #0f3460;
|
| 164 |
+
border-radius: 4px;
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
transition: background 0.15s;
|
| 167 |
+
font-size: 0.85rem;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.city-row:hover {
|
| 171 |
+
background: #1a1a5e;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.city-name {
|
| 175 |
+
color: #e0e0e0;
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.city-price {
|
| 179 |
+
font-weight: 600;
|
| 180 |
+
color: #53d8fb;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* Footer */
|
| 184 |
+
.footer {
|
| 185 |
+
padding-top: 10px;
|
| 186 |
+
border-top: 1px solid #0f3460;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.footer p {
|
| 190 |
+
font-size: 0.7rem;
|
| 191 |
+
color: #555;
|
| 192 |
+
line-height: 1.4;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/* Loading overlay */
|
| 196 |
+
#loading {
|
| 197 |
+
position: fixed;
|
| 198 |
+
top: 0;
|
| 199 |
+
left: 0;
|
| 200 |
+
right: 0;
|
| 201 |
+
bottom: 0;
|
| 202 |
+
background: rgba(26, 26, 46, 0.9);
|
| 203 |
+
display: flex;
|
| 204 |
+
align-items: center;
|
| 205 |
+
justify-content: center;
|
| 206 |
+
z-index: 1000;
|
| 207 |
+
font-size: 1.2rem;
|
| 208 |
+
color: #53d8fb;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Responsive */
|
| 212 |
+
@media (max-width: 768px) {
|
| 213 |
+
#app {
|
| 214 |
+
flex-direction: column-reverse;
|
| 215 |
+
}
|
| 216 |
+
#panel {
|
| 217 |
+
width: 100%;
|
| 218 |
+
height: 40vh;
|
| 219 |
+
border-left: none;
|
| 220 |
+
border-top: 1px solid #0f3460;
|
| 221 |
+
}
|
| 222 |
+
#map {
|
| 223 |
+
height: 60vh;
|
| 224 |
+
}
|
| 225 |
+
}
|