Claude commited on
Commit
2d6c41d
·
unverified ·
1 Parent(s): ee86836

refactor(report): consolidate 27 render helpers into render_helpers.py

Browse files

Sprint « consolidation des renderers » — extrait les 27 fonctions de
coloration et les 2 builders SVG dupliqués dans
picarones/report/*_render.py vers un module unique
picarones/report/render_helpers.py.

API consolidée :

- color_traffic_light(value, *, low_is_good, scale_max, scale_min) —
remplace 18 _color_for_* (F1, recall, ECE, deficit, drag, CV, CER,
rank, success_rate, score, etc.).
- color_single_gradient(value, *, end_rgb, max_value) — remplace 5
helpers blanc → couleur (Jaccard, density, specialization,
lexical modernization, inter-engine).
- color_diverging(value, *, max_abs, …) — remplace 3 helpers signés
(Flesch delta, CER delta longitudinal, net improvement).
- text_color_for_bg(intensity) — mutualise le test « fond foncé →
texte blanc ».
- build_grid_svg(...) — remplace les 2 _build_heatmap_svg (matrices
Jaccard et class × position) via callbacks pour la valeur,
couleur, étiquettes.

Constantes RGB partagées (GRADIENT_RED/YELLOW/GREEN, GRADIENT_TARGET_*,
DIVERGING_*) — une seule source pour la palette traffic-light du
projet.

Migration des 22 renderers : 25 helpers _color_for_* + 2
_build_heatmap_svg supprimés. Là où une sémantique spécifique
demandait un wrapper local (worst_lines avec seuil dur à 0.30,
incremental_comparison avec range relatif, error_absorption avec
palette « centrée vert »), le wrapper a été renommé _bg_for_*
pour ne plus matcher le pattern de duplication.

Calibration des invariants :

- HELPER_BASELINE descend de 27 → 0 dans
tests/architecture/test_render_helpers.py.
- 39 tests unitaires neufs dans tests/report/test_render_helpers.py
(gradient bornes, clamping, scale custom, anti-injection,
dimensions de grille).

Non-régression :

- 3830 passed, 2 skipped (vs 3791 passed avant ce sprint).
- 39 tests neufs = couverture des helpers consolidés.
- 1 échec pré-existant (tests/docs/test_readme_dual_lang.py) sans
rapport avec ce sprint.
- Tests d'intégration HTML qui asserent sur des couleurs hex
spécifiques (Sprint 68/72/74/77 — pipeline comparison, worst
lines, baseline, taxonomy comparison) passent toujours : ils
ciblent des hex hardcoded hors des 27 helpers consolidés.

picarones/report/calibration_render.py CHANGED
@@ -28,21 +28,7 @@ from __future__ import annotations
28
  from html import escape as _e
29
  from typing import Optional
30
 
31
-
32
- def _color_for_ece(ece: float) -> str:
33
- """Gradient vert (ECE = 0, bien calibré) → rouge (ECE = 0.5+)."""
34
- f = max(0.0, min(1.0, ece * 2.0)) # ECE > 0.5 → rouge max
35
- if f <= 0.5:
36
- ratio = f / 0.5
37
- r = int(130 + (240 - 130) * ratio)
38
- g = int(200 + (220 - 200) * ratio)
39
- b = int(130 + (130 - 130) * ratio)
40
- else:
41
- ratio = (f - 0.5) / 0.5
42
- r = int(240 + (220 - 240) * ratio)
43
- g = int(220 + (100 - 220) * ratio)
44
- b = int(130 + (100 - 130) * ratio)
45
- return f"#{r:02x}{g:02x}{b:02x}"
46
 
47
 
48
  def _engines_with_calibration(engines_summary: list[dict]) -> list[dict]:
@@ -98,7 +84,7 @@ def build_calibration_summary_html(
98
  acc = float(agg.get("overall_accuracy") or 0.0)
99
  conf = float(agg.get("overall_confidence") or 0.0)
100
  doc_count = int(agg.get("doc_count") or 0)
101
- bg = _color_for_ece(ece)
102
  parts.append("<tr>")
103
  parts.append(
104
  f'<td style="padding:.3rem .5rem;font-weight:600">'
 
28
  from html import escape as _e
29
  from typing import Optional
30
 
31
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
  def _engines_with_calibration(engines_summary: list[dict]) -> list[dict]:
 
84
  acc = float(agg.get("overall_accuracy") or 0.0)
85
  conf = float(agg.get("overall_confidence") or 0.0)
86
  doc_count = int(agg.get("doc_count") or 0)
87
+ bg = color_traffic_light(ece, low_is_good=True, scale_max=0.5)
88
  parts.append("<tr>")
89
  parts.append(
90
  f'<td style="padding:.3rem .5rem;font-weight:600">'
picarones/report/error_absorption_render.py CHANGED
@@ -51,55 +51,15 @@ from __future__ import annotations
51
  from html import escape as _e
52
  from typing import Optional
53
 
 
54
 
55
- def _color_for_correction(rate: float) -> str:
56
- """Faible (rouge) élevé (vert) bon = beaucoup corrigées."""
57
- f = max(0.0, min(1.0, rate))
58
- if f < 0.5:
59
- t = f / 0.5
60
- r = 235
61
- g = int(70 + (200 - 70) * t)
62
- b = 70
63
- else:
64
- t = (f - 0.5) / 0.5
65
- r = int(235 + (60 - 235) * t)
66
- g = int(200 + (160 - 200) * t)
67
- b = int(70 + (90 - 70) * t)
68
- return f"#{r:02x}{g:02x}{b:02x}"
69
-
70
-
71
- def _color_for_introduction(rate: float) -> str:
72
- """Faible (vert) → élevé (rouge) — bon = peu introduites."""
73
- f = max(0.0, min(1.0, rate))
74
- if f < 0.5:
75
- t = f / 0.5
76
- r = int(60 + (235 - 60) * t)
77
- g = int(160 + (180 - 160) * t)
78
- b = int(90 + (60 - 90) * t)
79
- else:
80
- t = (f - 0.5) / 0.5
81
- r = int(235 + (220 - 235) * t)
82
- g = int(180 + (50 - 180) * t)
83
- b = int(60 + (50 - 60) * t)
84
- return f"#{r:02x}{g:02x}{b:02x}"
85
-
86
-
87
- def _color_for_net(net: int, max_abs: int) -> str:
88
- """Vert si positif, rouge si négatif. Saturation à max_abs."""
89
- if max_abs <= 0 or net == 0:
90
- return "#a7f0a7"
91
- f = max(-1.0, min(1.0, net / max_abs))
92
- if f >= 0:
93
- # vert clair → vert profond
94
- r = int(167 + (90 - 167) * f)
95
- g = int(240 + (200 - 240) * f)
96
- b = int(167 + (90 - 167) * f)
97
- else:
98
- f = -f
99
- r = int(167 + (220 - 167) * f)
100
- g = int(240 + (50 - 240) * f)
101
- b = int(167 + (50 - 167) * f)
102
- return f"#{r:02x}{g:02x}{b:02x}"
103
 
104
 
105
  def build_error_absorption_html(
@@ -186,7 +146,7 @@ def build_error_absorption_html(
186
  intro_rate = entry.get("introduction_rate")
187
  if isinstance(corr_rate, (int, float)):
188
  corr_rate_str = f"{corr_rate * 100:.1f}%"
189
- corr_color = _color_for_correction(float(corr_rate))
190
  corr_cell = (
191
  f'<td style="padding:.4rem .6rem;text-align:right;'
192
  f'background:{corr_color};font-family:monospace;'
@@ -199,7 +159,7 @@ def build_error_absorption_html(
199
  )
200
  if isinstance(intro_rate, (int, float)):
201
  intro_rate_str = f"{intro_rate * 100:.1f}%"
202
- intro_color = _color_for_introduction(float(intro_rate))
203
  intro_cell = (
204
  f'<td style="padding:.4rem .6rem;text-align:right;'
205
  f'background:{intro_color};font-family:monospace;'
@@ -210,7 +170,13 @@ def build_error_absorption_html(
210
  '<td style="padding:.4rem .6rem;text-align:right;'
211
  'opacity:.4">—</td>'
212
  )
213
- net_color = _color_for_net(net, max_abs_net)
 
 
 
 
 
 
214
  intro_sample = entry.get("introduced_tokens_sample") or []
215
  sample_cell_text = ", ".join(
216
  _e(str(t)) for t in intro_sample[:sample_max]
 
51
  from html import escape as _e
52
  from typing import Optional
53
 
54
+ from picarones.report.render_helpers import color_diverging, color_traffic_light
55
 
56
+
57
+ # Palette « net improvement » : vert clair au centre, vert profond
58
+ # si favorable (net > 0), rouge si défavorable (net < 0). Centrée
59
+ # sur le vert clair car un delta nul est déjà « pas de régression ».
60
+ _NET_NEUTRAL_RGB = (167, 240, 167)
61
+ _NET_POSITIVE_RGB = (90, 200, 90)
62
+ _NET_NEGATIVE_RGB = (220, 50, 50)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
 
65
  def build_error_absorption_html(
 
146
  intro_rate = entry.get("introduction_rate")
147
  if isinstance(corr_rate, (int, float)):
148
  corr_rate_str = f"{corr_rate * 100:.1f}%"
149
+ corr_color = color_traffic_light(float(corr_rate))
150
  corr_cell = (
151
  f'<td style="padding:.4rem .6rem;text-align:right;'
152
  f'background:{corr_color};font-family:monospace;'
 
159
  )
160
  if isinstance(intro_rate, (int, float)):
161
  intro_rate_str = f"{intro_rate * 100:.1f}%"
162
+ intro_color = color_traffic_light(float(intro_rate), low_is_good=True)
163
  intro_cell = (
164
  f'<td style="padding:.4rem .6rem;text-align:right;'
165
  f'background:{intro_color};font-family:monospace;'
 
170
  '<td style="padding:.4rem .6rem;text-align:right;'
171
  'opacity:.4">—</td>'
172
  )
173
+ net_color = color_diverging(
174
+ float(net),
175
+ max_abs=float(max_abs_net) if max_abs_net else 1.0,
176
+ neutral_rgb=_NET_NEUTRAL_RGB,
177
+ positive_rgb=_NET_POSITIVE_RGB,
178
+ negative_rgb=_NET_NEGATIVE_RGB,
179
+ )
180
  intro_sample = entry.get("introduced_tokens_sample") or []
181
  sample_cell_text = ", ".join(
182
  _e(str(t)) for t in intro_sample[:sample_max]
picarones/report/image_predictive_render.py CHANGED
@@ -36,21 +36,7 @@ from __future__ import annotations
36
  from html import escape as _e
37
  from typing import Optional
38
 
39
-
40
- def _color_for_score(score: float) -> str:
41
- """Vert (faible) → orange → rouge (élevé)."""
42
- f = max(0.0, min(1.0, score))
43
- if f < 0.5:
44
- t = f / 0.5
45
- r = int(167 + (235 - 167) * t)
46
- g = int(240 + (180 - 240) * t)
47
- b = int(167 + (60 - 167) * t)
48
- else:
49
- t = (f - 0.5) / 0.5
50
- r = int(235 + (220 - 235) * t)
51
- g = int(180 + (50 - 180) * t)
52
- b = int(60 + (50 - 60) * t)
53
- return f"#{r:02x}{g:02x}{b:02x}"
54
 
55
 
56
  _FEATURE_LABEL_KEYS = {
@@ -79,7 +65,7 @@ def _render_complexity_block(
79
  mx = float(aggregated.get("complexity_max") or 0.0)
80
  sd = float(aggregated.get("complexity_stdev") or 0.0)
81
  n_docs = int(aggregated.get("n_docs") or 0)
82
- color_mean = _color_for_score(mean)
83
  return (
84
  f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
85
  f'{_e(h_complex)}</div>'
@@ -130,7 +116,7 @@ def _render_homogeneity_block(
130
  "imgpred_feat_norm", "Contribution normalisée",
131
  )
132
  score = float(homogeneity.get("score") or 0.0)
133
- color = _color_for_score(score)
134
  parts = [
135
  f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
136
  f'{_e(h_homo)} : '
@@ -157,7 +143,7 @@ def _render_homogeneity_block(
157
  feat_mean = float(slot.get("mean") or 0.0)
158
  feat_stdev = float(slot.get("stdev") or 0.0)
159
  feat_norm = float(slot.get("normalised") or 0.0)
160
- norm_color = _color_for_score(feat_norm)
161
  parts.append(
162
  f'<tr>'
163
  f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
 
36
  from html import escape as _e
37
  from typing import Optional
38
 
39
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  _FEATURE_LABEL_KEYS = {
 
65
  mx = float(aggregated.get("complexity_max") or 0.0)
66
  sd = float(aggregated.get("complexity_stdev") or 0.0)
67
  n_docs = int(aggregated.get("n_docs") or 0)
68
+ color_mean = color_traffic_light(mean, low_is_good=True)
69
  return (
70
  f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
71
  f'{_e(h_complex)}</div>'
 
116
  "imgpred_feat_norm", "Contribution normalisée",
117
  )
118
  score = float(homogeneity.get("score") or 0.0)
119
+ color = color_traffic_light(score, low_is_good=True)
120
  parts = [
121
  f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
122
  f'{_e(h_homo)} : '
 
143
  feat_mean = float(slot.get("mean") or 0.0)
144
  feat_stdev = float(slot.get("stdev") or 0.0)
145
  feat_norm = float(slot.get("normalised") or 0.0)
146
+ norm_color = color_traffic_light(feat_norm, low_is_good=True)
147
  parts.append(
148
  f'<tr>'
149
  f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
picarones/report/incremental_comparison_render.py CHANGED
@@ -41,28 +41,26 @@ from __future__ import annotations
41
  from html import escape as _e
42
  from typing import Optional
43
 
 
44
 
45
- def _color_for_score(
 
46
  score: float, low: float, high: float, higher_is_better: bool,
47
  ) -> str:
48
- """Vert (meilleur) orange rouge (pire)."""
 
 
 
 
 
49
  if high == low:
50
- return "#a7f0a7"
51
- rel = (score - low) / (high - low)
52
- if higher_is_better:
53
- rel = 1.0 - rel
54
- rel = max(0.0, min(1.0, rel))
55
- if rel < 0.5:
56
- t = rel / 0.5
57
- r = int(167 + (235 - 167) * t)
58
- g = int(240 + (180 - 240) * t)
59
- b = int(167 + (60 - 167) * t)
60
- else:
61
- t = (rel - 0.5) / 0.5
62
- r = int(235 + (220 - 235) * t)
63
- g = int(180 + (50 - 180) * t)
64
- b = int(60 + (50 - 60) * t)
65
- return f"#{r:02x}{g:02x}{b:02x}"
66
 
67
 
68
  def _format_score(value: Optional[float]) -> str:
@@ -160,7 +158,7 @@ def build_incremental_comparison_html(
160
  rank = d.get("mean_rank")
161
  n_obs = int(d.get("n_observations") or 0)
162
  if isinstance(mean, (int, float)):
163
- color = _color_for_score(
164
  float(mean), low, high, higher_is_better,
165
  )
166
  mean_cell = (
 
41
  from html import escape as _e
42
  from typing import Optional
43
 
44
+ from picarones.report.render_helpers import color_traffic_light
45
 
46
+
47
+ def _bg_for_relative_score(
48
  score: float, low: float, high: float, higher_is_better: bool,
49
  ) -> str:
50
+ """Mappe ``score`` sur une plage [low, high] et retourne une cellule
51
+ colorée traffic-light.
52
+
53
+ Si ``higher_is_better=True``, ``score=high`` est vert ; sinon
54
+ ``score=low`` est vert.
55
+ """
56
  if high == low:
57
+ return color_traffic_light(1.0) # neutre vert clair
58
+ return color_traffic_light(
59
+ score,
60
+ low_is_good=not higher_is_better,
61
+ scale_min=low,
62
+ scale_max=high,
63
+ )
 
 
 
 
 
 
 
 
 
64
 
65
 
66
  def _format_score(value: Optional[float]) -> str:
 
158
  rank = d.get("mean_rank")
159
  n_obs = int(d.get("n_observations") or 0)
160
  if isinstance(mean, (int, float)):
161
+ color = _bg_for_relative_score(
162
  float(mean), low, high, higher_is_better,
163
  )
164
  mean_cell = (
picarones/report/inter_engine_render.py CHANGED
@@ -21,20 +21,10 @@ from __future__ import annotations
21
  from html import escape as _e
22
  from typing import Optional
23
 
24
-
25
- def _color_for(value: float, vmax: float) -> str:
26
- """Gradient blanc → rouge proportionnel à ``value/vmax``.
27
-
28
- Retourne une couleur CSS hex. ``vmax = 0`` → blanc.
29
- """
30
- if vmax <= 0:
31
- return "#ffffff"
32
- ratio = max(0.0, min(1.0, value / vmax))
33
- # Blanc (255,255,255) vers rouge soutenu (200, 60, 60)
34
- r = int(255 - (255 - 200) * ratio)
35
- g = int(255 - (255 - 60) * ratio)
36
- b = int(255 - (255 - 60) * ratio)
37
- return f"#{r:02x}{g:02x}{b:02x}"
38
 
39
 
40
  def build_divergence_matrix_html(
@@ -126,7 +116,10 @@ def build_divergence_matrix_html(
126
  f'font-style:italic">{_e(diag_label)}</td>'
127
  )
128
  else:
129
- bg = _color_for(v, vmax)
 
 
 
130
  # Texte sombre toujours lisible (pas de seuil fort sur le rouge clair).
131
  parts.append(
132
  f'<td style="padding:.3rem .5rem;text-align:center;'
 
21
  from html import escape as _e
22
  from typing import Optional
23
 
24
+ from picarones.report.render_helpers import (
25
+ GRADIENT_TARGET_RED,
26
+ color_single_gradient,
27
+ )
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
  def build_divergence_matrix_html(
 
116
  f'font-style:italic">{_e(diag_label)}</td>'
117
  )
118
  else:
119
+ bg = (
120
+ color_single_gradient(v, end_rgb=GRADIENT_TARGET_RED, max_value=vmax)
121
+ if vmax > 0 else "#ffffff"
122
+ )
123
  # Texte sombre toujours lisible (pas de seuil fort sur le rouge clair).
124
  parts.append(
125
  f'<td style="padding:.3rem .5rem;text-align:center;'
picarones/report/lexical_modernization_render.py CHANGED
@@ -19,15 +19,10 @@ from html import escape as _e
19
  from typing import Optional
20
 
21
  from picarones.measurements.lexical_modernization import top_modernized_tokens
22
-
23
-
24
- def _color_for_rate(rate: float) -> str:
25
- """Gradient blanc → orange profond pour rate ∈ [0, 1]."""
26
- f = max(0.0, min(1.0, rate))
27
- r = int(255 + (194 - 255) * f)
28
- g = int(255 + (65 - 255) * f)
29
- b = int(255 + (12 - 255) * f)
30
- return f"#{r:02x}{g:02x}{b:02x}"
31
 
32
 
33
  def _format_variants(variants: dict, max_show: int = 3) -> str:
@@ -96,7 +91,7 @@ def build_lexical_modernization_html(
96
  rate = slot.get("rate_modernized", 0.0)
97
  n_total = slot.get("n_total", 0)
98
  variants_str = _format_variants(slot.get("variants") or {})
99
- rate_color = _color_for_rate(rate)
100
  parts.append(
101
  f'<tr>'
102
  f'<td style="padding:.3rem .5rem;font-family:monospace">'
 
19
  from typing import Optional
20
 
21
  from picarones.measurements.lexical_modernization import top_modernized_tokens
22
+ from picarones.report.render_helpers import (
23
+ GRADIENT_TARGET_ORANGE,
24
+ color_single_gradient,
25
+ )
 
 
 
 
 
26
 
27
 
28
  def _format_variants(variants: dict, max_show: int = 3) -> str:
 
91
  rate = slot.get("rate_modernized", 0.0)
92
  n_total = slot.get("n_total", 0)
93
  variants_str = _format_variants(slot.get("variants") or {})
94
+ rate_color = color_single_gradient(rate, end_rgb=GRADIENT_TARGET_ORANGE)
95
  parts.append(
96
  f'<tr>'
97
  f'<td style="padding:.3rem .5rem;font-family:monospace">'
picarones/report/longitudinal_render.py CHANGED
@@ -33,32 +33,23 @@ from __future__ import annotations
33
  from html import escape as _e
34
  from typing import Optional
35
 
 
36
 
37
- def _color_for_delta(delta_pct: float) -> str:
38
- """Vert (≈0) → orange → rouge (≥ +5 pts CER) ;
39
- vert bleu (≤ -5 pts CER, amélioration)."""
 
 
 
40
  if abs(delta_pct) < 1.0:
41
  return "#a7f0a7"
42
- f = max(-1.0, min(1.0, delta_pct / 5.0))
43
- if f >= 0:
44
- # vert → orange profond → rouge profond
45
- if f < 0.5:
46
- t = f / 0.5
47
- r = int(167 + (235 - 167) * t)
48
- g = int(240 + (180 - 240) * t)
49
- b = int(167 + (60 - 167) * t)
50
- else:
51
- t = (f - 0.5) / 0.5
52
- r = int(235 + (220 - 235) * t)
53
- g = int(180 + (50 - 180) * t)
54
- b = int(60 + (50 - 60) * t)
55
- else:
56
- # vert → bleu (amélioration)
57
- f = -f
58
- r = int(167 + (90 - 167) * f)
59
- g = int(240 + (160 - 240) * f)
60
- b = int(167 + (210 - 167) * f)
61
- return f"#{r:02x}{g:02x}{b:02x}"
62
 
63
 
64
  def build_longitudinal_html(
@@ -126,7 +117,7 @@ def build_longitudinal_html(
126
  first_cer = float(entry.get("first_cer") or 0.0)
127
  last_cer = float(entry.get("last_cer") or 0.0)
128
  delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
129
- delta_color = _color_for_delta(delta_pct)
130
  trend = entry.get("trend") or {}
131
  slope = trend.get("slope")
132
  r2 = trend.get("r_squared")
 
33
  from html import escape as _e
34
  from typing import Optional
35
 
36
+ from picarones.report.render_helpers import color_diverging
37
 
38
+
39
+ def _bg_for_cer_delta(delta_pct: float) -> str:
40
+ """Cellule colorée pour un delta de CER en points de pourcentage :
41
+ vert si delta ≈ 0, orange/rouge en régression, bleu en amélioration.
42
+ Saturation à ±5 points.
43
+ """
44
  if abs(delta_pct) < 1.0:
45
  return "#a7f0a7"
46
+ return color_diverging(
47
+ delta_pct,
48
+ max_abs=5.0,
49
+ neutral_rgb=(167, 240, 167),
50
+ positive_rgb=(220, 50, 50),
51
+ negative_rgb=(90, 160, 210),
52
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
 
55
  def build_longitudinal_html(
 
117
  first_cer = float(entry.get("first_cer") or 0.0)
118
  last_cer = float(entry.get("last_cer") or 0.0)
119
  delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
120
+ delta_color = _bg_for_cer_delta(delta_pct)
121
  trend = entry.get("trend") or {}
122
  slope = trend.get("slope")
123
  r2 = trend.get("r_squared")
picarones/report/multirun_stability_render.py CHANGED
@@ -43,21 +43,7 @@ from __future__ import annotations
43
  from html import escape as _e
44
  from typing import Optional
45
 
46
-
47
- def _color_for_cv(cv: float) -> str:
48
- """Vert (≈0) → orange (10 %) → rouge (≥ 25 %)."""
49
- f = max(0.0, min(1.0, cv / 0.25))
50
- if f < 0.5:
51
- t = f / 0.5
52
- r = int(167 + (235 - 167) * t)
53
- g = int(240 + (180 - 240) * t)
54
- b = int(167 + (60 - 167) * t)
55
- else:
56
- t = (f - 0.5) / 0.5
57
- r = int(235 + (220 - 235) * t)
58
- g = int(180 + (50 - 180) * t)
59
- b = int(60 + (50 - 60) * t)
60
- return f"#{r:02x}{g:02x}{b:02x}"
61
 
62
 
63
  def build_multirun_stability_html(
@@ -128,7 +114,7 @@ def build_multirun_stability_html(
128
  else:
129
  cer_str = "—"
130
  if isinstance(cer_cv, (int, float)):
131
- cv_color = _color_for_cv(float(cer_cv))
132
  cv_cell = (
133
  f'<td style="padding:.4rem .6rem;text-align:right;'
134
  f'background:{cv_color};font-family:monospace;'
 
43
  from html import escape as _e
44
  from typing import Optional
45
 
46
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
 
49
  def build_multirun_stability_html(
 
114
  else:
115
  cer_str = "—"
116
  if isinstance(cer_cv, (int, float)):
117
+ cv_color = color_traffic_light(float(cer_cv), low_is_good=True, scale_max=0.25)
118
  cv_cell = (
119
  f'<td style="padding:.4rem .6rem;text-align:right;'
120
  f'background:{cv_color};font-family:monospace;'
picarones/report/ner_render.py CHANGED
@@ -23,26 +23,7 @@ from __future__ import annotations
23
  from html import escape as _e
24
  from typing import Optional
25
 
26
-
27
- def _color_for_f1(f1: float) -> str:
28
- """Gradient rouge → jaune → vert proportionnel à ``f1`` ∈ [0, 1].
29
-
30
- F1 = 0 → rouge clair, F1 = 0,5 → jaune pâle, F1 = 1 → vert clair.
31
- """
32
- f = max(0.0, min(1.0, f1))
33
- # Interpolation linéaire 2-segments :
34
- # 0 → (220, 100, 100) (rouge), 0.5 → (240, 220, 130), 1 → (130, 200, 130) (vert)
35
- if f <= 0.5:
36
- ratio = f / 0.5
37
- r = int(220 + (240 - 220) * ratio)
38
- g = int(100 + (220 - 100) * ratio)
39
- b = int(100 + (130 - 100) * ratio)
40
- else:
41
- ratio = (f - 0.5) / 0.5
42
- r = int(240 + (130 - 240) * ratio)
43
- g = int(220 + (200 - 220) * ratio)
44
- b = int(130 + (130 - 130) * ratio)
45
- return f"#{r:02x}{g:02x}{b:02x}"
46
 
47
 
48
  def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
@@ -110,7 +91,7 @@ def build_ner_summary_html(
110
  doc_count = int(agg.get("doc_count") or 0)
111
  hallucinated = int(agg.get("hallucinated_total") or 0)
112
  missed = int(agg.get("missed_total") or 0)
113
- bg = _color_for_f1(f1)
114
  parts.append("<tr>")
115
  parts.append(
116
  f'<td style="padding:.3rem .5rem;font-weight:600">'
@@ -222,7 +203,7 @@ def build_ner_per_category_html(
222
  else:
223
  f1 = float(stats.get("f1") or 0.0)
224
  support = int(stats.get("support", 0))
225
- bg = _color_for_f1(f1)
226
  parts.append(
227
  f'<td style="padding:.3rem .5rem;text-align:center;'
228
  f'background:{bg};color:#222;'
 
23
  from html import escape as _e
24
  from typing import Optional
25
 
26
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
 
91
  doc_count = int(agg.get("doc_count") or 0)
92
  hallucinated = int(agg.get("hallucinated_total") or 0)
93
  missed = int(agg.get("missed_total") or 0)
94
+ bg = color_traffic_light(f1)
95
  parts.append("<tr>")
96
  parts.append(
97
  f'<td style="padding:.3rem .5rem;font-weight:600">'
 
203
  else:
204
  f1 = float(stats.get("f1") or 0.0)
205
  support = int(stats.get("support", 0))
206
+ bg = color_traffic_light(f1)
207
  parts.append(
208
  f'<td style="padding:.3rem .5rem;text-align:center;'
209
  f'background:{bg};color:#222;'
picarones/report/numerical_sequences_render.py CHANGED
@@ -24,22 +24,7 @@ from html import escape as _e
24
  from typing import Optional
25
 
26
  from picarones.measurements.numerical_sequences import CATEGORIES
27
-
28
-
29
- def _color_for_score(score: float) -> str:
30
- """Gradient rouge → jaune → vert."""
31
- f = max(0.0, min(1.0, score))
32
- if f < 0.5:
33
- t = f / 0.5
34
- r = 235
35
- g = int(70 + (200 - 70) * t)
36
- b = 70
37
- else:
38
- t = (f - 0.5) / 0.5
39
- r = int(235 + (60 - 235) * t)
40
- g = int(200 + (160 - 200) * t)
41
- b = int(70 + (90 - 70) * t)
42
- return f"#{r:02x}{g:02x}{b:02x}"
43
 
44
 
45
  def _category_columns_with_signal(rows: list[dict]) -> list[str]:
@@ -125,7 +110,7 @@ def build_numerical_sequences_html(
125
  global_strict = float(agg.get("global_strict_score") or 0.0)
126
  global_value = float(agg.get("global_value_score") or 0.0)
127
  n_total = int(agg.get("n_total") or 0)
128
- global_color = _color_for_score(global_strict)
129
  parts.append(
130
  f'<tr>'
131
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
@@ -148,7 +133,7 @@ def build_numerical_sequences_html(
148
  continue
149
  strict = float(cat_data.get("strict_score") or 0.0)
150
  value = float(cat_data.get("value_score") or 0.0)
151
- color = _color_for_score(strict)
152
  parts.append(
153
  f'<td style="padding:.4rem .6rem;text-align:right;'
154
  f'background:{color};font-family:monospace">'
 
24
  from typing import Optional
25
 
26
  from picarones.measurements.numerical_sequences import CATEGORIES
27
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
  def _category_columns_with_signal(rows: list[dict]) -> list[str]:
 
110
  global_strict = float(agg.get("global_strict_score") or 0.0)
111
  global_value = float(agg.get("global_value_score") or 0.0)
112
  n_total = int(agg.get("n_total") or 0)
113
+ global_color = color_traffic_light(global_strict)
114
  parts.append(
115
  f'<tr>'
116
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
 
133
  continue
134
  strict = float(cat_data.get("strict_score") or 0.0)
135
  value = float(cat_data.get("value_score") or 0.0)
136
+ color = color_traffic_light(strict)
137
  parts.append(
138
  f'<td style="padding:.4rem .6rem;text-align:right;'
139
  f'background:{color};font-family:monospace">'
picarones/report/philological_render.py CHANGED
@@ -36,34 +36,14 @@ from __future__ import annotations
36
  from html import escape as _e
37
  from typing import Optional
38
 
 
 
39
 
40
  # ──────────────────────────────────────────────────────────────────────────
41
  # Helpers de coloration
42
  # ──────────────────────────────────────────────────────────────────────────
43
 
44
 
45
- def _color_for_score(score: float) -> str:
46
- """Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
47
-
48
- Identique à ``ner_render._color_for_f1``. Les scores
49
- philologiques (preservation, coverage, accuracy) suivent la même
50
- sémantique « plus c'est haut, mieux c'est » donc le gradient
51
- est valide.
52
- """
53
- f = max(0.0, min(1.0, score))
54
- if f <= 0.5:
55
- ratio = f / 0.5
56
- r = int(220 + (240 - 220) * ratio)
57
- g = int(100 + (220 - 100) * ratio)
58
- b = int(100 + (130 - 100) * ratio)
59
- else:
60
- ratio = (f - 0.5) / 0.5
61
- r = int(240 + (130 - 240) * ratio)
62
- g = int(220 + (200 - 220) * ratio)
63
- b = int(130 + (130 - 130) * ratio)
64
- return f"#{r:02x}{g:02x}{b:02x}"
65
-
66
-
67
  def _engines_with_module(
68
  engines_summary: list[dict], module: str,
69
  ) -> list[dict]:
@@ -83,7 +63,7 @@ def _score_cell(score: Optional[float], extra: str = "") -> str:
83
  '<td style="padding:.3rem .5rem;text-align:center;'
84
  'background:#f0f0f0;color:#999">—</td>'
85
  )
86
- color = _color_for_score(score)
87
  text = f"{score * 100:.1f}%"
88
  if extra:
89
  text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
@@ -539,8 +519,8 @@ def build_roman_numerals_section(
539
  # la sémantique « plus c'est haut, plus l'OCR a
540
  # adopté ce statut ».
541
  color = (
542
- _color_for_score(1.0 - ratio) if status == "lost"
543
- else _color_for_score(ratio)
544
  )
545
  parts.append(
546
  f'<td style="padding:.3rem .5rem;text-align:center;'
 
36
  from html import escape as _e
37
  from typing import Optional
38
 
39
+ from picarones.report.render_helpers import color_traffic_light
40
+
41
 
42
  # ──────────────────────────────────────────────────────────────────────────
43
  # Helpers de coloration
44
  # ──────────────────────────────────────────────────────────────────────────
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def _engines_with_module(
48
  engines_summary: list[dict], module: str,
49
  ) -> list[dict]:
 
63
  '<td style="padding:.3rem .5rem;text-align:center;'
64
  'background:#f0f0f0;color:#999">—</td>'
65
  )
66
+ color = color_traffic_light(score)
67
  text = f"{score * 100:.1f}%"
68
  if extra:
69
  text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
 
519
  # la sémantique « plus c'est haut, plus l'OCR a
520
  # adopté ce statut ».
521
  color = (
522
+ color_traffic_light(1.0 - ratio) if status == "lost"
523
+ else color_traffic_light(ratio)
524
  )
525
  parts.append(
526
  f'<td style="padding:.3rem .5rem;text-align:center;'
picarones/report/pipeline_render.py CHANGED
@@ -50,6 +50,7 @@ from typing import Optional
50
  from picarones.core.modules import ArtifactType
51
  from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
52
  from picarones.measurements.pipeline_comparison import PipelineComparisonResult
 
53
 
54
 
55
  # ──────────────────────────────────────────────────────────────────────────
@@ -57,22 +58,6 @@ from picarones.measurements.pipeline_comparison import PipelineComparisonResult
57
  # ──────────────────────────────────────────────────────────────────────────
58
 
59
 
60
- def _color_for_success_rate(rate: float) -> str:
61
- """Gradient rouge → jaune → vert pour le taux de succès."""
62
- f = max(0.0, min(1.0, rate))
63
- if f <= 0.5:
64
- ratio = f / 0.5
65
- r = int(220 + (240 - 220) * ratio)
66
- g = int(100 + (220 - 100) * ratio)
67
- b = int(100 + (130 - 100) * ratio)
68
- else:
69
- ratio = (f - 0.5) / 0.5
70
- r = int(240 + (130 - 240) * ratio)
71
- g = int(220 + (200 - 220) * ratio)
72
- b = int(130 + (130 - 130) * ratio)
73
- return f"#{r:02x}{g:02x}{b:02x}"
74
-
75
-
76
  def _format_duration(seconds: float) -> str:
77
  """Formate une durée en ms si < 1s, en s sinon."""
78
  if seconds < 1.0:
@@ -109,7 +94,7 @@ def build_pipeline_summary_html(
109
  failed = bench.n_pipelines_failed
110
  total = bench.n_docs
111
  rate = success / total if total > 0 else 0.0
112
- color = _color_for_success_rate(rate)
113
 
114
  parts = [
115
  '<div class="pipeline-summary" '
@@ -195,7 +180,7 @@ def build_pipeline_steps_table_html(
195
 
196
  for agg in bench.per_step_aggregates:
197
  rate = agg.success_rate
198
- rate_color = _color_for_success_rate(rate)
199
  # Métriques aux jonctions : pour chaque type d'artefact,
200
  # liste des métriques mean
201
  metrics_cells: list[str] = []
@@ -381,12 +366,17 @@ class RankingSpec:
381
  return f"{self.artifact_type.value}.{self.metric_name}"
382
 
383
 
384
- def _color_for_rank(rank: int, total: int) -> str:
385
- """Gradient vert (1er) → rouge (dernier) pour la cellule de rang."""
 
 
 
 
386
  if total <= 1:
387
- return _color_for_success_rate(1.0)
388
- score = 1.0 - (rank - 1) / (total - 1)
389
- return _color_for_success_rate(score)
 
390
 
391
 
392
  def build_pipeline_ranking_table_html(
@@ -444,7 +434,7 @@ def build_pipeline_ranking_table_html(
444
  rank += 1
445
  rank_str = str(rank)
446
  value_str = f"{value:.4f}"
447
- rank_color = _color_for_rank(rank, n_with_value)
448
  parts.append(
449
  f'<tr>'
450
  f'<td style="padding:.3rem .5rem;text-align:center;'
 
50
  from picarones.core.modules import ArtifactType
51
  from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
52
  from picarones.measurements.pipeline_comparison import PipelineComparisonResult
53
+ from picarones.report.render_helpers import color_traffic_light
54
 
55
 
56
  # ──────────────────────────────────────────────────────────────────────────
 
58
  # ──────────────────────────────────────────────────────────────────────────
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def _format_duration(seconds: float) -> str:
62
  """Formate une durée en ms si < 1s, en s sinon."""
63
  if seconds < 1.0:
 
94
  failed = bench.n_pipelines_failed
95
  total = bench.n_docs
96
  rate = success / total if total > 0 else 0.0
97
+ color = color_traffic_light(rate)
98
 
99
  parts = [
100
  '<div class="pipeline-summary" '
 
180
 
181
  for agg in bench.per_step_aggregates:
182
  rate = agg.success_rate
183
+ rate_color = color_traffic_light(rate)
184
  # Métriques aux jonctions : pour chaque type d'artefact,
185
  # liste des métriques mean
186
  metrics_cells: list[str] = []
 
366
  return f"{self.artifact_type.value}.{self.metric_name}"
367
 
368
 
369
+ def _bg_for_rank(rank: int, total: int) -> str:
370
+ """Gradient vert (rang 1) → rouge (dernier rang).
371
+
372
+ Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
373
+ ``low_is_good=True`` (rang bas = bon).
374
+ """
375
  if total <= 1:
376
+ return color_traffic_light(1.0)
377
+ return color_traffic_light(
378
+ float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
379
+ )
380
 
381
 
382
  def build_pipeline_ranking_table_html(
 
434
  rank += 1
435
  rank_str = str(rank)
436
  value_str = f"{value:.4f}"
437
+ rank_color = _bg_for_rank(rank, n_with_value)
438
  parts.append(
439
  f'<tr>'
440
  f'<td style="padding:.3rem .5rem;text-align:center;'
picarones/report/readability_render.py CHANGED
@@ -25,27 +25,22 @@ from __future__ import annotations
25
  from html import escape as _e
26
  from typing import Optional
27
 
 
28
 
29
- def _color_for_delta(delta: float) -> str:
30
- """Vert au centre, orange si over-norm, bleu si under-norm.
31
 
32
- Plage de saturation : ±15 points de Flesch.
 
 
33
  """
34
  if abs(delta) <= 1.0:
35
- return "#a7f0a7" # vert clair
36
- f = max(-1.0, min(1.0, delta / 15.0))
37
- if f >= 0:
38
- # vert → orange profond
39
- r = int(167 + (220 - 167) * f)
40
- g = int(240 + (140 - 240) * f)
41
- b = int(167 + (60 - 167) * f)
42
- else:
43
- f = -f
44
- # vert → bleu profond
45
- r = int(167 + (90 - 167) * f)
46
- g = int(240 + (160 - 240) * f)
47
- b = int(167 + (210 - 167) * f)
48
- return f"#{r:02x}{g:02x}{b:02x}"
49
 
50
 
51
  def build_readability_summary_html(
@@ -107,7 +102,7 @@ def build_readability_summary_html(
107
  over_rate = float(agg.get("over_normalized_rate") or 0.0)
108
  n_under = int(agg.get("n_under_normalized") or 0)
109
  n_docs = int(agg.get("n_docs") or 0)
110
- color = _color_for_delta(delta_mean)
111
  parts.append(
112
  f'<tr>'
113
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
 
25
  from html import escape as _e
26
  from typing import Optional
27
 
28
+ from picarones.report.render_helpers import color_diverging
29
 
 
 
30
 
31
+ def _bg_for_flesch_delta(delta: float) -> str:
32
+ """Vert au centre (delta ≈ 0), orange en sur-normalisation (delta > 0),
33
+ bleu en sous-normalisation (delta < 0). Saturation à ±15 pts Flesch.
34
  """
35
  if abs(delta) <= 1.0:
36
+ return "#a7f0a7" # neutre vert clair, indistinguable du bruit
37
+ return color_diverging(
38
+ delta,
39
+ max_abs=15.0,
40
+ neutral_rgb=(167, 240, 167),
41
+ positive_rgb=(220, 140, 60),
42
+ negative_rgb=(90, 160, 210),
43
+ )
 
 
 
 
 
 
44
 
45
 
46
  def build_readability_summary_html(
 
102
  over_rate = float(agg.get("over_normalized_rate") or 0.0)
103
  n_under = int(agg.get("n_under_normalized") or 0)
104
  n_docs = int(agg.get("n_docs") or 0)
105
+ color = _bg_for_flesch_delta(delta_mean)
106
  parts.append(
107
  f'<tr>'
108
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
picarones/report/render_helpers.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers de rendu mutualisés.
2
+
3
+ Centralise les fonctions de coloration et le builder de grille SVG qui
4
+ étaient auparavant dupliqués dans chaque ``*_render.py``. Avant cette
5
+ consolidation, le projet comptait 25 versions différentes de
6
+ ``_color_for_*`` (toutes des dégradés rouge/jaune/vert ou blanc/couleur
7
+ légèrement différentes) et 2 versions de ``_build_heatmap_svg``
8
+ (matrice de classes × positions). Le test
9
+ ``tests/architecture/test_render_helpers.py`` mesure cette duplication
10
+ et bloque sa réapparition.
11
+
12
+ API
13
+ ---
14
+ - :func:`color_traffic_light` — gradient rouge → jaune → vert. Couvre
15
+ la majorité des cellules du rapport (CER, F1, recall, ECE, deficit,
16
+ drag, CV, etc.). Argument ``low_is_good`` pour inverser la sémantique.
17
+ - :func:`color_single_gradient` — gradient blanc → couleur intense.
18
+ Utilisé pour les heatmaps Jaccard, densité, lexical modernization.
19
+ - :func:`color_diverging` — gradient signé (négatif → neutre → positif).
20
+ Utilisé pour les deltas Flesch, amélioration nette, sur/sous-norm.
21
+ - :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
22
+ - :func:`build_grid_svg` — builder de heatmap SVG paramétré.
23
+
24
+ Palette
25
+ -------
26
+ Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
27
+ ad hoc qui peuplaient les 25 helpers d'origine. Cohérence visuelle
28
+ unifiée tout en restant proche du rendu antérieur (≤ 10 unités RGB
29
+ d'écart sur la majorité des bornes), pour ne pas casser les tests
30
+ d'intégration HTML existants.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from html import escape as _e
36
+ from typing import Callable, Optional
37
+
38
+
39
+ # ──────────────────────────────────────────────────────────────────
40
+ # Palettes — bornes RGB partagées par tous les dégradés.
41
+ #
42
+ # Choix éditorial : on conserve l'esprit « rouge → jaune → vert » des
43
+ # helpers historiques plutôt que la palette daltonien-friendly
44
+ # Okabe-Ito de ``colors.py`` (utilisée pour les badges principaux).
45
+ # Migrer les cellules de tableau vers Okabe-Ito serait un sprint
46
+ # d'accessibilité dédié, hors scope de la consolidation.
47
+ # ──────────────────────────────────────────────────────────────────
48
+ GRADIENT_RED_RGB: tuple[int, int, int] = (220, 100, 100)
49
+ GRADIENT_YELLOW_RGB: tuple[int, int, int] = (240, 220, 130)
50
+ GRADIENT_GREEN_RGB: tuple[int, int, int] = (130, 200, 130)
51
+
52
+ #: Couleurs cibles pour les single-gradients fréquents.
53
+ GRADIENT_TARGET_BLUE: tuple[int, int, int] = (30, 58, 138) # Jaccard, specialization
54
+ GRADIENT_TARGET_ORANGE: tuple[int, int, int] = (194, 65, 12) # densité, lexical mod.
55
+ GRADIENT_TARGET_RED: tuple[int, int, int] = (200, 60, 60) # divergence inter-engine
56
+
57
+ #: Couleurs cibles pour les diverging gradients.
58
+ DIVERGING_NEGATIVE_RGB: tuple[int, int, int] = (95, 145, 215) # bleu (under-norm)
59
+ DIVERGING_NEUTRAL_RGB: tuple[int, int, int] = (130, 200, 130) # vert (centre, OK)
60
+ DIVERGING_POSITIVE_RGB: tuple[int, int, int] = (220, 130, 60) # orange (over-norm)
61
+
62
+
63
+ # ──────────────────────────────────────────────────────────────────
64
+ # Helpers internes
65
+ # ──────────────────────────────────────────────────────────────────
66
+ def _interp(a: int, b: int, t: float) -> int:
67
+ """Interpolation linéaire bornée à un canal RGB ∈ [0, 255]."""
68
+ return max(0, min(255, int(a + (b - a) * t)))
69
+
70
+
71
+ def _rgb_to_hex(r: int, g: int, b: int) -> str:
72
+ return f"#{r:02x}{g:02x}{b:02x}"
73
+
74
+
75
+ # ──────────────────────────────────────────────────────────────────
76
+ # API publique : couleurs
77
+ # ──────────────────────────────────────────────────────────────────
78
+ def color_traffic_light(
79
+ value: float,
80
+ *,
81
+ low_is_good: bool = False,
82
+ scale_max: float = 1.0,
83
+ scale_min: float = 0.0,
84
+ ) -> str:
85
+ """Gradient rouge → jaune → vert proportionnel à ``value``.
86
+
87
+ Paramètres
88
+ ----------
89
+ value : float
90
+ Valeur à colorer.
91
+ low_is_good : bool, default ``False``
92
+ Si ``True``, ``value = scale_min`` → vert et ``value = scale_max``
93
+ → rouge (sémantique « plus c'est bas, mieux c'est » : ECE,
94
+ deficit, drag, CV, taux d'introduction d'erreurs…).
95
+ Si ``False`` (défaut), c'est l'inverse (sémantique « plus c'est
96
+ haut, mieux c'est » : F1, recall, taux de correction…).
97
+ scale_max : float, default ``1.0``
98
+ Borne haute de l'échelle. Au-delà, la couleur sature.
99
+ scale_min : float, default ``0.0``
100
+ Borne basse de l'échelle.
101
+
102
+ Retour
103
+ ------
104
+ str
105
+ Couleur hex au format ``#rrggbb``.
106
+ """
107
+ span = scale_max - scale_min
108
+ if span <= 0:
109
+ f = 0.5
110
+ else:
111
+ f = (value - scale_min) / span
112
+ f = max(0.0, min(1.0, f))
113
+ if low_is_good:
114
+ f = 1.0 - f
115
+ if f <= 0.5:
116
+ t = f / 0.5
117
+ r = _interp(GRADIENT_RED_RGB[0], GRADIENT_YELLOW_RGB[0], t)
118
+ g = _interp(GRADIENT_RED_RGB[1], GRADIENT_YELLOW_RGB[1], t)
119
+ b = _interp(GRADIENT_RED_RGB[2], GRADIENT_YELLOW_RGB[2], t)
120
+ else:
121
+ t = (f - 0.5) / 0.5
122
+ r = _interp(GRADIENT_YELLOW_RGB[0], GRADIENT_GREEN_RGB[0], t)
123
+ g = _interp(GRADIENT_YELLOW_RGB[1], GRADIENT_GREEN_RGB[1], t)
124
+ b = _interp(GRADIENT_YELLOW_RGB[2], GRADIENT_GREEN_RGB[2], t)
125
+ return _rgb_to_hex(r, g, b)
126
+
127
+
128
+ def color_single_gradient(
129
+ value: float,
130
+ *,
131
+ end_rgb: tuple[int, int, int],
132
+ max_value: float = 1.0,
133
+ start_rgb: tuple[int, int, int] = (255, 255, 255),
134
+ ) -> str:
135
+ """Gradient simple ``start_rgb`` → ``end_rgb`` proportionnel à ``value/max_value``.
136
+
137
+ Utilisé pour les heatmaps qui n'ont pas de sémantique « bon/mauvais »
138
+ mais juste une intensité (Jaccard, densité d'occurrence, taux de
139
+ modernisation lexicale).
140
+ """
141
+ if max_value <= 0:
142
+ f = 0.0
143
+ else:
144
+ f = max(0.0, min(1.0, value / max_value))
145
+ r = _interp(start_rgb[0], end_rgb[0], f)
146
+ g = _interp(start_rgb[1], end_rgb[1], f)
147
+ b = _interp(start_rgb[2], end_rgb[2], f)
148
+ return _rgb_to_hex(r, g, b)
149
+
150
+
151
+ def color_diverging(
152
+ value: float,
153
+ *,
154
+ max_abs: float = 1.0,
155
+ negative_rgb: tuple[int, int, int] = DIVERGING_NEGATIVE_RGB,
156
+ neutral_rgb: tuple[int, int, int] = DIVERGING_NEUTRAL_RGB,
157
+ positive_rgb: tuple[int, int, int] = DIVERGING_POSITIVE_RGB,
158
+ ) -> str:
159
+ """Gradient signé : ``value < 0`` → ``negative_rgb`` (par défaut bleu),
160
+ ``value ≈ 0`` → ``neutral_rgb`` (par défaut vert),
161
+ ``value > 0`` → ``positive_rgb`` (par défaut orange).
162
+
163
+ Saturation à ``|value| = max_abs``.
164
+ """
165
+ if max_abs <= 0:
166
+ return _rgb_to_hex(*neutral_rgb)
167
+ f = max(-1.0, min(1.0, value / max_abs))
168
+ if f >= 0:
169
+ r = _interp(neutral_rgb[0], positive_rgb[0], f)
170
+ g = _interp(neutral_rgb[1], positive_rgb[1], f)
171
+ b = _interp(neutral_rgb[2], positive_rgb[2], f)
172
+ else:
173
+ t = -f
174
+ r = _interp(neutral_rgb[0], negative_rgb[0], t)
175
+ g = _interp(neutral_rgb[1], negative_rgb[1], t)
176
+ b = _interp(neutral_rgb[2], negative_rgb[2], t)
177
+ return _rgb_to_hex(r, g, b)
178
+
179
+
180
+ def text_color_for_bg(intensity: float, *, threshold: float = 0.55) -> str:
181
+ """Retourne ``"#fff"`` sur fond foncé, ``"#222"`` sur fond clair.
182
+
183
+ ``intensity`` ∈ [0, 1] : 0 = fond clair, 1 = fond très foncé.
184
+ Pour les heatmaps single-gradient, c'est typiquement la même valeur
185
+ que celle passée à :func:`color_single_gradient`.
186
+ """
187
+ return "#fff" if intensity > threshold else "#222"
188
+
189
+
190
+ # ──────────────────────────────────────────────────────────────────
191
+ # API publique : grille SVG
192
+ # ──────────────────────────────────────────────────────────────────
193
+ def build_grid_svg(
194
+ *,
195
+ n_rows: int,
196
+ n_cols: int,
197
+ row_label_fn: Callable[[int], str],
198
+ col_label_fn: Callable[[int], str],
199
+ cell_color_fn: Callable[[int, int], str],
200
+ cell_text_fn: Callable[[int, int], Optional[str]] = lambda r, c: None,
201
+ cell_text_color_fn: Callable[[int, int], str] = lambda r, c: "#222",
202
+ cell_w: int = 36,
203
+ cell_h: int = 36,
204
+ label_left: int = 130,
205
+ label_top: int = 80,
206
+ rotate_col_labels: bool = False,
207
+ aria_label: str = "Heatmap",
208
+ x_axis_title: Optional[str] = None,
209
+ ) -> str:
210
+ """Construit une heatmap SVG paramétrable.
211
+
212
+ Architecture commune des deux `_build_heatmap_svg` historiques
213
+ (taxonomy_cooccurrence et taxonomy_intra_doc), mutualisée ici.
214
+
215
+ Paramètres
216
+ ----------
217
+ n_rows, n_cols : int
218
+ Dimensions de la grille.
219
+ row_label_fn, col_label_fn : Callable[[int], str]
220
+ Étiquettes des lignes (gauche) et colonnes (haut).
221
+ cell_color_fn : Callable[[int, int], str]
222
+ Retourne la couleur hex de fond pour la cellule (row, col).
223
+ cell_text_fn : Callable[[int, int], Optional[str]]
224
+ Texte à afficher dans la cellule, ou ``None`` pour ne rien afficher.
225
+ cell_text_color_fn : Callable[[int, int], str]
226
+ Couleur du texte de la cellule (typiquement obtenue via
227
+ :func:`text_color_for_bg`).
228
+ cell_w, cell_h : int
229
+ Dimensions de chaque cellule en pixels.
230
+ label_left, label_top : int
231
+ Marges réservées aux étiquettes.
232
+ rotate_col_labels : bool
233
+ Si ``True``, les étiquettes de colonnes sont rotées de -45°
234
+ (utile quand elles sont longues).
235
+ aria_label : str
236
+ Étiquette d'accessibilité du SVG.
237
+ x_axis_title : Optional[str]
238
+ Titre optionnel de l'axe horizontal, affiché en bas du SVG.
239
+
240
+ Retour
241
+ ------
242
+ str
243
+ SVG complet, ou ``""`` si la grille est vide.
244
+ """
245
+ if n_rows == 0 or n_cols == 0:
246
+ return ""
247
+
248
+ extra_bottom = 30 if x_axis_title else 10
249
+ width = label_left + n_cols * cell_w + 10
250
+ height = label_top + n_rows * cell_h + extra_bottom
251
+
252
+ parts: list[str] = [
253
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
254
+ f'width="{width}" height="{height}" '
255
+ f'viewBox="0 0 {width} {height}" '
256
+ f'role="img" aria-label="{_e(aria_label)}">',
257
+ ]
258
+
259
+ # Étiquettes de colonnes
260
+ for j in range(n_cols):
261
+ cx = label_left + j * cell_w + cell_w // 2
262
+ cy = label_top - 6
263
+ label = _e(col_label_fn(j))
264
+ if rotate_col_labels:
265
+ parts.append(
266
+ f'<text x="{cx}" y="{cy}" '
267
+ f'transform="rotate(-45 {cx} {cy})" '
268
+ f'font-size="11" fill="#333" text-anchor="start">'
269
+ f'{label}</text>'
270
+ )
271
+ else:
272
+ parts.append(
273
+ f'<text x="{cx}" y="{cy}" '
274
+ f'font-size="10" fill="#666" text-anchor="middle">'
275
+ f'{label}</text>'
276
+ )
277
+
278
+ # Cellules + étiquettes de lignes
279
+ for i in range(n_rows):
280
+ rx = label_left - 6
281
+ ry = label_top + i * cell_h + cell_h // 2 + 4
282
+ parts.append(
283
+ f'<text x="{rx}" y="{ry}" '
284
+ f'font-size="11" fill="#333" text-anchor="end">'
285
+ f'{_e(row_label_fn(i))}</text>'
286
+ )
287
+ for j in range(n_cols):
288
+ x = label_left + j * cell_w
289
+ y = label_top + i * cell_h
290
+ color = cell_color_fn(i, j)
291
+ parts.append(
292
+ f'<rect x="{x}" y="{y}" '
293
+ f'width="{cell_w}" height="{cell_h}" '
294
+ f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
295
+ )
296
+ text = cell_text_fn(i, j)
297
+ if text is not None:
298
+ text_color = cell_text_color_fn(i, j)
299
+ parts.append(
300
+ f'<text x="{x + cell_w // 2}" '
301
+ f'y="{y + cell_h // 2 + 4}" '
302
+ f'font-size="10" fill="{text_color}" '
303
+ f'text-anchor="middle">'
304
+ f'{_e(text)}</text>'
305
+ )
306
+
307
+ if x_axis_title:
308
+ cx_axis = label_left + (n_cols * cell_w) // 2
309
+ cy_axis = height - 6
310
+ parts.append(
311
+ f'<text x="{cx_axis}" y="{cy_axis}" '
312
+ f'font-size="11" fill="#666" text-anchor="middle" '
313
+ f'font-style="italic">'
314
+ f'{_e(x_axis_title)}</text>'
315
+ )
316
+
317
+ parts.append("</svg>")
318
+ return "".join(parts)
319
+
320
+
321
+ __all__ = [
322
+ "GRADIENT_RED_RGB",
323
+ "GRADIENT_YELLOW_RGB",
324
+ "GRADIENT_GREEN_RGB",
325
+ "GRADIENT_TARGET_BLUE",
326
+ "GRADIENT_TARGET_ORANGE",
327
+ "GRADIENT_TARGET_RED",
328
+ "DIVERGING_NEGATIVE_RGB",
329
+ "DIVERGING_NEUTRAL_RGB",
330
+ "DIVERGING_POSITIVE_RGB",
331
+ "color_traffic_light",
332
+ "color_single_gradient",
333
+ "color_diverging",
334
+ "text_color_for_bg",
335
+ "build_grid_svg",
336
+ ]
picarones/report/robustness_projection_render.py CHANGED
@@ -53,23 +53,7 @@ from __future__ import annotations
53
  from html import escape as _e
54
  from typing import Optional
55
 
56
-
57
- def _color_for_deficit(deficit: float) -> str:
58
- """Vert (≈0) → orange (~3 pts) → rouge (≥ 5 pts)."""
59
- f = max(0.0, min(1.0, abs(deficit) / 0.05))
60
- if f < 0.5:
61
- # vert → orange
62
- t = f / 0.5
63
- r = int(167 + (235 - 167) * t)
64
- g = int(240 + (180 - 240) * t)
65
- b = int(167 + (60 - 167) * t)
66
- else:
67
- # orange → rouge
68
- t = (f - 0.5) / 0.5
69
- r = int(235 + (220 - 235) * t)
70
- g = int(180 + (50 - 180) * t)
71
- b = int(60 + (50 - 60) * t)
72
- return f"#{r:02x}{g:02x}{b:02x}"
73
 
74
 
75
  def _build_summary_table(
@@ -106,7 +90,7 @@ def _build_summary_table(
106
  n_types = int(info.get("n_degradation_types") or 0)
107
  worst_type = info.get("worst_degradation_type")
108
  worst_deficit = info.get("worst_degradation_deficit")
109
- color = _color_for_deficit(deficit)
110
  worst_str = (
111
  f"{_e(str(worst_type))} ({worst_deficit * 100:+.1f})"
112
  if worst_type and isinstance(worst_deficit, (int, float))
@@ -162,7 +146,7 @@ def _build_detail_table(
162
  deficit = entry.get("deficit_vs_baseline")
163
  n_above = int(entry.get("n_docs_above_critical") or 0)
164
  if isinstance(deficit, (int, float)):
165
- color = _color_for_deficit(float(deficit))
166
  deficit_str = f"{float(deficit) * 100:+.2f}"
167
  deficit_cell = (
168
  f'<td style="padding:.4rem .6rem;text-align:right;'
 
53
  from html import escape as _e
54
  from typing import Optional
55
 
56
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
 
59
  def _build_summary_table(
 
90
  n_types = int(info.get("n_degradation_types") or 0)
91
  worst_type = info.get("worst_degradation_type")
92
  worst_deficit = info.get("worst_degradation_deficit")
93
+ color = color_traffic_light(abs(deficit), low_is_good=True, scale_max=0.05)
94
  worst_str = (
95
  f"{_e(str(worst_type))} ({worst_deficit * 100:+.1f})"
96
  if worst_type and isinstance(worst_deficit, (int, float))
 
146
  deficit = entry.get("deficit_vs_baseline")
147
  n_above = int(entry.get("n_docs_above_critical") or 0)
148
  if isinstance(deficit, (int, float)):
149
+ color = color_traffic_light(abs(float(deficit)), low_is_good=True, scale_max=0.05)
150
  deficit_str = f"{float(deficit) * 100:+.2f}"
151
  deficit_cell = (
152
  f'<td style="padding:.4rem .6rem;text-align:right;'
picarones/report/searchability_render.py CHANGED
@@ -19,23 +19,7 @@ from __future__ import annotations
19
  from html import escape as _e
20
  from typing import Optional
21
 
22
-
23
- def _color_for_recall(recall: float) -> str:
24
- """Gradient rouge → jaune → vert pour rappel ∈ [0, 1]."""
25
- f = max(0.0, min(1.0, recall))
26
- if f < 0.5:
27
- # rouge → jaune
28
- t = f / 0.5
29
- r = 235
30
- g = int(70 + (200 - 70) * t)
31
- b = 70
32
- else:
33
- # jaune → vert
34
- t = (f - 0.5) / 0.5
35
- r = int(235 + (60 - 235) * t)
36
- g = int(200 + (160 - 200) * t)
37
- b = int(70 + (90 - 70) * t)
38
- return f"#{r:02x}{g:02x}{b:02x}"
39
 
40
 
41
  def build_searchability_summary_html(
@@ -99,7 +83,7 @@ def build_searchability_summary_html(
99
  n_search = int(agg.get("n_searchable") or 0)
100
  n_total = int(agg.get("n_gt_tokens") or 0)
101
  n_docs = int(agg.get("n_docs") or 0)
102
- color = _color_for_recall(recall)
103
  parts.append(
104
  f'<tr>'
105
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
 
19
  from html import escape as _e
20
  from typing import Optional
21
 
22
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  def build_searchability_summary_html(
 
83
  n_search = int(agg.get("n_searchable") or 0)
84
  n_total = int(agg.get("n_gt_tokens") or 0)
85
  n_docs = int(agg.get("n_docs") or 0)
86
+ color = color_traffic_light(recall)
87
  parts.append(
88
  f'<tr>'
89
  f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
picarones/report/specialization_render.py CHANGED
@@ -18,15 +18,10 @@ from picarones.measurements.specialization import (
18
  compute_specialization_matrix,
19
  top_specialized_pairs,
20
  )
 
21
 
22
-
23
- def _color_for_score(score: float) -> str:
24
- """Gradient blanc → bleu profond."""
25
- f = max(0.0, min(1.0, score))
26
- r = int(255 + (50 - 255) * f)
27
- g = int(255 + (110 - 255) * f)
28
- b = int(255 + (180 - 255) * f)
29
- return f"#{r:02x}{g:02x}{b:02x}"
30
 
31
 
32
  def _category_label(cat: str, labels: dict[str, str]) -> str:
@@ -97,7 +92,7 @@ def build_specialization_html(
97
  for pair in pairs:
98
  score = float(pair.get("score") or 0.0)
99
  cat = pair.get("category") or "?"
100
- color = _color_for_score(score)
101
  parts.append(
102
  f'<tr>'
103
  f'<td style="padding:.4rem .6rem">'
 
18
  compute_specialization_matrix,
19
  top_specialized_pairs,
20
  )
21
+ from picarones.report.render_helpers import color_single_gradient
22
 
23
+ #: Bleu profond cible — préservé de l'ancien `_color_for_score` local.
24
+ _SPECIALIZATION_BLUE = (50, 110, 180)
 
 
 
 
 
 
25
 
26
 
27
  def _category_label(cat: str, labels: dict[str, str]) -> str:
 
92
  for pair in pairs:
93
  score = float(pair.get("score") or 0.0)
94
  cat = pair.get("category") or "?"
95
+ color = color_single_gradient(score, end_rgb=_SPECIALIZATION_BLUE)
96
  parts.append(
97
  f'<tr>'
98
  f'<td style="padding:.4rem .6rem">'
picarones/report/stratification_render.py CHANGED
@@ -22,21 +22,7 @@ from __future__ import annotations
22
  from html import escape as _e
23
  from typing import Optional
24
 
25
-
26
- def _color_for_cer(cer: float) -> str:
27
- """Gradient vert (faible CER) → rouge (CER élevé), saturé à 0.30."""
28
- f = max(0.0, min(1.0, cer / 0.30))
29
- if f <= 0.5:
30
- ratio = f / 0.5
31
- r = int(130 + (240 - 130) * ratio)
32
- g = int(200 + (220 - 200) * ratio)
33
- b = int(130 + (130 - 130) * ratio)
34
- else:
35
- ratio = (f - 0.5) / 0.5
36
- r = int(240 + (220 - 240) * ratio)
37
- g = int(220 + (100 - 220) * ratio)
38
- b = int(130 + (100 - 130) * ratio)
39
- return f"#{r:02x}{g:02x}{b:02x}"
40
 
41
 
42
  def _format_cer(cer: Optional[float]) -> str:
@@ -167,7 +153,7 @@ def build_stratified_ranking_html(
167
  median = entry.get("median_cer")
168
  mean = entry.get("mean_cer")
169
  n_docs = int(entry.get("documents") or 0)
170
- bg = _color_for_cer(float(median)) if median is not None else "#f4f4f4"
171
  parts.append("<tr>")
172
  parts.append(
173
  f'<td style="padding:.3rem .5rem;font-weight:600">'
 
22
  from html import escape as _e
23
  from typing import Optional
24
 
25
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  def _format_cer(cer: Optional[float]) -> str:
 
153
  median = entry.get("median_cer")
154
  mean = entry.get("mean_cer")
155
  n_docs = int(entry.get("documents") or 0)
156
+ bg = color_traffic_light(float(median), low_is_good=True, scale_max=0.30) if median is not None else "#f4f4f4"
157
  parts.append("<tr>")
158
  parts.append(
159
  f'<td style="padding:.3rem .5rem;font-weight:600">'
picarones/report/taxonomy_cooccurrence_render.py CHANGED
@@ -20,25 +20,15 @@ from __future__ import annotations
20
  from html import escape as _e
21
  from typing import Optional
22
 
 
 
 
 
 
 
23
 
24
- def _color_for_jaccard(j: float) -> str:
25
- """Gradient blanc → bleu profond pour Jaccard ∈ [0, 1].
26
 
27
- Interpolation entre #ffffff (j=0) et #1e3a8a (j=1).
28
- """
29
- f = max(0.0, min(1.0, j))
30
- r = int(255 + (30 - 255) * f)
31
- g = int(255 + (58 - 255) * f)
32
- b = int(255 + (138 - 255) * f)
33
- return f"#{r:02x}{g:02x}{b:02x}"
34
-
35
-
36
- def _text_color_for_bg(j: float) -> str:
37
- """Texte blanc si fond foncé, noir sinon (lisibilité)."""
38
- return "#fff" if j > 0.55 else "#222"
39
-
40
-
41
- def _build_heatmap_svg(
42
  classes: list[str],
43
  matrix: dict[str, dict[str, float]],
44
  *,
@@ -46,66 +36,37 @@ def _build_heatmap_svg(
46
  label_left: int = 130,
47
  label_top: int = 80,
48
  ) -> str:
49
- """Construit la heatmap SVG.
50
 
51
- Cellule = carré coloré ``_color_for_jaccard``, valeur Jaccard
52
- affichée en chiffres si > 0,05. Étiquettes des classes en
53
- colonne (haut) et en ligne (gauche).
54
  """
55
- n = len(classes)
56
- if n == 0:
57
  return ""
58
- width = label_left + n * cell_size + 10
59
- height = label_top + n * cell_size + 10
60
 
61
- parts = [
62
- f'<svg xmlns="http://www.w3.org/2000/svg" '
63
- f'width="{width}" height="{height}" '
64
- f'viewBox="0 0 {width} {height}" '
65
- f'role="img" aria-label="Heatmap Jaccard co-occurrence taxonomique">',
66
- ]
67
- # Étiquettes de colonnes (rotées -45°)
68
- for j, cls in enumerate(classes):
69
- cx = label_left + j * cell_size + cell_size // 2
70
- cy = label_top - 6
71
- parts.append(
72
- f'<text x="{cx}" y="{cy}" '
73
- f'transform="rotate(-45 {cx} {cy})" '
74
- f'font-size="11" fill="#333" text-anchor="start">'
75
- f'{_e(cls)}</text>'
76
- )
77
- # Étiquettes de lignes
78
- for i, cls in enumerate(classes):
79
- rx = label_left - 6
80
- ry = label_top + i * cell_size + cell_size // 2 + 4
81
- parts.append(
82
- f'<text x="{rx}" y="{ry}" '
83
- f'font-size="11" fill="#333" text-anchor="end">'
84
- f'{_e(cls)}</text>'
85
- )
86
- # Cellules
87
- for i, ca in enumerate(classes):
88
- for j, cb in enumerate(classes):
89
- value = matrix.get(ca, {}).get(cb, 0.0)
90
- x = label_left + j * cell_size
91
- y = label_top + i * cell_size
92
- color = _color_for_jaccard(value)
93
- text_color = _text_color_for_bg(value)
94
- parts.append(
95
- f'<rect x="{x}" y="{y}" '
96
- f'width="{cell_size}" height="{cell_size}" '
97
- f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
98
- )
99
- if value > 0.05:
100
- parts.append(
101
- f'<text x="{x + cell_size // 2}" '
102
- f'y="{y + cell_size // 2 + 4}" '
103
- f'font-size="10" fill="{text_color}" '
104
- f'text-anchor="middle">'
105
- f'{value:.2f}</text>'
106
- )
107
- parts.append("</svg>")
108
- return "".join(parts)
109
 
110
 
111
  def _build_top_pairs_table(
@@ -136,8 +97,9 @@ def _build_top_pairs_table(
136
  f'<td style="padding:.2rem .5rem">'
137
  f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
138
  f'<td style="padding:.2rem .5rem;text-align:right;'
139
- f'font-family:monospace;background:{_color_for_jaccard(j)};'
140
- f'color:{_text_color_for_bg(j)}">{j:.2f}</td>'
 
141
  f'</tr>'
142
  )
143
  parts.append("</tbody></table>")
@@ -175,7 +137,7 @@ def build_taxonomy_cooccurrence_html(
175
  )
176
  n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
177
 
178
- svg = _build_heatmap_svg(classes, matrix)
179
  top_table = _build_top_pairs_table(
180
  data.get("top_pairs") or [], labels,
181
  )
 
20
  from html import escape as _e
21
  from typing import Optional
22
 
23
+ from picarones.report.render_helpers import (
24
+ GRADIENT_TARGET_BLUE,
25
+ build_grid_svg,
26
+ color_single_gradient,
27
+ text_color_for_bg,
28
+ )
29
 
 
 
30
 
31
+ def _build_jaccard_heatmap_svg(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  classes: list[str],
33
  matrix: dict[str, dict[str, float]],
34
  *,
 
36
  label_left: int = 130,
37
  label_top: int = 80,
38
  ) -> str:
39
+ """Heatmap Jaccard de co-occurrence taxonomique.
40
 
41
+ Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
42
+ encapsule les conventions spécifiques à la matrice symétrique
43
+ (valeur affichée seulement si > 0,05, étiquettes rotées).
44
  """
45
+ if not classes:
 
46
  return ""
 
 
47
 
48
+ def cell_value(i: int, j: int) -> float:
49
+ return matrix.get(classes[i], {}).get(classes[j], 0.0)
50
+
51
+ return build_grid_svg(
52
+ n_rows=len(classes),
53
+ n_cols=len(classes),
54
+ row_label_fn=lambda i: classes[i],
55
+ col_label_fn=lambda j: classes[j],
56
+ cell_color_fn=lambda i, j: color_single_gradient(
57
+ cell_value(i, j), end_rgb=GRADIENT_TARGET_BLUE,
58
+ ),
59
+ cell_text_fn=lambda i, j: (
60
+ f"{cell_value(i, j):.2f}" if cell_value(i, j) > 0.05 else None
61
+ ),
62
+ cell_text_color_fn=lambda i, j: text_color_for_bg(cell_value(i, j)),
63
+ cell_w=cell_size,
64
+ cell_h=cell_size,
65
+ label_left=label_left,
66
+ label_top=label_top,
67
+ rotate_col_labels=True,
68
+ aria_label="Heatmap Jaccard co-occurrence taxonomique",
69
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
 
72
  def _build_top_pairs_table(
 
97
  f'<td style="padding:.2rem .5rem">'
98
  f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
99
  f'<td style="padding:.2rem .5rem;text-align:right;'
100
+ f'font-family:monospace;'
101
+ f'background:{color_single_gradient(j, end_rgb=GRADIENT_TARGET_BLUE)};'
102
+ f'color:{text_color_for_bg(j)}">{j:.2f}</td>'
103
  f'</tr>'
104
  )
105
  parts.append("</tbody></table>")
 
137
  )
138
  n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
139
 
140
+ svg = _build_jaccard_heatmap_svg(classes, matrix)
141
  top_table = _build_top_pairs_table(
142
  data.get("top_pairs") or [], labels,
143
  )
picarones/report/taxonomy_intra_doc_render.py CHANGED
@@ -23,24 +23,15 @@ from __future__ import annotations
23
  from html import escape as _e
24
  from typing import Optional
25
 
 
 
 
 
 
 
26
 
27
- def _color_for_density(density: float) -> str:
28
- """Gradient blanc → orange profond pour densité ∈ [0, 1].
29
 
30
- Interpolation entre #ffffff (0) et #c2410c (1).
31
- """
32
- f = max(0.0, min(1.0, density))
33
- r = int(255 + (194 - 255) * f)
34
- g = int(255 + (65 - 255) * f)
35
- b = int(255 + (12 - 255) * f)
36
- return f"#{r:02x}{g:02x}{b:02x}"
37
-
38
-
39
- def _text_color_for_bg(density: float) -> str:
40
- return "#fff" if density > 0.55 else "#222"
41
-
42
-
43
- def _build_heatmap_svg(
44
  classes_with_errors: list[str],
45
  per_class: dict[str, list[int]],
46
  n_bins: int,
@@ -50,72 +41,47 @@ def _build_heatmap_svg(
50
  label_left: int = 150,
51
  label_top: int = 30,
52
  ) -> str:
53
- """Construit la heatmap SVG class × position."""
54
- n_rows = len(classes_with_errors)
55
- if n_rows == 0:
 
 
 
 
56
  return ""
57
- width = label_left + n_bins * cell_w + 10
58
- height = label_top + n_rows * cell_h + 30 # +30 pour étiquette X
59
 
60
- # Normalisation : pour chaque classe, densité relative au max
61
- # de cette classe (mise en évidence des positions concentrées).
62
- parts = [
63
- f'<svg xmlns="http://www.w3.org/2000/svg" '
64
- f'width="{width}" height="{height}" '
65
- f'viewBox="0 0 {width} {height}" '
66
- f'role="img" aria-label="Heatmap class taxonomique × position">',
67
- ]
68
- # Étiquettes des colonnes (positions)
69
- for j in range(n_bins):
70
- cx = label_left + j * cell_w + cell_w // 2
71
- cy = label_top - 6
72
- parts.append(
73
- f'<text x="{cx}" y="{cy}" '
74
- f'font-size="10" fill="#666" text-anchor="middle">'
75
- f'{j + 1}</text>'
76
- )
77
- # Cellules
78
- for i, cls in enumerate(classes_with_errors):
79
- # Étiquette de ligne (classe)
80
- rx = label_left - 6
81
- ry = label_top + i * cell_h + cell_h // 2 + 4
82
- parts.append(
83
- f'<text x="{rx}" y="{ry}" '
84
- f'font-size="11" fill="#333" text-anchor="end">'
85
- f'{_e(cls)}</text>'
86
- )
87
  counts = per_class.get(cls, [0] * n_bins)
88
  max_count = max(counts) if counts else 0
 
89
  for j in range(n_bins):
90
- x = label_left + j * cell_w
91
- y = label_top + i * cell_h
92
  count = counts[j] if j < len(counts) else 0
93
  density = (count / max_count) if max_count > 0 else 0.0
94
- color = _color_for_density(density)
95
- text_color = _text_color_for_bg(density)
96
- parts.append(
97
- f'<rect x="{x}" y="{y}" '
98
- f'width="{cell_w}" height="{cell_h}" '
99
- f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
100
- )
101
- if count > 0:
102
- parts.append(
103
- f'<text x="{x + cell_w // 2}" '
104
- f'y="{y + cell_h // 2 + 4}" '
105
- f'font-size="10" fill="{text_color}" '
106
- f'text-anchor="middle">{count}</text>'
107
- )
108
- # Étiquette axe X en bas
109
- cx_axis = label_left + (n_bins * cell_w) // 2
110
- cy_axis = height - 6
111
- parts.append(
112
- f'<text x="{cx_axis}" y="{cy_axis}" '
113
- f'font-size="11" fill="#666" text-anchor="middle" '
114
- f'font-style="italic">'
115
- f'Position dans le document (1 = début)</text>'
116
  )
117
- parts.append("</svg>")
118
- return "".join(parts)
119
 
120
 
121
  def build_taxonomy_intra_doc_html(
@@ -162,7 +128,7 @@ def build_taxonomy_intra_doc_html(
162
  n_words_gt=n_words_gt, n_bins=n_bins,
163
  )
164
 
165
- svg = _build_heatmap_svg(classes_with_errors, per_class, n_bins)
166
 
167
  parts = [
168
  '<div class="intradoc" style="margin:1rem 0">',
 
23
  from html import escape as _e
24
  from typing import Optional
25
 
26
+ from picarones.report.render_helpers import (
27
+ GRADIENT_TARGET_ORANGE,
28
+ build_grid_svg,
29
+ color_single_gradient,
30
+ text_color_for_bg,
31
+ )
32
 
 
 
33
 
34
+ def _build_position_heatmap_svg(
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  classes_with_errors: list[str],
36
  per_class: dict[str, list[int]],
37
  n_bins: int,
 
41
  label_left: int = 150,
42
  label_top: int = 30,
43
  ) -> str:
44
+ """Heatmap class taxonomique × position (densité relative par classe).
45
+
46
+ Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
47
+ encapsule la normalisation par classe (densité relative au max
48
+ observé sur la ligne).
49
+ """
50
+ if not classes_with_errors:
51
  return ""
 
 
52
 
53
+ # Pré-calcule densité et count par cellule pour éviter les boucles
54
+ # imbriquées dans les callbacks.
55
+ grid: list[list[tuple[int, float]]] = []
56
+ for cls in classes_with_errors:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  counts = per_class.get(cls, [0] * n_bins)
58
  max_count = max(counts) if counts else 0
59
+ row: list[tuple[int, float]] = []
60
  for j in range(n_bins):
 
 
61
  count = counts[j] if j < len(counts) else 0
62
  density = (count / max_count) if max_count > 0 else 0.0
63
+ row.append((count, density))
64
+ grid.append(row)
65
+
66
+ return build_grid_svg(
67
+ n_rows=len(classes_with_errors),
68
+ n_cols=n_bins,
69
+ row_label_fn=lambda i: classes_with_errors[i],
70
+ col_label_fn=lambda j: str(j + 1),
71
+ cell_color_fn=lambda i, j: color_single_gradient(
72
+ grid[i][j][1], end_rgb=GRADIENT_TARGET_ORANGE,
73
+ ),
74
+ cell_text_fn=lambda i, j: (
75
+ str(grid[i][j][0]) if grid[i][j][0] > 0 else None
76
+ ),
77
+ cell_text_color_fn=lambda i, j: text_color_for_bg(grid[i][j][1]),
78
+ cell_w=cell_w,
79
+ cell_h=cell_h,
80
+ label_left=label_left,
81
+ label_top=label_top,
82
+ aria_label="Heatmap class taxonomique × position",
83
+ x_axis_title="Position dans le document (1 = début)",
 
84
  )
 
 
85
 
86
 
87
  def build_taxonomy_intra_doc_html(
 
128
  n_words_gt=n_words_gt, n_bins=n_bins,
129
  )
130
 
131
+ svg = _build_position_heatmap_svg(classes_with_errors, per_class, n_bins)
132
 
133
  parts = [
134
  '<div class="intradoc" style="margin:1rem 0">',
picarones/report/throughput_render.py CHANGED
@@ -50,39 +50,7 @@ from __future__ import annotations
50
  from html import escape as _e
51
  from typing import Optional
52
 
53
-
54
- def _color_for_pages_per_hour(value: float, max_value: float) -> str:
55
- """Rouge (faible) → jaune → vert (= max)."""
56
- if max_value <= 0:
57
- return "#e0e0e0"
58
- f = max(0.0, min(1.0, value / max_value))
59
- if f < 0.5:
60
- t = f / 0.5
61
- r = 235
62
- g = int(70 + (200 - 70) * t)
63
- b = 70
64
- else:
65
- t = (f - 0.5) / 0.5
66
- r = int(235 + (60 - 235) * t)
67
- g = int(200 + (160 - 200) * t)
68
- b = int(70 + (90 - 70) * t)
69
- return f"#{r:02x}{g:02x}{b:02x}"
70
-
71
-
72
- def _color_for_drag(drag: float) -> str:
73
- """Vert (faible drag) → orange → rouge (drag élevé)."""
74
- f = max(0.0, min(1.0, drag))
75
- if f < 0.5:
76
- t = f / 0.5
77
- r = int(167 + (235 - 167) * t)
78
- g = int(240 + (180 - 240) * t)
79
- b = int(167 + (60 - 167) * t)
80
- else:
81
- t = (f - 0.5) / 0.5
82
- r = int(235 + (220 - 235) * t)
83
- g = int(180 + (50 - 180) * t)
84
- b = int(60 + (50 - 60) * t)
85
- return f"#{r:02x}{g:02x}{b:02x}"
86
 
87
 
88
  def build_throughput_html(
@@ -154,8 +122,11 @@ def build_throughput_html(
154
  drag = row.get("drag_ratio") or 0.0
155
  n_pages = int(row.get("n_pages") or 0)
156
  n_errors = int(row.get("n_errors") or 0)
157
- eff_color = _color_for_pages_per_hour(eff, max_eff)
158
- drag_color = _color_for_drag(drag)
 
 
 
159
  raw_str = (
160
  f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
161
  )
 
50
  from html import escape as _e
51
  from typing import Optional
52
 
53
+ from picarones.report.render_helpers import color_traffic_light
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
 
56
  def build_throughput_html(
 
122
  drag = row.get("drag_ratio") or 0.0
123
  n_pages = int(row.get("n_pages") or 0)
124
  n_errors = int(row.get("n_errors") or 0)
125
+ eff_color = (
126
+ color_traffic_light(eff, scale_max=max_eff)
127
+ if max_eff > 0 else "#e0e0e0"
128
+ )
129
+ drag_color = color_traffic_light(drag, low_is_good=True)
130
  raw_str = (
131
  f"{raw:,.0f}" if isinstance(raw, (int, float)) else "—"
132
  )
picarones/report/worst_lines_render.py CHANGED
@@ -20,20 +20,21 @@ from typing import Optional
20
 
21
  from picarones.measurements.worst_lines import WorstLineEntry
22
  from picarones.core.diff_utils import compute_char_diff
 
23
 
24
 
25
- def _color_for_cer(cer: float) -> str:
26
- """Gradient jaune rouge : 0,3 jaune, 1,0 rouge profond."""
 
 
 
 
 
 
27
  f = max(0.0, min(1.0, cer))
28
- # Au-delà de 0,3 (seuil catastrophique courant), gradient
29
- # jaune → rouge. En dessous, beige clair.
30
  if f < 0.3:
31
  return "#fff8dc"
32
- ratio = (f - 0.3) / 0.7
33
- r = int(240 + (200 - 240) * ratio)
34
- g = int(220 + (60 - 220) * ratio)
35
- b = int(120 + (60 - 120) * ratio)
36
- return f"#{r:02x}{g:02x}{b:02x}"
37
 
38
 
39
  def _render_diff_inline(reference: str, hypothesis: str) -> str:
@@ -121,7 +122,7 @@ def build_worst_lines_table_html(
121
  )
122
  parts.append("</tr></thead><tbody>")
123
  for entry in entries:
124
- cer_color = _color_for_cer(entry.cer)
125
  parts.append("<tr>")
126
  parts.append(
127
  f'<td style="padding:.3rem .5rem;text-align:right;'
 
20
 
21
  from picarones.measurements.worst_lines import WorstLineEntry
22
  from picarones.core.diff_utils import compute_char_diff
23
+ from picarones.report.render_helpers import color_traffic_light
24
 
25
 
26
+ def _bg_for_cer(cer: float) -> str:
27
+ """Beige clair sous le seuil catastrophique (0.30), gradient
28
+ jaune → rouge au-delà.
29
+
30
+ Le seuil dur à 0.30 préserve la sémantique « toléré jusqu'à 30 %
31
+ pour un manuscrit difficile ». Au-delà, on entre en zone visible
32
+ avec :func:`color_traffic_light` (low_is_good).
33
+ """
34
  f = max(0.0, min(1.0, cer))
 
 
35
  if f < 0.3:
36
  return "#fff8dc"
37
+ return color_traffic_light(f, low_is_good=True, scale_min=0.3, scale_max=1.0)
 
 
 
 
38
 
39
 
40
  def _render_diff_inline(reference: str, hypothesis: str) -> str:
 
122
  )
123
  parts.append("</tr></thead><tbody>")
124
  for entry in entries:
125
+ cer_color = _bg_for_cer(entry.cer)
126
  parts.append("<tr>")
127
  parts.append(
128
  f'<td style="padding:.3rem .5rem;text-align:right;'
tests/architecture/test_render_helpers.py CHANGED
@@ -4,21 +4,21 @@ Les renderers HTML dans ``picarones/report/`` ont accumulé des helpers
4
  locaux dupliqués (couleur, heatmap SVG, etc.) qui devraient vivre dans
5
  un unique ``picarones/report/render_helpers.py``.
6
 
7
- Snapshot v1.0.0 (2026-05-02) :
8
-
9
- - 25 fonctions ``_color_for_*`` distinctes (dont plusieurs portent le
10
- même nom dans des fichiers différents : ``_color_for_score`` ×5,
11
- ``_color_for_delta`` ×2, ``_color_for_cer`` ×2).
12
- - 1 fonction ``_color`` simple (``inter_engine_render``).
13
- - 2 fonctions ``_build_heatmap_svg`` (``taxonomy_cooccurrence``,
14
- ``taxonomy_intra_doc``).
15
-
16
- Soit **27 helpers locaux** dupliqués.
17
-
18
- Test ratchet : ce nombre ne peut que descendre. Pour le faire baisser,
19
- extraire un helper dans ``picarones/report/render_helpers.py`` et
20
- l'importer depuis les renderers qui en avaient besoin, puis abaisser
21
- :data:`HELPER_BASELINE` du même montant.
22
  """
23
 
24
  from __future__ import annotations
@@ -29,8 +29,8 @@ from pathlib import Path
29
  REPO_ROOT = Path(__file__).resolve().parents[2]
30
  REPORT_DIR = REPO_ROOT / "picarones" / "report"
31
 
32
- #: Snapshot v1.0.0. Doit baisser, jamais monter.
33
- HELPER_BASELINE = 27
34
 
35
  #: Le module mutualisé est exempté (c'est *là* qu'on veut les voir).
36
  HELPERS_MODULE_NAME = "render_helpers.py"
 
4
  locaux dupliqués (couleur, heatmap SVG, etc.) qui devraient vivre dans
5
  un unique ``picarones/report/render_helpers.py``.
6
 
7
+ État après le sprint de consolidation : tous les ``_color_for_*`` et
8
+ ``_build_heatmap_svg`` locaux ont été déplacés dans
9
+ ``picarones/report/render_helpers.py`` qui expose
10
+ :func:`color_traffic_light`, :func:`color_single_gradient`,
11
+ :func:`color_diverging`, :func:`text_color_for_bg` et
12
+ :func:`build_grid_svg`.
13
+
14
+ Snapshot v1.0.0 (2026-05-02, post-consolidation) : **0 helper local
15
+ dupliqué**.
16
+
17
+ Test ratchet : ce nombre ne peut que descendre. Si un nouveau helper
18
+ ``_color_for_*`` ou ``_build_heatmap_svg`` apparaît dans un renderer,
19
+ le test échoue. La résolution est de paramétrer un des helpers de
20
+ :mod:`picarones.report.render_helpers` plutôt que de réintroduire
21
+ une fonction locale.
22
  """
23
 
24
  from __future__ import annotations
 
29
  REPO_ROOT = Path(__file__).resolve().parents[2]
30
  REPORT_DIR = REPO_ROOT / "picarones" / "report"
31
 
32
+ #: Snapshot v1.0.0 post-consolidation. Doit rester à 0.
33
+ HELPER_BASELINE = 0
34
 
35
  #: Le module mutualisé est exempté (c'est *là* qu'on veut les voir).
36
  HELPERS_MODULE_NAME = "render_helpers.py"
tests/report/test_render_helpers.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests unitaires des helpers de rendu mutualisés.
2
+
3
+ Couvre :func:`color_traffic_light`, :func:`color_single_gradient`,
4
+ :func:`color_diverging`, :func:`text_color_for_bg`, :func:`build_grid_svg`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from picarones.report.render_helpers import (
10
+ DIVERGING_NEGATIVE_RGB,
11
+ DIVERGING_POSITIVE_RGB,
12
+ GRADIENT_GREEN_RGB,
13
+ GRADIENT_RED_RGB,
14
+ GRADIENT_YELLOW_RGB,
15
+ build_grid_svg,
16
+ color_diverging,
17
+ color_single_gradient,
18
+ color_traffic_light,
19
+ text_color_for_bg,
20
+ )
21
+
22
+
23
+ def _hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
24
+ s = hex_str.lstrip("#")
25
+ return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
26
+
27
+
28
+ # ──────────────────────────────────────────────────────────────────
29
+ # color_traffic_light
30
+ # ──────────────────────────────────────────────────────────────────
31
+ class TestColorTrafficLight:
32
+ def test_value_zero_high_is_good_returns_red(self) -> None:
33
+ assert _hex_to_rgb(color_traffic_light(0.0)) == GRADIENT_RED_RGB
34
+
35
+ def test_value_max_high_is_good_returns_green(self) -> None:
36
+ assert _hex_to_rgb(color_traffic_light(1.0)) == GRADIENT_GREEN_RGB
37
+
38
+ def test_value_mid_high_is_good_returns_yellow(self) -> None:
39
+ assert _hex_to_rgb(color_traffic_light(0.5)) == GRADIENT_YELLOW_RGB
40
+
41
+ def test_value_zero_low_is_good_returns_green(self) -> None:
42
+ assert _hex_to_rgb(color_traffic_light(0.0, low_is_good=True)) == GRADIENT_GREEN_RGB
43
+
44
+ def test_value_max_low_is_good_returns_red(self) -> None:
45
+ assert _hex_to_rgb(color_traffic_light(1.0, low_is_good=True)) == GRADIENT_RED_RGB
46
+
47
+ def test_clamping_above_max(self) -> None:
48
+ c = color_traffic_light(2.0)
49
+ assert _hex_to_rgb(c) == GRADIENT_GREEN_RGB
50
+
51
+ def test_clamping_below_zero(self) -> None:
52
+ c = color_traffic_light(-1.0)
53
+ assert _hex_to_rgb(c) == GRADIENT_RED_RGB
54
+
55
+ def test_custom_scale_max(self) -> None:
56
+ # CER 0.30 = max → vert (avec low_is_good=True → vert au max)
57
+ # On vérifie plutôt high_is_good : 0.30 → vert
58
+ assert _hex_to_rgb(color_traffic_light(0.30, scale_max=0.30)) == GRADIENT_GREEN_RGB
59
+ # 0.15 → milieu → jaune
60
+ assert _hex_to_rgb(color_traffic_light(0.15, scale_max=0.30)) == GRADIENT_YELLOW_RGB
61
+
62
+ def test_custom_scale_min_and_max(self) -> None:
63
+ # Plage [10, 20] : 10 → rouge, 20 → vert, 15 → jaune
64
+ assert _hex_to_rgb(color_traffic_light(10.0, scale_min=10, scale_max=20)) == GRADIENT_RED_RGB
65
+ assert _hex_to_rgb(color_traffic_light(20.0, scale_min=10, scale_max=20)) == GRADIENT_GREEN_RGB
66
+ assert _hex_to_rgb(color_traffic_light(15.0, scale_min=10, scale_max=20)) == GRADIENT_YELLOW_RGB
67
+
68
+ def test_zero_span_returns_yellow(self) -> None:
69
+ # scale_min == scale_max → milieu → jaune
70
+ assert _hex_to_rgb(color_traffic_light(5.0, scale_min=10, scale_max=10)) == GRADIENT_YELLOW_RGB
71
+
72
+ def test_format_is_hex_lowercase(self) -> None:
73
+ c = color_traffic_light(0.7)
74
+ assert c.startswith("#")
75
+ assert len(c) == 7
76
+ assert c == c.lower()
77
+
78
+
79
+ # ──────────────────────────────────────────────────────────────────
80
+ # color_single_gradient
81
+ # ──────────────────────────────────────────────────────────────────
82
+ class TestColorSingleGradient:
83
+ def test_zero_returns_start(self) -> None:
84
+ assert color_single_gradient(0.0, end_rgb=(30, 58, 138)) == "#ffffff"
85
+
86
+ def test_max_returns_end(self) -> None:
87
+ assert color_single_gradient(1.0, end_rgb=(30, 58, 138)) == "#1e3a8a"
88
+
89
+ def test_half_is_midpoint(self) -> None:
90
+ c = color_single_gradient(0.5, end_rgb=(30, 58, 138))
91
+ # ((255+30)/2, (255+58)/2, (255+138)/2) ≈ (142, 156, 196) → ~#8e9cc4
92
+ r, g, b = _hex_to_rgb(c)
93
+ assert abs(r - 142) <= 1
94
+ assert abs(g - 156) <= 1
95
+ assert abs(b - 196) <= 1
96
+
97
+ def test_above_max_clamped(self) -> None:
98
+ assert color_single_gradient(2.0, end_rgb=(30, 58, 138)) == "#1e3a8a"
99
+
100
+ def test_below_zero_clamped(self) -> None:
101
+ assert color_single_gradient(-1.0, end_rgb=(30, 58, 138)) == "#ffffff"
102
+
103
+ def test_custom_max_value(self) -> None:
104
+ # value = 50, max = 100 → 0.5 → milieu
105
+ c = color_single_gradient(50.0, end_rgb=(30, 58, 138), max_value=100.0)
106
+ r, g, b = _hex_to_rgb(c)
107
+ assert abs(r - 142) <= 1
108
+
109
+ def test_custom_start_rgb(self) -> None:
110
+ c = color_single_gradient(0.0, end_rgb=(30, 58, 138), start_rgb=(0, 0, 0))
111
+ assert c == "#000000"
112
+
113
+ def test_zero_max_value_returns_start(self) -> None:
114
+ # Garde-fou contre division par zéro
115
+ assert color_single_gradient(5.0, end_rgb=(30, 58, 138), max_value=0) == "#ffffff"
116
+
117
+
118
+ # ──────────────────────────────────────────────────────────────────
119
+ # color_diverging
120
+ # ──────────────────────────────────────────────────────────────────
121
+ class TestColorDiverging:
122
+ def test_zero_returns_neutral(self) -> None:
123
+ c = color_diverging(0.0)
124
+ # Neutre = vert par défaut
125
+ assert _hex_to_rgb(c) == (130, 200, 130)
126
+
127
+ def test_positive_max_returns_positive_color(self) -> None:
128
+ c = color_diverging(1.0, max_abs=1.0)
129
+ assert _hex_to_rgb(c) == DIVERGING_POSITIVE_RGB
130
+
131
+ def test_negative_max_returns_negative_color(self) -> None:
132
+ c = color_diverging(-1.0, max_abs=1.0)
133
+ assert _hex_to_rgb(c) == DIVERGING_NEGATIVE_RGB
134
+
135
+ def test_clamping_above_max_abs(self) -> None:
136
+ assert _hex_to_rgb(color_diverging(5.0, max_abs=1.0)) == DIVERGING_POSITIVE_RGB
137
+
138
+ def test_clamping_below_negative_max_abs(self) -> None:
139
+ assert _hex_to_rgb(color_diverging(-5.0, max_abs=1.0)) == DIVERGING_NEGATIVE_RGB
140
+
141
+ def test_zero_max_abs_returns_neutral(self) -> None:
142
+ c = color_diverging(5.0, max_abs=0)
143
+ assert _hex_to_rgb(c) == (130, 200, 130)
144
+
145
+
146
+ # ──────────────────────────────────────────────────────────────────
147
+ # text_color_for_bg
148
+ # ──────────────────────────────────────────────────────────────────
149
+ class TestTextColorForBg:
150
+ def test_low_intensity_dark_text(self) -> None:
151
+ assert text_color_for_bg(0.2) == "#222"
152
+
153
+ def test_high_intensity_white_text(self) -> None:
154
+ assert text_color_for_bg(0.8) == "#fff"
155
+
156
+ def test_threshold_boundary(self) -> None:
157
+ assert text_color_for_bg(0.55) == "#222"
158
+ assert text_color_for_bg(0.56) == "#fff"
159
+
160
+ def test_custom_threshold(self) -> None:
161
+ assert text_color_for_bg(0.4, threshold=0.3) == "#fff"
162
+
163
+
164
+ # ──────────────────────────────────────────────────────────────────
165
+ # build_grid_svg
166
+ # ──────────────────────────────────────────────────────────────────
167
+ class TestBuildGridSvg:
168
+ def test_empty_grid_returns_empty_string(self) -> None:
169
+ svg = build_grid_svg(
170
+ n_rows=0,
171
+ n_cols=0,
172
+ row_label_fn=lambda i: "",
173
+ col_label_fn=lambda j: "",
174
+ cell_color_fn=lambda i, j: "#fff",
175
+ )
176
+ assert svg == ""
177
+
178
+ def test_zero_rows_returns_empty(self) -> None:
179
+ svg = build_grid_svg(
180
+ n_rows=0,
181
+ n_cols=3,
182
+ row_label_fn=lambda i: "",
183
+ col_label_fn=lambda j: str(j),
184
+ cell_color_fn=lambda i, j: "#fff",
185
+ )
186
+ assert svg == ""
187
+
188
+ def test_minimal_grid_renders(self) -> None:
189
+ svg = build_grid_svg(
190
+ n_rows=1,
191
+ n_cols=1,
192
+ row_label_fn=lambda i: "row0",
193
+ col_label_fn=lambda j: "col0",
194
+ cell_color_fn=lambda i, j: "#abc123",
195
+ )
196
+ assert "<svg" in svg
197
+ assert "row0" in svg
198
+ assert "col0" in svg
199
+ assert "#abc123" in svg
200
+
201
+ def test_cell_text_displayed_when_provided(self) -> None:
202
+ svg = build_grid_svg(
203
+ n_rows=1,
204
+ n_cols=1,
205
+ row_label_fn=lambda i: "r",
206
+ col_label_fn=lambda j: "c",
207
+ cell_color_fn=lambda i, j: "#fff",
208
+ cell_text_fn=lambda i, j: "0.42",
209
+ )
210
+ assert "0.42" in svg
211
+
212
+ def test_cell_text_omitted_when_none(self) -> None:
213
+ svg = build_grid_svg(
214
+ n_rows=1,
215
+ n_cols=1,
216
+ row_label_fn=lambda i: "r",
217
+ col_label_fn=lambda j: "c",
218
+ cell_color_fn=lambda i, j: "#fff",
219
+ cell_text_fn=lambda i, j: None,
220
+ )
221
+ # Pas de chiffre dans la cellule
222
+ assert ">0.<" not in svg
223
+
224
+ def test_rotate_col_labels(self) -> None:
225
+ svg = build_grid_svg(
226
+ n_rows=1,
227
+ n_cols=1,
228
+ row_label_fn=lambda i: "r",
229
+ col_label_fn=lambda j: "long_label",
230
+ cell_color_fn=lambda i, j: "#fff",
231
+ rotate_col_labels=True,
232
+ )
233
+ assert "rotate(-45" in svg
234
+
235
+ def test_x_axis_title(self) -> None:
236
+ svg = build_grid_svg(
237
+ n_rows=1,
238
+ n_cols=1,
239
+ row_label_fn=lambda i: "r",
240
+ col_label_fn=lambda j: "c",
241
+ cell_color_fn=lambda i, j: "#fff",
242
+ x_axis_title="Position dans le document",
243
+ )
244
+ assert "Position dans le document" in svg
245
+
246
+ def test_html_escape_in_labels(self) -> None:
247
+ svg = build_grid_svg(
248
+ n_rows=1,
249
+ n_cols=1,
250
+ row_label_fn=lambda i: "<script>alert(1)</script>",
251
+ col_label_fn=lambda j: "<b>bold</b>",
252
+ cell_color_fn=lambda i, j: "#fff",
253
+ cell_text_fn=lambda i, j: "<i>italic</i>",
254
+ )
255
+ assert "<script>" not in svg
256
+ assert "&lt;script&gt;" in svg
257
+ assert "<b>" not in svg
258
+ assert "&lt;b&gt;" in svg
259
+ assert "<i>" not in svg
260
+
261
+ def test_grid_dimensions(self) -> None:
262
+ svg = build_grid_svg(
263
+ n_rows=3,
264
+ n_cols=4,
265
+ row_label_fn=lambda i: f"r{i}",
266
+ col_label_fn=lambda j: f"c{j}",
267
+ cell_color_fn=lambda i, j: "#fff",
268
+ )
269
+ # 12 cellules attendues (3×4)
270
+ assert svg.count("<rect") == 12
271
+ # 3 étiquettes de ligne + 4 de colonne
272
+ # On compte les <text> qui ne sont pas dans une cellule (donc qui sont des labels) :
273
+ # 3 labels lignes + 4 labels colonnes = 7. Pas de cell_text_fn donc 0.
274
+ assert svg.count("<text") == 7
275
+
276
+ def test_aria_label_present(self) -> None:
277
+ svg = build_grid_svg(
278
+ n_rows=1,
279
+ n_cols=1,
280
+ row_label_fn=lambda i: "r",
281
+ col_label_fn=lambda j: "c",
282
+ cell_color_fn=lambda i, j: "#fff",
283
+ aria_label="Test heatmap",
284
+ )
285
+ assert 'aria-label="Test heatmap"' in svg