/* 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 = `

Acceso

`; 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 ? `
${title}
${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 ? `
Todas las métricas (${rest.length})
${rest.map(([k, v]) => statRow(k, v)).join('')}
` : ''; 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
${hist}
Temp.LigaMinValorValor-1
` : ''; const inj = (d.injuries || []); const injHTML = inj.length ? `
Historial de lesiones (${inj.length})
${inj.map(x => ``).join('')}
Temp.LesiónDíasPJ perdidos
${esc(x.season)}${esc(x.injury)}${esc(x.days || x.days_out)}${esc(x.games_missed)}
` : ''; 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)}
Actual
${fmtMV(d.mv_now)}
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();