from datetime import datetime
from typing import Literal
import dash_bootstrap_components as dbc
import dash_leaflet as dl
import dash_leaflet.express as dlx
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import polars as pl
from dash import dash_table, dcc, html
from dash_extensions.javascript import Namespace
from polars.exceptions import ColumnNotFoundError
from src.db import schema
from src.utils import logger
from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON
from src.utils.table import add_links, format_number, setup_table_columns
def get_yearly_statistics(statistics, today_str) -> html.Div:
# Build DataFrame from statistics
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
data = []
for year in years:
year_str = str(year)
stat = statistics[year_str]
data.append(
{
"Année": year_str,
"Marchés et accord-cadres": format_number(
stat["nb_notifications_marches"]
),
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
}
)
dff = pl.DataFrame(data)
# Create Dash DataTable
table = dash_table.DataTable(
data=dff.to_dicts(),
columns=[
{"name": "Année", "id": "Année"},
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
{"name": "Acheteurs", "id": "Acheteurs"},
{"name": "Titulaires", "id": "Titulaires"},
],
page_size=10,
sort_action="none",
filter_action="none",
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=table, className="marches_table")
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
labels = {
"dateNotification": "notification",
"datePublicationDonnees": "publication des données",
}
now_year = datetime.now().year
lff = lff.select("uid", type_date, "sourceDataset")
lff = lff.unique("uid")
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
.then(pl.lit("plateformes atexo"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
lff = lff.with_columns(
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
.then(pl.lit("aws"))
.otherwise(pl.col("sourceDataset"))
.alias("sourceDataset")
)
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
lff = lff.filter(
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, now_year)
)
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
lff = (
lff.group_by([type_date, "sourceDataset"])
.len()
.sort(by=[type_date, "len"], descending=True)
)
lff = lff.sort(by=["sourceDataset"], descending=False)
dff: pl.DataFrame = lff.collect(engine="streaming")
fig = px.bar(
dff,
x=type_date,
y="len",
color="sourceDataset",
labels={
"len": "Nombre de marchés",
type_date: f"Mois de {labels[type_date]}",
"sourceDataset": "Source de données",
},
)
graph = dcc.Graph(figure=fig)
return graph
def get_sources_tables(source_path) -> html.Div:
try:
if not source_path:
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
dff = pl.read_csv(source_path)
except Exception as e:
logger.warning(f"Sources de données indisponibles ({e})")
return html.Div("Sources de données momentanément indisponibles.")
dff = dff.with_columns(
(
pl.lit('')
+ pl.col("nom")
+ pl.lit("")
).alias("nom")
)
dff = dff.drop("url", "unique")
dff = dff.sort(by=["nb_marchés"], descending=True)
columns = {
"nom": "Nom de la source",
"organisation": "Responsable de publication",
"nb_marchés": "Nb de marchés",
"nb_acheteurs": "Nb d'acheteurs",
"code": "Code",
}
datatable = dash_table.DataTable(
id="source_table",
data=dff.to_dicts(),
columns=[
{
"name": columns[i],
"id": i,
"presentation": "markdown",
"type": "text",
"format": {"nully": "N/A"},
}
for i in dff.schema.names()
],
style_cell_conditional=[
{
"if": {"column_id": ["nom", "organisation"]},
"minWidth": "350px",
"textAlign": "left",
"overflow": "hidden",
"lineHeight": "14px",
"whiteSpace": "normal",
},
],
sort_action="native",
markdown_options={"html": True},
style_header={"fontFamily": "Inter", "fontSize": "16px"},
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
)
return html.Div(children=datatable)
def point_on_map(lat, lon, departement_code=None):
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
Args:
lat: Coordonnée de latitude
lon: Coordonnée de longitude
departement_code: Code du département (ex: '75', '971', etc.)
Returns:
html.Div contenant la carte, ou div vide si invalide
"""
# Validation des coordonnées
try:
lat = float(lat)
lon = float(lon)
except (TypeError, ValueError):
return html.Div() # Div vide pour les coordonnées invalides
# Vérification que les coordonnées sont valides
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return html.Div()
# Si aucun code département n'est fourni, retourner une div vide
if not departement_code:
return html.Div()
# Détermination de la région en utilisant le code département
# Logique identique à get_geographic_maps
if departement_code in ["971", "972", "973", "974", "976"]:
region_key = departement_code # Département d'outre-mer
elif len(departement_code) == 2: # Département métropolitain
region_key = "Hexagone"
else:
return html.Div() # Format de code département invalide
# Paramètres de carte par région (réutilisés de get_geographic_maps)
regions = {
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
}
settings = regions.get(region_key, regions["Hexagone"])
# Création de la carte
fig = px.scatter_map(
lat=[lat],
lon=[lon],
height=300,
# width=400,
color=[1],
zoom=settings["zoom"],
)
fig.update_traces(marker=dict(size=10))
# Configuration de la carte (interactive - zoomable)
fig.update_layout(
map_style="light", # Fond de carte clair
margin={"r": 0, "t": 0, "l": 0, "b": 0},
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
mapbox_zoom=settings["zoom"],
coloraxis_showscale=False,
)
return html.Div(
dcc.Graph(figure=fig, config={"displayModeBar": False}),
)
class DataTable(dash_table.DataTable):
def __init__(
self,
dtid: str,
hidden_columns: list[str] | None = None,
data: list[dict[str, str | int | float | bool]] | None = None,
columns: list[dict[str, str]] | None = None,
page_size: int = 20,
page_action: Literal["native", "custom", "none"] = "native",
sort_action: Literal["native", "custom", "none"] = "native",
filter_action: Literal["native", "custom", "none"] = "native",
style_cell_conditional: list | None = None,
style_cell: dict | None = None,
**kwargs,
):
# Styles de base
style_cell_conditional_common = [
{
"if": {"column_id": "objet"},
"minWidth": "350px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_id"},
"minWidth": "160px",
"overflow": "hidden",
"whiteSpace": "normal",
},
{
"if": {"column_id": "acheteur_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
{
"if": {"column_id": "titulaire_nom"},
"minWidth": "250px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
},
]
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
for key in DATA_SCHEMA.keys():
field = DATA_SCHEMA[key]
if field["type"] in ["number", "integer"]:
rule = {
"if": {"column_id": field["name"]},
"textAlign": "right",
# "fontFamily": "Fira Code",
}
style_cell_conditional_common.append(rule)
style_cell_conditional = (
style_cell_conditional or []
) + style_cell_conditional_common
if style_cell:
style_cell.update(style_cell_common)
else:
style_cell = style_cell_common
style_header = style_cell
# Initialisation de la classe parente avec les arguments
super().__init__(
id=dtid,
data=data,
columns=columns,
cell_selectable=False,
page_size=page_size,
filter_action=filter_action,
page_action=page_action,
filter_options={
"case": "insensitive",
"placeholder_text": "Filtre de colonne...",
},
sort_action=sort_action,
sort_mode="multi",
row_deletable=False,
page_current=0,
style_cell_conditional=style_cell_conditional,
data_timestamp=0,
markdown_options={"html": True},
style_header=style_header,
style_cell=style_cell,
tooltip_duration=8000,
tooltip_delay=350,
hidden_columns=hidden_columns,
**kwargs, # Possibilité de remplacer des arguments
)
def get_duplicate_matrix() -> dcc.Graph:
"""
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
:return:
"""
lff = pl.scan_parquet(
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
).sort("sourceDataset")
lff = lff.select(
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
)
dff = lff.collect()
# Extract data
z_data = dff.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
x_labels = dff.columns[1:] # columns after "sourceDataset"
y_labels = dff["sourceDataset"].to_list()
# Create heatmap
fig = go.Figure(
data=go.Heatmap(
z=z_data,
x=x_labels,
y=y_labels,
colorscale=[
[0.0, "white"], # 0% → white
[0.10, "lightsalmon"], # 10% → light warm tone
[1.0, "darkred"], # 100% → deep red
],
zmin=0,
zmax=1,
hoverongaps=False,
showscale=True,
hovertemplate=(
"%{z:.0%} des marchés présents dans %{y} sont également présents dans %{x}"
),
)
)
# Update layout: make it wider and taller
fig.update_layout(
title="",
xaxis_title="Sources de données",
yaxis_title="Sources de données",
yaxis=dict(autorange="reversed"),
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
width=1000, # Wider
height=1000, # Taller
font=dict(size=11), # Overall font size
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
)
return dcc.Graph(figure=fig)
def get_geographic_maps(dff: pl.DataFrame) -> list[dbc.Col] | list:
"""
Génère les cartes géographiques pour l'hexagone et les DOM-TOM.
"""
regions: dict = {
"Hexagone": {
"coordinates": [46.6, 2.2],
"zoom_leaflet": 5,
"zoom_chloropleth": 1,
"name": "Hexagone",
},
"971": {
"coordinates": [16.23, -61.55],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "Guadeloupe",
},
"972": {
"coordinates": [14.64, -61.02],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Martinique",
},
"973": {
"coordinates": [3.93, -53.12],
"zoom_leaflet": 7,
"zoom_chloropleth": 1,
"name": "Guyane",
},
"974": {
"coordinates": [-21.11, 55.53],
"zoom_leaflet": 9,
"zoom_chloropleth": 1,
"name": "La Réunion",
},
"976": {
"coordinates": [-12.82, 45.16],
"zoom_leaflet": 10,
"zoom_chloropleth": 1,
"name": "Mayotte",
},
}
def make_map_data(region_code: str) -> tuple[list, str | None]:
lff: pl.LazyFrame = dff.lazy()
if region_code == "Hexagone":
lff = lff.filter(
(pl.col("acheteur_departement_code").str.len_chars() == 2)
& (pl.col("titulaire_departement_code").str.len_chars() == 2)
)
else:
lff = lff.filter(
(pl.col("acheteur_departement_code") == code)
| (pl.col("titulaire_departement_code") == code)
)
nb_marches = lff.select("uid").collect()["uid"].n_unique()
if nb_marches == 0:
return [], None
dfs = []
if (code == "Hexagone" and nb_marches > 30000) or (
code != "Hexagone" and nb_marches > 10000
):
_map_type: str = "chloropleth"
lff = lff.rename({"acheteur_departement_code": "Département"})
lff = (
lff.select(["uid", "Département"])
.drop_nulls()
.group_by("uid")
.agg(pl.col("Département").first())
.group_by("Département")
.len("uid")
)
dfs.append(lff.collect())
else:
_map_type: str = "clusters"
for org_type in ["acheteur", "titulaire"]:
lff_org = (
lff.select(
"uid",
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.group_by(
f"{org_type}_longitude",
f"{org_type}_latitude",
f"{org_type}_nom",
)
.len("nb_marches")
.filter(
pl.col(f"{org_type}_latitude").is_not_null()
& pl.col(f"{org_type}_longitude").is_not_null()
)
)
markers = []
# Couleurs accessibles (Okabe-Ito)
colors = {
"acheteur": "#E69F00", # orange
"titulaire": "#56B4E9", # bleu ciel
}
for row in lff_org.collect().to_dicts():
markers.append(
{
"lat": row[f"{org_type}_latitude"],
"lon": row[f"{org_type}_longitude"],
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
"marker_color": colors[org_type],
}
)
dfs.append(markers)
return dfs, _map_type
cols = []
for code in regions.keys():
regions[code]["data"], map_type = make_map_data(code)
if map_type == "chloropleth":
map_graph = make_chloropleth_map(regions[code])
elif map_type == "clusters":
map_graph = make_clusters_map(regions[code])
elif map_type is None:
continue
else:
raise ValueError(f"Map type '{map_type}' not recognised")
lg, xl = (12, 8) if code == "Hexagone" else (6, 4)
col = make_card(regions[code]["name"], fig=map_graph, lg=lg, xl=xl)
cols.append(col)
return cols
def make_chloropleth_map(region: dict) -> dcc.Graph:
df_map = region["data"][0]
fig = px.choropleth(
df_map,
geojson=DEPARTEMENTS_GEOJSON,
locations="Département",
color="uid",
color_continuous_scale="Reds",
range_color=(df_map["uid"].min(), df_map["uid"].max()),
labels={"uid": "Marchés attribués"},
scope="europe",
)
fig.update_geos(fitbounds="locations", visible=False)
fig.update_layout(
mapbox={
"style": "carto-positron",
"center": {"lon": 10, "lat": 10},
"zoom": 8,
"domain": {"x": [0, 1], "y": [0, 1]},
}
)
graph = dcc.Graph(figure=fig, config={"displayModeBar": False})
return graph
def make_clusters_map(region: dict) -> dl.Map:
# JavaScript functions for styling
ns = Namespace("dash_clientside", "leaflet")
point_to_layer = ns("pointToLayer")
cluster_to_layer = ns("clusterToLayer")
name = region["name"]
# Données de la région
region_acheteurs = region["data"][0]
region_titulaires = region["data"][1]
# Couleurs
color_acheteur = region_acheteurs[0]["marker_color"]
color_titulaire = region_titulaires[0]["marker_color"]
acheteurs_geojson_data = dlx.dicts_to_geojson(region_acheteurs)
titulaires_geojson_data = dlx.dicts_to_geojson(region_titulaires)
center, zoom = region["coordinates"], region["zoom_leaflet"]
region_id = name.lower().replace(" ", "-")
leaflet_map = dl.Map(
[
dl.TileLayer(),
dl.GeoJSON(
data=titulaires_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-titulaires",
options={"fillColor": color_titulaire},
),
dl.GeoJSON(
data=acheteurs_geojson_data,
cluster=True,
zoomToBoundsOnClick=True,
pointToLayer=point_to_layer,
clusterToLayer=cluster_to_layer,
id=f"geojson-{region_id}-acheteurs",
options={"fillColor": color_acheteur},
),
],
center=center,
zoom=zoom,
style={
"width": "100%",
"height": "400px" if name == "Hexagone" else "300px",
},
id=f"map-{region_id}",
)
return leaflet_map
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
if "titulaire_distance" not in lff.collect_schema().names():
dff = pl.DataFrame({"titulaire_distance": pl.Series([], dtype=pl.Float64)})
else:
dff = (
lff.select("titulaire_distance")
.drop_nulls()
.filter(pl.col("titulaire_distance") > 0)
.collect(engine="streaming")
)
log_distances = dff["titulaire_distance"].log(10).to_numpy()
fig = go.Figure()
if len(log_distances) > 0:
counts, bin_edges = np.histogram(log_distances, bins=25)
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
bin_widths = bin_edges[1:] - bin_edges[:-1]
bin_edges_km = 10.0**bin_edges
def fmt_km(km):
if km < 10:
return f"{km:.1f}"
elif km < 1000:
return f"{round(km)}"
else:
return f"{round(km):,}".replace(",", " ")
hover_texts = []
for i in range(len(counts)):
nb = f"{counts[i]:,}".replace(",", " ")
hover_texts.append(
f"Distance : {fmt_km(bin_edges_km[i])} – {fmt_km(bin_edges_km[i + 1])} km"
f"
Nombre de marchés : {nb}"
)
fig.add_trace(
go.Bar(
x=bin_centers,
y=counts,
width=bin_widths,
hovertext=hover_texts,
hoverinfo="text",
)
)
fig.update_layout(bargap=0)
fig.update_layout(margin=dict(r=10, t=10))
fig.update_xaxes(
tickvals=[0, 1, 2, 3, 4],
ticktext=["1", "10", "100", "1 000", "10 000"],
title_text="Distance (km)",
)
fig.update_yaxes(title_text="Nombre de marchés")
return dcc.Graph(figure=fig)
def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
nb_acheteurs = dff.select("acheteur_id").n_unique()
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
total_montant = int(dff_per_uid.select(pl.col("montant").sum()).item())
median_distance = dff.select(pl.median("titulaire_distance")).item()
summary_table = [
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
html.P(
[
"Nombre d'acheteurs uniques : ",
html.Strong(str(format_number(nb_acheteurs))),
]
),
html.P(
[
"Nombre de titulaires uniques : ",
html.Strong(str(format_number(nb_titulaires))),
]
),
html.P(
[
"Montant total (",
html.Span(
"?",
id={"type": "modal-trigger", "index": "montant"},
style={"cursor": "pointer", "textDecoration": "underline dotted"},
),
") : ",
html.Strong(format_number(total_montant) + " €"),
]
),
html.P(
[
"Distance acheteur-titulaire médiane : ",
html.Strong(format_number(median_distance) + " km"),
]
),
]
return summary_table
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
CONSIDERATIONS_COLUMNS = {
"sociales": "considerationsSociales",
"environnementales": "considerationsEnvironnementales",
}
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int, int]]:
"""Statistiques considérations pour l'observatoire.
Clés renvoyées :
- "champs_renseignes" : (count_ren_sociales, pct / total)
- "{key}_renseignees" : (count_ren, pct positifs parmi renseignés)
Colonne absente -> (0, 0).
"""
names = lff.collect_schema().names()
present = {key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names}
stats: dict[str, tuple[int, int, int]] = {"champs_renseignes": (0, 0)}
for key in CONSIDERATIONS_COLUMNS:
stats[f"{key}_renseignees"] = (0, 0)
if not present:
return stats
agg = (
lff.select(["uid"] + list(present.values()))
.group_by("uid")
.agg([pl.col(col).first() for col in present.values()])
.collect(engine="streaming")
)
total = agg.height
if total == 0:
return stats
for key, col in present.items():
count_ren = agg.filter(pl.col(col).is_not_null()).height
count_pos_ren = agg.filter(
pl.col(col).is_not_null() & (pl.col(col) != "Sans objet")
).height
pct_pos_ren = round(100 * count_pos_ren / count_ren) if count_ren > 0 else 0
stats[f"{key}_renseignees"] = (count_ren, count_pos_ren, pct_pos_ren)
if key == "sociales":
stats["champs_renseignes"] = (count_ren, round(100 * count_ren / total))
return stats
# (clé stats, libellé, couleur)
CONSIDERATIONS_RENSEIGNEES = [
("sociales_renseignees", "Considérations sociales", "#CC6677"),
("environnementales_renseignees", "Considérations environnementales", "#117733"),
]
def _progress_bar(pct: int, color: str) -> dbc.Progress:
return dbc.Progress(
dbc.Progress(
value=pct, label=f"{pct} %", bar=True, color=color, style={"color": "white"}
),
)
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
"""Trois barres : champs renseignés + part positive pour sociales et environnementales."""
stats = compute_considerations_stats(lff)
count_ren, pct_ren = stats["champs_renseignes"]
blocks = [
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span("Champs considérations renseignés"),
html.Span(
f"{format_number(count_ren)} marchés",
className="text-muted",
),
],
),
_progress_bar(pct_ren, "#6c757d"),
],
)
]
for key, label, color in CONSIDERATIONS_RENSEIGNEES:
total_count, count, pct = stats[key]
blocks.append(
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span(label),
html.Span(
f"dans {format_number(count)} marchés",
className="text-muted",
),
],
),
_progress_bar(pct, color),
],
)
)
return html.Div(children=blocks)
def make_card(
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
) -> dbc.Col:
children = []
if title:
children.append(html.H5(title, className="card-title"))
if subtitle:
children.append(html.H6(subtitle, className="card-subtitle mb-2 text-muted"))
if fig is not None:
children.append(fig)
if paragraphs:
for p in paragraphs:
p.className = "card-text"
children.append(p)
card = dbc.Col(
html.Div(html.Div(className="card-body", children=children), className="card"),
lg=lg,
xl=xl,
# width=width,
# className="mb-4",
)
return card
def make_donut(
lff: pl.LazyFrame,
names_col,
per_uid: bool,
nulls="?",
potentially_many_names: bool = False,
):
title = DATA_SCHEMA[names_col]["title"]
lff = lff.rename({names_col: title})
lff = lff.select("uid", title)
if per_uid:
lff = lff.group_by("uid").first()
lff = lff.group_by(title).len("Nombre")
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
dff = lff.collect(engine="streaming")
nb_names = dff[title].n_unique()
sum_values = dff["Nombre"].sum()
dff = dff.with_columns(
pl.when((pl.col("Nombre") / sum_values) < 0.01)
.then(pl.lit("Autres"))
.otherwise(pl.col(title))
.alias(title)
)
dff = dff.with_columns(
pl.col("Nombre")
.map_elements(format_number, return_dtype=pl.String)
.alias("Nombre_fmt")
)
fig = px.pie(
dff,
values="Nombre",
names=title,
hole=0.4,
color_discrete_sequence=px.colors.qualitative.Safe,
custom_data=["Nombre_fmt"],
)
fig = fig.update_traces(
texttemplate="%{label}
%{percent}",
hovertemplate="%{label}
%{customdata[0]}",
)
fig = fig.update_layout(showlegend=False, font=dict(size=14))
graph = dcc.Graph(figure=fig)
if potentially_many_names:
return graph, nb_names
return graph
def make_column_picker(page: str):
table_data = []
table_columns = [
{
"id": col,
"name": DATA_SCHEMA[col]["title"],
"description": DATA_SCHEMA[col]["description"],
}
for col in schema.names()
]
for column in table_columns:
new_column = {
"id": column["id"],
"name": column["name"],
"description": DATA_SCHEMA[column["id"]]["description"],
}
table_data.append(new_column)
table = (
DataTable(
row_selectable="multi",
data=table_data,
filter_action="native",
sort_action="none",
style_cell={
"textAlign": "left",
},
columns=[
{
"name": "Nom",
"id": "name",
},
{
"name": "Description",
"id": "description",
},
],
style_cell_conditional=[
{
"if": {"column_id": "description"},
"minWidth": "450px",
"overflow": "hidden",
"lineHeight": "18px",
"whiteSpace": "normal",
}
],
page_action="none",
dtid=f"{page}_column_list",
),
)
return table
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
if isinstance(data, pl.LazyFrame):
lff = data
else:
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
if org_type == "titulaire":
extra_columns.append("titulaire_typeIdentifiant")
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
lff = lff.select(columns)
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg(
pl.len().alias("Attributions")
)
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
lff = lff.cast(pl.String)
lff = lff.fill_null("")
try:
dff: pl.DataFrame = lff.collect(engine="streaming")
except ColumnNotFoundError:
logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}")
return html.Div()
if dff.height == 0:
return html.Div()
columns, tooltip = setup_table_columns(
dff, hideable=False, exclude=[f"{org_type}_id"]
)
dff = add_links(dff)
data = dff.to_dicts()
# data = add_links_in_dict(data, f"{org_type}")
return DataTable(
dtid=f"top10_{org_type}",
data=data,
page_action="native",
page_size=10,
columns=columns,
tooltip_header=tooltip,
filter_action="native" if filters else "none",
)