'use strict';
// ── Palette couleurs par moteur ──────────────────────────────────
const PALETTE = [
'#2563eb','#dc2626','#16a34a','#ca8a04','#7c3aed',
'#0891b2','#c2410c','#0f766e','#9333ea','#b45309',
];
function engineColor(idx) { return PALETTE[idx % PALETTE.length]; }
// ── Navigation ──────────────────────────────────────────────────
let currentView = 'ranking';
function _switchView(name) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.getElementById('view-' + name).classList.add('active');
// Activer le bon onglet nav
const tabMap = {ranking:'classement',gallery:'galerie',document:'document',characters:'caract',analyses:'analyses'};
const prefix = tabMap[name] || name;
document.querySelectorAll('.tab-btn').forEach(b => {
if (b.textContent.toLowerCase().startsWith(prefix.toLowerCase())) b.classList.add('active');
});
currentView = name;
if (name === 'analyses' && !chartsBuilt) buildCharts();
if (name === 'characters' && !charViewBuilt) initCharView();
}
function showView(name) {
_switchView(name);
updateURL(name);
// Sprint A6 — re-attache les boutons d'a11y aux nouveaux charts
// qui ont été instanciés paresseusement au switch de vue.
if (typeof attachChartA11y === 'function') {
setTimeout(attachChartA11y, 50);
}
}
// ─── Sprint A7 (m-5) — toggle de la palette daltonien-friendly ───────
//
// L'utilisateur peut basculer la palette du rapport via la case du
// panneau Avancé OU via ``?palette=classic`` dans l'URL. Le choix est
// persisté dans l'URL (state-stable, partageable) et appliqué au body
// au démarrage.
function togglePalette(useClassic) {
if (useClassic) {
document.body.classList.add('palette-classic');
} else {
document.body.classList.remove('palette-classic');
}
// Persiste le choix dans l'URL.
try {
const url = new URL(window.location.href);
if (useClassic) url.searchParams.set('palette', 'classic');
else url.searchParams.delete('palette');
window.history.replaceState({}, '', url);
} catch (e) { /* URL API absente — silencieux */ }
}
function _initPaletteFromURL() {
try {
const url = new URL(window.location.href);
const palette = url.searchParams.get('palette');
if (palette === 'classic') {
document.body.classList.add('palette-classic');
const cb = document.getElementById('palette-toggle-cb');
if (cb) cb.checked = true;
}
} catch (e) { /* silencieux */ }
}
// ── Formatage ───────────────────────────────────────────────────
//
// Sprint A7 (m-6) — formatage localisé des nombres. ``I18N.locale``
// est ``"fr-FR"`` ou ``"en-GB"`` (ou un autre locale BCP-47 si une
// nouvelle langue est ajoutée). Le séparateur de milliers et le
// séparateur décimal suivent la convention locale (1 234,56 en FR
// vs 1,234.56 en EN). Un ``locale`` absent retombe sur la locale
// navigateur (``undefined`` passé à ``toLocaleString``).
function fmtNum(v, opts) {
if (v === null || v === undefined || v === '' || Number.isNaN(v)) return '—';
const locale = (typeof I18N !== 'undefined' && I18N.locale) || undefined;
return Number(v).toLocaleString(locale, opts || {});
}
function fmtInt(v) {
return fmtNum(v, { maximumFractionDigits: 0 });
}
function pct(v, d=2) {
if (v === null || v === undefined) return '—';
return (v * 100).toFixed(d) + ' %';
}
function cerColor(v) {
if (v < 0.05) return '#16a34a';
if (v < 0.15) return '#ca8a04';
if (v < 0.30) return '#ea580c';
return '#dc2626';
}
function cerBg(v) {
if (v < 0.05) return '#dcfce7';
if (v < 0.15) return '#fef9c3';
if (v < 0.30) return '#ffedd5';
return '#fee2e2';
}
// Sprint 30 — accessibilité WCAG : un tier non-couleur permet aux
// daltoniens et aux lecteurs d'écran de distinguer les paliers.
// Mappé en CSS sur des patterns visuels (icône + bordure) en plus
// de la couleur. ``aria-label`` complète pour les lecteurs d'écran.
function cerTier(v) {
if (v < 0.05) return 'excellent';
if (v < 0.15) return 'acceptable';
if (v < 0.30) return 'mediocre';
return 'critical';
}
function cerTierIcon(tier) {
// Caractères unicode lisibles indépendamment de la couleur.
return {
excellent: '●', // disque plein
acceptable: '◐', // demi-disque
mediocre: '◑', // demi-disque inverse
critical: '○', // cercle vide
}[tier] || '';
}
function cerTierLabel(tier) {
return {
excellent: 'CER excellent',
acceptable: 'CER acceptable',
mediocre: 'CER médiocre',
critical: 'CER critique',
}[tier] || 'CER';
}
function esc(s) {
return String(s)
.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
// ── Diff renderer ──────────────────────────────────────────────
function renderDiff(ops) {
if (!ops || !ops.length) return '— aucune sortie — ';
return ops.map(op => {
if (op.op === 'equal')
return '' + esc(op.text) + ' ';
if (op.op === 'insert')
return '' + esc(op.text) + ' ';
if (op.op === 'delete')
return '' + esc(op.text) + ' ';
if (op.op === 'replace')
return '' + esc(op.old) + ' '
+ '' + esc(op.new) + ' ';
return '';
}).join(' ');
}
// ── Rendu côte à côte (char-level) ──────────────────────────────────
function renderSideBySide(docId) {
const doc = DATA.documents.find(d => d.doc_id === docId);
if (!doc) return;
const sel = document.getElementById('sbs-engine-dropdown');
const engineIdx = sel && sel.value !== '' ? parseInt(sel.value, 10) : 0;
const er = doc.engine_results[engineIdx];
if (!er) return;
const ops = er.diff || [];
// Construire le HTML GT (gauche) et OCR (droite) depuis les mêmes ops
let gtHtml = '', ocrHtml = '';
ops.forEach(op => {
if (op.op === 'equal') {
const t = esc(op.text);
gtHtml += t;
ocrHtml += t;
} else if (op.op === 'delete') {
// Présent dans GT, absent de l'OCR → orange dans GT
gtHtml += '' + esc(op.text) + ' ';
} else if (op.op === 'insert') {
// Présent dans OCR, absent du GT → vert dans OCR
ocrHtml += '' + esc(op.text) + ' ';
} else if (op.op === 'replace') {
// Substitution : orange dans GT, rouge dans OCR
gtHtml += '' + esc(op.old) + ' ';
ocrHtml += '' + esc(op.new) + ' ';
}
});
document.getElementById('sbs-gt-body').innerHTML = gtHtml || '— ';
document.getElementById('sbs-ocr-body').innerHTML = ocrHtml || 'Aucune sortie ';
// En-tête OCR : nom moteur + CER
const c = cerColor(er.cer); const bg = cerBg(er.cer);
const tier = cerTier(er.cer);
document.getElementById('sbs-ocr-engine-name').textContent = er.engine;
const cerBadgeEl = document.getElementById('sbs-ocr-cer');
// Sprint 30 — ajout du tier non-couleur + aria-label (a11y WCAG).
// L'icône préfixée distingue les paliers indépendamment de la couleur,
// et ``aria-label`` est lu par les lecteurs d'écran.
cerBadgeEl.textContent = `${cerTierIcon(tier)} ${pct(er.cer)}`;
cerBadgeEl.setAttribute('data-cer-tier', tier);
cerBadgeEl.setAttribute('aria-label', `${cerTierLabel(tier)} ${pct(er.cer)}`);
cerBadgeEl.style.cssText = `color:${c};background:${bg};display:inline-block`;
// Pipeline triple-diff (si applicable)
const tripleEl = document.getElementById('sbs-triple-diff');
if (er.ocr_intermediate) {
const ocrDiffHtml = renderDiff(er.ocr_diff);
const llmDiffHtml = renderDiff(er.llm_correction_diff);
const isPipeline = er.ocr_intermediate !== undefined;
const modeLabel = {text_only:'texte seul', text_and_image:'image+texte', zero_shot:'zero-shot'}[er.pipeline_mode] || '';
const pipeTag = `⛓ ${modeLabel || 'pipeline'} `;
let onBadge = '';
if (er.over_normalization) {
const on = er.over_normalization;
const onPct = (on.score * 100).toFixed(2);
const cls = on.score > 0.05 ? 'over-norm-badge high' : 'over-norm-badge';
onBadge = `Sur-norm. ${onPct}% `;
}
let diplomaBadge = '';
if (er.cer_diplomatic !== null && er.cer_diplomatic !== undefined) {
const dipC = cerColor(er.cer_diplomatic); const dipB = cerBg(er.cer_diplomatic);
const delta = er.cer - er.cer_diplomatic;
const deltaHint = delta > 0.001 ? ` (−${(delta*100).toFixed(1)}% avec normalisation)` : '';
diplomaBadge = `diplo. ${pct(er.cer_diplomatic)} `;
}
tripleEl.style.display = '';
tripleEl.innerHTML = `
${pipeTag} ${diplomaBadge} ${onBadge}
WER ${pct(er.wer)}
GT → OCR brut
${ocrDiffHtml || '— '}
OCR brut → Correction LLM
${llmDiffHtml || '— '}
`;
} else {
// Afficher WER / CER diplomatique même hors pipeline
let diplomaBadge = '';
if (er.cer_diplomatic !== null && er.cer_diplomatic !== undefined) {
const dipC = cerColor(er.cer_diplomatic); const dipB = cerBg(er.cer_diplomatic);
const delta = er.cer - er.cer_diplomatic;
const deltaHint = delta > 0.001 ? ` (−${(delta*100).toFixed(1)}% avec normalisation)` : '';
diplomaBadge = `diplo. ${pct(er.cer_diplomatic)} `;
}
const errBadge = er.error ? `Erreur ` : '';
if (diplomaBadge || errBadge) {
tripleEl.style.display = '';
tripleEl.innerHTML = `
WER ${pct(er.wer)}
${diplomaBadge} ${errBadge}
`;
} else {
tripleEl.style.display = 'none';
tripleEl.innerHTML = '';
}
}
}
// ── Score badge (ligatures / diacritiques) ───────────────────────
function _scoreBadge(v, label) {
if (v === null || v === undefined) return '— ';
const pctVal = (v * 100).toFixed(1);
const color = v >= 0.9 ? '#16a34a' : v >= 0.7 ? '#ca8a04' : '#dc2626';
const bg = v >= 0.9 ? '#f0fdf4' : v >= 0.7 ? '#fefce8' : '#fef2f2';
return `${pctVal}% `;
}
// ── Vue Classement ──────────────────────────────────────────────
let rankingSort = { col: 'cer', dir: 'asc' };
function renderRanking() {
const engines = [...DATA.engines];
// Trier
engines.sort((a, b) => {
let va = a[rankingSort.col], vb = b[rankingSort.col];
if (typeof va === 'string') va = va.toLowerCase();
if (typeof vb === 'string') vb = vb.toLowerCase();
if (va === null) va = Infinity;
if (vb === null) vb = Infinity;
return rankingSort.dir === 'asc' ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1);
});
const tbody = document.getElementById('ranking-tbody');
tbody.innerHTML = engines.map((e, i) => {
const rank = i + 1;
const badgeClass = rank === 1 ? 'rank-badge rank-1' : 'rank-badge';
const cerC = cerColor(e.cer); const cerB = cerBg(e.cer);
const barW = Math.min(100, e.cer * 100 * 3);
// Badge pipeline
let pipelineBadge = '';
let pipelineStepsHtml = '';
if (e.is_pipeline && e.pipeline_info) {
const pi = e.pipeline_info;
const modeLabel = {text_only:'texte', text_and_image:'image+texte', zero_shot:'zero-shot'}[pi.pipeline_mode] || pi.pipeline_mode || '';
pipelineBadge = `
⛓ pipeline·${modeLabel} `;
if (pi.pipeline_steps) {
pipelineStepsHtml = `` +
pi.pipeline_steps.map(s => s.type === 'ocr'
? `OCR: ${esc(s.engine)} `
: `LLM: ${esc(s.model)} `
).join(`→ `) +
`
`;
}
}
// Sur-normalisation (classe 10)
let overNormCell = '— ';
if (e.is_pipeline && e.pipeline_info && e.pipeline_info.over_normalization) {
const on = e.pipeline_info.over_normalization;
const onPct = (on.score * 100).toFixed(2);
const cls = on.score > 0.05 ? 'over-norm-badge high' : 'over-norm-badge';
overNormCell = `${onPct} % `;
}
// CER diplomatique
let diploCerCell = '— ';
if (e.cer_diplomatic !== null && e.cer_diplomatic !== undefined) {
const dipC = cerColor(e.cer_diplomatic); const dipB = cerBg(e.cer_diplomatic);
const delta = e.cer - e.cer_diplomatic;
const deltaStr = delta > 0.001 ? ` -${(delta*100).toFixed(1)}% ` : '';
const profileHint = e.cer_diplomatic_profile ? ` title="Profil : ${esc(e.cer_diplomatic_profile)}"` : '';
diploCerCell = `
${pct(e.cer_diplomatic)} ${deltaStr}
`;
}
// ── Sprint 10 : Gini + Ancrage ─────────────────────────────────────
let giniCell = '— ';
if (e.gini !== null && e.gini !== undefined) {
const gv = e.gini;
const gColor = gv < 0.3 ? '#16a34a' : gv < 0.5 ? '#ca8a04' : '#dc2626';
const gBg = gv < 0.3 ? '#f0fdf4' : gv < 0.5 ? '#fefce8' : '#fef2f2';
giniCell = `${gv.toFixed(3)} `;
}
let anchorCell = '— ';
if (e.anchor_score !== null && e.anchor_score !== undefined) {
const av = e.anchor_score;
const hallBadge = (e.hallucinating_doc_rate && e.hallucinating_doc_rate > 0.2)
? ' ⚠️ ' : '';
anchorCell = `${_scoreBadge(av, 'Ancrage trigrammes')}${hallBadge} `;
}
return `
${rank}
${esc(e.name)}
${pipelineBadge}
${e.is_vlm ? '👁 VLM ' : ''}
v${esc(e.version)}
${pipelineStepsHtml}
${pct(e.cer)}
${diploCerCell.replace('${pct(e.wer)}
${pct(e.mer)}
${pct(e.wil)}
${_scoreBadge(e.ligature_score, 'Ligatures')}
${_scoreBadge(e.diacritic_score, 'Diacritiques')}
${giniCell.replace('${pct(e.cer_median)}
${pct(e.cer_min)}
${pct(e.cer_max)}
${overNormCell}
${e.doc_count}
`;
}).join('');
// Stats globales
const pipelineCount = DATA.engines.filter(e => e.is_pipeline).length;
const totalDocs = DATA.meta.document_count;
const exclCount = EXCLUDED_DOCS.size;
const activeDocs = totalDocs - exclCount;
const stats = document.getElementById('ranking-stats');
stats.innerHTML = `
Corpus ${esc(DATA.meta.corpus_name)}
Documents ${activeDocs} ${exclCount > 0 ? ` (−${exclCount} exclu${exclCount>1?'s':''}) ` : ''}
Concurrents ${DATA.engines.length}
${pipelineCount ? `${pipelineCount} pipeline${pipelineCount>1?'s':''} ` : ''}
`;
}
// Tri au clic sur en-tête
document.querySelectorAll('#ranking-table th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
if (rankingSort.col === col) {
rankingSort.dir = rankingSort.dir === 'asc' ? 'desc' : 'asc';
} else {
rankingSort.col = col;
rankingSort.dir = 'asc';
}
document.querySelectorAll('#ranking-table th').forEach(t => {
t.classList.remove('sorted');
const icon = t.querySelector('.sort-icon');
if (icon) icon.textContent = '↕';
});
th.classList.add('sorted');
const icon = th.querySelector('.sort-icon');
if (icon) icon.textContent = rankingSort.dir === 'asc' ? '↑' : '↓';
renderRanking();
});
});
// ── Système d'exclusion globale ─────────────────────────────────
// Union de toutes les sources d'exclusion (manuelle + hallucination toggles)
const EXCLUDED_DOCS = new Set();
const _manualExclusions = new Set();
const _hallucinationExclusions = new Set();
// Données originales sauvegardées pour recalcul
const _originalEngines = JSON.parse(JSON.stringify(DATA.engines));
function _updateExcludedDocs() {
EXCLUDED_DOCS.clear();
_manualExclusions.forEach(id => EXCLUDED_DOCS.add(id));
_hallucinationExclusions.forEach(id => EXCLUDED_DOCS.add(id));
_updateExclusionBanner();
}
function _updateExclusionBanner() {
const banner = document.getElementById('global-exclusion-banner');
const text = document.getElementById('global-exclusion-text');
if (EXCLUDED_DOCS.size > 0) {
banner.style.display = '';
text.textContent = EXCLUDED_DOCS.size + ' document' + (EXCLUDED_DOCS.size > 1 ? 's' : '') +
' exclu' + (EXCLUDED_DOCS.size > 1 ? 's' : '') + ' de l\'analyse' +
(_manualExclusions.size > 0 ? ' (' + _manualExclusions.size + ' manuel' + (_manualExclusions.size > 1 ? 's' : '') + ')' : '') +
(_hallucinationExclusions.size > 0 ? ' (' + _hallucinationExclusions.size + ' hallucination' + (_hallucinationExclusions.size > 1 ? 's' : '') + ')' : '');
} else {
banner.style.display = 'none';
}
}
function resetAllExclusions() {
_manualExclusions.clear();
_hallucinationExclusions.clear();
EXCLUDED_DOCS.clear();
_updateExclusionBanner();
// Reset hallucination toggles
['robust-cer-toggle','robust-anchor-toggle','robust-ratio-toggle'].forEach(id => {
const btn = document.getElementById(id);
if (btn) { btn.dataset.active = 'true'; btn.textContent = '✓'; btn.closest('label').classList.remove('criterion-off'); }
});
document.getElementById('robust-cer').value = 100;
document.getElementById('robust-cer-val').textContent = '100%';
document.getElementById('robust-anchor').value = 0.5;
document.getElementById('robust-anchor-val').textContent = '0.50';
document.getElementById('robust-ratio').value = 1.5;
document.getElementById('robust-ratio-val').textContent = '1.5';
recalculateAll();
renderGallery();
}
function _recalcEngineMetrics() {
// Recalcule les métriques agrégées de chaque moteur en excluant EXCLUDED_DOCS
DATA.engines.forEach((eng, idx) => {
const orig = _originalEngines[idx];
if (EXCLUDED_DOCS.size === 0) {
// Restaurer les valeurs originales
eng.cer = orig.cer;
eng.wer = orig.wer;
eng.mer = orig.mer;
eng.wil = orig.wil;
eng.cer_median = orig.cer_median;
eng.cer_min = orig.cer_min;
eng.cer_max = orig.cer_max;
eng.cer_values = orig.cer_values.slice();
eng.doc_count = orig.doc_count;
eng.gini = orig.gini;
eng.anchor_score = orig.anchor_score;
eng.length_ratio = orig.length_ratio;
eng.hallucinating_doc_rate = orig.hallucinating_doc_rate;
return;
}
// Recalculer depuis les documents non exclus
const cerVals = [], werVals = [], merVals = [], wilVals = [];
const giniVals = [], anchorVals = [];
DATA.documents.forEach(doc => {
if (EXCLUDED_DOCS.has(doc.doc_id)) return;
const er = doc.engine_results.find(r => r.engine === eng.name);
if (!er || er.error) return;
if (er.cer !== null) cerVals.push(er.cer);
if (er.wer !== null) werVals.push(er.wer);
if (er.mer !== null) merVals.push(er.mer);
if (er.wil !== null) wilVals.push(er.wil);
const lm = er.line_metrics;
if (lm && lm.gini !== null) giniVals.push(lm.gini);
const hm = er.hallucination_metrics;
if (hm && hm.anchor_score !== null) anchorVals.push(hm.anchor_score);
});
const mean = arr => arr.length ? arr.reduce((a,b) => a+b, 0) / arr.length : 0;
const sorted = arr => [...arr].sort((a,b) => a - b);
const median = arr => {
if (!arr.length) return 0;
const s = sorted(arr); const n = s.length;
return n % 2 === 0 ? (s[n/2-1] + s[n/2]) / 2 : s[Math.floor(n/2)];
};
eng.cer = cerVals.length ? mean(cerVals) : orig.cer;
eng.wer = werVals.length ? mean(werVals) : orig.wer;
eng.mer = merVals.length ? mean(merVals) : orig.mer;
eng.wil = wilVals.length ? mean(wilVals) : orig.wil;
eng.cer_median = cerVals.length ? median(cerVals) : orig.cer_median;
eng.cer_min = cerVals.length ? Math.min(...cerVals) : orig.cer_min;
eng.cer_max = cerVals.length ? Math.max(...cerVals) : orig.cer_max;
eng.cer_values = cerVals;
eng.doc_count = cerVals.length;
eng.gini = giniVals.length ? mean(giniVals) : orig.gini;
eng.anchor_score = anchorVals.length ? mean(anchorVals) : orig.anchor_score;
});
}
function recalculateAll() {
console.log('[Picarones] recalculateAll — EXCLUDED_DOCS:', [...EXCLUDED_DOCS]);
_recalcEngineMetrics();
renderRanking();
renderRobustMetrics();
// Rebuild charts if they were already built
if (chartsBuilt) {
chartsBuilt = false;
Object.keys(chartInstances).forEach(id => destroyChart(id));
buildCharts();
}
}
// ── Métriques robustes ──────────────────────────────────────────
function _computeHallucinationExclusions() {
// Recalcule _hallucinationExclusions à partir des toggles/sliders
_hallucinationExclusions.clear();
const cerOn = document.getElementById('robust-cer-toggle').dataset.active === 'true';
const anchorOn = document.getElementById('robust-anchor-toggle').dataset.active === 'true';
const ratioOn = document.getElementById('robust-ratio-toggle').dataset.active === 'true';
const cerThreshold = parseInt(document.getElementById('robust-cer').value) / 100;
const anchorThreshold = parseFloat(document.getElementById('robust-anchor').value);
const ratioThreshold = parseFloat(document.getElementById('robust-ratio').value);
DATA.documents.forEach(doc => {
// Un doc est exclu par hallucination si AU MOINS un moteur le détecte comme problématique
const dominated = doc.engine_results.some(er => {
if (!er || er.error) return false;
const hm = er.hallucination_metrics;
if (cerOn && cerThreshold < 1.0 && er.cer !== null && er.cer > cerThreshold) return true;
if (anchorOn && hm && hm.anchor_score < anchorThreshold) return true;
if (ratioOn && hm && hm.length_ratio > ratioThreshold) return true;
return false;
});
if (dominated) _hallucinationExclusions.add(doc.doc_id);
});
console.log('[Picarones] _hallucinationExclusions:', [..._hallucinationExclusions]);
_updateExcludedDocs();
}
function _robustStat(arr) {
// Retourne {mean, median, p90, p95} ou null si tableau vide
if (!arr.length) return null;
const sorted = [...arr].sort((a, b) => a - b);
const n = sorted.length;
const mean = sorted.reduce((a, b) => a + b, 0) / n;
const median = n % 2 === 0 ? (sorted[n/2-1] + sorted[n/2]) / 2 : sorted[Math.floor(n/2)];
const p90 = sorted[Math.min(Math.ceil(n * 0.9) - 1, n - 1)];
const p95 = sorted[Math.min(Math.ceil(n * 0.95) - 1, n - 1)];
return { mean, median, p90, p95 };
}
function _deltaCell(globalVal, robustVal) {
if (robustVal === null || globalVal === null) return '—';
const delta = robustVal - globalVal;
const cls = delta < -0.001 ? 'color:#16a34a' : delta > 0.001 ? 'color:#dc2626' : 'color:var(--text-muted)';
const sign = delta >= 0 ? '+' : '';
return `${sign}${(delta*100).toFixed(2)}% `;
}
function toggleRobustCriterion(id, btn) {
const active = btn.dataset.active !== 'true';
btn.dataset.active = active ? 'true' : 'false';
btn.textContent = active ? '✓' : '✕';
btn.closest('label').classList.toggle('criterion-off', !active);
_computeHallucinationExclusions();
recalculateAll();
}
function renderRobustMetrics() {
const cerOn = document.getElementById('robust-cer-toggle').dataset.active === 'true';
const anchorOn = document.getElementById('robust-anchor-toggle').dataset.active === 'true';
const ratioOn = document.getElementById('robust-ratio-toggle').dataset.active === 'true';
const cerThreshold = parseInt(document.getElementById('robust-cer').value) / 100;
const anchorThreshold = parseFloat(document.getElementById('robust-anchor').value);
const ratioThreshold = parseFloat(document.getElementById('robust-ratio').value);
const totalDocs = DATA.documents.length;
// Pour chaque engine : recalculer métriques en excluant les docs problématiques
const results = DATA.engines.map(eng => {
const excluded = [];
const cerVals = [], werVals = [], merVals = [], wilVals = [], giniVals = [], anchorVals = [];
DATA.documents.forEach(doc => {
const er = doc.engine_results.find(r => r.engine === eng.name);
if (!er || er.error) return;
const hm = er.hallucination_metrics;
const lm = er.line_metrics;
// Raisons d'exclusion
const reasons = [];
if (cerOn && cerThreshold < 1.0 && er.cer !== null && er.cer > cerThreshold)
reasons.push(`CER ${(er.cer*100).toFixed(1)}% > ${(cerThreshold*100).toFixed(0)}%`);
if (anchorOn && hm && hm.anchor_score < anchorThreshold)
reasons.push(`ancrage ${hm.anchor_score.toFixed(3)} < ${anchorThreshold.toFixed(2)}`);
if (ratioOn && hm && hm.length_ratio > ratioThreshold)
reasons.push(`ratio ${hm.length_ratio.toFixed(2)} > ${ratioThreshold.toFixed(1)}`);
if (_manualExclusions.has(doc.doc_id))
reasons.push('exclusion manuelle');
if (reasons.length > 0) {
excluded.push({
doc_id: doc.doc_id,
cer: er.cer,
anchor: hm ? hm.anchor_score : undefined,
ratio: hm ? hm.length_ratio : undefined,
reasons,
});
} else {
if (er.cer !== null) cerVals.push(er.cer);
if (er.wer !== null) werVals.push(er.wer);
if (er.mer !== null) merVals.push(er.mer);
if (er.wil !== null) wilVals.push(er.wil);
if (lm && lm.gini !== null) giniVals.push(lm.gini);
if (hm && hm.anchor_score !== null) anchorVals.push(hm.anchor_score);
}
});
const meanOf = arr => arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null;
return {
name: eng.name,
global_cer: eng.cer,
global_wer: eng.wer,
global_mer: eng.mer,
global_wil: eng.wil,
robust_cer: _robustStat(cerVals),
robust_wer: meanOf(werVals),
robust_mer: meanOf(merVals),
robust_wil: meanOf(wilVals),
robust_gini: meanOf(giniVals),
robust_anchor: meanOf(anchorVals),
robust_docs: cerVals.length,
excluded_count: excluded.length,
excluded_docs: excluded,
};
});
// Résumé — nombre unique de docs exclus (au moins par un moteur)
const allExcludedIds = new Set(results.flatMap(r => r.excluded_docs.map(d => d.doc_id)));
const countExcl = allExcludedIds.size;
const countIncl = totalDocs - countExcl;
const summaryEl = document.getElementById('robust-summary');
summaryEl.textContent = countExcl === 0
? `Aucun document exclu — métriques calculées sur ${totalDocs} documents.`
: `${countExcl} doc${countExcl>1?'s':''} exclu${countExcl>1?'s':''} sur ${totalDocs} — métriques robustes calculées sur ${countIncl} document${countIncl>1?'s':''}.`;
if (!results.some(r => r.robust_cer !== null)) {
document.getElementById('robust-table-wrap').innerHTML =
'Aucune donnée disponible pour ce corpus.
';
document.getElementById('robust-excluded-docs').innerHTML = '';
return;
}
// Tableau comparatif étendu
const fmt = v => v !== null ? pct(v) : '—';
const rows = results.map(r => {
const rs = r.robust_cer;
const robCerMean = rs ? rs.mean : null;
return `
${esc(r.name)}
${fmt(r.global_cer)}
${rs ? pct(rs.mean) : '—'}
${_deltaCell(r.global_cer, robCerMean)}
${rs ? pct(rs.median) : '—'}
${rs ? pct(rs.p90) : '—'}
${rs ? pct(rs.p95) : '—'}
${fmt(r.global_wer)}
${fmt(r.robust_wer)}
${_deltaCell(r.global_wer, r.robust_wer)}
${fmt(r.global_mer)}
${fmt(r.robust_mer)}
${fmt(r.global_wil)}
${fmt(r.robust_wil)}
${r.robust_gini !== null ? r.robust_gini.toFixed(3) : '—'}
${r.robust_anchor !== null ? r.robust_anchor.toFixed(3) : '—'}
${r.excluded_count} / ${r.robust_docs}
`;
}).join('');
const thStyle = 'padding:.35rem .5rem;font-size:.75rem;white-space:nowrap;text-align:center;border-bottom:1px solid var(--border)';
const thStyleL = thStyle + ';text-align:left';
document.getElementById('robust-table-wrap').innerHTML = `
Moteur
— CER —
— CER robuste détail —
— WER —
— MER —
— WIL —
Gini rob.
Ancrage rob.
Excl./Incl.
Global
Robuste
Δ
Médiane
P90
P95
Global
Robuste
Δ
Global
Robuste
Global
Robuste
${rows}
`;
// Documents exclus — liste déroulante unifiée
if (allExcludedIds.size > 0) {
// Collecter infos par doc_id (union des raisons de tous les moteurs)
const docInfoMap = new Map();
results.forEach(r => {
r.excluded_docs.forEach(d => {
if (!docInfoMap.has(d.doc_id)) {
docInfoMap.set(d.doc_id, { doc_id: d.doc_id, cer: d.cer, anchor: d.anchor, ratio: d.ratio, reasons: new Set() });
}
d.reasons.forEach(reason => docInfoMap.get(d.doc_id).reasons.add(reason));
});
});
const uniqDocs = [...docInfoMap.values()].sort((a,b) => a.doc_id.localeCompare(b.doc_id));
document.getElementById('robust-excluded-docs').innerHTML =
`` +
`▶ Documents exclus (${uniqDocs.length}) ` +
`` +
uniqDocs.map(d => {
const cerStr = d.cer !== null && d.cer !== undefined ? ` CER ${(d.cer*100).toFixed(1)}%` : '';
return `${esc(d.doc_id)} ${cerStr} — ${[...d.reasons].join(', ')} `;
}).join('') +
` `;
} else {
document.getElementById('robust-excluded-docs').innerHTML = '';
}
}
// ── Vue Galerie ─────────────────────────────────────────────────
function toggleGalleryExclusion(docId, checked) {
if (checked) {
_manualExclusions.delete(docId);
} else {
_manualExclusions.add(docId);
}
_updateExcludedDocs();
_updateGalleryExclusionUI();
}
function resetGalleryExclusions() {
_manualExclusions.clear();
_updateExcludedDocs();
renderGallery();
recalculateAll();
}
function _updateGalleryExclusionUI() {
const count = _manualExclusions.size;
const btn = document.getElementById('gallery-reset-btn');
const info = document.getElementById('gallery-exclusion-info');
if (count > 0) {
btn.style.display = '';
info.style.display = '';
info.textContent = `${count} document${count>1?'s':''} exclu${count>1?'s':''} manuellement de l'analyse.`;
} else {
btn.style.display = 'none';
info.style.display = 'none';
}
recalculateAll();
}
function renderGallery() {
const sortKey = document.getElementById('gallery-sort').value;
const filterCer = parseFloat(document.getElementById('gallery-filter-cer').value) / 100 || 0;
const filterEngine = document.getElementById('gallery-engine-select').value;
let docs = [...DATA.documents];
// Filtre CER
if (filterCer > 0) {
docs = docs.filter(d => {
if (filterEngine) {
const er = d.engine_results.find(r => r.engine === filterEngine);
return er && er.cer >= filterCer;
}
return d.mean_cer >= filterCer;
});
}
// Tri
docs.sort((a, b) => {
if (sortKey === 'mean_cer') return a.mean_cer - b.mean_cer;
if (sortKey === 'difficulty_score') return (b.difficulty_score||0) - (a.difficulty_score||0);
if (sortKey === 'best_engine') return a.best_engine.localeCompare(b.best_engine);
return a.doc_id.localeCompare(b.doc_id);
});
const grid = document.getElementById('gallery-grid');
const empty = document.getElementById('gallery-empty');
if (!docs.length) {
grid.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
// Mise à jour bouton reset
const btn = document.getElementById('gallery-reset-btn');
const info = document.getElementById('gallery-exclusion-info');
if (_manualExclusions.size > 0) {
btn.style.display = '';
info.style.display = '';
info.textContent = `${_manualExclusions.size} document${_manualExclusions.size>1?'s':''} exclu${_manualExclusions.size>1?'s':''} manuellement de l'analyse.`;
} else {
btn.style.display = 'none';
info.style.display = 'none';
}
grid.innerHTML = docs.map(doc => {
const imgTag = doc.image_b64
? ` `
: `🖹
`;
const badges = doc.engine_results.map(er => {
const c = cerColor(er.cer); const bg = cerBg(er.cer);
const isPipe = er.ocr_intermediate !== undefined;
const label = isPipe ? '⛓' + er.engine.slice(0,8) : er.engine.slice(0,8);
return `${esc(label)} ${pct(er.cer,1)} `;
}).join('');
// Difficulty badge
let diffBadge = '';
if (doc.difficulty_score !== undefined) {
const dScore = doc.difficulty_score;
const dColor = dScore < 0.25 ? '#16a34a' : dScore < 0.5 ? '#ca8a04' : dScore < 0.75 ? '#ea580c' : '#dc2626';
const dBg = dScore < 0.25 ? '#f0fdf4' : dScore < 0.5 ? '#fefce8' : dScore < 0.75 ? '#fff7ed' : '#fef2f2';
diffBadge = `⚡ ${doc.difficulty_label} `;
}
const isExcluded = _manualExclusions.has(doc.doc_id);
const checkboxId = `gal-chk-${doc.doc_id.replace(/[^a-z0-9]/gi,'_')}`;
const cardStyle = isExcluded ? 'opacity:.5;border:2px dashed #dc2626' : '';
return `
${isExcluded ? 'Exclu' : 'Inclus'}
${imgTag}
${esc(doc.doc_id)}${diffBadge}
${badges}
`;
}).join('');
}
// ── Vue Document ────────────────────────────────────────────────
let currentDocId = null;
let zoomLevel = 1;
let dragStart = null;
let imgOffset = { x: 0, y: 0 };
function openDocument(docId) {
_switchView('document');
updateURL('document', { doc: docId });
loadDocument(docId);
}
function loadDocument(docId) {
const doc = DATA.documents.find(d => d.doc_id === docId);
if (!doc) return;
currentDocId = docId;
// Sidebar : highlight
document.querySelectorAll('.doc-list-item').forEach(el => {
el.classList.toggle('active', el.dataset.docId === docId);
});
// Titre
document.getElementById('doc-detail-title').textContent = doc.doc_id;
// Métriques
const metricsDiv = document.getElementById('doc-detail-metrics');
const cer = doc.mean_cer;
const dScore = doc.difficulty_score;
const dColor = dScore < 0.25 ? '#16a34a' : dScore < 0.5 ? '#ca8a04' : dScore < 0.75 ? '#ea580c' : '#dc2626';
const dLabel = doc.difficulty_label || '';
metricsDiv.innerHTML = `CER moyen ${pct(cer)}
Meilleur moteur ${esc(doc.best_engine)}
${dScore !== undefined ? `Difficulté ${dLabel} (${(dScore*100).toFixed(0)}%)
` : ''}`;
// Image
resetZoom();
const img = document.getElementById('doc-image');
const placeholder = document.getElementById('doc-image-placeholder');
if (doc.image_b64) {
img.src = doc.image_b64;
img.style.display = '';
placeholder.style.display = 'none';
} else {
img.style.display = 'none';
placeholder.style.display = '';
placeholder.innerHTML = `🖹 ${esc(doc.image_path)} `;
}
// Side-by-side diff — sélecteur de concurrent
const selWrap = document.getElementById('sbs-engine-select');
const sel = document.getElementById('sbs-engine-dropdown');
if (doc.engine_results.length > 1) {
sel.innerHTML = doc.engine_results.map((er, i) =>
`${esc(er.engine)} `
).join('');
selWrap.style.display = '';
} else {
sel.innerHTML = '';
selWrap.style.display = 'none';
}
renderSideBySide(docId);
// ── Sprint 10 : distribution CER par ligne ──────────────────────────
const lineCard = document.getElementById('doc-line-metrics-card');
const lineContent = document.getElementById('doc-line-metrics-content');
// Prendre le premier moteur ayant des line_metrics
const erWithLine = doc.engine_results.find(er => er.line_metrics);
if (erWithLine && erWithLine.line_metrics) {
lineCard.style.display = '';
lineContent.innerHTML = renderLineMetrics(doc.engine_results);
} else {
lineCard.style.display = 'none';
}
// ── Sprint 10 : hallucinations ──────────────────────────────────────
const hallCard = document.getElementById('doc-hallucination-card');
const hallContent = document.getElementById('doc-hallucination-content');
const erWithHall = doc.engine_results.find(er => er.hallucination_metrics && er.hallucination_metrics.is_hallucinating);
if (erWithHall || doc.engine_results.some(er => er.hallucination_metrics)) {
hallCard.style.display = '';
hallContent.innerHTML = renderHallucinationPanel(doc.engine_results);
} else {
hallCard.style.display = 'none';
}
}
// ── Sprint 10 : rendu distribution CER par ligne ────────────────
function renderLineMetrics(engineResults) {
const heatmapColors = (v) => {
if (v < 0.05) return '#86efac';
if (v < 0.15) return '#fde68a';
if (v < 0.30) return '#fb923c';
return '#f87171';
};
return engineResults.filter(er => er.line_metrics).map(er => {
const lm = er.line_metrics;
const c = cerColor(er.cer); const bg = cerBg(er.cer);
// Heatmap de position
const heatmap = lm.heatmap || [];
const maxHeat = Math.max(...heatmap, 0.01);
const heatmapHtml = heatmap.length > 0
? `` +
heatmap.map((v, i) => {
const h = Math.max(4, Math.round(60 * v / maxHeat));
return `
`;
}).join('') +
`
${I18N.heatmap_start||'Début'} ${I18N.heatmap_mid||'Milieu'} ${I18N.heatmap_end||'Fin'}
`
: '— ';
// Percentiles
const p = lm.percentiles || {};
const pctBars = ['p50','p75','p90','p95','p99'].map(k => {
const v = p[k] || 0;
const w = Math.min(100, v * 100 * 2);
const fillColor = v < 0.15 ? '#86efac' : v < 0.30 ? '#fde68a' : '#f87171';
return `
${k}
${(v*100).toFixed(1)}%
`;
}).join('');
// Taux catastrophiques
const cr = lm.catastrophic_rate || {};
const crRows = Object.entries(cr).map(([t, rate]) => {
const tPct = (parseFloat(t)*100).toFixed(0);
const ratePct = (rate*100).toFixed(1);
const color = rate < 0.05 ? '#16a34a' : rate < 0.15 ? '#ca8a04' : '#dc2626';
return `${ratePct}% lignes CER>${tPct}% `;
}).join('');
// Gini
const gini = lm.gini !== undefined ? lm.gini.toFixed(3) : '—';
const giniColor = lm.gini < 0.3 ? '#16a34a' : lm.gini < 0.5 ? '#ca8a04' : '#dc2626';
return `
${esc(er.engine)}
${pct(er.cer)}
Gini ${gini}
${lm.line_count} ${I18N.lines||'lignes'}
${crRows}
${I18N.heatmap_title||'CARTE THERMIQUE (position)'}
${heatmapHtml}
${I18N.percentile_title||'PERCENTILES CER'}
${pctBars}
`;
}).join('') || `${I18N.no_line_metrics||'Aucune métrique de ligne disponible.'} `;
}
// ── Sprint 10 : rendu panneau hallucinations ─────────────────────
function renderHallucinationPanel(engineResults) {
const withHall = engineResults.filter(er => er.hallucination_metrics);
if (!withHall.length) return `${I18N.no_hall_metrics||"Aucune métrique d'hallucination disponible."} `;
return withHall.map(er => {
const hm = er.hallucination_metrics;
const isHall = hm.is_hallucinating;
const badgeClass = isHall ? 'hallucination-badge' : 'hallucination-badge ok';
const badgeLabel = isHall ? (I18N.hall_detected||'⚠️ Hallucinations détectées') : (I18N.hall_ok||'✓ Ancrage satisfaisant');
const blocksHtml = hm.hallucinated_blocks && hm.hallucinated_blocks.length > 0
? hm.hallucinated_blocks.slice(0, 5).map(b =>
`
${I18N.hall_block_label||'Bloc halluciné'} — ${b.length} mots (tokens ${b.start_token}–${b.end_token})
${esc(b.text)}
`
).join('') +
(hm.hallucinated_blocks.length > 5 ? `… ${hm.hallucinated_blocks.length - 5} ${I18N.hall_more_blocks||'bloc(s) supplémentaire(s)'}
` : '')
: `${I18N.no_hall_blocks||'Aucun bloc halluciné détecté.'} `;
return `
${esc(er.engine)}
${badgeLabel}
Ancrage ${(hm.anchor_score*100).toFixed(1)}%
Ratio longueur ${hm.length_ratio.toFixed(2)}
Insertion nette ${(hm.net_insertion_rate*100).toFixed(1)}%
${hm.gt_word_count} mots GT / ${hm.hyp_word_count} mots sortie
${isHall ? `
${I18N.hall_blocks_title||'Blocs sans ancrage dans le GT :'}
` : ''}
${isHall ? blocksHtml : ''}
`;
}).join('');
}
// ── Sprint 10 — Scatter Gini vs CER moyen ──────────────────────
function buildGiniCerScatter() {
const canvas = document.getElementById('chart-gini-cer');
if (!canvas) return;
const pts = DATA.gini_vs_cer || [];
if (!pts.length) {
canvas.parentElement.innerHTML = `${I18N.no_gini||'Gini data unavailable.'}
`;
return;
}
const datasets = pts.map((p, i) => ({
label: p.engine,
data: [{ x: p.cer * 100, y: p.gini }],
backgroundColor: engineColor(DATA.engines.findIndex(e => e.name === p.engine)) + 'cc',
borderColor: engineColor(DATA.engines.findIndex(e => e.name === p.engine)),
borderWidth: p.is_pipeline ? 2 : 1,
pointRadius: p.is_pipeline ? 9 : 7,
pointStyle: p.is_pipeline ? 'triangle' : 'circle',
}));
chartInstances['gini-cer'] = new Chart(canvas.getContext('2d'), {
type: 'scatter',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11 } } },
tooltip: { callbacks: {
label: ctx => `${ctx.dataset.label}: CER=${ctx.parsed.x.toFixed(2)}%, Gini=${ctx.parsed.y.toFixed(3)}`,
} },
},
scales: {
x: { min: 0, title: { display: true, text: 'CER moyen (%)', font: { size: 11 } } },
y: { min: 0, max: 1, title: { display: true, text: 'Coefficient de Gini', font: { size: 11 } } },
},
},
});
}
// ── Sprint 10 — Scatter ratio longueur vs score d'ancrage ────────
function buildRatioAnchorScatter() {
const canvas = document.getElementById('chart-ratio-anchor');
if (!canvas) return;
const pts = DATA.ratio_vs_anchor || [];
if (!pts.length) {
canvas.parentElement.innerHTML = `${I18N.no_anchor_data||'Anchor data unavailable.'}
`;
return;
}
// Zone de danger (ancrage < 0.5 OU ratio > 1.2) dessinée via plugin
const datasets = pts.map((p, i) => ({
label: p.engine + (p.is_vlm ? ' 👁' : ''),
data: [{ x: p.anchor_score, y: p.length_ratio }],
backgroundColor: engineColor(DATA.engines.findIndex(e => e.name === p.engine)) + 'cc',
borderColor: engineColor(DATA.engines.findIndex(e => e.name === p.engine)),
borderWidth: p.is_vlm ? 3 : 1,
pointRadius: p.is_vlm ? 10 : 7,
pointStyle: p.is_vlm ? 'star' : 'circle',
}));
chartInstances['ratio-anchor'] = new Chart(canvas.getContext('2d'), {
type: 'scatter',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11 } } },
tooltip: { callbacks: {
label: ctx => `${ctx.dataset.label}: ancrage=${(ctx.parsed.x*100).toFixed(1)}%, ratio=${ctx.parsed.y.toFixed(2)}`,
} },
},
scales: {
x: { min: 0, max: 1, title: { display: true, text: "Score d'ancrage [0–1]", font: { size: 11 } } },
y: { min: 0, title: { display: true, text: 'Ratio longueur (sortie/GT)', font: { size: 11 } } },
},
},
plugins: [{
id: 'danger-zones',
beforeDraw(chart) {
const { ctx: c, chartArea: { left, top, right, bottom }, scales: { x, y } } = chart;
c.save();
// Ancrage < 0.5 (gauche)
const xHalf = x.getPixelForValue(0.5);
c.fillStyle = 'rgba(239,68,68,0.07)';
c.fillRect(left, top, xHalf - left, bottom - top);
// Ratio > 1.2 (haut)
const y12 = y.getPixelForValue(1.2);
if (y12 > top) {
c.fillRect(left, top, right - left, y12 - top);
}
// Lignes de seuil
c.strokeStyle = 'rgba(239,68,68,0.35)'; c.lineWidth = 1; c.setLineDash([4,4]);
c.beginPath(); c.moveTo(xHalf, top); c.lineTo(xHalf, bottom); c.stroke();
if (y12 > top) {
c.beginPath(); c.moveTo(left, y12); c.lineTo(right, y12); c.stroke();
}
c.restore();
},
}],
});
}
function buildDocList() {
const list = document.getElementById('doc-list');
list.innerHTML = DATA.documents.map(doc => {
const c = cerColor(doc.mean_cer); const bg = cerBg(doc.mean_cer);
return `
${esc(doc.doc_id)}
${pct(doc.mean_cer,1)}
`;
}).join('');
if (DATA.documents.length) loadDocument(DATA.documents[0].doc_id);
}
// Zoom
function handleZoom(e) {
e.preventDefault();
zoom(e.deltaY < 0 ? 1.15 : 0.87);
}
function zoom(factor) {
zoomLevel = Math.max(0.5, Math.min(5, zoomLevel * factor));
applyZoom();
}
function resetZoom() {
zoomLevel = 1; imgOffset = { x: 0, y: 0 };
applyZoom();
}
function applyZoom() {
const img = document.getElementById('doc-image');
img.style.transform = `scale(${zoomLevel}) translate(${imgOffset.x}px, ${imgOffset.y}px)`;
}
function startDrag(e) {
if (zoomLevel <= 1) return;
dragStart = { x: e.clientX - imgOffset.x * zoomLevel, y: e.clientY - imgOffset.y * zoomLevel };
document.getElementById('doc-image-wrap').style.cursor = 'grabbing';
}
function doDrag(e) {
if (!dragStart) return;
imgOffset.x = (e.clientX - dragStart.x) / zoomLevel;
imgOffset.y = (e.clientY - dragStart.y) / zoomLevel;
applyZoom();
}
function endDrag() {
dragStart = null;
document.getElementById('doc-image-wrap').style.cursor = zoomLevel > 1 ? 'grab' : 'zoom-in';
}
// ── Graphiques ──────────────────────────────────────────────────
let chartsBuilt = false;
let chartInstances = {};
function destroyChart(id) {
if (chartInstances[id]) { chartInstances[id].destroy(); delete chartInstances[id]; }
}
function buildCharts() {
if (chartsBuilt) return;
chartsBuilt = true;
buildCerHistogram();
buildRadar();
buildCerPerDoc();
buildDurationChart();
buildQualityCerScatter();
buildTaxonomyChart();
// Sprint 7
buildReliabilityCurves();
buildBootstrapCIChart();
buildVennDiagram();
buildWilcoxonTable();
buildErrorClusters();
initCorrelationMatrix();
// Sprint 10
buildGiniCerScatter();
buildRatioAnchorScatter();
}
function buildCerHistogram() {
destroyChart('cer-hist');
const ctx = document.getElementById('chart-cer-hist').getContext('2d');
// Construire histogramme à bins fixes [0-5, 5-10, 10-20, 20-30, 30-50, 50+]
const bins = [0, 0.05, 0.10, 0.20, 0.30, 0.50, 1.01];
const labels = ['0–5%', '5–10%', '10–20%', '20–30%', '30–50%', '>50%'];
const colors = ['#16a34a','#65a30d','#ca8a04','#ea580c','#dc2626','#9f1239'];
const datasets = DATA.engines.map((e, ei) => {
const counts = new Array(labels.length).fill(0);
e.cer_values.forEach(v => {
for (let i = 0; i < bins.length - 1; i++) {
if (v >= bins[i] && v < bins[i+1]) { counts[i]++; break; }
}
});
return {
label: e.name, data: counts,
backgroundColor: engineColor(ei) + 'aa',
borderColor: engineColor(ei),
borderWidth: 1,
};
});
chartInstances['cer-hist'] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
x: { title: { display: true, text: 'Plage CER', font: { size: 11 } } },
y: { title: { display: true, text: 'Nombre de documents', font: { size: 11 } },
ticks: { stepSize: 1 } },
},
},
});
}
function buildRadar() {
destroyChart('radar');
const ctx = document.getElementById('chart-radar').getContext('2d');
// Axes : CER, WER, MER, WIL inversés (1 - valeur → plus c'est élevé, mieux c'est)
const metrics = ['CER', 'WER', 'MER', 'WIL'];
const keys = ['cer', 'wer', 'mer', 'wil'];
const datasets = DATA.engines.map((e, i) => {
const data = keys.map(k => Math.max(0, (1 - (e[k] || 0)) * 100));
return {
label: e.name, data,
backgroundColor: engineColor(i) + '33',
borderColor: engineColor(i),
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
};
});
chartInstances['radar'] = new Chart(ctx, {
type: 'radar',
data: { labels: metrics, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
r: {
min: 0, max: 100,
ticks: { stepSize: 20, font: { size: 10 } },
pointLabels: { font: { size: 12, weight: 'bold' } },
},
},
},
});
}
function buildCerPerDoc() {
destroyChart('cer-doc');
const ctx = document.getElementById('chart-cer-doc').getContext('2d');
const filteredDocs = DATA.documents.filter(d => !EXCLUDED_DOCS.has(d.doc_id));
const labels = filteredDocs.map(d => d.doc_id);
const datasets = DATA.engines.map((e, ei) => {
const data = filteredDocs.map(doc => {
const er = doc.engine_results.find(r => r.engine === e.name);
return er ? er.cer * 100 : null;
});
return {
label: e.name, data,
borderColor: engineColor(ei),
backgroundColor: engineColor(ei) + '22',
tension: 0.3, fill: false,
pointRadius: 3, pointHoverRadius: 5,
};
});
chartInstances['cer-doc'] = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
x: { ticks: { maxRotation: 45, font: { size: 10 } } },
y: { title: { display: true, text: 'CER (%)', font: { size: 11 } }, min: 0 },
},
},
});
}
function buildDurationChart() {
destroyChart('duration');
const ctx = document.getElementById('chart-duration').getContext('2d');
const filteredDocs = DATA.documents.filter(d => !EXCLUDED_DOCS.has(d.doc_id));
const labels = DATA.engines.map(e => e.name);
const data = DATA.engines.map(e => {
const durs = filteredDocs.flatMap(d => d.engine_results
.filter(r => r.engine === e.name)
.map(r => r.duration));
const mean = durs.length ? durs.reduce((a,b) => a+b, 0) / durs.length : 0;
return parseFloat(mean.toFixed(3));
});
chartInstances['duration'] = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Durée moy. (s)',
data,
backgroundColor: DATA.engines.map((_, i) => engineColor(i) + 'aa'),
borderColor: DATA.engines.map((_, i) => engineColor(i)),
borderWidth: 1,
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { title: { display: true, text: 'Secondes', font: { size: 11 } }, min: 0 },
},
},
});
}
function buildQualityCerScatter() {
const ctx = document.getElementById('chart-quality-cer');
if (!ctx) return;
const filteredDocs = DATA.documents.filter(d => !EXCLUDED_DOCS.has(d.doc_id));
// Construire les points : un par document, un dataset par moteur
const datasets = DATA.engines.map((e, ei) => {
const points = filteredDocs.flatMap(doc => {
const er = doc.engine_results.find(r => r.engine === e.name);
if (!er || er.error || !er.image_quality) return [];
return [{ x: er.image_quality.quality_score, y: er.cer * 100 }];
});
return {
label: e.name, data: points,
backgroundColor: engineColor(ei) + 'bb',
borderColor: engineColor(ei),
borderWidth: 1, pointRadius: 5, pointHoverRadius: 7,
};
}).filter(d => d.data.length > 0);
if (!datasets.length) { ctx.parentElement.innerHTML = 'Aucune donnée de qualité image disponible.
'; return; }
chartInstances['quality-cer'] = new Chart(ctx.getContext('2d'), {
type: 'scatter',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: { font: { size: 11 } } },
tooltip: { callbacks: {
label: ctx => `${ctx.dataset.label}: qualité=${ctx.parsed.x.toFixed(2)}, CER=${ctx.parsed.y.toFixed(1)}%`,
} },
},
scales: {
x: { min: 0, max: 1, title: { display: true, text: 'Score qualité image [0–1]', font: { size: 11 } } },
y: { min: 0, title: { display: true, text: 'CER (%)', font: { size: 11 } } },
},
},
});
}
function buildTaxonomyChart() {
const ctx = document.getElementById('chart-taxonomy');
if (!ctx) return;
const taxLabels = ['Confusion visuelle','Diacritique','Casse','Ligature','Abréviation','Hapax','Segmentation','Hors-vocab.','Lacune'];
const taxKeys = ['visual_confusion','diacritic_error','case_error','ligature_error','abbreviation_error','hapax','segmentation_error','oov_character','lacuna'];
const taxColors = ['#6366f1','#f59e0b','#ec4899','#14b8a6','#8b5cf6','#64748b','#f97316','#06b6d4','#ef4444'];
const datasets = DATA.engines.map((e, ei) => {
const tax = e.aggregated_taxonomy;
const data = taxKeys.map(k => tax && tax.counts ? (tax.counts[k] || 0) : 0);
return {
label: e.name, data,
backgroundColor: engineColor(ei) + '99',
borderColor: engineColor(ei),
borderWidth: 1,
};
});
chartInstances['taxonomy'] = new Chart(ctx.getContext('2d'), {
type: 'bar',
data: { labels: taxLabels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } } },
y: { title: { display: true, text: "Nb d'erreurs", font: { size: 11 } }, min: 0, ticks: { stepSize: 1 } },
},
},
});
}
// ── Sprint 7 — Courbes de fiabilité ─────────────────────────────
function buildReliabilityCurves() {
const ctx = document.getElementById('chart-reliability');
if (!ctx) return;
const curves = DATA.reliability_curves || [];
if (!curves.length) { ctx.parentElement.innerHTML = 'Données insuffisantes.
'; return; }
const datasets = curves.map((c, i) => {
const points = (c.points || []).map(p => ({ x: p.pct_docs, y: p.mean_cer * 100 }));
return {
label: c.engine, data: points,
borderColor: engineColor(i), backgroundColor: engineColor(i) + '22',
tension: 0.3, fill: false, pointRadius: 2, pointHoverRadius: 5,
};
});
destroyChart('reliability');
chartInstances['reliability'] = new Chart(ctx.getContext('2d'), {
type: 'line',
data: { datasets },
options: {
responsive: true, maintainAspectRatio: false,
parsing: { xAxisKey: 'x', yAxisKey: 'y' },
plugins: {
legend: { position: 'top', labels: { font: { size: 11 } } },
tooltip: { callbacks: {
title: ([item]) => `${item.parsed.x.toFixed(0)}% docs les plus faciles`,
label: item => `${item.dataset.label}: CER moy = ${item.parsed.y.toFixed(2)}%`,
} },
},
scales: {
x: { type:'linear', min:0, max:100,
title: { display:true, text:'% documents (triés par CER croissant)', font:{ size:11 } } },
y: { min:0, title: { display:true, text:'CER moyen (%)', font:{ size:11 } } },
},
},
});
}
// ── Sprint 7 — Bootstrap CI ──────────────────────────────────────
function buildBootstrapCIChart() {
const ctx = document.getElementById('chart-bootstrap-ci');
if (!ctx) return;
const cis = DATA.statistics && DATA.statistics.bootstrap_cis || [];
if (!cis.length) { ctx.parentElement.innerHTML = 'Données insuffisantes.
'; return; }
const labels = cis.map(c => c.engine);
const means = cis.map(c => (c.mean * 100));
const lowers = cis.map(c => (c.mean - c.ci_lower) * 100);
const uppers = cis.map(c => (c.ci_upper - c.mean) * 100);
destroyChart('bootstrap-ci');
chartInstances['bootstrap-ci'] = new Chart(ctx.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [{
label: 'CER moyen (%)',
data: means,
backgroundColor: cis.map((_, i) => engineColor(i) + 'aa'),
borderColor: cis.map((_, i) => engineColor(i)),
borderWidth: 1,
errorBars: { symmetric: false },
}],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const ci = cis[ctx.dataIndex];
return `IC 95% : [${(ci.ci_lower*100).toFixed(2)}%, ${(ci.ci_upper*100).toFixed(2)}%]`;
},
},
},
},
scales: { y: { min: 0, title: { display:true, text:'CER (%)', font:{size:11} } } },
},
plugins: [{
id: 'errorBars',
afterDatasetsDraw(chart) {
const { ctx: c, data, scales: { x, y } } = chart;
chart.data.datasets[0].data.forEach((val, i) => {
const ci = cis[i];
if (!ci) return;
const xPos = x.getPixelForValue(i);
const yTop = y.getPixelForValue(ci.ci_upper * 100);
const yBot = y.getPixelForValue(ci.ci_lower * 100);
c.save();
c.strokeStyle = '#374151'; c.lineWidth = 2;
c.beginPath(); c.moveTo(xPos, yTop); c.lineTo(xPos, yBot); c.stroke();
c.beginPath(); c.moveTo(xPos-6, yTop); c.lineTo(xPos+6, yTop); c.stroke();
c.beginPath(); c.moveTo(xPos-6, yBot); c.lineTo(xPos+6, yBot); c.stroke();
c.restore();
});
},
}],
});
}
// ── Sprint 7 — Diagramme de Venn ────────────────────────────────
function buildVennDiagram() {
const container = document.getElementById('venn-container');
if (!container) return;
const venn = DATA.venn_data;
if (!venn || !venn.type) {
container.innerHTML = 'Données insuffisantes pour le diagramme de Venn.
';
return;
}
if (venn.type === 'venn2') {
const total = (venn.only_a || 0) + (venn.both || 0) + (venn.only_b || 0);
const maxR = 80;
const rA = Math.sqrt((venn.only_a + venn.both) / (total || 1)) * maxR + 30;
const rB = Math.sqrt((venn.only_b + venn.both) / (total || 1)) * maxR + 30;
const overlap = venn.both > 0 ? Math.min(rA, rB) * 0.6 : 0;
const cxA = 140, cxB = cxA + rA + rB - overlap, cy = 130;
const w = cxB + rB + 20, h = 260;
container.innerHTML = `
${venn.only_a}
${venn.both}
${venn.only_b}
${esc(venn.label_a)}
${esc(venn.label_b)}
commun
Erreurs exclusives ${esc(venn.label_a)} : ${venn.only_a} ·
Communes : ${venn.both} ·
Exclusives ${esc(venn.label_b)} : ${venn.only_b}
`;
} else if (venn.type === 'venn3') {
// Venn 3 cercles simplifié
const total = (venn.only_a||0)+(venn.only_b||0)+(venn.only_c||0)+(venn.ab||0)+(venn.ac||0)+(venn.bc||0)+(venn.abc||0) || 1;
container.innerHTML = `
${venn.only_a}
${venn.only_b}
${venn.only_c}
${venn.ab}
${venn.ac}
${venn.bc}
${venn.abc}
${esc((venn.label_a||'').slice(0,10))}
${esc((venn.label_b||'').slice(0,10))}
${esc((venn.label_c||'').slice(0,10))}
`;
}
}
// ── Sprint 7 — Table de Wilcoxon ─────────────────────────────────
function buildWilcoxonTable() {
const container = document.getElementById('wilcoxon-table-container');
if (!container) return;
const stats = DATA.statistics && DATA.statistics.pairwise_wilcoxon || [];
if (!stats.length) {
container.innerHTML = 'Pas assez de données pour les tests statistiques (min 2 concurrents).
';
return;
}
const rows = stats.map(s => {
const sigClass = s.significant ? 'stat-sig' : 'stat-ns';
const sigLabel = s.significant ? '✓ Significative' : '○ Non significative';
return `
${esc(s.engine_a)}
vs
${esc(s.engine_b)}
${s.n_pairs}
${s.statistic}
${s.p_value}
${sigLabel}
${esc(s.interpretation)}
`;
}).join('');
container.innerHTML = `
Concurrent A
Concurrent B
N paires
W
p-value
Verdict
Interprétation
${rows}
`;
}
// ── Sprint 7 — Clustering des erreurs ───────────────────────────
function buildErrorClusters() {
const container = document.getElementById('error-clusters-container');
if (!container) return;
const clusters = DATA.error_clusters || [];
if (!clusters.length) {
container.innerHTML = `Aucun cluster d'erreur détecté.
`;
return;
}
const cards = clusters.map(cl => {
const examplesHtml = (cl.examples || []).slice(0, 3).map(ex => {
const oldStr = ex.gt_fragment || '';
const newStr = ex.ocr_fragment || '';
return `
${esc(oldStr || '∅')}
→
${esc(newStr || '∅')}
(${esc(ex.engine || '')})
`;
}).join('');
return `
Cluster #${cl.cluster_id} : ${esc(cl.label)}
${cl.count} cas détectés
${examplesHtml}
`;
}).join('');
container.innerHTML = `${cards}
`;
}
// ── Sprint 7 — Matrice de corrélation ───────────────────────────
function initCorrelationMatrix() {
const sel = document.getElementById('corr-engine-select');
if (!sel) return;
const corrs = DATA.correlation_per_engine || [];
sel.innerHTML = '';
corrs.forEach(c => {
const opt = document.createElement('option');
opt.value = c.engine; opt.textContent = c.engine;
sel.appendChild(opt);
});
renderCorrelationMatrix();
}
function renderCorrelationMatrix() {
const container = document.getElementById('corr-matrix-container');
if (!container) return;
const sel = document.getElementById('corr-engine-select');
const engineName = sel && sel.value;
const corrs = DATA.correlation_per_engine || [];
const entry = corrs.find(c => c.engine === engineName) || corrs[0];
if (!entry || !entry.labels || !entry.matrix) {
container.innerHTML = 'Données insuffisantes.
';
return;
}
const labels = entry.labels;
const matrix = entry.matrix;
const n = labels.length;
const labelNames = {
cer: 'CER', wer: 'WER', mer: 'MER', wil: 'WIL',
quality_score: 'Qualité img', sharpness: 'Netteté',
ligature: 'Ligatures', diacritic: 'Diacritiques',
};
function corrColor(r) {
if (r >= 0.7) return 'background:#dcfce7;color:#14532d';
if (r >= 0.3) return 'background:#f0fdf4;color:#166534';
if (r >= -0.3) return 'background:#f8fafc;color:#374151';
if (r >= -0.7) return 'background:#fef2f2;color:#991b1b';
return 'background:#fee2e2;color:#7f1d1d';
}
const headerRow = ' ' + labels.map(l =>
`${esc(labelNames[l] || l)} `).join('') + ' ';
const dataRows = matrix.map((row, i) =>
'' + esc(labelNames[labels[i]] || labels[i]) + ' ' +
row.map((v, j) => {
const style = corrColor(v);
const display = i === j ? '1.00' : v.toFixed(2);
return `${display} `;
}).join('') + ' '
).join('');
container.innerHTML = ``;
}
// ── Sprint 7 — URL stateful ──────────────────────────────────────
function updateURL(view, params) {
const hash = '#' + view + (params ? '?' + new URLSearchParams(params).toString() : '');
history.replaceState(null, '', hash);
}
function readURLState() {
const hash = location.hash.slice(1);
const [view, query] = hash.split('?');
const params = query ? Object.fromEntries(new URLSearchParams(query)) : {};
return { view: view || 'ranking', params };
}
// ── Sprint 17 — Aide Critical Difference Diagram ────────────────
function toggleCDDHelp() {
const el = document.getElementById('cdd-help');
if (!el) return;
el.hidden = !el.hidden;
}
// ── Sprint 20 — Glossaire contextuel (panneau latéral) ──────────
function openGlossary(termKey) {
if (!window.GLOSSARY) return;
const entry = GLOSSARY[termKey];
const panel = document.getElementById('glossary-panel');
const title = document.getElementById('glossary-panel-title');
const body = document.getElementById('glossary-panel-body');
if (!panel || !title || !body) return;
if (!entry) {
title.textContent = termKey;
body.innerHTML = '' +
(I18N.glossary_empty || 'Aucune entrée pour ce terme.') + '
';
} else {
title.textContent = entry.title || termKey;
body.innerHTML = '';
const fields = [
['definition', I18N.glossary_definition || 'Définition'],
['measures', I18N.glossary_measures || 'Ce que la métrique mesure'],
['usage', I18N.glossary_usage || "Cas d'usage"],
['limits', I18N.glossary_limits || 'Limites'],
['reference', I18N.glossary_reference || 'Référence'],
];
fields.forEach(([k, label]) => {
if (!entry[k]) return;
const h = document.createElement('h4'); h.textContent = label;
const p = document.createElement('p'); p.textContent = entry[k];
body.appendChild(h); body.appendChild(p);
});
}
panel.hidden = false;
panel.setAttribute('aria-hidden', 'false');
document.body.classList.add('side-panel-open');
}
function closeGlossary() {
const panel = document.getElementById('glossary-panel');
if (!panel) return;
panel.hidden = true;
panel.setAttribute('aria-hidden', 'true');
if (!document.querySelector('.side-panel:not([hidden])')) {
document.body.classList.remove('side-panel-open');
}
}
function injectGlossaryButtons() {
if (!window.GLOSSARY) return;
document.querySelectorAll('th[data-glossary-key]').forEach(th => {
if (th.querySelector('.glossary-btn')) return;
const key = th.getAttribute('data-glossary-key');
if (!GLOSSARY[key]) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'glossary-btn';
btn.textContent = '?';
btn.title = (I18N.glossary_tooltip || 'Définition');
btn.setAttribute('aria-label', btn.title);
btn.onclick = (ev) => {
ev.stopPropagation(); // ne pas déclencher le tri de colonne
openGlossary(key);
};
th.appendChild(btn);
});
}
// ── Sprint 20 — Panneau "Mode avancé" (personnalisation) ────────
const _CUSTOM_COLS = [
'cer', 'cer_diplomatic', 'wer', 'mer', 'wil',
'ligature_score', 'diacritic_score', 'gini', 'anchor_score',
];
let _CUSTOM_STATE = {
hiddenColumns: new Set(),
strataFilter: {},
weightsEnabled: false,
weights: {},
};
function openCustomize() {
const panel = document.getElementById('customize-panel');
if (!panel) return;
_populateCustomize();
panel.hidden = false;
panel.setAttribute('aria-hidden', 'false');
document.body.classList.add('side-panel-open');
}
function closeCustomize() {
const panel = document.getElementById('customize-panel');
if (!panel) return;
panel.hidden = true;
panel.setAttribute('aria-hidden', 'true');
if (!document.querySelector('.side-panel:not([hidden])')) {
document.body.classList.remove('side-panel-open');
}
}
function _populateCustomize() {
const colList = document.getElementById('customize-columns-list');
colList.innerHTML = '';
_CUSTOM_COLS.forEach(col => {
const label = (I18N['col_' + col] || col);
const id = 'custom-col-' + col;
const wrap = document.createElement('label');
wrap.className = 'custom-col-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = id;
cb.checked = !_CUSTOM_STATE.hiddenColumns.has(col);
cb.addEventListener('change', () => {
if (cb.checked) _CUSTOM_STATE.hiddenColumns.delete(col);
else _CUSTOM_STATE.hiddenColumns.add(col);
applyColumnVisibility();
updateCustomURL();
});
wrap.appendChild(cb);
wrap.appendChild(document.createTextNode(' ' + label));
colList.appendChild(wrap);
});
// Strates : détection sur documents[].script_type
const strata = {};
(DATA.documents || []).forEach(d => {
const s = d.script_type;
if (!s) return;
strata[s] = (strata[s] || 0) + 1;
});
const filtersList = document.getElementById('customize-filters-list');
filtersList.innerHTML = '';
const keys = Object.keys(strata);
if (keys.length === 0) {
const p = document.createElement('p');
p.className = 'custom-note';
p.textContent = I18N.customize_filters_empty ||
'Aucune strate détectée dans les métadonnées du corpus.';
filtersList.appendChild(p);
} else {
keys.sort().forEach(k => {
const wrap = document.createElement('label');
wrap.className = 'custom-col-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = _CUSTOM_STATE.strataFilter[k] !== false;
cb.addEventListener('change', () => {
_CUSTOM_STATE.strataFilter[k] = cb.checked;
applyStrataFilter();
updateCustomURL();
});
wrap.appendChild(cb);
wrap.appendChild(document.createTextNode(
' ' + k + ' (' + strata[k] + ')'
));
filtersList.appendChild(wrap);
});
}
_renderCustomWeightsControls();
}
function toggleCustomWeights() {
_CUSTOM_STATE.weightsEnabled = !_CUSTOM_STATE.weightsEnabled;
_renderCustomWeightsControls();
applyCompositeScore();
updateCustomURL();
}
function _renderCustomWeightsControls() {
const container = document.getElementById('custom-weights-controls');
const toggle = document.getElementById('custom-weights-toggle');
const list = document.getElementById('custom-weights-list');
const formula = document.getElementById('custom-formula');
if (!container || !list || !formula || !toggle) return;
toggle.textContent = _CUSTOM_STATE.weightsEnabled
? (I18N.customize_weights_disable || 'Désactiver')
: (I18N.customize_weights_enable || 'Activer');
container.hidden = !_CUSTOM_STATE.weightsEnabled;
if (!_CUSTOM_STATE.weightsEnabled) return;
list.innerHTML = '';
const metrics = ['cer', 'wer', 'mer', 'wil',
'ligature_score', 'diacritic_score',
'gini', 'anchor_score'];
metrics.forEach(m => {
const w = _CUSTOM_STATE.weights[m] || 0;
const row = document.createElement('div');
row.className = 'custom-weight-row';
row.innerHTML = '' + (I18N['col_' + m] || m) + ' ' +
' ' +
'' + (w * 100).toFixed(0) + ' % ';
const slider = row.querySelector('input');
const output = row.querySelector('output');
slider.addEventListener('input', () => {
const v = parseFloat(slider.value) / 100;
_CUSTOM_STATE.weights[m] = v;
output.textContent = slider.value + ' %';
renderCompositeFormula();
applyCompositeScore();
updateCustomURL();
});
list.appendChild(row);
});
renderCompositeFormula();
}
function renderCompositeFormula() {
const formula = document.getElementById('custom-formula');
if (!formula) return;
const terms = [];
Object.keys(_CUSTOM_STATE.weights).forEach(m => {
const w = _CUSTOM_STATE.weights[m];
if (w && w > 0) {
terms.push(w.toFixed(2) + ' × ' + m);
}
});
if (terms.length === 0) {
formula.innerHTML = '' + (I18N.customize_weights_none || 'Aucun poids non nul — score composite inactif.') + ' ';
} else {
formula.innerHTML = 'score = ' + terms.join(' + ') + '';
}
}
function applyColumnVisibility() {
_CUSTOM_COLS.forEach(col => {
const hidden = _CUSTOM_STATE.hiddenColumns.has(col);
document.querySelectorAll('th[data-col="' + col + '"], td[data-col="' + col + '"]').forEach(el => {
el.style.display = hidden ? 'none' : '';
});
});
}
function applyStrataFilter() {
// Effet : cache les documents dont le script_type est désactivé dans la galerie
const active = new Set(Object.keys(_CUSTOM_STATE.strataFilter)
.filter(k => _CUSTOM_STATE.strataFilter[k] !== false));
// Si rien n'est filtré, pas de traitement
if (active.size === 0) return;
document.querySelectorAll('.gallery-card').forEach(card => {
const s = card.dataset.scriptType;
if (!s) return;
card.style.display = active.has(s) ? '' : 'none';
});
}
function applyCompositeScore() {
const headTh = document.querySelector('th[data-col="composite"]');
if (!_CUSTOM_STATE.weightsEnabled) {
// Retirer colonne si présente
if (headTh) headTh.remove();
document.querySelectorAll('td[data-col="composite"]').forEach(td => td.remove());
return;
}
const weights = _CUSTOM_STATE.weights;
const weightKeys = Object.keys(weights).filter(k => weights[k] > 0);
if (weightKeys.length === 0) {
if (headTh) headTh.remove();
document.querySelectorAll('td[data-col="composite"]').forEach(td => td.remove());
return;
}
// Injecter colonne dans le tableau si absente
const tbl = document.querySelector('#view-ranking table');
if (!tbl) return;
const thead = tbl.querySelector('thead tr');
if (thead && !thead.querySelector('th[data-col="composite"]')) {
const th = document.createElement('th');
th.dataset.col = 'composite';
th.className = 'sortable';
th.innerHTML = (I18N.customize_composite_col || 'Score') +
'↕ ';
thead.appendChild(th);
}
// Calculer le score pour chaque ligne moteur
const rows = tbl.querySelectorAll('tbody tr');
rows.forEach(tr => {
const name = tr.dataset.engine;
const engine = (DATA.engines || []).find(e => e.name === name);
if (!engine) return;
let score = 0;
weightKeys.forEach(k => {
const v = engine[k];
if (v == null) return;
// Pour les métriques "plus petit = mieux" (CER, WER…), on inverse
const invert = ['cer', 'wer', 'mer', 'wil', 'gini', 'length_ratio'].includes(k);
const val = invert ? (1 - Math.min(1, v)) : v;
score += weights[k] * val;
});
let td = tr.querySelector('td[data-col="composite"]');
if (!td) {
td = document.createElement('td');
td.dataset.col = 'composite';
tr.appendChild(td);
}
td.textContent = score.toFixed(3);
});
}
function resetCustomization() {
_CUSTOM_STATE = {
hiddenColumns: new Set(),
strataFilter: {},
weightsEnabled: false,
weights: {},
};
applyColumnVisibility();
applyStrataFilter();
applyCompositeScore();
_populateCustomize();
updateCustomURL();
}
function updateCustomURL() {
// Sérialise _CUSTOM_STATE dans l'URL (paramètre ``view`` existant)
const params = new URLSearchParams(window.location.search);
const hc = [..._CUSTOM_STATE.hiddenColumns].join(',');
if (hc) params.set('hidden', hc); else params.delete('hidden');
const inactive = Object.keys(_CUSTOM_STATE.strataFilter)
.filter(k => _CUSTOM_STATE.strataFilter[k] === false).join(',');
if (inactive) params.set('strata_off', inactive);
else params.delete('strata_off');
const w = Object.entries(_CUSTOM_STATE.weights)
.filter(([, v]) => v > 0)
.map(([k, v]) => k + ':' + v.toFixed(2));
if (_CUSTOM_STATE.weightsEnabled && w.length) {
params.set('w', w.join(','));
} else {
params.delete('w');
}
const newUrl = window.location.pathname + '?' + params.toString() + window.location.hash;
window.history.replaceState({}, '', newUrl);
}
function restoreCustomFromURL() {
const params = new URLSearchParams(window.location.search);
const hc = params.get('hidden');
if (hc) hc.split(',').filter(Boolean).forEach(c => _CUSTOM_STATE.hiddenColumns.add(c));
const strataOff = params.get('strata_off');
if (strataOff) strataOff.split(',').filter(Boolean).forEach(s => {
_CUSTOM_STATE.strataFilter[s] = false;
});
const w = params.get('w');
if (w) {
w.split(',').forEach(pair => {
const [k, v] = pair.split(':');
const num = parseFloat(v);
if (k && !isNaN(num) && num > 0) _CUSTOM_STATE.weights[k] = num;
});
if (Object.keys(_CUSTOM_STATE.weights).length > 0) {
_CUSTOM_STATE.weightsEnabled = true;
}
}
}
// ── Sprint 19 — Vue Pareto coût/qualité ─────────────────────────
let _paretoChart = null;
let _paretoAxis = 'cost';
function setParetoAxis(axis) {
_paretoAxis = axis;
document.querySelectorAll('.pareto-toggle').forEach(btn => {
btn.classList.toggle('active', btn.dataset.axis === axis);
});
renderParetoChart();
renderParetoAssumptions();
}
function _paretoAxisConfig(axis) {
const pareto = (DATA.pareto || {})[axis] || {};
const xKey = axis === 'cost' ? 'cost' : (axis === 'speed' ? 'dur' : 'co2');
const xLabel = pareto.axis_label ||
(I18N['pareto_axis_' + axis] || axis);
return { pareto, xKey, xLabel };
}
function renderParetoChart() {
const canvas = document.getElementById('pareto-chart');
if (!canvas || !window.Chart || !DATA.pareto) return;
const { pareto, xKey, xLabel } = _paretoAxisConfig(_paretoAxis);
const points = pareto.points || [];
const frontNames = new Set(pareto.front || []);
if (_paretoChart) { _paretoChart.destroy(); _paretoChart = null; }
if (points.length === 0) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#64748b';
ctx.font = '13px system-ui, sans-serif';
ctx.fillText(I18N.pareto_empty || 'Données insuffisantes pour cette vue.', 10, 30);
return;
}
const frontPts = points.filter(p => frontNames.has(p.engine));
const otherPts = points.filter(p => !frontNames.has(p.engine));
_paretoChart = new Chart(canvas.getContext('2d'), {
type: 'scatter',
data: {
datasets: [
{
label: I18N.pareto_front_label || 'Front Pareto',
data: frontPts.map(p => ({ x: p[xKey], y: p.cer * 100, engine: p.engine })),
backgroundColor: '#16a34a',
borderColor: '#166534',
pointRadius: 8,
pointHoverRadius: 10,
},
{
label: I18N.pareto_dominated_label || 'Dominés',
data: otherPts.map(p => ({ x: p[xKey], y: p.cer * 100, engine: p.engine })),
backgroundColor: '#94a3b8',
borderColor: '#64748b',
pointRadius: 6,
pointHoverRadius: 8,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: ctx => {
const p = ctx.raw;
return p.engine + ' — CER ' + p.y.toFixed(2) + ' %, ' +
xLabel + ' : ' + p.x.toFixed(2);
},
},
},
},
scales: {
x: {
type: _paretoAxis === 'cost' ? 'logarithmic' : 'linear',
title: { display: true, text: xLabel },
},
y: {
title: { display: true, text: I18N.col_cer || 'CER (%)' },
ticks: { callback: v => v + ' %' },
},
},
},
});
}
function renderParetoAssumptions() {
const ul = document.getElementById('pareto-assumptions-list');
if (!ul) return;
ul.innerHTML = '';
(DATA.engines || []).forEach(e => {
const c = e.cost || {};
const parts = [];
if (c.cost_per_1k_pages_eur != null) {
parts.push((c.cost_per_1k_pages_eur).toFixed(2) + ' €/1000 pages');
}
if (c.type) parts.push(c.type);
if (c.pricing_source_url) {
parts.push('' +
(c.pricing_date || 'source') + ' ');
}
const assumptions = (c.assumptions || []).join(' ');
const li = document.createElement('li');
li.innerHTML = '' + e.name + ' — ' + parts.join(' · ') +
(assumptions ? ' ' + assumptions + ' ' : '');
ul.appendChild(li);
});
}
// ── Sprint 7 — Mode présentation ────────────────────────────────
let presentMode = false;
function togglePresentMode() {
presentMode = !presentMode;
document.body.classList.toggle('present-mode', presentMode);
const btn = document.getElementById('btn-present');
if (btn) {
btn.classList.toggle('active', presentMode);
btn.textContent = presentMode ? '⊡ Normal' : '⊞ Présentation';
}
}
// ── Sprint 7 — Export CSV ────────────────────────────────────────
function _buildCSVRows(docs) {
const header = ['doc_id','engine','cer','wer','mer','wil','duration','ligature_score','diacritic_score','difficulty_score','gini','anchor_score','length_ratio','is_hallucinating'];
const rows = [header];
docs.forEach(doc => {
doc.engine_results.forEach(er => {
rows.push([
doc.doc_id,
er.engine,
er.cer !== null ? (er.cer * 100).toFixed(4) : '',
er.wer !== null ? (er.wer * 100).toFixed(4) : '',
er.mer !== null ? (er.mer * 100).toFixed(4) : '',
er.wil !== null ? (er.wil * 100).toFixed(4) : '',
er.duration !== null ? er.duration : '',
er.ligature_score !== null ? er.ligature_score : '',
er.diacritic_score !== null ? er.diacritic_score : '',
doc.difficulty_score !== undefined ? (doc.difficulty_score * 100).toFixed(2) : '',
er.line_metrics ? er.line_metrics.gini.toFixed(6) : '',
er.hallucination_metrics ? er.hallucination_metrics.anchor_score.toFixed(6) : '',
er.hallucination_metrics ? er.hallucination_metrics.length_ratio.toFixed(4) : '',
er.hallucination_metrics ? (er.hallucination_metrics.is_hallucinating ? '1' : '0') : '',
]);
});
});
return rows.map(r => r.map(v => JSON.stringify(String(v ?? ''))).join(',')).join('\n');
}
function _downloadCSV(content, filename) {
const blob = new Blob(['\ufeff' + content], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100);
}
function exportCSV() {
// Feuille 1 : tous les documents
const corpusSlug = DATA.meta.corpus_name.replace(/\s+/g,'-');
_downloadCSV(_buildCSVRows(DATA.documents), `picarones_metrics_${corpusSlug}.csv`);
// Feuille 2 : documents filtrés (exclusions robustes actives)
const cerThreshold = parseInt(document.getElementById('robust-cer').value) / 100;
const anchorThreshold = parseFloat(document.getElementById('robust-anchor').value);
const ratioThreshold = parseFloat(document.getElementById('robust-ratio').value);
const filteredDocs = DATA.documents.filter(doc => {
// Exclure si doc est dans _manualExclusions
if (_manualExclusions.has(doc.doc_id)) return false;
// Exclure si tous les moteurs le détectent comme problématique
return doc.engine_results.some(er => {
if (!er || er.error) return false;
if (cerThreshold < 1.0 && er.cer !== null && er.cer > cerThreshold) return false;
const hm = er.hallucination_metrics;
if (hm && hm.anchor_score < anchorThreshold) return false;
if (hm && hm.length_ratio > ratioThreshold) return false;
return true;
});
});
// Télécharger avec un délai pour ne pas bloquer le premier download
setTimeout(() => {
_downloadCSV(_buildCSVRows(filteredDocs), `picarones_metrics_${corpusSlug}_robust.csv`);
}, 400);
}
// ── Vue Caractères ───────────────────────────────────────────────
let charViewBuilt = false;
function initCharView() {
charViewBuilt = true;
// Remplir le sélecteur de moteur
const sel = document.getElementById('char-engine-select');
sel.innerHTML = '';
DATA.engines.forEach(e => {
const opt = document.createElement('option');
opt.value = e.name; opt.textContent = e.name;
sel.appendChild(opt);
});
renderCharView();
}
function renderCharView() {
const engineName = document.getElementById('char-engine-select').value;
const eng = DATA.engines.find(e => e.name === engineName);
if (!eng) return;
// Scores ligatures / diacritiques
const scoresRow = document.getElementById('char-scores-row');
const ligScore = eng.ligature_score;
const diacScore = eng.diacritic_score;
scoresRow.innerHTML = `
Ligatures ${_scoreBadge(ligScore, 'Ligatures')}
Diacritiques ${_scoreBadge(diacScore, 'Diacritiques')}
${eng.aggregated_structure ? `
Précision lignes ${_scoreBadge(eng.aggregated_structure.mean_line_accuracy, 'Précision nb lignes')}
Ordre lecture ${_scoreBadge(eng.aggregated_structure.mean_reading_order_score, 'Score ordre de lecture')}
` : ''}
${eng.aggregated_image_quality ? `
Qualité image moy. ${_scoreBadge(eng.aggregated_image_quality.mean_quality_score, 'Qualité image moyenne')}
` : ''}
`;
// Matrice de confusion heatmap
renderConfusionHeatmap(eng);
// Détail ligatures
renderLigatureDetail(eng);
// Taxonomie détaillée
renderTaxonomyDetail(eng);
}
function renderConfusionHeatmap(eng) {
const container = document.getElementById('confusion-heatmap');
const cm = eng.aggregated_confusion;
if (!cm || !cm.matrix) {
container.innerHTML = 'Aucune donnée de confusion disponible.
';
return;
}
// Collecter les top confusions (substitutions uniquement, hors ∅)
const pairs = [];
for (const [gt, ocrs] of Object.entries(cm.matrix)) {
if (gt === '∅') continue;
for (const [ocr, cnt] of Object.entries(ocrs)) {
if (ocr !== gt && ocr !== '∅' && cnt > 0) {
pairs.push({ gt, ocr, cnt });
}
}
}
pairs.sort((a,b) => b.cnt - a.cnt);
const top = pairs.slice(0, 30);
if (!top.length) {
container.innerHTML = 'Aucune substitution détectée.
';
return;
}
// Heatmap sous forme de tableau compact
const maxCnt = top[0].cnt;
const rows = top.map(p => {
const intensity = Math.round((p.cnt / maxCnt) * 200 + 55); // 55–255
const bg = `rgb(${intensity},50,50)`;
const fg = intensity > 150 ? '#fff' : '#222';
return `
${esc(p.gt)}
→
${esc(p.ocr)}
`;
}).join('');
container.innerHTML = `
Cliquer sur une ligne pour voir les exemples dans la vue Document.
Total substitutions : ${cm.total_substitutions}
· Insertions : ${cm.total_insertions}
· Suppressions : ${cm.total_deletions}
`;
}
function showConfusionExamples(gtChar, ocrChar) {
// Naviguer vers la vue Document en cherchant un exemple de cette confusion
showView('document');
const docWithConfusion = DATA.documents.find(doc =>
doc.engine_results.some(er => {
const h = er.hypothesis || '';
const g = doc.ground_truth || '';
return g.includes(gtChar) && h.includes(ocrChar);
})
);
if (docWithConfusion) loadDocument(docWithConfusion.doc_id);
}
function renderLigatureDetail(eng) {
const container = document.getElementById('ligature-detail');
// Agrégation sur tous les documents pour ce moteur
const ligData = {};
DATA.documents.forEach(doc => {
const er = doc.engine_results.find(r => r.engine === eng.name);
if (!er || !er.ligature_score) return;
// On n'a que le score global par doc; pour le détail, utiliser aggregated_char_scores
});
const agg = eng.aggregated_char_scores;
if (!agg || !agg.ligature || !agg.ligature.per_ligature) {
const overallScore = eng.ligature_score;
if (overallScore !== null && overallScore !== undefined) {
container.innerHTML = `Score global ligatures : ${_scoreBadge(overallScore, 'Ligatures')}
`;
} else {
container.innerHTML = 'Aucune donnée ligature disponible (pas de ligatures dans le corpus).
';
}
return;
}
const perLig = agg.ligature.per_ligature;
if (!Object.keys(perLig).length) {
container.innerHTML = 'Aucune ligature trouvée dans le corpus GT.
';
return;
}
const rows = Object.entries(perLig)
.sort((a,b) => b[1].gt_count - a[1].gt_count)
.map(([lig, d]) => {
const sc = d.score;
const color = sc >= 0.9 ? '#16a34a' : sc >= 0.7 ? '#ca8a04' : '#dc2626';
const barW = Math.round(sc * 120);
return `
${esc(lig)}
${esc(lig.codePointAt(0).toString(16).toUpperCase().padStart(4,'0'))}
${d.gt_count} GT
${d.ocr_correct} corrects
`;
}).join('');
container.innerHTML = `
Ligature
Unicode
GT
Corrects
Score
${rows}
`;
}
function renderTaxonomyDetail(eng) {
const container = document.getElementById('taxonomy-detail');
const tax = eng.aggregated_taxonomy;
if (!tax || !tax.counts) {
container.innerHTML = 'Aucune donnée taxonomique disponible.
';
return;
}
const classNames = {
visual_confusion: '1 — Confusion visuelle',
diacritic_error: '2 — Erreur diacritique',
case_error: '3 — Erreur de casse',
ligature_error: '4 — Ligature',
abbreviation_error: '5 — Abréviation',
hapax: '6 — Hapax',
segmentation_error: '7 — Segmentation',
oov_character: '8 — Hors-vocabulaire',
lacuna: '9 — Lacune',
};
const total = tax.total_errors || 1;
const maxCnt = Math.max(...Object.values(tax.counts));
const rows = Object.entries(tax.counts)
.filter(([, cnt]) => cnt > 0)
.sort((a,b) => b[1]-a[1])
.map(([cls, cnt]) => {
const pctVal = (cnt / total * 100).toFixed(1);
const barW = maxCnt > 0 ? Math.round(cnt/maxCnt * 200) : 0;
return `
${esc(classNames[cls] || cls)}
${cnt}
`;
}).join('');
container.innerHTML = `
Total : ${tax.total_errors} erreurs classifiées.
Classe
N
Proportion
${rows}
`;
}
// ── Init ────────────────────────────────────────────────────────
function applyI18n() {
// Applique les traductions aux éléments avec data-i18n (textContent)
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (I18N[key] !== undefined) el.textContent = I18N[key];
});
// Options de select avec data-i18n-opt
document.querySelectorAll('[data-i18n-opt]').forEach(el => {
const key = el.getAttribute('data-i18n-opt');
if (I18N[key] !== undefined) el.textContent = I18N[key];
});
// Tooltips des th via id
const thMap = {
'th-cer-diplo': 'col_cer_diplo_title',
'th-ligatures': 'col_ligatures_title',
'th-diacritics': 'col_diacritics_title',
'th-gini': 'col_gini_title',
'th-anchor': 'col_anchor_title',
'th-overnorm': 'col_overnorm_title',
};
Object.entries(thMap).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el && I18N[key]) el.title = I18N[key];
});
}
function init() {
// i18n
applyI18n();
// Méta nav
const d = new Date(DATA.meta.run_date);
const locale = I18N.date_locale || 'fr-FR';
const fmt = d.toLocaleDateString(locale, { year:'numeric', month:'short', day:'numeric' });
document.getElementById('nav-meta').textContent =
DATA.meta.corpus_name + ' · ' + fmt;
document.getElementById('footer-date').textContent =
(I18N.footer_generated || 'Rapport généré le') + ' ' + fmt;
// Sélecteur moteur galerie
const sel = document.getElementById('gallery-engine-select');
DATA.engines.forEach(e => {
const opt = document.createElement('option');
opt.value = e.name; opt.textContent = e.name;
sel.appendChild(opt);
});
renderRanking();
renderRobustMetrics();
renderGallery();
buildDocList();
renderParetoChart();
renderParetoAssumptions();
injectGlossaryButtons();
restoreCustomFromURL();
applyColumnVisibility();
applyStrataFilter();
applyCompositeScore();
// Restaurer l'état depuis l'URL
const { view, params } = readURLState();
if (view && view !== 'ranking') {
_switchView(view); // appel direct pour ne pas écraser l'URL
if (view === 'document' && params.doc) {
loadDocument(params.doc);
}
}
// Gérer le bouton retour
window.addEventListener('popstate', () => {
const { view: v, params: p } = readURLState();
_switchView(v || 'ranking');
if ((v === 'document') && p.doc) loadDocument(p.doc);
});
}
// ─── Sprint A6 (B-9) — accessibilité des graphiques Chart.js ──────────
//
// Les Chart.js ne sont **pas** accessibles aux lecteurs d'écran
// par défaut (le rendu est purement pixel). Pour respecter WCAG 1.1.1
// (Non-text Content) niveau A, on ajoute :
//
// 1. ``role="img"`` + ``aria-label`` (déjà posés statiquement dans le
// HTML via le helper Python ``_enrich_canvas_with_aria``) ;
// 2. une table de données jumelle générée à la demande à partir de
// l'instance Chart.js, avec un bouton "Voir les données" qui la
// révèle pour TOUS (utile aussi pour la copie / vérification).
//
// Cette fonction est idempotente : on peut l'appeler plusieurs fois
// sans dupliquer les boutons (test ``data-a11y-attached``).
function attachChartA11y() {
const canvases = document.querySelectorAll('canvas[data-a11y-label]');
canvases.forEach(canvas => {
if (canvas.dataset.a11yAttached === '1') return;
canvas.dataset.a11yAttached = '1';
const id = canvas.id;
if (!id) return;
// Bouton "Voir les données" en dessous du canvas.
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-toggle-data';
btn.setAttribute('data-i18n', 'view_data');
btn.textContent = (typeof I18N !== 'undefined' && I18N.view_data)
? I18N.view_data : 'Voir les données';
btn.setAttribute('aria-controls', id + '-data');
btn.setAttribute('aria-expanded', 'false');
// Conteneur de table (caché visuellement mais lu par les AT via
// aria-describedby ; révélé visuellement au clic via .is-revealed).
const wrapper = document.createElement('div');
wrapper.id = id + '-data';
wrapper.className = 'chart-data-table visually-hidden';
wrapper.setAttribute('role', 'region');
wrapper.setAttribute('aria-label',
((typeof I18N !== 'undefined' && I18N.chart_data_caption)
? I18N.chart_data_caption
: 'Données du graphique')
+ ' : ' + (canvas.dataset.a11yLabel || id));
// Lien aria-describedby pour que le lecteur d'écran annonce
// l'existence de la table dès qu'il atteint le canvas.
canvas.setAttribute('aria-describedby', wrapper.id);
btn.addEventListener('click', () => {
const expanded = wrapper.classList.toggle('is-revealed');
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
btn.textContent = expanded
? ((typeof I18N !== 'undefined' && I18N.hide_data) ? I18N.hide_data : 'Masquer les données')
: ((typeof I18N !== 'undefined' && I18N.view_data) ? I18N.view_data : 'Voir les données');
// Génération paresseuse du tableau au premier clic.
if (expanded && !wrapper.dataset.populated) {
_populateChartDataTable(wrapper, id);
wrapper.dataset.populated = '1';
}
});
canvas.parentElement.appendChild(btn);
canvas.parentElement.appendChild(wrapper);
});
}
function _populateChartDataTable(wrapper, canvasId) {
const chart = (typeof chartInstances !== 'undefined') ? chartInstances[canvasId] : null;
if (!chart || !chart.data) {
wrapper.innerHTML = '' +
((typeof I18N !== 'undefined' && I18N.chart_no_data)
? I18N.chart_no_data : 'Aucune donnée disponible')
+ '
';
return;
}
const labels = chart.data.labels || [];
const datasets = chart.data.datasets || [];
// En-tête : colonne libellé puis une colonne par dataset.
let html = '';
html += '— ';
datasets.forEach(ds => {
html += '' + esc(ds.label || '') + ' ';
});
html += '';
// Une ligne par label.
for (let i = 0; i < labels.length; i++) {
html += '' + esc(String(labels[i])) + ' ';
datasets.forEach(ds => {
const v = ds.data ? ds.data[i] : '';
html += '' + esc(String(v == null ? '' : v)) + ' ';
});
html += ' ';
}
// Cas particulier : pas de labels (scatter, radar) — on dump les datasets.
if (labels.length === 0 && datasets.length > 0) {
datasets.forEach(ds => {
html += '' + esc(ds.label || '') + ' ' +
esc(JSON.stringify(ds.data).slice(0, 200)) + ' ';
});
}
html += '
';
wrapper.innerHTML = html;
}
document.addEventListener('DOMContentLoaded', () => {
init();
_initPaletteFromURL();
// Délai pour laisser les charts s'instancier au switch de vue.
// Les boutons sont posés sur les canvas déjà visibles ; pour les
// canvas qui se créent au premier showView('analyses'), on rappelle
// attachChartA11y depuis showView aussi.
setTimeout(attachChartA11y, 200);
});