Début de dashboard avec quelques filtres et viz #65
This commit is contained in:
@@ -151,6 +151,10 @@ p.version > a {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.seeBorder {
|
||||
border: dotted 1px green;
|
||||
}
|
||||
|
||||
/* --- Search Page --- */
|
||||
.tagline {
|
||||
text-align: center;
|
||||
|
||||
+126
-6
@@ -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
@@ -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"])}
|
||||
html.P(["Montant total : ", html.Strong(format_number(total_montant) + " €")]),
|
||||
]
|
||||
|
||||
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"
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
geographic_maps = get_geographic_maps(dff)
|
||||
|
||||
return card_basic_counts, geographic_maps
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user