Picarones / picarones /report /render_helpers.py
Claude
fix(audit-2): 5 correctifs supplémentaires d'un 2e tour d'audit
b80bb93 unverified
Raw
History Blame
16.8 kB
"""Helpers de rendu mutualisés.
Centralise les fonctions de coloration et le builder de grille SVG qui
étaient auparavant dupliqués dans chaque ``*_render.py``. Avant cette
consolidation, le projet comptait 25 versions différentes de
``_color_for_*`` (toutes des dégradés rouge/jaune/vert ou blanc/couleur
légèrement différentes) et 2 versions de ``_build_heatmap_svg``
(matrice de classes × positions). Le test
``tests/architecture/test_render_helpers.py`` mesure cette duplication
et bloque sa réapparition.
API
---
- :func:`color_traffic_light` — gradient rouge → jaune → vert. Couvre
la majorité des cellules du rapport (CER, F1, recall, ECE, deficit,
drag, CV, etc.). Argument ``low_is_good`` pour inverser la sémantique.
- :func:`color_single_gradient` — gradient blanc → couleur intense.
Utilisé pour les heatmaps Jaccard, densité, lexical modernization.
- :func:`color_diverging` — gradient signé (négatif → neutre → positif).
Utilisé pour les deltas Flesch, amélioration nette, sur/sous-norm.
- :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
- :func:`build_grid_svg` — builder de heatmap SVG paramétré.
Conventions de bornes
---------------------
Trois conventions de paramétrage cohabitent (par dessein, pas par
maladresse) :
- :func:`color_traffic_light` accepte ``scale_min`` + ``scale_max``
parce que les cellules concernées (CER, ECE, deficit) peuvent
démarrer à une borne basse non nulle (rang 1 = vert, ou
``scale_min=0.30`` pour démarrer le dégradé à partir d'un seuil).
- :func:`color_single_gradient` accepte ``max_value`` parce que ces
cellules (Jaccard, densité) sont toujours bornées en bas par 0 —
pas besoin de ``scale_min``.
- :func:`color_diverging` accepte ``max_abs`` parce que ces cellules
(deltas signés) sont symétriques autour de 0 — la borne est la
même des deux côtés.
Le choix des couleurs reflète la sémantique métier :
- **Traffic-light** rouge/jaune/vert : convention historique
largement comprise pour vision trichromate normale. **Compromis
d'accessibilité accepté** : la confusion rouge/vert affecte ~8 %
des hommes (deutéranopie/protanopie). Une migration vers la
palette Okabe-Ito de :mod:`picarones.report.colors` est tracée
comme dette dans un sprint dédié.
- **Diverging** bleu/vert/orange par défaut : vert au centre =
neutre, extrémités opposées sémantiquement, et ces 3 teintes
restent distinguables en daltonisme deutéranope. Choix retenu
parce que les cellules diverging sont moins nombreuses et
qu'on a pu repartir de zéro en les écrivant.
Palette
-------
Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
ad hoc qui peuplaient les 25 helpers d'origine. Cohérence visuelle
unifiée tout en restant proche du rendu antérieur (≤ 10 unités RGB
d'écart sur la majorité des bornes), pour ne pas casser les tests
d'intégration HTML existants.
"""
from __future__ import annotations
from html import escape as _e
from typing import Callable, Optional
# ──────────────────────────────────────────────────────────────────
# Palettes — bornes RGB partagées par tous les dégradés.
#
# Choix éditorial : on conserve l'esprit « rouge → jaune → vert » des
# helpers historiques plutôt que la palette daltonien-friendly
# Okabe-Ito de ``colors.py`` (utilisée pour les badges principaux).
# Migrer les cellules de tableau vers Okabe-Ito serait un sprint
# d'accessibilité dédié, hors scope de la consolidation.
# ──────────────────────────────────────────────────────────────────
GRADIENT_RED_RGB: tuple[int, int, int] = (220, 100, 100)
GRADIENT_YELLOW_RGB: tuple[int, int, int] = (240, 220, 130)
GRADIENT_GREEN_RGB: tuple[int, int, int] = (130, 200, 130)
#: Couleurs cibles pour les single-gradients fréquents.
GRADIENT_TARGET_BLUE: tuple[int, int, int] = (30, 58, 138) # Jaccard, specialization
GRADIENT_TARGET_ORANGE: tuple[int, int, int] = (194, 65, 12) # densité, lexical mod.
GRADIENT_TARGET_RED: tuple[int, int, int] = (200, 60, 60) # divergence inter-engine
#: Couleurs cibles pour les diverging gradients.
DIVERGING_NEGATIVE_RGB: tuple[int, int, int] = (95, 145, 215) # bleu (under-norm)
DIVERGING_NEUTRAL_RGB: tuple[int, int, int] = (130, 200, 130) # vert (centre, OK)
DIVERGING_POSITIVE_RGB: tuple[int, int, int] = (220, 130, 60) # orange (over-norm)
# ──────────────────────────────────────────────────────────────────
# Helpers internes
# ──────────────────────────────────────────────────────────────────
def _interp(a: int, b: int, t: float) -> int:
"""Interpolation linéaire bornée à un canal RGB ∈ [0, 255]."""
return max(0, min(255, int(a + (b - a) * t)))
def _rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
# ──────────────────────────────────────────────────────────────────
# API publique : couleurs
# ──────────────────────────────────────────────────────────────────
def color_traffic_light(
value: float,
*,
low_is_good: bool = False,
scale_max: float = 1.0,
scale_min: float = 0.0,
) -> str:
"""Gradient rouge → jaune → vert proportionnel à ``value``.
Paramètres
----------
value : float
Valeur à colorer.
low_is_good : bool, default ``False``
Si ``True``, ``value = scale_min`` → vert et ``value = scale_max``
→ rouge (sémantique « plus c'est bas, mieux c'est » : ECE,
deficit, drag, CV, taux d'introduction d'erreurs…).
Si ``False`` (défaut), c'est l'inverse (sémantique « plus c'est
haut, mieux c'est » : F1, recall, taux de correction…).
scale_max : float, default ``1.0``
Borne haute de l'échelle. Au-delà, la couleur sature.
scale_min : float, default ``0.0``
Borne basse de l'échelle.
Retour
------
str
Couleur hex au format ``#rrggbb``.
"""
span = scale_max - scale_min
if span <= 0:
f = 0.5
else:
f = (value - scale_min) / span
f = max(0.0, min(1.0, f))
if low_is_good:
f = 1.0 - f
if f <= 0.5:
t = f / 0.5
r = _interp(GRADIENT_RED_RGB[0], GRADIENT_YELLOW_RGB[0], t)
g = _interp(GRADIENT_RED_RGB[1], GRADIENT_YELLOW_RGB[1], t)
b = _interp(GRADIENT_RED_RGB[2], GRADIENT_YELLOW_RGB[2], t)
else:
t = (f - 0.5) / 0.5
r = _interp(GRADIENT_YELLOW_RGB[0], GRADIENT_GREEN_RGB[0], t)
g = _interp(GRADIENT_YELLOW_RGB[1], GRADIENT_GREEN_RGB[1], t)
b = _interp(GRADIENT_YELLOW_RGB[2], GRADIENT_GREEN_RGB[2], t)
return _rgb_to_hex(r, g, b)
def color_single_gradient(
value: float,
*,
end_rgb: tuple[int, int, int],
max_value: float = 1.0,
start_rgb: tuple[int, int, int] = (255, 255, 255),
) -> str:
"""Gradient simple ``start_rgb`` → ``end_rgb`` proportionnel à ``value/max_value``.
Utilisé pour les heatmaps qui n'ont pas de sémantique « bon/mauvais »
mais juste une intensité (Jaccard, densité d'occurrence, taux de
modernisation lexicale).
"""
if max_value <= 0:
f = 0.0
else:
f = max(0.0, min(1.0, value / max_value))
r = _interp(start_rgb[0], end_rgb[0], f)
g = _interp(start_rgb[1], end_rgb[1], f)
b = _interp(start_rgb[2], end_rgb[2], f)
return _rgb_to_hex(r, g, b)
def color_diverging(
value: float,
*,
max_abs: float = 1.0,
negative_rgb: tuple[int, int, int] = DIVERGING_NEGATIVE_RGB,
neutral_rgb: tuple[int, int, int] = DIVERGING_NEUTRAL_RGB,
positive_rgb: tuple[int, int, int] = DIVERGING_POSITIVE_RGB,
) -> str:
"""Gradient signé : ``value < 0`` → ``negative_rgb`` (par défaut bleu),
``value ≈ 0`` → ``neutral_rgb`` (par défaut vert),
``value > 0`` → ``positive_rgb`` (par défaut orange).
Saturation à ``|value| = max_abs``.
"""
if max_abs <= 0:
return _rgb_to_hex(*neutral_rgb)
f = max(-1.0, min(1.0, value / max_abs))
if f >= 0:
r = _interp(neutral_rgb[0], positive_rgb[0], f)
g = _interp(neutral_rgb[1], positive_rgb[1], f)
b = _interp(neutral_rgb[2], positive_rgb[2], f)
else:
t = -f
r = _interp(neutral_rgb[0], negative_rgb[0], t)
g = _interp(neutral_rgb[1], negative_rgb[1], t)
b = _interp(neutral_rgb[2], negative_rgb[2], t)
return _rgb_to_hex(r, g, b)
def text_color_for_bg(intensity: float, *, threshold: float = 0.55) -> str:
"""Retourne ``"#fff"`` sur fond foncé, ``"#222"`` sur fond clair.
``intensity`` ∈ [0, 1] : 0 = fond clair, 1 = fond très foncé.
Pour les heatmaps single-gradient, c'est typiquement la même valeur
que celle passée à :func:`color_single_gradient`.
"""
return "#fff" if intensity > threshold else "#222"
# ──────────────────────────────────────────────────────────────────
# API publique : barème CER par paliers (badges du rapport)
# ──────────────────────────────────────────────────────────────────
#
# Les badges de qualité du rapport (galerie, tableau de classement)
# n'utilisent pas un dégradé continu mais un barème discret à 4
# paliers calibrés sur les seuils éditoriaux usuels :
#
# < 5 % : vert (qualité publication directe)
# < 15 % : jaune (relecture humaine légère)
# < 30 % : orange (relecture humaine systématique)
# ≥ 30 % : rouge (catastrophique, à reprendre)
#
# Les couleurs sont importées de :mod:`picarones.report.colors`
# (palette Okabe-Ito daltonien-friendly active par défaut).
def cer_step_color(cer: float) -> str:
"""Couleur de texte CSS pour un score CER, par paliers.
Voir le barème dans le bloc de documentation ci-dessus.
"""
from picarones.report.colors import (
COLOR_GREEN,
COLOR_ORANGE,
COLOR_RED,
COLOR_YELLOW,
)
if cer < 0.05:
return COLOR_GREEN
if cer < 0.15:
return COLOR_YELLOW
if cer < 0.30:
return COLOR_ORANGE
return COLOR_RED
def cer_step_bg(cer: float) -> str:
"""Couleur de fond CSS associée à :func:`cer_step_color`."""
from picarones.report.colors import (
BG_GREEN,
BG_ORANGE,
BG_RED,
BG_YELLOW,
)
if cer < 0.05:
return BG_GREEN
if cer < 0.15:
return BG_YELLOW
if cer < 0.30:
return BG_ORANGE
return BG_RED
# ──────────────────────────────────────────────────────────────────
# API publique : grille SVG
# ──────────────────────────────────────────────────────────────────
def build_grid_svg(
*,
n_rows: int,
n_cols: int,
row_label_fn: Callable[[int], str],
col_label_fn: Callable[[int], str],
cell_color_fn: Callable[[int, int], str],
cell_text_fn: Callable[[int, int], Optional[str]] = lambda r, c: None,
cell_text_color_fn: Callable[[int, int], str] = lambda r, c: "#222",
cell_w: int = 36,
cell_h: int = 36,
label_left: int = 130,
label_top: int = 80,
rotate_col_labels: bool = False,
aria_label: str = "Heatmap",
x_axis_title: Optional[str] = None,
) -> str:
"""Construit une heatmap SVG paramétrable.
Architecture commune des deux `_build_heatmap_svg` historiques
(taxonomy_cooccurrence et taxonomy_intra_doc), mutualisée ici.
Paramètres
----------
n_rows, n_cols : int
Dimensions de la grille.
row_label_fn, col_label_fn : Callable[[int], str]
Étiquettes des lignes (gauche) et colonnes (haut).
cell_color_fn : Callable[[int, int], str]
Retourne la couleur hex de fond pour la cellule (row, col).
cell_text_fn : Callable[[int, int], Optional[str]]
Texte à afficher dans la cellule, ou ``None`` pour ne rien afficher.
cell_text_color_fn : Callable[[int, int], str]
Couleur du texte de la cellule (typiquement obtenue via
:func:`text_color_for_bg`).
cell_w, cell_h : int
Dimensions de chaque cellule en pixels.
label_left, label_top : int
Marges réservées aux étiquettes.
rotate_col_labels : bool
Si ``True``, les étiquettes de colonnes sont rotées de -45°
(utile quand elles sont longues).
aria_label : str
Étiquette d'accessibilité du SVG.
x_axis_title : Optional[str]
Titre optionnel de l'axe horizontal, affiché en bas du SVG.
Retour
------
str
SVG complet, ou ``""`` si la grille est vide.
"""
if n_rows == 0 or n_cols == 0:
return ""
extra_bottom = 30 if x_axis_title else 10
width = label_left + n_cols * cell_w + 10
height = label_top + n_rows * cell_h + extra_bottom
parts: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{width}" height="{height}" '
f'viewBox="0 0 {width} {height}" '
f'role="img" aria-label="{_e(aria_label)}">',
]
# Étiquettes de colonnes
for j in range(n_cols):
cx = label_left + j * cell_w + cell_w // 2
cy = label_top - 6
label = _e(col_label_fn(j))
if rotate_col_labels:
parts.append(
f'<text x="{cx}" y="{cy}" '
f'transform="rotate(-45 {cx} {cy})" '
f'font-size="11" fill="#333" text-anchor="start">'
f'{label}</text>'
)
else:
parts.append(
f'<text x="{cx}" y="{cy}" '
f'font-size="10" fill="#666" text-anchor="middle">'
f'{label}</text>'
)
# Cellules + étiquettes de lignes
for i in range(n_rows):
rx = label_left - 6
ry = label_top + i * cell_h + cell_h // 2 + 4
parts.append(
f'<text x="{rx}" y="{ry}" '
f'font-size="11" fill="#333" text-anchor="end">'
f'{_e(row_label_fn(i))}</text>'
)
for j in range(n_cols):
x = label_left + j * cell_w
y = label_top + i * cell_h
color = cell_color_fn(i, j)
parts.append(
f'<rect x="{x}" y="{y}" '
f'width="{cell_w}" height="{cell_h}" '
f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
)
text = cell_text_fn(i, j)
if text is not None:
text_color = cell_text_color_fn(i, j)
parts.append(
f'<text x="{x + cell_w // 2}" '
f'y="{y + cell_h // 2 + 4}" '
f'font-size="10" fill="{text_color}" '
f'text-anchor="middle">'
f'{_e(text)}</text>'
)
if x_axis_title:
cx_axis = label_left + (n_cols * cell_w) // 2
cy_axis = height - 6
parts.append(
f'<text x="{cx_axis}" y="{cy_axis}" '
f'font-size="11" fill="#666" text-anchor="middle" '
f'font-style="italic">'
f'{_e(x_axis_title)}</text>'
)
parts.append("</svg>")
return "".join(parts)
__all__ = [
"GRADIENT_RED_RGB",
"GRADIENT_YELLOW_RGB",
"GRADIENT_GREEN_RGB",
"GRADIENT_TARGET_BLUE",
"GRADIENT_TARGET_ORANGE",
"GRADIENT_TARGET_RED",
"DIVERGING_NEGATIVE_RGB",
"DIVERGING_NEUTRAL_RGB",
"DIVERGING_POSITIVE_RGB",
"cer_step_color",
"cer_step_bg",
"color_traffic_light",
"color_single_gradient",
"color_diverging",
"text_color_for_bg",
"build_grid_svg",
]