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; max-width: 900px;
} }
.seeBorder {
border: dotted 1px green;
}
/* --- Search Page --- */ /* --- Search Page --- */
.tagline { .tagline {
text-align: center; text-align: center;
+126 -6
View File
@@ -1,6 +1,8 @@
import json import json
from typing import Literal from typing import Literal
from urllib.error import HTTPError, URLError
import dash_bootstrap_components as dbc
import plotly.express as px import plotly.express as px
import plotly.graph_objects as go import plotly.graph_objects as go
import polars as pl 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: def get_sources_tables(source_path) -> html.Div:
df = pl.read_csv(source_path) try:
df = df.with_columns( dff = pl.read_csv(source_path)
except (URLError, HTTPError):
return html.Div("Erreur de connexion")
dff = dff.with_columns(
( (
pl.lit('<a href = "') pl.lit('<a href = "')
+ pl.col("url") + pl.col("url")
@@ -171,8 +176,8 @@ def get_sources_tables(source_path) -> html.Div:
+ pl.lit("</a>") + pl.lit("</a>")
).alias("nom") ).alias("nom")
) )
df = df.drop("url", "unique") dff = dff.drop("url", "unique")
df = df.sort(by=["nb_marchés"], descending=True) dff = dff.sort(by=["nb_marchés"], descending=True)
columns = { columns = {
"nom": "Nom de la source", "nom": "Nom de la source",
@@ -184,7 +189,7 @@ def get_sources_tables(source_path) -> html.Div:
datatable = dash_table.DataTable( datatable = dash_table.DataTable(
id="source_table", id="source_table",
data=df.to_dicts(), data=dff.to_dicts(),
columns=[ columns=[
{ {
"name": columns[i], "name": columns[i],
@@ -193,7 +198,7 @@ def get_sources_tables(source_path) -> html.Div:
"type": "text", "type": "text",
"format": {"nully": "N/A"}, "format": {"nully": "N/A"},
} }
for i in df.schema.names() for i in dff.schema.names()
], ],
style_cell_conditional=[ 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): def make_column_picker(page: str):
table_data = [] table_data = []
table_columns = [ 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 ( from src.figures import (
get_barchart_sources, get_geographic_maps,
get_duplicate_matrix, )
get_map_count_marches, from src.utils import (
get_yearly_statistics, departements,
df,
format_number,
get_enum_values_as_dict,
meta_content,
) )
from src.utils import df, format_number, get_statistics, meta_content
name = "Statistiques" name = "Statistiques"
@@ -21,13 +27,21 @@ register_page(
image_url=meta_content["image_url"], image_url=meta_content["image_url"],
order=3, 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 = [ layout = [
dcc.Store(id="dashboard-filters"),
dcc.Location(id="dashboard_url"),
html.Div( html.Div(
className="container", className="container-fluid",
children=[ children=[
html.H2(name), html.H2(name),
dcc.Loading( dcc.Loading(
@@ -35,51 +49,136 @@ layout = [
id="loading-statistques", id="loading-statistques",
type="default", type="default",
children=[ children=[
html.Div( dbc.Row(
children=[ [
dcc.Markdown(f""" dbc.Col(
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par width=12,
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas, md=3,
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 id="filters",
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative. children=[
html.H5("Période d'attribution"),
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), dbc.Row(
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité. dcc.Dropdown(
id="dashboard_year",
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}). options=options_years,
"""), placeholder="12 derniers mois",
html.H3( ),
"Statistiques générales sur les marchés", ),
id="marches", 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,
),
),
],
), ),
html.P( dbc.Col(
"À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur." 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"),
],
), ),
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"
)
),
],
) )
], ],
), ),
], ],
) ),
] ]
@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(
["Nombre de titulaires : ", html.Strong(str(format_number(nb_titulaires)))]
),
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 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): 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. 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.