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;
|
max-width: 900px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.seeBorder {
|
||||||
|
border: dotted 1px green;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Search Page --- */
|
/* --- Search Page --- */
|
||||||
.tagline {
|
.tagline {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
+126
-6
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user