/* 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 = `
`;
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 '—';
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = `| Ningún jugador con los filtros actuales |
`;
return;
}
const CAP = 800;
body.innerHTML = data.slice(0, CAP).map(p => {
const confBadge = p.conf === 'low' ? 'baja' : '';
return `
| ${esc(p.j)} ${confBadge} |
${esc(p.p) || '—'} |
${esc(p.t)} |
${p.age != null ? Math.round(p.age) : '—'} |
${esc(p.nat) || '—'} |
${esc(p.c) || '—'} |
${p.min != null ? Math.round(p.min).toLocaleString() : '—'} |
${fmtMV(p.mv)} |
${fmtMV(p.mv_prev)} |
${SRC_LABEL[p.src] || p.src || '—'} |
`;
}).join('');
if (data.length > CAP) body.innerHTML += `| Mostrando ${CAP} de ${data.length}. Refiná con filtros o búsqueda. |
`;
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 `${esc(label)}
${typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(2)) : esc(v)}
`;
}
function drawerHTML(d) {
const tmLink = d.tm_url ? `Transfermarkt ↗` : '';
const confTag = d.match_conf === 'low' ? 'match sin verificar' : 'match verificado';
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 ? `` : '';
}).join('');
const rest = Object.entries(d.stats).filter(([k]) => !used.has(k)).sort((a, b) => a[0].localeCompare(b[0]));
const restHTML = rest.length ? `` : '';
const hist = (d.history || []).map(h =>
`| ${esc(h.Temporada)} | ${esc(h.Competencia)} | ${Math.round(h['Minutos totales'] || 0)} | ${fmtMV(h.mv_eos)} | ${fmtMV(h.mv_eos_prev)} |
`).join('');
const histHTML = hist ? `Historial por temporada
| Temp. | Liga | Min | Valor | Valor-1 |
${hist}
` : '';
const inj = (d.injuries || []);
const injHTML = inj.length ? `Historial de lesiones (${inj.length})
| Temp. | Lesión | Días | PJ perdidos |
${inj.map(x => `| ${esc(x.season)} | ${esc(x.injury)} | ${esc(x.days || x.days_out)} | ${esc(x.games_missed)} |
`).join('')}
` : '';
return `
${esc(d.jugador)}
${esc(d.equipo)} · ${esc(d.competencia)} · ${esc(d.season)}
${fmtMV(d.mv_eos)}
Valor fin temp.
${confTag}${SRC_LABEL[d.mv_source] || d.mv_source}${tmLink}
Valor de mercado
Fin temporada
${fmtMV(d.mv_eos)}
Temporada anterior
${fmtMV(d.mv_eos_prev)}
Datos
${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)}
${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 = 'Error cargando detalle
';
}
}
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 => ``).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 => ``).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 => ``).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();