antoinechevre/Affectation_mobpro
0
1"""Potentiel MOBPRO — affectation des flux domicile-travail sur le réseau routier structurant.2 3Même modèle que github.com/antoinechevre/geofer_analysis (app.py) : carte4unique rendue en HTML statique (st.components.v1.html / st.iframe), fond de5carte OpenStreetMap + CartoDB (Positron/Dark Matter), carreaux INSEE 200 m6pour la densité de population, gares et isochrones Géofer en couches de7contexte. Ce qui change : le réseau routier structurant (autoroutes /8nationales / départementales) est extrait à la volée depuis OpenStreetMap9(osmnx) pour le département choisi, et les flux domicile-travail INSEE y10sont affectés au plus court chemin en temps de parcours (networkx),11affectation "tout-ou-rien" — cf. potentiel_DT_DE.ipynb, dont cette appli12reprend telle quelle la logique des cellules 1 à 4.13"""14 15import io16import json17import math18import os19import shutil20import tempfile21import threading22import zipfile23 24import folium25import geopandas as gpd26import networkx as nx27import osmnx as ox28import pandas as pd29import streamlit as st30from folium.plugins import MarkerCluster31from huggingface_hub import HfApi, hf_hub_download32from shapely.ops import unary_union33from shapely.validation import make_valid34 35GEOFER_DIR = "Data_geofer"36INSEE_DIR = "Data_INSEE"37ADMIN_DIR = "Data_admin"38CACHE_DIR = "cache"39 40# Données hébergées sur le dataset HF qui sert aussi de cache à41# github.com/antoinechevre/geofer_analysis (Corridor Analyse) : utilisées si42# le fichier n'est pas déjà présent en local (mode développement dans ce dépôt).43DATASET_REPO = "antoinechevre/Analyse_gare"44DATASET_PREFIX = "mobpro"45 46CARREAUX_DEPT_DIR = f"{INSEE_DIR}/carreaux_dept"47 48 49@st.cache_resource(show_spinner="Récupération des données (premier chargement)...")50def data_path(nom_fichier: str) -> str:51 """Chemin local du fichier : celui du dépôt (dev) si présent, sinon52 téléchargé une fois depuis DATASET_REPO (déploiement HF Space)."""53 local_fallbacks = {54 "departements.geojson": f"{ADMIN_DIR}/departements.geojson",55 "geofer_gares.csv": f"{GEOFER_DIR}/geofer_gares.csv",56 "iso_10min_voiture.geojson": f"{GEOFER_DIR}/iso_10min_voiture.geojson",57 "iso_10min_velo.geojson": f"{GEOFER_DIR}/iso_10min_velo.geojson",58 "iso_15min_pieton.geojson": f"{GEOFER_DIR}/iso_15min_pieton.geojson",59 "flux_domicile_travail.csv": f"{INSEE_DIR}/flux_domicile_travail.csv",60 }61 local_path = local_fallbacks.get(nom_fichier)62 if local_path and os.path.exists(local_path):63 return local_path64 return hf_hub_download(65 repo_id=DATASET_REPO, repo_type="dataset",66 filename=f"{DATASET_PREFIX}/{nom_fichier}",67 token=os.environ.get("HF_TOKEN"),68 )69 70 71def code_dept_commune(code_commune: str) -> str:72 """Code département déduit d'un code commune INSEE (5 caractères) —73 DOM sur 3 caractères, Corse 2A/2B, sinon les 2 premiers caractères."""74 if code_commune.startswith(("971", "972", "973", "974", "975", "976")):75 return code_commune[:3]76 return code_commune[:2]77 78 79@st.cache_resource(show_spinner="Chargement des carreaux INSEE du département...")80def load_carreaux_departement(dept_code: str) -> gpd.GeoDataFrame:81 """Carreaux INSEE 200 m (lcog_geo/ind/géométrie, WGS84) d'un seul82 département. Extraits en amont, un fichier Parquet par département83 (cf. Notebook_extraction_carreaux / partitionnement de la chaîne de84 traitement), plutôt qu'un fichier national filtré à la volée : chaque85 session ne charge/télécharge que les départements réellement affichés."""86 nom_fichier = f"{dept_code}.parquet"87 chemin_local = os.path.join(CARREAUX_DEPT_DIR, nom_fichier)88 if os.path.exists(chemin_local):89 return gpd.read_parquet(chemin_local)90 try:91 chemin = hf_hub_download(92 repo_id=DATASET_REPO, repo_type="dataset",93 filename=f"{DATASET_PREFIX}/carreaux/{nom_fichier}",94 token=os.environ.get("HF_TOKEN"),95 )96 except Exception:97 return gpd.GeoDataFrame(columns=["lcog_geo", "ind", "geometry"], crs=4326)98 return gpd.read_parquet(chemin)99 100 101def load_carreaux_zone(codes_dept: list) -> gpd.GeoDataFrame:102 morceaux = [load_carreaux_departement(code) for code in codes_dept]103 morceaux = [m for m in morceaux if not m.empty]104 if not morceaux:105 return gpd.GeoDataFrame(columns=["lcog_geo", "ind", "geometry"], crs=4326)106 return pd.concat(morceaux, ignore_index=True)107 108 109ISOCHRONE_FILES = {110 "10 min en voiture": ("iso_10min_voiture.geojson", "#9cd0ed"),111 "10 min à vélo": ("iso_10min_velo.geojson", "#1992d4"),112 "15 min à pied": ("iso_15min_pieton.geojson", "#0a3a55"),113}114 115# Classes OSM du réseau structurant et couleur par catégorie (reprend les116# teintes de la signalétique routière française : bleu autoroute, rouge117# nationale, jaune départementale) — cf. potentiel_DT_DE.ipynb cellule 2.118HIGHWAY_FILTER = (119 '["highway"~"motorway|motorway_link|trunk|trunk_link|'120 'primary|primary_link|secondary|secondary_link"]'121)122 123# overpass-api.de répartit ses requêtes sur plusieurs serveurs (DNS round-robin) : l'un d'eux124# refuse parfois les connexions pendant que les autres répondent normalement — repli sur des125# miroirs alternatifs plutôt que de faire planter la page sur un aléa de résolution DNS126# (cf. precompute_reseau_departements.py, même mécanisme).127OVERPASS_MIRRORS = [128 "https://overpass-api.de/api/interpreter",129 "https://overpass.kumi.systems/api/interpreter",130 "https://overpass.openstreetmap.fr/api/interpreter",131]132 133 134def graphe_avec_repli(geom):135 derniere_erreur = None136 for mirroir in OVERPASS_MIRRORS:137 ox.settings.overpass_url = mirroir138 try:139 return ox.graph_from_polygon(geom, custom_filter=HIGHWAY_FILTER, simplify=True)140 except Exception as exc:141 derniere_erreur = exc142 raise derniere_erreur143ROUTE_COULEURS = {144 "Autoroute": "#08519c",145 "Nationale": "#e31a1c",146 "Départementale": "#fdae61",147}148FLUX_COULEUR = "#6a3d9a"149FLUX_MIN_WEIGHT_PX = 1150FLUX_MAX_WEIGHT_PX = 9151SEUIL_FLUX_DEFAUT = 350152 153# CARTO exige une clé API sur ses fonds raster (sinon un filigrane "API KEY154# REQUIRED" recouvre les tuiles) : chargée depuis le secret CARTO_API_KEY,155# ce fichier étant public (même mécanisme que geofer_analysis).156CARTO_API_KEY = os.environ.get("CARTO_API_KEY")157CARTO_ATTR = (158 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors '159 '© <a href="https://carto.com/attributions">CARTO</a>'160)161 162 163def carto_tile_url(variant: str) -> str:164 url = f"https://{{s}}.basemaps.cartocdn.com/{variant}/{{z}}/{{x}}/{{y}}{{r}}.png"165 return f"{url}?key={CARTO_API_KEY}" if CARTO_API_KEY else url166 167 168# Rouge séquentiel ColorBrewer "Reds" (8 paliers), repris de geofer_analysis169# pour garder la même charte de densité de population entre les deux applis.170CARREAUX_COLOR_SCALE = [171 "#fff5f0", "#fee0d2", "#fcbba1", "#fc9272",172 "#fb6a4a", "#de2d26", "#a50f15", "#67000d",173]174CARREAUX_COLOR_GAMMA = 0.45175 176FRANCE_CENTER = [46.6, 2.5]177FRANCE_ZOOM = 6178FRANCE_BOUNDS = (-5.5, 41.2, 9.8, 51.3) # minx, miny, maxx, maxy179 180GEOFER_PRIMARY = "#1992D4"181CUSTOM_CSS = f"""182<link rel="preconnect" href="https://fonts.googleapis.com">183<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>184<link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap" rel="stylesheet">185<style>186html, body, [class*="css"] {{187 font-family: 'Lato', Helvetica, sans-serif;188}}189[data-testid="stMetricValue"] {{190 color: {GEOFER_PRIMARY};191}}192</style>193"""194 195 196@st.cache_data(show_spinner="Chargement des contours des départements...")197def load_departements() -> gpd.GeoDataFrame:198 gdf = gpd.read_file(data_path("departements.geojson"))199 gdf["label"] = gdf["code"] + " — " + gdf["nom"]200 return gdf.sort_values("code")201 202 203def departements_limitrophes(dept_code: str, departements: gpd.GeoDataFrame) -> gpd.GeoDataFrame:204 dept_geom = departements.loc[departements["code"] == dept_code, "geometry"].iloc[0]205 autres = departements[departements["code"] != dept_code]206 voisins = autres[autres.geometry.touches(dept_geom)]207 if voisins.empty:208 voisins = autres[autres.geometry.intersects(dept_geom.buffer(0.005))]209 return voisins210 211 212@st.cache_data(show_spinner="Chargement des gares Géofer...")213def load_gares() -> pd.DataFrame:214 df = pd.read_csv(data_path("geofer_gares.csv"))215 df = df[df["siOuverte"]].copy()216 df["codeUic"] = df["codeUic"].astype(str)217 df["inseeCommune"] = df["inseeCommune"].astype(str).str.zfill(5)218 return df219 220 221@st.cache_data(show_spinner=False)222def load_isochrones(nom_fichier: str) -> gpd.GeoDataFrame:223 gdf = gpd.read_file(data_path(nom_fichier))224 gdf["code_uic"] = gdf["code_uic"].astype(str)225 return gdf226 227 228@st.cache_data(show_spinner="Chargement des flux domicile-travail INSEE...")229def load_flux_domicile_travail() -> pd.DataFrame:230 return pd.read_csv(231 data_path("flux_domicile_travail.csv"), sep=";", dtype={"CODGEO": str, "DCLT": str},232 )233 234 235def flux_du_departement(flux_dt: pd.DataFrame, dept_code: str, seuil: float) -> pd.DataFrame:236 return (237 flux_dt[238 flux_dt["CODGEO"].str.startswith(dept_code)239 & (flux_dt["CODGEO"] != flux_dt["DCLT"])240 & (flux_dt["NBFLUX_C22_ACTOCC15P"] > seuil)241 ]242 .sort_values("NBFLUX_C22_ACTOCC15P", ascending=False)243 .reset_index(drop=True)244 )245 246 247def classer_route(row) -> str:248 ref = row.get("ref", "")249 if isinstance(ref, list):250 ref = ref[0] if ref else ""251 ref = str(ref)252 253 highway = row.get("highway", "")254 if isinstance(highway, list):255 highway = highway[0]256 257 if ref.startswith("A"):258 return "Autoroute"259 if ref.startswith("N"):260 return "Nationale"261 if ref.startswith("D"):262 return "Départementale"263 if highway in ("motorway", "motorway_link"):264 return "Autoroute"265 if highway in ("trunk", "trunk_link"):266 return "Nationale"267 return "Départementale"268 269 270def publier_cache_reseau(chemin_cache: str, zone_key: str):271 """Best-effort, appelé dans un thread à part (cf. enregistrer_visite de272 geofer_analysis/app.py) : republie le graphe fraîchement calculé sur le273 dataset HF pour que les prochaines sessions — ou un redémarrage du Space,274 dont le disque local est éphémère — n'aient pas à refaire l'appel Overpass."""275 try:276 HfApi().upload_file(277 path_or_fileobj=chemin_cache,278 path_in_repo=f"{DATASET_PREFIX}/reseau/reseau_{zone_key}.graphml",279 repo_id=DATASET_REPO, repo_type="dataset",280 token=os.environ.get("HF_TOKEN"),281 )282 except Exception:283 pass284 285 286@st.cache_resource(show_spinner="Extraction du réseau routier structurant (OpenStreetMap)...")287def reseau_structurant(zone_key: str, _zone_geom):288 """Graphe routier + tronçons classés, mis en cache disque (GraphML).289 Repris du dataset HF s'il a déjà été précalculé — cf. potentiel_DT_DE.ipynb290 et precompute_reseau_departements.py, qui publient un cache par département291 (zone_key sans "+") — sinon interroge l'API Overpass et republie le292 résultat pour les prochaines sessions."""293 os.makedirs(CACHE_DIR, exist_ok=True)294 chemin_cache = os.path.join(CACHE_DIR, f"reseau_{zone_key}.graphml")295 296 if not os.path.exists(chemin_cache) and "+" not in zone_key:297 try:298 chemin_distant = hf_hub_download(299 repo_id=DATASET_REPO, repo_type="dataset",300 filename=f"{DATASET_PREFIX}/reseau/reseau_{zone_key}.graphml",301 token=os.environ.get("HF_TOKEN"),302 )303 shutil.copy(chemin_distant, chemin_cache)304 except Exception:305 pass306 307 if os.path.exists(chemin_cache):308 graphe = ox.load_graphml(chemin_cache)309 else:310 graphe = graphe_avec_repli(_zone_geom)311 graphe = ox.add_edge_speeds(graphe)312 graphe = ox.add_edge_travel_times(graphe)313 ox.save_graphml(graphe, chemin_cache)314 threading.Thread(target=publier_cache_reseau, args=(chemin_cache, zone_key), daemon=True).start()315 316 routes = ox.graph_to_gdfs(graphe, nodes=False, edges=True).reset_index()317 routes["categorie"] = routes.apply(classer_route, axis=1)318 return graphe, routes319 320 321@st.cache_data(show_spinner="Localisation des communes (carreaux INSEE)...")322def centroides_communes(codes: tuple) -> dict:323 """Point représentatif de chaque commune : centroïde des carreaux INSEE324 200 m pondéré par la population — cf. potentiel_DT_DE.ipynb cellule 4.325 Les communes d'un couple domicile-travail peuvent être dans des326 départements différents (bassin d'emploi à cheval sur deux départements) :327 on charge le carreau de chaque département réellement nécessaire, pas328 seulement celui de la zone affichée."""329 depts_necessaires = sorted({code_dept_commune(code) for code in codes})330 carreaux = load_carreaux_zone(depts_necessaires)331 carreaux = carreaux[carreaux["lcog_geo"].isin(codes)]332 if carreaux.empty:333 return {}334 carreaux = carreaux.copy()335 carreaux["x"] = carreaux.geometry.centroid.x336 carreaux["y"] = carreaux.geometry.centroid.y337 338 agg = carreaux.groupby("lcog_geo").apply(339 lambda g: pd.Series({340 "x": (g["x"] * g["ind"]).sum() / g["ind"].sum() if g["ind"].sum() > 0 else g["x"].mean(),341 "y": (g["y"] * g["ind"]).sum() / g["ind"].sum() if g["ind"].sum() > 0 else g["y"].mean(),342 }),343 include_groups=False,344 )345 return {code: (row["x"], row["y"]) for code, row in agg.iterrows()}346 347 348def affecter_flux(graphe, routes: pd.DataFrame, flux_dept: pd.DataFrame):349 """Affectation "tout-ou-rien" : plus court chemin en temps pour chaque350 couple de communes, cumul du flux sur les tronçons traversés."""351 if flux_dept.empty:352 return routes.assign(flux_affecte=0.0), 0353 354 codes = tuple(sorted(pd.unique(flux_dept[["CODGEO", "DCLT"]].values.ravel())))355 noeuds_communes = {}356 for code, (lon, lat) in centroides_communes(codes).items():357 noeuds_communes[code] = ox.distance.nearest_nodes(graphe, lon, lat)358 359 compteur_flux = {}360 couples_sans_chemin = 0361 for _, ligne in flux_dept.iterrows():362 origine = noeuds_communes.get(ligne["CODGEO"])363 destination = noeuds_communes.get(ligne["DCLT"])364 if origine is None or destination is None or origine == destination:365 continue366 try:367 chemin = nx.shortest_path(graphe, origine, destination, weight="travel_time")368 except nx.NetworkXNoPath:369 couples_sans_chemin += 1370 continue371 for u, v in zip(chemin[:-1], chemin[1:]):372 cle = frozenset((u, v))373 compteur_flux[cle] = compteur_flux.get(cle, 0) + ligne["NBFLUX_C22_ACTOCC15P"]374 375 routes = routes.copy()376 cle_troncon = routes.apply(lambda r: frozenset((r["u"], r["v"])), axis=1)377 routes["flux_affecte"] = cle_troncon.map(compteur_flux).fillna(0.0)378 return routes, couples_sans_chemin379 380 381def script_reajuster_si_masque(m, bounds):382 """<script> qui réajuste une carte Leaflet (invalidateSize + fitBounds)383 une fois son conteneur stabilisé en taille — cf. geofer_analysis/app.py."""384 nom_carte = m.get_name()385 minx, miny, maxx, maxy = bounds386 bounds_json = json.dumps([[float(miny), float(minx)], [float(maxy), float(maxx)]])387 return f"""388 <script>389 window.addEventListener("load", function() {{390 var carte = {nom_carte};391 var DEBOUNCE_MS = 250;392 var minuteur = null;393 var observer = new ResizeObserver(function(entries) {{394 for (var entree of entries) {{395 if (entree.contentRect.width > 0 && entree.contentRect.height > 0) {{396 clearTimeout(minuteur);397 minuteur = setTimeout(function() {{398 carte.invalidateSize({{animate: false}});399 carte.fitBounds({bounds_json}, {{animate: false}});400 observer.disconnect();401 }}, DEBOUNCE_MS);402 return;403 }}404 }}405 }});406 observer.observe(carte.getContainer());407 }});408 </script>409 """410 411 412def script_export_png(m):413 nom_carte = m.get_name()414 return f"""415 <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>416 <script>417 window.addEventListener("load", function() {{418 var carte = {nom_carte};419 var bouton = document.createElement("button");420 bouton.innerHTML = "\\u2b07\\ufe0f Export PNG";421 bouton.style.cssText = "position:absolute; bottom:28px; right:10px; z-index:1000; "422 + "background:white; border:2px solid rgba(0,0,0,0.2); border-radius:4px; "423 + "padding:6px 10px; font-family:'Lato',Helvetica,sans-serif; font-size:13px; "424 + "cursor:pointer; box-shadow:0 1px 4px rgba(0,0,0,0.2);";425 bouton.onclick = function() {{426 bouton.disabled = true;427 var texte_origine = bouton.innerHTML;428 bouton.innerHTML = "Export en cours...";429 bouton.style.visibility = "hidden";430 html2canvas(carte.getContainer(), {{useCORS: true, allowTaint: false}}).then(function(canvas) {{431 bouton.disabled = false;432 bouton.innerHTML = texte_origine;433 bouton.style.visibility = "visible";434 var lien = document.createElement("a");435 lien.download = "mobpro_carte.png";436 lien.href = canvas.toDataURL("image/png");437 document.body.appendChild(lien);438 lien.click();439 document.body.removeChild(lien);440 }}).catch(function(err) {{441 bouton.disabled = false;442 bouton.innerHTML = texte_origine;443 bouton.style.visibility = "visible";444 console.error(err);445 alert("Export PNG impossible : " + err);446 }});447 }};448 carte.getContainer().appendChild(bouton);449 }});450 </script>451 """452 453 454def script_legende(afficher_reseau: bool, afficher_flux: bool):455 lignes = ""456 if afficher_reseau:457 for categorie, couleur in ROUTE_COULEURS.items():458 lignes += (459 f'<span style="display:inline-block;width:16px;height:3px;'460 f'background:{couleur};margin-right:4px;vertical-align:middle;"></span>{categorie}<br>'461 )462 if afficher_flux:463 lignes += (464 f'<span style="display:inline-block;width:16px;height:3px;'465 f'background:{FLUX_COULEUR};margin-right:4px;vertical-align:middle;"></span>'466 f"Flux domicile-travail affecté (épaisseur ∝ flux)<br>"467 )468 if not lignes:469 return ""470 return f"""471 <div style="position:fixed; bottom:10px; left:50%; transform:translateX(-50%); z-index:1000; background:white;472 border:2px solid rgba(0,0,0,0.2); border-radius:4px; padding:6px 10px;473 font-family:'Lato',Helvetica,sans-serif; font-size:12px; line-height:1.6; max-width:340px;">474 {lignes}475 </div>476 """477 478 479def shapefile_zip_bytes(gdf: gpd.GeoDataFrame, nom_base: str) -> bytes:480 """Zip contenant un shapefile (.shp/.shx/.dbf/.prj/.cpg) du réseau481 structurant affecté — les colonnes non scalaires (liste OSM, clé de482 tronçon interne) ne sont pas des attributs valides pour ce format."""483 export = gdf.drop(columns=["cle_troncon"], errors="ignore").copy()484 for colonne in export.columns:485 if colonne == "geometry":486 continue487 if export[colonne].apply(lambda v: isinstance(v, list)).any():488 export[colonne] = export[colonne].apply(lambda v: ";".join(map(str, v)) if isinstance(v, list) else v)489 490 with tempfile.TemporaryDirectory() as dossier_tmp:491 chemin_shp = os.path.join(dossier_tmp, f"{nom_base}.shp")492 export.to_file(chemin_shp, driver="ESRI Shapefile")493 tampon = io.BytesIO()494 with zipfile.ZipFile(tampon, "w", zipfile.ZIP_DEFLATED) as archive:495 for fichier in os.listdir(dossier_tmp):496 archive.write(os.path.join(dossier_tmp, fichier), arcname=fichier)497 return tampon.getvalue()498 499 500def build_map(501 center, zoom, bounds, gares_in_zone, isochrones_in_zone, selected_modes,502 routes, afficher_reseau, afficher_flux, carreaux, afficher_pop,503):504 m = folium.Map(location=center, zoom_start=zoom, tiles=None, prefer_canvas=True, control_scale=True)505 folium.TileLayer("OpenStreetMap", name="OpenStreetMap", cross_origin=True).add_to(m)506 folium.TileLayer(507 carto_tile_url("light_all"), name="CartoDB Positron", attr=CARTO_ATTR, cross_origin=True,508 ).add_to(m)509 folium.TileLayer(510 carto_tile_url("dark_all"), name="CartoDB Dark Matter", attr=CARTO_ATTR, cross_origin=True,511 ).add_to(m)512 513 if afficher_pop and carreaux is not None and not carreaux.empty:514 vmin = float(carreaux["ind"].min())515 vmax = float(carreaux["ind"].max())516 colormap = folium.LinearColormap(517 colors=CARREAUX_COLOR_SCALE, vmin=vmin, vmax=vmax, caption="Population — carreaux INSEE 200 m",518 )519 520 def style_carreau(feature, cm=colormap, lo=vmin, hi=vmax):521 value = feature["properties"]["ind"]522 if hi > lo:523 frac = ((value - lo) / (hi - lo)) ** CARREAUX_COLOR_GAMMA524 value = lo + frac * (hi - lo)525 return {"fillColor": cm(value), "color": "#581012", "weight": 0, "fillOpacity": 1.0}526 527 folium.GeoJson(528 carreaux,529 name="Population (carreaux INSEE 200 m)",530 style_function=style_carreau,531 tooltip=folium.GeoJsonTooltip(fields=["ind"], aliases=["Population"], localize=True),532 ).add_to(m)533 colormap.add_to(m)534 535 for mode in selected_modes:536 gdf = isochrones_in_zone.get(mode)537 if gdf is None or gdf.empty:538 continue539 _, couleur = ISOCHRONE_FILES[mode]540 folium.GeoJson(541 gdf, name=mode,542 style_function=lambda feature, c=couleur: {543 "color": c, "weight": 2, "fill": True, "fillColor": c, "fillOpacity": 0.3,544 },545 ).add_to(m)546 547 if afficher_reseau and routes is not None and not routes.empty:548 for categorie, couleur in ROUTE_COULEURS.items():549 sous_ensemble = routes[routes["categorie"] == categorie]550 if sous_ensemble.empty:551 continue552 folium.GeoJson(553 sous_ensemble,554 name=f"Réseau structurant — {categorie}",555 style_function=lambda feature, c=couleur: {"color": c, "weight": 2, "opacity": 0.8},556 tooltip=folium.GeoJsonTooltip(fields=["ref", "name"], aliases=["Référence", "Nom"]),557 ).add_to(m)558 559 if afficher_flux and routes is not None and "flux_affecte" in routes and (routes["flux_affecte"] > 0).any():560 charges = routes[routes["flux_affecte"] > 0]561 flux_max = charges["flux_affecte"].max()562 flux_layer = folium.FeatureGroup(name="Flux domicile-travail affecté")563 for _, troncon in charges.iterrows():564 poids = FLUX_MIN_WEIGHT_PX + (FLUX_MAX_WEIGHT_PX - FLUX_MIN_WEIGHT_PX) * math.sqrt(565 troncon["flux_affecte"] / flux_max566 )567 folium.GeoJson(568 troncon.geometry,569 style_function=lambda feature, w=poids: {"color": FLUX_COULEUR, "weight": w, "opacity": 0.75},570 tooltip=f"{troncon.get('name') or troncon.get('ref') or 'Tronçon'} — "571 f"{troncon['flux_affecte']:.0f} actifs/jour",572 ).add_to(flux_layer)573 flux_layer.add_to(m)574 575 if not gares_in_zone.empty:576 cluster = MarkerCluster(name="Gares").add_to(m)577 for _, gare in gares_in_zone.iterrows():578 folium.Marker(579 [gare["wgs84Lat"], gare["wgs84Lon"]],580 tooltip=f"{gare['nomGare']} — {gare['nomCommune']}",581 icon=folium.Icon(color="darkred", icon="train", prefix="fa"),582 ).add_to(cluster)583 584 folium.LayerControl(collapsed=False).add_to(m)585 legende = script_legende(afficher_reseau, afficher_flux)586 if legende:587 m.get_root().html.add_child(folium.Element(legende))588 m.get_root().html.add_child(folium.Element(script_reajuster_si_masque(m, bounds)))589 m.get_root().html.add_child(folium.Element(script_export_png(m)))590 return m591 592 593def main():594 st.set_page_config(page_title="Potentiel MOBPRO", page_icon="🚗", layout="wide")595 st.markdown(CUSTOM_CSS, unsafe_allow_html=True)596 st.title("🚗 Potentiel MOBPRO — flux domicile-travail sur le réseau structurant")597 st.caption(598 "Choisissez un département : le réseau routier structurant (autoroutes, nationales, "599 "départementales) est extrait depuis OpenStreetMap, puis les flux domicile-travail INSEE "600 "dépassant le seuil choisi sont affectés au plus court chemin en temps de parcours."601 )602 st.caption(603 "Même principe que "604 "[geofer_analysis](https://github.com/antoinechevre/geofer_analysis) — "605 "code source de cette appli dans `potentiel_DT_DE.ipynb`."606 )607 608 departements = load_departements()609 gares = load_gares()610 flux_dt = load_flux_domicile_travail()611 612 with st.sidebar:613 st.header("Zone à charger")614 dept_label = st.selectbox(615 "Département", departements["label"], index=None, placeholder="Choisir un département",616 )617 include_voisins = st.checkbox("Inclure les départements limitrophes", value=False)618 619 st.header("Flux domicile-travail")620 seuil_flux = st.slider(621 "Seuil de représentation (flux minimal entre 2 communes)",622 min_value=50, max_value=2000, value=SEUIL_FLUX_DEFAUT, step=50,623 )624 afficher_reseau = st.checkbox("Réseau routier structurant", value=False)625 afficher_flux = st.checkbox("Flux affecté sur le réseau", value=True)626 627 st.header("Carreaux INSEE")628 afficher_pop = st.checkbox("Densité de population (200 m)", value=False)629 630 st.header("Isochrones Géofer")631 selected_modes = [mode for mode in ISOCHRONE_FILES if st.checkbox(mode, value=False)]632 633 if dept_label is None:634 st.info("Choisissez un département dans le menu de gauche pour lancer l'analyse.")635 m = folium.Map(location=FRANCE_CENTER, zoom_start=FRANCE_ZOOM, tiles="OpenStreetMap")636 st.components.v1.html(m.get_root().render(), height=650)637 return638 639 dept = departements[departements["label"] == dept_label].iloc[0]640 if include_voisins:641 voisins = departements_limitrophes(dept["code"], departements)642 else:643 voisins = departements.iloc[0:0]644 645 zone_geoms = [make_valid(dept.geometry)] + [make_valid(g) for g in voisins.geometry]646 zone_geom = unary_union(zone_geoms)647 zone_key = "+".join(sorted([dept["code"]] + voisins["code"].tolist()))648 bounds = zone_geom.bounds649 center = [(bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2]650 zoom = 9 if voisins.empty else 8651 652 if not voisins.empty:653 st.caption("Départements limitrophes inclus : " + ", ".join(sorted(voisins["nom"])))654 655 graphe, routes = reseau_structurant(zone_key, zone_geom)656 flux_dept = flux_du_departement(flux_dt, dept["code"], seuil_flux)657 routes, couples_sans_chemin = affecter_flux(graphe, routes, flux_dept)658 659 gares_in_zone = gares[660 gares["wgs84Lon"].between(bounds[0], bounds[2]) & gares["wgs84Lat"].between(bounds[1], bounds[3])661 ]662 codes_in_zone = set(gares_in_zone["codeUic"])663 isochrones_in_zone = {}664 for mode, (nom_fichier, _) in ISOCHRONE_FILES.items():665 if mode in selected_modes:666 full = load_isochrones(nom_fichier)667 isochrones_in_zone[mode] = full[full["code_uic"].isin(codes_in_zone)]668 669 codes_zone = [dept["code"]] + voisins["code"].tolist()670 carreaux = load_carreaux_zone(codes_zone) if afficher_pop else None671 672 col_map, col_stats = st.columns([3, 1])673 674 with col_map:675 m = build_map(676 center, zoom, bounds, gares_in_zone, isochrones_in_zone, selected_modes,677 routes, afficher_reseau, afficher_flux, carreaux, afficher_pop,678 )679 st.components.v1.html(m.get_root().render(), height=650)680 681 with col_stats:682 st.subheader(dept_label)683 st.metric("Couples de communes retenus", f"{len(flux_dept):,}".replace(",", " "))684 st.metric(685 "Flux domicile-travail cumulé", f"{int(flux_dept['NBFLUX_C22_ACTOCC15P'].sum()):,}".replace(",", " "),686 )687 if not routes.empty and "flux_affecte" in routes:688 charge_max = routes["flux_affecte"].max()689 st.metric("Tronçon le plus chargé", f"{charge_max:,.0f} actifs/jour".replace(",", " "))690 if couples_sans_chemin:691 st.caption(f"{couples_sans_chemin} couple(s) sans chemin trouvé sur le réseau structurant.")692 st.download_button(693 "Télécharger les flux domicile-travail (.csv)",694 data=flux_dept.to_csv(index=False).encode("utf-8"),695 file_name=f"flux_domicile_travail_{dept['code']}.csv",696 mime="text/csv",697 )698 if not routes.empty:699 st.download_button(700 "Télécharger le réseau structurant affecté (.shp, zippé)",701 data=shapefile_zip_bytes(routes, f"reseau_structurant_{dept['code']}"),702 file_name=f"reseau_structurant_{dept['code']}.zip",703 mime="application/zip",704 )705 706 707if __name__ == "__main__":708 main()709 