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
|
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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user