scouting / static /app.js
matias-cataife's picture
browse MV db: static/app.js
1afdb4c verified
Raw
History Blame Contribute Delete
16.5 kB
/* Browsable MV database — Opta stats + Transfermarkt per-season market values. */
const API = {
meta: () => jfetch('/api/meta'),
rows: payload => jfetch('/api/rows', { method: 'POST', body: JSON.stringify(payload) }),
player: (pid, season) => jfetch(`/api/player/${encodeURIComponent(pid)}?season=${encodeURIComponent(season)}`),
authStatus: () => jfetch('/api/auth-status'),
login: pw => jfetch('/api/login', { method: 'POST', body: JSON.stringify({ password: pw }) }),
};
async function jfetch(url, opts = {}) {
const o = Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts);
const r = await fetch(url, o);
if (r.status === 401) {
await showLoginModal();
return jfetch(url, opts);
}
if (!r.ok) throw new Error(`${r.status} ${url}`);
return r.json();
}
function showLoginModal() {
return new Promise(resolve => {
let modal = document.getElementById('loginModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'loginModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:9999;';
modal.innerHTML = `<div style="background:var(--surface,#1a1a1a);padding:2rem;border-radius:12px;width:min(340px,90vw);">
<h3 style="margin:0 0 1rem;">Acceso</h3>
<input id="loginPw" type="password" placeholder="Contraseña" style="width:100%;padding:.6rem;margin-bottom:.6rem;box-sizing:border-box;">
<div id="loginErr" style="color:#e66;font-size:.8rem;min-height:1rem;"></div>
<button id="loginSubmit" style="width:100%;padding:.6rem;margin-top:.5rem;">Entrar</button></div>`;
document.body.appendChild(modal);
}
modal.style.display = 'flex';
const pw = modal.querySelector('#loginPw');
const err = modal.querySelector('#loginErr');
const handle = async () => {
err.textContent = '';
try {
await API.login(pw.value);
modal.style.display = 'none';
resolve();
} catch { err.textContent = 'Contraseña incorrecta'; }
};
modal.querySelector('#loginSubmit').onclick = handle;
pw.onkeydown = e => { if (e.key === 'Enter') handle(); };
pw.focus();
});
}
// ===== state =====
let META = { seasons: [], leagues: [], positions: [] };
const sel = { seasons: new Set(), leagues: new Set(), positions: new Set() };
let sort = { key: 'mv_eos', dir: 'desc' };
let rowsCache = [];
let searchVal = '';
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
// ===== formatters =====
function fmtMV(v) {
if (v == null) return '<span style="opacity:.4">—</span>';
if (v >= 1e6) return '€' + (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M';
if (v >= 1e3) return '€' + Math.round(v / 1e3) + 'K';
return '€' + v;
}
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const SRC_LABEL = { tm_history: 'Histórico', tm_scraped: 'Scrape', tm_transfers: 'Traspasos' };
// ===== build payload + refresh =====
function buildPayload() {
const num = id => { const v = document.getElementById(id).value; return v === '' ? null : Number(v); };
return {
seasons: sel.seasons.size ? [...sel.seasons] : null,
leagues: sel.leagues.size ? [...sel.leagues] : null,
positions: sel.positions.size ? [...sel.positions] : null,
age_min: num('ageMin'), age_max: num('ageMax'),
min_minutes: num('minMinutes') || 0,
foot: document.getElementById('footFilter').value || null,
mv_min_millions: num('mvMin'), mv_max_millions: num('mvMax'),
nationality: document.getElementById('natFilter').value.trim() || null,
conf: document.getElementById('confFilter').value || null,
sort: sort.key, sort_dir: sort.dir, limit: 3000,
};
}
const refresh = debounce(async () => {
try {
const res = await API.rows(buildPayload());
rowsCache = res.rows;
renderTable();
} catch (e) { console.error(e); }
}, 250);
// ===== table =====
function rowMatchesSearch(p, q) {
if (!q) return true;
return (p.j || '').toLowerCase().includes(q) || (p.e || '').toLowerCase().includes(q) || (p.c || '').toLowerCase().includes(q);
}
function renderTable() {
const body = document.getElementById('dataTableBody');
const q = searchVal.trim().toLowerCase();
const data = rowsCache.filter(p => rowMatchesSearch(p, q));
document.getElementById('tableShownCount').textContent = data.length;
document.getElementById('tableTotalCount').textContent = rowsCache.length;
if (!data.length) {
body.innerHTML = `<tr><td colspan="10" class="empty-state">Ningún jugador con los filtros actuales</td></tr>`;
return;
}
const CAP = 800;
body.innerHTML = data.slice(0, CAP).map(p => {
const confBadge = p.conf === 'low' ? '<span class="tag" style="opacity:.6;">baja</span>' : '';
return `<tr data-pid="${esc(p.pid)}" data-season="${esc(p.t)}">
<td>${esc(p.j)} ${confBadge}</td>
<td>${esc(p.p) || '—'}</td>
<td>${esc(p.t)}</td>
<td class="num">${p.age != null ? Math.round(p.age) : '—'}</td>
<td>${esc(p.nat) || '—'}</td>
<td>${esc(p.c) || '—'}</td>
<td class="num">${p.min != null ? Math.round(p.min).toLocaleString() : '—'}</td>
<td class="num">${fmtMV(p.mv)}</td>
<td class="num">${fmtMV(p.mv_prev)}</td>
<td><span class="tag">${SRC_LABEL[p.src] || p.src || '—'}</span></td>
</tr>`;
}).join('');
if (data.length > CAP) body.innerHTML += `<tr><td colspan="10" class="empty-state">Mostrando ${CAP} de ${data.length}. Refiná con filtros o búsqueda.</td></tr>`;
body.querySelectorAll('tr[data-pid]').forEach(tr => {
tr.onclick = () => openDrawer(tr.dataset.pid, tr.dataset.season);
});
}
function wireSort() {
document.querySelectorAll('#dataTable thead th.sortable').forEach(th => {
th.onclick = () => {
const key = th.dataset.key;
if (sort.key === key) sort.dir = sort.dir === 'desc' ? 'asc' : 'desc';
else { sort.key = key; sort.dir = ['Jugador', 'Posicion', 'Temporada', 'nationality', 'Competencia'].includes(key) ? 'asc' : 'desc'; }
document.querySelectorAll('#dataTable thead th').forEach(h => { h.classList.remove('sorted'); const a = h.querySelector('.sort-arrow'); if (a) a.textContent = ''; });
th.classList.add('sorted');
th.querySelector('.sort-arrow').textContent = sort.dir === 'desc' ? '↓' : '↑';
refresh();
};
});
}
// ===== drawer detail =====
const STAT_GROUPS = [
['Tiro / Gol', ['Goles_totales', 'xG_suma_tiros', 'Shots', 'Shots_on_target', 'big_chances', 'xG_por_tiro', 'goles_menos_xG', 'penaltis_recibidos']],
['Pase / Creación', ['asistencias', 'xA_sum', 'KeyPass', 'Pases_totales', 'pct_pases_progresivos_exito', 'pases_progresivos', 'Throughballs', 'big_chance_created', 'xT_sum']],
['Conducción / Regate', ['conducciones_intentadas', 'conducciones_exitosas', 'regates_intentados', 'regates_completados', 'distancia_total_conducciones']],
['Defensa', ['acciones_defensivas_totales', 'Intercepciones', 'tackles_totales', 'clearance_totales', 'BallRecovery', 'pct_Duelos_aereos_ganados', 'presiones']],
];
function statRow(label, v) {
if (v == null) return '';
return `<div class="info-item"><div class="info-item-label">${esc(label)}</div><div class="info-item-value">${typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(2)) : esc(v)}</div></div>`;
}
function drawerHTML(d) {
const tmLink = d.tm_url ? `<a href="${esc(d.tm_url)}" target="_blank" rel="noopener" style="font-size:.75rem;color:var(--accent,#5af);">Transfermarkt ↗</a>` : '';
const confTag = d.match_conf === 'low' ? '<span class="tag" style="opacity:.6;">match sin verificar</span>' : '<span class="tag">match verificado</span>';
const used = new Set();
const groups = STAT_GROUPS.map(([title, keys]) => {
const items = keys.map(k => { used.add(k); return statRow(k, d.stats[k]); }).filter(Boolean).join('');
return items ? `<div class="detail-section"><div class="detail-label">${title}</div><div class="info-grid">${items}</div></div>` : '';
}).join('');
const rest = Object.entries(d.stats).filter(([k]) => !used.has(k)).sort((a, b) => a[0].localeCompare(b[0]));
const restHTML = rest.length ? `<div class="detail-section"><div class="detail-label">Todas las métricas (${rest.length}) <input id="statSearch" placeholder="filtrar…" style="float:right;font-size:.7rem;padding:2px 6px;"></div><div class="info-grid" id="allStatsGrid">${rest.map(([k, v]) => statRow(k, v)).join('')}</div></div>` : '';
const hist = (d.history || []).map(h =>
`<tr><td>${esc(h.Temporada)}</td><td>${esc(h.Competencia)}</td><td>${Math.round(h['Minutos totales'] || 0)}</td><td>${fmtMV(h.mv_eos)}</td><td>${fmtMV(h.mv_eos_prev)}</td></tr>`).join('');
const histHTML = hist ? `<div class="detail-section"><div class="detail-label">Historial por temporada</div>
<table class="season-table"><thead><tr><th>Temp.</th><th>Liga</th><th>Min</th><th>Valor</th><th>Valor-1</th></tr></thead><tbody>${hist}</tbody></table></div>` : '';
const inj = (d.injuries || []);
const injHTML = inj.length ? `<div class="detail-section"><div class="detail-label">Historial de lesiones (${inj.length})</div>
<table class="season-table"><thead><tr><th>Temp.</th><th>Lesión</th><th>Días</th><th>PJ perdidos</th></tr></thead>
<tbody>${inj.map(x => `<tr><td>${esc(x.season)}</td><td>${esc(x.injury)}</td><td>${esc(x.days || x.days_out)}</td><td>${esc(x.games_missed)}</td></tr>`).join('')}</tbody></table></div>` : '';
return `
<div class="detail-section">
<div style="display:flex;justify-content:space-between;align-items:baseline;gap:1rem;">
<div><div style="font-weight:600;font-size:1.05rem;">${esc(d.jugador)}</div>
<div style="font-size:.78rem;color:var(--text-dim,#999);">${esc(d.equipo)} · ${esc(d.competencia)} · ${esc(d.season)}</div></div>
<div style="text-align:right;"><div style="font-size:1.3rem;font-weight:700;color:var(--accent,#5af);">${fmtMV(d.mv_eos)}</div>
<div style="font-size:.6rem;color:var(--text-muted,#777);text-transform:uppercase;">Valor fin temp.</div></div>
</div>
<div style="margin-top:.5rem;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;">${confTag}<span class="tag">${SRC_LABEL[d.mv_source] || d.mv_source}</span>${tmLink}</div>
</div>
<div class="detail-section">
<div class="detail-label">Valor de mercado</div>
<div class="info-grid">
<div class="info-item"><div class="info-item-label">Fin temporada</div><div class="info-item-value">${fmtMV(d.mv_eos)}</div></div>
<div class="info-item"><div class="info-item-label">Temporada anterior</div><div class="info-item-value">${fmtMV(d.mv_eos_prev)}</div></div>
<div class="info-item"><div class="info-item-label">Actual</div><div class="info-item-value">${fmtMV(d.mv_now)}</div></div>
</div>
</div>
<div class="detail-section">
<div class="detail-label">Datos</div>
<div class="info-grid">
${statRow('Edad', d.age != null ? Math.round(d.age) : null)}
${statRow('Nacionalidad', d.nationality)}
${statRow('Pie', d.foot)}
${statRow('Altura', d.height)}
${statRow('Posición', d.pos)}
${statRow('Minutos', d.minutos)}
${statRow('Partidos', d.partidos)}
${statRow('Contrato hasta', d.contract)}
</div>
</div>
${groups}${restHTML}${histHTML}${injHTML}`;
}
async function openDrawer(pid, season) {
const drawer = document.getElementById('tableDrawer');
document.getElementById('tableDrawerTitle').textContent = 'Cargando…';
document.getElementById('tableDrawerBody').innerHTML = '';
drawer.classList.add('open');
document.getElementById('drawerBackdrop').classList.add('open');
try {
const d = await API.player(pid, season);
document.getElementById('tableDrawerTitle').textContent = d.jugador;
document.getElementById('tableDrawerBody').innerHTML = drawerHTML(d);
const ss = document.getElementById('statSearch');
if (ss) ss.oninput = () => {
const q = ss.value.toLowerCase();
document.querySelectorAll('#allStatsGrid .info-item').forEach(it => {
it.style.display = it.querySelector('.info-item-label').textContent.toLowerCase().includes(q) ? '' : 'none';
});
};
} catch (e) {
document.getElementById('tableDrawerBody').innerHTML = '<div class="empty-state">Error cargando detalle</div>';
}
}
function closeDrawer() {
document.getElementById('tableDrawer').classList.remove('open');
document.getElementById('drawerBackdrop').classList.remove('open');
}
// ===== filter UI builders =====
function buildSeasonChips() {
const wrap = document.getElementById('seasonChips');
wrap.innerHTML = META.seasons.map(s => `<button type="button" class="season-chip" data-v="${esc(s)}">${esc(s)}</button>`).join('');
wrap.querySelectorAll('.season-chip').forEach(b => b.onclick = () => {
const v = b.dataset.v;
if (sel.seasons.has(v)) { sel.seasons.delete(v); b.classList.remove('active'); }
else { sel.seasons.add(v); b.classList.add('active'); }
refresh();
});
}
function buildPositionChips() {
const wrap = document.getElementById('positionChips');
wrap.innerHTML = META.positions.map(s => `<button type="button" class="position-chip" data-v="${esc(s)}">${esc(s)}</button>`).join('');
wrap.querySelectorAll('.position-chip').forEach(b => b.onclick = () => {
const v = b.dataset.v;
if (sel.positions.has(v)) { sel.positions.delete(v); b.classList.remove('active'); }
else { sel.positions.add(v); b.classList.add('active'); }
refresh();
});
}
function buildLeagueMs() {
const btn = document.getElementById('leagueMsBtn');
const panel = document.querySelector('#leagueMs .ms-panel');
const list = document.getElementById('leagueMsList');
const search = document.getElementById('leagueMsSearch');
const updateLabel = () => {
document.getElementById('leagueMsLabel').textContent = sel.leagues.size ? `${sel.leagues.size} ligas` : 'Todas';
};
const render = (q = '') => {
const items = META.leagues.filter(l => l.toLowerCase().includes(q.toLowerCase()));
list.innerHTML = items.map(l => `<label class="ms-option"><input type="checkbox" data-v="${esc(l)}" ${sel.leagues.has(l) ? 'checked' : ''}><span class="ms-option-label">${esc(l)}</span></label>`).join('');
list.querySelectorAll('input').forEach(cb => cb.onchange = () => {
const v = cb.dataset.v;
if (cb.checked) sel.leagues.add(v); else sel.leagues.delete(v);
updateLabel(); refresh();
});
};
btn.onclick = () => { const open = panel.classList.toggle('open'); btn.setAttribute('aria-expanded', open); if (open) render(search.value); };
search.oninput = () => render(search.value);
document.getElementById('leagueMsAll').onclick = () => { META.leagues.forEach(l => sel.leagues.add(l)); render(search.value); updateLabel(); refresh(); };
document.getElementById('leagueMsNone').onclick = () => { sel.leagues.clear(); render(search.value); updateLabel(); refresh(); };
document.addEventListener('click', e => { if (!document.getElementById('leagueMs').contains(e.target)) panel.classList.remove('open'); });
}
// ===== init =====
async function init() {
try { await API.authStatus(); } catch { }
META = await API.meta();
document.querySelector('.subtitle').textContent = `${META.n_players.toLocaleString()} jugadores · ${META.n_rows.toLocaleString()} player-seasons`;
document.getElementById('apiMeta').textContent = `${META.leagues.length} ligas · ${META.seasons.length} temporadas`;
buildSeasonChips();
buildPositionChips();
buildLeagueMs();
wireSort();
['ageMin', 'ageMax', 'mvMin', 'mvMax', 'minMinutes', 'natFilter'].forEach(id =>
document.getElementById(id).addEventListener('input', refresh));
['footFilter', 'confFilter'].forEach(id =>
document.getElementById(id).addEventListener('change', refresh));
document.getElementById('tableSearch').addEventListener('input', e => { searchVal = e.target.value; renderTable(); });
document.getElementById('tableDrawerClose').onclick = closeDrawer;
document.getElementById('drawerBackdrop').onclick = closeDrawer;
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(); });
refresh();
}
init();