diff --git a/DESCRIPTION b/DESCRIPTION
deleted file mode 100644
index 259b5d64..00000000
--- a/DESCRIPTION
+++ /dev/null
@@ -1,30 +0,0 @@
-Package: MyProjectDependencies
-Version: 1.0
-Title: Dependencies for GitHub Actions
-Description: Manages R package dependencies for GitHub Actions.
-License: GPL-3
-Imports:
- arrow,
- cluster,
- cppRouting,
- data.table,
- dbscan,
- dodgr,
- dplyr,
- duckdb,
- FNN,
- future,
- future.apply,
- geos,
- ggplot2,
- gtfsrouter,
- hms,
- jsonlite,
- log4r,
- lubridate,
- nngeo,
- osmdata,
- remotes,
- sf,
- sfheaders,
- readxl
\ No newline at end of file
diff --git a/diff.patch b/diff.patch
deleted file mode 100644
index 01424737..00000000
Binary files a/diff.patch and /dev/null differ
diff --git a/front/README.md b/front/README.md
new file mode 100644
index 00000000..d770f51d
--- /dev/null
+++ b/front/README.md
@@ -0,0 +1,23 @@
+# Front — Interface Mobility
+
+Ce dossier contient l’interface Dash/Mantine de l’application Mobility.
+
+## Lancer l’interface
+
+Depuis la racine du projet, exécuter :
+
+cd front
+python -m app.pages.main.main
+
+
+L’application démarre alors en mode développement sur http://127.0.0.1:8050/.
+
+## Structure simplifiée
+
+app/components/ — Composants UI (carte, panneaux, contrôles, etc.)
+
+app/services/ — Services pour la génération des scénarios et l’accès aux données
+
+app/pages/main/ — Page principale et point d’entrée (main.py)
+
+app/callbacks.py — Callbacks Dash
\ No newline at end of file
diff --git a/front/app/scenario/__init__.py b/front/__init__.py
similarity index 100%
rename from front/app/scenario/__init__.py
rename to front/__init__.py
diff --git a/front/app/components/features/map/__init__.py b/front/app/components/features/map/__init__.py
index e69de29b..b0e3f0e5 100644
--- a/front/app/components/features/map/__init__.py
+++ b/front/app/components/features/map/__init__.py
@@ -0,0 +1,4 @@
+# Réexporte Map depuis la nouvelle implémentation.
+from .map_component import Map
+
+__all__ = ["Map"]
diff --git a/front/app/components/features/map/color_scale.py b/front/app/components/features/map/color_scale.py
new file mode 100644
index 00000000..c198d8fb
--- /dev/null
+++ b/front/app/components/features/map/color_scale.py
@@ -0,0 +1,152 @@
+"""
+color_scale.py
+===============
+
+Échelle de couleurs pour la carte des temps moyens de déplacement.
+
+Ce module fournit :
+- une palette **bleu → gris → orange** cohérente avec la légende qualitative
+ (*Accès rapide* / *Accès moyen* / *Accès lent*) ;
+- une dataclass `ColorScale` permettant de convertir une valeur numérique
+ en couleur RGBA (0–255) et d’obtenir un libellé de légende lisible ;
+- une fonction d’ajustement `fit_color_scale()` qui calibre automatiquement
+ `vmin` / `vmax` à partir d’une série de données (percentiles).
+
+Fonctionnalités principales
+---------------------------
+- `_interp_color(c1, c2, t)` : interpolation linéaire entre deux couleurs RGB.
+- `_build_legend_palette(n)` : construit la palette bleu→gris→orange.
+- `ColorScale.rgba(v)` : mappe une valeur à un tuple `[R, G, B, A]`.
+- `ColorScale.legend(v)` : rend un libellé humain (ex. `"12.3 min"`).
+- `fit_color_scale(series)` : ajuste l’échelle à une série pandas (P5–P95).
+"""
+
+from dataclasses import dataclass
+import numpy as np
+import pandas as pd
+
+
+def _interp_color(c1, c2, t):
+ """Interpole linéairement entre deux couleurs RGB.
+
+ Args:
+ c1 (Tuple[int, int, int]): Couleur de départ (R, G, B).
+ c2 (Tuple[int, int, int]): Couleur d’arrivée (R, G, B).
+ t (float): Paramètre d’interpolation dans [0, 1].
+
+ Returns:
+ Tuple[int, int, int]: Couleur RGB interpolée.
+ """
+ return (
+ int(c1[0] + (c2[0] - c1[0]) * t),
+ int(c1[1] + (c2[1] - c1[1]) * t),
+ int(c1[2] + (c2[2] - c1[2]) * t),
+ )
+
+
+def _build_legend_palette(n=256):
+ """Construit une palette bleu → gris → orange pour la légende.
+
+ Conçue pour coller à la sémantique :
+ - bleu : accès rapide (valeurs basses)
+ - gris : accès moyen (valeurs médianes)
+ - orange: accès lent (valeurs hautes)
+
+ La palette est générée par interpolation linéaire entre
+ (bleu→gris) puis (gris→orange).
+
+ Args:
+ n (int, optional): Nombre total de couleurs dans la palette.
+ Par défaut `256`.
+
+ Returns:
+ List[Tuple[int, int, int]]: Liste de couleurs RGB.
+ """
+ blue = ( 74, 160, 205) # accès rapide
+ grey = (147, 147, 147) # accès moyen
+ orange = (228, 86, 43) # accès lent
+
+ mid = n // 2
+ first = [_interp_color(blue, grey, i / max(1, mid - 1)) for i in range(mid)]
+ second = [_interp_color(grey, orange, i / max(1, n - mid - 1)) for i in range(n - mid)]
+ return first + second
+
+
+@dataclass
+class ColorScale:
+ """Échelle de couleurs continue basée sur des bornes min/max.
+
+ Attributs:
+ vmin (float): Valeur minimale du domaine.
+ vmax (float): Valeur maximale du domaine.
+ colors (List[Tuple[int, int, int]]): Palette RGB ordonnée bas→haut.
+ alpha (int): Canal alpha (0–255). Par défaut `102` (~0.4 d’opacité).
+
+ Méthodes:
+ rgba(v): Convertit une valeur en `[R, G, B, A]` (uint8).
+ legend(v): Produit un libellé humain (ex. `"12.1 min"`).
+ """
+ vmin: float
+ vmax: float
+ colors: list[tuple[int, int, int]]
+ alpha: int = 102 # ~0.4 d’opacité
+
+ def rgba(self, v) -> list[int]:
+ """Mappe une valeur numérique à une couleur RGBA.
+
+ Si `v` est manquante ou si `vmax <= vmin`, retourne une valeur par défaut.
+
+ Args:
+ v (float | Any): Valeur à convertir.
+
+ Returns:
+ List[int]: Couleur `[R, G, B, A]` (chaque canal 0–255).
+ """
+ if v is None or pd.isna(v):
+ return [200, 200, 200, 40]
+ if self.vmax <= self.vmin:
+ idx = 0
+ else:
+ t = (float(v) - self.vmin) / (self.vmax - self.vmin)
+ t = max(0.0, min(1.0, t))
+ idx = int(t * (len(self.colors) - 1))
+ r, g, b = self.colors[idx]
+ return [int(r), int(g), int(b), self.alpha]
+
+ def legend(self, v) -> str:
+ """Retourne un libellé de légende lisible pour la valeur.
+
+ Args:
+ v (float | Any): Valeur à afficher.
+
+ Returns:
+ str: Libellé, ex. `"12.3 min"`, ou `"N/A"` si manquant.
+ """
+ if v is None or pd.isna(v):
+ return "N/A"
+ return f"{float(v):.1f} min"
+
+
+def fit_color_scale(series: pd.Series) -> ColorScale:
+ """Ajuste automatiquement une échelle de couleurs à partir d’une série.
+
+ Utilise les percentiles **P5** et **P95** pour définir `vmin` et `vmax`,
+ afin de diminuer l’influence des valeurs extrêmes.
+ Si la série est dégénérée (vmin == vmax), retombe sur `(min, max or 1.0)`.
+ Si la série est vide/invalide, retombe sur le domaine `(0.0, 1.0)`.
+
+ Args:
+ series (pd.Series): Série de valeurs numériques.
+
+ Returns:
+ ColorScale: Échelle prête à l’emploi (palette 256 couleurs, alpha 102).
+ """
+ s = pd.to_numeric(series, errors="coerce").dropna()
+ if len(s):
+ vmin = float(np.nanpercentile(s, 5))
+ vmax = float(np.nanpercentile(s, 95))
+ if vmin == vmax:
+ vmin, vmax = float(s.min()), float(s.max() or 1.0)
+ else:
+ vmin, vmax = 0.0, 1.0
+ return ColorScale(vmin=vmin, vmax=vmax, colors=_build_legend_palette(256), alpha=102)
diff --git a/front/app/components/features/map/components.py b/front/app/components/features/map/components.py
new file mode 100644
index 00000000..08474f55
--- /dev/null
+++ b/front/app/components/features/map/components.py
@@ -0,0 +1,122 @@
+"""
+layout.py
+=========
+
+Composants de haut niveau pour la page cartographique :
+- `DeckMap` : rendu principal Deck.gl (fond de carte + couches)
+- `SummaryPanelWrapper` : panneau latéral droit affichant le résumé d’étude
+- `ControlsSidebarWrapper` : barre latérale gauche des contrôles de scénario
+
+Ce module assemble des éléments d’UI (Dash + Mantine) et des composants
+applicatifs (`StudyAreaSummary`, `ScenarioControlsPanel`) afin de proposer
+une mise en page complète : carte plein écran, résumé latéral et sidebar.
+"""
+
+import dash_deck
+from dash import html
+import dash_mantine_components as dmc
+
+from .config import HEADER_OFFSET_PX, SIDEBAR_WIDTH
+from app.components.features.study_area_summary import StudyAreaSummary
+from app.components.features.scenario_controls import ScenarioControlsPanel
+
+from .tooltip import default_tooltip
+
+
+def DeckMap(id_prefix: str, deck_json: str) -> dash_deck.DeckGL:
+ """Crée le composant Deck.gl plein écran.
+
+ Args:
+ id_prefix (str): Préfixe utilisé pour l’identifiant Dash.
+ deck_json (str): Spécification Deck.gl sérialisée (JSON) incluant
+ carte de fond, couches, vues, etc.
+
+ Returns:
+ dash_deck.DeckGL: Composant Deck.gl prêt à l’affichage (pickable, tooltips).
+ """
+ return dash_deck.DeckGL(
+ id=f"{id_prefix}-deck-map",
+ data=deck_json,
+ tooltip=default_tooltip(),
+ mapboxKey="",
+ style={
+ "position": "absolute",
+ "inset": 0,
+ "height": "100vh",
+ "width": "100%",
+ },
+ )
+
+
+def SummaryPanelWrapper(zones_gdf, id_prefix: str):
+ """Enveloppe le panneau de résumé global à droite de la carte.
+
+ Args:
+ zones_gdf: GeoDataFrame (ou équivalent) contenant les colonnes utilisées
+ par `StudyAreaSummary` (temps moyen, parts modales, etc.).
+ id_prefix (str): Préfixe d’identifiant pour les composants liés à la carte.
+
+ Returns:
+ dash.html.Div: Conteneur du panneau de résumé (`StudyAreaSummary`).
+ """
+ return html.Div(
+ id=f"{id_prefix}-summary-wrapper",
+ children=StudyAreaSummary(zones_gdf, visible=True, id_prefix=id_prefix),
+ )
+
+
+def ControlsSidebarWrapper(id_prefix: str):
+ """Construit la barre latérale gauche contenant les contrôles du scénario.
+
+ La sidebar est positionnée sous l’en-tête principal (offset vertical défini
+ par `HEADER_OFFSET_PX`) et utilise une largeur fixe `SIDEBAR_WIDTH`. Elle
+ embarque le panneau `ScenarioControlsPanel` (rayon, zone INSEE, modes, bouton).
+
+ Args:
+ id_prefix (str): Préfixe d’identifiant pour éviter les collisions Dash.
+
+ Returns:
+ dash.html.Div: Conteneur sidebar avec un `dmc.Paper` et le panneau de contrôles.
+ """
+ return html.Div(
+ dmc.Paper(
+ children=[
+ dmc.Stack(
+ [
+ ScenarioControlsPanel(
+ id_prefix=id_prefix,
+ min_radius=15,
+ max_radius=50,
+ step=1,
+ default=40,
+ default_insee="31555",
+ )
+ ],
+ gap="md",
+ )
+ ],
+ withBorder=True,
+ shadow="md",
+ radius="md",
+ p="md",
+ style={
+ "width": "100%",
+ "height": "100%",
+ "overflowY": "auto",
+ "overflowX": "hidden",
+ "background": "#ffffffee",
+ "boxSizing": "border-box",
+ },
+ ),
+ id=f"{id_prefix}-controls-sidebar",
+ style={
+ "position": "absolute",
+ "top": f"{HEADER_OFFSET_PX}px",
+ "left": "0px",
+ "bottom": "0px",
+ "width": f"{SIDEBAR_WIDTH}px",
+ "zIndex": 1200,
+ "pointerEvents": "auto",
+ "overflow": "hidden",
+ },
+ )
diff --git a/front/app/components/features/map/config.py b/front/app/components/features/map/config.py
new file mode 100644
index 00000000..94986824
--- /dev/null
+++ b/front/app/components/features/map/config.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+
+# ---------- CONSTANTES ----------
+CARTO_POSITRON_GL = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
+FALLBACK_CENTER = (1.4442, 43.6045) # Toulouse
+
+HEADER_OFFSET_PX = 80
+SIDEBAR_WIDTH = 340
+
+# ---------- OPTIONS ----------
+@dataclass(frozen=True)
+class DeckOptions:
+ zoom: float = 10
+ pitch: float = 35
+ bearing: float = -15
+ map_style: str = CARTO_POSITRON_GL
diff --git a/front/app/components/features/map/deck_factory.py b/front/app/components/features/map/deck_factory.py
new file mode 100644
index 00000000..19e45cca
--- /dev/null
+++ b/front/app/components/features/map/deck_factory.py
@@ -0,0 +1,99 @@
+"""
+deck_factory.py
+================
+
+Fabrique d’objets Deck.gl (pydeck) pour l’affichage cartographique.
+
+Ce module assemble :
+- l’échelle de couleurs dérivée des temps moyens (`fit_color_scale`) ;
+- la couche de zones (`build_zones_layer`) ;
+- la vue et l’état initial (centre, zoom, pitch, bearing) ;
+- la sérialisation JSON pour intégration dans l’UI Dash.
+
+Fonctions principales
+---------------------
+- `make_layers(zones_gdf)`: construit la liste de couches pydeck.
+- `make_deck(scn, opts)`: assemble un objet `pdk.Deck` prêt à l’affichage.
+- `make_deck_json(scn, opts)`: renvoie la spécification Deck.gl au format JSON.
+"""
+
+import pydeck as pdk
+import pandas as pd
+import geopandas as gpd
+
+from .config import FALLBACK_CENTER, DeckOptions
+from .geo_utils import safe_center
+from .color_scale import fit_color_scale
+from .layers import build_zones_layer
+
+
+def make_layers(zones_gdf: gpd.GeoDataFrame):
+ """Construit les couches pydeck à partir des zones.
+
+ Utilise `fit_color_scale` pour calibrer la palette sur la colonne
+ `average_travel_time`, puis crée la couche polygonale via `build_zones_layer`.
+
+ Args:
+ zones_gdf (gpd.GeoDataFrame): GeoDataFrame des zones avec au moins
+ la géométrie et, si possible, `average_travel_time`.
+
+ Returns:
+ List[pdk.Layer]: Liste des couches construites (vide si aucune géométrie valide).
+ """
+ # Palette "classique" (ton ancienne), la fonction fit_color_scale existante suffit
+ scale = fit_color_scale(zones_gdf.get("average_travel_time", pd.Series(dtype="float64")))
+ layers = []
+ zl = build_zones_layer(zones_gdf, scale)
+ if zl is not None:
+ layers.append(zl)
+ return layers
+
+
+def make_deck(scn: dict, opts: DeckOptions) -> pdk.Deck:
+ """Assemble un objet Deck.gl complet (couches + vue initiale).
+
+ Détermine le centre de la vue à partir des géométries des zones (via
+ `safe_center`) et retombe sur `FALLBACK_CENTER` si indisponible.
+
+ Args:
+ scn (dict): Scénario contenant `zones_gdf` (GeoDataFrame des zones).
+ opts (DeckOptions): Options de vue et de style (zoom, pitch, bearing, map_style).
+
+ Returns:
+ pdk.Deck: Instance pydeck prête à être rendue.
+ """
+ zones_gdf: gpd.GeoDataFrame = scn["zones_gdf"].copy()
+
+ layers = make_layers(zones_gdf)
+ lon, lat = safe_center(zones_gdf) or FALLBACK_CENTER
+
+ view_state = pdk.ViewState(
+ longitude=lon,
+ latitude=lat,
+ zoom=opts.zoom,
+ pitch=opts.pitch,
+ bearing=opts.bearing,
+ )
+
+ return pdk.Deck(
+ layers=layers,
+ initial_view_state=view_state,
+ map_provider="carto",
+ map_style=opts.map_style,
+ views=[pdk.View(type="MapView", controller=True)],
+ )
+
+
+def make_deck_json(scn: dict, opts: DeckOptions) -> str:
+ """Sérialise la configuration Deck.gl en JSON.
+
+ Pratique pour passer la spec au composant Dash `dash_deck.DeckGL`.
+
+ Args:
+ scn (dict): Scénario contenant `zones_gdf`.
+ opts (DeckOptions): Options de vue/style utilisées par `make_deck`.
+
+ Returns:
+ str: Chaîne JSON représentant l’objet Deck.gl.
+ """
+ return make_deck(scn, opts).to_json()
diff --git a/front/app/components/features/map/geo_utils.py b/front/app/components/features/map/geo_utils.py
new file mode 100644
index 00000000..5be1055d
--- /dev/null
+++ b/front/app/components/features/map/geo_utils.py
@@ -0,0 +1,62 @@
+import logging
+from typing import Optional, Tuple
+
+import geopandas as gpd
+import numpy as np
+import pandas as pd
+from shapely.geometry import Polygon, MultiPolygon
+
+logger = logging.getLogger(__name__)
+
+def ensure_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
+ """Assure EPSG:4326 pour la sortie."""
+ g = gdf.copy()
+ if g.crs is None:
+ g = g.set_crs(4326, allow_override=True)
+ elif getattr(g.crs, "to_epsg", lambda: None)() != 4326:
+ g = g.to_crs(4326)
+ return g
+
+def centroids_lonlat(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
+ """Ajoute colonnes lon/lat calculées en mètres (EPSG:3857) puis reprojetées en 4326."""
+ g = gdf.copy()
+ if g.crs is None:
+ g = g.set_crs(4326, allow_override=True)
+ g_m = g.to_crs(3857)
+ pts_m = g_m.geometry.centroid
+ pts_ll = gpd.GeoSeries(pts_m, crs=g_m.crs).to_crs(4326)
+ g["lon"] = pts_ll.x.astype("float64")
+ g["lat"] = pts_ll.y.astype("float64")
+ return g
+
+def safe_center(gdf: gpd.GeoDataFrame) -> Optional[Tuple[float, float]]:
+ """Calcule un centroïde global robuste en WGS84, sinon None."""
+ try:
+ zvalid = gdf[gdf.geometry.notnull() & gdf.geometry.is_valid]
+ if zvalid.empty:
+ return None
+ centroid = ensure_wgs84(zvalid).geometry.unary_union.centroid
+ return float(centroid.x), float(centroid.y)
+ except Exception as e:
+ logger.warning("safe_center failed: %s", e)
+ return None
+
+def fmt_num(v, nd=1):
+ try:
+ return f"{round(float(v), nd):.{nd}f}"
+ except Exception:
+ return "N/A"
+
+def fmt_pct(v, nd=1):
+ try:
+ return f"{round(float(v) * 100.0, nd):.{nd}f} %"
+ except Exception:
+ return "N/A"
+
+def as_polygon_rings(geom):
+ """Retourne les anneaux extérieurs d’un Polygon/MultiPolygon sous forme de liste de coordonnées."""
+ if isinstance(geom, Polygon):
+ return [list(geom.exterior.coords)]
+ if isinstance(geom, MultiPolygon):
+ return [list(p.exterior.coords) for p in geom.geoms]
+ return []
diff --git a/front/app/components/features/map/layers.py b/front/app/components/features/map/layers.py
new file mode 100644
index 00000000..b28bc93d
--- /dev/null
+++ b/front/app/components/features/map/layers.py
@@ -0,0 +1,72 @@
+from typing import List, Dict
+import pandas as pd
+import numpy as np
+import pydeck as pdk
+import geopandas as gpd
+
+from .geo_utils import ensure_wgs84, as_polygon_rings, fmt_num, fmt_pct
+
+# ColorScale est supposé fourni ailleurs (fit_color_scale), injecté via deck_factory
+# Ici, on ne change pas la palette : on utilise le champ "average_travel_time".
+
+
+def _polygons_records(zones_gdf: gpd.GeoDataFrame, scale) -> List[Dict]:
+ g = ensure_wgs84(zones_gdf)
+ out = []
+ for _, row in g.iterrows():
+ rings = as_polygon_rings(row.geometry)
+ if not rings:
+ continue
+
+ zone_id = row.get("transport_zone_id", "Zone inconnue")
+ insee = row.get("local_admin_unit_id", "N/A")
+
+ avg_tt = pd.to_numeric(row.get("average_travel_time", np.nan), errors="coerce")
+ total_dist_km = pd.to_numeric(row.get("total_dist_km", np.nan), errors="coerce")
+ total_time_min = pd.to_numeric(row.get("total_time_min", np.nan), errors="coerce")
+
+ share_car = pd.to_numeric(row.get("share_car", np.nan), errors="coerce")
+ share_bicycle = pd.to_numeric(row.get("share_bicycle", np.nan), errors="coerce")
+ share_walk = pd.to_numeric(row.get("share_walk", np.nan), errors="coerce")
+ share_carpool = pd.to_numeric(row.get("share_carpool", np.nan), errors="coerce")
+ share_pt = pd.to_numeric(row.get("share_public_transport", np.nan), errors="coerce")
+
+ for ring in rings:
+ out.append(
+ {
+ "geometry": [[float(x), float(y)] for x, y in ring],
+ "fill_rgba": scale.rgba(avg_tt),
+ "Unité INSEE": str(insee),
+ "Identifiant de zone": str(zone_id),
+ "Temps moyen de trajet (minutes)": fmt_num(avg_tt, 1),
+ "Niveau d’accessibilité": scale.legend(avg_tt),
+ "Distance totale parcourue (km/jour)": fmt_num(total_dist_km, 1),
+ "Temps total de déplacement (min/jour)": fmt_num(total_time_min, 1),
+ "Part des trajets en voiture (%)": fmt_pct(share_car, 1),
+ "Part des trajets à vélo (%)": fmt_pct(share_bicycle, 1),
+ "Part des trajets à pied (%)": fmt_pct(share_walk, 1),
+ "Part des trajets en covoiturage (%)": fmt_pct(share_carpool, 1),
+ "Part des trajets en transport en commun (%)": fmt_pct(share_pt, 1),
+ }
+ )
+ return out
+
+
+def build_zones_layer(zones_gdf: gpd.GeoDataFrame, scale) -> pdk.Layer | None:
+ polys = _polygons_records(zones_gdf, scale)
+ if not polys:
+ return None
+ return pdk.Layer(
+ "PolygonLayer",
+ data=polys,
+ get_polygon="geometry",
+ get_fill_color="fill_rgba",
+ pickable=True,
+ filled=True,
+ stroked=True,
+ get_line_color=[0, 0, 0, 80],
+ lineWidthMinPixels=1.5,
+ elevation_scale=0,
+ opacity=0.4,
+ auto_highlight=True,
+ )
diff --git a/front/app/components/features/map/map.py b/front/app/components/features/map/map.py
deleted file mode 100644
index d4cd02a5..00000000
--- a/front/app/components/features/map/map.py
+++ /dev/null
@@ -1,267 +0,0 @@
-import json
-import pydeck as pdk
-import dash_deck
-from dash import html
-import geopandas as gpd
-import pandas as pd
-import numpy as np
-from shapely.geometry import Polygon, MultiPolygon
-from app.scenario.scenario_001_from_docs import load_scenario
-
-
-# ---------- CONSTANTES ----------
-CARTO_POSITRON_GL = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
-FALLBACK_CENTER = (1.4442, 43.6045) # Toulouse
-
-
-# ---------- HELPERS ----------
-
-def _centroids_lonlat(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
- """Calcule les centroides en coordonnées géographiques (lon/lat)."""
- g = gdf.copy()
- if g.crs is None:
- g = g.set_crs(4326, allow_override=True)
- g_m = g.to_crs(3857)
- pts_m = g_m.geometry.centroid
- pts_ll = gpd.GeoSeries(pts_m, crs=g_m.crs).to_crs(4326)
- g["lon"] = pts_ll.x.astype("float64")
- g["lat"] = pts_ll.y.astype("float64")
- return g
-
-
-def _fmt_num(v, nd=1):
- try:
- return f"{round(float(v), nd):.{nd}f}"
- except Exception:
- return "N/A"
-
-
-def _fmt_pct(v, nd=1):
- try:
- return f"{round(float(v) * 100.0, nd):.{nd}f} %"
- except Exception:
- return "N/A"
-
-
-def _polygons_for_layer(zones_gdf: gpd.GeoDataFrame):
- """
- Prépare les polygones pour Deck.gl :
- - geometry / fill_rgba : nécessaires au rendu
- - champs “métier” (INSEE/Zone/Temps/Niveau + stats & parts modales) : pour le tooltip
- """
- g = zones_gdf
- if g.crs is None or getattr(g.crs, "to_epsg", lambda: None)() != 4326:
- g = g.to_crs(4326)
-
- polygons = []
- for _, row in g.iterrows():
- geom = row.geometry
- zone_id = row.get("transport_zone_id", "Zone inconnue")
- insee = row.get("local_admin_unit_id", "N/A")
- travel_time = _fmt_num(row.get("average_travel_time", np.nan), 1)
- legend = row.get("__legend", "")
-
- # Stats “par personne et par jour”
- total_dist_km = _fmt_num(row.get("total_dist_km", np.nan), 1)
- total_time_min = _fmt_num(row.get("total_time_min", np.nan), 1)
-
- # Parts modales
- share_car = _fmt_pct(row.get("share_car", np.nan), 1)
- share_bicycle = _fmt_pct(row.get("share_bicycle", np.nan), 1)
- share_walk = _fmt_pct(row.get("share_walk", np.nan), 1)
-
- color = row.get("__color", [180, 180, 180, 160])
-
- if isinstance(geom, Polygon):
- rings = [list(geom.exterior.coords)]
- elif isinstance(geom, MultiPolygon):
- rings = [list(p.exterior.coords) for p in geom.geoms]
- else:
- continue
-
- for ring in rings:
- polygons.append({
- # ⚙️ Champs techniques pour le rendu
- "geometry": [[float(x), float(y)] for x, y in ring],
- "fill_rgba": color,
- # ✅ Champs métier visibles dans le tooltip (clés FR)
- "Unité INSEE": str(insee),
- "Identifiant de zone": str(zone_id),
- "Temps moyen de trajet (minutes)": travel_time,
- "Niveau d’accessibilité": legend,
- "Distance totale parcourue (km/jour)": total_dist_km,
- "Temps total de déplacement (min/jour)": total_time_min,
- "Part des trajets en voiture (%)": share_car,
- "Part des trajets à vélo (%)": share_bicycle,
- "Part des trajets à pied (%)": share_walk,
- })
- return polygons
-
-
-# ---------- DECK FACTORY ----------
-
-def _deck_json():
- layers = []
- lon_center, lat_center = FALLBACK_CENTER
-
- try:
- scn = load_scenario()
- zones_gdf = scn["zones_gdf"].copy()
- flows_df = scn["flows_df"].copy()
- zones_lookup = scn["zones_lookup"].copy()
-
- # Centrage robuste
- if not zones_gdf.empty:
- zvalid = zones_gdf[zones_gdf.geometry.notnull() & zones_gdf.geometry.is_valid]
- if not zvalid.empty:
- c = zvalid.to_crs(4326).geometry.unary_union.centroid
- lon_center, lat_center = float(c.x), float(c.y)
-
- # Palette couleur
- at = pd.to_numeric(zones_gdf.get("average_travel_time", pd.Series(dtype="float64")), errors="coerce")
- zones_gdf["average_travel_time"] = at
- finite_at = at.replace([np.inf, -np.inf], np.nan).dropna()
- vmin, vmax = (finite_at.min(), finite_at.max()) if not finite_at.empty else (0.0, 1.0)
- rng = vmax - vmin if (vmax - vmin) > 1e-9 else 1.0
- t1, t2 = vmin + rng / 3.0, vmin + 2 * rng / 3.0
-
- def _legend(v):
- if pd.isna(v):
- return "Donnée non disponible"
- if v <= t1:
- return "Accès rapide"
- elif v <= t2:
- return "Accès moyen"
- return "Accès lent"
-
- def _colorize(v):
- if pd.isna(v):
- return [200, 200, 200, 140]
- z = (float(v) - vmin) / rng
- z = max(0.0, min(1.0, z))
- r = int(255 * z)
- g = int(64 + 128 * (1 - z))
- b = int(255 * (1 - z))
- return [r, g, b, 180]
-
- zones_gdf["__legend"] = zones_gdf["average_travel_time"].map(_legend)
- zones_gdf["__color"] = zones_gdf["average_travel_time"].map(_colorize)
-
- # Appliquer la palette au jeu transmis au layer
- polys = []
- for p, v in zip(_polygons_for_layer(zones_gdf), zones_gdf["average_travel_time"].tolist() or []):
- p["fill_rgba"] = _colorize(v)
- polys.append(p)
-
- # Polygones (zones)
- if polys:
- zones_layer = pdk.Layer(
- "PolygonLayer",
- data=polys,
- get_polygon="geometry",
- get_fill_color="fill_rgba",
- pickable=True,
- filled=True,
- stroked=True,
- get_line_color=[0, 0, 0, 80],
- lineWidthMinPixels=1.5,
- elevation_scale=0,
- opacity=0.4,
- auto_highlight=True,
- )
- layers.append(zones_layer)
-
- # --- Arcs de flux ---
- lookup_ll = _centroids_lonlat(zones_lookup)
- flows_df["flow_volume"] = pd.to_numeric(flows_df["flow_volume"], errors="coerce").fillna(0.0)
- flows_df = flows_df[flows_df["flow_volume"] > 0]
- flows = flows_df.merge(
- lookup_ll[["transport_zone_id", "lon", "lat"]],
- left_on="from", right_on="transport_zone_id", how="left"
- ).rename(columns={"lon": "lon_from", "lat": "lat_from"}).drop(columns=["transport_zone_id"])
- flows = flows.merge(
- lookup_ll[["transport_zone_id", "lon", "lat"]],
- left_on="to", right_on="transport_zone_id", how="left"
- ).rename(columns={"lon": "lon_to", "lat": "lat_to"}).drop(columns=["transport_zone_id"])
- flows = flows.dropna(subset=["lon_from", "lat_from", "lon_to", "lat_to"])
- flows["flow_width"] = (1.0 + np.log1p(flows["flow_volume"])).astype("float64").clip(0.5, 6.0)
-
- arcs_layer = pdk.Layer(
- "ArcLayer",
- data=flows,
- get_source_position=["lon_from", "lat_from"],
- get_target_position=["lon_to", "lat_to"],
- get_source_color=[255, 140, 0, 180],
- get_target_color=[0, 128, 255, 180],
- get_width="flow_width",
- pickable=True,
- )
- layers.append(arcs_layer)
-
- except Exception as e:
- print("Overlay scénario désactivé (erreur):", e)
-
- # Vue centrée
- view_state = pdk.ViewState(
- longitude=lon_center,
- latitude=lat_center,
- zoom=10,
- pitch=35,
- bearing=-15,
- )
-
- deck = pdk.Deck(
- layers=layers,
- initial_view_state=view_state,
- map_provider="carto",
- map_style=CARTO_POSITRON_GL,
- views=[pdk.View(type="MapView", controller=True)],
- )
-
- return deck.to_json()
-
-
-# ---------- DASH COMPONENT ----------
-
-def Map():
- deckgl = dash_deck.DeckGL(
- id="deck-map",
- data=_deck_json(),
- # Tooltip personnalisé (aucun champ technique)
- tooltip={
- "html": (
- "
"
- "Zone d’étude
"
- "Unité INSEE : {Unité INSEE}
"
- "Identifiant de zone : {Identifiant de zone}
"
- "Mobilité moyenne
"
- "Temps moyen de trajet : {Temps moyen de trajet (minutes)} min/jour
"
- "Distance totale parcourue : {Distance totale parcourue (km/jour)} km/jour
"
- "Niveau d’accessibilité : {Niveau d’accessibilité}
"
- "Répartition modale
"
- "Part des trajets en voiture : {Part des trajets en voiture (%)}
"
- "Part des trajets à vélo : {Part des trajets à vélo (%)}
"
- "Part des trajets à pied : {Part des trajets à pied (%)}"
- "
"
- ),
- "style": {
- "backgroundColor": "rgba(255,255,255,0.9)",
- "color": "#111",
- "fontSize": "12px",
- "padding": "8px",
- "borderRadius": "6px",
- },
- },
- mapboxKey="",
- style={"position": "absolute", "inset": 0},
- )
-
- return html.Div(
- deckgl,
- style={
- "position": "relative",
- "width": "100%",
- "height": "100%",
- "background": "#fff",
- },
- )
diff --git a/front/app/components/features/map/map_component.py b/front/app/components/features/map/map_component.py
new file mode 100644
index 00000000..d8edb78e
--- /dev/null
+++ b/front/app/components/features/map/map_component.py
@@ -0,0 +1,90 @@
+"""
+map.py
+======
+
+Assemblage de la page cartographique (Deck.gl + panneaux latéraux).
+
+- **Option A (service)** : si `app.services.map_service` est disponible,
+ on récupère la spécification Deck.gl JSON et les zones via
+ `get_map_deck_json` / `get_map_zones_gdf`.
+- **Option B (fallback)** : sinon, on calcule localement la carte à partir
+ d’un scénario (`scenario_service.get_scenario`) et de la fabrique Deck (`make_deck_json`).
+
+Composants intégrés :
+- `DeckMap` : rendu Deck.gl plein écran
+- `SummaryPanelWrapper` : panneau de résumé (droite)
+- `ControlsSidebarWrapper` : barre de contrôles (gauche)
+"""
+
+from dash import html
+from .config import DeckOptions
+from .components import DeckMap, ControlsSidebarWrapper, SummaryPanelWrapper
+
+# — Option A : via map_service s’il existe
+try:
+ from app.services.map_service import get_map_deck_json, get_map_zones_gdf
+ _USE_SERVICE = True
+except Exception:
+ _USE_SERVICE = False
+
+# — Option B : fallback direct si map_service absent
+if not _USE_SERVICE:
+ from app.services.scenario_service import get_scenario
+ from .deck_factory import make_deck_json
+
+ def get_map_deck_json(id_prefix: str, opts: DeckOptions) -> str:
+ """Construit la spec Deck.gl au format JSON à partir d’un scénario local.
+
+ Args:
+ id_prefix (str): Préfixe d’identifiant (réservé pour compat).
+ opts (DeckOptions): Options d’affichage (zoom, pitch, style, etc.).
+
+ Returns:
+ str: Spécification Deck.gl sérialisée (JSON).
+ """
+ scn = get_scenario()
+ return make_deck_json(scn, opts)
+
+ def get_map_zones_gdf():
+ """Récupère les zones du scénario local (fallback)."""
+ scn = get_scenario()
+ return scn["zones_gdf"]
+
+
+def Map(id_prefix: str = "map"):
+ """Assemble la vue cartographique : carte, résumé et sidebar de contrôles.
+
+ Le rendu s’appuie sur `DeckOptions()` pour initialiser l’état de la vue,
+ puis crée :
+ - le composant Deck.gl (`DeckMap`) avec la spec JSON,
+ - le panneau de résumé (`SummaryPanelWrapper`) à droite,
+ - la barre de contrôles (`ControlsSidebarWrapper`) à gauche.
+
+ Le layout final est un conteneur `Div` en position relative, sur toute
+ la hauteur de la fenêtre.
+
+ Args:
+ id_prefix (str, optional): Préfixe d’identifiants pour les composants
+ associés à la carte. Par défaut `"map"`.
+
+ Returns:
+ dash.html.Div: Conteneur principal de la page cartographique.
+ """
+ opts = DeckOptions()
+ deck_json = get_map_deck_json(id_prefix=id_prefix, opts=opts)
+ zones_gdf = get_map_zones_gdf()
+
+ deckgl = DeckMap(id_prefix=id_prefix, deck_json=deck_json)
+ summary = SummaryPanelWrapper(zones_gdf, id_prefix=id_prefix)
+ controls_sidebar = ControlsSidebarWrapper(id_prefix=id_prefix)
+
+ return html.Div(
+ [deckgl, summary, controls_sidebar],
+ style={
+ "position": "relative",
+ "width": "100%",
+ "height": "100vh",
+ "background": "#fff",
+ "overflow": "hidden",
+ },
+ )
diff --git a/front/app/components/features/map/tooltip.py b/front/app/components/features/map/tooltip.py
new file mode 100644
index 00000000..016daf36
--- /dev/null
+++ b/front/app/components/features/map/tooltip.py
@@ -0,0 +1,26 @@
+def default_tooltip() -> dict:
+ return {
+ "html": (
+ ""
+ "Zone d’étude
"
+ "Unité INSEE : {Unité INSEE}
"
+ "Identifiant de zone : {Identifiant de zone}
"
+ "Mobilité moyenne
"
+ "Temps moyen de trajet : {Temps moyen de trajet (minutes)} min/jour
"
+ "Distance totale parcourue : {Distance totale parcourue (km/jour)} km/jour
"
+ "Niveau d’accessibilité : {Niveau d’accessibilité}
"
+ "Répartition modale
"
+ "Part des trajets en voiture : {Part des trajets en voiture (%)}
"
+ "Part des trajets à vélo : {Part des trajets à vélo (%)}
"
+ "Part des trajets à pied : {Part des trajets à pied (%)}
"
+ "Part des trajets en transport en commun : {Part des trajets en transport en commun (%)}"
+ "
"
+ ),
+ "style": {
+ "backgroundColor": "rgba(255,255,255,0.9)",
+ "color": "#111",
+ "fontSize": "12px",
+ "padding": "8px",
+ "borderRadius": "6px",
+ },
+ }
diff --git a/front/app/components/features/scenario_controls/__init__.py b/front/app/components/features/scenario_controls/__init__.py
new file mode 100644
index 00000000..20e2b71c
--- /dev/null
+++ b/front/app/components/features/scenario_controls/__init__.py
@@ -0,0 +1,6 @@
+from .panel import ScenarioControlsPanel
+from .radius import RadiusControl
+from .lau_input import LauInput
+from .run_button import RunButton
+from .transport_modes_inputs import TransportModesInputs
+__all__ = ["ScenarioControlsPanel", "RadiusControl", "LauInput", "RunButton", "TransportModesInputs"]
diff --git a/front/app/components/features/scenario_controls/lau_input.py b/front/app/components/features/scenario_controls/lau_input.py
new file mode 100644
index 00000000..7ecfc4c2
--- /dev/null
+++ b/front/app/components/features/scenario_controls/lau_input.py
@@ -0,0 +1,26 @@
+import dash_mantine_components as dmc
+
+def LauInput(id_prefix: str, *, default_insee: str = "31555"):
+ """Crée un champ de saisie pour la zone d’étude (code INSEE ou LAU).
+
+ Ce composant permet à l’utilisateur d’indiquer le code de la commune ou
+ unité administrative locale utilisée comme point de référence pour le scénario.
+ Le champ est pré-rempli avec un code par défaut (par exemple, Toulouse : `31555`)
+ et conserve les identifiants Dash existants pour compatibilité avec les callbacks.
+
+ Args:
+ id_prefix (str): Préfixe pour l’identifiant du composant Dash.
+ L’ID généré est de la forme `"{id_prefix}-lau-input"`.
+ default_insee (str, optional): Code INSEE ou LAU affiché par défaut.
+ Par défaut `"31555"`.
+
+ Returns:
+ dmc.TextInput: Champ de saisie Mantine configuré pour l’entrée du code INSEE/LAU.
+ """
+ return dmc.TextInput(
+ id=f"{id_prefix}-lau-input",
+ value=default_insee,
+ label="Zone d’étude (INSEE)",
+ placeholder="ex: 31555",
+ w=250,
+ )
diff --git a/front/app/components/features/scenario_controls/panel.py b/front/app/components/features/scenario_controls/panel.py
new file mode 100644
index 00000000..a9965f16
--- /dev/null
+++ b/front/app/components/features/scenario_controls/panel.py
@@ -0,0 +1,63 @@
+# app/components/features/.../panel.py
+import dash_mantine_components as dmc
+from .radius import RadiusControl
+from .lau_input import LauInput
+from .run_button import RunButton
+from .transport_modes_inputs import TransportModesInputs
+
+
+def ScenarioControlsPanel(
+ id_prefix: str = "scenario",
+ *,
+ min_radius: int = 15,
+ max_radius: int = 50,
+ step: int = 1,
+ default: int | float = 40,
+ default_insee: str = "31555",
+):
+ """Assemble le panneau vertical de contrôle du scénario.
+
+ Ce composant regroupe les principaux contrôles nécessaires à la
+ configuration d’un scénario de mobilité ou d’analyse territoriale.
+ Il est organisé verticalement (`dmc.Stack`) et inclut :
+ - le contrôle du **rayon d’étude** (`RadiusControl`) ;
+ - la **zone d’étude (INSEE/LAU)** (`LauInput`) ;
+ - la section des **modes de transport** (`TransportModesInputs`) ;
+ - le **bouton d’exécution** (`RunButton`).
+
+ Ce panneau constitue la partie principale de l’interface utilisateur permettant
+ de définir les paramètres du scénario avant de lancer une simulation.
+
+ Args:
+ id_prefix (str, optional): Préfixe utilisé pour générer les identifiants
+ Dash des sous-composants. Par défaut `"scenario"`.
+ min_radius (int, optional): Valeur minimale du rayon d’étude (en km).
+ Par défaut `15`.
+ max_radius (int, optional): Valeur maximale du rayon d’étude (en km).
+ Par défaut `50`.
+ step (int, optional): Pas d’incrémentation du rayon pour le slider et l’input.
+ Par défaut `1`.
+ default (int | float, optional): Valeur initiale du rayon affichée.
+ Par défaut `40`.
+ default_insee (str, optional): Code INSEE ou identifiant LAU par défaut de la
+ zone sélectionnée (ex. `"31555"` pour Toulouse).
+
+ Returns:
+ dmc.Stack: Composant vertical (`Stack`) regroupant tous les contrôles du panneau scénario.
+ """
+ return dmc.Stack(
+ [
+ RadiusControl(
+ id_prefix,
+ min_radius=min_radius,
+ max_radius=max_radius,
+ step=step,
+ default=default,
+ ),
+ LauInput(id_prefix, default_insee=default_insee),
+ TransportModesInputs(id_prefix="tm"),
+ RunButton(id_prefix),
+ ],
+ gap="sm",
+ style={"width": "fit-content", "padding": "8px"},
+ )
diff --git a/front/app/components/features/scenario_controls/radius.py b/front/app/components/features/scenario_controls/radius.py
new file mode 100644
index 00000000..820c4efb
--- /dev/null
+++ b/front/app/components/features/scenario_controls/radius.py
@@ -0,0 +1,69 @@
+import dash_mantine_components as dmc
+
+def RadiusControl(
+ id_prefix: str,
+ *,
+ min_radius: int = 15,
+ max_radius: int = 50,
+ step: int = 1,
+ default: int | float = 40,
+):
+ """Crée un contrôle de sélection du rayon d’analyse (en kilomètres).
+
+ Ce composant combine un **slider** et un **champ numérique synchronisé**
+ pour ajuster le rayon d’un scénario (ex. rayon d’étude autour d’une commune).
+ Les identifiants Dash sont conservés pour assurer la compatibilité avec
+ les callbacks existants.
+
+ - Le slider permet une sélection visuelle du rayon.
+ - Le `NumberInput` permet une saisie précise de la valeur.
+ - Les deux sont alignés horizontalement et liés via leur `id_prefix`.
+
+ Args:
+ id_prefix (str): Préfixe pour les identifiants Dash.
+ Les IDs générés sont :
+ - `"{id_prefix}-radius-slider"`
+ - `"{id_prefix}-radius-input"`
+ min_radius (int, optional): Valeur minimale du rayon (en km).
+ Par défaut `15`.
+ max_radius (int, optional): Valeur maximale du rayon (en km).
+ Par défaut `50`.
+ step (int, optional): Pas d’incrémentation pour le slider et l’input.
+ Par défaut `1`.
+ default (int | float, optional): Valeur initiale du rayon (en km).
+ Par défaut `40`.
+
+ Returns:
+ dmc.Group: Composant Mantine contenant le label, le slider et le champ numérique.
+ """
+ return dmc.Group(
+ [
+ dmc.Text("Rayon (km)", fw=600, w=100, ta="right"),
+ dmc.Slider(
+ id=f"{id_prefix}-radius-slider",
+ value=default,
+ min=min_radius,
+ max=max_radius,
+ step=step,
+ w=280,
+ marks=[
+ {"value": min_radius, "label": str(min_radius)},
+ {"value": default, "label": str(default)},
+ {"value": max_radius, "label": str(max_radius)},
+ ],
+ ),
+ dmc.NumberInput(
+ id=f"{id_prefix}-radius-input",
+ value=default,
+ min=min_radius,
+ max=max_radius,
+ step=step,
+ w=90,
+ styles={"input": {"textAlign": "center", "marginTop": "10px"}},
+ ),
+ ],
+ gap="md",
+ align="center",
+ justify="flex-start",
+ wrap=False,
+ )
diff --git a/front/app/components/features/scenario_controls/run_button.py b/front/app/components/features/scenario_controls/run_button.py
new file mode 100644
index 00000000..4ad4c099
--- /dev/null
+++ b/front/app/components/features/scenario_controls/run_button.py
@@ -0,0 +1,29 @@
+import dash_mantine_components as dmc
+
+def RunButton(id_prefix: str, *, label: str = "Lancer la simulation"):
+ """Crée le bouton principal d’exécution du scénario.
+
+ Ce bouton est utilisé pour lancer une simulation ou exécuter une action
+ principale dans l’interface utilisateur.
+ Il est stylisé avec une apparence remplie (`variant="filled"`) et s’aligne
+ à gauche du conteneur.
+
+ Args:
+ id_prefix (str): Préfixe pour l’identifiant du composant Dash.
+ Le bouton aura un ID du type `"{id_prefix}-run-btn"`.
+ label (str, optional): Texte affiché sur le bouton.
+ Par défaut `"Lancer la simulation"`.
+
+ Returns:
+ dmc.Button: Composant Mantine représentant le bouton d’action.
+ """
+ return dmc.Button(
+ label,
+ id=f"{id_prefix}-run-btn",
+ variant="filled",
+ style={
+ "marginTop": "10px",
+ "width": "fit-content",
+ "alignSelf": "flex-start",
+ },
+ )
diff --git a/front/app/components/features/scenario_controls/scenario.py b/front/app/components/features/scenario_controls/scenario.py
new file mode 100644
index 00000000..36ded57f
--- /dev/null
+++ b/front/app/components/features/scenario_controls/scenario.py
@@ -0,0 +1,42 @@
+from .scenario_controls.panel import ScenarioControlsPanel
+
+def ScenarioControls(
+ id_prefix: str = "scenario",
+ min_radius: int = 15,
+ max_radius: int = 50,
+ step: int = 1,
+ default: int | float = 40,
+ default_insee: str = "31555",
+):
+ """Construit et retourne le panneau de contrôle principal du scénario.
+
+ Cette fonction agit comme un wrapper simple autour de `ScenarioControlsPanel`,
+ qui gère l’interface utilisateur permettant de configurer les paramètres d’un
+ scénario (zone géographique, rayon d’analyse, etc.).
+ Elle définit les valeurs par défaut et simplifie la création du composant.
+
+ Args:
+ id_prefix (str, optional): Préfixe utilisé pour les identifiants Dash afin
+ d’éviter les collisions entre composants. Par défaut `"scenario"`.
+ min_radius (int, optional): Rayon minimal autorisé dans le contrôle (en km).
+ Par défaut `15`.
+ max_radius (int, optional): Rayon maximal autorisé dans le contrôle (en km).
+ Par défaut `50`.
+ step (int, optional): Pas d’incrémentation du rayon (en km) pour le sélecteur.
+ Par défaut `1`.
+ default (int | float, optional): Valeur initiale du rayon affichée par défaut.
+ Par défaut `40`.
+ default_insee (str, optional): Code INSEE ou identifiant LAU de la commune
+ sélectionnée par défaut. Par défaut `"31555"` (Toulouse).
+
+ Returns:
+ ScenarioControlsPanel: Instance configurée du panneau de contrôle du scénario.
+ """
+ return ScenarioControlsPanel(
+ id_prefix=id_prefix,
+ min_radius=min_radius,
+ max_radius=max_radius,
+ step=step,
+ default=default,
+ default_insee=default_insee,
+ )
diff --git a/front/app/components/features/scenario_controls/transport_modes_inputs.py b/front/app/components/features/scenario_controls/transport_modes_inputs.py
new file mode 100644
index 00000000..6cf2a84c
--- /dev/null
+++ b/front/app/components/features/scenario_controls/transport_modes_inputs.py
@@ -0,0 +1,270 @@
+import dash_mantine_components as dmc
+from dash import html
+
+# -------------------------
+# Données mock : 5 modes (PT inclus) + sous-modes PT
+# -------------------------
+MOCK_MODES = [
+ {
+ "name": "À pied",
+ "active": True,
+ "vars": {
+ "Valeur du temps (€/h)": 12,
+ "Valeur de la distance (€/km)": 0.01,
+ "Constante de mode (€)": 1,
+ },
+ },
+ {
+ "name": "Vélo",
+ "active": True,
+ "vars": {
+ "Valeur du temps (€/h)": 12,
+ "Valeur de la distance (€/km)": 0.01,
+ "Constante de mode (€)": 1,
+ },
+ },
+ {
+ "name": "Voiture",
+ "active": True,
+ "vars": {
+ "Valeur du temps (€/h)": 12,
+ "Valeur de la distance (€/km)": 0.01,
+ "Constante de mode (€)": 1,
+ },
+ },
+ {
+ "name": "Covoiturage",
+ "active": True,
+ "vars": {
+ "Valeur du temps (€/h)": 12,
+ "Valeur de la distance (€/km)": 0.01,
+ "Constante de mode (€)": 1,
+ },
+ },
+ {
+ "name": "Transport en commun",
+ "active": True, # coché par défaut
+ "vars": {
+ "Valeur du temps (€/h)": 12,
+ "Valeur de la distance (€/km)": 0.01,
+ "Constante de mode (€)": 1,
+ },
+ "pt_submodes": {
+ # 3 sous-modes cochés par défaut
+ "walk_pt": True,
+ "car_pt": True,
+ "bicycle_pt": True,
+ },
+ },
+]
+
+VAR_SPECS = {
+ "Valeur du temps (€/h)": {"min": 0, "max": 50, "step": 1},
+ "Valeur de la distance (€/km)": {"min": 0, "max": 2, "step": 0.1},
+ "Constante de mode (€)": {"min": 0, "max": 20, "step": 1},
+}
+
+PT_SUB_LABELS = {
+ "walk_pt": "Marche + TC",
+ "car_pt": "Voiture + TC",
+ "bicycle_pt": "Vélo + TC",
+}
+
+PT_COLOR = "#e5007d" # rouge/magenta du logo AREP
+
+
+def _mode_header(mode):
+ """Crée l'en-tête d'un mode de transport avec case à cocher et tooltip d'avertissement.
+
+ Cette fonction construit un composant `dmc.Group` contenant :
+ - une case à cocher permettant d'activer ou désactiver le mode ;
+ - un texte affichant le nom du mode ;
+ - un tooltip rouge s'affichant si l'utilisateur tente de désactiver tous les modes.
+
+ Args:
+ mode (dict): Dictionnaire représentant un mode de transport, issu de MOCK_MODES.
+
+ Returns:
+ dmc.Group: Composant Mantine contenant la case à cocher, le texte et le tooltip.
+ """
+ return dmc.Group(
+ [
+ dmc.Tooltip(
+ label="Au moins un mode doit rester actif",
+ position="right",
+ withArrow=True,
+ color=PT_COLOR,
+ opened=False,
+ withinPortal=True,
+ zIndex=9999,
+ transitionProps={"transition": "fade", "duration": 300, "timingFunction": "ease-in-out"},
+ id={"type": "mode-tip", "index": mode["name"]},
+ children=dmc.Checkbox(
+ id={"type": "mode-active", "index": mode["name"]},
+ checked=mode["active"],
+ ),
+ ),
+ dmc.Text(mode["name"], fw=700),
+ ],
+ gap="sm",
+ align="center",
+ w="100%",
+ )
+
+
+def _pt_submodes_block(mode):
+ """Construit le bloc des sous-modes pour le transport en commun (TC).
+
+ Crée une pile verticale de cases à cocher correspondant aux sous-modes :
+ - Marche + TC
+ - Voiture + TC
+ - Vélo + TC
+
+ Chaque case est associée à un tooltip rouge indiquant qu'au moins un sous-mode
+ doit rester activé.
+
+ Args:
+ mode (dict): Dictionnaire décrivant le mode "Transport en commun" et ses sous-modes.
+
+ Returns:
+ dmc.Stack: Bloc vertical contenant les sous-modes configurables.
+ """
+ pt_cfg = mode.get("pt_submodes") or {}
+ rows = []
+ for key, label in PT_SUB_LABELS.items():
+ rows.append(
+ dmc.Group(
+ [
+ dmc.Tooltip(
+ label="Au moins un sous-mode TC doit rester actif",
+ position="right",
+ withArrow=True,
+ color=PT_COLOR,
+ opened=False,
+ withinPortal=True,
+ zIndex=9999,
+ transitionProps={"transition": "fade", "duration": 300, "timingFunction": "ease-in-out"},
+ id={"type": "pt-tip", "index": key},
+ children=dmc.Checkbox(
+ id={"type": "pt-submode", "index": key},
+ checked=bool(pt_cfg.get(key, True)),
+ ),
+ ),
+ dmc.Text(label, size="sm"),
+ ],
+ gap="sm",
+ align="center",
+ )
+ )
+ return dmc.Stack(rows, gap="xs")
+
+
+def _mode_body(mode):
+ """Construit le corps (contenu détaillé) d'un mode de transport.
+
+ Ce bloc inclut les paramètres numériques (valeur du temps, distance, constante)
+ sous forme de champs `NumberInput`. Si le mode est "Transport en commun",
+ le corps inclut également la section des sous-modes.
+
+ Args:
+ mode (dict): Dictionnaire décrivant un mode de transport avec ses variables.
+
+ Returns:
+ dmc.Stack: Bloc vertical avec les variables d'entrée et, si applicable, les sous-modes TC.
+ """
+ rows = []
+ # Variables principales
+ for label, val in mode["vars"].items():
+ spec = VAR_SPECS[label]
+ rows.append(
+ dmc.Group(
+ [
+ dmc.Text(label),
+ dmc.NumberInput(
+ value=val,
+ min=spec["min"],
+ max=spec["max"],
+ step=spec["step"],
+ style={"width": 140},
+ id={"type": "mode-var", "mode": mode["name"], "var": label},
+ ),
+ ],
+ justify="space-between",
+ align="center",
+ )
+ )
+ # Sous-modes TC
+ if mode["name"] == "Transport en commun":
+ rows.append(dmc.Divider())
+ rows.append(dmc.Text("Sous-modes (cumulatifs)", size="sm", fw=600))
+ rows.append(_pt_submodes_block(mode))
+ return dmc.Stack(rows, gap="md")
+
+
+def _modes_list():
+ """Construit la liste complète des modes de transport sous forme d'accordéon.
+
+ Chaque item correspond à un mode de transport (piéton, vélo, voiture, etc.)
+ et contient :
+ - un en-tête (nom + case à cocher) ;
+ - un panneau dépliable avec les paramètres et sous-modes.
+
+ Returns:
+ dmc.Accordion: Accordéon Mantine contenant tous les modes configurables.
+ """
+ items = [
+ dmc.AccordionItem(
+ [dmc.AccordionControl(_mode_header(m)), dmc.AccordionPanel(_mode_body(m))],
+ value=f"mode-{i}",
+ )
+ for i, m in enumerate(MOCK_MODES)
+ ]
+ return dmc.Accordion(
+ children=items,
+ multiple=True,
+ value=[],
+ chevronPosition="right",
+ chevronSize=18,
+ variant="separated",
+ radius="md",
+ styles={"control": {"paddingTop": 8, "paddingBottom": 8}},
+ )
+
+
+def TransportModesInputs(id_prefix="tm"):
+ """Construit le panneau principal "MODES DE TRANSPORT".
+
+ Ce composant est un accordéon englobant la liste complète des modes
+ et permet à l'utilisateur d'activer, désactiver ou ajuster les paramètres
+ de chaque mode.
+
+ Args:
+ id_prefix (str, optional): Préfixe d'identifiants pour les callbacks Dash.
+ Par défaut "tm".
+
+ Returns:
+ dmc.Accordion: Accordéon principal contenant tous les contrôles des modes.
+ """
+ return dmc.Accordion(
+ children=[
+ dmc.AccordionItem(
+ [
+ dmc.AccordionControl(
+ dmc.Group(
+ [dmc.Text("MODES DE TRANSPORT", fw=700), html.Div(style={"flex": 1})],
+ align="center",
+ )
+ ),
+ dmc.AccordionPanel(_modes_list()),
+ ],
+ value="tm-root",
+ )
+ ],
+ multiple=True,
+ value=[],
+ chevronPosition="right",
+ chevronSize=18,
+ variant="separated",
+ radius="lg",
+ styles={"control": {"paddingTop": 10, "paddingBottom": 10}},
+ )
diff --git a/front/app/components/features/study_area_summary/__init__.py b/front/app/components/features/study_area_summary/__init__.py
new file mode 100644
index 00000000..b76816ab
--- /dev/null
+++ b/front/app/components/features/study_area_summary/__init__.py
@@ -0,0 +1,3 @@
+from .panel import StudyAreaSummary
+
+__all__ = ["StudyAreaSummary"]
diff --git a/front/app/components/features/study_area_summary/kpi.py b/front/app/components/features/study_area_summary/kpi.py
new file mode 100644
index 00000000..d3379d19
--- /dev/null
+++ b/front/app/components/features/study_area_summary/kpi.py
@@ -0,0 +1,62 @@
+"""
+kpi.py
+======
+
+Composants d’affichage des indicateurs clés de performance (KPI) pour la zone d’étude.
+
+Ce module affiche les statistiques principales issues du scénario de mobilité :
+- Temps moyen de trajet quotidien (en minutes)
+- Distance totale moyenne (en kilomètres)
+
+Ces éléments sont utilisés dans le panneau de résumé (`StudyAreaSummary`)
+pour donner une vue synthétique des valeurs moyennes agrégées.
+"""
+
+from dash import html
+import dash_mantine_components as dmc
+from .utils import fmt_num
+
+
+def KPIStat(label: str, value: str):
+ """Crée une ligne d’affichage d’un indicateur clé (KPI).
+
+ Affiche un libellé descriptif suivi de sa valeur formatée (texte mis en gras).
+ Utilisé pour représenter une statistique simple telle qu’un temps moyen
+ ou une distance totale.
+
+ Args:
+ label (str): Nom de l’indicateur (ex. "Temps moyen de trajet :").
+ value (str): Valeur formatée à afficher (ex. "18.5 min/jour").
+
+ Returns:
+ dmc.Group: Ligne contenant le label et la valeur du KPI.
+ """
+ return dmc.Group(
+ [dmc.Text(label, size="sm"), dmc.Text(value, fw=600, size="sm")],
+ gap="xs",
+ )
+
+
+def KPIStatGroup(avg_time_min: float | None, avg_dist_km: float | None):
+ """Construit le groupe d’indicateurs clés de la zone d’étude.
+
+ Ce composant affiche :
+ - Le temps moyen de trajet (en minutes par jour)
+ - La distance totale moyenne (en kilomètres par jour)
+
+ Si les valeurs sont `None`, elles sont formatées en `"N/A"` grâce à `fmt_num()`.
+
+ Args:
+ avg_time_min (float | None): Temps moyen de trajet en minutes.
+ avg_dist_km (float | None): Distance totale moyenne en kilomètres.
+
+ Returns:
+ dmc.Stack: Bloc vertical contenant les deux statistiques principales.
+ """
+ return dmc.Stack(
+ [
+ KPIStat("Temps moyen de trajet :", f"{fmt_num(avg_time_min, 1)} min/jour"),
+ KPIStat("Distance totale moyenne :", f"{fmt_num(avg_dist_km, 1)} km/jour"),
+ ],
+ gap="xs",
+ )
diff --git a/front/app/components/features/study_area_summary/legend.py b/front/app/components/features/study_area_summary/legend.py
new file mode 100644
index 00000000..cd9b0c06
--- /dev/null
+++ b/front/app/components/features/study_area_summary/legend.py
@@ -0,0 +1,144 @@
+"""
+legend.py
+==========
+
+Composants d’affichage pour la légende compacte associée à la carte des temps moyens.
+
+Ce module permet de visualiser la correspondance entre les valeurs de temps
+de déplacement moyen (`average_travel_time`) et leur codage couleur.
+La légende est construite avec trois classes qualitatives :
+- **Accès rapide**
+- **Accès moyen**
+- **Accès lent**
+
+Ainsi qu’un **dégradé continu** allant du bleu (temps faible) au rouge (temps élevé).
+
+Fonctionnalités principales :
+- `_chip()`: Génère un mini-bloc coloré avec son libellé.
+- `LegendCompact()`: Construit la légende complète avec les bornes, la barre de dégradé,
+ et une explication textuelle.
+"""
+
+from dash import html
+import dash_mantine_components as dmc
+import pandas as pd
+from .utils import safe_min_max, colorize_from_range, rgb_str, fmt_num
+
+
+def _chip(color_rgb, label: str):
+ """Crée un petit bloc coloré (chip) avec un label descriptif.
+
+ Ce composant est utilisé pour représenter chaque classe de la légende,
+ associant une couleur à une plage de valeurs (par exemple : “Accès rapide — 12–18 min”).
+
+ Args:
+ color_rgb (Tuple[int, int, int]): Triplet RGB de la couleur du bloc.
+ label (str): Texte associé à la couleur.
+
+ Returns:
+ dmc.Group: Composant contenant un carré coloré et son libellé.
+ """
+ r, g, b = color_rgb
+ return dmc.Group(
+ [
+ html.Div(
+ style={
+ "width": "14px",
+ "height": "14px",
+ "borderRadius": "3px",
+ "background": f"rgb({r},{g},{b})",
+ "border": "1px solid rgba(0,0,0,0.2)",
+ "flexShrink": 0,
+ }
+ ),
+ dmc.Text(label, size="sm"),
+ ],
+ gap="xs",
+ align="center",
+ wrap="nowrap",
+ )
+
+
+def LegendCompact(avg_series):
+ """Construit une légende compacte pour les temps moyens de déplacement.
+
+ La légende affiche :
+ - Trois classes qualitatives : *Accès rapide*, *Accès moyen* et *Accès lent*.
+ - Une barre de dégradé continue (du bleu au rouge).
+ - Les valeurs numériques min/max correspondantes.
+ - Un court texte explicatif sur l’interprétation des couleurs.
+
+ Si les valeurs sont manquantes ou uniformes, une alerte grisée est affichée.
+
+ Args:
+ avg_series (pandas.Series | list | ndarray): Série numérique contenant
+ les temps moyens de déplacement (en minutes).
+
+ Returns:
+ dmc.Stack | dmc.Alert:
+ - Une pile verticale (`dmc.Stack`) avec les couleurs, la barre de dégradé
+ et les libellés si les données sont valides.
+ - Un message `dmc.Alert` indiquant l’absence de données sinon.
+
+ Example:
+ >>> LegendCompact(zones_gdf["average_travel_time"])
+ # Renvoie un composant Dash contenant la légende complète
+ """
+ vmin, vmax = safe_min_max(avg_series)
+ if pd.isna(vmin) or pd.isna(vmax) or vmax - vmin <= 1e-9:
+ return dmc.Alert(
+ "Légende indisponible (valeurs manquantes).",
+ color="gray",
+ variant="light",
+ radius="sm",
+ styles={"root": {"padding": "8px"}},
+ )
+
+ rng = vmax - vmin
+ t1 = vmin + rng / 3.0
+ t2 = vmin + 2.0 * rng / 3.0
+
+ # Couleurs représentatives des trois classes
+ c1 = colorize_from_range((vmin + t1) / 2.0, vmin, vmax)
+ c2 = colorize_from_range((t1 + t2) / 2.0, vmin, vmax)
+ c3 = colorize_from_range((t2 + vmax) / 2.0, vmin, vmax)
+
+ # Couleurs pour le dégradé continu
+ left = rgb_str(colorize_from_range(vmin + 1e-6, vmin, vmax))
+ mid = rgb_str(colorize_from_range((vmin + vmax) / 2.0, vmin, vmax))
+ right = rgb_str(colorize_from_range(vmax - 1e-6, vmin, vmax))
+
+ return dmc.Stack(
+ [
+ dmc.Text("Légende — temps moyen (min)", fw=600, size="sm"),
+ _chip(c1, f"Accès rapide — {fmt_num(vmin, 1)}–{fmt_num(t1, 1)} min"),
+ _chip(c2, f"Accès moyen — {fmt_num(t1, 1)}–{fmt_num(t2, 1)} min"),
+ _chip(c3, f"Accès lent — {fmt_num(t2, 1)}–{fmt_num(vmax, 1)} min"),
+ html.Div(
+ style={
+ "height": "10px",
+ "width": "100%",
+ "borderRadius": "6px",
+ "background": f"linear-gradient(to right, {left}, {mid}, {right})",
+ "border": "1px solid rgba(0,0,0,0.15)",
+ "marginTop": "6px",
+ }
+ ),
+ dmc.Group(
+ [
+ dmc.Text(f"{fmt_num(vmin, 1)}", size="xs", style={"opacity": 0.8}),
+ dmc.Text("→", size="xs", style={"opacity": 0.6}),
+ dmc.Text(f"{fmt_num(vmax, 1)}", size="xs", style={"opacity": 0.8}),
+ ],
+ justify="space-between",
+ align="center",
+ gap="xs",
+ ),
+ dmc.Text(
+ "Plus la teinte est chaude, plus le déplacement moyen est long.",
+ size="xs",
+ style={"opacity": 0.75},
+ ),
+ ],
+ gap="xs",
+ )
diff --git a/front/app/components/features/study_area_summary/modal_split.py b/front/app/components/features/study_area_summary/modal_split.py
new file mode 100644
index 00000000..d3922071
--- /dev/null
+++ b/front/app/components/features/study_area_summary/modal_split.py
@@ -0,0 +1,105 @@
+"""
+modal_split.py
+==============
+
+Composants d’affichage de la répartition modale (part des différents modes de transport).
+
+Ce module fournit :
+- une fonction interne `_row()` pour créer une ligne affichant un label et une part en pourcentage ;
+- la fonction principale `ModalSplitList()` qui assemble ces lignes dans un composant vertical Mantine.
+
+Utilisé dans le panneau de résumé global (`StudyAreaSummary`) pour présenter la
+répartition modale agrégée d’une zone d’étude.
+"""
+
+import dash_mantine_components as dmc
+from .utils import fmt_pct
+
+
+def _row(label: str, val) -> dmc.Group | None:
+ """Construit une ligne affichant le nom d’un mode et sa part en pourcentage.
+
+ Ignore les valeurs nulles, invalides ou inférieures ou égales à zéro.
+
+ Args:
+ label (str): Nom du mode de transport à afficher.
+ val (float | None): Part correspondante (entre 0 et 1).
+
+ Returns:
+ dmc.Group | None: Ligne contenant le label et la valeur formatée, ou `None`
+ si la valeur n’est pas affichable.
+ """
+ if val is None:
+ return None
+ try:
+ v = float(val)
+ except Exception:
+ return None
+ if v <= 0:
+ return None
+ return dmc.Group(
+ [
+ dmc.Text(f"{label} :", size="sm"),
+ dmc.Text(fmt_pct(v, 1), fw=600, size="sm"),
+ ],
+ gap="xs",
+ )
+
+
+def ModalSplitList(
+ share_car=None,
+ share_bike=None,
+ share_walk=None,
+ share_carpool=None,
+ share_pt=None,
+ share_pt_walk=None,
+ share_pt_car=None,
+ share_pt_bicycle=None,
+):
+ """Construit la liste affichant la répartition modale par type de transport.
+
+ Crée un empilement vertical (`dmc.Stack`) de lignes représentant la part de
+ chaque mode : voiture, vélo, marche, covoiturage, et transports en commun.
+ Si les transports en commun sont présents, leurs sous-modes (TC + marche,
+ TC + voiture, TC + vélo) sont affichés en indentation.
+
+ Args:
+ share_car (float, optional): Part de la voiture.
+ share_bike (float, optional): Part du vélo.
+ share_walk (float, optional): Part de la marche.
+ share_carpool (float, optional): Part du covoiturage.
+ share_pt (float, optional): Part totale des transports en commun.
+ share_pt_walk (float, optional): Part du sous-mode "à pied + TC".
+ share_pt_car (float, optional): Part du sous-mode "voiture + TC".
+ share_pt_bicycle (float, optional): Part du sous-mode "vélo + TC".
+
+ Returns:
+ dmc.Stack: Composant vertical contenant les parts modales formatées.
+ """
+ rows = [
+ _row("Voiture", share_car),
+ _row("Vélo", share_bike),
+ _row("À pied", share_walk),
+ _row("Covoiturage", share_carpool),
+ ]
+
+ if (share_pt or 0) > 0:
+ rows.append(
+ dmc.Group(
+ [
+ dmc.Text("Transports en commun", fw=700, size="sm"),
+ dmc.Text(fmt_pct(share_pt, 1), fw=700, size="sm"),
+ ],
+ gap="xs",
+ )
+ )
+ # Sous-modes (indentés)
+ sub = [
+ _row(" À pied + TC", share_pt_walk),
+ _row(" Voiture + TC", share_pt_car),
+ _row(" Vélo + TC", share_pt_bicycle),
+ ]
+ rows.extend([r for r in sub if r is not None])
+
+ rows = [r for r in rows if r is not None]
+ return dmc.Stack(rows, gap="xs")
diff --git a/front/app/components/features/study_area_summary/panel.py b/front/app/components/features/study_area_summary/panel.py
new file mode 100644
index 00000000..58badaa7
--- /dev/null
+++ b/front/app/components/features/study_area_summary/panel.py
@@ -0,0 +1,121 @@
+from dash import html
+import dash_mantine_components as dmc
+from .utils import safe_mean
+from .kpi import KPIStatGroup
+from .modal_split import ModalSplitList
+from .legend import LegendCompact
+
+
+def StudyAreaSummary(zones_gdf, visible=True, id_prefix="map", header_offset_px=80, width_px=340):
+ """Crée le panneau de résumé global d'une zone d’étude.
+
+ Ce composant affiche un résumé synthétique des indicateurs calculés pour la zone
+ d’étude sélectionnée, tels que :
+ - les temps et distances de déplacement moyens ;
+ - la répartition modale (voiture, vélo, marche, covoiturage, transport collectif) ;
+ - la légende de la carte (liée à la variable de temps de trajet moyen).
+
+ Le panneau s’affiche à droite de la carte, avec une position et une taille fixes.
+ Si `zones_gdf` est vide ou manquant, un message d’indisponibilité est affiché.
+
+ Args:
+ zones_gdf (GeoDataFrame | None): Données géographiques de la zone d’étude,
+ contenant au minimum les colonnes :
+ - `average_travel_time`
+ - `total_dist_km`
+ - `share_car`, `share_bicycle`, `share_walk`, `share_carpool`
+ - `share_public_transport`, `share_pt_walk`, `share_pt_car`, `share_pt_bicycle`
+ visible (bool, optional): Définit si le panneau est visible ou masqué.
+ Par défaut `True`.
+ id_prefix (str, optional): Préfixe d’identifiant Dash pour éviter les collisions.
+ Par défaut `"map"`.
+ header_offset_px (int, optional): Décalage vertical en pixels sous l’en-tête
+ principal de la page. Par défaut `80`.
+ width_px (int, optional): Largeur du panneau latéral (en pixels).
+ Par défaut `340`.
+
+ Returns:
+ html.Div: Conteneur principal du panneau de résumé (`div` HTML) contenant un
+ composant `dmc.Paper` avec les statistiques et graphiques de la zone.
+
+ Notes:
+ - Les moyennes sont calculées avec la fonction utilitaire `safe_mean()` pour
+ éviter les erreurs sur valeurs manquantes ou NaN.
+ - Si `zones_gdf` est vide, le contenu du panneau se limite à un texte indiquant
+ l’absence de données globales.
+ """
+ comp_id = f"{id_prefix}-study-summary"
+
+ if zones_gdf is None or getattr(zones_gdf, "empty", True):
+ content = dmc.Text(
+ "Données globales indisponibles.",
+ size="sm",
+ style={"fontStyle": "italic", "opacity": 0.8},
+ )
+ else:
+ # Calcul des moyennes sécurisées
+ avg_time = safe_mean(zones_gdf.get("average_travel_time"))
+ avg_dist = safe_mean(zones_gdf.get("total_dist_km"))
+
+ share_car = safe_mean(zones_gdf.get("share_car"))
+ share_bike = safe_mean(zones_gdf.get("share_bicycle"))
+ share_walk = safe_mean(zones_gdf.get("share_walk"))
+ share_pool = safe_mean(zones_gdf.get("share_carpool"))
+
+ share_pt = safe_mean(zones_gdf.get("share_public_transport"))
+ share_pt_walk = safe_mean(zones_gdf.get("share_pt_walk"))
+ share_pt_car = safe_mean(zones_gdf.get("share_pt_car"))
+ share_pt_bicycle = safe_mean(zones_gdf.get("share_pt_bicycle"))
+
+ # Construction du contenu principal
+ content = dmc.Stack(
+ [
+ dmc.Text("Résumé global de la zone d'étude", fw=700, size="md"),
+ dmc.Divider(),
+ KPIStatGroup(avg_time_min=avg_time, avg_dist_km=avg_dist),
+ dmc.Divider(),
+ dmc.Text("Répartition modale", fw=600, size="sm"),
+ ModalSplitList(
+ share_car=share_car,
+ share_bike=share_bike,
+ share_walk=share_walk,
+ share_carpool=share_pool,
+ share_pt=share_pt,
+ share_pt_walk=share_pt_walk,
+ share_pt_car=share_pt_car,
+ share_pt_bicycle=share_pt_bicycle,
+ ),
+ dmc.Divider(),
+ LegendCompact(zones_gdf.get("average_travel_time")),
+ ],
+ gap="md",
+ )
+
+ return html.Div(
+ id=comp_id,
+ children=dmc.Paper(
+ content,
+ withBorder=True,
+ shadow="md",
+ radius="md",
+ p="md",
+ style={
+ "width": "100%",
+ "height": "100%",
+ "overflowY": "auto",
+ "background": "#ffffffee",
+ "boxSizing": "border-box",
+ },
+ ),
+ style={
+ "display": "block" if visible else "none",
+ "position": "absolute",
+ "top": f"{header_offset_px}px",
+ "right": "0px",
+ "bottom": "0px",
+ "width": f"{width_px}px",
+ "zIndex": 1200,
+ "pointerEvents": "auto",
+ "overflow": "hidden",
+ },
+ )
diff --git a/front/app/components/features/study_area_summary/utils.py b/front/app/components/features/study_area_summary/utils.py
new file mode 100644
index 00000000..dfd5eb23
--- /dev/null
+++ b/front/app/components/features/study_area_summary/utils.py
@@ -0,0 +1,106 @@
+"""
+utils.py
+=========
+
+Module utilitaire regroupant des fonctions de formatage, de calculs statistiques
+et de génération de couleurs.
+Ces fonctions sont utilisées dans différents composants de l’application
+(panneaux de résumé, cartes, indicateurs, etc.) pour assurer une cohérence
+visuelle et numérique des données affichées.
+
+Fonctionnalités principales :
+- Formatage des nombres et pourcentages (`fmt_num`, `fmt_pct`)
+- Calculs robustes de moyennes et d’extrema (`safe_mean`, `safe_min_max`)
+- Génération de couleurs selon une rampe continue (`colorize_from_range`)
+- Conversion des couleurs RGB au format CSS (`rgb_str`)
+"""
+
+from __future__ import annotations
+from typing import Tuple
+import numpy as np
+import pandas as pd
+
+
+def fmt_num(v, nd: int = 1) -> str:
+ """Formate un nombre flottant avec un nombre fixe de décimales.
+
+ Convertit une valeur numérique en chaîne de caractères formatée,
+ arrondie à `nd` décimales. Si la conversion échoue (valeur None,
+ non numérique, etc.), renvoie `"N/A"`.
+ """
+ try:
+ return f"{round(float(v), nd):.{nd}f}"
+ except Exception:
+ return "N/A"
+
+
+def fmt_pct(v, nd: int = 1) -> str:
+ """Formate une valeur en pourcentage avec arrondi.
+
+ Multiplie la valeur par 100, puis la formate avec `nd` décimales.
+ En cas d’erreur ou de valeur invalide, renvoie `"N/A"`.
+ """
+ try:
+ return f"{round(float(v) * 100.0, nd):.{nd}f} %"
+ except Exception:
+ return "N/A"
+
+
+def safe_mean(series) -> float:
+ """Calcule la moyenne d'une série de valeurs de manière sécurisée.
+
+ Convertit la série en valeurs numériques, ignore les NaN et les
+ erreurs de conversion. Retourne `NaN` si la série est vide ou None.
+ """
+ if series is None:
+ return float("nan")
+ s = pd.to_numeric(series, errors="coerce")
+ return float(np.nanmean(s)) if s.size else float("nan")
+
+
+def safe_min_max(series) -> Tuple[float, float]:
+ """Renvoie les valeurs minimale et maximale d'une série en toute sécurité.
+
+ Ignore les valeurs non numériques, infinies ou manquantes. Retourne `(NaN, NaN)`
+ si la série est vide ou invalide.
+ """
+ if series is None:
+ return float("nan"), float("nan")
+ s = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
+ if s.empty:
+ return float("nan"), float("nan")
+ return float(s.min()), float(s.max())
+
+
+def colorize_from_range(value, vmin, vmax):
+ """Convertit une valeur numérique en couleur RGB selon une rampe de dégradé.
+
+ La rampe est la même que celle utilisée pour la carte :
+ - rouge augmente avec la valeur (`r = 255 * z`)
+ - vert diminue légèrement (`g = 64 + 128 * (1 - z)`)
+ - bleu diminue avec la valeur (`b = 255 * (1 - z)`)
+
+ Si la valeur ou les bornes sont invalides, renvoie un gris neutre `(200, 200, 200)`.
+ """
+ if value is None or pd.isna(value) or vmin is None or vmax is None or (vmax - vmin) <= 1e-9:
+ return (200, 200, 200)
+ rng = max(vmax - vmin, 1e-9)
+ z = (float(value) - vmin) / rng
+ z = max(0.0, min(1.0, z))
+ r = int(255 * z)
+ g = int(64 + 128 * (1 - z))
+ b = int(255 * (1 - z))
+ return (r, g, b)
+
+
+def rgb_str(rgb) -> str:
+ """Convertit un tuple RGB en chaîne CSS utilisable.
+
+ Args:
+ rgb (Tuple[int, int, int]): Triplet de composantes (R, G, B).
+
+ Returns:
+ str: Chaîne formatée `"rgb(r,g,b)"`.
+ """
+ r, g, b = rgb
+ return f"rgb({r},{g},{b})"
diff --git a/front/app/pages/main/callbacks.py b/front/app/pages/main/callbacks.py
new file mode 100644
index 00000000..948df99c
--- /dev/null
+++ b/front/app/pages/main/callbacks.py
@@ -0,0 +1,283 @@
+"""
+callbacks.py
+============
+
+Callbacks Dash pour l’application cartographique.
+
+Ce module :
+- synchronise les contrôles de rayon (slider ↔ number input) ;
+- reconstruit les paramètres de modes de transport à partir de l’UI ;
+- exécute le calcul de scénario et régénère la carte Deck.gl + le résumé ;
+- applique des garde-fous UX : au moins un mode actif et au moins un sous-mode TC actif.
+
+Deux stratégies de génération de carte sont supportées :
+- **Service externe** (`app.services.map_service`) si disponible ;
+- **Fallback local** via `make_deck_json` sinon.
+"""
+
+from dash import Input, Output, State, ALL, no_update, ctx
+import uuid
+import dash_mantine_components as dmc
+
+from app.components.features.study_area_summary import StudyAreaSummary
+from app.components.features.map.config import DeckOptions
+from app.services.scenario_service import get_scenario
+
+# Utilise map_service si dispo (même logique que dans ton code)
+try:
+ from app.services.map_service import get_map_deck_json_from_scn
+ USE_MAP_SERVICE = True
+except Exception:
+ from app.components.features.map.deck_factory import make_deck_json
+ USE_MAP_SERVICE = False
+
+# Mapping des libellés UI → clés internes attendues par le service de scénario
+UI_TO_INTERNAL = {
+ "À pied": "walk",
+ "A pied": "walk",
+ "Vélo": "bicycle",
+ "Voiture": "car",
+ "Covoiturage": "carpool",
+ "Transport en commun": "public_transport",
+}
+
+
+def _normalize_lau(code: str) -> str:
+ """Normalise un code INSEE/LAU au format `fr-xxxxx`.
+
+ - Si le code commence par `fr-`, il est renvoyé tel quel (en minuscules).
+ - Si le code est un entier à 5 chiffres, on préfixe `fr-`.
+ - Sinon, on renvoie un fallback (`fr-31555`).
+
+ Args:
+ code (str): Code INSEE/LAU saisi par l’utilisateur.
+
+ Returns:
+ str: Code normalisé de la forme `fr-xxxxx`.
+ """
+ s = (code or "").strip().lower()
+ if s.startswith("fr-"):
+ return s
+ if s.isdigit() and len(s) == 5:
+ return f"fr-{s}"
+ return s or "fr-31555"
+
+
+def _make_deck_json_from_scn(scn: dict) -> str:
+ """Génère la spécification Deck.gl JSON pour un scénario donné.
+
+ Utilise le service `map_service` si disponible, sinon le fallback local
+ via `make_deck_json`. Les options Deck (`zoom`, `pitch`, etc.) sont
+ instanciées avec les valeurs par défaut.
+
+ Args:
+ scn (dict): Scénario déjà calculé (incluant `zones_gdf`).
+
+ Returns:
+ str: Chaîne JSON de la configuration Deck.gl.
+ """
+ if USE_MAP_SERVICE:
+ return get_map_deck_json_from_scn(scn, DeckOptions())
+ return make_deck_json(scn, DeckOptions())
+
+
+def register_callbacks(app, MAPP: str = "map"):
+ """Enregistre l’ensemble des callbacks Dash de la page.
+
+ Callbacks enregistrés :
+ 1) Synchronisation **slider ↔ input** du rayon (km).
+ 2) **Lancement de simulation** : reconstruit `transport_modes_params`
+ depuis l’UI, calcule le scénario, régénère Deck.gl + résumé,
+ et conserve la caméra si le LAU n’a pas changé.
+ 3) **Garde-fou modes** : impose au moins un mode actif (tooltip si besoin).
+ 4) **Garde-fou sous-modes TC** : impose au moins un sous-mode actif.
+
+ Args:
+ app: Instance Dash (application).
+ MAPP (str, optional): Préfixe d’identifiants des composants carte. Par défaut `"map"`.
+ """
+
+ # -------------------- CALLBACKS --------------------
+
+ @app.callback(
+ Output(f"{MAPP}-radius-input", "value"),
+ Input(f"{MAPP}-radius-slider", "value"),
+ State(f"{MAPP}-radius-input", "value"),
+ )
+ def _sync_input_from_slider(slider_val, current_input):
+ """Répercute la valeur du slider dans l’input numérique du rayon."""
+ if slider_val is None or slider_val == current_input:
+ return no_update
+ return slider_val
+
+ @app.callback(
+ Output(f"{MAPP}-radius-slider", "value"),
+ Input(f"{MAPP}-radius-input", "value"),
+ State(f"{MAPP}-radius-slider", "value"),
+ )
+ def _sync_slider_from_input(input_val, current_slider):
+ """Répercute la valeur de l’input numérique dans le slider du rayon."""
+ if input_val is None or input_val == current_slider:
+ return no_update
+ return input_val
+
+ # Lancer la simulation — forcer le refresh du deck
+ # MAIS on conserve la caméra si le LAU n'a pas changé: on ne change pas la "key"
+ @app.callback(
+ Output(f"{MAPP}-deck-map", "data"),
+ Output(f"{MAPP}-deck-map", "key"),
+ Output(f"{MAPP}-summary-wrapper", "children"),
+ Output(f"{MAPP}-deck-memo", "data"),
+ Input(f"{MAPP}-run-btn", "n_clicks"),
+ State(f"{MAPP}-radius-input", "value"),
+ State(f"{MAPP}-lau-input", "value"),
+ State({"type": "mode-active", "index": ALL}, "checked"),
+ State({"type": "mode-active", "index": ALL}, "id"),
+ State({"type": "mode-var", "mode": ALL, "var": ALL}, "value"),
+ State({"type": "mode-var", "mode": ALL, "var": ALL}, "id"),
+ State({"type": "pt-submode", "index": ALL}, "checked"),
+ State({"type": "pt-submode", "index": ALL}, "id"),
+ State(f"{MAPP}-deck-memo", "data"), # mémo préalable
+ prevent_initial_call=True,
+ )
+ def _run_simulation(
+ n_clicks,
+ radius_val,
+ lau_val,
+ active_values,
+ active_ids,
+ vars_values,
+ vars_ids,
+ pt_checked_vals,
+ pt_checked_ids,
+ deck_memo,
+ ):
+ """Exécute la simulation et met à jour la carte + le panneau résumé.
+
+ Étapes :
+ - Normalise le LAU et le rayon.
+ - Reconstruit `transport_modes_params` à partir des cases/inputs UI.
+ - Appelle `get_scenario()` avec ces paramètres.
+ - Génère la spec Deck.gl JSON et le résumé.
+ - Conserve la caméra si le LAU n’a pas changé (via `key` mémorisée).
+
+ Returns:
+ Tuple: (deck_json, deck_key, summary_component, new_memo)
+ """
+ try:
+ r = 40.0 if radius_val is None else float(radius_val)
+ lau_norm = _normalize_lau(lau_val or "31555")
+
+ # Reconstruire transport_modes_params depuis l'UI
+ params = {}
+
+ # Actifs/inactifs
+ for aid, val in zip(active_ids or [], active_values or []):
+ label = aid["index"]
+ key = UI_TO_INTERNAL.get(label)
+ if key:
+ params.setdefault(key, {})["active"] = bool(val)
+
+ # Variables (temps, distance, constante)
+ for vid, val in zip(vars_ids or [], vars_values or []):
+ key = UI_TO_INTERNAL.get(vid["mode"])
+ if not key:
+ continue
+ p = params.setdefault(key, {"active": True})
+ vlabel = (vid["var"] or "").lower()
+ if "temps" in vlabel:
+ p["cost_of_time_eur_per_h"] = float(val or 0)
+ elif "distance" in vlabel:
+ p["cost_of_distance_eur_per_km"] = float(val or 0)
+ elif "constante" in vlabel:
+ p["cost_constant"] = float(val or 0)
+
+ # Sous-modes TC
+ if pt_checked_ids and pt_checked_vals:
+ pt_map = {"walk_pt": "pt_walk", "car_pt": "pt_car", "bicycle_pt": "pt_bicycle"}
+ pt_cfg = params.setdefault(
+ "public_transport",
+ {"active": params.get("public_transport", {}).get("active", True)},
+ )
+ for pid, checked in zip(pt_checked_ids, pt_checked_vals):
+ alias = pt_map.get(pid["index"])
+ if alias:
+ pt_cfg[alias] = bool(checked)
+
+ # Calcul scénario
+ scn = get_scenario(local_admin_unit_id=lau_norm, radius=r, transport_modes_params=params)
+ deck_json = _make_deck_json_from_scn(scn)
+ summary = StudyAreaSummary(scn["zones_gdf"], visible=True, id_prefix=MAPP)
+
+ # Conserver la caméra si le LAU ne change pas
+ prev_key = (deck_memo or {}).get("key") or str(uuid.uuid4())
+ prev_lau = (deck_memo or {}).get("lau")
+ new_key = prev_key if prev_lau == lau_norm else str(uuid.uuid4())
+ new_memo = {"key": new_key, "lau": lau_norm}
+
+ return deck_json, new_key, summary, new_memo
+
+ except Exception as e:
+ err = dmc.Alert(
+ f"Erreur pendant la simulation : {e}",
+ color="red",
+ variant="filled",
+ radius="md",
+ )
+ return no_update, no_update, err, no_update
+
+ # Forcer au moins un mode actif (tooltips)
+ @app.callback(
+ Output({"type": "mode-active", "index": ALL}, "checked"),
+ Output({"type": "mode-tip", "index": ALL}, "opened"),
+ Input({"type": "mode-active", "index": ALL}, "checked"),
+ State({"type": "mode-active", "index": ALL}, "id"),
+ prevent_initial_call=True,
+ )
+ def _enforce_one_mode(checked_list, ids):
+ """Empêche la désactivation simultanée de tous les modes.
+
+ Si l’utilisateur tente de décocher le dernier mode actif, on le réactive
+ et on affiche un tooltip explicatif uniquement sur ce mode.
+ """
+ if not checked_list or not ids:
+ return no_update, no_update
+ n_checked = sum(bool(v) for v in checked_list)
+ triggered = ctx.triggered_id
+ if n_checked == 0 and triggered is not None:
+ new_checked, new_opened = [], []
+ for id_, val in zip(ids, checked_list):
+ if id_ == triggered:
+ new_checked.append(True)
+ new_opened.append(True)
+ else:
+ new_checked.append(bool(val))
+ new_opened.append(False)
+ return new_checked, new_opened
+ return [bool(v) for v in checked_list], [False] * len(ids)
+
+ # Forcer au moins un sous-mode PT actif (tooltips)
+ @app.callback(
+ Output({"type": "pt-submode", "index": ALL}, "checked"),
+ Output({"type": "pt-tip", "index": ALL}, "opened"),
+ Input({"type": "pt-submode", "index": ALL}, "checked"),
+ State({"type": "pt-submode", "index": ALL}, "id"),
+ prevent_initial_call=True,
+ )
+ def _enforce_one_pt_submode(checked_list, ids):
+ """Empêche la désactivation simultanée de tous les sous-modes TC."""
+ if not checked_list or not ids:
+ return no_update, no_update
+ n_checked = sum(bool(v) for v in checked_list)
+ triggered = ctx.triggered_id
+ if n_checked == 0 and triggered is not None:
+ new_checked, new_opened = [], []
+ for id_, val in zip(ids, checked_list):
+ if id_ == triggered:
+ new_checked.append(True)
+ new_opened.append(True)
+ else:
+ new_checked.append(bool(val))
+ new_opened.append(False)
+ return new_checked, new_opened
+ return [bool(v) for v in checked_list], [False] * len(ids)
diff --git a/front/app/pages/main/main.py b/front/app/pages/main/main.py
index 66535790..edf0cfc9 100644
--- a/front/app/pages/main/main.py
+++ b/front/app/pages/main/main.py
@@ -1,42 +1,104 @@
+"""
+main.py
+=======
+
+Point d’entrée de l’application Dash.
+
+- Construit et configure l’UI globale (entête, carte, panneau de résumé, pied de page).
+- Initialise l’état applicatif (Store pour la carte, options Deck.gl).
+- Enregistre les callbacks via `register_callbacks`.
+- Expose `app` et lance le serveur en exécution directe.
+
+Notes:
+ - Les assets statiques (CSS, images, etc.) sont servis depuis `ASSETS_PATH`.
+ - Le préfixe d’identifiants de la carte est `MAPP = "map"`.
+"""
+
from pathlib import Path
-from dash import Dash
+import os
+import uuid
+from dash import Dash, html, no_update, dcc
+from dash import Input, Output, State, ALL, ctx
import dash_mantine_components as dmc
+
from app.components.layout.header.header import Header
-from app.components.features.map.map import Map
+from app.components.features.map import Map
from app.components.layout.footer.footer import Footer
-
+from app.components.features.study_area_summary import StudyAreaSummary
+from app.components.features.map.config import DeckOptions
+from app.services.scenario_service import get_scenario
+from app.pages.main.callbacks import register_callbacks
ASSETS_PATH = Path(__file__).resolve().parents[3] / "assets"
-HEADER_HEIGHT = 60
-
-
-
-app = Dash(
- __name__,
- suppress_callback_exceptions=True,
- assets_folder=str(ASSETS_PATH),
- assets_url_path="/assets",
-)
-
-app.layout = dmc.MantineProvider(
- dmc.AppShell(
- children=[
- Header("MOBILITY"),
- dmc.AppShellMain(
- Map(),
- style={
- "height": f"calc(100vh - {HEADER_HEIGHT}px)",
- "padding": 0,
- "margin": 0,
- "overflow": "hidden",
- },
- ),
- Footer(),
- ],
- padding=0,
- styles={"main": {"padding": 0}},
+MAPP = "map"
+
+
+def create_app() -> Dash:
+ """Crée et configure l'application Dash principale.
+
+ Assemble la structure de page avec Mantine AppShell :
+ - `Header` : entête de l'application.
+ - `dcc.Store` : état mémorisé pour la carte (`{key, lau}`).
+ - `AppShellMain` : contenu principal avec la vue `Map`.
+ - `Footer` : pied de page.
+ Enregistre ensuite l'ensemble des callbacks via `register_callbacks`.
+
+ Returns:
+ Dash: Instance configurée de l'application Dash.
+ """
+ app = Dash(
+ __name__,
+ suppress_callback_exceptions=True,
+ assets_folder=str(ASSETS_PATH),
+ assets_url_path="/assets",
+ )
+
+ app.layout = dmc.MantineProvider(
+ dmc.AppShell(
+ children=[
+ Header("MOBILITY"),
+ dcc.Store(id=f"{MAPP}-deck-memo", data={"key": str(uuid.uuid4()), "lau": "fr-31555"}),
+ dmc.AppShellMain(
+ html.Div(
+ Map(id_prefix=MAPP),
+ style={
+ "height": "100%",
+ "width": "100%",
+ "position": "relative",
+ "overflow": "hidden",
+ "margin": 0,
+ "padding": 0,
+ },
+ ),
+ style={
+ "flex": "1 1 auto",
+ "minHeight": 0,
+ "padding": 0,
+ "margin": 0,
+ "overflow": "hidden",
+ },
+ ),
+ html.Div(Footer(), style={"flexShrink": "0"}),
+ ],
+ padding=0,
+ styles={
+ "root": {"height": "100vh", "overflow": "hidden"},
+ "main": {"padding": 0, "margin": 0, "overflow": "hidden"},
+ },
+ )
)
-)
-if __name__ == "__main__":
- app.run(debug=True, dev_tools_ui=False)
+ # <<< Enregistre tous les callbacks déplacés (navigation, interactions carte/UI, etc.)
+ register_callbacks(app, MAPP=MAPP)
+
+ return app
+
+
+# Application globale (utile pour gunicorn / uvicorn)
+app = create_app()
+
+if __name__ == "__main__": # pragma: no cover
+ # Lance le serveur de développement local.
+ # PORT peut être surchargé via la variable d'environnement PORT.
+ port = int(os.environ.get("PORT", "8050"))
+ app.run(debug=True, dev_tools_ui=False, port=port, host="127.0.0.1")
diff --git a/front/app/scenario/scenario_001_from_docs.py b/front/app/scenario/scenario_001_from_docs.py
deleted file mode 100644
index 0fefa618..00000000
--- a/front/app/scenario/scenario_001_from_docs.py
+++ /dev/null
@@ -1,274 +0,0 @@
-# app/scenario/scenario_001_from_docs.py
-from __future__ import annotations
-import os
-import pandas as pd
-import geopandas as gpd
-import numpy as np
-from shapely.geometry import Point
-
-
-def _to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
- if gdf.crs is None:
- return gdf.set_crs(4326, allow_override=True)
- try:
- epsg = gdf.crs.to_epsg()
- except Exception:
- epsg = None
- return gdf if epsg == 4326 else gdf.to_crs(4326)
-
-
-def _fallback_scenario() -> dict:
- """Scénario minimal de secours (Paris–Lyon)."""
- paris = (2.3522, 48.8566)
- lyon = (4.8357, 45.7640)
-
- pts = gpd.GeoDataFrame(
- {"transport_zone_id": ["paris", "lyon"], "geometry": [Point(*paris), Point(*lyon)]},
- geometry="geometry", crs=4326
- )
- zones = pts.to_crs(3857)
- zones["geometry"] = zones.geometry.buffer(5000) # 5 km
- zones = zones.to_crs(4326)
- # Indicateurs d'exemple (minutes, km/personne/jour)
- zones["average_travel_time"] = [18.0, 25.0]
- zones["total_dist_km"] = [15.0, 22.0]
- zones["share_car"] = [0.6, 0.55]
- zones["share_bicycle"] = [0.25, 0.30]
- zones["share_walk"] = [0.15, 0.15]
- zones["local_admin_unit_id"] = ["N/A", "N/A"]
-
- flows_df = pd.DataFrame({"from": ["lyon"], "to": ["paris"], "flow_volume": [120.0]})
-
- return {
- "zones_gdf": _to_wgs84(zones),
- "flows_df": flows_df,
- "zones_lookup": _to_wgs84(pts),
- }
-
-
-def load_scenario() -> dict:
- """
- Charge un scénario de mobilité (Toulouse = fr-31555) et calcule:
- - average_travel_time (minutes)
- - total_dist_km (km/personne/jour)
- - parts modales share_car / share_bicycle / share_walk
- Bascule sur un fallback si la lib échoue.
- """
- try:
- import mobility
- from mobility.path_routing_parameters import PathRoutingParameters
-
- mobility.set_params(debug=True, r_packages_download_method="wininet")
-
- # Patch **instanciation** : fournir cache_path si attendu (certaines versions)
- def _safe_instantiate(cls, *args, **kwargs):
- try:
- return cls(*args, **kwargs)
- except TypeError as e:
- if "takes 2 positional arguments but 3 were given" in str(e):
- raise
- elif "missing 1 required positional argument: 'cache_path'" in str(e):
- return cls(*args, cache_path=None, **kwargs)
- else:
- raise
-
- # --- Création des assets (Toulouse) ---
- transport_zones = _safe_instantiate(
- mobility.TransportZones,
- local_admin_unit_id="fr-31555", # Toulouse
- radius=40,
- level_of_detail=0,
- )
-
- # Modes
- car = _safe_instantiate(
- mobility.CarMode,
- transport_zones=transport_zones,
- generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.1),
- )
- bicycle = _safe_instantiate(
- mobility.BicycleMode,
- transport_zones=transport_zones,
- generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.0),
- )
-
- # 🚶 Marche : on l'active si la classe existe, avec une fenêtre plus permissive
- walk = None
- for cls_name in ("WalkMode", "PedestrianMode", "WalkingMode", "Pedestrian"):
- if walk is None and hasattr(mobility, cls_name):
- walk_params = PathRoutingParameters(
- # La lib sort time en HEURES -> autorise 2h de marche max
- filter_max_time=2.0,
- # Vitesse 5 km/h (marche urbaine)
- filter_max_speed=5.0
- )
- walk = _safe_instantiate(
- getattr(mobility, cls_name),
- transport_zones=transport_zones,
- routing_parameters=walk_params,
- generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.0),
- )
-
- modes = [m for m in (car, bicycle, walk) if m is not None]
-
- work_choice_model = _safe_instantiate(
- mobility.WorkDestinationChoiceModel,
- transport_zones,
- modes=modes,
- )
- mode_choice_model = _safe_instantiate(
- mobility.TransportModeChoiceModel,
- destination_choice_model=work_choice_model,
- )
-
- # Résultats des modèles
- work_choice_model.get()
- mode_df = mode_choice_model.get() # colonnes attendues: from, to, mode, prob
- comparison = work_choice_model.get_comparison()
-
- # --- Harmoniser les labels de mode (canonisation) ---
- def _canon_mode(label: str) -> str:
- s = str(label).strip().lower()
- if s in {"bike", "bicycle", "velo", "cycling"}:
- return "bicycle"
- if s in {"walk", "walking", "foot", "pedestrian", "pedestrianmode"}:
- return "walk"
- if s in {"car", "auto", "driving", "voiture"}:
- return "car"
- return s
-
- if "mode" in mode_df.columns:
- mode_df["mode"] = mode_df["mode"].map(_canon_mode)
-
- # ---- Coûts de déplacement par mode ----
- def _get_costs(m, label):
- df = m.travel_costs.get().copy()
- df["mode"] = label
- return df
-
- costs_list = [
- _get_costs(car, "car"),
- _get_costs(bicycle, "bicycle"),
- ]
- if walk is not None:
- costs_list.append(_get_costs(walk, "walk"))
-
- travel_costs = pd.concat(costs_list, ignore_index=True)
- travel_costs["mode"] = travel_costs["mode"].map(_canon_mode)
-
- # --- Normaliser les unités ---
- # 1) TEMPS : la lib renvoie des HEURES -> convertir en MINUTES
- if "time" in travel_costs.columns:
- t_hours = pd.to_numeric(travel_costs["time"], errors="coerce")
- travel_costs["time_min"] = t_hours * 60.0
- else:
- travel_costs["time_min"] = np.nan
-
- # 2) DISTANCE :
- # - si max > 200 -> probablement des mètres -> /1000 en km
- # - sinon c'est déjà des km
- if "distance" in travel_costs.columns:
- d_raw = pd.to_numeric(travel_costs["distance"], errors="coerce")
- d_max = d_raw.replace([np.inf, -np.inf], np.nan).max()
- if pd.notna(d_max) and d_max > 200:
- travel_costs["dist_km"] = d_raw / 1000.0
- else:
- travel_costs["dist_km"] = d_raw
- else:
- travel_costs["dist_km"] = np.nan
-
- # ---- Jointures d'identifiants zones ----
- ids = transport_zones.get()[["local_admin_unit_id", "transport_zone_id"]].copy()
-
- ori_dest_counts = (
- comparison.merge(ids, left_on="local_admin_unit_id_from", right_on="local_admin_unit_id", how="left")
- .merge(ids, left_on="local_admin_unit_id_to", right_on="local_admin_unit_id", how="left")
- [["transport_zone_id_x", "transport_zone_id_y", "flow_volume"]]
- .rename(columns={"transport_zone_id_x": "from", "transport_zone_id_y": "to"})
- )
- ori_dest_counts["flow_volume"] = pd.to_numeric(ori_dest_counts["flow_volume"], errors="coerce").fillna(0.0)
- ori_dest_counts = ori_dest_counts[ori_dest_counts["flow_volume"] > 0]
-
- # Parts modales OD (pondération par proba)
- modal_shares = mode_df.merge(ori_dest_counts, on=["from", "to"], how="inner")
- modal_shares["prob"] = pd.to_numeric(modal_shares["prob"], errors="coerce").fillna(0.0)
- modal_shares["flow_volume"] *= modal_shares["prob"]
-
- # Joindre les coûts par mode (from, to, mode)
- costs_cols = ["from", "to", "mode", "time_min", "dist_km"]
- available = [c for c in costs_cols if c in travel_costs.columns]
- travel_costs_norm = travel_costs[available].copy()
-
- od_mode = modal_shares.merge(travel_costs_norm, on=["from", "to", "mode"], how="left")
- od_mode["time_min"] = pd.to_numeric(od_mode.get("time_min", np.nan), errors="coerce")
- od_mode["dist_km"] = pd.to_numeric(od_mode.get("dist_km", np.nan), errors="coerce")
-
- # Agrégats par origine ("from")
- den = od_mode.groupby("from", as_index=True)["flow_volume"].sum().replace(0, np.nan)
-
- # Temps moyen (minutes) par trajet
- num_time = (od_mode["time_min"] * od_mode["flow_volume"]).groupby(od_mode["from"]).sum(min_count=1)
- avg_time_min = (num_time / den).rename("average_travel_time")
-
- # Distance totale par personne et par jour (sans fréquence explicite -> distance moyenne pondérée)
- num_dist = (od_mode["dist_km"] * od_mode["flow_volume"]).groupby(od_mode["from"]).sum(min_count=1)
- per_person_dist_km = (num_dist / den).rename("total_dist_km")
-
- # Parts modales par origine (car / bicycle / walk)
- mode_flow_by_from = od_mode.pivot_table(
- index="from", columns="mode", values="flow_volume", aggfunc="sum", fill_value=0.0
- )
- for col in ("car", "bicycle", "walk"):
- if col not in mode_flow_by_from.columns:
- mode_flow_by_from[col] = 0.0
-
- share_car = (mode_flow_by_from["car"] / den).rename("share_car")
- share_bicycle = (mode_flow_by_from["bicycle"] / den).rename("share_bicycle")
- share_walk = (mode_flow_by_from["walk"] / den).rename("share_walk")
-
- # ---- Construction du GeoDataFrame des zones ----
- zones = transport_zones.get()[["transport_zone_id", "geometry", "local_admin_unit_id"]].copy()
- zones_gdf = gpd.GeoDataFrame(zones, geometry="geometry")
-
- agg = pd.concat(
- [avg_time_min, per_person_dist_km, share_car, share_bicycle, share_walk],
- axis=1
- ).reset_index().rename(columns={"from": "transport_zone_id"})
-
- zones_gdf = zones_gdf.merge(agg, on="transport_zone_id", how="left")
- zones_gdf = _to_wgs84(zones_gdf)
-
- zones_lookup = gpd.GeoDataFrame(zones[["transport_zone_id", "geometry"]], geometry="geometry", crs=zones_gdf.crs)
- flows_df = ori_dest_counts.groupby(["from", "to"], as_index=False)["flow_volume"].sum()
-
- # Logs utiles (désactiver si trop verbeux)
- try:
- md_modes = sorted(pd.unique(mode_df["mode"]).tolist())
- tc_modes = sorted(pd.unique(travel_costs["mode"]).tolist())
- print("Modes (mode_df):", md_modes)
- print("Modes (travel_costs):", tc_modes)
- print("time_min (min) – min/med/max:",
- np.nanmin(travel_costs["time_min"]),
- np.nanmedian(travel_costs["time_min"]),
- np.nanmax(travel_costs["time_min"]))
- print("dist_km (km) – min/med/max:",
- np.nanmin(travel_costs["dist_km"]),
- np.nanmedian(travel_costs["dist_km"]),
- np.nanmax(travel_costs["dist_km"]))
- except Exception:
- pass
-
- print(
- f"SCENARIO_META: source=mobility zones={len(zones_gdf)} "
- f"flows={len(flows_df)} time_unit=minutes distance_unit=kilometers"
- )
-
- return {
- "zones_gdf": zones_gdf, # average_travel_time, total_dist_km, share_car, share_bicycle, share_walk, local_admin_unit_id
- "flows_df": flows_df,
- "zones_lookup": _to_wgs84(zones_lookup),
- }
-
- except Exception as e:
- print(f"[Fallback used due to error: {e}]")
- return _fallback_scenario()
diff --git a/file_upstream.py b/front/app/services/__init__.py
similarity index 100%
rename from file_upstream.py
rename to front/app/services/__init__.py
diff --git a/front/app/services/map_service.py b/front/app/services/map_service.py
new file mode 100644
index 00000000..099366ca
--- /dev/null
+++ b/front/app/services/map_service.py
@@ -0,0 +1,89 @@
+"""
+map_service.py
+==============
+
+Service d’intégration entre le backend de scénario (`get_scenario`) et
+les composants carte (Deck.gl) du front.
+
+Rôles principaux :
+- Récupérer un scénario via `get_scenario()` ;
+- Construire la spécification Deck.gl JSON via `make_deck_json` ;
+- Exposer les données géographiques des zones pour la carte (`get_map_zones_gdf`).
+
+Un point d’extension `_scenario_snapshot_key()` est prévu pour, à terme,
+brancher une logique de versionnement ou d’horodatage des scénarios et
+affiner le cache si nécessaire.
+"""
+
+from __future__ import annotations
+from functools import lru_cache
+
+from front.app.services.scenario_service import get_scenario
+from app.components.features.map.config import DeckOptions
+from app.components.features.map.deck_factory import make_deck_json
+
+
+@lru_cache(maxsize=8)
+def _scenario_snapshot_key() -> int:
+ """Clé de cache grossière pour un futur versionnement des scénarios.
+
+ Pour l’instant, renvoie toujours `0`, ce qui revient à ne pas exploiter
+ finement le cache. Si `get_scenario()` expose un identifiant de version,
+ un hash ou un horodatage, on pourra l’utiliser ici pour invalider ou
+ différencier les résultats en fonction de l’évolution des données.
+
+ Returns:
+ int: Identifiant de snapshot de scénario (actuellement toujours `0`).
+ """
+ return 0
+
+
+def get_map_deck_json_from_scn(scn: dict, opts: DeckOptions | None = None) -> str:
+ """Construit la spec Deck.gl JSON à partir d’un scénario déjà calculé.
+
+ Ce helper est utile lorsque le scénario `scn` a été obtenu en amont
+ (par exemple dans un service ou un callback) et que l’on souhaite
+ simplement générer la configuration de carte correspondante.
+
+ Args:
+ scn (dict): Scénario contenant au minimum `zones_gdf`.
+ opts (DeckOptions | None, optional): Options d’affichage de la carte
+ (zoom, pitch, style, etc.). Si `None`, utilise `DeckOptions()`.
+
+ Returns:
+ str: Spécification Deck.gl sérialisée au format JSON.
+ """
+ opts = opts or DeckOptions()
+ return make_deck_json(scn, opts)
+
+
+def get_map_deck_json(id_prefix: str, opts: DeckOptions) -> str:
+ """Construit la spec Deck.gl JSON en récupérant un scénario via `get_scenario()`.
+
+ Le paramètre `id_prefix` est présent pour homogénéité avec d’autres couches
+ de l’application, mais n’est pas utilisé directement ici. À terme, il pourrait
+ servir si la config de carte dépend de plusieurs instances ou contextes.
+
+ Args:
+ id_prefix (str): Préfixe d’identifiants lié à la carte (non utilisé ici).
+ opts (DeckOptions): Options d’affichage Deck.gl (zoom, pitch, style, etc.).
+
+ Returns:
+ str: Spécification Deck.gl sérialisée au format JSON.
+ """
+ # Éventuellement invalider le cache selon _scenario_snapshot_key() plus tard.
+ scn = get_scenario()
+ return make_deck_json(scn, opts)
+
+
+def get_map_zones_gdf():
+ """Retourne le GeoDataFrame des zones issu du scénario courant.
+
+ Récupère un scénario via `get_scenario()` et renvoie le champ `zones_gdf`,
+ utilisé comme base pour les couches cartographiques et les résumés.
+
+ Returns:
+ geopandas.GeoDataFrame: Données géographiques des zones d’étude.
+ """
+ scn = get_scenario()
+ return scn["zones_gdf"]
diff --git a/front/app/services/scenario_service.py b/front/app/services/scenario_service.py
new file mode 100644
index 00000000..e6cfbd54
--- /dev/null
+++ b/front/app/services/scenario_service.py
@@ -0,0 +1,320 @@
+"""
+scenario_service.py
+===================
+
+Service de construction de scénarios de mobilité (zones, parts modales, indicateurs).
+
+Principes :
+- Tente d’utiliser le module externe **`mobility`** pour générer des zones réalistes.
+- Fournit un **fallback** déterministe Toulouse–Blagnac si `mobility` est indisponible.
+- Crée systématiquement toutes les colonnes de parts (voiture, vélo, marche, covoiturage,
+ transports en commun + sous-modes TC).
+- Renormalise les parts sur les **modes actifs uniquement**.
+- Recalcule un **temps moyen de trajet** sensible aux variables de coût par mode.
+- Met à disposition un **cache LRU** pour les scénarios sans paramètres de modes.
+
+Sortie principale (dict):
+ - `zones_gdf` (GeoDataFrame, WGS84): zones avec géométries et indicateurs.
+ - `flows_df` (DataFrame): tableau des flux (vide par défaut).
+ - `zones_lookup` (GeoDataFrame, WGS84): points de référence des zones.
+"""
+
+from __future__ import annotations
+from functools import lru_cache
+from typing import Dict, Any, Tuple
+import pandas as pd
+import geopandas as gpd
+import numpy as np
+from shapely.geometry import Point
+
+# ------------------------------------------------------------
+# Helpers & fallback
+# ------------------------------------------------------------
+def _to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
+ """Assure que le GeoDataFrame est en WGS84 (EPSG:4326).
+
+ - Si le CRS est absent, le définit à 4326 (allow_override=True).
+ - Si le CRS n’est pas 4326, reprojette en 4326.
+ """
+ if gdf.crs is None:
+ return gdf.set_crs(4326, allow_override=True)
+ try:
+ epsg = gdf.crs.to_epsg()
+ except Exception:
+ epsg = None
+ return gdf if epsg == 4326 else gdf.to_crs(4326)
+
+
+def _fallback_scenario() -> Dict[str, Any]:
+ """Scénario de secours (Toulouse–Blagnac) avec toutes les colonnes de parts (y compris TC).
+
+ Construit deux buffers de 5 km autour de Toulouse et Blagnac, assigne des parts
+ modales plausibles, normalise, et retourne un dict {zones_gdf, flows_df, zones_lookup}.
+ """
+ toulouse = (1.4442, 43.6047)
+ blagnac = (1.3903, 43.6350)
+
+ pts = gpd.GeoDataFrame(
+ {"transport_zone_id": ["toulouse", "blagnac"], "geometry": [Point(*toulouse), Point(*blagnac)]},
+ geometry="geometry",
+ crs=4326,
+ )
+
+ zones = pts.to_crs(3857)
+ zones["geometry"] = zones.geometry.buffer(5000) # 5 km
+ zones = zones.to_crs(4326)
+
+ zones["average_travel_time"] = [18.0, 25.0]
+ zones["total_dist_km"] = [15.0, 22.0]
+
+ # parts modales "plausibles"
+ car_tlse, bike_tlse, walk_tlse = 0.55, 0.19, 0.16
+ ptw_tlse, ptc_tlse, ptb_tlse = 0.06, 0.03, 0.02 # sous-modes TC
+ carpool_tlse = 0.05
+
+ car_blg, bike_blg, walk_blg = 0.50, 0.20, 0.15
+ ptw_blg, ptc_blg, ptb_blg = 0.08, 0.04, 0.03
+ carpool_blg = 0.00
+
+ zones["share_car"] = [car_tlse, car_blg]
+ zones["share_bicycle"] = [bike_tlse, bike_blg]
+ zones["share_walk"] = [walk_tlse, walk_blg]
+ zones["share_carpool"] = [carpool_tlse, carpool_blg]
+
+ zones["share_pt_walk"] = [ptw_tlse, ptw_blg]
+ zones["share_pt_car"] = [ptc_tlse, ptc_blg]
+ zones["share_pt_bicycle"] = [ptb_tlse, ptb_blg]
+ zones["share_public_transport"] = zones[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1)
+
+ # normalisation pour s’assurer que la somme = 1
+ cols_all = [
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle"
+ ]
+ total = zones[cols_all].sum(axis=1)
+ zones[cols_all] = zones[cols_all].div(total.replace(0, np.nan), axis=0).fillna(0)
+ zones["share_public_transport"] = zones[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1)
+
+ zones["local_admin_unit_id"] = ["fr-31555", "fr-31069"]
+
+ empty_flows = pd.DataFrame(columns=["from", "to", "flow_volume"])
+ return {"zones_gdf": _to_wgs84(zones), "flows_df": empty_flows, "zones_lookup": _to_wgs84(pts)}
+
+
+def _normalize_lau_code(code: str) -> str:
+ """Normalise un code INSEE/LAU au format `fr-xxxxx` si nécessaire."""
+ s = str(code).strip().lower()
+ if s.startswith("fr-"):
+ return s
+ if s.isdigit() and len(s) == 5:
+ return f"fr-{s}"
+ return s
+
+
+# ------------------------------------------------------------
+# Param helpers
+# ------------------------------------------------------------
+def _safe_cost_of_time(v_per_hour: float):
+ """Objet léger pour compatibilité (valeur du temps en €/h)."""
+ # On garde la présence de cette fonction pour compatibilité,
+ # mais on n’instancie pas de modèles lourds ici.
+ class _COT:
+ def __init__(self, v): self.value_per_hour = float(v)
+ return _COT(v_per_hour)
+
+
+def _extract_vars(d: Dict[str, Any], defaults: Dict[str, float]) -> Dict[str, float]:
+ """Récupère cost_constant / cost_of_time_eur_per_h / cost_of_distance_eur_per_km avec défauts."""
+ return {
+ "cost_constant": float((d or {}).get("cost_constant", defaults["cost_constant"])),
+ "cost_of_time_eur_per_h": float((d or {}).get("cost_of_time_eur_per_h", defaults["cost_of_time_eur_per_h"])),
+ "cost_of_distance_eur_per_km": float((d or {}).get("cost_of_distance_eur_per_km", defaults["cost_of_distance_eur_per_km"])),
+ }
+
+
+def _mode_cost_to_weight(vars_: Dict[str, float], base_minutes: float) -> float:
+ """Convertit des variables de coût d’un mode en un poids-temps synthétique (minutes).
+
+ Plus les coûts sont élevés, plus le "poids" est haut (→ augmente average_travel_time si
+ la part du mode est forte). Transformation simple, stable et déterministe.
+ """
+ cc = vars_["cost_constant"] # €
+ cot = vars_["cost_of_time_eur_per_h"] # €/h
+ cod = vars_["cost_of_distance_eur_per_km"] # €/km
+ return (
+ base_minutes
+ + 0.6 * (cot) # €/h → ~impact direct
+ + 4.0 * (cod) # €/km → faible
+ + 0.8 * (cc) # €
+ )
+
+
+# ------------------------------------------------------------
+# Core computation (robuste aux modes manquants)
+# ------------------------------------------------------------
+def _compute_scenario(
+ local_admin_unit_id: str = "31555",
+ radius: float = 40.0,
+ transport_modes_params: Dict[str, Any] | None = None,
+) -> Dict[str, Any]:
+ """Calcule un scénario, remplit les parts des modes actifs, renormalise et dérive les indicateurs."""
+ try:
+ import mobility
+ except Exception as e:
+ print(f"[SCENARIO] fallback (mobility indisponible): {e}")
+ return _fallback_scenario()
+
+ p = transport_modes_params or {}
+ # états d’activation des modes principaux
+ active = {
+ "car": bool(p.get("car", {}).get("active", True)),
+ "bicycle": bool(p.get("bicycle", {}).get("active", True)),
+ "walk": bool(p.get("walk", {}).get("active", True)),
+ "carpool": bool(p.get("carpool", {}).get("active", True)),
+ "public_transport": bool(p.get("public_transport", {}).get("active", True)),
+ }
+ # états des sous-modes TC
+ pt_sub = {
+ "walk_pt": bool((p.get("public_transport", {}) or {}).get("pt_walk", True)),
+ "car_pt": bool((p.get("public_transport", {}) or {}).get("pt_car", True)),
+ "bicycle_pt": bool((p.get("public_transport", {}) or {}).get("pt_bicycle", True)),
+ }
+
+ # Variables des modes (avec défauts souhaités : 12€/h ; 0.01€/km ; 1€)
+ defaults = {"cost_constant": 1.0, "cost_of_time_eur_per_h": 12.0, "cost_of_distance_eur_per_km": 0.01}
+ vars_car = _extract_vars(p.get("car"), defaults)
+ vars_bicycle = _extract_vars(p.get("bicycle"), defaults)
+ vars_walk = _extract_vars(p.get("walk"), defaults)
+ vars_carpool = _extract_vars(p.get("carpool"), defaults)
+ vars_pt = _extract_vars(p.get("public_transport"), defaults) # appliqué au bloc TC
+
+ # Zones issues de mobility (géométrie réaliste) — sans lancer de modèles
+ lau_norm = _normalize_lau_code(local_admin_unit_id or "31555")
+ mobility.set_params(debug=True, r_packages_download_method="wininet")
+ tz = mobility.TransportZones(local_admin_unit_id=lau_norm, radius=float(radius), level_of_detail=0)
+
+ zones = tz.get()[["transport_zone_id", "geometry", "local_admin_unit_id"]].copy()
+ zones_gdf = gpd.GeoDataFrame(zones, geometry="geometry")
+ n = len(zones_gdf)
+
+ # --- Initialisation TOUTES parts à 0
+ for col in [
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle", "share_public_transport"
+ ]:
+ zones_gdf[col] = 0.0
+
+ # --- Assigner des parts uniquement pour ce qui est actif (RNG déterministe)
+ rng = np.random.default_rng(42)
+ if active["car"]:
+ zones_gdf["share_car"] = rng.uniform(0.25, 0.65, n)
+ if active["bicycle"]:
+ zones_gdf["share_bicycle"] = rng.uniform(0.05, 0.25, n)
+ if active["walk"]:
+ zones_gdf["share_walk"] = rng.uniform(0.05, 0.30, n)
+ if active["carpool"]:
+ zones_gdf["share_carpool"] = rng.uniform(0.03, 0.20, n)
+
+ if active["public_transport"]:
+ if pt_sub["walk_pt"]:
+ zones_gdf["share_pt_walk"] = rng.uniform(0.03, 0.15, n)
+ if pt_sub["car_pt"]:
+ zones_gdf["share_pt_car"] = rng.uniform(0.02, 0.12, n)
+ if pt_sub["bicycle_pt"]:
+ zones_gdf["share_pt_bicycle"] = rng.uniform(0.01, 0.08, n)
+ zones_gdf["share_public_transport"] = zones_gdf[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1)
+
+ # --- Renormalisation : uniquement sur les colonnes présentes/actives
+ cols_all = [
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle"
+ ]
+ active_cols = []
+ if active["car"]: active_cols.append("share_car")
+ if active["bicycle"]: active_cols.append("share_bicycle")
+ if active["walk"]: active_cols.append("share_walk")
+ if active["carpool"]: active_cols.append("share_carpool")
+ if active["public_transport"] and pt_sub["walk_pt"]: active_cols.append("share_pt_walk")
+ if active["public_transport"] and pt_sub["car_pt"]: active_cols.append("share_pt_car")
+ if active["public_transport"] and pt_sub["bicycle_pt"]: active_cols.append("share_pt_bicycle")
+
+ if not active_cols:
+ # Rien d'actif → fallback
+ return _fallback_scenario()
+
+ total = zones_gdf[active_cols].sum(axis=1).replace(0, np.nan)
+ for col in cols_all:
+ if col in zones_gdf.columns:
+ zones_gdf[col] = zones_gdf[col] / total
+ zones_gdf = zones_gdf.fillna(0.0)
+ zones_gdf["share_public_transport"] = zones_gdf[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1)
+
+ # --- Recalcul average_travel_time sensible aux variables (Option B)
+ base_minutes = {
+ "car": 20.0, "bicycle": 15.0, "walk": 25.0, "carpool": 18.0, "public_transport": 22.0
+ }
+ W = {
+ "car": _mode_cost_to_weight(vars_car, base_minutes["car"]),
+ "bicycle": _mode_cost_to_weight(vars_bicycle, base_minutes["bicycle"]),
+ "walk": _mode_cost_to_weight(vars_walk, base_minutes["walk"]),
+ "carpool": _mode_cost_to_weight(vars_carpool, base_minutes["carpool"]),
+ "public_transport": _mode_cost_to_weight(vars_pt, base_minutes["public_transport"]),
+ }
+ zones_gdf["average_travel_time"] = (
+ zones_gdf["share_car"] * W["car"]
+ + zones_gdf["share_bicycle"] * W["bicycle"]
+ + zones_gdf["share_walk"] * W["walk"]
+ + zones_gdf["share_carpool"] * W["carpool"]
+ + zones_gdf["share_public_transport"] * W["public_transport"]
+ )
+
+ # --- Autres indicateurs synthétiques (déterministes et sans RNG)
+ # Distance "typique" proportionnelle à la racine de la surface (km).
+ zones_gdf["total_dist_km"] = zones_gdf.geometry.area ** 0.5 / 1000
+
+ # Types cohérents & WGS84
+ zones_gdf["transport_zone_id"] = zones_gdf["transport_zone_id"].astype(str)
+ zones_lookup = gpd.GeoDataFrame(
+ zones[["transport_zone_id", "geometry"]].astype({"transport_zone_id": str}),
+ geometry="geometry",
+ crs=zones_gdf.crs,
+ )
+
+ return {
+ "zones_gdf": _to_wgs84(zones_gdf),
+ "flows_df": pd.DataFrame(columns=["from", "to", "flow_volume"]),
+ "zones_lookup": _to_wgs84(zones_lookup),
+ }
+
+
+# ------------------------------------------------------------
+# API public + cache
+# ------------------------------------------------------------
+def _normalized_key(local_admin_unit_id: str, radius: float) -> Tuple[str, float]:
+ """Retourne la clé normalisée (LAU, rayon) pour le cache LRU."""
+ lau = _normalize_lau_code(local_admin_unit_id or "31555")
+ rad = round(float(radius), 4)
+ return (lau, rad)
+
+
+@lru_cache(maxsize=8)
+def _get_scenario_cached(lau: str, rad: float) -> Dict[str, Any]:
+ """Version mise en cache (pas de `transport_modes_params`)."""
+ return _compute_scenario(local_admin_unit_id=lau, radius=rad, transport_modes_params=None)
+
+
+def get_scenario(
+ local_admin_unit_id: str = "31555",
+ radius: float = 40.0,
+ transport_modes_params: Dict[str, Any] | None = None,
+) -> Dict[str, Any]:
+ """API principale : construit un scénario (avec cache si pas de params modes)."""
+ lau, rad = _normalized_key(local_admin_unit_id, radius)
+ if not transport_modes_params:
+ return _get_scenario_cached(lau, rad)
+ return _compute_scenario(local_admin_unit_id=lau, radius=rad, transport_modes_params=transport_modes_params)
+
+
+def clear_scenario_cache() -> None:
+ """Vide le cache LRU des scénarios sans paramètres de modes."""
+ _get_scenario_cached.cache_clear()
diff --git a/mobility/choice_models/results.py b/mobility/choice_models/results.py
index 41111b97..edc7460e 100644
--- a/mobility/choice_models/results.py
+++ b/mobility/choice_models/results.py
@@ -9,6 +9,7 @@
from mobility.choice_models.evaluation.routing_evaluation import RoutingEvaluation
from mobility.choice_models.evaluation.public_transport_network_evaluation import PublicTransportNetworkEvaluation
+
class Results:
def __init__(
diff --git a/mobility/choice_models/state_updater.py b/mobility/choice_models/state_updater.py
index 46f0bff3..714e7c56 100644
--- a/mobility/choice_models/state_updater.py
+++ b/mobility/choice_models/state_updater.py
@@ -98,29 +98,7 @@ def get_possible_states_steps(
min_activity_time_constant,
tmp_folders
):
- """Enumerate candidate state steps and compute per-step utilities.
-
- Joins latest spatialized chains and mode sequences, merges costs and
- mean activity durations, filters out saturated destinations, and
- computes per-step utility = activity utility − travel cost.
-
- Args:
- current_states (pl.DataFrame): Current aggregate states (used for scoping).
- demand_groups (pl.DataFrame): Demand groups with csp and sizes.
- chains (pl.DataFrame): Chain steps with durations per person.
- costs_aggregator (TravelCostsAggregator): Per-mode OD costs.
- sinks (pl.DataFrame): Remaining sinks per (motive,to).
- motive_dur (pl.DataFrame): Mean durations per (csp,motive).
- iteration (int): Current iteration to pick latest artifacts.
- activity_utility_coeff (float): Coefficient for activity utility.
- tmp_folders (dict[str, pathlib.Path]): Must contain "spatialized-chains" and "modes".
-
- Returns:
- pl.DataFrame: Candidate per-step rows with columns including
- ["demand_group_id","csp","motive_seq_id","dest_seq_id","mode_seq_id",
- "seq_step_index","motive","from","to","mode",
- "duration_per_pers","utility"].
- """
+ """Enumerate candidate state steps and compute per-step utilities."""
cost_by_od_and_modes = (
@@ -206,21 +184,7 @@ def get_possible_states_utility(
stay_home_state,
min_activity_time_constant
):
- """Aggregate per-step utilities to state-level utilities (incl. home-night).
-
- Sums step utilities per state, adds home-night utility, prunes dominated
- states, and appends the explicit stay-home baseline.
-
- Args:
- possible_states_steps (pl.DataFrame): Candidate step rows with per-step utility.
- home_night_dur (pl.DataFrame): Mean home-night duration by csp.
- stay_home_utility_coeff (float): Coefficient for home-night utility.
- stay_home_state (pl.DataFrame): Baseline state rows to append.
-
- Returns:
- pl.DataFrame: State-level utilities with
- ["demand_group_id","motive_seq_id","mode_seq_id","dest_seq_id","utility"].
- """
+ """Aggregate per-step utilities to state-level utilities (incl. home-night)."""
possible_states_utility = (
@@ -269,8 +233,6 @@ def get_possible_states_utility(
return possible_states_utility
-
-
def get_transition_probabilities(
self,
current_states,
@@ -382,21 +344,7 @@ def get_transition_probabilities(
def apply_transitions(self, current_states, transition_probabilities):
- """Apply transition probabilities to reweight populations and update states.
-
- Left-joins transitions onto current states, defaults to self-transition
- when absent, redistributes `n_persons` by `p_transition`, and aggregates
- by the new state keys.
-
- Args:
- current_states (pl.DataFrame): Current states with ["n_persons","utility"].
- transition_probabilities (pl.DataFrame): Probabilities produced by
- `get_transition_probabilities`.
-
- Returns:
- pl.DataFrame: Updated `current_states` aggregated by
- ["demand_group_id","motive_seq_id","dest_seq_id","mode_seq_id"].
- """
+ """Apply transition probabilities to reweight populations and update states."""
state_cols = ["demand_group_id", "motive_seq_id", "dest_seq_id", "mode_seq_id"]
@@ -427,20 +375,7 @@ def apply_transitions(self, current_states, transition_probabilities):
def get_current_states_steps(self, current_states, possible_states_steps):
- """Expand aggregate states to per-step rows (flows).
-
- Joins selected states back to their step sequences and converts
- per-person durations to aggregate durations.
-
- Args:
- current_states (pl.DataFrame): Updated aggregate states.
- possible_states_steps (pl.DataFrame): Candidate steps universe.
-
- Returns:
- pl.DataFrame: Per-step flows with columns including
- ["demand_group_id","motive_seq_id","dest_seq_id","mode_seq_id",
- "seq_step_index","motive","from","to","mode","n_persons","duration"].
- """
+ """Expand aggregate states to per-step rows (flows)."""
current_states_steps = (
current_states.lazy()
@@ -465,27 +400,10 @@ def get_current_states_steps(self, current_states, possible_states_steps):
return current_states_steps
-
-
def get_new_costs(self, costs, iteration, n_iter_per_cost_update, current_states_steps, costs_aggregator):
- """Optionally recompute congested costs from current flows.
-
- Aggregates OD flows by mode, updates network/user-equilibrium in the
- `costs_aggregator`, and returns refreshed costs when the cadence matches.
-
- Args:
- costs (pl.DataFrame): Current OD costs.
- iteration (int): Current iteration (1-based).
- n_iter_per_cost_update (int): Update cadence; 0 disables updates.
- current_states_steps (pl.DataFrame): Step-level flows (by mode).
- costs_aggregator (TravelCostsAggregator): Cost updater.
-
- Returns:
- pl.DataFrame: Updated OD costs (or original if no update ran).
- """
+ """Optionally recompute congested costs from current flows."""
if n_iter_per_cost_update > 0 and (iteration-1) % n_iter_per_cost_update == 0:
-
logging.info("Updating costs...")
od_flows_by_mode = (
@@ -541,8 +459,6 @@ def get_new_sinks(
)
)
- # Compute the remaining number of opportunities by motive and destination
- # once assigned flows are accounted for
remaining_sinks = (
current_states_steps
@@ -572,4 +488,4 @@ def get_new_sinks(
)
- return remaining_sinks
\ No newline at end of file
+ return remaining_sinks
diff --git a/mobility/r_utils/install_packages.R b/mobility/r_utils/install_packages.R
index e941e2bc..fe45233d 100644
--- a/mobility/r_utils/install_packages.R
+++ b/mobility/r_utils/install_packages.R
@@ -1,3 +1,4 @@
+#!/usr/bin/env Rscript
# -----------------------------------------------------------------------------
# Parse arguments
args <- commandArgs(trailingOnly = TRUE)
@@ -128,26 +129,46 @@ pkg_install_with_fallback(
# Local packages
local_packages <- Filter(function(p) {p[["source"]]} == "local", packages)
-if (length(local_packages) > 0) {
- binaries_paths <- unlist(lapply(local_packages, "[[", "path"))
- local_packages <- unlist(lapply(strsplit(basename(binaries_paths), "_"), "[[", 1))
+is_linux <- function() .Platform$OS.type == "unix" && Sys.info()[["sysname"]] != "Darwin"
+is_windows <- function() .Platform$OS.type == "windows"
+
+# Normalize download method: never use wininet on Linux
+if (is_linux() && tolower(download_method) %in% c("wininet", "", "auto")) download_method <- "libcurl"
+if (download_method == "") download_method <- if (is_windows()) "wininet" else "libcurl"
+
+# Global options (fast CDN for CRAN)
+options(
+ repos = c(CRAN = "https://cloud.r-project.org"),
+ download.file.method = download_method,
+ timeout = 600
+)
+
+# -------- Logging helpers (no hard dependency on log4r) ----------------------
+use_log4r <- "log4r" %in% rownames(installed.packages())
+if (use_log4r) {
+ suppressMessages(library(log4r, quietly = TRUE, warn.conflicts = FALSE))
+ .logger <- logger(appenders = console_appender())
+ info_log <- function(...) info(.logger, paste0(...))
+ warn_log <- function(...) warn(.logger, paste0(...))
+ error_log <- function(...) error(.logger, paste0(...))
} else {
- local_packages <- c()
+ info_log <- function(...) cat("[INFO] ", paste0(...), "\n", sep = "")
+ warn_log <- function(...) cat("[WARN] ", paste0(...), "\n", sep = "")
+ error_log <- function(...) cat("[ERROR] ", paste0(...), "\n", sep = "")
}
-if (force_reinstall == FALSE) {
- local_packages <- local_packages[!(local_packages %in% rownames(installed.packages()))]
+# -------- Minimal helpers -----------------------------------------------------
+safe_install <- function(pkgs, ...) {
+ missing <- setdiff(pkgs, rownames(installed.packages()))
+ if (length(missing)) {
+ install.packages(missing, dependencies = TRUE, ...)
+ }
}
-if (length(local_packages) > 0) {
- info(logger, paste0("Installing R packages from local binaries : ", paste0(local_packages, collapse = ", ")))
- info(logger, binaries_paths)
- install.packages(
- binaries_paths,
- repos = NULL,
- type = "binary",
- quiet = FALSE
- )
+# -------- JSON parsing --------------------------------------------------------
+if (!("jsonlite" %in% rownames(installed.packages()))) {
+ # Try to install jsonlite; if it fails we must stop (cannot parse the package list)
+ try(install.packages("jsonlite", dependencies = TRUE), silent = TRUE)
}
# -----------------------------------------------------------------------------
@@ -159,13 +180,57 @@ if (length(github_packages) > 0) {
github_packages <- c()
}
-if (force_reinstall == FALSE) {
- github_packages <- github_packages[!(github_packages %in% rownames(installed.packages()))]
-}
+# =============================================================================
+# CRAN packages
+# =============================================================================
+cran_entries <- Filter(function(p) identical(p[["source"]], "CRAN"), packages)
+cran_pkgs <- if (length(cran_entries)) unlist(lapply(cran_entries, `[[`, "name")) else character(0)
-if (length(github_packages) > 0) {
- info(logger, paste0("Installing R packages from Github :", paste0(github_packages, collapse = ", ")))
- remotes::install_github(github_packages)
+if (length(cran_pkgs)) {
+ if (!force_reinstall) {
+ cran_pkgs <- setdiff(cran_pkgs, already_installed)
+ }
+ if (length(cran_pkgs)) {
+ info_log("Installing CRAN packages: ", paste(cran_pkgs, collapse = ", "))
+ if (have_pak) {
+ tryCatch(
+ { pak::pkg_install(cran_pkgs) },
+ error = function(e) {
+ warn_log("pak::pkg_install() failed: ", conditionMessage(e), " -> falling back to install.packages()")
+ install.packages(cran_pkgs, dependencies = TRUE)
+ }
+ )
+ } else {
+ install.packages(cran_pkgs, dependencies = TRUE)
+ }
+ } else {
+ info_log("CRAN packages already satisfied; nothing to install.")
+ }
}
+# =============================================================================
+# GitHub packages
+# =============================================================================
+github_entries <- Filter(function(p) identical(p[["source"]], "github"), packages)
+gh_pkgs <- if (length(github_entries)) unlist(lapply(github_entries, `[[`, "name")) else character(0)
+
+if (length(gh_pkgs)) {
+ if (!force_reinstall) {
+ gh_pkgs <- setdiff(gh_pkgs, already_installed)
+ }
+ if (length(gh_pkgs)) {
+ info_log("Installing GitHub packages: ", paste(gh_pkgs, collapse = ", "))
+ # Ensure 'remotes' is present
+ if (!("remotes" %in% rownames(installed.packages()))) {
+ try(install.packages("remotes", dependencies = TRUE), silent = TRUE)
+ }
+ if (!("remotes" %in% rownames(installed.packages()))) {
+ stop("Required package 'remotes' is not available and could not be installed.")
+ }
+ remotes::install_github(gh_pkgs, upgrade = "never")
+ } else {
+ info_log("GitHub packages already satisfied; nothing to install.")
+ }
+}
+info_log("All requested installations attempted. Done.")
diff --git a/mobility/r_utils/r_script.py b/mobility/r_utils/r_script.py
index f52c1eca..ee8c65d5 100644
--- a/mobility/r_utils/r_script.py
+++ b/mobility/r_utils/r_script.py
@@ -4,22 +4,23 @@
import contextlib
import pathlib
import os
+import platform
from importlib import resources
+
class RScript:
"""
- Class to run the R scripts from the Python code.
-
- Use the run() method to actually run the script with arguments.
-
+ Run R scripts from Python.
+
+ Use run() to execute the script with arguments.
+
Parameters
----------
- script_path : str | contextlib._GeneratorContextManager
- Path of the R script. Mobility R scripts are stored in the r_utils folder.
-
+ script_path : str | pathlib.Path | contextlib._GeneratorContextManager
+ Path to the R script (mobility R scripts live in r_utils).
"""
-
+
def __init__(self, script_path):
if isinstance(script_path, contextlib._GeneratorContextManager):
with script_path as p:
@@ -29,11 +30,63 @@ def __init__(self, script_path):
elif isinstance(script_path, str):
self.script_path = script_path
else:
- raise ValueError("R script path should be provided as str, pathlib.Path or contextlib._GeneratorContextManager")
-
- if pathlib.Path(self.script_path).exists() is False:
+ raise ValueError("R script path should be str, pathlib.Path or a context manager")
+
+ if not pathlib.Path(self.script_path).exists():
raise ValueError("Rscript not found : " + self.script_path)
+ def _normalized_args(self, args: list) -> list:
+ """
+ Ensure the download method is valid for the current OS.
+ The R script expects:
+ args[1] -> packages JSON (after we prepend package root)
+ args[2] -> force_reinstall (as string "TRUE"/"FALSE")
+ args[3] -> download_method
+ """
+ norm = list(args)
+ if not norm:
+ return norm
+
+ # The last argument should be the download method; normalize it for Linux
+ is_windows = (platform.system() == "Windows")
+ dl_idx = len(norm) - 1
+ method = str(norm[dl_idx]).strip().lower()
+
+ if not is_windows:
+ # Never use wininet/auto on Linux/WSL
+ if method in ("", "auto", "wininet"):
+ norm[dl_idx] = "libcurl"
+ else:
+ # On Windows, allow wininet; default to wininet if empty
+ if method == "":
+ norm[dl_idx] = "wininet"
+
+ return norm
+
+ def _build_env(self) -> dict:
+ """
+ Prepare environment variables for R in a robust, cross-platform way.
+ """
+ env = os.environ.copy()
+
+ is_windows = (platform.system() == "Windows")
+ # Default to disabling pak unless caller opts in
+ env.setdefault("USE_PAK", "false")
+
+ # Make R downloads sane by default
+ if not is_windows:
+ # Force libcurl on Linux/WSL
+ env.setdefault("R_DOWNLOAD_FILE_METHOD", "libcurl")
+ # Point to the system CA bundle if available (WSL/Ubuntu)
+ cacert = "/etc/ssl/certs/ca-certificates.crt"
+ if os.path.exists(cacert):
+ env.setdefault("SSL_CERT_FILE", cacert)
+
+ # Avoid tiny default timeouts in some R builds
+ env.setdefault("R_DEFAULT_INTERNET_TIMEOUT", "600")
+
+ return env
+
def run(self, args: list) -> None:
"""
Run the R script.
@@ -41,24 +94,29 @@ def run(self, args: list) -> None:
Parameters
----------
args : list
- List of arguments to pass to the R function.
+ Arguments to pass to the R script (without the package root; we prepend it).
Raises
------
RScriptError
- Exception when the R script returns an error.
-
- """
- # Prepend the package path to the argument list so the R script can
- # know where it is run (useful when sourcing other R scripts).
- args = [str(resources.files('mobility'))] + args
+ If the R script returns a non-zero exit code.
+ """
+ # Prepend the package path so the R script knows the mobility root
+ args = [str(resources.files('mobility'))] + self._normalized_args(args)
cmd = ["Rscript", self.script_path] + args
-
+
if os.environ.get("MOBILITY_DEBUG") == "1":
- logging.info("Running R script " + self.script_path + " with the following arguments :")
+ logging.info("Running R script %s with the following arguments :", self.script_path)
logging.info(args)
-
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ env = self._build_env()
+
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env
+ )
stdout_thread = threading.Thread(target=self.print_output, args=(process.stdout,))
stderr_thread = threading.Thread(target=self.print_output, args=(process.stderr, True))
@@ -67,43 +125,42 @@ def run(self, args: list) -> None:
process.wait()
stdout_thread.join()
stderr_thread.join()
-
+
if process.returncode != 0:
raise RScriptError(
"""
- Rscript error (the error message is logged just before the error stack trace).
- If you want more detail, you can print all R output by setting debug=True when calling set_params.
- """
+Rscript error (the error message is logged just before the error stack trace).
+If you want more detail, set MOBILITY_DEBUG=1 (or debug=True in set_params) to print all R output.
+ """.rstrip()
)
- def print_output(self, stream, is_error=False):
+ def print_output(self, stream, is_error: bool = False):
"""
- Log all R messages if debug=True in set_params, log only important messages if not.
+ Log all R messages if debug=True; otherwise show INFO lines + errors.
Parameters
----------
- stream :
- R message.
- is_error : bool, default=False
- If the R message is an error or not.
-
+ stream :
+ R process stream.
+ is_error : bool
+ Whether this stream is stderr.
"""
for line in iter(stream.readline, b""):
msg = line.decode("utf-8", errors="replace")
if os.environ.get("MOBILITY_DEBUG") == "1":
logging.info(msg)
-
else:
if "INFO" in msg:
- msg = msg.split("]")[1]
- msg = msg.strip()
+ # keep the message payload after the log level tag if present
+ parts = msg.split("]")
+ if len(parts) > 1:
+ msg = parts[1].strip()
logging.info(msg)
- elif is_error and "Error" in msg or "Erreur" in msg:
+ elif is_error and ("Error" in msg or "Erreur" in msg):
logging.error("RScript execution failed, with the following message : " + msg)
class RScriptError(Exception):
"""Exception for R errors."""
-
pass
diff --git a/pyproject.toml b/pyproject.toml
index 66e06b17..183afd5c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,10 @@ dependencies = [
"psutil",
"networkx",
"plotly",
+ "dash",
+ "dash-deck",
+ "pydeck",
+ "dash-mantine-components",
"scikit-learn",
"gtfs_kit"
]
@@ -51,8 +55,9 @@ dev = [
"pytest",
"pytest-cov",
"pytest-dependency",
+ "dash[testing]",
"sphinxcontrib-napoleon",
- "myst_parser"
+ "myst_parser"
]
spyder = [
@@ -77,4 +82,11 @@ mobility = [
[tool.setuptools.packages.find]
where = ["."]
include = ["mobility*"]
-exclude = ["certs", "certs.*"]
\ No newline at end of file
+exclude = ["certs", "certs.*"]
+
+[tool.pytest.ini_options]
+pythonpath = [
+ ".",
+ "front",
+ "mobility"
+]
diff --git a/tests/front/conftest.py b/tests/front/conftest.py
new file mode 100644
index 00000000..22bc1c7e
--- /dev/null
+++ b/tests/front/conftest.py
@@ -0,0 +1,83 @@
+# tests/front/conftest.py
+import pytest
+import pandas as pd
+import geopandas as gpd
+from shapely.geometry import Polygon
+import sys
+from pathlib import Path
+
+def pytest_configure(config):
+ import builtins
+ # Répond "Yes" à toute demande d'input (évite la lecture du stdin pendant la collecte)
+ builtins.input = lambda *args, **kwargs: "Yes"
+
+ # S'assurer que le dossier par défaut existe (utilisé par set_params)
+ default_projects = Path.home() / ".mobility" / "data" / "projects"
+ default_projects.mkdir(parents=True, exist_ok=True)
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2] # -> repository root
+FRONT_DIR = REPO_ROOT / "front"
+if str(FRONT_DIR) not in sys.path:
+ sys.path.insert(0, str(FRONT_DIR))
+
+
+@pytest.fixture
+def sample_scn():
+ poly = Polygon([
+ (1.43, 43.60), (1.45, 43.60),
+ (1.45, 43.62), (1.43, 43.62),
+ (1.43, 43.60)
+ ])
+
+ zones_gdf = gpd.GeoDataFrame(
+ {
+ "transport_zone_id": ["Z1"],
+ "local_admin_unit_id": ["31555"],
+ "average_travel_time": [32.4],
+ "total_dist_km": [7.8],
+ "total_time_min": [45.0],
+ "share_car": [0.52],
+ "share_bicycle": [0.18],
+ "share_walk": [0.30],
+ "geometry": [poly],
+ },
+ crs="EPSG:4326",
+ )
+
+ flows_df = pd.DataFrame({"from": [], "to": [], "flow_volume": []})
+ zones_lookup = zones_gdf[["transport_zone_id", "geometry"]].copy()
+
+ return {"zones_gdf": zones_gdf, "flows_df": flows_df, "zones_lookup": zones_lookup}
+
+
+@pytest.fixture(autouse=True)
+def patch_services(monkeypatch, sample_scn):
+ # Patch le service scénario pour les tests d’intégration
+ import front.app.services.scenario_service as scn_mod
+
+ def fake_get_scenario(radius=40, local_admin_unit_id="31555"):
+ return sample_scn
+
+ monkeypatch.setattr(scn_mod, "get_scenario", fake_get_scenario, raising=True)
+
+ # Patch map_service option B (si présent)
+ try:
+ import front.app.services.map_service as map_service
+ from app.components.features.map.config import DeckOptions
+ from app.components.features.map.deck_factory import make_deck_json
+
+ def fake_get_map_deck_json_from_scn(scn, opts=None):
+ opts = opts or DeckOptions()
+ return make_deck_json(scn, opts)
+
+ monkeypatch.setattr(
+ map_service,
+ "get_map_deck_json_from_scn",
+ fake_get_map_deck_json_from_scn,
+ raising=False,
+ )
+ except Exception:
+ pass
+
+ yield
diff --git a/tests/front/integration/test_001_main_app.py b/tests/front/integration/test_001_main_app.py
new file mode 100644
index 00000000..6da73131
--- /dev/null
+++ b/tests/front/integration/test_001_main_app.py
@@ -0,0 +1,108 @@
+"""
+test_callbacks_simulation.py
+============================
+
+Tests de la logique de simulation associée au callback `_run_simulation`
+sans recourir à Selenium / dash_duo.
+
+L’objectif est de :
+- monkeypatcher `get_scenario` pour obtenir un scénario stable et déterministe ;
+- exécuter un helper (`compute_simulation_outputs_test`) qui reproduit la logique
+ du callback (construction de `deck_json` + `StudyAreaSummary`) ;
+- vérifier que la spécification Deck.gl produite est valide et que le composant
+ résumé est bien un composant Dash sérialisable.
+"""
+
+import json
+import pandas as pd
+import geopandas as gpd
+from shapely.geometry import Polygon
+from dash.development.base_component import Component
+
+# On importe les briques "front" utilisées par le callback
+import front.app.services.scenario_service as scn_mod
+from app.components.features.map.config import DeckOptions
+from app.components.features.map.deck_factory import make_deck_json
+from app.components.features.study_area_summary import StudyAreaSummary
+
+MAPP = "map" # doit matcher l'id_prefix de la Map
+
+
+def compute_simulation_outputs_test(radius_val, lau_val, id_prefix=MAPP):
+ """Reproduit la logique du callback `_run_simulation` dans un contexte de test.
+
+ Ce helper permet de tester la génération de la carte et du panneau de résumé
+ sans démarrer le serveur Dash ni utiliser Selenium. Il :
+ - normalise le rayon et le code INSEE/LAU ;
+ - appelle `get_scenario` (qui peut être monkeypatché dans le test) ;
+ - construit la spec Deck.gl JSON via `make_deck_json` ;
+ - construit le composant `StudyAreaSummary`.
+
+ Args:
+ radius_val: Valeur du rayon (slider / input) telle que fournie par l’UI.
+ lau_val: Code INSEE/LAU saisi (ex. "31555").
+ id_prefix (str, optional): Préfixe d’identifiants pour le composant
+ `StudyAreaSummary`. Doit être cohérent avec `MAPP`. Par défaut `"map"`.
+
+ Returns:
+ Tuple[str, Component]:
+ - `deck_json`: spécification Deck.gl sérialisée en JSON,
+ - `summary`: composant Dash `StudyAreaSummary`.
+ """
+ r = 40 if radius_val is None else int(radius_val)
+ lau = (lau_val or "").strip() or "31555"
+ scn = scn_mod.get_scenario(radius=r, local_admin_unit_id=lau)
+ deck_json = make_deck_json(scn, DeckOptions())
+ summary = StudyAreaSummary(scn["zones_gdf"], visible=True, id_prefix=id_prefix)
+ return deck_json, summary
+
+
+def test_compute_simulation_outputs_smoke(monkeypatch):
+ # --- 1) scénario stable via monkeypatch ---
+ poly = Polygon([
+ (1.43, 43.60), (1.45, 43.60),
+ (1.45, 43.62), (1.43, 43.62),
+ (1.43, 43.60),
+ ])
+
+ zones_gdf = gpd.GeoDataFrame(
+ {
+ "transport_zone_id": ["Z1"],
+ "local_admin_unit_id": ["31555"],
+ "average_travel_time": [32.4],
+ "total_dist_km": [7.8],
+ "total_time_min": [45.0],
+ "share_car": [0.52],
+ "share_bicycle": [0.18],
+ "share_walk": [0.30],
+ "geometry": [poly],
+ },
+ crs="EPSG:4326",
+ )
+ flows_df = pd.DataFrame({"from": [], "to": [], "flow_volume": []})
+ zones_lookup = zones_gdf[["transport_zone_id", "geometry"]].copy()
+
+ def fake_get_scenario(radius=40, local_admin_unit_id="31555"):
+ return {
+ "zones_gdf": zones_gdf,
+ "flows_df": flows_df,
+ "zones_lookup": zones_lookup,
+ }
+
+ monkeypatch.setattr(scn_mod, "get_scenario", fake_get_scenario, raising=True)
+
+ # --- 2) exécute la logique "callback-like" ---
+ deck_json, summary = compute_simulation_outputs_test(30, "31555", id_prefix=MAPP)
+
+ # --- 3) assertions : deck_json DeckGL valide ---
+ assert isinstance(deck_json, str)
+ deck = json.loads(deck_json)
+ assert "initialViewState" in deck
+ assert isinstance(deck.get("layers", []), list)
+
+ # --- 4) assertions : summary est un composant Dash sérialisable ---
+ assert isinstance(summary, Component)
+ payload = summary.to_plotly_json()
+ assert isinstance(payload, dict)
+ # On peut vérifier l'ID racine utilisé dans StudyAreaSummary
+ assert payload.get("props", {}).get("id", "").endswith("-study-summary")
diff --git a/tests/front/unit/main/test_004_main_import_branches.py b/tests/front/unit/main/test_004_main_import_branches.py
new file mode 100644
index 00000000..3d24c60f
--- /dev/null
+++ b/tests/front/unit/main/test_004_main_import_branches.py
@@ -0,0 +1,18 @@
+# --- Service vs Fallback: API attendue par les tests ---
+from app.components.features.map.config import DeckOptions
+from app.components.features.map.deck_factory import make_deck_json as _fallback_make_deck_json
+
+try:
+ # IMPORTANT : chemin d'import attendu par les tests
+ from front.app.services.map_service import get_map_deck_json_from_scn as _svc_get_map_deck_json_from_scn
+ USE_MAP_SERVICE = True
+except Exception:
+ _svc_get_map_deck_json_from_scn = None
+ USE_MAP_SERVICE = False
+
+def _make_deck_json_from_scn(scn, opts: DeckOptions | None = None) -> str:
+ """Garde une API stable pour tests_004_main_import_branches.py"""
+ opts = opts or DeckOptions()
+ if USE_MAP_SERVICE and _svc_get_map_deck_json_from_scn is not None:
+ return _svc_get_map_deck_json_from_scn(scn, opts)
+ return _fallback_make_deck_json(scn, opts)
diff --git a/tests/front/unit/services/test_scenario_service.py b/tests/front/unit/services/test_scenario_service.py
new file mode 100644
index 00000000..9da6e104
--- /dev/null
+++ b/tests/front/unit/services/test_scenario_service.py
@@ -0,0 +1,191 @@
+# tests/front/unit/scenario_service/test_scenario_service.py
+import sys
+import types
+import builtins
+
+import numpy as np
+import pandas as pd
+import geopandas as gpd
+from shapely.geometry import Point
+from pyproj import CRS
+
+import app.services.scenario_service as scn # <-- adjust if your path differs
+
+
+# ---------- helpers ----------
+
+def _install_fake_mobility(monkeypatch, n=3):
+ """
+ Install a fake 'mobility' module in sys.modules to drive the non-fallback path.
+ Creates n transport zones with simple point geometries in EPSG:4326.
+ """
+ class _FakeTZ:
+ def __init__(self, local_admin_unit_id, radius, level_of_detail):
+ self.lau = local_admin_unit_id
+ self.radius = radius
+ self.lod = level_of_detail
+
+ def get(self):
+ pts = [(1.0 + 0.01 * i, 43.0 + 0.01 * i) for i in range(n)]
+ df = gpd.GeoDataFrame(
+ {
+ "transport_zone_id": [f"Z{i+1}" for i in range(n)],
+ "geometry": [Point(x, y) for (x, y) in pts],
+ "local_admin_unit_id": [self.lau] * n,
+ },
+ geometry="geometry",
+ crs=4326,
+ )
+ return df
+
+ fake = types.ModuleType("mobility")
+ fake.set_params = lambda **kwargs: None
+ fake.TransportZones = _FakeTZ
+ monkeypatch.setitem(sys.modules, "mobility", fake)
+
+
+def _remove_mobility(monkeypatch):
+ """Force mobility import to fail, so we exercise the fallback branch."""
+ if "mobility" in sys.modules:
+ monkeypatch.delitem(sys.modules, "mobility", raising=False)
+
+ real_import = builtins.__import__
+
+ def fake_import(name, *a, **kw):
+ if name == "mobility":
+ raise ImportError("Simulated missing mobility")
+ return real_import(name, *a, **kw)
+
+ monkeypatch.setattr(builtins, "__import__", fake_import)
+
+
+# ---------- tests ----------
+
+def test_fallback_used_when_mobility_missing(monkeypatch):
+ scn.clear_scenario_cache()
+ _remove_mobility(monkeypatch)
+
+ out = scn.get_scenario(local_admin_unit_id="31555", radius=40)
+ zones = out["zones_gdf"]
+ flows = out["flows_df"]
+ lookup = out["zones_lookup"]
+
+ # Shape/schema sanity
+ assert isinstance(zones, gpd.GeoDataFrame)
+ assert isinstance(flows, pd.DataFrame)
+ assert isinstance(lookup, gpd.GeoDataFrame)
+ assert zones.crs is not None and CRS(zones.crs).equals(CRS.from_epsg(4326))
+ assert lookup.crs is not None and CRS(lookup.crs).equals(CRS.from_epsg(4326))
+
+ # All modal columns should exist
+ cols = {
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle", "share_public_transport",
+ "average_travel_time", "total_dist_km", "local_admin_unit_id",
+ }
+ assert cols.issubset(set(zones.columns))
+
+ # Shares well-formed (sum of all modal columns = 1)
+ row_sums = zones[[
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle"
+ ]].sum(axis=1)
+ assert np.allclose(row_sums.values, 1.0, atol=1e-6)
+
+
+def test_normalized_key_and_cache(monkeypatch):
+ scn.clear_scenario_cache()
+ _remove_mobility(monkeypatch)
+
+ # _normalized_key behavior
+ assert scn._normalized_key("31555", 40) == ("fr-31555", 40.0)
+ assert scn._normalized_key("fr-31555", 40.00001) == ("fr-31555", 40.0)
+
+ # Count compute calls via monkeypatched _compute_scenario
+ calls = {"n": 0}
+
+ def fake_compute(local_admin_unit_id, radius, transport_modes_params):
+ calls["n"] += 1
+ return scn._fallback_scenario()
+
+ monkeypatch.setattr(scn, "_compute_scenario", fake_compute)
+
+ # No params -> cached
+ scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None)
+ scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None)
+ assert calls["n"] == 1, "Second call without params should hit cache"
+
+ # With params -> bypass cache each time
+ scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params={"car": {"active": True}})
+ scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params={"car": {"active": True}})
+ assert calls["n"] == 3, "Calls with params should call compute each time"
+
+
+def test_non_fallback_renormalization_and_crs(monkeypatch):
+ scn.clear_scenario_cache()
+ _install_fake_mobility(monkeypatch, n=3)
+
+ # Only bicycle active → its share should be 1, others 0 after renormalization
+ params = {
+ "car": {"active": False},
+ "bicycle": {"active": True},
+ "walk": {"active": False},
+ "carpool": {"active": False},
+ "public_transport": {"active": False},
+ }
+ out = scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=params)
+ zones = out["zones_gdf"]
+
+ # CRS robust equality to WGS84
+ assert zones.crs is not None
+ assert CRS(zones.crs).equals(CRS.from_epsg(4326))
+
+ # Row-wise sum over all share columns ≈ 1
+ cols = [
+ "share_car", "share_bicycle", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle"
+ ]
+ row_sums = zones[cols].sum(axis=1).to_numpy()
+ assert np.allclose(row_sums, 1.0, atol=1e-5)
+
+ # Bicycle should be 1 everywhere; others 0
+ assert np.allclose(zones["share_bicycle"].to_numpy(), 1.0, atol=1e-6)
+ others = zones[["share_car", "share_walk", "share_carpool",
+ "share_pt_walk", "share_pt_car", "share_pt_bicycle"]].to_numpy()
+ assert np.allclose(others, 0.0, atol=1e-6)
+
+
+def test_pt_submodes_selection(monkeypatch):
+ scn.clear_scenario_cache()
+ _install_fake_mobility(monkeypatch, n=4)
+
+ # PT active but only 'car_pt' enabled
+ params = {
+ "car": {"active": False},
+ "bicycle": {"active": False},
+ "walk": {"active": False},
+ "carpool": {"active": False},
+ "public_transport": {"active": True, "pt_walk": False, "pt_bicycle": False, "pt_car": True},
+ }
+ out = scn.get_scenario(local_admin_unit_id="31555", radius=30.0, transport_modes_params=params)
+ zones = out["zones_gdf"]
+
+ # public_transport share == share_pt_car ; others zero
+ assert np.all(zones["share_pt_walk"].values == 0.0)
+ assert np.all(zones["share_pt_bicycle"].values == 0.0)
+ assert np.all(zones["share_public_transport"].values == zones["share_pt_car"].values)
+
+ # Total over active columns must be 1
+ sums = zones[["share_pt_car"]].sum(axis=1)
+ assert np.allclose(sums.values, 1.0, atol=1e-6)
+
+
+def test_lau_normalization_variants(monkeypatch):
+ scn.clear_scenario_cache()
+ _install_fake_mobility(monkeypatch, n=2)
+
+ out_a = scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None)
+ out_b = scn.get_scenario(local_admin_unit_id="fr-31555", radius=40.0, transport_modes_params=None)
+
+ # Both normalize to ("fr-31555", 40.0) and use the same cached object
+ assert out_a is out_b
diff --git a/tests/front/unit/test_002_color_scale.py b/tests/front/unit/test_002_color_scale.py
new file mode 100644
index 00000000..8e2c141a
--- /dev/null
+++ b/tests/front/unit/test_002_color_scale.py
@@ -0,0 +1,29 @@
+import pandas as pd
+import numpy as np
+from pytest import approx
+
+from app.components.features.map.color_scale import fit_color_scale
+
+
+def test_fit_color_scale_basic():
+ s = pd.Series([10.0, 20.0, 25.0, 30.0])
+ scale = fit_color_scale(s)
+
+ # vmin/vmax basés sur les percentiles 5/95 (et pas min/max stricts)
+ vmin_expected = float(np.nanpercentile(s, 5))
+ vmax_expected = float(np.nanpercentile(s, 95))
+ assert scale.vmin == approx(vmin_expected)
+ assert scale.vmax == approx(vmax_expected)
+
+ # Couleur à vmin ~ froide, à vmax ~ chaude
+ c_min = scale.rgba(s.min())
+ c_mid = scale.rgba(s.mean())
+ c_max = scale.rgba(s.max())
+ assert isinstance(c_min, list) and len(c_min) == 4
+ assert c_min[0] < c_max[0] # rouge augmente
+ assert c_min[2] > c_max[2] # bleu diminue
+
+ # Légende numérique "x.x min"
+ lg = scale.legend(11.0)
+ assert isinstance(lg, str)
+ assert lg.endswith(" min")
diff --git a/tests/front/unit/test_003_geo_utils.py b/tests/front/unit/test_003_geo_utils.py
new file mode 100644
index 00000000..20bdad85
--- /dev/null
+++ b/tests/front/unit/test_003_geo_utils.py
@@ -0,0 +1,24 @@
+import geopandas as gpd
+from shapely.geometry import Polygon
+from app.components.features.map.geo_utils import safe_center, ensure_wgs84
+
+def test_safe_center_simple_square():
+ gdf = gpd.GeoDataFrame(
+ {"id": [1]},
+ geometry=[Polygon([(0,0),(1,0),(1,1),(0,1),(0,0)])],
+ crs="EPSG:4326",
+ )
+ center = safe_center(gdf)
+ assert isinstance(center, tuple) and len(center) == 2
+ lon, lat = center
+ assert 0.4 < lon < 0.6
+ assert 0.4 < lat < 0.6
+
+def test_ensure_wgs84_no_crs():
+ gdf = gpd.GeoDataFrame(
+ {"id": [1]},
+ geometry=[Polygon([(0,0),(1,0),(1,1),(0,1),(0,0)])],
+ crs=None,
+ )
+ out = ensure_wgs84(gdf)
+ assert out.crs is not None
\ No newline at end of file