"""Rendu HTML du diagramme miroir taxonomique — Sprint 77. A.I.4 chantier 3 du plan d'évolution 2026. Suite directe ``picarones/core/taxonomy_comparison.py``. Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76) : **server-side**, pas de JavaScript, anti-injection systématique. Diagramme miroir ---------------- Une ligne par classe taxonomique, divisée en deux barres horizontales : - À **gauche** : barre du moteur A (orientée vers la gauche, du centre vers le bord). - À **droite** : barre du moteur B (orientée vers la droite). - Couleur de la classe selon ``recoverability`` : - vert (#5fa860) : ``recoverable`` - orange (#e0a050) : ``difficult`` - rouge (#d8553b) : ``irrecoverable`` Lecture immédiate : un moteur dont les barres tirent vers la **gauche** sur du vert (case_error, ligature_error) et un moteur qui tire à droite sur du rouge (lacuna) — la décision éditoriale est évidente même si les CER globaux sont identiques. """ from __future__ import annotations from html import escape as _e from typing import Optional _RECOVERABILITY_COLORS = { "recoverable": "#5fa860", "difficult": "#e0a050", "irrecoverable": "#d8553b", } def _build_mirror_chart_svg( data: dict, *, bar_max_width: int = 200, row_height: int = 22, label_width: int = 140, margin_top: int = 50, margin_bottom: int = 20, ) -> str: """Construit le diagramme miroir SVG.""" classes = data["classes"] prop_a = data["proportions_a"] prop_b = data["proportions_b"] recov = data["recoverability"] engine_a = data["engine_a"] engine_b = data["engine_b"] n_rows = len(classes) if n_rows == 0: return "" # Échelle : on normalise à la valeur max de toutes les # proportions (pour que la classe la plus présente atteigne # bar_max_width). max_prop = max( max(prop_a.values(), default=0.0), max(prop_b.values(), default=0.0), ) if max_prop <= 0: max_prop = 1.0 # évite division par zéro (cas dégénéré) width = label_width + 2 * bar_max_width + 40 height = margin_top + n_rows * row_height + margin_bottom center = width // 2 parts = [ f'', # En-têtes des deux moteurs f'{_e(engine_a)}', f'{_e(engine_b)}', # Ligne centrale f'', ] # Barres for i, cls in enumerate(classes): y = margin_top + i * row_height level = recov.get(cls, "difficult") color = _RECOVERABILITY_COLORS.get(level, "#888") # Étiquette de classe au centre parts.append( f'{_e(cls)}' ) # Barre A (gauche) a_width = (prop_a.get(cls, 0.0) / max_prop) * bar_max_width if a_width > 0: x_a = center - label_width // 2 - a_width parts.append( f'' ) # Valeur en % parts.append( f'' f'{prop_a.get(cls, 0.0) * 100:.1f}%' ) # Barre B (droite) b_width = (prop_b.get(cls, 0.0) / max_prop) * bar_max_width if b_width > 0: x_b = center + label_width // 2 parts.append( f'' ) parts.append( f'' f'{prop_b.get(cls, 0.0) * 100:.1f}%' ) parts.append("") return "".join(parts) def _build_recoverability_summary_html( data: dict, labels: dict, ) -> str: """Encart résumé par catégorie de récupérabilité (3 lignes).""" totals = data.get("totals_by_recoverability") or {} if not totals: return "" label_recov = labels.get("taxocomp_recoverable", "Récupérable") label_diff = labels.get("taxocomp_difficult", "Difficile") label_irrec = labels.get("taxocomp_irrecoverable", "Irrécupérable") rows = [ ("recoverable", label_recov), ("difficult", label_diff), ("irrecoverable", label_irrec), ] parts = [ '', '', '', '', '', '', ] for level, label in rows: cell = totals.get(level, {"a": 0.0, "b": 0.0}) color = _RECOVERABILITY_COLORS.get(level, "#888") parts.append( f'' f'' f'' f'' f'' ) parts.append("
' f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}' f'{_e(_e(data["engine_a"]))}' f'{_e(_e(data["engine_b"]))}
' f'' f'{_e(label)}{cell["a"] * 100:.1f}%{cell["b"] * 100:.1f}%
") return "".join(parts) def build_taxonomy_comparison_html( data: Optional[dict], labels: Optional[dict[str, str]] = None, ) -> str: """Construit le bloc HTML de comparaison taxonomique entre 2 moteurs. Retourne ``""`` si ``data is None`` ou aucune classe. """ if not data: return "" classes = data.get("classes") or [] if not classes: return "" labels = labels or {} title_template = labels.get( "taxocomp_title", "Profil taxonomique : {engine_a} vs {engine_b}", ) title = title_template.format( engine_a=data["engine_a"], engine_b=data["engine_b"], ) note = labels.get( "taxocomp_note", "Diagramme miroir des proportions d'erreurs par classe. " "Couleur selon récupérabilité éditoriale (vert = corrigeable, " "rouge = irrécupérable). À CER global égal, un moteur dont les " "erreurs sont majoritairement vertes est préférable pour une " "édition critique.", ) parts = [ '
', f'
{_e(title)}
', f'
' f'{_e(note)}
', _build_mirror_chart_svg(data), _build_recoverability_summary_html(data, labels), "
", ] return "".join(parts) __all__ = [ "build_taxonomy_comparison_html", ]