Améliorations sur le style de l'ag grid #47

This commit is contained in:
Colin Maudry
2026-07-11 16:58:36 +02:00
parent 117ce9a4ab
commit f580459bd5
4 changed files with 157 additions and 20 deletions
+28 -1
View File
@@ -1,9 +1,10 @@
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600); @import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600|archivo-narrow:500);
/* ========================================================================== /* ==========================================================================
Variables Variables
========================================================================== */ ========================================================================== */
:root { :root {
--main-font: "Inter", sans-serif;
--bs-font-monospace: "Fira Code"; --bs-font-monospace: "Fira Code";
--primary-color: rgb(179, 56, 33); --primary-color: rgb(179, 56, 33);
--primary-color-text: #b33821; --primary-color-text: #b33821;
@@ -11,6 +12,9 @@
dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé
par notre couleur primaire. */ par notre couleur primaire. */
--Dash-Fill-Interactive-Strong: var(--primary-color); --Dash-Fill-Interactive-Strong: var(--primary-color);
/* Override AG Grid */
--ag-font-family: var(--main-font);
} }
/* ========================================================================== /* ==========================================================================
@@ -620,6 +624,29 @@ button.show-hide {
margin-right: 10px; margin-right: 10px;
} */ } */
/* Override des styles AG Grid */
/* La ligne de filtres flottants reprend le fond des lignes paires (rosé),
distinct du fond rouge brique appliqué à la ligne d'en-têtes (theme
params AG Grid n'exposant pas de couleur de fond dédiée à cette ligne). */
.marches_table .ag-floating-filter {
background-color: var(--ag-odd-row-background-color);
}
.ag-row {
--ag-internal-content-line-height: 20px;
}
.ag-header-cell-text {
font-family: "Archivo Narrow", sans-serif;
font-size: 18px;
}
/* Lien "voir le marché" (loupe) : pas de soulignement, c'est une icône. */
.ag-cell[col-id="marche"] a {
text-decoration: none;
}
/* fin overrides AG Grid*/
#btn-copy-url:before { #btn-copy-url:before {
} }
+31 -3
View File
@@ -1106,8 +1106,9 @@ AG_GRID_LOCALE_FR = {
def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid": def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
"""Grille AG Grid server-side (infinite) pour la page Tableau. """Grille AG Grid server-side (infinite) pour la page Tableau.
Apparence de base d'AG Grid (aucun thème custom au Lot 1) ; libellés Thème aligné sur les dash_table.DataTable du reste du site (en-tête
de filtre traduits en français via localeText. rouge brique, lignes alternées, police Inter) ; libellés de filtre
traduits en français via localeText.
""" """
return dag.AgGrid( return dag.AgGrid(
id=grid_id, id=grid_id,
@@ -1120,10 +1121,37 @@ def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
"maxBlocksInCache": 10, "maxBlocksInCache": 10,
"rowBuffer": 0, "rowBuffer": 0,
"infiniteInitialRowCount": 100, "infiniteInitialRowCount": 100,
# rowHeight fixe (pas autoHeight, non supporté par rowModelType
# "infinite") pour laisser la place au texte replié à la ligne
# de la colonne "objet" (cf. grid_column_defs).
"rowHeight": 60,
"suppressCellFocus": True, "suppressCellFocus": True,
"enableCellTextSelection": True,
"ensureDomOrder": True,
# Permet de sélectionner/copier le texte des infobulles (ex.
# colonne "objet" tronquée, cf. tooltipField dans grid_column_defs).
"tooltipInteraction": True,
"localeText": AG_GRID_LOCALE_FR, "localeText": AG_GRID_LOCALE_FR,
"theme": {
"function": (
"themeQuartz.withParams({"
"accentColor: 'rgb(179, 56, 33)',"
"headerTextColor: 'white',"
"headerBackgroundColor: 'rgb(179, 56, 33)',"
"oddRowBackgroundColor: 'rgba(255, 240, 240, 0.4)',"
"borderColor: '#ccc',"
"fontFamily: 'Inter, sans-serif',"
# "headerFontFamily: '\"Inter Tight\", sans-serif',"
"fontSize: 16"
"})"
)
},
}, },
columnSize="responsiveSizeToFit", # Pas de columnSize="responsiveSizeToFit" : les colonnes gardent leur
# largeur définie dans columnDefs (cf. _column_width dans
# src.utils.grid) même à 50 colonnes affichées, quitte à faire
# apparaître le défilement horizontal natif d'AG Grid plutôt que de
# les compresser/étirer toutes à la même largeur.
style={"height": "70vh", "width": "100%"}, style={"height": "70vh", "width": "100%"},
persistence=True, persistence=True,
persistence_type="local", persistence_type="local",
+28 -12
View File
@@ -34,6 +34,7 @@ from src.utils.query_ast import (
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import ( from src.utils.table import (
COLUMNS, COLUMNS,
format_number,
get_default_hidden_columns, get_default_hidden_columns,
write_styled_excel, write_styled_excel,
) )
@@ -126,6 +127,7 @@ layout = [
dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-hidden-columns", storage_type="local"),
dcc.Store(id="tableau-table"), dcc.Store(id="tableau-table"),
dcc.Store(id="tableau-total"), dcc.Store(id="tableau-total"),
dcc.Store(id="tableau-total-unique"),
html.Script( html.Script(
type="application/ld+json", type="application/ld+json",
id="dataset_jsonld", id="dataset_jsonld",
@@ -415,12 +417,13 @@ layout = [
@callback( @callback(
Output("tableau_grid", "getRowsResponse"), Output("tableau_grid", "getRowsResponse"),
Output("tableau-total", "data"), Output("tableau-total", "data"),
Output("tableau-total-unique", "data"),
Input("tableau_grid", "getRowsRequest"), Input("tableau_grid", "getRowsRequest"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def get_rows_tableau(request): def get_rows_tableau(request):
if request is None: if request is None:
return no_update, no_update return no_update, no_update, no_update
filter_model = request.get("filterModel") or None filter_model = request.get("filterModel") or None
sort_model = request.get("sortModel") or None sort_model = request.get("sortModel") or None
# AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de
@@ -429,13 +432,13 @@ def get_rows_tableau(request):
# pas une par bloc chargé au défilement. # pas une par bloc chargé au défilement.
if filter_model and request.get("startRow", 0) == 0: if filter_model and request.get("startRow", 0) == 0:
track_search(json.dumps(filter_model), "tableau") track_search(json.dumps(filter_model), "tableau")
rows, total = fetch_grid_page( rows, total, total_unique = fetch_grid_page(
filter_model, filter_model,
sort_model, sort_model,
request.get("startRow", 0), request.get("startRow", 0),
request.get("endRow", 100), request.get("endRow", 100),
) )
return {"rowData": rows, "rowCount": total}, total return {"rowData": rows, "rowCount": total}, total, total_unique
@callback( @callback(
@@ -443,16 +446,22 @@ def get_rows_tableau(request):
Output("btn-download-data", "disabled"), Output("btn-download-data", "disabled"),
Output("download-hint", "children"), Output("download-hint", "children"),
Input("tableau-total", "data"), Input("tableau-total", "data"),
Input("tableau-total-unique", "data"),
) )
def update_meta(total): def update_meta(total, total_unique):
total = total or 0 total = total or 0
total_unique = total_unique or 0
too_many = total > 65000 too_many = total > 65000
hint = ( hint = (
" · Filtrez sous 65 000 lignes pour activer le téléchargement" " · Filtrez sous 65 000 lignes pour activer le téléchargement"
if too_many if too_many
else "" else ""
) )
return f"{total} lignes", too_many, hint nb_rows = (
f"{format_number(total_unique) or 0} marchés "
f"({format_number(total) or 0} lignes)"
)
return nb_rows, too_many, hint
@callback( @callback(
@@ -499,12 +508,15 @@ def toggle_tableau_help(click_open, click_close, is_open):
prevent_initial_call=True, prevent_initial_call=True,
) )
def update_hidden_columns_from_checkboxes(selected_columns): def update_hidden_columns_from_checkboxes(selected_columns):
if selected_columns: # selected_columns == [] est un choix explicite (tout décoché), à ne pas
selected_columns = [COLUMNS[i] for i in selected_columns] # confondre avec « pas encore de préférence » : cf. apply_hidden_columns
hidden_columns = [col for col in COLUMNS if col not in selected_columns] # et update_checkboxes_from_hidden_columns, qui eux distinguent ce cas de
return hidden_columns # None. Sans ce traitement uniforme, décocher/cocher toutes les colonnes
else: # se faisait écraser par update_checkboxes_from_hidden_columns au tour
return [] # suivant (`hidden_cols or get_default_hidden_columns(...)`).
selected = [COLUMNS[i] for i in (selected_columns or [])]
hidden_columns = [col for col in COLUMNS if col not in selected]
return hidden_columns
@callback( @callback(
@@ -523,7 +535,11 @@ def apply_hidden_columns(hidden_columns):
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
) )
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
hidden_cols = hidden_cols or get_default_hidden_columns("tableau") # None = pas encore de préférence enregistrée (première visite) ; []
# est un choix explicite (« ne rien masquer »/tout afficher) et doit
# être respecté tel quel, cf. update_hidden_columns_from_checkboxes.
if hidden_cols is None:
hidden_cols = get_default_hidden_columns("tableau")
# Show all columns that are NOT hidden # Show all columns that are NOT hidden
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols] visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
+70 -4
View File
@@ -2,7 +2,7 @@
import polars as pl import polars as pl
from src.db import count_marches, query_marches, schema from src.db import count_marches, count_unique_marches, query_marches, schema
from src.figures import DATA_SCHEMA from src.figures import DATA_SCHEMA
from src.utils.cache import cache from src.utils.cache import cache
from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql
@@ -21,6 +21,13 @@ def _cached_count(where_sql: str, params: tuple) -> int:
return count_marches(where_sql, params) return count_marches(where_sql, params)
@cache.memoize()
def _cached_unique_count(where_sql: str, params: tuple) -> int:
"""Cache le COUNT(DISTINCT uid) sur (where_sql, params), même raison que
`_cached_count`."""
return count_unique_marches(where_sql, params)
def fetch_grid_page( def fetch_grid_page(
filter_model, filter_model,
sort_model, sort_model,
@@ -28,8 +35,8 @@ def fetch_grid_page(
end_row: int, end_row: int,
base_where_sql: str = "TRUE", base_where_sql: str = "TRUE",
base_params: tuple = (), base_params: tuple = (),
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int, int]:
"""Renvoie (row_data, total_count) pour un bloc [start_row, end_row).""" """Renvoie (row_data, total_count, total_unique_count) pour un bloc [start_row, end_row)."""
ast = filtermodel_to_ast(filter_model, schema) ast = filtermodel_to_ast(filter_model, schema)
filter_sql, filter_params = ast_to_sql(ast, schema) filter_sql, filter_params = ast_to_sql(ast, schema)
where_sql = f"({base_where_sql}) AND ({filter_sql})" where_sql = f"({base_where_sql}) AND ({filter_sql})"
@@ -37,6 +44,7 @@ def fetch_grid_page(
order_by = sort_model_to_sql(sort_model, schema) or None order_by = sort_model_to_sql(sort_model, schema) or None
total = _cached_count(where_sql, tuple(params)) total = _cached_count(where_sql, tuple(params))
total_unique = _cached_unique_count(where_sql, tuple(params))
limit = max(0, end_row - start_row) limit = max(0, end_row - start_row)
page = query_marches( page = query_marches(
@@ -47,7 +55,7 @@ def fetch_grid_page(
offset=start_row, offset=start_row,
) )
page = postprocess_page(page) page = postprocess_page(page)
return page.to_dicts(), total return page.to_dicts(), total, total_unique
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame: def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame:
@@ -74,9 +82,33 @@ _LINK_COLUMNS = {
"acheteur_nom", "acheteur_nom",
"titulaire_id", "titulaire_id",
"titulaire_nom", "titulaire_nom",
"sourceDataset",
}
# Colonnes oui/non (cf. `booleans_to_strings` dans src.db) : valeur très
# courte, pas besoin de place.
_BOOLEAN_LIKE_COLUMNS = {
"attributionAvance",
"marcheInnovant",
"sousTraitanceDeclaree",
"considerationsSociales",
"considerationsEnvironnementales",
}
# Codes/identifiants qui ne suivent pas le pattern de suffixe `_id`/`_code`
# (ex. codeCPV, idAccordCadre) mais restent des valeurs courtes.
_SHORT_CODE_COLUMNS = {
"uid",
"id",
"codeCPV",
"idAccordCadre",
"lieuExecution_typeCode",
"sourceFile", "sourceFile",
} }
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
_WIDE_LABEL_COLUMNS = {"sourceDataset"}
def _filter_for(col_type) -> str: def _filter_for(col_type) -> str:
if col_type.is_numeric(): if col_type.is_numeric():
@@ -86,6 +118,28 @@ def _filter_for(col_type) -> str:
return "agTextColumnFilter" return "agTextColumnFilter"
def _column_width(col: str, col_type) -> dict:
"""Largeur par défaut selon la nature de la colonne.
`columnSize="responsiveSizeToFit"` (cf. ag_grid()) étire les colonnes
proportionnellement à cette largeur pour remplir l'espace disponible :
sans ça, les codes/booléens s'étirent autant que les noms/libellés.
"""
if col == "montant":
return {"width": 150, "maxWidth": 200}
if col_type == pl.Date:
return {"width": 140, "maxWidth": 160}
if col_type.is_numeric():
return {"width": 120, "maxWidth": 150}
if col in _BOOLEAN_LIKE_COLUMNS:
return {"width": 110, "maxWidth": 140}
if col in _SHORT_CODE_COLUMNS or col.endswith(("_id", "_code")):
return {"width": 130, "maxWidth": 170}
if col in _WIDE_LABEL_COLUMNS or col.endswith("_nom"):
return {"width": 200}
return {"width": 170}
def grid_column_defs(hidden_columns=None): def grid_column_defs(hidden_columns=None):
"""columnDefs dérivés du schéma DuckDB. """columnDefs dérivés du schéma DuckDB.
@@ -120,5 +174,17 @@ def grid_column_defs(hidden_columns=None):
) )
if col in _LINK_COLUMNS: if col in _LINK_COLUMNS:
col_def["cellRenderer"] = "markdown" col_def["cellRenderer"] = "markdown"
if col != "objet":
col_def.update(_column_width(col, col_type))
if col == "objet":
# autoHeight n'est pas supporté avec rowModelType="infinite" (la
# grille doit pouvoir calculer la position des lignes non
# chargées, donc une hauteur de ligne fixe) : cf. ag_grid(),
# rowHeight fixe côté dashGridOptions plutôt qu'autoHeight ici.
col_def["wrapText"] = True
col_def["minWidth"] = 360
# Le texte tronqué par la hauteur de ligne fixe reste consultable
# en entier via l'infobulle native AG Grid au survol.
col_def["tooltipField"] = "objet"
defs.append(col_def) defs.append(col_def)
return defs return defs