"""Rendu HTML de la heatmap de co-occurrence taxonomique — Sprint 75. A.I.4 chantier 1 du plan d'évolution 2026. Suite directe ``picarones/core/taxonomy_cooccurrence.py``. Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74) : **server-side**, pas de JavaScript, anti-injection systématique. Sortie typique -------------- - ``build_taxonomy_cooccurrence_html(data, labels)`` produit un bloc complet : titre + note d'usage + heatmap SVG + table des paires les plus co-occurrentes. - ``""`` retourné si ``data is None`` ou si la matrice est vide (rapport adaptatif). """ from __future__ import annotations from html import escape as _e from typing import Optional from picarones.report.render_helpers import ( GRADIENT_TARGET_BLUE, build_grid_svg, color_single_gradient, text_color_for_bg, ) def _build_jaccard_heatmap_svg( classes: list[str], matrix: dict[str, dict[str, float]], *, cell_size: int = 36, label_left: int = 130, label_top: int = 80, ) -> str: """Heatmap Jaccard de co-occurrence taxonomique. Délègue à :func:`build_grid_svg` ; reste un wrapper local qui encapsule les conventions spécifiques à la matrice symétrique (valeur affichée seulement si > 0,05, étiquettes rotées). """ if not classes: return "" def cell_value(i: int, j: int) -> float: return matrix.get(classes[i], {}).get(classes[j], 0.0) return build_grid_svg( n_rows=len(classes), n_cols=len(classes), row_label_fn=lambda i: classes[i], col_label_fn=lambda j: classes[j], cell_color_fn=lambda i, j: color_single_gradient( cell_value(i, j), end_rgb=GRADIENT_TARGET_BLUE, ), cell_text_fn=lambda i, j: ( f"{cell_value(i, j):.2f}" if cell_value(i, j) > 0.05 else None ), cell_text_color_fn=lambda i, j: text_color_for_bg(cell_value(i, j)), cell_w=cell_size, cell_h=cell_size, label_left=label_left, label_top=label_top, rotate_col_labels=True, aria_label="Heatmap Jaccard co-occurrence taxonomique", ) def _build_top_pairs_table( top_pairs: list, labels: dict, ) -> str: """Construit la table HTML des paires les plus co-occurrentes.""" if not top_pairs: return "" pair_label = labels.get("taxocooc_pair_label", "Paire") jaccard_label = labels.get("taxocooc_jaccard_label", "Jaccard") parts = [ '', '', f'', f'', '', ] for ca, cb, j in top_pairs: parts.append( f'' f'' f'' f'' ) parts.append("
' f'{_e(pair_label)}' f'{_e(jaccard_label)}
' f'{_e(ca)}{_e(cb)}{j:.2f}
") return "".join(parts) def build_taxonomy_cooccurrence_html( data: Optional[dict], labels: Optional[dict[str, str]] = None, ) -> str: """Construit le bloc HTML complet de co-occurrence taxonomique. Retourne ``""`` si ``data is None`` ou matrice vide. """ if not data: return "" classes = data.get("classes") or [] matrix = data.get("cooccurrence_matrix") or {} if not classes or not matrix: return "" labels = labels or {} title = labels.get( "taxocooc_title", "Co-occurrence des classes d'erreur", ) note = labels.get( "taxocooc_note", "Indice de Jaccard au niveau document : 1,00 = ces deux classes " "apparaissent toujours ensemble ; 0,00 = jamais. Lecture par paires " "co-occurrentes ci-dessous.", ) n_docs = data.get("n_documents", 0) n_docs_label_template = labels.get( "taxocooc_n_docs", "Calculé sur {n_docs} documents.", ) n_docs_phrase = n_docs_label_template.format(n_docs=n_docs) svg = _build_jaccard_heatmap_svg(classes, matrix) top_table = _build_top_pairs_table( data.get("top_pairs") or [], labels, ) parts = [ '
', f'
{_e(title)}
', f'
' f'{_e(note)}
', f'
' f'{_e(n_docs_phrase)}
', svg, top_table, "
", ] return "".join(parts) __all__ = [ "build_taxonomy_cooccurrence_html", ]