Style inputs, ajout donut type marché #65

This commit is contained in:
Colin Maudry
2026-03-18 13:38:38 +01:00
parent d50ec5b01e
commit 2f90a754ab
3 changed files with 271 additions and 108 deletions
+10
View File
@@ -197,6 +197,16 @@ p.version > a {
background-color: rgba(255, 240, 240, 0.4);
}
#filters .col > * {
margin-bottom: 6px;
}
#filters input[type="text"] {
border: 1px #ccc solid;
border-radius: 3px;
padding-left: 8px;
}
/* --- Tables (Dash & Custom) --- */
/* Table Menu (Exports etc) */
+50 -45
View File
@@ -52,19 +52,18 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
return html.Div(children=table, className="marches_table")
def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
lf = df_source.lazy()
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
}
lf = lf.select("uid", type_date, "sourceDataset")
lff = lff.select("uid", type_date, "sourceDataset")
lf = lf.unique("uid")
lff = lff.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lf = lf.with_columns(
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
@@ -72,34 +71,34 @@ def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lf = lf.with_columns(
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")
)
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
lf = lf.filter(
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, 2025)
)
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lf = (
lf.group_by([type_date, "sourceDataset"])
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)
)
# lf = lf.with_columns(
# lff = lff.with_columns(
# pl.when(pl.col("sourceDataset").is_null()).then(
# pl.lit("Source inconnue")).alias("sourceDataset")
# )
lf = lf.sort(by=["sourceDataset"], descending=False)
df: pl.DataFrame = lf.collect(engine="streaming")
lff = lff.sort(by=["sourceDataset"], descending=False)
dff: pl.DataFrame = lff.collect(engine="streaming")
fig = px.bar(
df,
dff,
x=type_date,
y="len",
color="sourceDataset",
@@ -111,7 +110,9 @@ def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
},
)
return fig
graph = dcc.Graph(figure=fig)
return graph
def get_sources_tables(source_path) -> html.Div:
@@ -301,33 +302,24 @@ class DataTable(dash_table.DataTable):
)
def get_duplicate_matrix() -> html.Div:
def get_duplicate_matrix() -> dcc.Graph:
"""
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
:return:
"""
result_df = pl.read_parquet(
lff = pl.scan_parquet(
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
).sort("sourceDataset")
result_df = result_df.select(
["sourceDataset", "unique"] + sorted(result_df.columns[2:])
lff = lff.select(
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
)
description = dcc.Markdown("""
Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source. Il s'appuie sur les identifiants `uid` qui sont pour chaque marché la concaténation du SIRET de l'acheteur et de l'identifiant interne du marché.
**Comment lire ce graphique ?**
On part des codes de sources de données en ordonnée. Ces jeux de données sont documentés dans [À propos](/a-propos#sources).
La première colonne (**unique**) représente le pourcentage de marchés fournis par cette source qui sont uniquement disponibles dans cette source. Plus le rouge est foncé, plus important est le pourcentage. Donc, à l'inverse, plus le rouge est clair dans la première colonne, plus la source en ordonnée a des marchés en commun avec d'autres sources, et donc plus on trouvera sur la même ligne d'autres cases plus ou moins foncées qui indiqueront avec quelles autres sources cette source partage des marchés.
Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""")
dff = lff.collect()
# Extract data
z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
x_labels = result_df.columns[1:] # columns after "sourceDataset"
y_labels = result_df["sourceDataset"].to_list()
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(
@@ -345,7 +337,7 @@ def get_duplicate_matrix() -> html.Div:
hoverongaps=False,
showscale=True,
hovertemplate=(
"<b>%{z:.0%}</b> des marchés de <b>%{y}</b> sont également présents dans <b>%{x}</b>"
"<b>%{z:.0%}</b> des marchés présents dans <b>%{y}</b> sont également présents dans <b>%{x}</b>"
),
)
)
@@ -364,13 +356,7 @@ def get_duplicate_matrix() -> html.Div:
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
)
return html.Div(
children=[
html.H3("Doublons de marchés entre les sources"),
description,
dcc.Graph(figure=fig),
]
)
return dcc.Graph(figure=fig)
def get_geographic_maps(dff: pl.DataFrame) -> list | None:
@@ -624,20 +610,39 @@ def make_card(
return card
def make_donut(lff: pl.LazyFrame, names_col):
def make_donut(
lff: pl.LazyFrame,
names_col,
per_uid: bool,
nulls="?",
):
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("?")))
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
dff = lff.collect(engine="streaming")
dff = dff.with_columns(
pl.col("Nombre")
.map_elements(format_number, return_dtype=pl.String)
.alias("Nombre_fmt")
)
fig = px.pie(
lff.collect(engine="streaming"),
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_traces(texttemplate="<b>%{label}</b><br><b>%{percent}</b>")
fig = fig.update_layout(showlegend=False, font=dict(size=14))
graph = dcc.Graph(figure=fig)
return graph
+211 -63
View File
@@ -3,9 +3,15 @@ from datetime import datetime, timedelta
import dash_bootstrap_components as dbc
import polars as pl
import polars.selectors as cs
from dash import Input, Output, callback, dcc, html, register_page
from dash import ALL, Input, Output, callback, ctx, dcc, html, register_page
from src.figures import get_geographic_maps, make_card, make_donut
from src.figures import (
get_barchart_sources,
get_duplicate_matrix,
get_geographic_maps,
make_card,
make_donut,
)
from src.utils import (
departements,
df,
@@ -38,6 +44,29 @@ for code, obj in departements.items():
layout = [
dcc.Store(id="dashboard-filters"),
dcc.Location(id="dashboard_url"),
dbc.Modal(
[
dbc.ModalHeader(dbc.ModalTitle("Montants")),
dbc.ModalBody(
[
dcc.Markdown(
"""
Les données saisies et publiées par les acheteurs comportent de nombreux montants farfelus qui sabotent les statistiques, au lieu de montants estimés avec rigueur. On parle de montant atteignant parfois les millions de milliards. Certains réutilisateurs mettent de côté ces marchés ou bien modifient les montants selon des règles fatalement arbitraires. J'ai fait le choix de ne quasiment pas modifier les données* afin de visibiliser le problème.
Alors, on fait comment ?
\* Les montants composés de plus de 11 chiffres, sans les décimales, [sont ramenés](https://github.com/ColinMaudry/decp-processing/blob/main/src/tasks/clean.py#L63-L71) à 12 311 111 111, un nombre qui reste très élevé et qui est facilement reconnaissable.
"""
),
]
),
dbc.ModalFooter(
dbc.Button("Fermer", id="montant-modal-close", className="ms-auto")
),
],
id="montant-modal",
is_open=False,
),
html.Div(
className="container-fluid",
children=[
@@ -56,66 +85,109 @@ layout = [
children=[
html.H5("Période d'attribution"),
dbc.Row(
dcc.Dropdown(
id="dashboard_year",
options=options_years,
placeholder="12 derniers mois",
dbc.Col(
dcc.Dropdown(
id="dashboard_year",
options=options_years,
placeholder="12 derniers mois",
),
),
),
html.H5("Acheteur"),
dbc.Row(
dcc.Dropdown(
id="dashboard_acheteur_categorie",
options=get_enum_values_as_dict(
"acheteur_categorie"
dbc.Col(
dcc.Input(
id="dashboard_acheteur_id",
placeholder="SIRET",
style={"width": "100%"},
),
placeholder="Catégorie",
),
),
dbc.Row(
dcc.Dropdown(
id="dashboard_acheteur_departement_code",
searchable=True,
multi=True,
placeholder="Code département acheteur",
options=options_departements,
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_categorie",
options=get_enum_values_as_dict(
"acheteur_categorie"
),
placeholder="Catégorie",
)
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_acheteur_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=options_departements,
),
),
),
html.H5("Titulaire"),
dbc.Row(
dcc.Dropdown(
id="dashboard_titulaire_categorie",
placeholder="Catégorie",
options=get_enum_values_as_dict(
"titulaire_categorie"
dbc.Col(
dcc.Input(
id="dashboard_titulaire_id",
placeholder="SIRET",
style={"width": "100%"},
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_categorie",
placeholder="Catégorie",
options=get_enum_values_as_dict(
"titulaire_categorie"
),
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_titulaire_departement_code",
searchable=True,
multi=True,
placeholder="Département",
options=options_departements,
),
),
),
html.H5("Marché"),
dbc.Row(
dcc.Dropdown(
id="dashboard_marche_type",
placeholder="Type",
options=get_enum_values_as_dict("type"),
),
),
dbc.Row(
dcc.Dropdown(
id="dashboard_marche_considerationsSociales",
placeholder="Considérations sociales",
options=get_enum_values_as_dict(
"considerationsSociales"
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_type",
placeholder="Type",
options=get_enum_values_as_dict("type"),
),
multi=True,
),
),
dbc.Row(
dcc.Dropdown(
id="dashboard_marche_considerationsEnvironnementales",
placeholder="Considérations environnementales",
multi=True,
options=get_enum_values_as_dict(
"considerationsEnvironnementales"
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerationsSociales",
placeholder="Considérations sociales",
options=get_enum_values_as_dict(
"considerationsSociales"
),
multi=True,
),
),
),
dbc.Row(
dbc.Col(
dcc.Dropdown(
id="dashboard_marche_considerationsEnvironnementales",
placeholder="Considérations environnementales",
multi=True,
options=get_enum_values_as_dict(
"considerationsEnvironnementales"
),
),
),
),
@@ -140,21 +212,27 @@ layout = [
@callback(
Output("cards", "children"),
Input("dashboard_year", "value"),
Input("dashboard_acheteur_id", "value"),
Input("dashboard_acheteur_categorie", "value"),
Input("dashboard_acheteur_departement_code", "value"),
Input("dashboard_titulaire_id", "value"),
Input("dashboard_titulaire_categorie", "value"),
Input("dashboard_titulaire_departement_code", "value"),
Input("dashboard_marche_type", "value"),
Input("dashboard_marche_considerationsSociales", "value"),
Input("dashboard_marche_considerationsEnvironnementales", "value"),
)
def udpate_dashboard_cards(
dashboard_year,
dashboard_acheteur_id,
dashboard_acheteur_categorie,
dashboard_acheteur_departement_code,
dashboard_titulaire_id,
dashboard_titulaire_categorie,
dashboard_titulaire_departement_code,
dashboard_marche_type,
dashboard_marche_considerationsSociales,
dashboard_marche_considerationsEnvironnementales,
dashboard_marche_considerations_sociales,
dashboard_marche_considerations_environnementales,
):
lff: pl.LazyFrame = df.lazy()
lff = lff.select(
@@ -165,6 +243,8 @@ def udpate_dashboard_cards(
"montant",
"considerationsSociales",
"considerationsEnvironnementales",
"sourceDataset",
"type",
)
# Application des filtres
@@ -180,40 +260,57 @@ def udpate_dashboard_cards(
## Acheteur
if dashboard_acheteur_categorie:
lff = lff.filter(pl.col("acheteur_categorie") == dashboard_acheteur_categorie)
if dashboard_acheteur_departement_code:
lff = lff.filter(
pl.col("acheteur_departement_code").is_in(
dashboard_acheteur_departement_code
if dashboard_acheteur_id:
lff = lff.filter(pl.col("acheteur_id").str.contains(dashboard_acheteur_id))
else:
if dashboard_acheteur_categorie:
lff = lff.filter(
pl.col("acheteur_categorie") == dashboard_acheteur_categorie
)
if dashboard_acheteur_departement_code:
lff = lff.filter(
pl.col("acheteur_departement_code").is_in(
dashboard_acheteur_departement_code
)
)
)
## Titulaire
if dashboard_titulaire_categorie:
lff = lff.filter(pl.col("titulaire_categorie") == dashboard_titulaire_categorie)
if dashboard_titulaire_id:
lff = lff.filter(pl.col("titulaire_id").str.contains(dashboard_titulaire_id))
else:
if dashboard_titulaire_categorie:
lff = lff.filter(
pl.col("titulaire_categorie") == dashboard_titulaire_categorie
)
if dashboard_titulaire_departement_code:
lff = lff.filter(
pl.col("titulaire_departement_code").is_in(
dashboard_titulaire_departement_code
)
)
## Marché
if dashboard_marche_type:
lff = lff.filter(pl.col("type") == dashboard_marche_type)
if dashboard_marche_considerationsSociales:
if dashboard_marche_considerations_sociales:
lff = lff.filter(
pl.col("considerationsSociales")
.str.split(", ")
.list.set_intersection(dashboard_marche_considerationsSociales)
.list.set_intersection(dashboard_marche_considerations_sociales)
.list.len()
> 0
)
if dashboard_marche_considerationsEnvironnementales:
if dashboard_marche_considerations_environnementales:
lff = lff.filter(
pl.col("considerationsEnvironnementales")
.str.split(", ")
.list.set_intersection(dashboard_marche_considerationsEnvironnementales)
.list.set_intersection(dashboard_marche_considerations_environnementales)
.list.len()
> 0
)
@@ -228,7 +325,8 @@ def udpate_dashboard_cards(
df_per_uid = (
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
)
total_montant = df_per_uid.select(pl.col("montant").sum()).item()
total_montant = int(df_per_uid.select(pl.col("montant").sum()).item())
nb_marches = df_per_uid.height
cards = []
@@ -241,19 +339,69 @@ def udpate_dashboard_cards(
html.P(
["Nombre de titulaires : ", html.Strong(str(format_number(nb_titulaires)))]
),
html.P(["Montant total : ", html.Strong(format_number(total_montant) + "")]),
html.P(
[
"Montant total (",
html.Span(
"?",
id={"type": "modal-trigger", "index": "montant"},
style={"cursor": "pointer", "textDecoration": "underline dotted"},
),
") : ",
html.Strong(format_number(total_montant) + ""),
]
),
]
cards.append(make_card(title="Résumé", paragraphs=card_basic_counts))
donut_acheteur_categorie = make_donut(lff, "acheteur_categorie")
cards.append(make_card(title="Catégorie d'acheteur", fig=donut_acheteur_categorie))
donut_acheteur_categorie = make_donut(
lff, "acheteur_categorie", nulls="Autres", per_uid=True
)
cards.append(
make_card(
title="Catégorie d'acheteur", fig=donut_acheteur_categorie, lg=12, xl=8
)
)
donut_titulaire_categorie = make_donut(lff, "titulaire_categorie")
donut_titulaire_categorie = make_donut(
lff, "titulaire_categorie", per_uid=False, nulls="?"
)
cards.append(
make_card(title="Catégorie d'entreprise", fig=donut_titulaire_categorie)
)
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(make_card(title="Type d'achat", fig=donut_marche_type))
geographic_maps: list[dbc.Col] = get_geographic_maps(dff)
return dbc.Row(children=cards + geographic_maps)
other_cards = []
sources_barchart = get_barchart_sources(lff, type_date="dateNotification")
other_cards.append(
make_card(title="Sources de données", fig=sources_barchart, lg=12, xl=8)
)
duplicate_matrix = get_duplicate_matrix()
other_cards.append(
make_card(
title="Matrice de doublons entre sources de données",
subtitle="Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source.",
fig=duplicate_matrix,
lg=12,
xl=8,
)
)
return dbc.Row(children=cards + geographic_maps + other_cards)
@callback(
Output("montant-modal", "is_open"),
Input({"type": "modal-trigger", "index": ALL}, "n_clicks"),
Input("montant-modal-close", "n_clicks"),
prevent_initial_call=True,
)
def toggle_montant_modal(n_triggers, _close):
return isinstance(ctx.triggered_id, dict) and any(n_triggers)