demo: carte interactive des arbres de Paris, complément du notebook
La page Leaflet versionnée est celle publiée sur demo.gregoiremureau.com. Le notebook et les cartes Folium d'origine ne changent pas.
This commit is contained in:
parent
fb22464aa3
commit
be6f7d1e06
6 changed files with 1062 additions and 0 deletions
|
|
@ -47,9 +47,14 @@ Le dataset est inclus dans le projet sous le nom `p2-trees-fr.csv`.
|
||||||
├── data_analysis_functions.py
|
├── data_analysis_functions.py
|
||||||
├── map_arrondissement.html
|
├── map_arrondissement.html
|
||||||
├── map_lieu.html
|
├── map_lieu.html
|
||||||
|
├── demo/ (travail complémentaire : carte interactive)
|
||||||
└── p2-trees-fr.csv
|
└── p2-trees-fr.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Travail complémentaire (`demo/`)
|
||||||
|
|
||||||
|
Une carte interactive Leaflet, générée à partir du même CSV, avec filtres par zone et par espèce, trois vues (densité, heatmap, points) et indicateurs recalculés. Elle ne remplace pas le notebook ni les cartes Folium d'origine. Page en ligne : https://demo.gregoiremureau.com/arbres-paris-carte-interactive.html. Détail : [`demo/README.fr.md`](./demo/README.fr.md).
|
||||||
|
|
||||||
### 1. `Paris_trees_analysis.ipynb`
|
### 1. `Paris_trees_analysis.ipynb`
|
||||||
|
|
||||||
**Notebook Principal d'Analyse des Arbres Parisiens**
|
**Notebook Principal d'Analyse des Arbres Parisiens**
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,14 @@ The dataset is included in the project under the name `p2-trees-fr.csv`.
|
||||||
├── data_analysis_functions.py
|
├── data_analysis_functions.py
|
||||||
├── map_arrondissement.html
|
├── map_arrondissement.html
|
||||||
├── map_lieu.html
|
├── map_lieu.html
|
||||||
|
├── demo/ (complementary work: interactive map)
|
||||||
└── p2-trees-fr.csv
|
└── p2-trees-fr.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Complementary work (`demo/`)
|
||||||
|
|
||||||
|
An interactive Leaflet map generated from the same CSV, with filters by district and species, three views (density, heatmap, points) and live KPIs. It does not replace the notebook or the original Folium maps. Live page: https://demo.gregoiremureau.com/arbres-paris-carte-interactive.html. Details: [`demo/README.md`](./demo/README.md).
|
||||||
|
|
||||||
### 1. `Paris_trees_analysis.ipynb`
|
### 1. `Paris_trees_analysis.ipynb`
|
||||||
|
|
||||||
**Main Notebook for Paris Trees Analysis**
|
**Main Notebook for Paris Trees Analysis**
|
||||||
|
|
|
||||||
53
demo/README.fr.md
Normal file
53
demo/README.fr.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Travail complémentaire - carte interactive des arbres de Paris
|
||||||
|
|
||||||
|
📘 This folder is also documented in [English 🇬🇧](./README.md)
|
||||||
|
|
||||||
|
Ce dossier est un **complément** au projet P02, pas une pièce du rendu d'origine.
|
||||||
|
Le notebook `Paris_trees_analysis.ipynb`, le module `data_analysis_functions.py`
|
||||||
|
et les cartes Folium `map_arrondissement.html` / `map_lieu.html` restent la
|
||||||
|
source de référence et ne sont pas modifiés.
|
||||||
|
|
||||||
|
## Ce que ça ajoute
|
||||||
|
|
||||||
|
Le notebook produit des cartes Folium statiques. Cette page va plus loin côté
|
||||||
|
restitution :
|
||||||
|
|
||||||
|
- **filtres** par zone (arrondissements, bois, départements limitrophes) et par
|
||||||
|
espèce (top 40 + « autres espèces ») ;
|
||||||
|
- **trois vues** : densité par zone (cercles proportionnels), heatmap, points
|
||||||
|
individuels ;
|
||||||
|
- **KPI recalculés à la volée** : nombre d'arbres, hauteur moyenne,
|
||||||
|
circonférence moyenne, nombre de remarquables ;
|
||||||
|
- **top espèces / top zones** mis à jour selon les filtres ;
|
||||||
|
- page **autonome** (un seul fichier HTML). Leaflet et Leaflet.heat sont
|
||||||
|
chargés depuis un CDN, avec des empreintes Subresource Integrity.
|
||||||
|
|
||||||
|
C'est le même moteur que Folium (Leaflet), piloté directement en JavaScript
|
||||||
|
pour avoir des filtres utilisables.
|
||||||
|
|
||||||
|
Page en ligne (le même fichier) : https://demo.gregoiremureau.com/arbres-paris-carte-interactive.html
|
||||||
|
|
||||||
|
## Régénérer la page
|
||||||
|
|
||||||
|
Le CSV n'est pas versionné (environ 29 Mo). Voir `../fetch-data.sh` pour le
|
||||||
|
récupérer depuis l'open data de la Ville de Paris.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd demo
|
||||||
|
python3 generer_carte_arbres.py ../p2-trees-fr.csv carte_arbres_paris_interactive.html
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script n'a besoin que de la bibliothèque standard Python (csv, json, random).
|
||||||
|
Il échantillonne 8 000 points pour la heatmap et agrège les 200 137 arbres
|
||||||
|
par zone et par espèce.
|
||||||
|
|
||||||
|
## Données
|
||||||
|
|
||||||
|
- Source : [Open Data Ville de Paris - Les arbres](https://opendata.paris.fr/explore/dataset/les-arbres/)
|
||||||
|
- 200 137 arbres, 536 espèces, 184 remarquables.
|
||||||
|
- Les valeurs manquantes et aberrantes sont traitées dans le notebook
|
||||||
|
d'origine. Cette page part du CSV brut pour la restitution.
|
||||||
|
|
||||||
|
## Auteur
|
||||||
|
|
||||||
|
Grégoire Mureau - travail complémentaire au projet P02 (Master AI Engineer).
|
||||||
52
demo/README.md
Normal file
52
demo/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Complementary work - interactive map of Paris trees
|
||||||
|
|
||||||
|
📘 Ce dossier est également documenté en [français 🇫🇷](./README.fr.md)
|
||||||
|
|
||||||
|
This folder is a **complement** to project P02, not part of the original submission.
|
||||||
|
The notebook `Paris_trees_analysis.ipynb`, the module `data_analysis_functions.py`
|
||||||
|
and the Folium maps `map_arrondissement.html` / `map_lieu.html` stay the
|
||||||
|
reference and are not modified.
|
||||||
|
|
||||||
|
## What it adds
|
||||||
|
|
||||||
|
The notebook produces static Folium maps. This page goes further on the
|
||||||
|
user-facing side:
|
||||||
|
|
||||||
|
- **filters** by area (districts, woods, neighbouring departments) and by
|
||||||
|
species (top 40 + "other species");
|
||||||
|
- **three views**: density by area (proportional circles), heatmap, individual points;
|
||||||
|
- **KPIs recomputed on the fly**: tree count, mean height, mean circumference,
|
||||||
|
number of remarkable trees;
|
||||||
|
- **top species / top areas** updated with the filters;
|
||||||
|
- a **single HTML file**. Leaflet and Leaflet.heat are loaded from a CDN,
|
||||||
|
with Subresource Integrity hashes.
|
||||||
|
|
||||||
|
It uses the same engine as Folium (Leaflet), driven directly in JavaScript
|
||||||
|
so the filters are practical to use.
|
||||||
|
|
||||||
|
Live page (same file): https://demo.gregoiremureau.com/arbres-paris-carte-interactive.html
|
||||||
|
|
||||||
|
## Regenerate the page
|
||||||
|
|
||||||
|
The CSV is not versioned (about 29 MB). See `../fetch-data.sh` to get it
|
||||||
|
from the City of Paris open data.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd demo
|
||||||
|
python3 generer_carte_arbres.py ../p2-trees-fr.csv carte_arbres_paris_interactive.html
|
||||||
|
```
|
||||||
|
|
||||||
|
The script only needs the Python standard library (csv, json, random).
|
||||||
|
It samples 8,000 points for the heatmap and aggregates the 200,137 trees
|
||||||
|
by area and by species.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
- Source: [Open Data City of Paris - Trees](https://opendata.paris.fr/explore/dataset/les-arbres/)
|
||||||
|
- 200,137 trees, 536 species, 184 remarkable trees.
|
||||||
|
- Missing and aberrant values are handled in the original notebook. This page
|
||||||
|
starts from the raw CSV for the display.
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Grégoire Mureau - complementary work on project P02 (Master AI Engineer).
|
||||||
376
demo/carte_arbres_paris_interactive.html
Normal file
376
demo/carte_arbres_paris_interactive.html
Normal file
File diff suppressed because one or more lines are too long
571
demo/generer_carte_arbres.py
Normal file
571
demo/generer_carte_arbres.py
Normal file
|
|
@ -0,0 +1,571 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Génère une carte interactive des arbres de Paris (travail complémentaire P02).
|
||||||
|
|
||||||
|
Lit le CSV Open Data Paris (p2-trees-fr.csv), agrège par zone et par espèce,
|
||||||
|
échantillonne des points pour la heatmap, et écrit une page HTML autonome
|
||||||
|
(Leaflet + Leaflet.heat via CDN).
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 generer_carte_arbres.py ../p2-trees-fr.csv carte_arbres_paris_interactive.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SAMPLE_POINTS = 8000
|
||||||
|
TOP_SPECIES = 40
|
||||||
|
|
||||||
|
|
||||||
|
def parse_float(value):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def zone_sort_key(name):
|
||||||
|
upper = name.upper()
|
||||||
|
if upper.startswith("PARIS ") and "ARRDT" in upper:
|
||||||
|
digits = "".join(c for c in upper.split()[1] if c.isdigit())
|
||||||
|
return (0, int(digits) if digits else 99)
|
||||||
|
if upper.startswith("BOIS DE"):
|
||||||
|
return (1, upper)
|
||||||
|
return (2, upper)
|
||||||
|
|
||||||
|
|
||||||
|
def species_label(row):
|
||||||
|
label = (row.get("libelle_francais") or "").strip()
|
||||||
|
if label:
|
||||||
|
return label
|
||||||
|
genre = (row.get("genre") or "").strip()
|
||||||
|
espece = (row.get("espece") or "").strip()
|
||||||
|
label = f"{genre} {espece}".strip()
|
||||||
|
return label or "Non renseigné"
|
||||||
|
|
||||||
|
|
||||||
|
def main(csv_path, out_path):
|
||||||
|
rows = []
|
||||||
|
zone_counts = defaultdict(int)
|
||||||
|
species_counts = defaultdict(int)
|
||||||
|
zone_species = defaultdict(lambda: [0, 0, 0.0, 0, 0.0, 0, 0, 0.0, 0, 0.0])
|
||||||
|
zone_lat = defaultdict(float)
|
||||||
|
zone_lon = defaultdict(float)
|
||||||
|
zone_n = defaultdict(int)
|
||||||
|
zone_top = defaultdict(lambda: defaultdict(int))
|
||||||
|
species_top = defaultdict(lambda: defaultdict(int))
|
||||||
|
global_stats = [0, 0, 0.0, 0, 0.0, 0, 0, 0.0, 0, 0.0]
|
||||||
|
|
||||||
|
reservoir = []
|
||||||
|
seen = 0
|
||||||
|
rng = random.Random(42)
|
||||||
|
|
||||||
|
with open(csv_path, newline="", encoding="utf-8") as handle:
|
||||||
|
reader = csv.DictReader(handle, delimiter=";")
|
||||||
|
for row in reader:
|
||||||
|
zone = (row.get("arrondissement") or "").strip() or "Non renseigné"
|
||||||
|
species = species_label(row)
|
||||||
|
height = parse_float(row.get("hauteur_m"))
|
||||||
|
circ = parse_float(row.get("circonference_cm"))
|
||||||
|
remarkable = (row.get("remarquable") or "").strip() == "1"
|
||||||
|
lat = parse_float(row.get("geo_point_2d_a"))
|
||||||
|
lon = parse_float(row.get("geo_point_2d_b"))
|
||||||
|
|
||||||
|
zone_counts[zone] += 1
|
||||||
|
species_counts[species] += 1
|
||||||
|
zone_top[zone][species] += 1
|
||||||
|
species_top[species][zone] += 1
|
||||||
|
|
||||||
|
cell = zone_species[(zone, species)]
|
||||||
|
cell[0] += 1
|
||||||
|
if height is not None:
|
||||||
|
cell[1] += 1
|
||||||
|
cell[2] += height
|
||||||
|
if circ is not None:
|
||||||
|
cell[3] += 1
|
||||||
|
cell[4] += circ
|
||||||
|
if remarkable:
|
||||||
|
cell[5] += 1
|
||||||
|
if height is not None:
|
||||||
|
cell[6] += 1
|
||||||
|
cell[7] += height
|
||||||
|
if circ is not None:
|
||||||
|
cell[8] += 1
|
||||||
|
cell[9] += circ
|
||||||
|
|
||||||
|
global_stats[0] += 1
|
||||||
|
if height is not None:
|
||||||
|
global_stats[1] += 1
|
||||||
|
global_stats[2] += height
|
||||||
|
if circ is not None:
|
||||||
|
global_stats[3] += 1
|
||||||
|
global_stats[4] += circ
|
||||||
|
if remarkable:
|
||||||
|
global_stats[5] += 1
|
||||||
|
if height is not None:
|
||||||
|
global_stats[6] += 1
|
||||||
|
global_stats[7] += height
|
||||||
|
if circ is not None:
|
||||||
|
global_stats[8] += 1
|
||||||
|
global_stats[9] += circ
|
||||||
|
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
zone_lat[zone] += lat
|
||||||
|
zone_lon[zone] += lon
|
||||||
|
zone_n[zone] += 1
|
||||||
|
seen += 1
|
||||||
|
point = [round(lat, 5), round(lon, 5), zone, species,
|
||||||
|
round(height, 1) if height is not None else None,
|
||||||
|
round(circ, 1) if circ is not None else None,
|
||||||
|
1 if remarkable else 0]
|
||||||
|
if len(reservoir) < SAMPLE_POINTS:
|
||||||
|
reservoir.append(point)
|
||||||
|
else:
|
||||||
|
j = rng.randint(0, seen - 1)
|
||||||
|
if j < SAMPLE_POINTS:
|
||||||
|
reservoir[j] = point
|
||||||
|
|
||||||
|
zones = sorted(zone_counts, key=zone_sort_key)
|
||||||
|
species = sorted(species_counts, key=lambda s: (-species_counts[s], s))
|
||||||
|
zone_index = {name: i for i, name in enumerate(zones)}
|
||||||
|
species_index = {name: i for i, name in enumerate(species)}
|
||||||
|
|
||||||
|
zone_payload = []
|
||||||
|
for zone in zones:
|
||||||
|
lat = zone_lat[zone] / zone_n[zone] if zone_n[zone] else None
|
||||||
|
lon = zone_lon[zone] / zone_n[zone] if zone_n[zone] else None
|
||||||
|
top = sorted(zone_top[zone].items(), key=lambda kv: (-kv[1], kv[0]))[:3]
|
||||||
|
zone_payload.append({
|
||||||
|
"name": zone,
|
||||||
|
"count": zone_counts[zone],
|
||||||
|
"lat": round(lat, 5) if lat is not None else None,
|
||||||
|
"lon": round(lon, 5) if lon is not None else None,
|
||||||
|
"top": [[name, count] for name, count in top],
|
||||||
|
})
|
||||||
|
|
||||||
|
species_payload = []
|
||||||
|
for name in species:
|
||||||
|
top = sorted(species_top[name].items(), key=lambda kv: (-kv[1], kv[0]))[:1]
|
||||||
|
species_payload.append({
|
||||||
|
"name": name,
|
||||||
|
"count": species_counts[name],
|
||||||
|
"top": [[zone, count] for zone, count in top],
|
||||||
|
})
|
||||||
|
|
||||||
|
matrix = {}
|
||||||
|
for (zone, sp), cell in zone_species.items():
|
||||||
|
matrix[f"{zone_index[zone]}|{species_index[sp]}"] = [round(v, 1) if isinstance(v, float) else v for v in cell]
|
||||||
|
|
||||||
|
points = []
|
||||||
|
for lat, lon, zone, sp, height, circ, remarkable in reservoir:
|
||||||
|
points.append([lat, lon, zone_index[zone], species_index[sp], height, circ, remarkable])
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"global": {
|
||||||
|
"total": global_stats[0],
|
||||||
|
"zones": len(zones),
|
||||||
|
"species": len(species),
|
||||||
|
"avgHeight": round(global_stats[2] / global_stats[1], 1) if global_stats[1] else None,
|
||||||
|
"avgCirc": round(global_stats[4] / global_stats[3], 1) if global_stats[3] else None,
|
||||||
|
"remarkable": global_stats[5],
|
||||||
|
},
|
||||||
|
"zones": zone_payload,
|
||||||
|
"species": species_payload,
|
||||||
|
"matrix": matrix,
|
||||||
|
"points": points,
|
||||||
|
"topSpecies": species[:TOP_SPECIES],
|
||||||
|
}
|
||||||
|
|
||||||
|
html = HTML_TEMPLATE.replace("__DATA__", json.dumps(data, ensure_ascii=False, separators=(",", ":")))
|
||||||
|
Path(out_path).write_text(html, encoding="utf-8")
|
||||||
|
print(f"écrit {out_path} ({Path(out_path).stat().st_size / 1024 / 1024:.2f} Mo)")
|
||||||
|
print(f"{len(zones)} zones, {len(species)} espèces, {len(points)} points échantillonnés")
|
||||||
|
|
||||||
|
|
||||||
|
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="index, follow"><meta name="description" content="Carte interactive des arbres de Paris (open data Ville de Paris) : filtres par zone et espèce, densité, heatmap, indicateurs recalculés. Démo de cartographie web.">
|
||||||
|
<title>Arbres de Paris - carte interactive</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha384-sHL9NAb7lN7rfvG5lfHpm643Xkcjzp4jFvuavGOndn6pjVqS6ny56CAt3nsEVT4H" crossorigin="anonymous">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha384-cxOPjt7s7Iz04uaHJceBmS+qpjv2JkIHNVcuOrM+YHwZOmJGBXI00mdUXEq65HTH" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js" integrity="sha384-mFKkGiGvT5vo1fEyGCD3hshDdKmW3wzXW/x+fWriYJArD0R3gawT6lMvLboM22c0" crossorigin="anonymous"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1120;
|
||||||
|
--panel: #171a2e;
|
||||||
|
--panel-2: #1e2238;
|
||||||
|
--line: #2b3050;
|
||||||
|
--ink: #e8eaf2;
|
||||||
|
--muted: #9aa1b8;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent-2: #ff9f43;
|
||||||
|
--ok: #2ecc71;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1240px; margin: 0 auto; padding: 1.4rem 1rem 3rem; }
|
||||||
|
header { margin-bottom: 1rem; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid rgba(233, 69, 96, 0.45);
|
||||||
|
background: rgba(233, 69, 96, 0.12);
|
||||||
|
color: #ff8ba0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 { margin: 0.45rem 0 0.2rem; font-size: 1.65rem; }
|
||||||
|
.sub { margin: 0; color: var(--muted); font-size: 0.9rem; max-width: 900px; }
|
||||||
|
.sub a { color: #ff9f43; }
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
}
|
||||||
|
.kpi strong { display: block; font-size: 1.15rem; color: #fff; line-height: 1.15; }
|
||||||
|
.kpi span { display: block; font-size: 0.68rem; color: var(--muted); margin-top: 0.15rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.6fr auto auto auto;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: end;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
|
.field label { font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
select, button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
}
|
||||||
|
select:focus, button:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
.check { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--muted); padding-bottom: 0.45rem; }
|
||||||
|
.check input { accent-color: var(--accent); }
|
||||||
|
.layout { display: grid; grid-template-columns: 1fr 300px; gap: 0.75rem; }
|
||||||
|
#map {
|
||||||
|
height: 62vh;
|
||||||
|
min-height: 440px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #dfe6ee;
|
||||||
|
}
|
||||||
|
.side { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
}
|
||||||
|
.panel h2 { margin: 0 0 0.5rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||||
|
.bar-row { margin-bottom: 0.45rem; }
|
||||||
|
.bar-top { display: flex; justify-content: space-between; gap: 0.5rem; font-size: 0.78rem; }
|
||||||
|
.bar-top span:last-child { color: var(--muted); white-space: nowrap; }
|
||||||
|
.bar { height: 5px; background: #0d0f1c; border-radius: 999px; margin-top: 0.2rem; overflow: hidden; }
|
||||||
|
.bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); border-radius: 999px; }
|
||||||
|
.legend {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legend .grad { height: 8px; border-radius: 999px; margin: 0.35rem 0; background: linear-gradient(90deg, #ffe08a, #ff9f43, #e94560, #8e1f38); }
|
||||||
|
.legend .row { display: flex; justify-content: space-between; font-size: 0.68rem; }
|
||||||
|
.note { font-size: 0.78rem; color: var(--muted); margin-top: 0.8rem; }
|
||||||
|
.note a { color: #ff9f43; }
|
||||||
|
footer { margin-top: 1.4rem; padding-top: 0.8rem; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.78rem; }
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.toolbar { grid-template-columns: 1fr 1fr; }
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
#map { height: 55vh; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.kpis { grid-template-columns: 1fr 1fr; }
|
||||||
|
.toolbar { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header>
|
||||||
|
<span class="badge">Travail complémentaire - projet P02</span>
|
||||||
|
<h1>Arbres de Paris - carte interactive</h1>
|
||||||
|
<p class="sub">Exploration du patrimoine arboré parisien à partir de l'open data de la Ville de Paris. Filtres par zone et par espèce, trois vues (densité, heatmap, points), indicateurs recalculés à la volée. Le notebook et les cartes Folium d'origine restent dans <a href="https://git.gregoiremureau.com/openclassroom-ai/oc-p02-paris-trees" target="_blank" rel="noopener">le dépôt P02</a> ; cette page est un complément interactif.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="kpis" id="kpis"></div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="field">
|
||||||
|
<label for="zone">Zone</label>
|
||||||
|
<select id="zone"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="species">Espèce</label>
|
||||||
|
<select id="species"></select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="view">Vue</label>
|
||||||
|
<select id="view">
|
||||||
|
<option value="zones">Densité par zone</option>
|
||||||
|
<option value="heat">Heatmap</option>
|
||||||
|
<option value="points">Points</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="check"><input type="checkbox" id="remarkable"> Remarquables</label>
|
||||||
|
<button type="button" id="reset">Réinitialiser</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div id="map"></div>
|
||||||
|
<aside class="side">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Top espèces</h2>
|
||||||
|
<div id="topSpecies"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Top zones</h2>
|
||||||
|
<div id="topZones"></div>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<div><strong>Légende</strong></div>
|
||||||
|
<div class="grad"></div>
|
||||||
|
<div class="row"><span>Faible</span><span>Forte densité</span></div>
|
||||||
|
<div id="legendText" style="margin-top:0.4rem;"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">Données : <a href="https://opendata.paris.fr/explore/dataset/les-arbres/" target="_blank" rel="noopener">Open Data Ville de Paris - Les arbres</a>. 200 137 arbres, 536 espèces, 184 remarquables. Le notebook d'origine traite le nettoyage et les analyses statistiques ; cette page ajoute les agrégats, les filtres et la restitution interactive.</p>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Grégoire Mureau - démo de cartographie web, travail complémentaire au projet P02 (Master AI Engineer).
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const DATA = __DATA__;
|
||||||
|
const TOP = DATA.topSpecies.length;
|
||||||
|
const state = { zone: -1, species: -1, remarkable: false, view: 'zones' };
|
||||||
|
|
||||||
|
const map = L.map('map', { zoomControl: true, preferCanvas: true }).setView([48.8566, 2.3522], 12);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const zonesLayer = L.layerGroup();
|
||||||
|
const pointsLayer = L.layerGroup();
|
||||||
|
let heatLayer = null;
|
||||||
|
|
||||||
|
function zoneName(i) { return DATA.zones[i] ? DATA.zones[i].name : ''; }
|
||||||
|
function speciesName(i) { return DATA.species[i] ? DATA.species[i].name : ''; }
|
||||||
|
function fmt(n) { return n == null ? 'n/d' : Number(n).toLocaleString('fr-FR'); }
|
||||||
|
|
||||||
|
function cellFor(zi, si) {
|
||||||
|
const cell = DATA.matrix[zi + '|' + si];
|
||||||
|
return cell || [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedSpeciesMatch(si) {
|
||||||
|
if (state.species === -1) return true;
|
||||||
|
if (state.species === -2) return si >= TOP;
|
||||||
|
return si === state.species;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate() {
|
||||||
|
let count = 0, nH = 0, sumH = 0, nC = 0, sumC = 0, rem = 0, remNH = 0, remSumH = 0, remNC = 0, remSumC = 0;
|
||||||
|
const bySpecies = {};
|
||||||
|
const byZone = {};
|
||||||
|
DATA.zones.forEach((z, zi) => {
|
||||||
|
if (state.zone !== -1 && zi !== state.zone) return;
|
||||||
|
DATA.species.forEach((s, si) => {
|
||||||
|
if (!selectedSpeciesMatch(si)) return;
|
||||||
|
const c = cellFor(zi, si);
|
||||||
|
if (!c[0]) return;
|
||||||
|
if (state.remarkable) {
|
||||||
|
if (!c[5]) return;
|
||||||
|
count += c[5]; rem += c[5];
|
||||||
|
nH += c[6]; sumH += c[7]; nC += c[8]; sumC += c[9];
|
||||||
|
} else {
|
||||||
|
count += c[0]; nH += c[1]; sumH += c[2]; nC += c[3]; sumC += c[4]; rem += c[5];
|
||||||
|
remNH += c[6]; remSumH += c[7]; remNC += c[8]; remSumC += c[9];
|
||||||
|
}
|
||||||
|
bySpecies[si] = (bySpecies[si] || 0) + (state.remarkable ? c[5] : c[0]);
|
||||||
|
byZone[zi] = (byZone[zi] || 0) + (state.remarkable ? c[5] : c[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { count, nH, sumH, nC, sumC, rem, bySpecies, byZone };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpis(agg) {
|
||||||
|
const avgH = agg.nH ? (agg.sumH / agg.nH) : null;
|
||||||
|
const avgC = agg.nC ? (agg.sumC / agg.nC) : null;
|
||||||
|
const items = [
|
||||||
|
['Arbres', fmt(agg.count)],
|
||||||
|
['Zones', fmt(Object.keys(agg.byZone).length)],
|
||||||
|
['Espèces', fmt(Object.keys(agg.bySpecies).length)],
|
||||||
|
['Hauteur moy.', avgH == null ? 'n/d' : avgH.toFixed(1) + ' m'],
|
||||||
|
['Circonf. moy.', avgC == null ? 'n/d' : avgC.toFixed(0) + ' cm'],
|
||||||
|
['Remarquables', fmt(agg.rem)]
|
||||||
|
];
|
||||||
|
document.getElementById('kpis').innerHTML = items.map(([label, value]) =>
|
||||||
|
`<div class="kpi"><strong>${value}</strong><span>${label}</span></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBars(container, entries, total) {
|
||||||
|
const max = Math.max(1, ...entries.map(e => e[1]));
|
||||||
|
document.getElementById(container).innerHTML = entries.length ? entries.map(([name, count]) =>
|
||||||
|
`<div class="bar-row"><div class="bar-top"><span>${name}</span><span>${fmt(count)}</span></div><div class="bar"><i style="width:${Math.round(count / max * 100)}%"></i></div></div>`
|
||||||
|
).join('') : '<div style="color:var(--muted);font-size:0.8rem;">Aucune donnée pour ces filtres.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSide(agg) {
|
||||||
|
const species = Object.entries(agg.bySpecies).sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||||||
|
.map(([si, c]) => [speciesName(Number(si)), c]);
|
||||||
|
const zones = Object.entries(agg.byZone).sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||||||
|
.map(([zi, c]) => [zoneName(Number(zi)), c]);
|
||||||
|
renderBars('topSpecies', species);
|
||||||
|
renderBars('topZones', zones);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLayers() {
|
||||||
|
zonesLayer.clearLayers();
|
||||||
|
pointsLayer.clearLayers();
|
||||||
|
if (map.hasLayer(zonesLayer)) map.removeLayer(zonesLayer);
|
||||||
|
if (map.hasLayer(pointsLayer)) map.removeLayer(pointsLayer);
|
||||||
|
if (heatLayer && map.hasLayer(heatLayer)) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderZones(agg) {
|
||||||
|
const max = Math.max(1, ...Object.values(agg.byZone));
|
||||||
|
DATA.zones.forEach((z, zi) => {
|
||||||
|
if (state.zone !== -1 && zi !== state.zone) return;
|
||||||
|
const count = agg.byZone[zi] || 0;
|
||||||
|
if (!count || z.lat == null || z.lon == null) return;
|
||||||
|
const ratio = count / max;
|
||||||
|
const radius = 8 + 30 * Math.sqrt(ratio);
|
||||||
|
const color = ratio > 0.75 ? '#8e1f38' : ratio > 0.5 ? '#e94560' : ratio > 0.25 ? '#ff9f43' : '#ffe08a';
|
||||||
|
L.circleMarker([z.lat, z.lon], {
|
||||||
|
radius, color: '#ffffff', weight: 1, fillColor: color, fillOpacity: 0.62
|
||||||
|
}).bindPopup(
|
||||||
|
`<strong>${z.name}</strong><br>${fmt(count)} arbres<br>` +
|
||||||
|
`Top espèces : ${z.top.map(t => t[0] + ' (' + fmt(t[1]) + ')').join(', ')}`
|
||||||
|
).addTo(zonesLayer);
|
||||||
|
});
|
||||||
|
zonesLayer.addTo(map);
|
||||||
|
document.getElementById('legendText').textContent = 'Cercles proportionnels au nombre d’arbres.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredPoints() {
|
||||||
|
return DATA.points.filter(p => {
|
||||||
|
if (state.zone !== -1 && p[2] !== state.zone) return false;
|
||||||
|
if (!selectedSpeciesMatch(p[3])) return false;
|
||||||
|
if (state.remarkable && p[6] !== 1) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeat(agg) {
|
||||||
|
const pts = filteredPoints();
|
||||||
|
// Nouvelle couche à chaque fois : Leaflet.heat ne se réattache pas proprement
|
||||||
|
// après un removeLayer (canvas détaché), donc on ne réutilise pas l'ancienne.
|
||||||
|
heatLayer = L.heatLayer(pts.map(p => [p[0], p[1]]), { radius: 20, blur: 24, maxZoom: 14 }).addTo(map);
|
||||||
|
document.getElementById('legendText').textContent = `${fmt(pts.length)} points échantillonnés (sur ${fmt(agg.count)} arbres filtrés).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPoints(agg) {
|
||||||
|
const pts = filteredPoints();
|
||||||
|
pts.forEach(p => {
|
||||||
|
const color = p[6] ? '#e94560' : '#2f6fb0';
|
||||||
|
L.circleMarker([p[0], p[1]], { radius: 3, color: '#fff', weight: 0.5, fillColor: color, fillOpacity: 0.8 })
|
||||||
|
.bindPopup(`<strong>${speciesName(p[3])}</strong><br>${zoneName(p[2])}<br>` +
|
||||||
|
`${p[4] == null ? 'hauteur n/d' : p[4] + ' m'} · ${p[5] == null ? 'circonf. n/d' : p[5] + ' cm'}` +
|
||||||
|
(p[6] ? '<br><span style="color:#e94560">Remarquable</span>' : ''))
|
||||||
|
.addTo(pointsLayer);
|
||||||
|
});
|
||||||
|
pointsLayer.addTo(map);
|
||||||
|
document.getElementById('legendText').textContent = `${fmt(pts.length)} points affichés (échantillon). Rouge = remarquable.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const agg = aggregate();
|
||||||
|
renderKpis(agg);
|
||||||
|
renderSide(agg);
|
||||||
|
clearLayers();
|
||||||
|
if (state.view === 'zones') renderZones(agg);
|
||||||
|
else if (state.view === 'heat') renderHeat(agg);
|
||||||
|
else renderPoints(agg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelects() {
|
||||||
|
const zone = document.getElementById('zone');
|
||||||
|
zone.innerHTML = '<option value="-1">Toutes les zones</option>' +
|
||||||
|
DATA.zones.map((z, i) => `<option value="${i}">${z.name}</option>`).join('');
|
||||||
|
const species = document.getElementById('species');
|
||||||
|
species.innerHTML = '<option value="-1">Toutes les espèces</option>' +
|
||||||
|
DATA.topSpecies.map((s, i) => `<option value="${i}">${s}</option>`).join('') +
|
||||||
|
'<option value="-2">Autres espèces</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('zone').addEventListener('change', e => { state.zone = Number(e.target.value); render(); });
|
||||||
|
document.getElementById('species').addEventListener('change', e => { state.species = Number(e.target.value); render(); });
|
||||||
|
document.getElementById('view').addEventListener('change', e => { state.view = e.target.value; render(); });
|
||||||
|
document.getElementById('remarkable').addEventListener('change', e => { state.remarkable = e.target.checked; render(); });
|
||||||
|
document.getElementById('reset').addEventListener('click', () => {
|
||||||
|
state.zone = -1; state.species = -1; state.remarkable = false; state.view = 'zones';
|
||||||
|
document.getElementById('zone').value = '-1';
|
||||||
|
document.getElementById('species').value = '-1';
|
||||||
|
document.getElementById('view').value = 'zones';
|
||||||
|
document.getElementById('remarkable').checked = false;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
fillSelects();
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
Loading…
Add table
Reference in a new issue