Début de dashboard avec quelques filtres et viz #65

This commit is contained in:
Colin Maudry
2026-03-13 19:22:48 +01:00
parent 4f19085b75
commit c7d1a5ec73
4 changed files with 290 additions and 57 deletions
+4
View File
@@ -151,6 +151,10 @@ p.version > a {
max-width: 900px;
}
.seeBorder {
border: dotted 1px green;
}
/* --- Search Page --- */
.tagline {
text-align: center;
+126 -6
View File
@@ -1,6 +1,8 @@
import json
from typing import Literal
from urllib.error import HTTPError, URLError
import dash_bootstrap_components as dbc
import plotly.express as px
import plotly.graph_objects as go
import polars as pl
@@ -161,8 +163,11 @@ def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
def get_sources_tables(source_path) -> html.Div:
df = pl.read_csv(source_path)
df = df.with_columns(
try:
dff = pl.read_csv(source_path)
except (URLError, HTTPError):
return html.Div("Erreur de connexion")
dff = dff.with_columns(
(
pl.lit('<a href = "')
+ pl.col("url")
@@ -171,8 +176,8 @@ def get_sources_tables(source_path) -> html.Div:
+ pl.lit("</a>")
).alias("nom")
)
df = df.drop("url", "unique")
df = df.sort(by=["nb_marchés"], descending=True)
dff = dff.drop("url", "unique")
dff = dff.sort(by=["nb_marchés"], descending=True)
columns = {
"nom": "Nom de la source",
@@ -184,7 +189,7 @@ def get_sources_tables(source_path) -> html.Div:
datatable = dash_table.DataTable(
id="source_table",
data=df.to_dicts(),
data=dff.to_dicts(),
columns=[
{
"name": columns[i],
@@ -193,7 +198,7 @@ def get_sources_tables(source_path) -> html.Div:
"type": "text",
"format": {"nully": "N/A"},
}
for i in df.schema.names()
for i in dff.schema.names()
],
style_cell_conditional=[
{
@@ -416,6 +421,121 @@ def get_duplicate_matrix() -> html.Div:
)
def get_geographic_maps(dff: pl.DataFrame) -> list | None:
"""
Génère les cartes géographiques pour la métropole et les DOM-TOM.
"""
# Seulement si les données ne sont pas trop importantes
if dff.height > 10000:
return []
# Liste des codes départements Outre-Mer
dom_codes = ["971", "972", "973", "974", "976"]
# Couleurs accessibles (Okabe-Ito)
color_acheteur = "#E69F00" # Orange
color_titulaire = "#56B4E9" # Bleu ciel
regions = {
"Métropole": dff.filter(~pl.col("acheteur_departement_code").is_in(dom_codes))
}
# Ajout des DOM s'ils ont des données
for code in dom_codes:
dom_data = dff.filter(pl.col("acheteur_departement_code") == code)
if dom_data.height > 0:
name = f"Département {code}"
if code == "971":
name = "Guadeloupe"
elif code == "972":
name = "Martinique"
elif code == "973":
name = "Guyane"
elif code == "974":
name = "La Réunion"
elif code == "976":
name = "Mayotte"
regions[name] = dom_data
cols = []
for name, region_df in regions.items():
fig = go.Figure()
# Trace Acheteurs
mask_acheteur = region_df.filter(
pl.col("acheteur_latitude").is_not_null()
& pl.col("acheteur_longitude").is_not_null()
)
if mask_acheteur.height > 0:
fig.add_trace(
go.Scattergeo(
lat=mask_acheteur["acheteur_latitude"],
lon=mask_acheteur["acheteur_longitude"],
mode="markers",
marker=dict(size=6, color=color_acheteur, opacity=0.5),
name="Acheteurs",
text=mask_acheteur["acheteur_nom"],
)
)
# Trace Titulaires
mask_titulaire = region_df.filter(
pl.col("titulaire_latitude").is_not_null()
& pl.col("titulaire_longitude").is_not_null()
)
if mask_titulaire.height > 0:
fig.add_trace(
go.Scattergeo(
lat=mask_titulaire["titulaire_latitude"],
lon=mask_titulaire["titulaire_longitude"],
mode="markers",
marker=dict(size=6, color=color_titulaire, opacity=0.5),
name="Titulaires",
text=mask_titulaire["titulaire_nom"],
)
)
# Configuration spécifique de la vue
geo_config = dict(
projection_type="mercator",
showland=True,
landcolor="lightgray",
showcountries=True,
countrycolor="white",
fitbounds="locations" if name != "Métropole" else False,
resolution=50,
)
if name == "Métropole":
geo_config["lataxis_range"] = [41, 52]
geo_config["lonaxis_range"] = [-5, 10]
fig.update_layout(
title=name,
geo=geo_config,
margin=dict(l=0, r=0, t=30, b=0),
height=400 if name == "Métropole" else 300,
showlegend=(name == "Métropole"),
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
)
# Taille de la colonne : 4 slots (md=12) pour Métropole, 1 slot (md=3) pour DOM
# On suppose une grille de 12 colonnes où 1 card = md=3
col_width = 12 if name == "Métropole" else 3
cols.append(
dbc.Col(
dcc.Graph(figure=fig, config={"displayModeBar": False}),
width=12,
md=col_width,
className="mb-4",
)
)
return cols
def make_column_picker(page: str):
table_data = []
table_columns = [
+150 -51
View File
@@ -1,14 +1,20 @@
from datetime import datetime
from datetime import datetime, timedelta
from dash import dcc, html, register_page
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 src.figures import (
get_barchart_sources,
get_duplicate_matrix,
get_map_count_marches,
get_yearly_statistics,
get_geographic_maps,
)
from src.utils import (
departements,
df,
format_number,
get_enum_values_as_dict,
meta_content,
)
from src.utils import df, format_number, get_statistics, meta_content
name = "Statistiques"
@@ -21,13 +27,21 @@ register_page(
image_url=meta_content["image_url"],
order=3,
)
options_years = {}
for year in reversed(range(2017, datetime.now().year + 1)):
year = str(year)
options_years[year] = year
options_departements = {}
for code, obj in departements.items():
options_departements[code] = f"{obj['departement']} ({code})"
statistics: dict = get_statistics()
today_str = datetime.fromisoformat(statistics["datetime"]).strftime("%d/%m/%Y")
layout = [
dcc.Store(id="dashboard-filters"),
dcc.Location(id="dashboard_url"),
html.Div(
className="container",
className="container-fluid",
children=[
html.H2(name),
dcc.Loading(
@@ -35,51 +49,136 @@ layout = [
id="loading-statistques",
type="default",
children=[
html.Div(
dbc.Row(
[
dbc.Col(
width=12,
md=3,
id="filters",
children=[
dcc.Markdown(f"""
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
html.H5("Période d'attribution"),
dbc.Row(
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"
),
placeholder="Catégorie d'acheteur",
),
),
dbc.Row(
dcc.Dropdown(
id="dashboard_acheteur_departement_code",
searchable=True,
multi=True,
placeholder="Code département acheteur",
options=options_departements,
),
),
],
),
dbc.Col(
width=12,
md=9,
id="cards",
children=[
dbc.Row(
(
dbc.Col(
width=6,
md=4,
className="card",
id="card_basic_counts",
)
),
className="mb-4",
),
dbc.Row(id="maps_row"),
],
),
]
)
],
),
],
),
]
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20label%3A%22source%20de%20donn%C3%A9es%22),
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}).
"""),
html.H3(
"Statistiques générales sur les marchés",
id="marches",
@callback(
Output("card_basic_counts", "children"),
Output("maps_row", "children"),
Input("dashboard_year", "value"),
Input("dashboard_acheteur_categorie", "value"),
Input("dashboard_acheteur_departement_code", "value"),
)
def udpate_dashboard_cards(
dashboard_year, dashboard_acheteur_categorie, dashboard_acheteur_departement_code
):
lff: pl.LazyFrame = df.lazy()
lff = lff.select(
"uid",
cs.starts_with("acheteur"),
cs.starts_with("titulaire"),
"dateNotification",
"montant",
)
# Application des filtres
if dashboard_year:
lff = lff.filter(pl.col("dateNotification").dt.year() == int(dashboard_year))
else:
lff = lff.filter(
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
)
if dashboard_acheteur_categorie:
lff = lff.filter(
pl.col("acheteur_categorie").is_in(dashboard_acheteur_categorie)
)
if (
dashboard_acheteur_departement_code
and len(dashboard_acheteur_departement_code) >= 2
):
lff = lff.filter(
pl.col("acheteur_departement_code").is_in(
dashboard_acheteur_departement_code
)
)
# Génération des métriques
dff = lff.collect()
nb_acheteurs = dff.select("acheteur_id").n_unique()
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
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()
nb_marches = df_per_uid.height
card_basic_counts = [
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
html.P(
["Nombre d'acheteurs : ", html.Strong(str(format_number(nb_acheteurs)))]
),
html.P(
"À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur."
["Nombre de titulaires : ", html.Strong(str(format_number(nb_titulaires)))]
),
html.H4("Statistiques cumulées"),
dcc.Markdown(f"""
- Nombre de marchés publics et accord-cadres : {format_number(statistics["nb_marches"])}
- Nombre d'acheteurs publics (SIRET) : {format_number(statistics["nb_acheteurs_uniques"])}
- Nombre de titulaires (SIRET) : {format_number(statistics["nb_titulaires_uniques"])}
Je ne publie pas encore de statistiques sur les montants de marchés car je n'ai pas encore trouvé la bonne formule pour traiter les trop nombreux montants fantaisistes qui polluent les calculs.
"""),
html.H4("Statistiques par année"),
get_yearly_statistics(statistics, today_str),
dcc.Graph(figure=get_map_count_marches()),
get_duplicate_matrix(),
html.H3("Nombre de marchés par source dans le temps"),
dcc.Graph(
figure=get_barchart_sources(df, "dateNotification")
),
dcc.Graph(
figure=get_barchart_sources(
df, "datePublicationDonnees"
)
),
],
)
],
),
],
)
html.P(["Montant total : ", html.Strong(format_number(total_montant) + "")]),
]
geographic_maps = get_geographic_maps(dff)
return card_basic_counts, geographic_maps
+10
View File
@@ -685,6 +685,16 @@ def get_button_properties(height):
return download_disabled, download_text, download_title
def get_enum_values_as_dict(column_name):
for column in data_schema:
if column == column_name:
options = {}
for value in data_schema[column]["enum"]:
options[value] = value
return options
return {"not_found": "not found"}
def invert_columns(columns):
"""
Renvoie les colonnes du schéma non spécifiées en paramètre. Utile pour passer d'une colonnes masquées à une liste de colonnes affichées, et vice versa.