Files
colibre/src/figures.py
T

1246 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
from datetime import datetime
from typing import Literal
import dash_ag_grid as dag
import dash_bootstrap_components as dbc
import dash_leaflet as dl
import dash_leaflet.express as dlx
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import polars as pl
from dash import dash_table, dcc, html
from dash_extensions.javascript import Namespace
from polars.exceptions import ColumnNotFoundError
from src.db import schema
from src.utils import logger
from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON
from src.utils.table import add_links, format_number, setup_table_columns
def get_yearly_statistics(statistics, today_str) -> html.Div:
# Build DataFrame from statistics
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
data = []
for year in years:
year_str = str(year)
stat = statistics[year_str]
data.append(
{
"Année": year_str,
"Marchés et accord-cadres": format_number(
stat["nb_notifications_marches"]
),
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
}
)
dff = pl.DataFrame(data)
# Create Dash DataTable
table = dash_table.DataTable(
data=dff.to_dicts(),
columns=[
{"name": "Année", "id": "Année"},
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
{"name": "Acheteurs", "id": "Acheteurs"},
{"name": "Titulaires", "id": "Titulaires"},
],
page_size=10,
sort_action="none",
filter_action="none",
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=table, className="marches_table")
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
}
now_year = datetime.now().year
lff = lff.select("uid", type_date, "sourceDataset")
lff = lff.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
.then(pl.lit("aws"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
lff = lff.filter(
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, now_year)
)
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lff = (
lff.group_by([type_date, "sourceDataset"])
.len()
.sort(by=[type_date, "len"], descending=True)
)
lff = lff.sort(by=["sourceDataset"], descending=False)
dff: pl.DataFrame = lff.collect(engine="streaming")
fig = px.bar(
dff,
x=type_date,
y="len",
color="sourceDataset",
labels={
"len": "Nombre de marchés",
type_date: f"Mois de {labels[type_date]}",
"sourceDataset": "Source de données",
},
)
graph = dcc.Graph(figure=fig)
return graph
def get_sources_tables(source_path) -> html.Div:
try:
if not source_path:
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
dff = pl.read_csv(source_path)
except Exception as e:
logger.warning(f"Sources de données indisponibles ({e})")
return html.Div("Sources de données momentanément indisponibles.")
dff = dff.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
+ pl.lit('">')
+ pl.col("nom")
+ pl.lit("</a>")
).alias("nom"),
)
dff = dff.drop("url", "unique")
dff = dff.sort(by=["nb_marchés"], descending=True)
dff = dff.with_columns(
pl.col("nb_marchés").map_elements(format_number, return_dtype=pl.String),
pl.col("nb_acheteurs").map_elements(format_number, return_dtype=pl.String),
)
columns = {
"nom": "Nom de la source",
"organisation": "Responsable de publication",
"nb_marchés": "Nb de marchés",
"nb_acheteurs": "Nb d'acheteurs",
"code": "Code",
}
datatable = dash_table.DataTable(
id="source_table",
data=dff.to_dicts(),
columns=[
{
"name": columns[i],
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in dff.schema.names()
],
style_cell_conditional=[
{
"if": {"column_id": ["nom", "organisation"]},
"minWidth": "220px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
sort_action="native",
markdown_options={"html": True},
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=datatable)
class DataTable(dash_table.DataTable):
def __init__(
self,
dtid: str,
hidden_columns: list[str] | None = None,
data: list[dict[str, str | int | float | bool]] | None = None,
columns: list[dict[str, str]] | None = None,
page_size: int = 20,
page_action: Literal["native", "custom", "none"] = "native",
sort_action: Literal["native", "custom", "none"] = "native",
filter_action: Literal["native", "custom", "none"] = "native",
style_cell_conditional: list | None = None,
style_cell: dict | None = None,
**kwargs,
):
# Styles de base
style_cell_conditional_common = [
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_id"},
"minWidth": "160px",
"overflow": "hidden",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
]
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
for key in DATA_SCHEMA.keys():
field = DATA_SCHEMA[key]
if field["type"] in ["number", "integer"]:
rule = {
"if": {"column_id": field["name"]},
"textAlign": "right",
# "fontFamily": "Fira Code",
}
style_cell_conditional_common.append(rule)
style_cell_conditional = (
style_cell_conditional or []
) + style_cell_conditional_common
if style_cell:
style_cell.update(style_cell_common)
else:
style_cell = style_cell_common
style_header = style_cell
# Initialisation de la classe parente avec les arguments
super().__init__(
id=dtid,
data=data,
columns=columns,
cell_selectable=False,
page_size=page_size,
filter_action=filter_action,
page_action=page_action,
filter_options={
"case": "insensitive",
"placeholder_text": "Filtre de colonne...",
},
sort_action=sort_action,
sort_mode="multi",
row_deletable=False,
page_current=0,
style_cell_conditional=style_cell_conditional,
data_timestamp=0,
markdown_options={"html": True},
style_header=style_header,
style_cell=style_cell,
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=hidden_columns,
**kwargs, # Possibilité de remplacer des arguments
)
def get_duplicate_matrix() -> dcc.Graph:
"""
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
:return:
"""
lff = pl.scan_parquet(
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
).sort("sourceDataset")
lff = lff.select(
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
)
dff = lff.collect()
# Extract data
z_data = dff.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
x_labels = dff.columns[1:] # columns after "sourceDataset"
y_labels = dff["sourceDataset"].to_list()
# Create heatmap
fig = go.Figure(
data=go.Heatmap(
z=z_data,
x=x_labels,
y=y_labels,
colorscale=[
[0.0, "white"], # 0% → white
[0.10, "lightsalmon"], # 10% → light warm tone
[1.0, "darkred"], # 100% → deep red
],
zmin=0,
zmax=1,
hoverongaps=False,
showscale=True,
hovertemplate=(
"<b>%{z:.0%}</b> des marchés présents dans <b>%{y}</b> sont également présents dans <b>%{x}</b>"
),
)
)
# Hauteur adaptée au nombre de sources pour que chaque libellé ait sa
# propre ligne (sinon Plotly n'affiche qu'un libellé sur deux faute de
# place).
height = max(1000, 22 * len(y_labels) + 150)
# Update layout: make it wider and taller
fig.update_layout(
title="",
xaxis_title="Sources de données",
yaxis_title="Sources de données",
yaxis=dict(autorange="reversed", tickmode="linear", dtick=1),
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
width=1000, # Wider
height=height,
font=dict(size=11), # Overall font size
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
)
return dcc.Graph(figure=fig)
ORG_COLORS = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
def build_org_markers(
lff: pl.LazyFrame, org_type: Literal["acheteur", "titulaire"]
) -> list[dict]:
"""Regroupe les marchés par point géographique pour un type d'organisme.
Renvoie [] (sans exception) si les colonnes longitude/latitude de ce
type sont absentes du LazyFrame (ex: tests/test.parquet).
"""
lon_col = f"{org_type}_longitude"
lat_col = f"{org_type}_latitude"
nom_col = f"{org_type}_nom"
available = set(lff.collect_schema().names())
if lon_col not in available or lat_col not in available:
return []
lff_org = (
lff.select("uid", lon_col, lat_col, nom_col)
.group_by(lon_col, lat_col, nom_col)
.len("nb_marches")
.filter(pl.col(lat_col).is_not_null() & pl.col(lon_col).is_not_null())
)
return [
{
"lat": row[lat_col],
"lon": row[lon_col],
"tooltip": f"{row[nom_col]} ({row['nb_marches']} marchés)",
"marker_color": ORG_COLORS[org_type],
}
for row in lff_org.collect().to_dicts()
]
def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:
"""
Génère les cartes géographiques pour l'hexagone et les DOM-TOM.
"""
regions: dict = {
"Hexagone": {
"coordinates": [46.6, 2.2],
"zoom_leaflet": 5,
"zoom_chloropleth": 1,
"name": "Hexagone",
},
"971": {
"coordinates": [16.23, -61.55],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "Guadeloupe",
},
"972": {
"coordinates": [14.64, -61.02],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Martinique",
},
"973": {
"coordinates": [3.93, -53.12],
"zoom_leaflet": 7,
"zoom_chloropleth": 1,
"name": "Guyane",
},
"974": {
"coordinates": [-21.11, 55.53],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "La Réunion",
},
"976": {
"coordinates": [-12.82, 45.16],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Mayotte",
},
}
def make_map_data(region_code: str) -> tuple[list, str | None]:
lff: pl.LazyFrame = dff.lazy()
if region_code == "Hexagone":
lff = lff.filter(
(pl.col("acheteur_departement_code").str.len_chars() == 2)
& (pl.col("titulaire_departement_code").str.len_chars() == 2)
)
else:
lff = lff.filter(
(pl.col("acheteur_departement_code") == code)
| (pl.col("titulaire_departement_code") == code)
)
nb_marches = lff.select("uid").collect()["uid"].n_unique()
if nb_marches == 0:
return [], None
dfs = []
if (code == "Hexagone" and nb_marches > 30000) or (
code != "Hexagone" and nb_marches > 10000
):
_map_type: str = "chloropleth"
lff = lff.rename({"acheteur_departement_code": "Département"})
lff = (
lff.select(["uid", "Département"])
.drop_nulls()
.group_by("uid")
.agg(pl.col("Département").first())
.group_by("Département")
.len("uid")
)
dfs.append(lff.collect())
else:
_map_type: str = "clusters"
for org_type in ["acheteur", "titulaire"]:
dfs.append(build_org_markers(lff, org_type))
return dfs, _map_type
cols = []
for code in regions.keys():
regions[code]["data"], map_type = make_map_data(code)
if map_type == "chloropleth":
map_graph = make_chloropleth_map(regions[code])
elif map_type == "clusters":
map_graph = make_clusters_map(regions[code])
elif map_type is None:
continue
else:
raise ValueError(f"Map type '{map_type}' not recognised")
lg, xl = (12, 8) if code == "Hexagone" else (6, 4)
col = make_card(regions[code]["name"], fig=map_graph, lg=lg, xl=xl)
cols.append(col)
return cols
def make_chloropleth_map(region: dict) -> dcc.Graph:
df_map = region["data"][0]
fig = px.choropleth(
df_map,
geojson=DEPARTEMENTS_GEOJSON,
locations="Département",
color="uid",
color_continuous_scale="Reds",
range_color=(df_map["uid"].min(), df_map["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
mapbox={
"style": "carto-positron",
"center": {"lon": 10, "lat": 10},
"zoom": 8,
"domain": {"x": [0, 1], "y": [0, 1]},
}
)
graph = dcc.Graph(figure=fig, config={"displayModeBar": False})
return graph
def make_clusters_map(region: dict) -> dl.Map:
# JavaScript functions for styling
ns = Namespace("dash_clientside", "leaflet")
point_to_layer = ns("pointToLayer")
cluster_to_layer = ns("clusterToLayer")
name = region["name"]
# Données de la région
region_acheteurs = region["data"][0]
region_titulaires = region["data"][1]
# Couleurs
color_acheteur = ORG_COLORS["acheteur"]
color_titulaire = ORG_COLORS["titulaire"]
acheteurs_geojson_data = dlx.dicts_to_geojson(region_acheteurs)
titulaires_geojson_data = dlx.dicts_to_geojson(region_titulaires)
center, zoom = region["coordinates"], region["zoom_leaflet"]
region_id = name.lower().replace(" ", "-")
leaflet_map = dl.Map(
[
dl.TileLayer(),
dl.GeoJSON(
data=titulaires_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-titulaires",
options={"fillColor": color_titulaire},
),
dl.GeoJSON(
data=acheteurs_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-acheteurs",
options={"fillColor": color_acheteur},
),
],
center=center,
zoom=zoom,
style={
"width": "100%",
"height": "400px" if name == "Hexagone" else "300px",
},
id=f"map-{region_id}",
)
return leaflet_map
_MERCATOR_TILE_SIZE = 256
def _mercator_y(lat: float) -> float:
lat_rad = math.radians(lat)
return math.log(math.tan(math.pi / 4 + lat_rad / 2)) / (2 * math.pi)
def bounds_to_center_zoom(
min_lat: float,
min_lon: float,
max_lat: float,
max_lon: float,
container_width_px: float = 350,
container_height_px: float = 300,
padding_px: float = 30,
max_zoom: int = 12,
) -> tuple[list[float], int]:
"""Calcule un centre et un niveau de zoom (projection Web Mercator) pour
cadrer une bounding box dans un conteneur de taille donnée.
Approximation côté serveur d'un fitBounds Leaflet : la taille réelle du
conteneur navigateur n'est pas connue côté Python, `container_width_px`
est une estimation raisonnable (la carte occupe une colonne étroite de
la page). Le résultat n'est donc pas pixel-perfect, contrairement à un
fitBounds() exécuté côté client, mais évite d'utiliser la prop `bounds`
de dash-leaflet (bug connu : exception au premier montage du composant).
"""
avail_w = max(container_width_px - 2 * padding_px, 1)
avail_h = max(container_height_px - 2 * padding_px, 1)
dx_norm = (max_lon - min_lon) / 360
dy_norm = abs(_mercator_y(max_lat) - _mercator_y(min_lat))
zoom_candidates = []
if dx_norm > 0:
zoom_candidates.append(math.log2(avail_w / (_MERCATOR_TILE_SIZE * dx_norm)))
if dy_norm > 0:
zoom_candidates.append(math.log2(avail_h / (_MERCATOR_TILE_SIZE * dy_norm)))
zoom = math.floor(min(zoom_candidates)) if zoom_candidates else max_zoom
zoom = max(0, min(zoom, max_zoom))
center = [(min_lat + max_lat) / 2, (min_lon + max_lon) / 2]
return center, zoom
def get_org_location_map(
dff: pl.DataFrame,
home_type: Literal["acheteur", "titulaire"],
map_id: str,
) -> dl.Map:
"""Carte cluster (dash-leaflet) d'un organisme et de sa contrepartie.
Affiche les marchés de `home_type` (fiche /acheteur ou /titulaire
consultée) ainsi que ceux de son type complémentaire, clusterisés et
colorés comme sur /observatoire. Cadrage calculé côté serveur
(`bounds_to_center_zoom`) sur l'ensemble des points ; repli sur une vue
France fixe si aucun point n'est disponible. Un center/zoom statiques
sont utilisés plutôt que la prop `bounds` de dash-leaflet, qui lève une
exception au premier montage du composant (bug connu de la librairie).
"""
lff = dff.lazy()
markers_by_type = {
"acheteur": build_org_markers(lff, "acheteur"),
"titulaire": build_org_markers(lff, "titulaire"),
}
for marker in markers_by_type[home_type]:
marker["is_home"] = True
ns = Namespace("dash_clientside", "leaflet")
point_to_layer = ns("pointToLayer")
cluster_to_layer = ns("clusterToLayer")
layers: list = [dl.TileLayer()]
all_points: list[tuple[float, float]] = []
# Ordre fixe (titulaire puis acheteur), comme sur /observatoire. Le point
# de l'organisme consulté (is_home) est de toute façon toujours ramené au
# premier plan côté client (pointToLayer, dash_clientside.js).
for org_type in ("titulaire", "acheteur"):
markers = markers_by_type[org_type]
if not markers:
continue
all_points.extend((m["lat"], m["lon"]) for m in markers)
layers.append(
dl.GeoJSON(
data=dlx.dicts_to_geojson(markers),
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"{map_id}-{org_type}",
options={"fillColor": ORG_COLORS[org_type]},
)
)
if all_points:
lats = [lat for lat, _ in all_points]
lons = [lon for _, lon in all_points]
center, zoom = bounds_to_center_zoom(min(lats), min(lons), max(lats), max(lons))
else:
center, zoom = [46.6, 2.2], 5
return dl.Map(
layers,
center=center,
zoom=zoom,
style={"width": "100%", "height": "300px"},
id=map_id,
)
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
if "titulaire_distance" not in lff.collect_schema().names():
dff = pl.DataFrame({"titulaire_distance": pl.Series([], dtype=pl.Float64)})
else:
dff = (
lff.select("titulaire_distance")
.drop_nulls()
.filter(pl.col("titulaire_distance") > 0)
.collect(engine="streaming")
)
log_distances = dff["titulaire_distance"].log(10).to_numpy()
fig = go.Figure()
if len(log_distances) > 0:
counts, bin_edges = np.histogram(log_distances, bins=25)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bin_widths = bin_edges[1:] - bin_edges[:-1]
bin_edges_km = 10.0**bin_edges
def fmt_km(km):
if km < 10:
return f"{km:.1f}"
elif km < 1000:
return f"{round(km)}"
else:
return f"{round(km):,}".replace(",", " ")
hover_texts = []
for i in range(len(counts)):
nb = f"{counts[i]:,}".replace(",", " ")
hover_texts.append(
f"Distance : {fmt_km(bin_edges_km[i])} {fmt_km(bin_edges_km[i + 1])} km"
f"<br>Nombre de marchés : {nb}"
)
fig.add_trace(
go.Bar(
x=bin_centers,
y=counts,
width=bin_widths,
hovertext=hover_texts,
hoverinfo="text",
)
)
fig.update_layout(bargap=0)
fig.update_layout(margin=dict(r=10, t=10), height=450)
fig.update_xaxes(
tickvals=[0, 1, 2, 3, 4],
ticktext=["1", "10", "100", "1 000", "10 000"],
title_text="Distance (km)",
)
fig.update_yaxes(title_text="Nombre de marchés")
return dcc.Graph(figure=fig)
def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
nb_acheteurs = dff.select("acheteur_id").n_unique()
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
total_montant = int(dff_per_uid.select(pl.col("montant").sum()).item())
median_distance = dff.select(pl.median("titulaire_distance")).item()
summary_table = [
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
html.P(
[
"Nombre d'acheteurs uniques : ",
html.Strong(str(format_number(nb_acheteurs))),
]
),
html.P(
[
"Nombre de titulaires uniques : ",
html.Strong(str(format_number(nb_titulaires))),
]
),
html.P(
[
"Montant total (",
html.Span(
"?",
id={"type": "modal-trigger", "index": "montant"},
style={"cursor": "pointer", "textDecoration": "underline dotted"},
),
") : ",
html.Strong(format_number(total_montant) + " €"),
]
),
html.P(
[
"Distance acheteur-titulaire médiane : ",
html.Strong(format_number(median_distance) + " km"),
]
),
]
return summary_table
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
CONSIDERATIONS_COLUMNS = {
"sociales": "considerationsSociales",
"environnementales": "considerationsEnvironnementales",
}
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int, int]]:
"""Statistiques considérations pour l'observatoire.
Clés renvoyées :
- "champs_renseignes" : (count_ren_sociales, pct / total)
- "{key}_renseignees" : (count_ren, pct positifs parmi renseignés)
Colonne absente -> (0, 0).
"""
names = lff.collect_schema().names()
present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names}
stats: dict[str, tuple[int, int, int]] = {"champs_renseignes": (0, 0)}
for key in CONSIDERATIONS_COLUMNS:
stats[f"{key}_renseignees"] = (0, 0)
if not present:
return stats
agg = (
lff.select(["uid"] + list(present.values()))
.group_by("uid")
.agg([pl.col(col).first() for col in present.values()])
.collect(engine="streaming")
)
total = agg.height
if total == 0:
return stats
for key, col in present.items():
count_ren = agg.filter(pl.col(col).is_not_null()).height
count_pos_ren = agg.filter(
pl.col(col).is_not_null() & (pl.col(col) != "Sans objet")
).height
pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0
stats[f"{key}_renseignees"] = (count_ren, count_pos_ren, pct_pos_ren)
if key == "sociales":
stats["champs_renseignes"] = (count_ren, round(100 * count_ren / total))
return stats
# (clé stats, libellé, couleur)
CONSIDERATIONS_RENSEIGNEES = [
("sociales_renseignees", "Considérations sociales", "#CC6677"),
("environnementales_renseignees", "Considérations environnementales", "#117733"),
]
def _progress_bar(pct: int, color: str) -> dbc.Progress:
return dbc.Progress(
dbc.Progress(
value=pct, label=f"{pct} %", bar=True, color=color, style={"color": "white"}
),
)
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
"""Trois barres : champs renseignés + part positive pour sociales et environnementales."""
stats = compute_considerations_stats(lff)
count_ren, pct_ren = stats["champs_renseignes"]
blocks = [
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span("Champs considérations renseignés"),
html.Span(
f"{format_number(count_ren)} marchés",
className="text-muted",
),
],
),
_progress_bar(pct_ren, "#6c757d"),
],
)
]
for key, label, color in CONSIDERATIONS_RENSEIGNEES:
total_count, count, pct = stats[key]
blocks.append(
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span(label),
html.Span(
f"dans {format_number(count)} marchés",
className="text-muted",
),
],
),
_progress_bar(pct, color),
],
)
)
return html.Div(children=blocks)
def make_card(
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
) -> dbc.Col:
children = []
if title:
children.append(html.H5(title, className="card-title"))
if subtitle:
children.append(html.H6(subtitle, className="card-subtitle mb-2 text-muted"))
if fig is not None:
children.append(fig)
if paragraphs:
for p in paragraphs:
p.className = "card-text"
children.append(p)
card = dbc.Col(
html.Div(html.Div(className="card-body", children=children), className="card"),
lg=lg,
xl=xl,
# width=width,
# className="mb-4",
)
return card
def make_donut(
lff: pl.LazyFrame,
names_col,
per_uid: bool,
nulls="?",
potentially_many_names: bool = False,
):
title = DATA_SCHEMA[names_col]["title"]
lff = lff.rename({names_col: title})
lff = lff.select("uid", title)
if per_uid:
lff = lff.group_by("uid").first()
lff = lff.group_by(title).len("Nombre")
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
dff = lff.collect(engine="streaming")
nb_names = dff[title].n_unique()
sum_values = dff["Nombre"].sum()
dff = dff.with_columns(
pl.when((pl.col("Nombre") / sum_values) < 0.01)
.then(pl.lit("Autres"))
.otherwise(pl.col(title))
.alias(title)
)
dff = dff.with_columns(
pl.col("Nombre")
.map_elements(format_number, return_dtype=pl.String)
.alias("Nombre_fmt")
)
fig = px.pie(
dff,
values="Nombre",
names=title,
hole=0.4,
color_discrete_sequence=px.colors.qualitative.Safe,
custom_data=["Nombre_fmt"],
)
fig = fig.update_traces(
texttemplate="<b>%{label}</b><br><b>%{percent}</b>",
hovertemplate="<b>%{label}</b><br>%{customdata[0]}<extra></extra>",
)
fig = fig.update_layout(showlegend=False, font=dict(size=14))
graph = dcc.Graph(figure=fig)
if potentially_many_names:
return graph, nb_names
return graph
def make_column_picker(page: str):
table_data = []
for col in schema.names():
column_data_schema = DATA_SCHEMA[col]
column_data = {
"id": col,
"name": column_data_schema["title"],
"description": column_data_schema["description"],
}
table_data.append(column_data)
table = (
DataTable(
row_selectable="multi",
data=table_data,
filter_action="native",
sort_action="none",
style_cell={
"textAlign": "left",
},
columns=[
{
"name": "Nom",
"id": "name",
},
{
"name": "Description",
"id": "description",
},
],
style_cell_conditional=[
{
"if": {"column_id": "description"},
"minWidth": "450px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
}
],
page_action="none",
dtid=f"{page}_column_list",
),
)
return table
def _top_org_aggregate(data, org_type: str, extra_columns: list):
"""Agrégation « top N » commune à get_top_org_table et get_top_org_ag_grid.
Renvoie le DataFrame agrégé (colonnes castées en str, tri par Attributions
décroissant), AVANT add_links, ou None si vide/colonne manquante.
"""
if isinstance(data, pl.LazyFrame):
lff = data
else:
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
extra = list(extra_columns) # copie : ne pas muter la liste du caller
if org_type == "titulaire":
extra.append("titulaire_typeIdentifiant")
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra
lff = lff.select(columns)
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra).agg(
pl.len().alias("Attributions")
)
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
lff = lff.cast(pl.String)
lff = lff.fill_null("")
try:
dff: pl.DataFrame = lff.collect(engine="streaming")
except ColumnNotFoundError as e:
# Ne pas rappeler lff.collect_schema() ici : sur le même plan lazy
# cassé, la résolution du schéma lève à nouveau ColumnNotFoundError
# (non rattrapée), au lieu de renvoyer None comme prévu.
logger.warning(f"_top_org_aggregate: column not found. {e}")
return None
return dff if dff.height > 0 else None
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
dff = _top_org_aggregate(data, org_type, extra_columns)
if dff is None:
return html.Div()
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=[f"{org_type}_id"]
)
dff = add_links(dff)
data = dff.to_dicts()
return DataTable(
dtid=f"top10_{org_type}",
data=data,
page_action="native",
page_size=10,
columns=columns,
tooltip_header=tooltip,
filter_action="native" if filters else "none",
)
def get_top_org_ag_grid(data, org_type: str, extra_columns: list, filters: bool = True):
"""Top N acheteurs/titulaires en AG Grid client-side (rowData directe).
Remplace get_top_org_table (dash_table) : même agrégation (helper partagé),
rendu AG Grid (thème brique, liens markdown). html.Div() si vide/erreur.
"""
dff = _top_org_aggregate(data, org_type, extra_columns)
if dff is None:
return html.Div()
dff = add_links(dff)
# columnDefs : on masque l'id (lien porté par le nom), on rend le nom en
# markdown (HTML <a>), on cache les colonnes techniques *_tooltip.
id_col = f"{org_type}_id"
nom_col = f"{org_type}_nom"
link_cols = {nom_col}
column_defs = []
for col in dff.columns:
if col == id_col or col.endswith("_tooltip"):
continue
col_def = {
"field": col,
"headerName": DATA_SCHEMA.get(col, {}).get("title", col),
"sortable": True,
"filter": "agTextColumnFilter" if filters else False,
}
if col in link_cols:
col_def["cellRenderer"] = "markdown"
col_def["flex"] = 1
column_defs.append(col_def)
# ag_grid() du Lot 1 est server-side (rowModelType="infinite", pas de
# rowData) : inadapté au top 10, qui est client-side. On construit donc une
# AG Grid client-side directe, en réutilisant le thème + localeText.
return dag.AgGrid(
id=f"top10_{org_type}",
columnDefs=column_defs,
rowData=dff.to_dicts(),
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
columnSize="responsiveSizeToFit",
dashGridOptions={
"domLayout": "autoHeight", # OK ici : row model client-side
"pagination": True,
"paginationPageSize": 10,
"suppressCellFocus": True,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
"themeQuartz.withParams({"
"accentColor: 'rgb(179, 56, 33)',"
"headerTextColor: 'white',"
"headerBackgroundColor: 'rgb(179, 56, 33)',"
"oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
"borderColor: '#ccc',"
"fontFamily: 'Inter, sans-serif',"
"fontSize: 16"
"})"
)
},
},
style={"width": "100%"},
persistence=False,
)
# Libellés du menu de filtre AG Grid, traduits en français (option native
# AG Grid localeText, exposée via dashGridOptions — n'affecte pas l'apparence
# de base conservée au Lot 1, seulement le texte).
AG_GRID_LOCALE_FR = {
# Filtres texte / nombre / date
"contains": "Contient",
"notContains": "Ne contient pas",
"equals": "Égal à",
"notEqual": "Différent de",
"startsWith": "Commence par",
"endsWith": "Se termine par",
"blank": "Vide",
"notBlank": "Non vide",
"empty": "Choisir une option",
"lessThan": "Inférieur à",
"lessThanOrEqual": "Inférieur ou égal à",
"greaterThan": "Supérieur à",
"greaterThanOrEqual": "Supérieur ou égal à",
"inRange": "Entre",
"inRangeStart": "de",
"inRangeEnd": "à",
"before": "Avant",
"after": "Après",
# Chrome du menu de filtre
"filterOoo": "Filtrer...",
"applyFilter": "Appliquer",
"resetFilter": "Réinitialiser",
"clearFilter": "Effacer",
"cancelFilter": "Annuler",
"andCondition": "ET",
"orCondition": "OU",
"noRowsToShow": "Aucune ligne à afficher",
"loadingOoo": "Chargement...",
}
def ag_grid(
grid_id: "str | dict",
column_defs: list[dict],
persisted_props=("filterModel", "columnState"),
persistence: bool = True,
) -> "dag.AgGrid":
"""Grille AG Grid server-side (infinite) pour la page Tableau.
Thème aligné sur les dash_table.DataTable du reste du site (en-tête
rouge brique, lignes alternées, police Inter) ; libellés de filtre
traduits en français via localeText.
"""
return dag.AgGrid(
id=grid_id,
columnDefs=column_defs,
defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True},
rowModelType="infinite",
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
dashGridOptions={
"cacheBlockSize": 100,
"maxBlocksInCache": 10,
"rowBuffer": 0,
"infiniteInitialRowCount": 100,
# rowHeight fixe (pas autoHeight, non supporté par rowModelType
# "infinite") pour laisser la place au texte replié à la ligne
# de la colonne "objet" (cf. grid_column_defs).
"rowHeight": 60,
"suppressCellFocus": True,
"enableCellTextSelection": True,
"ensureDomOrder": True,
# Permet de sélectionner/copier le texte des infobulles (ex.
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
"tooltipInteraction": True,
# Délai avant apparition des infobulles natives (200ms au lieu
# des 2000ms par défaut d'AG Grid).
"tooltipShowDelay": 200,
"localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
"themeQuartz.withParams({"
"accentColor: 'rgb(179, 56, 33)',"
"headerTextColor: 'white',"
"headerBackgroundColor: 'rgb(179, 56, 33)',"
"oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
"borderColor: '#ccc',"
"fontFamily: 'Inter, sans-serif',"
# "headerFontFamily: '\"Inter Tight\", sans-serif',"
"fontSize: 16"
"})"
)
},
# Réserve l'espace de la barre de défilement horizontale en permanence
# (comportement natif de Chrome). Sans effet sur Firefox : le curseur
# de la barre y reste en overlay au survol, géré par l'OS/le navigateur
# (ex. réglage GTK overlay-scrollbars), non contrôlable par l'appli.
"alwaysShowHorizontalScroll": True,
},
# Pas de columnSize="responsiveSizeToFit" : les colonnes gardent leur
# largeur définie dans columnDefs (cf. _column_width dans
# src.utils.grid) même à 50 colonnes affichées, quitte à faire
# apparaître le défilement horizontal natif d'AG Grid plutôt que de
# les compresser/étirer toutes à la même largeur.
style={"height": "70vh", "width": "100%"},
persistence=persistence,
persistence_type="local",
persisted_props=list(persisted_props),
)