| |
|
|
| 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(); |
| }); |
| } |
|
|
| |
| 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); }; }; |
|
|
| |
| 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| const SRC_LABEL = { tm_history: 'Histórico', tm_scraped: 'Scrape', tm_transfers: 'Traspasos' }; |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| }; |
| }); |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| 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'); }); |
| } |
|
|
| |
| 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(); |
|
|