From f580459bd5ba6d62bbeb592b26184ae3d8cac094 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Sat, 11 Jul 2026 16:58:36 +0200 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorations=20sur=20le=20style=20de=20l'?= =?UTF-8?q?ag=20grid=20#47?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/css/style.css | 29 +++++++++++++++- src/figures.py | 34 ++++++++++++++++-- src/pages/tableau.py | 40 +++++++++++++++------- src/utils/grid.py | 74 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 157 insertions(+), 20 deletions(-) diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 6a57376..7877de9 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -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 { } diff --git a/src/figures.py b/src/figures.py index bdfb690..40f5982 100644 --- a/src/figures.py +++ b/src/figures.py @@ -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", diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 129a2f3..db9e53a 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -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] diff --git a/src/utils/grid.py b/src/utils/grid.py index 9f48924..a795be4 100644 --- a/src/utils/grid.py +++ b/src/utils/grid.py @@ -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