"""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'', ] # É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'' f'{label}' ) else: parts.append( f'' f'{label}' ) # 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'' f'{_e(row_label_fn(i))}' ) 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'' ) text = cell_text_fn(i, j) if text is not None: text_color = cell_text_color_fn(i, j) parts.append( f'' f'{_e(text)}' ) if x_axis_title: cx_axis = label_left + (n_cols * cell_w) // 2 cy_axis = height - 6 parts.append( f'' f'{_e(x_axis_title)}' ) parts.append("") 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", ]