Download static/app.js from RRC-ARG/scouting: direct link, hf CLI and curl.
- Browser
- Download file 16.5 kB
-
https://huggingface.co/spaces/RRC-ARG/scouting/resolve/main/static/app.js
- Command line
-
hf download hf://spaces/RRC-ARG/scouting/static/app.js
-
curl -L -o app.js https://huggingface.co/spaces/RRC-ARG/scouting/resolve/main/static/app.js
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(); | |