Commander Meta requires a running backend to browse and fetch EDHREC average decks.
' +
'
';
}
return;
}
// Lazy-load: if we haven't fetched commanders yet, do it now
if (metaCommanders.length === 0) {
const listEl = document.getElementById('lab-meta-list');
if (listEl) listEl.innerHTML = '
Loading commanders...
';
await loadMetaCommanders();
}
renderMetaList(metaCommanders);
document.getElementById('lab-meta-search')?.focus();
}
function closeMetaPicker() {
const modal = document.getElementById('lab-meta-modal');
if (modal) modal.classList.remove('active');
const statusEl = document.getElementById('lab-meta-import-status');
if (statusEl) statusEl.innerHTML = '';
}
async function filterMetaCommanders(query) {
if (!query) {
renderMetaList(metaCommanders);
return;
}
// When backend is available, use the live search endpoint
if (backendAvailable) {
try {
const res = await fetch(`${API_BASE}/api/lab/meta/search?q=${encodeURIComponent(query)}`);
if (res.ok) {
const data = await res.json();
renderMetaList(data.results || []);
return;
}
} catch (err) {
console.warn('[AI Lab] Meta search failed, falling back to local filter:', err);
}
}
// Fallback: local filter
const q = query.toLowerCase();
const filtered = metaCommanders.filter(c => c.name.toLowerCase().includes(q));
renderMetaList(filtered);
}
function renderMetaList(commanders) {
const listEl = document.getElementById('lab-meta-list');
if (!listEl) return;
if (commanders.length === 0) {
listEl.innerHTML = '
';
}
}
function drawTrendsChart(results, canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
const colors = ['#58a6ff', '#f85149', '#3fb950', '#a78bfa', '#fbbf24'];
const padding = { top: 30, right: 20, bottom: 40, left: 50 };
const w = canvas.width - padding.left - padding.right;
const h = canvas.height - padding.top - padding.bottom;
// Find max data points
let maxPoints = 0;
for (const r of results) {
maxPoints = Math.max(maxPoints, (r.history || []).length);
}
if (maxPoints < 2) return;
// Draw axes
ctx.strokeStyle = '#2a2d3a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padding.left, padding.top);
ctx.lineTo(padding.left, canvas.height - padding.bottom);
ctx.lineTo(canvas.width - padding.right, canvas.height - padding.bottom);
ctx.stroke();
// Y-axis labels (0-100%)
ctx.fillStyle = '#8b90a0';
ctx.font = '11px Inter, sans-serif';
ctx.textAlign = 'right';
for (let pct = 0; pct <= 100; pct += 25) {
const y = canvas.height - padding.bottom - (pct / 100) * h;
ctx.fillText(pct + '%', padding.left - 8, y + 4);
// Grid line
ctx.strokeStyle = '#1e2130';
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(canvas.width - padding.right, y);
ctx.stroke();
}
// Draw lines for each deck
results.forEach((data, di) => {
const history = data.history || [];
if (history.length < 2) return;
ctx.strokeStyle = colors[di % colors.length];
ctx.lineWidth = 2;
ctx.beginPath();
history.forEach((point, i) => {
const x = padding.left + (i / (maxPoints - 1)) * w;
const y = canvas.height - padding.bottom - (point.winRate / 100) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Draw points
ctx.fillStyle = colors[di % colors.length];
history.forEach((point, i) => {
const x = padding.left + (i / (maxPoints - 1)) * w;
const y = canvas.height - padding.bottom - (point.winRate / 100) * h;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
});
// Label
const lastPt = history[history.length - 1];
const lx = padding.left + ((history.length - 1) / (maxPoints - 1)) * w;
const ly = canvas.height - padding.bottom - (lastPt.winRate / 100) * h;
ctx.fillStyle = colors[di % colors.length];
ctx.textAlign = 'left';
ctx.font = '11px Inter, sans-serif';
ctx.fillText(data.deckName, lx + 6, ly + 4);
});
}
// ── Precon Refresh ──────────────────────────────────────
async function refreshPrecons() {
const statusEl = document.getElementById('lab-precon-status');
if (!backendAvailable) {
showImportStatus(statusEl, 'Precon refresh requires a running backend.', 'error');
return;
}
showImportStatus(statusEl, 'Refreshing precon database from GitHub...', 'loading');
try {
const res = await fetch(`${API_BASE}/api/lab/precons/refresh`, {
method: 'POST',
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || 'Refresh failed');
}
showImportStatus(statusEl,
data.message || ('Downloaded ' + data.total + ' precon decks'),
'success'
);
// Reload precon list
await loadPrecons();
renderPreconGrid(preconDecks);
} catch (err) {
showImportStatus(statusEl, 'Error: ' + err.message, 'error');
}
}
function switchTab(tabId) {
document.querySelectorAll('.lab-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
document.querySelectorAll('.lab-tab-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === tabId);
});
// Load data for analytics/trends tabs on demand
if (tabId === 'tab-analytics') loadAnalytics();
if (tabId === 'tab-trends') loadTrends();
}
// ── Charts & Visualizations ────────────────────────────
const SEAT_COLORS = ['#58a6ff', '#f85149', '#3fb950', '#d29922'];
const SEAT_COLORS_DIM = ['rgba(88,166,255,0.3)', 'rgba(248,81,73,0.3)', 'rgba(63,185,80,0.3)', 'rgba(210,153,34,0.3)'];
function renderCharts(result) {
const games = result.games || [];
const perDeck = result.summary.perDeck || [];
const decks = result.decks || [];
drawWinRateChart(perDeck, decks);
drawWinConditionChart(perDeck, decks);
drawTurnDistribution(games, decks);
drawWinRateTrend(games, decks);
}
function drawWinRateChart(perDeck, decks) {
const canvas = document.getElementById('lab-chart-winrate');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const pad = { top: 20, right: 20, bottom: 50, left: 50 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
const barW = Math.min(60, chartW / perDeck.length - 20);
// Axes
ctx.strokeStyle = '#30363d';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, h - pad.bottom);
ctx.lineTo(w - pad.right, h - pad.bottom);
ctx.stroke();
// Y-axis labels (0%, 25%, 50%, 75%, 100%)
ctx.fillStyle = '#8b949e';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'right';
for (let pct = 0; pct <= 100; pct += 25) {
const y = h - pad.bottom - (pct / 100) * chartH;
ctx.fillText(pct + '%', pad.left - 8, y + 4);
if (pct > 0) {
ctx.strokeStyle = 'rgba(48,54,61,0.5)';
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
}
}
// Bars
perDeck.forEach((ds, i) => {
const x = pad.left + (i + 0.5) * (chartW / perDeck.length) - barW / 2;
const barH = (ds.winRate) * chartH;
const y = h - pad.bottom - barH;
// Bar fill
ctx.fillStyle = SEAT_COLORS[ds.seatIndex] || SEAT_COLORS[0];
ctx.beginPath();
roundRect(ctx, x, y, barW, barH, 4);
ctx.fill();
// Value on top
ctx.fillStyle = '#c9d1d9';
ctx.font = 'bold 12px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText((ds.winRate * 100).toFixed(1) + '%', x + barW / 2, y - 6);
// Label
ctx.fillStyle = '#8b949e';
ctx.font = '10px -apple-system, sans-serif';
const label = (decks[ds.seatIndex]?.commanderName || ds.deckName || '').substring(0, 16);
ctx.fillText(label, x + barW / 2, h - pad.bottom + 14);
});
}
function drawWinConditionChart(perDeck, decks) {
const canvas = document.getElementById('lab-chart-wincond');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
// Aggregate win conditions across all decks
const totals = {};
perDeck.forEach(ds => {
const bd = ds.winConditionBreakdown || {};
Object.entries(bd).forEach(([k, v]) => {
if (v > 0) totals[k] = (totals[k] || 0) + v;
});
});
const entries = Object.entries(totals).filter(([_, v]) => v > 0).sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
ctx.fillStyle = '#8b949e';
ctx.font = '13px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('No win data', w / 2, h / 2);
return;
}
const total = entries.reduce((s, [_, v]) => s + v, 0);
const pieColors = ['#58a6ff', '#f85149', '#3fb950', '#d29922', '#a371f7', '#f0883e', '#8b949e', '#56d364'];
const cx = w * 0.35, cy = h / 2, radius = Math.min(cx - 20, cy - 20);
let startAngle = -Math.PI / 2;
const slices = [];
entries.forEach(([k, v], i) => {
const sliceAngle = (v / total) * Math.PI * 2;
ctx.fillStyle = pieColors[i % pieColors.length];
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
slices.push({ key: k, value: v, color: pieColors[i % pieColors.length], startAngle, endAngle: startAngle + sliceAngle });
startAngle += sliceAngle;
});
// Legend
const legendX = w * 0.65;
let legendY = 30;
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'left';
slices.forEach(s => {
ctx.fillStyle = s.color;
ctx.fillRect(legendX, legendY - 8, 10, 10);
ctx.fillStyle = '#c9d1d9';
const pct = ((s.value / total) * 100).toFixed(0);
ctx.fillText(`${formatWinCondition(s.key)} (${pct}%)`, legendX + 16, legendY);
legendY += 18;
});
}
function drawTurnDistribution(games, decks) {
const canvas = document.getElementById('lab-chart-turns');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (games.length === 0) return;
const pad = { top: 20, right: 20, bottom: 50, left: 50 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
// Build histogram buckets
const turns = games.map(g => g.totalTurns);
const minT = Math.min(...turns);
const maxT = Math.max(...turns);
const bucketSize = Math.max(1, Math.ceil((maxT - minT + 1) / 15));
const buckets = [];
for (let t = minT; t <= maxT; t += bucketSize) {
const count = turns.filter(v => v >= t && v < t + bucketSize).length;
buckets.push({ label: bucketSize === 1 ? '' + t : t + '-' + (t + bucketSize - 1), count });
}
const maxCount = Math.max(...buckets.map(b => b.count));
const barW = Math.max(4, chartW / buckets.length - 2);
// Axes
ctx.strokeStyle = '#30363d';
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, h - pad.bottom);
ctx.lineTo(w - pad.right, h - pad.bottom);
ctx.stroke();
// Y-axis
ctx.fillStyle = '#8b949e';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'right';
for (let i = 0; i <= 4; i++) {
const val = Math.round(maxCount * i / 4);
const y = h - pad.bottom - (i / 4) * chartH;
ctx.fillText(val, pad.left - 8, y + 3);
}
// Bars
buckets.forEach((b, i) => {
const x = pad.left + i * (chartW / buckets.length) + 1;
const barH = maxCount > 0 ? (b.count / maxCount) * chartH : 0;
const y = h - pad.bottom - barH;
ctx.fillStyle = '#58a6ff';
ctx.globalAlpha = 0.8;
ctx.beginPath();
roundRect(ctx, x, y, barW, barH, 2);
ctx.fill();
ctx.globalAlpha = 1;
// Label
if (buckets.length <= 20) {
ctx.fillStyle = '#8b949e';
ctx.font = '9px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(b.label, x + barW / 2, h - pad.bottom + 14);
}
});
// X-axis label
ctx.fillStyle = '#8b949e';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Turns', w / 2, h - 6);
}
function drawWinRateTrend(games, decks) {
const canvas = document.getElementById('lab-chart-wintrend');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (games.length < 2) return;
const pad = { top: 20, right: 20, bottom: 50, left: 50 };
const chartW = w - pad.left - pad.right;
const chartH = h - pad.top - pad.bottom;
// Compute cumulative win rate for each seat
const seatWins = [0, 0, 0, 0];
const seatRates = [[], [], [], []];
// Sample points (don't plot every game for large batches)
const step = Math.max(1, Math.floor(games.length / 100));
games.forEach((g, gi) => {
if (g.winningSeat !== null && g.winningSeat !== undefined) {
seatWins[g.winningSeat]++;
}
if (gi % step === 0 || gi === games.length - 1) {
const n = gi + 1;
for (let s = 0; s < 4; s++) {
seatRates[s].push({ x: gi, y: seatWins[s] / n });
}
}
});
// Axes
ctx.strokeStyle = '#30363d';
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, h - pad.bottom);
ctx.lineTo(w - pad.right, h - pad.bottom);
ctx.stroke();
// Y-axis labels
ctx.fillStyle = '#8b949e';
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'right';
for (let pct = 0; pct <= 100; pct += 25) {
const y = h - pad.bottom - (pct / 100) * chartH;
ctx.fillText(pct + '%', pad.left - 8, y + 3);
ctx.strokeStyle = 'rgba(48,54,61,0.3)';
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(w - pad.right, y);
ctx.stroke();
}
// Lines
const maxX = games.length - 1;
for (let s = 0; s < 4; s++) {
const points = seatRates[s];
if (points.length < 2) continue;
ctx.strokeStyle = SEAT_COLORS[s];
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, pi) => {
const px = pad.left + (p.x / maxX) * chartW;
const py = h - pad.bottom - p.y * chartH;
if (pi === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
ctx.lineWidth = 1;
}
// Legend
ctx.font = '10px -apple-system, sans-serif';
ctx.textAlign = 'left';
for (let s = 0; s < 4; s++) {
const lx = pad.left + 10 + s * 120;
ctx.fillStyle = SEAT_COLORS[s];
ctx.fillRect(lx, pad.top + 2, 12, 3);
ctx.fillStyle = '#8b949e';
const label = (decks[s]?.commanderName || decks[s]?.deckName || 'Deck ' + (s + 1)).substring(0, 14);
ctx.fillText(label, lx + 16, pad.top + 7);
}
// X-axis label
ctx.fillStyle = '#8b949e';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Games Played', w / 2, h - 6);
}
function roundRect(ctx, x, y, w, h, r) {
if (h <= 0) { ctx.rect(x, y, w, 0); return; }
r = Math.min(r, h / 2, w / 2);
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
}
// ── Head-to-Head Matrix ────────────────────────────────
function renderHeadToHead(result) {
const el = document.getElementById('lab-h2h-matrix');
if (!el) return;
const games = result.games || [];
const decks = result.decks || [];
if (games.length === 0) { el.innerHTML = '
No game data
'; return; }
// Compute pairwise: for each pair (A, B), how often did A win when both were in the game (always — 4-player pod)
// Show: when deck A wins, what % of those games vs each opponent
// Better metric: For each pair, count games where A wins and B doesn't (and vice versa)
const n = decks.length;
const h2h = Array.from({ length: n }, () => Array(n).fill(0));
const pairGames = Array.from({ length: n }, () => Array(n).fill(0));
games.forEach(g => {
for (let a = 0; a < n; a++) {
for (let b = 0; b < n; b++) {
if (a === b) continue;
pairGames[a][b]++;
if (g.winningSeat === a) h2h[a][b]++;
}
}
});
let html = '
vs
';
decks.forEach((d, i) => {
const name = (d.commanderName || d.deckName || 'Deck ' + (i + 1)).substring(0, 18);
html += `
${escapeHtml(name)}
`;
});
html += '
';
for (let a = 0; a < n; a++) {
const name = (decks[a].commanderName || decks[a].deckName || 'Deck ' + (a + 1)).substring(0, 18);
html += `
${escapeHtml(name)}
`;
for (let b = 0; b < n; b++) {
if (a === b) {
html += '
—
';
} else {
const wins = h2h[a][b];
const total = pairGames[a][b];
const rate = total > 0 ? (wins / total * 100).toFixed(1) : '0.0';
const rateNum = total > 0 ? wins / total : 0;
const cls = rateNum > 0.5 ? 'lab-h2h-winning' : rateNum < 0.3 ? 'lab-h2h-losing' : '';
html += `
${rate}%${wins}/${total}
`;
}
}
html += '
';
}
html += '
';
html += '
Reads as: row deck\'s win rate in games where column deck also played.
';
el.innerHTML = html;
}
// ── Combat Stats ──────────────────────────────────────
function renderCombatStats(result) {
const el = document.getElementById('lab-combat-stats');
if (!el) return;
const games = result.games || [];
const decks = result.decks || [];
if (games.length === 0) { el.innerHTML = '