"""Rendu SVG du Critical Difference Diagram (Sprint 17).
Visualisation canonique du résultat Friedman-Nemenyi (Demšar 2006) :
axe horizontal des rangs moyens + barres horizontales reliant les
moteurs statistiquement indiscernables au seuil α.
Module séparé du calcul (:mod:`friedman_nemenyi`) pour respecter la
distinction "computation vs presentation" : on peut imaginer un
rendu PNG, PDF, ou autre, sans toucher au calcul.
"""
from __future__ import annotations
def build_critical_difference_svg(
nemenyi_result: dict,
width: int = 780,
row_height: int = 22,
) -> str:
"""Génère le SVG du Critical Difference Diagram (Demšar 2006).
Le diagramme montre :
* un axe horizontal des rangs moyens (1 à k),
* chaque moteur positionné sur l'axe à son rang moyen,
* des barres horizontales épaisses reliant les moteurs statistiquement
indiscernables (distance ≤ CD),
* la longueur de CD affichée au-dessus de l'axe en référence.
Parameters
----------
nemenyi_result:
Résultat de ``nemenyi_posthoc``.
width:
Largeur totale du SVG en pixels.
row_height:
Hauteur de chaque ligne d'étiquette moteur (auto-adaptatif).
Returns
-------
Chaîne contenant le SVG (balise racine ````).
"""
k = nemenyi_result.get("n_engines", 0)
if k < 2 or nemenyi_result.get("error"):
return (
''
)
engines_sorted: list[str] = list(nemenyi_result.get("engines_sorted", []))
mean_ranks: dict[str, float] = dict(nemenyi_result.get("mean_ranks", {}))
tied_groups: list[list[str]] = list(nemenyi_result.get("tied_groups", []))
cd: float = float(nemenyi_result.get("critical_distance", 0.0))
# Dimensions
left_pad, right_pad = 40, 40
top_pad = 50 # espace pour l'affichage CD
axis_y = top_pad + 10
bars_start_y = axis_y + 20 # première barre d'ex-aequo sous l'axe
# Empiler une ligne par groupe + une ligne par étiquette
label_rows = k # chaque moteur a sa propre ligne de label
bars_count = len(tied_groups)
total_h = bars_start_y + bars_count * 10 + label_rows * row_height + 20
axis_x0, axis_x1 = left_pad, width - right_pad
axis_width = axis_x1 - axis_x0
def x_for_rank(r: float) -> float:
# Rang 1 à gauche, rang k à droite
if k <= 1:
return axis_x0
return axis_x0 + (r - 1.0) / (k - 1.0) * axis_width
parts: list[str] = []
parts.append(
f'')
return "".join(parts)
def _svg_escape(text: str) -> str:
"""Échappe un texte pour inclusion sûre dans un nœud SVG/XML."""
return (text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'"))
__all__ = ["build_critical_difference_svg"]