Spaces:
Sleeping
Sleeping
Claude
feat(sprint-A6): WCAG niveau A bloquant — skip-link, canvas a11y, scope=col
43d25a5 unverified | """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'<svg xmlns="http://www.w3.org/2000/svg" ' | |
| f'width="{width}" height="{height}" ' | |
| f'viewBox="0 0 {width} {height}" ' | |
| f'role="img" aria-label="Diagramme miroir taxonomique">', | |
| # En-têtes des deux moteurs | |
| f'<text x="{center - bar_max_width // 2}" y="20" ' | |
| f'font-size="13" font-weight="600" fill="#333" ' | |
| f'text-anchor="middle">{_e(engine_a)}</text>', | |
| f'<text x="{center + bar_max_width // 2}" y="20" ' | |
| f'font-size="13" font-weight="600" fill="#333" ' | |
| f'text-anchor="middle">{_e(engine_b)}</text>', | |
| # Ligne centrale | |
| f'<line x1="{center}" y1="{margin_top - 4}" ' | |
| f'x2="{center}" y2="{height - margin_bottom + 4}" ' | |
| f'stroke="#999" stroke-width="1"/>', | |
| ] | |
| # 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'<text x="{center}" y="{y + row_height // 2 + 4}" ' | |
| f'font-size="11" fill="#222" text-anchor="middle" ' | |
| f'font-family="monospace">{_e(cls)}</text>' | |
| ) | |
| # 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'<rect x="{x_a:.1f}" y="{y + 3}" ' | |
| f'width="{a_width:.1f}" height="{row_height - 6}" ' | |
| f'fill="{color}" stroke="#666" stroke-width="0.5" ' | |
| f'opacity="0.85"/>' | |
| ) | |
| # Valeur en % | |
| parts.append( | |
| f'<text x="{x_a - 3:.1f}" y="{y + row_height // 2 + 4}" ' | |
| f'font-size="10" fill="#444" text-anchor="end">' | |
| f'{prop_a.get(cls, 0.0) * 100:.1f}%</text>' | |
| ) | |
| # 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'<rect x="{x_b:.1f}" y="{y + 3}" ' | |
| f'width="{b_width:.1f}" height="{row_height - 6}" ' | |
| f'fill="{color}" stroke="#666" stroke-width="0.5" ' | |
| f'opacity="0.85"/>' | |
| ) | |
| parts.append( | |
| f'<text x="{x_b + b_width + 3:.1f}" ' | |
| f'y="{y + row_height // 2 + 4}" ' | |
| f'font-size="10" fill="#444" text-anchor="start">' | |
| f'{prop_b.get(cls, 0.0) * 100:.1f}%</text>' | |
| ) | |
| parts.append("</svg>") | |
| 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 = [ | |
| '<table style="border-collapse:collapse;font-size:.85rem;' | |
| 'margin-top:.5rem">', | |
| '<thead><tr>', | |
| '<th scope=\"col\" style="padding:.2rem .5rem;text-align:left;' | |
| 'border-bottom:1px solid #ccc">' | |
| f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>', | |
| '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;' | |
| 'border-bottom:1px solid #ccc">' | |
| f'{_e(_e(data["engine_a"]))}</th>', | |
| '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;' | |
| 'border-bottom:1px solid #ccc">' | |
| f'{_e(_e(data["engine_b"]))}</th>', | |
| '</tr></thead><tbody>', | |
| ] | |
| for level, label in rows: | |
| cell = totals.get(level, {"a": 0.0, "b": 0.0}) | |
| color = _RECOVERABILITY_COLORS.get(level, "#888") | |
| parts.append( | |
| f'<tr>' | |
| f'<td style="padding:.2rem .5rem">' | |
| f'<span style="display:inline-block;width:10px;height:10px;' | |
| f'background:{color};margin-right:.4rem;border-radius:2px"></span>' | |
| f'{_e(label)}</td>' | |
| f'<td style="padding:.2rem .5rem;text-align:right;' | |
| f'font-family:monospace">{cell["a"] * 100:.1f}%</td>' | |
| f'<td style="padding:.2rem .5rem;text-align:right;' | |
| f'font-family:monospace">{cell["b"] * 100:.1f}%</td>' | |
| f'</tr>' | |
| ) | |
| parts.append("</tbody></table>") | |
| 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 = [ | |
| '<div class="taxocomp" style="margin:1rem 0">', | |
| f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>', | |
| f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">' | |
| f'{_e(note)}</div>', | |
| _build_mirror_chart_svg(data), | |
| _build_recoverability_summary_html(data, labels), | |
| "</div>", | |
| ] | |
| return "".join(parts) | |
| __all__ = [ | |
| "build_taxonomy_comparison_html", | |
| ] | |