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:
Grégoire Mureau 2026-10-30 11:13:58 +01:00
parent fb22464aa3
commit be6f7d1e06
6 changed files with 1062 additions and 0 deletions

View file

@ -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**

View file

@ -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
View 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
View 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).

File diff suppressed because one or more lines are too long

View 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: '&copy; 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])