CoolFace
Apppublic

antoinechevre/Affectation_mobpro

sourceHugging Faceupdated 7d agoView on Hugging Face
0likes
app.py709 linesDownload Raw Back to root
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    '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors '159    '&copy; <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