dcrey7 commited on
Commit
52559d6
·
1 Parent(s): fcb58ba

feat: add interactive map frontend and deployment config

Browse files

MapLibre GL JS choropleth with 4 zoom-based levels,
FastAPI backend, Dockerfile for HF Spaces.

Files changed (8) hide show
  1. .dockerignore +28 -0
  2. Dockerfile +21 -0
  3. README.md +48 -0
  4. app.py +35 -0
  5. requirements-app.txt +2 -0
  6. static/app.js +404 -0
  7. static/index.html +89 -0
  8. 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&sup2; (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
+ }