435 lines
14 KiB
JavaScript
435 lines
14 KiB
JavaScript
(() => {
|
||
const state = {
|
||
overview: null,
|
||
compare: null,
|
||
charts: { bar: null, radar: null, catBar: null },
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const COLORS = [
|
||
'#d6a24a', '#5ec4a0', '#6aa8d6', '#d9584c',
|
||
'#8b7ec8', '#e0c36a', '#5ec4b0', '#c98a6a',
|
||
];
|
||
|
||
function setMsg(text, type = '') {
|
||
const el = $('scanMsg');
|
||
el.textContent = text || '';
|
||
el.className = `msg ${type}`.trim();
|
||
}
|
||
|
||
async function api(path) {
|
||
const res = await fetch(path);
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
throw new Error(data.detail || res.statusText || 'request failed');
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function selectedValues(containerId) {
|
||
return [...document.querySelectorAll(`#${containerId} input:checked`)].map((el) => el.value);
|
||
}
|
||
|
||
function renderModelList(models) {
|
||
const box = $('modelList');
|
||
box.innerHTML = '';
|
||
if (!models.length) {
|
||
box.innerHTML = '<div class="muted">未发现模型结果目录</div>';
|
||
return;
|
||
}
|
||
models.forEach((m, i) => {
|
||
const label = document.createElement('label');
|
||
label.className = 'pick-item';
|
||
label.innerHTML = `
|
||
<input type="checkbox" value="${m.folder}" checked />
|
||
<span class="pick-main">
|
||
<strong>${m.folder}</strong>
|
||
<small>${m.model_name} · ${m.n_benchmarks} benches</small>
|
||
</span>
|
||
<i class="swatch" style="background:${COLORS[i % COLORS.length]}"></i>
|
||
`;
|
||
box.appendChild(label);
|
||
});
|
||
}
|
||
|
||
function renderBenchList(categories, benchmarks) {
|
||
const box = $('benchList');
|
||
box.innerHTML = '';
|
||
if (!benchmarks.length) {
|
||
box.innerHTML = '<div class="muted">暂无 benchmark 结果</div>';
|
||
return;
|
||
}
|
||
const cats = categories && categories.length
|
||
? categories
|
||
: [{ id: 'all', name: '全部', items: benchmarks }];
|
||
|
||
cats.forEach((cat) => {
|
||
const section = document.createElement('div');
|
||
section.className = `pick-cat cat-${cat.id}`;
|
||
section.innerHTML = `
|
||
<div class="pick-cat-head">
|
||
<strong>${cat.name}</strong>
|
||
<span class="muted">${cat.items.length}</span>
|
||
<button type="button" class="ghost cat-all">全选</button>
|
||
<button type="button" class="ghost cat-none">清空</button>
|
||
</div>
|
||
`;
|
||
const grid = document.createElement('div');
|
||
grid.className = 'pick-grid';
|
||
cat.items.forEach((name) => {
|
||
const label = document.createElement('label');
|
||
label.className = 'pick-chip';
|
||
label.innerHTML = `<input type="checkbox" value="${name}" checked /><span>${name}</span>`;
|
||
grid.appendChild(label);
|
||
});
|
||
section.querySelector('.cat-all').addEventListener('click', () => {
|
||
grid.querySelectorAll('input').forEach((el) => { el.checked = true; });
|
||
});
|
||
section.querySelector('.cat-none').addEventListener('click', () => {
|
||
grid.querySelectorAll('input').forEach((el) => { el.checked = false; });
|
||
});
|
||
section.appendChild(grid);
|
||
box.appendChild(section);
|
||
});
|
||
}
|
||
|
||
function destroyCharts() {
|
||
Object.keys(state.charts).forEach((k) => {
|
||
if (state.charts[k]) {
|
||
state.charts[k].destroy();
|
||
state.charts[k] = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
function chartDatasets(series, type) {
|
||
return series.map((s, i) => {
|
||
const color = COLORS[i % COLORS.length];
|
||
const base = {
|
||
label: s.display,
|
||
data: s.scores.map((v) => (v == null ? null : Number((v * 100).toFixed(2)))),
|
||
borderColor: color,
|
||
backgroundColor: type === 'radar' ? color + '33' : color + 'cc',
|
||
tension: 0.25,
|
||
spanGaps: false,
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
};
|
||
if (type === 'bar') {
|
||
return { ...base, borderWidth: 0, borderRadius: 3 };
|
||
}
|
||
if (type === 'radar') {
|
||
return { ...base, fill: true, borderWidth: 2 };
|
||
}
|
||
return { ...base, fill: false, borderWidth: 2 };
|
||
});
|
||
}
|
||
|
||
function axisOptions(maxRotation = 45) {
|
||
return {
|
||
x: {
|
||
ticks: { color: '#9aa89d', maxRotation, minRotation: 0, font: { size: 11 } },
|
||
grid: { color: 'rgba(40,48,42,0.8)' },
|
||
},
|
||
y: {
|
||
min: 0,
|
||
max: 100,
|
||
ticks: { color: '#9aa89d', callback: (v) => v + '%', font: { size: 11 } },
|
||
grid: { color: 'rgba(40,48,42,0.8)' },
|
||
title: { display: true, text: 'Score (%)', color: '#9aa89d', font: { size: 11 } },
|
||
},
|
||
};
|
||
}
|
||
|
||
function markEmpty(el, msg) {
|
||
if (!el) return;
|
||
const wrap = el.parentElement || el;
|
||
wrap.classList.add('empty');
|
||
wrap.dataset.empty = msg;
|
||
}
|
||
|
||
function clearEmpty(el) {
|
||
if (!el) return;
|
||
const wrap = el.parentElement || el;
|
||
wrap.classList.remove('empty');
|
||
delete wrap.dataset.empty;
|
||
}
|
||
|
||
function makeChart(key, canvas, config) {
|
||
if (typeof Chart === 'undefined') {
|
||
markEmpty(canvas, 'Chart.js 未加载,请强制刷新页面 (Ctrl+F5)');
|
||
return null;
|
||
}
|
||
if (!canvas) return null;
|
||
try {
|
||
clearEmpty(canvas);
|
||
const chart = new Chart(canvas, config);
|
||
state.charts[key] = chart;
|
||
return chart;
|
||
} catch (err) {
|
||
console.error('chart error', key, err);
|
||
markEmpty(canvas, `图表渲染失败: ${err.message || err}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function renderCharts(compare) {
|
||
destroyCharts();
|
||
const labels = compare.benchmarks || [];
|
||
const common = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
labels: { color: '#c5d0c7', boxWidth: 10, font: { family: 'Inter', size: 12 } },
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label(ctx) {
|
||
const v = ctx.parsed.y ?? ctx.parsed.r;
|
||
return `${ctx.dataset.label}: ${v == null ? '—' : v.toFixed(2) + '%'}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {},
|
||
};
|
||
|
||
const catLabels = compare.category_labels || [];
|
||
const catSeries = compare.category_series || [];
|
||
const catBarCanvas = $('categoryBarChart');
|
||
if (!catLabels.length || !catSeries.length) {
|
||
markEmpty(catBarCanvas, '当前选择下无可用能力分类数据');
|
||
} else {
|
||
makeChart('catBar', catBarCanvas, {
|
||
type: 'bar',
|
||
data: { labels: catLabels, datasets: chartDatasets(catSeries, 'bar') },
|
||
options: {
|
||
...common,
|
||
scales: {
|
||
x: { ...axisOptions(0).x, grid: { display: false } },
|
||
y: axisOptions(0).y,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
if (!labels.length) {
|
||
markEmpty($('barChart'), '暂无 benchmark 得分');
|
||
} else {
|
||
makeChart('bar', $('barChart'), {
|
||
type: 'bar',
|
||
data: { labels, datasets: chartDatasets(compare.series, 'bar') },
|
||
options: {
|
||
...common,
|
||
scales: {
|
||
x: { ...axisOptions(45).x, grid: { display: false } },
|
||
y: axisOptions(45).y,
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const radarLabels = labels;
|
||
const canRadar = radarLabels.length >= 3 && (compare.series || []).length > 0;
|
||
const radarCanvas = $('radarChart');
|
||
if (!canRadar) {
|
||
markEmpty(radarCanvas, '共同 benchmark 不足 3 个,暂不绘制雷达图');
|
||
} else {
|
||
makeChart('radar', radarCanvas, {
|
||
type: 'radar',
|
||
data: { labels: radarLabels, datasets: chartDatasets(compare.series, 'radar') },
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { labels: { color: '#c5d0c7', boxWidth: 10 } },
|
||
},
|
||
scales: {
|
||
r: {
|
||
min: 0,
|
||
max: 100,
|
||
ticks: { color: '#9aa89d', backdropColor: 'transparent', stepSize: 20, font: { size: 10 } },
|
||
grid: { color: 'rgba(40,48,42,0.8)' },
|
||
angleLines: { color: 'rgba(40,48,42,0.8)' },
|
||
pointLabels: { color: '#c5d0c7', font: { size: 11 } },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderTable(compare) {
|
||
const thead = $('scoreTable').querySelector('thead');
|
||
const tbody = $('scoreTable').querySelector('tbody');
|
||
thead.innerHTML = '';
|
||
tbody.innerHTML = '';
|
||
|
||
const head = document.createElement('tr');
|
||
head.innerHTML = `<th>Benchmark</th>${compare.series.map((s) => `<th>${s.display}</th>`).join('')}<th>最佳</th>`;
|
||
thead.appendChild(head);
|
||
|
||
compare.benchmarks.forEach((b, i) => {
|
||
const tr = document.createElement('tr');
|
||
let best = -1;
|
||
let bestFolder = '—';
|
||
compare.series.forEach((s) => {
|
||
const v = s.scores[i];
|
||
if (v != null && v > best) {
|
||
best = v;
|
||
bestFolder = s.display;
|
||
}
|
||
});
|
||
const cells = compare.series.map((s) => {
|
||
const v = s.scores[i];
|
||
if (v == null) return '<td class="na">—</td>';
|
||
const pct = (v * 100).toFixed(2);
|
||
const isBest = v === best && best >= 0;
|
||
return `<td class="${isBest ? 'best' : ''}">${pct}%</td>`;
|
||
}).join('');
|
||
tr.innerHTML = `<td class="bench-col">${b}</td>${cells}<td class="best-name">${bestFolder}</td>`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function renderWinMatrix(compare) {
|
||
const table = $('winMatrixTable');
|
||
const thead = table.querySelector('thead');
|
||
const tbody = table.querySelector('tbody');
|
||
thead.innerHTML = '';
|
||
tbody.innerHTML = '';
|
||
|
||
const folders = compare.series.map((s) => s.folder);
|
||
if (folders.length < 2) {
|
||
tbody.innerHTML = '<tr><td class="na">至少选择 2 个模型才能生成胜负矩阵</td></tr>';
|
||
return;
|
||
}
|
||
const matrix = compare.win_matrix || {};
|
||
|
||
const head = document.createElement('tr');
|
||
head.innerHTML = `<th>模型</th>${folders.map((f) => `<th>${f}</th>`).join('')}`;
|
||
thead.appendChild(head);
|
||
|
||
folders.forEach((f) => {
|
||
const tr = document.createElement('tr');
|
||
const cells = folders.map((g) => {
|
||
const val = matrix[f]?.[g] ?? 0;
|
||
return `<td>${val}</td>`;
|
||
}).join('');
|
||
tr.innerHTML = `<td class="bench-col">${f}</td>${cells}`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function renderOverallRank(compare) {
|
||
const box = $('overallRank');
|
||
box.innerHTML = '';
|
||
const overall = compare.overall || [];
|
||
const wins = compare.win_counts || {};
|
||
if (!overall.length) {
|
||
box.innerHTML = '<div class="muted">无数据</div>';
|
||
return;
|
||
}
|
||
overall.forEach((item, idx) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'rank-card';
|
||
const pct = item.mean != null ? (item.mean * 100).toFixed(2) + '%' : '—';
|
||
const win = wins[item.folder] != null ? wins[item.folder].toFixed(1) : '0';
|
||
card.innerHTML = `
|
||
<div class="rank-row">
|
||
<span class="rank-idx">#${idx + 1}</span>
|
||
<span class="rank-name">${item.folder}</span>
|
||
<span class="rank-score">${pct}</span>
|
||
</div>
|
||
<small class="muted">${item.n} benches · ${win} 项第一</small>
|
||
`;
|
||
box.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderRanking(compare) {
|
||
const box = $('rankList');
|
||
box.innerHTML = '';
|
||
compare.ranking.forEach((item) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'rank-card';
|
||
const rows = item.rows.length
|
||
? item.rows.map((r, idx) => `
|
||
<div class="rank-row">
|
||
<span class="rank-idx">#${idx + 1}</span>
|
||
<span class="rank-name">${r.folder}</span>
|
||
<span class="rank-score">${(r.score * 100).toFixed(2)}%</span>
|
||
</div>`).join('')
|
||
: '<div class="muted">无数据</div>';
|
||
card.innerHTML = `<strong>${item.benchmark}</strong>${rows}`;
|
||
box.appendChild(card);
|
||
});
|
||
}
|
||
|
||
async function loadOverview() {
|
||
const dir = $('outputDir').value.trim();
|
||
const qs = dir ? `?output_dir=${encodeURIComponent(dir)}` : '';
|
||
setMsg('扫描中…');
|
||
try {
|
||
const data = await api(`/api/results/overview${qs}`);
|
||
state.overview = data;
|
||
if (!$('outputDir').value) $('outputDir').value = data.output_dir || '';
|
||
$('scanMeta').textContent = `${data.models.length} 个模型 · ${data.benchmarks.length} 个 benchmark · ${data.output_dir}`;
|
||
renderModelList(data.models);
|
||
renderBenchList(data.categories, data.benchmarks);
|
||
setMsg('扫描完成', 'ok');
|
||
await runCompare();
|
||
} catch (e) {
|
||
setMsg(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function runCompare() {
|
||
const models = selectedValues('modelList');
|
||
const benchmarks = selectedValues('benchList');
|
||
if (!models.length) {
|
||
setMsg('请至少选择一个模型', 'error');
|
||
return;
|
||
}
|
||
if (!benchmarks.length) {
|
||
setMsg('请至少选择一个 benchmark', 'error');
|
||
return;
|
||
}
|
||
const dir = $('outputDir').value.trim();
|
||
const params = new URLSearchParams({
|
||
models: models.join(','),
|
||
benchmarks: benchmarks.join(','),
|
||
});
|
||
if (dir) params.set('output_dir', dir);
|
||
const data = await api(`/api/results/compare?${params}`);
|
||
state.compare = data;
|
||
renderCharts(data);
|
||
renderTable(data);
|
||
renderWinMatrix(data);
|
||
renderOverallRank(data);
|
||
renderRanking(data);
|
||
setMsg(`已对比 ${data.series.length} 模型 × ${data.benchmarks.length} benches`, 'ok');
|
||
}
|
||
|
||
$('reloadBtn').addEventListener('click', () => loadOverview());
|
||
$('compareBtn').addEventListener('click', () => runCompare().catch((e) => setMsg(e.message, 'error')));
|
||
$('modelAllBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = true; });
|
||
});
|
||
$('modelClearBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('#modelList input').forEach((el) => { el.checked = false; });
|
||
});
|
||
$('benchAllBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = true; });
|
||
});
|
||
$('benchClearBtn').addEventListener('click', () => {
|
||
document.querySelectorAll('#benchList input').forEach((el) => { el.checked = false; });
|
||
});
|
||
|
||
api('/api/meta').then((meta) => {
|
||
$('outputDir').value = meta.defaults?.output_dir || '';
|
||
return loadOverview();
|
||
}).catch(() => loadOverview());
|
||
})();
|