diff --git a/src/assets/css/style.css b/src/assets/css/style.css index e76e1f2..a183642 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -151,6 +151,10 @@ p.version > a { max-width: 900px; } +.seeBorder { + border: dotted 1px green; +} + /* --- Search Page --- */ .tagline { text-align: center; diff --git a/src/figures.py b/src/figures.py index e95b779..f06ae37 100644 --- a/src/figures.py +++ b/src/figures.py @@ -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(' html.Div: + pl.lit("") ).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 = [ diff --git a/src/pages/statistiques.py b/src/pages/statistiques.py index 058f660..1876dfa 100644 --- a/src/pages/statistiques.py +++ b/src/pages/statistiques.py @@ -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( - 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. - - 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", + dbc.Row( + [ + dbc.Col( + width=12, + md=3, + id="filters", + children=[ + 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, + ), + ), + ], ), - html.P( - "À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur." + 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"), + ], ), - 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 diff --git a/src/utils.py b/src/utils.py index 8c9dae7..992a99b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -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.