Améliorations sur le style de l'ag grid #47
This commit is contained in:
@@ -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
|
||||
========================================================================== */
|
||||
:root {
|
||||
--main-font: "Inter", sans-serif;
|
||||
--bs-font-monospace: "Fira Code";
|
||||
--primary-color: rgb(179, 56, 33);
|
||||
--primary-color-text: #b33821;
|
||||
@@ -11,6 +12,9 @@
|
||||
dcc (Dropdown, Input, Tabs, DatePicker...) ; défaut violet remplacé
|
||||
par notre couleur primaire. */
|
||||
--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;
|
||||
} */
|
||||
|
||||
/* 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 {
|
||||
}
|
||||
|
||||
|
||||
+31
-3
@@ -1106,8 +1106,9 @@ AG_GRID_LOCALE_FR = {
|
||||
def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
|
||||
"""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
|
||||
de filtre traduits en français via localeText.
|
||||
Thème aligné sur les dash_table.DataTable du reste du site (en-tête
|
||||
rouge brique, lignes alternées, police Inter) ; libellés de filtre
|
||||
traduits en français via localeText.
|
||||
"""
|
||||
return dag.AgGrid(
|
||||
id=grid_id,
|
||||
@@ -1120,10 +1121,37 @@ def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
|
||||
"maxBlocksInCache": 10,
|
||||
"rowBuffer": 0,
|
||||
"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,
|
||||
"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,
|
||||
"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%"},
|
||||
persistence=True,
|
||||
persistence_type="local",
|
||||
|
||||
+28
-12
@@ -34,6 +34,7 @@ from src.utils.query_ast import (
|
||||
from src.utils.seo import META_CONTENT
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
format_number,
|
||||
get_default_hidden_columns,
|
||||
write_styled_excel,
|
||||
)
|
||||
@@ -126,6 +127,7 @@ layout = [
|
||||
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
||||
dcc.Store(id="tableau-table"),
|
||||
dcc.Store(id="tableau-total"),
|
||||
dcc.Store(id="tableau-total-unique"),
|
||||
html.Script(
|
||||
type="application/ld+json",
|
||||
id="dataset_jsonld",
|
||||
@@ -415,12 +417,13 @@ layout = [
|
||||
@callback(
|
||||
Output("tableau_grid", "getRowsResponse"),
|
||||
Output("tableau-total", "data"),
|
||||
Output("tableau-total-unique", "data"),
|
||||
Input("tableau_grid", "getRowsRequest"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def get_rows_tableau(request):
|
||||
if request is None:
|
||||
return no_update, no_update
|
||||
return no_update, no_update, no_update
|
||||
filter_model = request.get("filterModel") or None
|
||||
sort_model = request.get("sortModel") or None
|
||||
# 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.
|
||||
if filter_model and request.get("startRow", 0) == 0:
|
||||
track_search(json.dumps(filter_model), "tableau")
|
||||
rows, total = fetch_grid_page(
|
||||
rows, total, total_unique = fetch_grid_page(
|
||||
filter_model,
|
||||
sort_model,
|
||||
request.get("startRow", 0),
|
||||
request.get("endRow", 100),
|
||||
)
|
||||
return {"rowData": rows, "rowCount": total}, total
|
||||
return {"rowData": rows, "rowCount": total}, total, total_unique
|
||||
|
||||
|
||||
@callback(
|
||||
@@ -443,16 +446,22 @@ def get_rows_tableau(request):
|
||||
Output("btn-download-data", "disabled"),
|
||||
Output("download-hint", "children"),
|
||||
Input("tableau-total", "data"),
|
||||
Input("tableau-total-unique", "data"),
|
||||
)
|
||||
def update_meta(total):
|
||||
def update_meta(total, total_unique):
|
||||
total = total or 0
|
||||
total_unique = total_unique or 0
|
||||
too_many = total > 65000
|
||||
hint = (
|
||||
" · Filtrez sous 65 000 lignes pour activer le téléchargement"
|
||||
if too_many
|
||||
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(
|
||||
@@ -499,12 +508,15 @@ def toggle_tableau_help(click_open, click_close, is_open):
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||
if selected_columns:
|
||||
selected_columns = [COLUMNS[i] for i in selected_columns]
|
||||
hidden_columns = [col for col in COLUMNS if col not in selected_columns]
|
||||
return hidden_columns
|
||||
else:
|
||||
return []
|
||||
# selected_columns == [] est un choix explicite (tout décoché), à ne pas
|
||||
# confondre avec « pas encore de préférence » : cf. apply_hidden_columns
|
||||
# et update_checkboxes_from_hidden_columns, qui eux distinguent ce cas de
|
||||
# None. Sans ce traitement uniforme, décocher/cocher toutes les colonnes
|
||||
# se faisait écraser par update_checkboxes_from_hidden_columns au tour
|
||||
# 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(
|
||||
@@ -523,7 +535,11 @@ def apply_hidden_columns(hidden_columns):
|
||||
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||
)
|
||||
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
|
||||
visible_cols = [COLUMNS.index(col) for col in COLUMNS if col not in hidden_cols]
|
||||
|
||||
+70
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
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.utils.cache import cache
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
filter_model,
|
||||
sort_model,
|
||||
@@ -28,8 +35,8 @@ def fetch_grid_page(
|
||||
end_row: int,
|
||||
base_where_sql: str = "TRUE",
|
||||
base_params: tuple = (),
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Renvoie (row_data, total_count) pour un bloc [start_row, end_row)."""
|
||||
) -> tuple[list[dict], int, int]:
|
||||
"""Renvoie (row_data, total_count, total_unique_count) pour un bloc [start_row, end_row)."""
|
||||
ast = filtermodel_to_ast(filter_model, schema)
|
||||
filter_sql, filter_params = ast_to_sql(ast, schema)
|
||||
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
|
||||
total = _cached_count(where_sql, tuple(params))
|
||||
total_unique = _cached_unique_count(where_sql, tuple(params))
|
||||
|
||||
limit = max(0, end_row - start_row)
|
||||
page = query_marches(
|
||||
@@ -47,7 +55,7 @@ def fetch_grid_page(
|
||||
offset=start_row,
|
||||
)
|
||||
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:
|
||||
@@ -74,9 +82,33 @@ _LINK_COLUMNS = {
|
||||
"acheteur_nom",
|
||||
"titulaire_id",
|
||||
"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",
|
||||
}
|
||||
|
||||
# Colonnes "nom" qui ne suivent pas le suffixe `_nom` (ex. sourceDataset).
|
||||
_WIDE_LABEL_COLUMNS = {"sourceDataset"}
|
||||
|
||||
|
||||
def _filter_for(col_type) -> str:
|
||||
if col_type.is_numeric():
|
||||
@@ -86,6 +118,28 @@ def _filter_for(col_type) -> str:
|
||||
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):
|
||||
"""columnDefs dérivés du schéma DuckDB.
|
||||
|
||||
@@ -120,5 +174,17 @@ def grid_column_defs(hidden_columns=None):
|
||||
)
|
||||
if col in _LINK_COLUMNS:
|
||||
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)
|
||||
return defs
|
||||
|
||||
Reference in New Issue
Block a user