1/* ââââââââ Idioma ââââââââ */ 2const LANGS = ['es', 'en', 'ca', 'de', 'fr', 'it']; 3const LANG_FLAGS = { 4 es: '<svg viewBox="0 0 3 2"><rect width="3" height="2" fill="#c60b1e"/><rect y="0.5" width="3" height="1" fill="#ffc400"/></svg>', 5 en: '<svg viewBox="0 0 60 30"><rect width="60" height="30" fill="#012169"/><path d="M0,0 60,30 M60,0 0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 60,30 M60,0 0,30" stroke="#C8102E" stroke-width="3"/><path d="M30,0 V30 M0,15 H60" stroke="#fff" stroke-width="10"/><path d="M30,0 V30 M0,15 H60" stroke="#C8102E" stroke-width="6"/></svg>', 6 ca: '<svg viewBox="0 0 9 6"><rect width="9" height="6" fill="#fcdd09"/><g fill="#da121a"><rect y="0.67" width="9" height="0.67"/><rect y="2" width="9" height="0.67"/><rect y="3.33" width="9" height="0.67"/><rect y="4.67" width="9" height="0.67"/></g></svg>', 7 de: '<svg viewBox="0 0 3 2"><rect width="3" height="2" fill="#000"/><rect y="0.67" width="3" height="1.33" fill="#d00"/><rect y="1.33" width="3" height="0.67" fill="#ffce00"/></svg>', 8 fr: '<svg viewBox="0 0 3 2"><rect width="3" height="2" fill="#fff"/><rect width="1" height="2" fill="#002395"/><rect x="2" width="1" height="2" fill="#ed2939"/></svg>', 9 it: '<svg viewBox="0 0 3 2"><rect width="3" height="2" fill="#fff"/><rect width="1" height="2" fill="#009246"/><rect x="2" width="1" height="2" fill="#ce2b37"/></svg>', 10}; 11let lang = 'es'; 12try { 13 lang = localStorage.getItem('fm26-lang') || (navigator.language || 'es').slice(0, 2).toLowerCase(); 14} catch (e) {} 15if (!I18N[lang]) lang = 'en'; 16 17function t(key, params) { 18 let s = I18N[lang][key] ?? I18N.en[key] ?? key; 19 if (params) for (const [k, v] of Object.entries(params)) s = s.replace('{' + k + '}', v); 20 return s; 21} 22// Nombre de selección: traducción propia â nombre de paÃs localizado (Intl) â nombre del dataset 23const _dnCache = {}; 24function teamName(code) { 25 const tr = I18N[lang].teams[code]; 26 if (tr) return tr; 27 const iso = TEAM_ISO[code]; 28 if (iso) { 29 try { 30 const dn = (_dnCache[lang] = _dnCache[lang] || new Intl.DisplayNames([lang], { type: 'region' })).of(iso); 31 if (dn && dn !== iso) return dn; 32 } catch (e) {} 33 } 34 for (const tid in teams) if (teams[tid].code === code) return teams[tid].name; 35 return code; 36} 37 38function setLang(l) { 39 lang = l; 40 try { localStorage.setItem('fm26-lang', l); } catch (e) {} 41 applyStatic(); 42} 43 44function applyStatic() { 45 document.documentElement.lang = lang; 46 const set = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; }; 47 set('app-sub', t('sub')); 48 set('t-detail', t('detail')); 49 set('t-detail-sub', t('detailSub')); 50 set('t-world', t('world')); 51 set('t-world-sub', t('worldSub')); 52 set('teams-title', t('chooseTeam')); 53 set('t-matches', t('matchesM')); 54 set('t-matches-sub', t('matchesSub')); 55 set('matches-title', t('chooseMatch')); 56 set('t-search', t('searchM')); 57 set('t-compare', t('compareM')); 58 set('t-favs', t('favsM')); 59 set('t-records', t('recordsM')); 60 set('t-teamsrank', t('teamsRankM')); 61 set('t-quiz', t('quizM')); 62 set('t-report', t('reportM')); 63 set('t-eleven', t('elevenM')); 64 set('compare-title', t('compareM')); 65 set('favs-title', t('favsM')); 66 set('records-title', t('recordsTitle')); 67 set('teamsrank-title', t('teamsRankM')); 68 set('quiz-title', t('quizM')); 69 const si = document.getElementById('search-input'); 70 if (si) si.placeholder = t('searchPh'); 71 set('source-splash', t('source')); 72 set('source-home', t('source')); 73 set('home-stats', t('statsLine', { 74 p: Object.keys(players).length, s: Object.keys(teams).length, m: Object.keys(APP_DATA.matches).length, 75 })); 76 set('t-install', t('installBtn')); 77 // Selector de mundial: el botón muestra el torneo al que se puede saltar 78 const other = tour === 'wc26' ? 'wc22' : 'wc26'; 79 set('t-tour', tour === 'wc26' ? t('seePast') : t('seeCurrent')); 80 set('t-tour-sub', t('tourSub', { 81 m: Object.keys(TOURNAMENTS[other].data.matches).length, 82 p: Object.keys(AGG[other].players).length, 83 })); 84 const tl = document.getElementById('tour-logo'); 85 if (tl) tl.src = TOURNAMENTS[other].logo; 86 set('brand-year', TOURNAMENTS[tour].short);
87 const row = document.getElementById('lang-row'); 88 if (row) row.innerHTML = LANGS.map(l => 89 `<button class="${l === lang ? 'on' : ''}" onclick="setLang('${l}')">${LANG_FLAGS[l]} ${l.toUpperCase()}</button>`).join(''); 90} 91 92/* ââââââââ Tema dÃa/noche ââââââââ */ 93let theme = 'dark'; 94try { theme = localStorage.getItem('fm26-theme') || 'dark'; } catch (e) {} 95 96function applyTheme() { 97 try { 98 document.documentElement.setAttribute('data-theme', theme); 99 const meta = document.querySelector('meta[name="theme-color"]'); 100 if (meta) meta.setAttribute('content', theme === 'light' ? '#f2f5fa' : '#0b1426'); 101 document.querySelectorAll('.js-theme').forEach(b => { b.innerHTML = navIcon(theme === 'dark' ? 'moon' : 'sun'); }); 102 } catch (e) {} 103} 104function toggleTheme() { 105 theme = theme === 'dark' ? 'light' : 'dark'; 106 try { localStorage.setItem('fm26-theme', theme); } catch (e) {} 107 applyTheme(); 108} 109 110// Coloca el botón dÃa/noche en la barra superior de TODAS las pantallas (como en Datos FÃsicos) 111function injectThemeButtons() { 112 document.querySelectorAll('.topbar').forEach(bar => { 113 if (bar.querySelector('.js-theme')) return; 114 let actions = bar.querySelector('.bar-actions'); 115 if (!actions) { 116 actions = document.createElement('span'); 117 actions.className = 'bar-actions'; 118 bar.appendChild(actions); 119 } 120 const b = document.createElement('button'); 121 b.className = 'iconbtn js-theme'; 122 b.setAttribute('onclick', 'toggleTheme()'); 123 b.setAttribute('aria-label', 'theme'); 124 b.innerHTML = navIcon(theme === 'dark' ? 'moon' : 'sun'); 125 actions.appendChild(b); 126 }); 127} 128 129/* ââââââââ Aviso de apoyo en Ko-fi (primera vez, luego cada 3 dÃas) ââââââââ */ 130const KOFI_URL = 'https://ko-fi.com/fifaphy'; 131const DONATE_REMIND_MS = 3 * 24 * 60 * 60 * 1000; 132function maybeShowDonateModal() { 133 let firstTime = true, next = 0; 134 try { 135 if (localStorage.getItem('fm26-donate-never') === '1') return; 136 firstTime = !localStorage.getItem('fm26-donate-shown'); 137 next = parseInt(localStorage.getItem('fm26-donate-next') || '0', 10); 138 } catch (e) { return; } 139 if (!firstTime && Date.now() < next) return; 140 document.getElementById('donate-title').textContent = firstTime ? t('donateTitleFirst') : t('donateTitleRepeat'); 141 document.getElementById('donate-body').textContent = firstTime ? t('donateBodyFirst') : t('donateBodyRepeat'); 142 document.getElementById('donate-kofi').textContent = t('donateKofi'); 143 document.getElementById('donate-later').textContent = t('donateLater'); 144 document.getElementById('donate-never').textContent = t('donateNever'); 145 document.getElementById('donate-modal').classList.add('open'); 146} 147function donateAction(kind) { 148 try { 149 if (kind === 'never') { 150 localStorage.setItem('fm26-donate-never', '1'); 151 } else { 152 if (kind === 'kofi') window.open(KOFI_URL, '_blank'); 153 localStorage.setItem('fm26-donate-next', String(Date.now() + DONATE_REMIND_MS)); 154 } 155 localStorage.setItem('fm26-donate-shown', '1'); 156 } catch (e) {} 157 document.getElementById('donate-modal').classList.remove('open'); 158} 159 160/* ââââââââ Instalación PWA ââââââââ */ 161let deferredPrompt = null; 162if (typeof window.addEventListener === 'function') { 163 window.addEventListener('beforeinstallprompt', e => { 164 e.preventDefault(); 165 deferredPrompt = e; 166 const b = document.getElementById('install-btn'); 167 if (b) b.style.display = 'flex'; 168 }); 169 window.addEventListener('appinstalled', () => { 170 const b = document.getElementById('install-btn'); 171 if (b) b.style.display = 'none'; 172 }); 173} 174async function installApp() { 175 if (!deferredPrompt) return; 176 deferredPrompt.prompt(); 177 await deferredPrompt.userChoice; 178 deferredPrompt = null; 179 document.getElementById('install-btn').style.display = 'none'; 180} 181 182/* ââââââââ Configuración de métricas ââââââââ */ 183const METRICS = [ 184 { key: 'TotalDistance', icon: 'ð', type: 'dist', per90: true, agg: 'sum' }, 185 { key: 'DistanceHighSpeedSprinting', icon: 'ð', type: 'dist', per90: true, agg: '
185sum' }, 186 { key: 'DistanceHighSpeedRunning', icon: 'ð', type: 'dist', per90: true, agg: 'sum' }, 187 { key: 'DistanceLowSpeedSprinting', icon: 'ð¨', type: 'dist', per90: true, agg: 'sum' }, 188 { key: 'DistanceJogging', icon: 'ð¦µ', type: 'dist', per90: true, agg: 'sum' }, 189 { key: 'DistanceWalking', icon: 'ð¶', type: 'dist', per90: true, agg: 'sum' }, 190 { key: 'Sprints', icon: 'â¡', type: 'count', per90: true, agg: 'sum' }, 191 { key: 'SpeedRuns', icon: 'ð¥', type: 'count', per90: true, agg: 'sum' }, 192 { key: 'TopSpeed', icon: 'ðï¸', type: 'speed', per90: false, agg: 'max' }, 193 { key: 'AvgSpeed', icon: 'ð', type: 'speed', per90: false, agg: 'wavg' }, 194 { key: 'TimePlayed', icon: 'â±ï¸', type: 'min', per90: false, agg: 'sum' }, 195]; 196 197/* Catálogo combinado: métricas fÃsicas (METRICS) + de juego (GAME_METRICS de catalog.js). 198 Se usa para agregar TODAS las métricas por jugador y para los modos Juego e Informe. */ 199const GAME_LIST = typeof GAME_METRICS !== 'undefined' ? GAME_METRICS : []; 200const OPTA_LIST = typeof OPTA_METRICS !== 'undefined' ? OPTA_METRICS : []; 201const AGG_METRICS = METRICS.concat(GAME_LIST, OPTA_LIST); 202const CATALOG = {}; 203for (const d of AGG_METRICS) CATALOG[d.key] = d; 204function metricDef(key) { return CATALOG[key]; } 205// Etiqueta de métrica: fÃsicas vÃa i18n (m.<key>) · de juego vÃa catálogo (es/en, fallback en) 206function metricLabel(key) { 207 const d = CATALOG[key]; 208 if (d && d.lab) return d.lab[lang] || d.lab.en || key; 209 return t('m.' + key); 210} 211// Etiqueta corta para radar/cards: r.<key> si existe, si no la 1ª palabra de la etiqueta 212function shortLabel(key) { 213 const d = CATALOG[key]; 214 if (d && d.lab) { 215 const full = d.lab[lang] || d.lab.en || key; 216 return full.length <= 14 ? full : full.split(/[ ·(]/)[0]; 217 } 218 const r = I18N[lang]['r.' + key] || I18N.en['r.' + key]; 219 return r || metricLabel(key); 220} 221 222/* ââââââââ Iconos monoline (portada y hubs) â un solo set de trazo unificado ââââââââ */ 223const NAV_ICONS = { 224 calendar: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9h18M8 3v4M16 3v4"/></svg>', 225 target: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="4.2"/><circle cx="12" cy="12" r=".6" fill="currentColor"/></svg>', 226 run: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="5" r="2"/><path d="M9 22l1-7-3-2 1-5 4-2 4 2 1 5-3 2 1 7"/></svg>', 227 gamepad: '<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="10" rx="5"/><circle cx="8" cy="13" r="1.2" fill="currentColor"/><path d="M16 11v4M14 13h4"/></svg>', 228 doc: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 3h9l3 3v15H6z"/><path d="M15 3v3h3M9 12h6M9 16h6"/></svg>', 229 star: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 3l2.6 5.7 6.2.6-4.7 4.1 1.4 6.1L12 16.8 6.5 19.5l1.4-6.1-4.7-4.1 6.2-.6z"/></svg>', 230 trophy: '<svg class="ic" viewBox="0 0 24 24"><path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 5H5a3 3 0 0 0 3 3M16 5h3a3 3 0 0 1-3 3M10 14v3h4v-3"/><path d="M8 21h8"/></svg>', 231 whistle: '<svg class="ic" viewBox="0 0 24 24"><path d="M3 9a5 5 0 0 1 5-5h7a4 4 0 0 1 0 8h-3"/><circle cx="9" cy="12" r="4"/><path d="M9 16v2"/></svg>', 232 scatter: '<svg class="ic" viewBox="0 0 24 24"><circle cx="5" cy="17" r="1.5" fill="currentColor" stroke="none"/><circle cx="10" cy="9" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="14" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="6" r="1.5" fill="currentColor" stroke="none"/><path d="M3 21h18"/></svg>', 233 card: '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="3" width="12" height="18" rx="2"/></svg>', 234 clock: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>', 235 drop: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 3c4 5 7 8.5 7 12a7 7 0 1 1-14 0c0-3.5 3-7 7-12Z"/></svg>', 236 user: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.5 3.5-7 8-7s8 2.5 8 7"/></svg>',
237 shield: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6Z"/></svg>', 238 ball: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7.5l3.5 2.5-1.3 4h-4.4l-1.3-4z"/><path d="M12 7.5V4M15.5 10l3-2M8.5 10l-3-2M10.2 16l-1.3 3.5M13.8 16l1.3 3.5"/></svg>', 239 table: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 10h18M9 10v9"/></svg>', 240 pitch: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M12 4v16"/><circle cx="12" cy="12" r="3.2"/></svg>', 241 live: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2.2" fill="currentColor" stroke="none"/><path d="M8.3 8.3a5.2 5.2 0 0 1 7.4 0M5.3 5.3a9.4 9.4 0 0 1 13.4 0"/></svg>', 242 check: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.7 2.7L16.5 9"/></svg>', 243 hourglass: '<svg class="ic" viewBox="0 0 24 24"><path d="M6.5 3h11M6.5 21h11M7.5 3c0 4.2 4.5 6.8 4.5 9s-4.5 4.8-4.5 9M16.5 3c0 4.2-4.5 6.8-4.5 9s4.5 4.8 4.5 9"/></svg>', 244 globe: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a13 13 0 0 1 0 18 13 13 0 0 1 0-18Z"/></svg>', 245 vs: '<svg class="ic" viewBox="0 0 24 24"><path d="M10 5L4 12l6 7M14 5l6 7-6 7"/></svg>', 246 search: '<svg class="ic" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M19.5 19.5l-4.4-4.4"/></svg>', 247 swords: '<svg class="ic" viewBox="0 0 24 24"><path d="M3 21l7-7M21 3l-7 7M14 14l7 7M10 10L3 3"/></svg>', 248 quiz: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.7 2.2c-.7.4-1.2 1-1.2 1.8v.5"/><circle cx="12" cy="17" r=".6" fill="currentColor" stroke="none"/></svg>', 249 phone: '<svg class="ic" viewBox="0 0 24 24"><rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M10 19h4"/></svg>', 250 users: '<svg class="ic" viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.6 2.7-5.8 6-5.8s6 2.2 6 5.8"/><circle cx="17" cy="9" r="2.6"/><path d="M16 14.5c2.6.3 4.5 2.2 5 5.5"/></svg>', 251 share: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 15V4M8 8l4-4 4 4"/><path d="M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6"/></svg>', 252 close: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>', 253 copy: '<svg class="ic" viewBox="0 0 24 24"><rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></svg>', 254 download: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 3v12M8 11l4 4 4-4"/><path d="M5 17v2a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-2"/></svg>', 255 replay: '<svg class="ic" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 0 2.6-5.9"/><path d="M3 4v5h5"/></svg>', 256 expand: '<svg class="ic" viewBox="0 0 24 24"><path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"/></svg>', 257 radar: '<svg class="ic" viewBox="0 0 24 24"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z"/><path d="M12 3v18M4.5 7.5l15 9M19.5 7.5l-15 9"/></svg>', 258 sequence: '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="9.5" width="5" height="5" rx="1"/><rect x="16" y="9.5" width="5" height="5" rx="1"/><path d="M8 12h8M13.5 9.5L16 12l-2.5 2.5"/></svg>', 259 flag: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 3v18"/><path d="M6 4h12l-3 4 3 4H6"/></svg>', 260 foot: '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3c-1.5 0-2.5 1.3-2.5 3 0 2-1.5 2.7-2 4.5-.6 2.2.3 4 2 5 1.8 1 3.3 1 5.5 1 2.7 0 4.5-1 4.5-3 0-2.5-2-2.5-3.5-4S12 5 11 4 10 3 9 3Z"/></svg>', 261 glove: '<svg class="ic" viewBox="0 0 24 24"><path d="M8 11V5a1.5 1.5 0 0 1 3 0v5M11 10V4a1.5 1.5 0 0 1 3 0v6M14 10.5V5a1.5 1.5 0 0 1 3 0v7"/><path d="M17 9.5a1.5 1.5 0 0 1 3 0V14c0 4-3 7-7 7s-7-2-7-6v-3c0-1.4 1.1-2.5 2.5-2.5S11 10.6 11 12"/></svg>', 262 arrow: '<svg class="ic" viewBox="0 0 24 24"><path d="M6 18L18 6M9 6h9v9"/></svg>', 263 swap: '<svg class="ic" viewBox="0 0 24 24"><path d="M3 9h14M13 5l4 4-4 4"/><path d="M21 15H7M11 11l-4 4 4 4"/></svg>', 264 moon: '<svg class="ic" viewBox="0 0 24 24"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4 7 7 0 0 0 20 14.5Z"/></svg>',
265 sun: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5"/><path d="M12 3v2.5M12 18.5V21M3 12h2.5M18.5 12H21M5.6 5.6l1.8 1.8M16.6 16.6l1.8 1.8M5.6 18.4l1.8-1.8M16.6 7.4l1.8-1.8"/></svg>', 266}; 267function navIcon(key) { return NAV_ICONS[key] || NAV_ICONS.target; } 268 269/* ââââââââ Posiciones (0 POR · 1 DEF · 2 MED · 3 DEL · 4 sin posición asignada) ââââââââ */ 270const POSITIONS = [0, 1, 2, 3]; // filtrables y alineables 271const POS_ALL = [0, 1, 2, 3, 4]; // incluye "sin posición" (presente en Qatar 2022) 272const POS_ICONS = ['ð§¤', 'ð¡ï¸', 'âï¸', 'ð¯', 'ð']; 273function posName(p) { return t('pos' + p); } 274function posShort(p) { return t('posS' + p); } 275 276const TEAM_ISO = { 277 MEX: 'MX', RSA: 'ZA', KOR: 'KR', CZE: 'CZ', QAT: 'QA', ECU: 'EC', IRN: 'IR', SEN: 'SN', 278 NED: 'NL', USA: 'US', KSA: 'SA', ARG: 'AR', TUN: 'TN', DEN: 'DK', POL: 'PL', FRA: 'FR', 279 AUS: 'AU', MAR: 'MA', CRO: 'HR', JPN: 'JP', GER: 'DE', CRC: 'CR', ESP: 'ES', CAN: 'CA', 280 BEL: 'BE', CMR: 'CM', SUI: 'CH', URU: 'UY', GHA: 'GH', POR: 'PT', BRA: 'BR', SRB: 'RS', 281 // Resto de selecciones plausibles del Mundial 2026 (código FIFA â ISO-2) 282 BIH: 'BA', ITA: 'IT', UKR: 'UA', NOR: 'NO', SWE: 'SE', AUT: 'AT', TUR: 'TR', GRE: 'GR', 283 IRL: 'IE', SVK: 'SK', SVN: 'SI', HUN: 'HU', ROU: 'RO', ALB: 'AL', GEO: 'GE', FIN: 'FI', 284 ISL: 'IS', MKD: 'MK', MNE: 'ME', KOS: 'XK', 285 COL: 'CO', CHI: 'CL', PER: 'PE', PAR: 'PY', VEN: 'VE', BOL: 'BO', 286 PAN: 'PA', HON: 'HN', GUA: 'GT', JAM: 'JM', HAI: 'HT', CUB: 'CU', SLV: 'SV', TRI: 'TT', 287 NGA: 'NG', EGY: 'EG', ALG: 'DZ', CIV: 'CI', MLI: 'ML', BFA: 'BF', GAB: 'GA', COD: 'CD', 288 ZAM: 'ZM', ANG: 'AO', CPV: 'CV', GUI: 'GN', KEN: 'KE', UGA: 'UG', 289 UZB: 'UZ', IRQ: 'IQ', JOR: 'JO', UAE: 'AE', CHN: 'CN', IDN: 'ID', THA: 'TH', VIE: 'VN', 290 OMA: 'OM', BHR: 'BH', PRK: 'KP', KGZ: 'KG', SYR: 'SY', LBN: 'LB', PLE: 'PS', 291 NZL: 'NZ', FIJ: 'FJ', 292}; 293// Naciones sin ISO-2 propio: bandera por secuencia de subdivisión Unicode 294// ENG/WAL/SCO: emoji de subdivisión (no se ve en Windows) · CUW: 'CU' darÃa Cuba. 295// Todos estos usan la imagen FIFA (flags-sq-1) en HTML; el emoji solo es para canvas. 296const FLAG_SPECIAL = { ENG: 'ð´ó §ó ¢ó ¥ó ®ó §ó ¿', WAL: 'ð´ó §ó ¢ó ·ó ¬ó ³ó ¿', SCO: 'ð´ó §ó ¢ó ³ó £ó ´ó ¿', CUW: 'ð¨ð¼' }; 297 298// Glifo emoji (para canvas y comparaciones simples) 299function flagGlyph(code) { 300 if (FLAG_SPECIAL[code]) return FLAG_SPECIAL[code]; 301 const iso = TEAM_ISO[code] || code.slice(0, 2); 302 return String.fromCodePoint(...[...iso].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); 303} 304// Para HTML: siempre imagen de la FIFA â los emojis de bandera (incluso los de 305// paÃs normales como ðªð¸) no se renderizan como bandera en Windows/Chrome de 306// escritorio (solo el código de dos letras). Si la imagen falla, cae al emoji. 307function flagEmoji(code) { 308 return `<img class="flag-mini" src="${flagUrl(code, 1)}" alt="${code}" loading="lazy" onerror="this.outerHTML='<span class="flag-emoji">${flagGlyph(code)}</span>'">`; 309} 310function flagUrl(code, size) { 311 return `https://api.fifa.com/api/v3/picture/flags-sq-${size || 3}/${code}`; 312} 313 314/* ââââââââ Torneos: agregación por jugador (suma de todos sus partidos) ââââââââ */ 315let tour = 'wc26'; 316let APP_DATA, players, teams, recsByMatch, recsByPlayer; 317const AGG = {}; 318 319function aggregate(data) { 320 const players = {}, teams = {}, recsByMatch = {}, recsByPlayer = {}; 321 for (const r of data.records) { 322 (recsByMatch[r.matchId] = recsByMatch[r.matchId] || []).push(r); 323 (recsByPlayer[r.playerId] = recsByPlayer[r.playerId] || []).push(r); 324 325 if (!teams[r.teamId]) teams[r.teamId] = { id: r.teamId, name: r.teamName, code: r.teamCode, matches: new Set(), squad: new Set() }; 326 teams[r.teamId].matches.add(r.matchId); 327 teams[r.teamId].squad.add(r.playerId); 328 329 let p = players[r.playerId]; 330 if (!p) { 331 p = players[r.playerId] = { 332 id: r.playerId, name: r.name, teamId: r.teamId, teamName: r.teamName, 333 teamCode: r.teamCode, photo: r.photo, minutes: 0, matches: 0, m: {}, _wavg: {}, 334 posMin: {}, mainPos: r.position, 335 }; 336 } 337 const mins = r.metrics.TimePlayed || 0; 338 p.minutes += mins; 339 p.matches += 1; 340 // Minutos por posición (+épsilon para registrar la posición aunque no jugara) 341 p.posMin[r.position] = (p.posMin[r.position] || 0) + mins + 0.001; 342 for (const def of AGG_METRICS) { 343 const v = r.metrics[def.key] ?? 0;
344 if (def.agg === 'max') p.m[def.key] = Math.max(p.m[def.key] || 0, v); 345 else if (def.agg === 'wavg') p._wavg[def.key] = (p._wavg[def.key] || 0) + v * mins; 346 else p.m[def.key] = (p.m[def.key] || 0) + v; 347 } 348 } 349 for (const p of Object.values(players)) { 350 for (const def of AGG_METRICS.filter(d => d.agg === 'wavg')) { 351 p.m[def.key] = p.minutes > 0 ? p._wavg[def.key] / p.minutes : 0; 352 } 353 delete p._wavg; 354 // Posición principal: la de más minutos; posList: todas las jugadas, ordenadas 355 p.posList = Object.keys(p.posMin).map(Number).sort((a, b) => p.posMin[b] - p.posMin[a]); 356 p.mainPos = p.posList[0]; 357 // Posición REAL (alineación del feed): por partido + la más usada. Los filtros siguen usando mainPos (0-3). 358 // Si el jugador salió en el once pero no llegó a jugar (lesión previa al pitido, etc.) no tiene posición real útil 359 const pr = (p.minutes > 0 && typeof POSREAL !== 'undefined' && POSREAL.players) ? POSREAL.players[p.id] : null; 360 p.posReal = pr ? pr.main : null; // p. ej. 'RB', 'DMF', 'RW' 361 p.posRealByMatch = pr ? pr.m : null; // { matchId: label } 362 p.posRealLine = pr ? pr.line : null; // 0-3 derivada de la posición real 363 // Rating tipo SofaScore/FotMob (modelo propio, aproximado): media y por partido 364 const rt = (typeof RATINGS !== 'undefined' && RATINGS.byPlayer) ? RATINGS.byPlayer[p.id] : null; 365 p.rating = rt ? rt.avg : null; 366 p.ratingByMatch = rt ? rt.m : null; // { matchId: rating } 367 } 368 return { players, teams, recsByMatch, recsByPlayer }; 369} 370 371/* ââââââââ Nota fÃsica 0-100: media de percentiles por 90' vs su posición ââââââââ */ 372const SCORE_KEYS = [ 373 'TotalDistance', 'DistanceHighSpeedRunning', 'DistanceHighSpeedSprinting', 374 'Sprints', 'SpeedRuns', 'TopSpeed', 'AvgSpeed', 375]; 376function scoreTier(s) { return s >= 75 ? 'gold' : s >= 60 ? 'silver' : 'bronze'; } 377// Color del rating (estilo SofaScore): verde alto, ámbar medio, rojo bajo 378function rtTier(r) { return r >= 7.5 ? 'rt-hot' : r >= 6.5 ? 'rt-ok' : 'rt-low'; } 379// Sub-rating 0-100: barra coloreada 380function subColor(v) { return v >= 70 ? 'var(--acc)' : v >= 45 ? 'var(--warn)' : '#e9707a'; } 381function subBar(label, val) { 382 if (val == null) return ''; 383 return `<div class="sub-rat"><span class="sr-l">${label}</span><span class="sr-bar"><span style="width:${val}%;background:${subColor(val)}"></span></span><span class="sr-v">${val}</span></div>`; 384} 385 386function computeScores(playersMap) { 387 const eligible = Object.values(playersMap).filter(p => p.minutes >= 45); 388 const byPos = {}; 389 for (const pos of POS_ALL) { 390 const pool = eligible.filter(p => p.mainPos === pos); 391 byPos[pos] = { _n: pool.length }; 392 for (const key of SCORE_KEYS) { 393 const def = METRICS.find(d => d.key === key); 394 byPos[pos][key] = pool.map(x => metricValue(x, def, true)); 395 } 396 } 397 for (const p of Object.values(playersMap)) { 398 if (p.minutes <= 0) { p.score = null; p.pcts = null; continue; } 399 const pools = byPos[p.mainPos]; 400 const pcts = {}; 401 let sum = 0; 402 for (const key of SCORE_KEYS) { 403 const def = METRICS.find(d => d.key === key); 404 pcts[key] = pools._n > 1 ? percentile(pools[key], metricValue(p, def, true)) : 50; 405 sum += pcts[key]; 406 } 407 p.pcts = pcts; 408 p.score = Math.round(sum / SCORE_KEYS.length); 409 } 410} 411 412/* ââââââââ Sub-ratings 0-100 (fÃsico, ofensivo, creación, defensivo) ââââââââ */ 413// FÃsico: métricas FIFA de distancia/velocidad, MÃS peso a las distancias duras. 414const FIS_W = { DistanceHighSpeedSprinting: .30, Sprints: .20, DistanceHighSpeedRunning: .20, 415 SpeedRuns: .12, TopSpeed: .08, AvgSpeed: .05, TotalDistance: .05 }; 416// Ofensivo / Creación / Defensivo: stats agregadas (stats_json) por 90'. 417const OFF_W = { goals: .30, ontargetScoringAtt: .22, totalScoringAtt: .14, bigChanceScored: .12, touchesInOppBox: .12, shotCreated: .10 }; 418const CRE_W = { totalAttAssist: .25, goalAssist: .20, bigChanceCreated: .18, putThrough: .12, successfulFinalThirdPasses: .10, progressiveCarries: .08, totalCross: .07 }; 419const DEF_W = { wonTackle: .18, interception: .18, totalClearance: .14, duelWon: .10, ballRecovery: .12, possWonDef3rd: .10, aerialWon: .07, possWonMid3rd: .06, outfielderBlock: .03, blockedPass: .02 }; 420 421function computeSubRatings(playersMap) { 422 const list = Object.values(playersMap); 423 for (const p of list) { 424 const rb = (typeof RATINGS !== 'undefined' && RATINGS.byPlayer) ? RATINGS.byPlayer[p.id] : null; 425 p.statAgg = rb && rb.s ? rb.s : null; 426 p.statMin = rb && rb.smin ? rb.smin : 0; 427 } 428 const elig = list.filter(p => p.minutes >= 45); 429 const fisDef = {}; for (const k in FIS_W) fisDef[k] = METRICS.find(d => d.key === k); 430 const fisPools = {}; for (const k in FIS_W) fisPools[k] = elig.map(x => metricValue(x, fisDef[k], true)); 431 const seli = list.filter(p => p.statMin >= 45 && p.statAgg); 432 const sp = key => seli.map(x => (x.statAgg[key] || 0) / x.statMin * 90); 433 const offP = {}, creP = {}, defP = {}; 434 for (const k in OFF_W) offP[k] = sp(k); 435 for (const k in CRE_W) creP[k] = sp(k); 436 for (const k in DEF_W) defP[k] = sp(k); 437 const wmean = (W, pools, val) => { 438 let s = 0, w = 0; 439 for (const k in W) { s += W[k] * percentile(pools[k], val(k)); w += W[k]; } 440 return Math.round(s / w); 441 }; 442 for (const p of list) { 443 p.rFis = p.minutes >= 45 ? wmean(FIS_W, fisPools, k => metricValue(p, fisDef[k], true)) : null; 444 if (p.statMin >= 45 && p.statAgg) { 445 const sval = k => (p.statAgg[k] || 0) / p.statMin * 90; 446 p.rOff = wmean(OFF_W, offP, sval); 447 p.rCre = wmean(CRE_W, creP, sval); 448 p.rDef = wmean(DEF_W, defP, sval); 449 } else { p.rOff = p.rCre = p.rDef = null; } 450 } 451} 452 453for (const id of Object.keys(TOURNAMENTS)) { 454 AGG[id] = aggregate(TOURNAMENTS[id].data); 455 computeScores(AGG[id].players); 456 computeSubRatings(AGG[id].players); 457} 458 459/* Nota de una actuación concreta: percentiles vs todas las actuaciones (â¥45') de su posición */ 460const _matchPools = {}; 461function recPer90(r, key) { 462 const def = METRICS.find(d => d.key === key); 463 const mins = r.metrics.TimePlayed || 0; 464 const v = r.metrics[key] || 0; 465 return def.per90 && mins > 0 ? (v / mins) * 90 : v; 466} 467function getMatchPools(tid) { 468 if (_matchPools[tid]) return _matchPools[tid]; 469 const recs = TOURNAMENTS[tid].data.records.filter(r => (r.metrics.TimePlayed || 0) >= 45); 470 const byPos = {}; 471 for (const pos of POS_ALL) { 472 const pool = recs.filter(r => r.position === pos); 473 byPos[pos] = { _n: pool.length }; 474 for (const key of SCORE_KEYS) byPos[pos][key] = pool.map(r => recPer90(r, key)); 475 } 476 return (_matchPools[tid] = byPos); 477} 478function recScore(r) { 479 if ((r.metrics.TimePlayed || 0) < 45) return null; 480 const pools = getMatchPools(tour)[r.position]; 481 let sum = 0; 482 for (const key of SCORE_KEYS) sum += pools._n > 1 ? percentile(pools[key], recPer90(r, key)) : 50; 483 return Math.round(sum / SCORE_KEYS.length); 484} 485 486function setTour(id) { 487 tour = id; 488 APP_DATA = TOURNAMENTS[id].data; 489 ({ players, teams, recsByMatch, recsByPlayer } = AGG[id]); 490 Object.assign(state, { teamId: null, matchId: null, playerId: null, metric: null, posFilter: -1, cmpA: null, cmpB: null, searchPick: null, xiMatch: null }); 491} 492// Re-enlaza los datos del torneo activo SIN resetear el estado/navegación 493// (se usa tras integrar en memoria partidos en directo recién acabados) 494function rebindTour() { 495 APP_DATA = TOURNAMENTS[tour].data; 496 ({ players, teams, recsByMatch, recsByPlayer } = AGG[tour]); 497} 498function toggleTour() { setTour(tour === 'wc26' ? 'wc22' : 'wc26'); applyStatic(); goHome(); } 499 500// Jugador presente (con minutos) en ambos mundiales â datos cruzados 501function crossData(pid) { 502 const q = AGG.wc22.players[pid], n = AGG.wc26.players[pid]; 503 return q && n && q.minutes > 0 && n.minutes > 0 ? { q, n } : null; 504} 505// Marca ð para distinguir a los jugadores con datos en ambos mundiales 506function crossMark(pid) { return crossData(pid) ? 'ð ' : ''; } 507 508/* ââââââââ Estado y navegación ââââââââ */ 509const state = { 510 flow: 'team', teamId: null, metric: null, view: 'p90', 511 playerId: null, pview: 'p90', playerBack: 'ranking', matchId: null, 512 posFilter: -1, 513}; 514setTour('wc26'); 515 516/* ââ Navegación con historial del navegador: el botón "atrás" (Android/gesto) 517 vuelve a la pantalla anterior en vez de salir de la app ââ */ 518let _navCurrent = null; 519let _navFromPop = false;
520 521function show(id) { 522 if (_navCurrent === 'livematch' && id !== 'livematch' && typeof stopLiveTimer === 'function') stopLiveTimer(); 523 if (_navCurrent === 'bracket' && id !== 'bracket' && typeof _bracketStopTimer === 'function') _bracketStopTimer(); 524 document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); 525 document.getElementById('screen-' + id).classList.add('active'); 526 window.scrollTo(0, 0); 527 if (!_navFromPop && id !== _navCurrent) { 528 history.pushState({ screen: id }, '', location.href); 529 } 530 _navCurrent = id; 531 _navFromPop = false; 532} 533 534window.addEventListener('popstate', e => { 535 _navFromPop = true; 536 show((e.state && e.state.screen) || 'modes'); 537}); 538 539function goHome() { state.featCtx = 'phys'; show('home'); if (typeof renderRecordAlert === 'function') renderRecordAlert(); } 540// Vuelta contextual: a la home de Juego o a la fÃsica según desde dónde se entró 541function backFeat() { state.featCtx === 'game' ? goGameHome() : goHome(); } 542function goTeams() { state.flow = 'team'; renderTeams(); show('teams'); } 543function goWorld() { state.flow = 'world'; state.teamId = null; renderMetrics(); show('metrics'); } 544function goMetrics() { renderMetrics(); show('metrics'); } 545function backFromMetrics() { state.flow === 'team' ? goTeams() : goHome(); } 546function pickTeam(id) { state.flow = 'team'; state.teamId = id; renderMetrics(); show('metrics'); } 547function pickMetric(key) { 548 state.metric = key; 549 state.rankBack = null; 550 const def = metricDef(key); 551 state.view = def.per90 ? 'p90' : 'total'; 552 state.posFilter = -1; 553 renderRanking(); 554 show('ranking'); 555} 556function setPosFilter(p) { state.posFilter = p; renderRanking(); } 557 558/* ââââââââ Filtro de minutos mÃnimos (por defecto â¥45') ââââââââ */ 559let minMinutes = 45; 560try { minMinutes = parseInt(localStorage.getItem('fm26-minmin') ?? '45', 10); } catch (e) {} 561function setMinMinutes(v) { 562 minMinutes = v; 563 try { localStorage.setItem('fm26-minmin', String(v)); } catch (e) {} 564 renderRanking(); 565} 566// Con mÃnimo 0 se exige al menos haber jugado (>0') 567function passesMinutes(mins) { return minMinutes > 0 ? mins >= minMinutes : mins > 0; } 568let minMatches = 1; 569try { minMatches = parseInt(localStorage.getItem('fm26-minmatches') ?? '1', 10); } catch (e) {} 570function setMinMatches(v) { 571 minMatches = v; 572 try { localStorage.setItem('fm26-minmatches', String(v)); } catch (e) {} 573 renderRanking(); 574} 575function setView(v) { state.view = v; renderRanking(); } 576function openPlayer(id, from) { 577 state.playerId = id; state.pview = 'p90'; state.playerBack = from || 'ranking'; 578 renderPlayer(); show('player'); 579} 580function backFromPlayer() { show(state.playerBack); } 581function setPView(v) { state.pview = v; renderPlayer(); } 582function goMatches() { renderMatches(); show('matches'); } 583function openMatch(id, from) { state.matchId = id; state.matchBack = from || 'matches'; renderMatch(); show('match'); } 584function backFromMatch() { show(state.matchBack || 'matches'); } 585function pickMatchMetric(key) { 586 state.flow = 'match'; state.metric = key; state.rankBack = null; 587 state.view = 'total'; // en un partido lo natural es el dato real del partido 588 state.posFilter = -1; 589 renderRanking(); show('ranking'); 590} 591function backFromRanking() { 592 if (state.rankBack === 'gamemetrics') return show('gamemetrics'); 593 if (state.rankBack === 'leaders') return show('leaders'); 594 state.flow === 'match' ? show('match') : goMetrics(); 595} 596 597/* ââââââââ Formato de valores ââââââââ */ 598function fmtValue(def, v, isP90) { 599 switch (def.type) { 600 case 'dist': return v >= 1000 ? (v / 1000).toFixed(2) + ' km' : Math.round(v) + ' m'; 601 case 'speed': return v.toFixed(1) + ' km/h'; 602 case 'count': return isP90 ? v.toFixed(1) : String(Math.round(v)); 603 case 'min': return Math.round(v) + "'"; 604 case 'dec': return v.toFixed(2); 605 case 'pct': return Math.round(v * 100) + '%'; 606 } 607} 608 609function avatarHTML(p) { 610 const initials = p.name.split(/\s+/).map(w => w[0]).slice(0, 2).join(''); 611 return `<span class="avatar">${initials}<img src="${p.photo}" alt="" loading="lazy" 612 onerror="this.remove()"></span>`;
613} 614 615/* ââââââââ Renderizado ââââââââ */ 616function renderTeams() { 617 const list = Object.values(teams) 618 .sort((a, b) => teamName(a.code).localeCompare(teamName(b.code), lang)); 619 document.getElementById('teams-list').innerHTML = list.map(tm => ` 620 <button class="card-item" onclick="pickTeam('${tm.id}')"> 621 <img class="flag" src="${flagUrl(tm.code)}" alt="" 622 onerror="this.outerHTML='<span class="flag-emoji">${flagGlyph(tm.code)}</span>'"> 623 <span class="ctxt"><b>${teamName(tm.code)}</b><small>${tm.matches.size} ${tm.matches.size > 1 ? t('match_pl') : t('match_sg')} · ${tm.squad.size} ${t('players_w')}</small></span> 624 <span class="teamfav" onclick="toggleTeamFav(event, '${tm.id}', 'teams')">${favTeams.includes(tm.id) ? 'â' : 'â'}</span> 625 </button>`).join(''); 626} 627 628function renderMetrics() { 629 const team = state.teamId ? teams[state.teamId] : null; 630 document.getElementById('metrics-title').innerHTML = 631 team ? `${flagEmoji(team.code)} ${teamName(team.code)}` : `ð ${t('worldTitle')}`; 632 document.getElementById('metrics-list').innerHTML = METRICS.map(d => ` 633 <button class="card-item" onclick="pickMetric('${d.key}')"> 634 <span class="micon">${d.icon}</span> 635 <span class="ctxt"><b>${t('m.' + d.key)}</b><small>${d.per90 ? t('per90Total') : t('realOnly')}</small></span> 636 <span class="chev">âº</span> 637 </button>`).join(''); 638} 639 640function fmtMatchDate(iso) { 641 try { 642 return new Intl.DateTimeFormat(lang, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) 643 .format(new Date(iso)); 644 } catch (e) { return iso.slice(0, 10); } 645} 646// Marcador con sufijo "(d.c.p.)" si el ganador se decidió en penaltis 647function fmtMatchScore(m) { 648 const s = `${m.homeScore ?? '?'} â ${m.awayScore ?? '?'}`; 649 return m.winner ? `${s} (${t('scorePen')})` : s; 650} 651function matchLabel(m) { return `${m.home} ${m.homeScore}â${m.awayScore} ${m.away}`; } 652 653function setMatchTeam(c) { state.matchTeam = c; renderMatches(); } 654function renderMatches() { 655 document.getElementById('matches-title').textContent = t('chooseMatch'); 656 const all = Object.entries(APP_DATA.matches).sort((a, b) => a[1].date.localeCompare(b[1].date)); 657 // selector de selección para filtrar 658 const codes = [...new Set(all.flatMap(([, m]) => [m.home, m.away]))].sort((a, b) => teamName(a).localeCompare(teamName(b))); 659 const sel = state.matchTeam || ''; 660 const teamSel = `<select class="xiselect" onchange="setMatchTeam(this.value)" style="width:100%;margin-bottom:10px"> 661 <option value="">${t('ratAllTeams')}</option> 662 ${codes.map(c => `<option value="${c}" ${c === sel ? 'selected' : ''}>${flagEmoji(c)} ${teamName(c)}</option>`).join('')}</select>`; 663 const list = sel ? all.filter(([, m]) => m.home === sel || m.away === sel) : all; 664 document.getElementById('matches-list').innerHTML = teamSel + list.map(([id, m]) => ` 665 <button class="match-card" onclick="openMatch('${id}', 'matches')"> 666 <span class="mc-row"> 667 <span class="mc-team"><span class="flag-emoji">${flagEmoji(m.home)}</span><b>${m.home}</b></span> 668 <span class="mc-score">${fmtMatchScore(m)}</span> 669 <span class="mc-team"><span class="flag-emoji">${flagEmoji(m.away)}</span><b>${m.away}</b></span> 670 </span> 671 <span class="mc-sub">${[fmtMatchDate(m.date), m.stage, m.group].filter(Boolean).join(' · ')}</span> 672 </button>`).join('') + 673 (list.length ? '' : `<p class="empty-note">${t('liveNoData')}</p>`); 674} 675 676function renderMatch() { 677 const m = APP_DATA.matches[state.matchId]; 678 const recs = recsByMatch[state.matchId] || []; 679 const played = recs.filter(r => (r.metrics.TimePlayed || 0) > 0) 680 .sort((a, b) => (b.metrics.TimePlayed || 0) - (a.metrics.TimePlayed || 0)); 681 const excluded = recs.length - played.length; 682 683 document.getElementById('match-title').textContent = matchLabel(m); 684 685 // MVP fÃsico: mayor distancia total del partido 686 const mvp = [...played].sort((a, b) => (b.metrics.TotalDistance || 0) - (a.metrics.TotalDistance || 0))[0]; 687 688 const distDef = METRICS.find(d => d.key === 'TotalDistance'); 689 const summaryRows = played.map(r => ` 690 <div class="rank-row" onclick="openPlayer('${r.playerId}', 'match')"> 691 ${avatarHTML(players[r.playerId])}
692 <span class="rank-info"> 693 <b>${r.name}</b> 694 <small>${POS_ICONS[r.position]} ${posShort(r.position)} · ${flagEmoji(r.teamCode)} ${r.teamCode} · ${Math.round(r.metrics.TimePlayed)}' ${t('chipPlayed')}</small> 695 </span> 696 <span class="rank-val"> 697 <b>${fmtValue(distDef, r.metrics.TotalDistance || 0, false)}</b> 698 <small>${(r.metrics.TopSpeed || 0).toFixed(1)} km/h · ${Math.round(r.metrics.Sprints || 0)}â¡</small> 699 </span> 700 </div>`).join(''); 701 702 document.getElementById('match-body').innerHTML = ` 703 <div class="match-card static"> 704 <span class="mc-row"> 705 <span class="mc-team"><span class="flag-emoji">${flagEmoji(m.home)}</span><b>${teamName(m.home)}</b></span> 706 <span class="mc-score">${fmtMatchScore(m)}</span> 707 <span class="mc-team"><span class="flag-emoji">${flagEmoji(m.away)}</span><b>${teamName(m.away)}</b></span> 708 </span> 709 <span class="mc-sub">${[fmtMatchDate(m.date), m.stage, m.group].filter(Boolean).join(' · ')}</span> 710 </div> 711 ${mvp ? ` 712 <div class="mvp" onclick="openPlayer('${mvp.playerId}', 'match')"> 713 ${avatarHTML(players[mvp.playerId])} 714 <span class="rank-info"> 715 <small>ð ${t('physMvp')}</small> 716 <b>${mvp.name}</b> 717 </span> 718 <span class="rank-val"><b>${fmtValue(distDef, mvp.metrics.TotalDistance, false)}</b></span> 719 </div>` : ''} 720 <div class="panel"> 721 <h3>${t('matchHint').toUpperCase()}</h3> 722 <div class="mgrid"> 723 ${METRICS.map(d => `<button onclick="pickMatchMetric('${d.key}')">${d.icon} ${t('m.' + d.key)}</button>`).join('')} 724 </div> 725 </div> 726 <div class="panel"> 727 <h3>${t('matchSummary').toUpperCase()}</h3> 728 ${summaryRows} 729 ${excluded > 0 ? `<p class="empty-note">${excluded > 1 ? t('excludedN', { n: excluded }) : t('excluded1')}</p>` : ''} 730 </div>`; 731} 732 733function renderRanking() { 734 const def = metricDef(state.metric); 735 const team = state.teamId ? teams[state.teamId] : null; 736 const isWorld = state.flow === 'world'; 737 const isMatch = state.flow === 'match'; 738 const isP90 = state.view === 'p90' && def.per90; 739 740 document.getElementById('ranking-title').innerHTML = 741 isMatch ? `ð ${matchLabel(APP_DATA.matches[state.matchId])}` : 742 isWorld ? `ð ${t('worldTitle')}` : `${flagEmoji(team.code)} ${teamName(team.code)}`; 743 document.getElementById('ranking-metric').textContent = `${def.icon} ${metricLabel(def.key)}`; 744 745 // Toggle 90'/Total solo si la métrica es normalizable 746 const toggle = document.getElementById('view-toggle'); 747 toggle.style.display = def.per90 ? 'flex' : 'none'; 748 const bp = document.getElementById('btn-p90'), bt = document.getElementById('btn-total'); 749 bp.textContent = t('per90'); bt.textContent = t('total'); 750 bp.classList.toggle('on', isP90); bt.classList.toggle('on', !isP90); 751 752 // Chips de filtro por posición 753 const pf = state.posFilter; 754 document.getElementById('pos-filter').innerHTML = 755 [`<button class="${pf === -1 ? 'on' : ''}" onclick="setPosFilter(-1)">${t('posAll')}</button>`] 756 .concat(POSITIONS.map(pos => 757 `<button class="${pf === pos ? 'on' : ''}" onclick="setPosFilter(${pos})">${POS_ICONS[pos]} ${posShort(pos)}</button>`)) 758 .join(''); 759 760 // Chips de minutos mÃnimos + número de partidos (1-8) salvo en vista de partido 761 const minChips = `<span class="pclabel">â±ï¸</span>` + [0, 45, 90].map(v => 762 `<button class="${minMinutes === v ? 'on' : ''}" onclick="setMinMinutes(${v})">${v === 0 ? ">0'" : 'â¥' + v + "'"}</button>`).join(''); 763 const pjChips = isMatch ? '' : `<span class="pclabel" style="margin-left:8px">ð </span>` + [1, 2, 3, 4, 5, 6, 7, 8].map(v => 764 `<button class="${minMatches === v ? 'on' : ''}" onclick="setMinMatches(${v})">â¥${v}</button>`).join(''); 765 document.getElementById('min-filter').innerHTML = minChips + pjChips; 766 767 // Jugadores que pasan el filtro de minutos: del partido, del equipo o de todos 768 let rows, excluded; 769 if (isMatch) { 770 let recs = recsByMatch[state.matchId] || []; 771 if (pf !== -1) recs = recs.filter(r => r.position === pf); 772 const played = recs.filter(r => passesMinutes(r.metrics.TimePlayed || 0)); 773 excluded = recs.length - played.length; 774 rows = played.map(r => { 775 const mins = r.metrics.TimePlayed; 776 const total = r.metrics[def.key] || 0; 777 const value = isP90 ? (total / mins) * 90 : total; 778 return { p: players[r.playerId], value, minutes: mins, pj: 1, inferred: isP90 && mins < 90, pos: r.position }; 779 }); 780 } else { 781 let scope = Object.values(players); 782 if (!isWorld) scope = scope.filter(p => p.teamId === state.teamId); 783 if (pf !== -1) scope = scope.filter(p => p.posList.includes(pf)); 784 if (minMatches > 1) scope = scope.filter(p => (p.matches || 0) >= minMatches); 785 const pool = scope.filter(p => passesMinutes(p.minutes)); 786 excluded = scope.length - pool.length; 787 rows = pool.map(p => {
788 const total = p.m[def.key] || 0; 789 const value = isP90 ? (total / p.minutes) * 90 : total; 790 return { p, value, minutes: p.minutes, pj: p.matches, inferred: isP90 && p.minutes < 90, pos: p.mainPos }; 791 }); 792 } 793 rows.sort((a, b) => b.value - a.value); 794 795 const max = rows.length ? Math.max(...rows.map(r => r.value)) : 1; 796 const anyInferred = rows.some(r => r.inferred); 797 document.getElementById('ranking-legend').textContent = 798 isP90 && anyInferred ? t('legendInferred') : 799 !def.per90 ? t('legendReal') : ''; 800 801 const medals = ['ð¥', 'ð¥', 'ð¥']; 802 document.getElementById('ranking-list').innerHTML = rows.map((r, i) => ` 803 <div class="rank-row${r.inferred ? ' inferred' : ''}" onclick="openPlayer('${r.p.id}')"> 804 <div class="bar" style="width:${max > 0 ? (r.value / max) * 100 : 0}%"></div> 805 <span class="rank-pos${i < 3 ? ' medal' : ''}">${i < 3 ? medals[i] : i + 1}</span> 806 ${avatarHTML(r.p)} 807 <span class="rank-info"> 808 <b>${crossMark(r.p.id)}${r.p.name}</b> 809 <small>${POS_ICONS[r.pos]} ${posShort(r.pos)} · ${isWorld || isMatch ? flagEmoji(r.p.teamCode) + ' ' + r.p.teamCode + ' · ' : ''}${isMatch 810 ? `${Math.round(r.minutes)}' ${t('chipPlayed')}` 811 : t('rankRowSub', { min: Math.round(r.minutes), pj: r.pj })}</small> 812 </span> 813 <span class="rank-val"> 814 <b>${fmtValue(def, r.value, isP90)}${r.inferred ? '<span class="inferred-badge">â</span>' : ''}</b> 815 <small>${isP90 ? t('per90Short') : t('totalShort')}</small> 816 </span> 817 </div>`).join('') + 818 (excluded > 0 ? `<p class="empty-note">${excluded > 1 ? t('excludedN', { n: excluded }) : t('excluded1')}</p>` : ''); 819} 820 821/* ââââââââ Ficha de jugador: percentiles y radar ââââââââ */ 822function metricValue(p, def, isP90) { 823 const total = p.m[def.key] || 0; 824 return isP90 && def.per90 && p.minutes > 0 ? (total / p.minutes) * 90 : total; 825} 826 827// Percentil del valor v dentro de pool (lista de valores que incluye al jugador) 828function percentile(values, v) { 829 if (values.length <= 1) return 100; 830 const below = values.filter(x => x < v).length; 831 const equal = values.filter(x => x === v).length; 832 return Math.round(100 * (below + 0.5 * (equal - 1)) / (values.length - 1)); 833} 834 835function radarSVG(axes, axes2) { 836 const cx = 150, cy = 128, R = 88, n = axes.length; 837 const pt = (i, r) => { 838 const a = -Math.PI / 2 + (i * 2 * Math.PI) / n; 839 return [cx + r * Math.cos(a), cy + r * Math.sin(a)]; 840 }; 841 const poly = r => Array.from({ length: n }, (_, i) => pt(i, r).map(c => c.toFixed(1)).join(',')).join(' '); 842 const rings = [25, 50, 75, 100].map(p => `<polygon class="radar-grid" points="${poly(R * p / 100)}"/>`).join(''); 843 const axisLines = axes.map((_, i) => { 844 const [x, y] = pt(i, R); 845 return `<line class="radar-axis" x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}"/>`; 846 }).join(''); 847 const labels = axes.map((a, i) => { 848 const [x, y] = pt(i, R + 18); 849 const anchor = Math.abs(x - cx) < 8 ? 'middle' : x > cx ? 'start' : 'end'; 850 return `<text class="radar-label" x="${x.toFixed(1)}" y="${(y + 3).toFixed(1)}" text-anchor="${anchor}">${a.label}</text>` + 851 (axes2 ? '' : `<text class="radar-pct" x="${x.toFixed(1)}" y="${(y + 13).toFixed(1)}" text-anchor="${anchor}">${a.pct}%</text>`); 852 }).join(''); 853 const shapeOf = arr => arr.map((a, i) => pt(i, R * Math.max(a.pct, 3) / 100).map(c => c.toFixed(1)).join(',')).join(' '); 854 const dots = axes.map((a, i) => { 855 const [x, y] = pt(i, R * Math.max(a.pct, 3) / 100); 856 return `<circle class="radar-dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3"/>`; 857 }).join(''); 858 const second = axes2 ? `<polygon class="radar-shape2" points="${shapeOf(axes2)}"/>` : ''; 859 return `<svg viewBox="0 0 300 262">${rings}${axisLines}${second}<polygon class="radar-shape" points="${shapeOf(axes)}"/>${dots}${labels}</svg>`; 860} 861 862function worldRadarAxes(p, pool) { 863 pool = pool || Object.values(players).filter(x => x.minutes > 0); 864 return RADAR_AXES.map(key => { 865 const def = METRICS.find(d => d.key === key); 866 const vals = pool.map(x => metricValue(x, def, true)); 867 return { label: t('r.' + key), pct: percentile(vals, metricValue(p, def, true)) }; 868 }); 869} 870 871// Radar de los 4 sub-ratings (fÃsico/ofensivo/creación/defensivo) 872function subRadarAxes(p) { 873 return [ 874 { label: t('rFis'), pct: p.rFis || 0 }, 875 { label: t('rOff'), pct: p.rOff || 0 }, 876 { label: t('rCre'), pct: p.rCre || 0 }, 877 { label: t('rDef'), pct: p.rDef || 0 }, 878 ]; 879} 880 881const RADAR_AXES = [ 882 'TotalDistance', 'DistanceHighSpeedRunning', 'DistanceHighSpeedSprinting', 883 'Sprints', 'SpeedRuns', 'TopSpeed', 'AvgSpeed', 884]; 885 886function renderPlayer() { 887 const p = players[state.playerId]; 888 const isP90 = state.pview === 'p90'; 889 const worldPool = Object.values(players).filter(x => x.minutes > 0); 890 const teamPool = worldPool.filter(x => x.teamId === p.teamId); 891 const posPool = worldPool.filter(x => x.mainPos === p.mainPos); 892 const radarVsPos = posPool.length >= 5; // con pocos jugadores el percentil no es fiable 893 894 document.getElementById('player-title').textContent = p.name; 895 896 const initials = p.name.split(/\s+/).map(w => w[0]).slice(0, 2).join(''); 897 898 // Radar: percentil vs su posición (o Mundial si hay pocos de su posición), por 90' 899 const axes = worldRadarAxes(p, radarVsPos ? posPool : null); 900 901 // Semáforo de carga: km acumulados vs media de jugadores con minutos 902 const avgDist = worldPool.reduce((s, x) => s + (x.m.TotalDistance || 0), 0) / worldPool.length; 903 const ratio = avgDist > 0 ? (p.m.TotalDistance || 0) / avgDist : 0; 904 const loadIcon = ratio > 1.2 ? 'ð´' : ratio >= 0.8 ? 'ð¡' : 'ð¢'; 905 const loadTxt = ratio > 1.2 ? t('loadHigh') : ratio >= 0.8 ? t('loadMed') : t('loadLow'); 906 907 // Evolución por partido (distancia total) 908 const recs = [...(recsByPlayer[p.id] || [])] 909 .filter(r => (r.metrics.TimePlayed || 0) > 0) 910 .sort((a, b) => APP_DATA.matches[a.matchId].date.localeCompare(APP_DATA.matches[b.matchI
910d].date)); 911 const maxDist = Math.max(...recs.map(r => r.metrics.TotalDistance || 0), 1); 912 const distDef = METRICS.find(d => d.key === 'TotalDistance'); 913 const perMatchRows = recs.map(r => { 914 const m = APP_DATA.matches[r.matchId]; 915 return ` 916 <div class="pm-match"> 917 <span class="mlabel"><b>${matchLabel(m)}${p.ratingByMatch && p.ratingByMatch[r.matchId] != null ? ` <span class="rt-pill ${rtTier(p.ratingByMatch[r.matchId])}">â ${p.ratingByMatch[r.matchId].toFixed(1)}</span>` : ''}</b><small>${p.posRealByMatch && p.posRealByMatch[r.matchId] ? p.posRealByMatch[r.matchId] + ' · ' : (p.posList.length > 1 ? POS_ICONS[r.position] + ' ' + posShort(r.position) + ' · ' : '')}${fmtMatchDate(m.date)} · ${Math.round(r.metrics.TimePlayed)}'</small></span> 918 <span class="track"><span class="fill" style="display:block;width:${((r.metrics.TotalDistance || 0) / maxDist) * 100}%"></span></span> 919 <span class="mval">${fmtValue(distDef, r.metrics.TotalDistance || 0, false)}</span> 920 </div>`; 921 }).join(''); 922 923 // Lista de métricas con percentil equipo / posición / mundial según la vista activa 924 const rows = METRICS.map(def => { 925 const useP90 = isP90 && def.per90; 926 const v = metricValue(p, def, useP90); 927 const teamPct = percentile(teamPool.map(x => metricValue(x, def, useP90)), v); 928 const posPct = percentile(posPool.map(x => metricValue(x, def, useP90)), v); 929 const worldPct = percentile(worldPool.map(x => metricValue(x, def, useP90)), v); 930 const inferred = useP90 && p.minutes < 90; 931 return ` 932 <div class="pmetric"> 933 <div class="pm-top"> 934 <span class="pm-label">${def.icon} ${t('m.' + def.key)}</span> 935 <b class="pm-val">${fmtValue(def, v, useP90)}${inferred ? '<span class="inferred-badge">â</span>' : ''}${def.per90 ? `<small style="color:var(--muted);font-weight:400"> ${useP90 ? t('per90Short') : t('totalShort')}</small>` : ''}</b> 936 </div> 937 <div class="pm-bar"><small>${t('teamBar')}</small><div class="track"><div class="fill" style="width:${teamPct}%"></div></div><span>${teamPct}%</span></div> 938 <div class="pm-bar"><small>${POS_ICONS[p.mainPos]} ${posShort(p.mainPos)}</small><div class="track"><div class="fill pos" style="width:${posPct}%"></div></div><span>${posPct}%</span></div> 939 <div class="pm-bar"><small>${t('worldBar')}</small><div class="track"><div class="fill world" style="width:${worldPct}%"></div></div><span>${worldPct}%</span></div> 940 </div>`; 941 }).join(''); 942 943 // Comparativa entre mundiales (si disputó Qatar 2022 y 2026) 944 const cross = crossData(p.id); 945 const crossPanel = cross ? ` 946 <div class="panel"> 947 <h3>ð ${t('crossPanel')}</h3> 948 <p class="legend">Qatar 2022: ${Math.round(cross.q.minutes)}' · ${cross.q.matches} ${t('chipPJ')} â 2026: ${Math.round(cross.n.minutes)}' · ${cross.n.matches} ${t('chipPJ')}</p> 949 ${METRICS.filter(d => d.key !== 'TimePlayed').map(def => { 950 const va = metricValue(cross.q, def, true), vb = metricValue(cross.n, def, true); 951 const sum = va + vb; 952 const wa = sum > 0 ? (va / sum) * 100 : 50; 953 return ` 954 <div class="cmp-row"> 955 <div class="cmp-label">${def.icon} ${t('m.' + def.key)}${def.per90 ? ` · ${t('per90Short')}` : ''}</div> 956 <div class="cmp-vals"> 957 <span class="cv a${va > vb ? ' win' : ''}">${fmtValue(def, va, def.per90)}</span> 958 <span class="duo"><span class="da" style="width:${wa}%"></span><span class="db" style="width:${100 - wa}%"></span></span> 959 <span class="cv b${vb > va ? ' win' : ''}">${fmtValue(def, vb, def.per90)}</span> 960 </div> 961 </div>`; 962 }).join('')} 963 <div class="radar-key"> 964 <span><i style="background:var(--acc)"></i>Qatar 2022</span> 965 <span><i style="background:#8a7df0"></i>2026</span> 966 </div> 967 </div>` : ''; 968 969 document.getElementById('player-body').innerHTML = ` 970 <div class="hero"> 971 <button class="favbtn" onclick="toggleFav('${p.id}')">${favs.includes(p.id) ? 'â' : 'â'}</button>
972 <span class="hero-photo">${initials}<img src="${p.photo}" alt="" onerror="this.remove()"></span> 973 <div class="hero-info"> 974 <h2>${p.name}</h2> 975 <div class="team">${flagEmoji(p.teamCode)} ${teamName(p.teamCode)} · ${POS_ICONS[p.mainPos]} ${posName(p.mainPos)}${p.posList.length > 1 ? ' <small>(' + p.posList.slice(1).map(posShort).join('/') + ')</small>' : ''}</div> 976 <div class="hero-mjpj">${t('heroMinLbl')}: <b>${Math.round(p.minutes)}'</b> · ${t('chipPJ')}: <b>${p.matches}</b></div> 977 <div class="hero-chips"> 978 ${p.rating != null ? `<span class="chip rt-chip ${rtTier(p.rating)}" title="${t('ratingTitle')}">â <b>${p.rating.toFixed(2)}</b></span>` : ''} 979 ${p.posReal ? `<span class="chip" title="${t('posRealTitle')}">ð <b>${p.posReal}</b></span>` : ''} 980 <span class="chip"><b>${(p.m.TopSpeed || 0).toFixed(1)}</b> ${t('chipTop')}</span> 981 ${cross ? `<span class="chip">ð <b>2022+2026</b></span>` : ''} 982 </div> 983 </div> 984 </div> 985 <div class="actionrow"> 986 <span class="loadchip">${loadIcon} ${t('loadLabel')}: <b> ${loadTxt}</b></span> 987 </div> 988 <div class="actionrow"> 989 <button class="sharebtn" onclick="openReport('${p.id}')">${navIcon('doc')} ${t('reportM')}</button> 990 <button class="sharebtn" onclick="shareCard()">${navIcon('share')} ${t('share')}</button> 991 </div> 992 ${p.minutes < 90 ? `<p class="hero-note">${t('legendInferred')}</p>` : ''} 993 ${ratingsRadarPanel(p)} 994 ${crossPanel} 995 ${perMatchRows ? ` 996 <div class="panel"> 997 <h3>${t('perMatch').toUpperCase()} · ${t('m.TotalDistance').toUpperCase()}</h3> 998 ${perMatchRows} 999 </div>` : ''} 1000 ${playerMetricCards(p)}`; 1001} 1002 1003/* ââââââââ Favoritos (jugadores y selecciones) ââââââââ */ 1004let favs = []; 1005let favTeams = []; 1006try { favs = JSON.parse(localStorage.getItem('fm26-favs') || '[]'); } catch (e) {} 1007try { favTeams = JSON.parse(localStorage.getItem('fm26-favteams') || '[]'); } catch (e) {} 1008 1009function toggleFav(id) { 1010 favs = favs.includes(id) ? favs.filter(f => f !== id) : [...favs, id]; 1011 try { localStorage.setItem('fm26-favs', JSON.stringify(favs)); } catch (e) {} 1012 renderPlayer(); 1013} 1014 1015function toggleTeamFav(ev, id, from) { 1016 if (ev && ev.stopPropagation) ev.stopPropagation(); 1017 favTeams = favTeams.includes(id) ? favTeams.filter(f => f !== id) : [...favTeams, id]; 1018 try { localStorage.setItem('fm26-favteams', JSON.stringify(favTeams)); } catch (e) {} 1019 from === 'favs' ? goFavs() : renderTeams(); 1020} 1021 1022function playerRowHTML(p, from) { 1023 return ` 1024 <div class="rank-row" onclick="openPlayer('${p.id}', '${from}')"> 1025 ${avatarHTML(p)} 1026 <span class="rank-info"> 1027 <b>${crossMark(p.id)}${p.name}</b> 1028 <small>${p.score != null ? `â${p.score} · ` : ''}${POS_ICONS[p.mainPos]} ${posShort(p.mainPos)} · ${flagEmoji(p.teamCode)} ${teamName(p.teamCode)} · ${Math.round(p.minutes)}' ${t('chipPlayed')}</small> 1029 </span> 1030 <span class="chev">âº</span> 1031 </div>`; 1032} 1033 1034function goFavs() { 1035 const teamRows = favTeams.map(id => teams[id]).filter(Boolean).map(tm => ` 1036 <div class="rank-row" onclick="pickTeam('${tm.id}')"> 1037 <span class="flag-emoji">${flagEmoji(tm.code)}</span> 1038 <span class="rank-info"> 1039 <b>${teamName(tm.code)}</b> 1040 <small>${tm.matches.size} ${tm.matches.size > 1 ? t('match_pl') : t('match_sg')} · ${tm.squad.size} ${t('players_w')}</small> 1041 </span> 1042 <span class="teamfav" onclick="toggleTeamFav(event, '${tm.id}', 'favs')">â</span> 1043 </div>`).join(''); 1044 const playerRows = favs.map(id => players[id]).filter(Boolean).map(p => playerRowHTML(p, 'favs')).join(''); 1045 1046 document.getElementById('favs-list').innerHTML = 1047 !teamRows && !playerRows ? `<p class="empty-note">${t('favEmpty')}</p>` : 1048 (teamRows ? `<div class="panel"><h3>${t('teamsRankM').toUpperCase()}</h3>${teamRows}</div>` : '') + 1049 (playerRows ? `<div class="panel"><h3>${t('players_w').toUpperCase()}</h3>${playerRows}</div>` : ''); 1050 show('favs'); 1051} 1052
1053/* ââââââââ Buscador (también selector del comparador) ââââââââ */ 1054function goSearch(pick) { 1055 state.searchPick = pick || null; 1056 document.getElementById('search-title').innerHTML = 1057 pick === 'A' ? t('comparePickA') : pick === 'B' ? t('comparePickB') : 1058 pick === 'R' ? `${navIcon('doc')} ${t('reportM')}` : pick === 'F' ? `${navIcon('doc')} ${t('reportPick')}` : t('searchM'); 1059 const input = document.getElementById('search-input'); 1060 input.value = ''; 1061 renderSearchResults(); 1062 show('search'); 1063 input.focus(); 1064} 1065function backFromSearch() { 1066 const pick = state.searchPick; 1067 state.searchPick = null; 1068 if (pick === 'A' || pick === 'B') return goCompare(); 1069 if (pick === 'F') return goModes(); 1070 if (pick === 'G') return goGameHome(); 1071 backFeat(); // búsqueda normal o stories: vuelve a la home fÃsica o de juego según contexto 1072} 1073 1074function renderSearchResults() { 1075 const q = document.getElementById('search-input').value.trim().toLowerCase(); 1076 let pool = Object.values(players); 1077 if (state.searchPick) pool = pool.filter(p => p.minutes > 0); // el radar necesita minutos 1078 const list = pool 1079 .filter(p => !q || p.name.toLowerCase().includes(q) || teamName(p.teamCode).toLowerCase().includes(q) || p.teamCode.toLowerCase().includes(q)) 1080 .sort((a, b) => b.minutes - a.minutes || a.name.localeCompare(b.name)) 1081 .slice(0, 40); 1082 document.getElementById('search-results').innerHTML = 1083 list.length === 0 ? `<p class="empty-note">${t('noResults')}</p>` : 1084 list.map(p => state.searchPick 1085 ? playerRowHTML(p, 'search').replace(`openPlayer('${p.id}', 'search')`, `searchSelect('${p.id}')`) 1086 : playerRowHTML(p, 'search')).join(''); 1087} 1088function searchSelect(id) { 1089 const pick = state.searchPick; 1090 state.searchPick = null; 1091 if (pick === 'R') { openReport(id); return; } 1092 if (pick === 'F' || pick === 'G') { openFullReport(id); return; } 1093 if (pick === 'A') state.cmpA = id; else state.cmpB = id; 1094 goCompare(); 1095} 1096 1097/* ââââââââ Comparador ââââââââ */ 1098function goCompare() { renderCompare(); show('compare'); } 1099function setCmpView(v) { state.cmpView = v; renderCompare(); } 1100 1101function cmpSlot(id, slot) { 1102 const p = id ? players[id] : null; 1103 return p ? ` 1104 <button class="cmp-slot ${slot.toLowerCase()} filled" onclick="goSearch('${slot}')"> 1105 ${avatarHTML(p)}<b>${p.name}</b><small>${p.score != null ? `â${p.score} · ` : ''}${POS_ICONS[p.mainPos]} ${posShort(p.mainPos)} · ${flagEmoji(p.teamCode)} ${p.teamCode} · ${Math.round(p.minutes)}'</small> 1106 </button>` : ` 1107 <button class="cmp-slot ${slot.toLowerCase()}" onclick="goSearch('${slot}')"> 1108 <span style="font-size:28px">â</span><b>${slot === 'A' ? t('comparePickA') : t('comparePickB')}</b> 1109 </button>`; 1110} 1111 1112function renderCompare() { 1113 const a = state.cmpA ? players[state.cmpA] : null; 1114 const b = state.cmpB ? players[state.cmpB] : null; 1115 const isP90 = (state.cmpView || 'p90') === 'p90'; 1116 let body = `<div class="cmp-slots">${cmpSlot(state.cmpA, 'A')}<span class="cmp-vs">VS</span>${cmpSlot(state.cmpB, 'B')}</div>`; 1117 1118 // Comparador de Datos de Juego: estilo por categorÃas 1119 if (a && b && state.featCtx === 'game') { 1120 document.getElementById('compare-body').innerHTML = body + gameCompareBody(a, b); 1121 return; 1122 } 1123 1124 if (!a || !b) { 1125 body += `<p class="empty-note">${t('compareHint')}</p>`; 1126 } else { 1127 body += ` 1128 <div class="panel"> 1129 <h3>${t('panelRadar')}</h3> 1130 <div class="radar-wrap">${radarSVG(worldRadarAxes(a), worldRadarAxes(b))}</div> 1131 <div class="radar-key"> 1132 <span><i style="background:var(--acc)"></i>${a.name}</span> 1133 <span><i style="background:#8a7df0"></i>${b.name}</span> 1134 </div> 1135 </div> 1136 <div class="panel"> 1137 <div class="toggle" style="margin-bottom:6px"> 1138 <button class="${isP90 ? 'on' : ''}" onclick="setCmpView('p90')">${t('per90')}</button> 1139 <button class="${isP90 ? '' : 'on'}" onclick="setCmpView('total')">${t('total')}</button> 1140 </div> 1141 ${METRICS.map(def => { 1142 const useP90 = isP90 && def.per90; 1143 const va = metricValue(a, def, useP90), vb = metricValue(b, def, useP90); 1144 const sum = va + vb; 1145 const wa = sum > 0 ? (va / sum) * 100 : 50; 1146 return ` 1147 <div class="cmp-row"> 1148 <div class="cmp-label">${def.icon}
1148 ${t('m.' + def.key)}${def.per90 ? ` · ${useP90 ? t('per90Short') : t('totalShort')}` : ''}</div> 1149 <div class="cmp-vals"> 1150 <span class="cv a${va > vb ? ' win' : ''}">${fmtValue(def, va, useP90)}</span> 1151 <span class="duo"><span class="da" style="width:${wa}%"></span><span class="db" style="width:${100 - wa}%"></span></span> 1152 <span class="cv b${vb > va ? ' win' : ''}">${fmtValue(def, vb, useP90)}</span> 1153 </div> 1154 </div>`; 1155 }).join('')} 1156 </div>`; 1157 } 1158 document.getElementById('compare-body').innerHTML = body; 1159} 1160 1161/* ââââââââ Récords del torneo (mejor marca en un partido) ââââââââ */ 1162/* ââââââââ Memoria de récords: avisa si baten alguno entre visitas ââââââââ */ 1163// Récord = mejor marca en un partido por métrica fÃsica (torneo 2026) 1164function snapshotRecords() { 1165 const recs = Object.values(AGG.wc26.recsByMatch).flat().filter(r => (r.metrics.TimePlayed || 0) > 0); 1166 const snap = {}; 1167 for (const def of METRICS) { 1168 if (def.key === 'TimePlayed') continue; 1169 let best = null; 1170 for (const r of recs) { 1171 const v = r.metrics[def.key] || 0; 1172 if (v > 0 && (!best || v > best.v)) best = { v, n: r.name, t: r.teamCode }; 1173 } 1174 if (best) snap[def.key] = best; 1175 } 1176 return snap; 1177} 1178let brokenRecords = []; // récords batidos desde la última visita (para el aviso) 1179let brokenSet = new Set(); // claves batidas (para marcar en la pantalla de récords) 1180function checkRecords() { 1181 let stored = {}; 1182 try { stored = JSON.parse(localStorage.getItem('fm26-records') || '{}'); } catch (e) {} 1183 const cur = snapshotRecords(); 1184 brokenRecords = []; 1185 for (const key in cur) { 1186 const s = stored[key]; 1187 if (s && cur[key].v > s.v + 1e-6) brokenRecords.push({ key, cur: cur[key], prev: s }); 1188 } 1189 brokenSet = new Set(brokenRecords.map(b => b.key)); 1190 try { localStorage.setItem('fm26-records', JSON.stringify(cur)); } catch (e) {} 1191} 1192 1193function goRecords() { 1194 const allRecs = Object.values(recsByMatch).flat().filter(r => (r.metrics.TimePlayed || 0) > 0); 1195 const markable = tour === 'wc26'; 1196 document.getElementById('records-list').innerHTML = METRICS.map(def => { 1197 const best = allRecs.reduce((acc, r) => ((r.metrics[def.key] || 0) > (acc.metrics[def.key] || 0) ? r : acc)); 1198 const m = APP_DATA.matches[best.matchId]; 1199 const p = players[best.playerId]; 1200 const isNew = markable && brokenSet.has(def.key); 1201 return ` 1202 <div class="rank-row${isNew ? ' record-new' : ''}" onclick="openPlayer('${best.playerId}', 'records')"> 1203 <span class="micon">${def.icon}</span> 1204 ${avatarHTML(p)} 1205 <span class="rank-info"> 1206 <small>${t('m.' + def.key)}${isNew ? ` <span class="rec-badge">ð ${t('recNew')}</span>` : ''}</small> 1207 <b>${best.name}</b> 1208 <small>${POS_ICONS[best.position]} ${posShort(best.position)} · ${flagEmoji(best.teamCode)} ${matchLabel(m)}</small> 1209 </span> 1210 <span class="rank-val"><b>${fmtValue(def, best.metrics[def.key] || 0, false)}</b></span> 1211 </div>`; 1212 }).join(''); 1213 show('records'); 1214} 1215 1216/* ââââââââ Comparativa de selecciones ââââââââ */ 1217// Minutos reales que el equipo ha estado en el campo: por cada partido, el jugador 1218// con más minutos (el que no fue sustituido) representa la duración real de ese partido. 1219// Sumar los minutos de los 11+ jugadores multiplicarÃa por ~11 los minutos reales. 1220function teamRealMinutes(team) { 1221 let total = 0; 1222 team.matches.forEach(mid => { 1223 const recs = recsByMatch[mid] || []; 1224 total += recs.reduce((mx, r) => r.teamId === team.id ? Math.max(mx, r.metrics.TimePlayed || 0) : mx, 0); 1225 }); 1226 return total; 1227} 1228function teamMetricValue(team, def) { 1229 const pool = Object.values(players).filter(p => p.teamId === team.id && p.minutes > 0); 1230 if (def.agg === 'max') return Math.max(...pool.map(p => p.m[def.key] || 0)); 1231 if (def.agg === 'wavg') { 1232 const mins = pool.reduce((s, p) => s + p.minutes, 0); 1233 return mins > 0 ? pool.reduce((s, p) =>
1233 s + (p.m[def.key] || 0) * p.minutes, 0) / mins : 0; 1234 } 1235 const total = pool.reduce((s, p) => s + (p.m[def.key] || 0), 0); 1236 return def.per90 ? (m => m > 0 ? (total / m) * 90 : total)(teamRealMinutes(team)) : total; 1237} 1238// Valor equipo según vista: total | p90 | p90perPlayer 1239function teamMetricValueView(team, def, view) { 1240 const pool = Object.values(players).filter(p => p.teamId === team.id && p.minutes > 0); 1241 if (!pool.length) return 0; 1242 if (def.agg === 'max') return Math.max(...pool.map(p => p.m[def.key] || 0)); 1243 if (def.agg === 'wavg') { 1244 const totalMins = pool.reduce((s, p) => s + p.minutes, 0); 1245 return totalMins > 0 ? pool.reduce((s, p) => s + (p.m[def.key] || 0) * p.minutes, 0) / totalMins : 0; 1246 } 1247 const N = pool.length; 1248 const total = pool.reduce((s, p) => s + (p.m[def.key] || 0), 0); 1249 if (view === 'p90') { const m = teamRealMinutes(team); return m > 0 ? (total / m) * 90 : 0; } 1250 if (view === 'p90perPlayer') return N > 0 1251 ? pool.reduce((s, p) => s + (p.minutes > 0 ? (p.m[def.key] || 0) / p.minutes * 90 : 0), 0) / N : 0; 1252 return total; 1253} 1254 1255function goTeamsRank(metricKey) { 1256 state.teamMetric = metricKey || state.teamMetric || 'TotalDistance'; 1257 const def = METRICS.find(d => d.key === state.teamMetric); 1258 const rows = Object.values(teams) 1259 .map(tm => ({ tm, value: teamMetricValue(tm, def) })) 1260 .sort((x, y) => y.value - x.value); 1261 const max = Math.max(...rows.map(r => r.value), 1); 1262 const medals = ['ð¥', 'ð¥', 'ð¥']; 1263 document.getElementById('teamsrank-body').innerHTML = ` 1264 <div class="panel"> 1265 <h3>${t('chooseMetric').toUpperCase()}</h3> 1266 <div class="mgrid"> 1267 ${METRICS.map(d => `<button style="${d.key === state.teamMetric ? 'border-color:var(--acc)' : ''}" onclick="goTeamsRank('${d.key}')">${d.icon} ${t('m.' + d.key)}</button>`).join('')} 1268 </div> 1269 </div> 1270 <p class="legend">${def.per90 ? t('teamPer90') : t('legendReal')}</p> 1271 ${rows.map((r, i) => ` 1272 <div class="rank-row" onclick="pickTeam('${r.tm.id}')"> 1273 <span class="rank-pos${i < 3 ? ' medal' : ''}">${i < 3 ? medals[i] : i + 1}</span> 1274 <span class="flag-emoji">${flagEmoji(r.tm.code)}</span> 1275 <span class="rank-info"><b>${teamName(r.tm.code)}</b></span> 1276 <div class="bar" style="width:${(r.value / max) * 100}%"></div> 1277 <span class="rank-val"><b>${fmtValue(def, r.value, def.per90)}</b><small>${def.per90 ? t('per90Short') : t('totalShort')}</small></span> 1278 </div>`).join('')}`; 1279 show('teamsrank'); 1280} 1281 1282/* ââââââââ Quiz ¿quién tiene más X? ââââââââ */ 1283const quiz = { streak: 0, best: 0, round: null, answered: false }; 1284try { quiz.best = parseInt(localStorage.getItem('fm26-quiz-best') || '0', 10); } catch (e) {} 1285 1286function goQuiz() { newQuizRound(); show('quiz'); } 1287 1288// Ronda: ámbito aleatorio â ð acumulado del campeonato (total o por 90') o ðï¸ en un partido 1289function newQuizRound() { 1290 const qMetrics = state.featCtx === 'game' 1291 ? QUIZ_GAME.map(metricDef) 1292 : METRICS.filter(d => d.key !== 'TimePlayed'); 1293 const rnd = arr => arr[Math.floor(Math.random() * arr.length)]; 1294 let round = null; 1295 for (let i = 0; i < 80 && !round; i++) { 1296 const def = rnd(qMetrics); 1297 const scope = Math.random() < 0.5 ? 'match' : 'tour'; 1298 if (scope === 'tour') { 1299 const cands = Object.values(players).filter(p => p.minutes >= 45); 1300 const p90 = def.per90 && Math.random() < 0.5; 1301 const a = rnd(cands); 1302 const samePos = cands.filter(p => p.mainPos === a.mainPos && p.id !== a.id); 1303 if (!samePos.length) continue; 1304 const b = rnd(samePos); 1305 const va = metricValue(a, def, p90), vb = metricValue(b, def, p90); 1306 if (va === vb) continue; 1307 round = { 1308 def, scope, p90, 1309 pair: [ 1310 { p: a, pos: a.mainPos, sub: '', value: va }, 1311 { p: b, pos: b.mainPos, sub: '', value: vb }, 1312 ], 1313 }; 1314 } else { 1315 const recs = Object.values(recsByMatch).flat().filter(r => (r.metrics.TimePlayed || 0) >= 45); 1316 const a = rnd(recs); 1317 if (!a) continue; 1318 const samePos = recs.filter(r => r.position === a.position && r.playerId !== a.playerId); 1319 if (!samePos.length) continue; 1320 const b = rnd(samePos); 1321 const va = a.metrics[def.key] || 0, vb = b.metrics[def.key] || 0; 1322 if (va === vb) continue; 1323 round = { 1324 def, scope, p90: false, 1325 pair: [ 1326 { p: players[a.playerId], pos: a.position, sub: matchLabel(APP_DATA.matches[a.matchId]), value: va }, 1327 { p: players[b.playerId], pos: b.position, sub: matchLabel(APP_DATA.matches[b.matchI
1327d]), value: vb }, 1328 ], 1329 }; 1330 } 1331 } 1332 quiz.round = round; 1333 quiz.answered = false; 1334 renderQuiz(); 1335} 1336 1337function quizAnswer(idx) { 1338 if (quiz.answered) return; 1339 quiz.answered = true; 1340 const vals = quiz.round.pair.map(x => x.value); 1341 const correct = vals[idx] > vals[1 - idx]; 1342 quiz.streak = correct ? quiz.streak + 1 : 0; 1343 if (quiz.streak > quiz.best) { 1344 quiz.best = quiz.streak; 1345 try { localStorage.setItem('fm26-quiz-best', String(quiz.best)); } catch (e) {} 1346 } 1347 quiz.lastCorrect = correct; 1348 quiz.lastPick = idx; 1349 renderQuiz(); 1350} 1351 1352function renderQuiz() { 1353 const { def, pair, p90, scope } = quiz.round; 1354 const vals = pair.map(x => x.value); 1355 const winner = vals[0] > vals[1] ? 0 : 1; 1356 const scopeChip = scope === 'match' 1357 ? `ðï¸ ${t('quizScopeMatch')}` 1358 : `ð ${t('quizScopeTour')}${def.per90 ? ` · ${p90 ? "â±ï¸ " + t('per90') : "Σ " + t('total')}` : ''}`; 1359 document.getElementById('quiz-body').innerHTML = ` 1360 <div class="quiz-score"> 1361 <span class="chip">ð¥ ${t('quizStreak')}: <b>${quiz.streak}</b></span> 1362 <span class="chip">ð ${t('quizBest')}: <b>${quiz.best}</b></span> 1363 </div> 1364 <p class="quiz-q">${t('quizQ', { metric: metricLabel(def.key) })} ${def.icon}</p> 1365 <div class="quiz-score"><span class="chip">${scopeChip}</span></div> 1366 <div class="quiz-cards"> 1367 ${pair.map((x, i) => ` 1368 <button class="quiz-card${quiz.answered ? (i === winner ? ' right' : (i === quiz.lastPick ? ' wrong' : '')) : ''}" 1369 onclick="quizAnswer(${i})"> 1370 ${avatarHTML(x.p)} 1371 <b>${x.p.name}</b> 1372 <small>${POS_ICONS[x.pos]} ${posShort(x.pos)} · ${flagEmoji(x.p.teamCode)} ${x.p.teamCode}</small> 1373 ${x.sub ? `<small>ð ${x.sub}</small>` : ''} 1374 <span class="qv">${quiz.answered ? fmtValue(def, vals[i], p90) : '?'}</span> 1375 </button>`).join('')} 1376 </div> 1377 <p class="quiz-result">${quiz.answered ? (quiz.lastCorrect ? 'â ' + t('quizRight') : 'â ' + t('quizWrong')) : ''}</p> 1378 ${quiz.answered ? `<button class="quiz-next" onclick="newQuizRound()">${t('quizNext')} âº</button>` : `<p class="empty-note">${t('quizNote')}</p>`}`; 1379} 1380 1381/* ââââââââ Modo informe (stories animadas) ââââââââ */ 1382let rptSlides = []; 1383let rptIdx = 0; 1384let rptTimer = null; 1385const RPT_MS = 6000; 1386const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (() => {}); 1387 1388function rankIn(pool, def, p) { 1389 const v = metricValue(p, def, true); 1390 return pool.filter(x => metricValue(x, def, true) > v).length + 1; 1391} 1392 1393// Logros automáticos: nº1 mundial / top5 / nº1 de posición / nº1 de equipo 1394// Base: jugadores con 45' o más, para que las proyecciones de pocos minutos no distorsionen 1395function playerInsights(p) { 1396 const world = Object.values(players).filter(x => x.minutes >= 45); 1397 const team = world.filter(x => x.teamId === p.teamId); 1398 const pos = world.filter(x => x.mainPos === p.mainPos); 1399 const badges = []; 1400 for (const def of METRICS.filter(d => d.key !== 'TimePlayed')) { 1401 const m = t('m.' + def.key); 1402 const wr = rankIn(world, def, p); 1403 if (wr === 1) { badges.push(`ð¥ ${t('top1World', { m })}`); continue; } 1404 if (wr <= 5) { badges.push(`ð ${t('topNWorld', { n: 5, m })}`); continue; } 1405 if (pos.length >= 5 && rankIn(pos, def, p) === 1) { badges.push(`${POS_ICONS[p.mainPos]} ${t('top1Pos', { m })}`); continue; } 1406 if (rankIn(team, def, p) === 1) badges.push(`â ${t('top1Team', { m })}`); 1407 } 1408 return badges.slice(0, 6); 1409} 1410 1411function rptStat(value, dec, suf, label, delay) { 1412 return ` 1413 <div class="rpt-stat" style="animation-delay:${delay}s"> 1414 <b data-count="${value}" data-dec="${dec}" data-suf="${suf}">0</b> 1415 <small>${label}</small> 1416 </div>`; 1417} 1418 1419// Agrega las métricas de un jugador sobre un subconjunto de partidos (null = todos) 1420function aggPlayerOver(p0, matchIds) { 1421 const recs = (recsByPlayer[p0.id] || []).filter(r => (r.metrics.TimePlayed || 0) > 0 && (!matchIds || matchIds.includes(r.matchId))); 1422 const m = {}, wavg = {}; let minutes = 0, matches = 0; 1423 for (const r of recs) { 1424 const mins = r.metrics.TimePlayed || 0; minutes += mins; matches += 1; 1425 for (const def of AGG_METRICS) { 1426 const v = r.metrics[def.key] ?? 0;
1427 if (def.agg === 'max') m[def.key] = Math.max(m[def.key] || 0, v); 1428 else if (def.agg === 'wavg') wavg[def.key] = (wavg[def.key] || 0) + v * mins; 1429 else m[def.key] = (m[def.key] || 0) + v; 1430 } 1431 } 1432 for (const def of AGG_METRICS.filter(d => d.agg === 'wavg')) m[def.key] = minutes > 0 ? wavg[def.key] / minutes : 0; 1433 return Object.assign({}, p0, { m, minutes, matches }); 1434} 1435 1436// Slide de una categorÃa de juego: métricas con valor, la mayor como titular 1437function gameCatSlide(p, catId, icon, title) { 1438 if (typeof GAME_METRICS === 'undefined') return ''; 1439 const metrics = GAME_METRICS.filter(d => d.cat === catId && (p.m[d.key] || 0) > 0) 1440 .sort((a, b) => (p.m[b.key] || 0) - (p.m[a.key] || 0)); 1441 if (!metrics.length) return ''; 1442 const head = metrics[0], rest = metrics.slice(1, 5); 1443 const num = (d) => d.type === 'dec' ? (p.m[d.key] || 0).toFixed(2) : Math.round(p.m[d.key] || 0); 1444 return ` 1445 <p class="rpt-title fadeup">${icon} ${title}</p> 1446 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${num(head)}" data-dec="${head.type === 'dec' ? 2 : 0}" data-suf="">0</b></div> 1447 <p class="rpt-sub fadeup" style="animation-delay:.5s">${metricLabel(head.key)}</p> 1448 <div class="rpt-stats"> 1449 ${rest.map((d, i) => rptStat(num(d), d.type === 'dec' ? 2 : 0, '', metricLabel(d.key), .8 + i * .2)).join('')} 1450 </div>`; 1451} 1452 1453// Código del rival respecto a un equipo en un partido 1454function rivalOf(mid, code) { const mm = APP_DATA.matches[mid]; return mm.home === code ? mm.away : mm.home; } 1455function renderRptMatchFilter() { 1456 const el = document.getElementById('rpt-matchfilter'); if (!el) return; 1457 let mids, ownCode; 1458 if (state.rptMode === 'team') { ownCode = state.rptTeam; mids = teamReportMatches(ownCode); } 1459 else { 1460 const p0 = players[state.playerId]; ownCode = p0.teamCode; 1461 mids = (recsByPlayer[p0.id] || []).filter(r => (r.metrics.TimePlayed || 0) > 0) 1462 .map(r => r.matchId).sort((a, b) => APP_DATA.matches[a].date.localeCompare(APP_DATA.matches[b].date)); 1463 } 1464 if (mids.length < 2) { el.innerHTML = ''; return; } 1465 const sel = state.rptMatches; 1466 const chip = (mid, label, active) => `<button class="${active ? 'on' : ''}" onclick="setRptMatch('${mid}')">${label}</button>`; 1467 el.innerHTML = chip('all', t('rptAllMatches'), !sel) + 1468 mids.map(mid => chip(mid, flagEmoji(rivalOf(mid, ownCode)), sel && sel.includes(mid))).join(''); 1469} 1470function setRptMatch(mid) { 1471 if (mid === 'all') state.rptMatches = null; 1472 else { 1473 let s = state.rptMatches ? state.rptMatches.slice() : []; 1474 s.includes(mid) ? s = s.filter(x => x !== mid) : s.push(mid); 1475 state.rptMatches = s.length ? s : null; 1476 } 1477 renderRptMatchFilter(); 1478 if (state.rptMode === 'team') buildTeamReportSlides(state.rptTeam); 1479 else buildReportSlides(players[state.playerId]); 1480 showSlide(0); 1481} 1482 1483function buildReportSlides(p0) { 1484 const p = aggPlayerOver(p0, state.rptMatches); 1485 const world = Object.values(players).filter(x => x.minutes > 0); 1486 const posPool = world.filter(x => x.mainPos === p.mainPos); 1487 const vsPos = posPool.length >= 5; 1488 const slides = []; 1489 1490 // 1 · Portada (fÃsico + juego unidos) 1491 const initials = p.name.split(/\s+/).map(w => w[0]).slice(0, 2).join(''); 1492 const coverChip = (key, label) => (p.m[key] || 0) > 0 ? `<span class="chip"><b>${Math.round(p.m[key])}</b> ${label}</span>` : ''; 1493 const ratingChip = p.rating != null ? `<span class="chip rt-chip ${rtTier(p.rating)}"><b>â ${p.rating.toFixed(1)}</b></span>` : ''; 1494 slides.push(` 1495 <span class="rpt-photo pop">${initials}<img src="${p.photo}" alt="" onerror="this.remove()"></span> 1496 <h1 class="rpt-name fadeup" style="animation-delay:.3s">${p.name}</h1> 1497 <p class="rpt-sub fadeup" style="animation-delay:.55s">${flagEmoji(p.teamCode)} ${teamName(p.teamCode)} · ${POS_ICONS[p.mainPos]} ${posName(p.mainPos)}</p> 1498 <div class="rpt-chips fadeup" style="animation-delay:.8s"> 1499 <span class="chip"><b>${Math.round(p.minutes)}'</b> ${t('chipPlayed')}</span> 1500 <span class="chip"><b>${p.matches}</b> ${t('chipPJ')}</span> 1501 ${ratingChip} 1502 ${coverChip('Goals', metricLabel('Goals'))} 1503 ${coverChip('Assists', metricLabel('Assists'))}
1504 <span class="chip"><b>${(p.m.TotalDistance ? (p.m.TotalDistance / 1000).toFixed(1) : 0)} km</b></span> 1505 </div> 1506 <p class="rpt-hint fadeup" style="animation-delay:1.4s">${t('tapHint')} âº</p>`); 1507 1508 // 2 · Motor fÃsico (distancia) 1509 const dist = p.m.TotalDistance || 0; 1510 const avgDist = world.reduce((s, x) => s + (x.m.TotalDistance || 0), 0) / world.length; 1511 const diff = avgDist > 0 ? Math.round((dist / avgDist - 1) * 100) : 0; 1512 slides.push(` 1513 <p class="rpt-title fadeup">ð ${t('rptEngine')}</p> 1514 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${(dist / 1000).toFixed(2)}" data-dec="2" data-suf=" km">0</b></div> 1515 <p class="rpt-sub fadeup" style="animation-delay:.5s">${t('m.TotalDistance')} · ${fmtValue(METRICS[0], p.minutes > 0 ? dist / p.minutes * 90 : 0, true)} ${t('per90Short')}</p> 1516 <p class="rpt-diff ${diff >= 0 ? 'up' : 'down'} fadeup" style="animation-delay:.8s">${diff >= 0 ? t('aboveAvg', { pct: diff }) : t('belowAvg', { pct: Math.abs(diff) })}</p> 1517 <div class="rpt-stats"> 1518 ${rptStat(((p.m.DistanceWalking || 0) / 1000).toFixed(2), 2, ' km', t('m.DistanceWalking'), 1.0)} 1519 ${rptStat(((p.m.DistanceJogging || 0) / 1000).toFixed(2), 2, ' km', t('m.DistanceJogging'), 1.2)} 1520 ${rptStat(Math.round(p.m.DistanceHighSpeedRunning || 0), 0, ' m', t('m.DistanceHighSpeedRunning'), 1.4)} 1521 </div>`); 1522 1523 // 3 · Velocidad (ranking entre jugadores con 45' o más) 1524 const tsDef = METRICS.find(d => d.key === 'TopSpeed'); 1525 const tsRank = rankIn(world.filter(x => x.minutes >= 45), tsDef, p); 1526 slides.push(` 1527 <p class="rpt-title fadeup">ðï¸ ${t('rptSpeed')}</p> 1528 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${(p.m.TopSpeed || 0).toFixed(1)}" data-dec="1" data-suf=" km/h">0</b></div> 1529 <p class="rpt-sub fadeup" style="animation-delay:.5s">${t('m.TopSpeed')}</p> 1530 ${tsRank <= 10 ? `<p class="rpt-diff up fadeup" style="animation-delay:.8s">ð ${t('topNWorld', { n: tsRank, m: t('m.TopSpeed') })}</p>` : ''} 1531 <div class="rpt-stats"> 1532 ${rptStat((p.m.AvgSpeed || 0).toFixed(1), 1, ' km/h', t('m.AvgSpeed'), 1.0)} 1533 ${rptStat(Math.round(p.m.DistanceHighSpeedSprinting || 0), 0, ' m', t('m.DistanceHighSpeedSprinting'), 1.2)} 1534 </div>`); 1535 1536 // 4 · Intensidad 1537 slides.push(` 1538 <p class="rpt-title fadeup">â¡ ${t('rptIntensity')}</p> 1539 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${Math.round(p.m.Sprints || 0)}" data-dec="0" data-suf="">0</b></div> 1540 <p class="rpt-sub fadeup" style="animation-delay:.5s">${t('m.Sprints')}</p> 1541 <div class="rpt-stats"> 1542 ${rptStat(Math.round(p.m.SpeedRuns || 0), 0, '', t('m.SpeedRuns'), .9)} 1543 ${rptStat(p.minutes > 0 ? ((p.m.Sprints || 0) / p.minutes * 90).toFixed(1) : 0, 1, '', `${t('m.Sprints')} · ${t('per90Short')}`, 1.1)} 1544 </div>`); 1545 1546 // 5 · Radar 1547 slides.push(` 1548 <p class="rpt-title fadeup">${vsPos ? `${POS_ICONS[p.mainPos]} ${t('panelRadarPos')}` : t('panelRadar')}</p> 1549 <div class="rpt-radar">${radarSVG(worldRadarAxes(p, vsPos ? posPool : null))}</div>`); 1550 1551 // 5b · Métricas de JUEGO (FIFA) por categorÃa â solo las que tienen datos 1552 const catLabel = id => { const c = (typeof GAME_CATS !== 'undefined') && GAME_CATS.find(x => x.id === id); return c ? (c[lang] || c.en || c.es) : id; }; 1553 ['Ofensiva', 'Creación', 'Defensiva', 'Presión'].forEach(id => { 1554 const c = (typeof GAME_CATS !== 'undefined') && GAME_CATS.find(x => x.id === id); 1555 const s = gameCatSlide(p, id, c ? c.icon : 'ð', catLabel(id)); if (s) slides.push(s); 1556 }); 1557 1558 // 5c · Datos avanzados por jugador (stats_json unidas a las FIFA) 1559 if (typeof playerAdvAgg === 'function') { 1560 const o = playerAdvAgg(p.id, state.rptMatches); 1561 const order = ['goals', 'goalAssist', 'totalAttAssist', 'bigChanceCreated', 'totalScoringAtt', 'wonContest', 'duelWon', 'interception', 'ballRecovery', 'totalClearance', 'accuratePass', 'touches']; 1562 const present = order.filter(k => (o[k] || 0) > 0); 1563 if (present.length) { 1564 const head = present[0], rest = present.slice(1, 5); 1565 slides.push(` 1566 <p class="rpt-title fadeup">ð ${t('atAdvStats') || 'Datos avanzados'}</p> 1567 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${Math.round(o[head])}" data-dec="0" data-suf="">0</b></div> 1568 <p class="rpt-sub fadeup" style="animation-delay:.5s">${atPstatLabel(head)}</p> 1569 <div class="rpt-stats">${rest.map((k, i) => rptStat(Math.round(o[k]), 0, '', atPstatLabel(k), .8 + i * .2)).join('')}</div>`); 1570 } 1571 } 1572 1573 // 6 · Por partido (si jugó) 1574 const recs = [...(recsByPlayer[p.id] || [])] 1575 .filter(r => (r.metrics.TimePlayed || 0) > 0) 1576 .sort((a, b) => APP_DATA.matches[a.matchId].date.localeCompare(APP_DATA.matches[b.matchI
1576d].date)); 1577 if (recs.length) { 1578 const maxD = Math.max(...recs.map(r => r.metrics.TotalDistance || 0), 1); 1579 const distDef = METRICS.find(d => d.key === 'TotalDistance'); 1580 slides.push(` 1581 <p class="rpt-title fadeup">ð ${t('perMatch')}</p> 1582 <div class="rpt-matches"> 1583 ${recs.map((r, i) => ` 1584 <div class="pm-match fadeup" style="animation-delay:${.3 + i * .25}s"> 1585 <span class="mlabel"><b>${matchLabel(APP_DATA.matches[r.matchId])}</b><small>${Math.round(r.metrics.TimePlayed)}'</small></span> 1586 <span class="track"><span class="fill growbar" style="width:${((r.metrics.TotalDistance || 0) / maxD) * 100}%;animation-delay:${.5 + i * .25}s"></span></span> 1587 <span class="mval">${fmtValue(distDef, r.metrics.TotalDistance || 0, false)}</span> 1588 </div>`).join('')} 1589 </div>`); 1590 } 1591 1592 // 7 · Entre mundiales (si disputó Qatar 2022 y 2026) 1593 const cross = crossData(p.id); 1594 if (cross) { 1595 const dDef = METRICS.find(d => d.key === 'TotalDistance'); 1596 const sDef = METRICS.find(d => d.key === 'Sprints'); 1597 const dd = (metricValue(cross.n, dDef, true) - metricValue(cross.q, dDef, true)) / 1000; 1598 const dt = (cross.n.m.TopSpeed || 0) - (cross.q.m.TopSpeed || 0); 1599 const ds = metricValue(cross.n, sDef, true) - metricValue(cross.q, sDef, true); 1600 slides.push(` 1601 <p class="rpt-title fadeup">ð ${t('rptCross')}</p> 1602 <p class="rpt-sub fadeup" style="animation-delay:.2s">Qatar 2022 â 2026</p> 1603 <div class="rpt-stats"> 1604 ${rptStat(dd.toFixed(2), 2, ' km', `${dd >= 0 ? 'ð' : 'ð'} ${t('m.TotalDistance')} · ${t('per90Short')}`, .5)} 1605 ${rptStat(dt.toFixed(1), 1, ' km/h', `${dt >= 0 ? 'ð' : 'ð'} ${t('m.TopSpeed')}`, .75)} 1606 ${rptStat(ds.toFixed(1), 1, '', `${ds >= 0 ? 'ð' : 'ð'} ${t('m.Sprints')} · ${t('per90Short')}`, 1.0)} 1607 </div>`); 1608 } 1609 1610 // 8 · Destacados (si hay logros) 1611 const badges = playerInsights(p); 1612 if (badges.length) { 1613 slides.push(` 1614 <p class="rpt-title fadeup">ð ${t('rptHighlights')}</p> 1615 <div class="rpt-badges"> 1616 ${badges.map((b, i) => `<div class="rpt-badge pop" style="animation-delay:${.25 + i * .2}s">${b}</div>`).join('')} 1617 </div>`); 1618 } 1619 1620 // 8 · Cierre 1621 slides.push(` 1622 <span class="rpt-photo pop">${initials}<img src="${p.photo}" alt="" onerror="this.remove()"></span> 1623 <h1 class="rpt-name fadeup" style="animation-delay:.2s">${p.name}</h1> 1624 <p class="rpt-sub fadeup" style="animation-delay:.4s">â½ FifaPhy · ${t('source')}</p> 1625 <div class="rpt-actions fadeup" style="animation-delay:.6s"> 1626 <button class="quiz-next" onclick="showSlide(0)">${navIcon('replay')} ${t('replay')}</button> 1627 <button class="quiz-next" onclick="shareCard()">${navIcon('share')} ${t('share')}</button> 1628 </div>`); 1629 1630 rptSlides = slides; 1631} 1632 1633/* ââââââââ Informe de Selección (análogo al de jugador) ââââââââ */ 1634function teamReportMatches(code) { 1635 return Object.keys(APP_DATA.matches) 1636 .filter(mid => (recsByMatch[mid] || []).some(r => r.teamCode === code && (r.metrics.TimePlayed || 0) > 0)) 1637 .sort((a, b) => APP_DATA.matches[a].date.localeCompare(APP_DATA.matches[b].date)); 1638} 1639// Agrega métricas (FIFA fÃsico+juego sumadas del equipo) y stats JSON sobre un subconjunto de partidos 1640function teamAggOver(code, matchIds) { 1641 const mids = matchIds && matchIds.length ? matchIds : teamReportMatches(code); 1642 const m = {}, wavg = {}; let minutes = 0, np = 0; 1643 for (const mid of mids) { 1644 for (const r of (recsByMatch[mid] || [])) { 1645 if (r.teamCode !== code || (r.metrics.TimePlayed || 0) <= 0) continue; 1646 const mins = r.metrics.TimePlayed || 0; minutes += mins; 1647 for (const def of AGG_METRICS) { 1648 const v = r.metrics[def.key] ?? 0; 1649 if (def.agg === 'max') m[def.key] = Math.max(m[def.key] || 0, v); 1650 else if (def.agg === 'wavg') wavg[def.key] = (wavg[def.key] || 0) + v * mins; 1651 else m[def.key] = (m[def.key] || 0) + v; 1652 } 1653 } 1654 } 1655 for (const def of AGG_METRICS.filter(d =>
1655 d.agg === 'wavg')) m[def.key] = minutes > 0 ? wavg[def.key] / minutes : 0; 1656 // stats JSON agregadas 1657 const js = {}; let jn = 0, possSum = 0, possN = 0; 1658 if (typeof ANALISIS_TACTICO !== 'undefined') { 1659 for (const mid of mids) { 1660 const M = ANALISIS_TACTICO.matches[mid]; if (!M || !M.stats) continue; 1661 const side = M.home === code ? 'home' : M.away === code ? 'away' : null; if (!side) continue; 1662 const st = M.stats[side] || {}; jn++; 1663 for (const k in st) { if (k === 'possessionPercentage') { possSum += st[k]; possN++; } else js[k] = (js[k] || 0) + st[k]; } 1664 } 1665 } 1666 if (possN) js.possessionPercentage = possSum / possN; 1667 // resultado (GF-GA) y récord 1668 let gf = 0, ga = 0, w = 0, d = 0, l = 0; 1669 for (const mid of mids) { 1670 const mm = APP_DATA.matches[mid]; const home = mm.home === code; 1671 const f = home ? mm.homeScore : mm.awayScore, a = home ? mm.awayScore : mm.homeScore; 1672 if (f == null || a == null) continue; 1673 gf += f; ga += a; if (f > a) w++; else if (f === a) d++; else l++; 1674 } 1675 return { m, js, minutes, matches: mids.length, gf, ga, w, d, l }; 1676} 1677 1678function buildTeamReportSlides(code) { 1679 const T = teamAggOver(code, state.rptMatches); 1680 const slides = []; 1681 const flag = flagEmoji(code), name = teamName(code); 1682 // 1 · Portada 1683 const jc = (k, lbl, pct) => (T.js[k] != null) ? `<span class="chip"><b>${pct ? Math.round(T.js[k]) + '%' : Math.round(T.js[k])}</b> ${lbl}</span>` : ''; 1684 slides.push(` 1685 <span class="rpt-photo pop" style="font-size:64px">${flag}</span> 1686 <h1 class="rpt-name fadeup" style="animation-delay:.3s">${name}</h1> 1687 <p class="rpt-sub fadeup" style="animation-delay:.55s">${T.matches} ${t('chipPJ')} · ${T.w}V ${T.d}E ${T.l}D</p> 1688 <div class="rpt-chips fadeup" style="animation-delay:.8s"> 1689 <span class="chip"><b>${T.gf}-${T.ga}</b> ${t('rptGoals') || 'goles'}</span> 1690 ${jc('possessionPercentage', t('m.Possession') || 'posesión', true)} 1691 <span class="chip"><b>${(T.m.TotalDistance ? (T.m.TotalDistance / 1000).toFixed(0) : 0)} km</b></span> 1692 </div> 1693 <p class="rpt-hint fadeup" style="animation-delay:1.4s">${t('tapHint')} âº</p>`); 1694 // 2 · FÃsico del equipo 1695 slides.push(` 1696 <p class="rpt-title fadeup">ð ${t('rptEngine')}</p> 1697 <div class="rpt-big fadeup" style="animation-delay:.2s"><b data-count="${(T.m.TotalDistance / 1000).toFixed(1)}" data-dec="1" data-suf=" km">0</b></div> 1698 <p class="rpt-sub fadeup" style="animation-delay:.5s">${t('m.TotalDistance')} · ${name}</p> 1699 <div class="rpt-stats"> 1700 ${rptStat(Math.round(T.m.Sprints || 0), 0, '', t('m.Sprints'), 1.0
1700)} 1701 ${rptStat((T.m.TopSpeed || 0).toFixed(1), 1, ' km/h', t('m.TopSpeed'), 1.2)} 1702 ${rptStat(Math.round(T.m.DistanceHighSpeedRunning || 0), 0, ' m', t('m.DistanceHighSpeedRunning'), 1.4)} 1703 </div>`); 1704 // 3 · Juego por categorÃa 1705 const pObj = { m: T.m }; 1706 ['Ofensiva', 'Creación', 'Defensiva', 'Presión'].forEach(id => { 1707 const c = (typeof GAME_CATS !== 'undefined') && GAME_CATS.find(x => x.id === id); 1708 const s = gameCatSlide(pObj, id, c ? c.icon : 'ð', c ? (c[lang] || c.en || c.es) : id); 1709 if (s) slides.push(s); 1710 }); 1711 // 4 · Datos avanzados (posesión, presión, entradas) 1712 const jstat = (k, lbl, dec) => (T.js[k] != null && T.js[k] !== 0) ? rptStat(dec ? T.js[k].toFixed(1) : Math.round(T.js[k]), dec || 0, '', lbl, 0) : ''; 1713 const jrows = [jstat('possessionPercentage', 'Posesión %', 0), jstat('ppda', 'PPDA', 1), jstat('finalThirdEntries', 'Entradas último tercio'), jstat('penAreaEntries', 'Entradas al área'), jstat('totalCross', 'Centros'), jstat('cornerTaken', 'Córners')].filter(Boolean); 1714 if (jrows.length) slides.push(`<p class="rpt-title fadeup">ð ${t('atAdvStats') || 'Datos avanzados'}</p><div class="rpt-stats" style="flex-wrap:wrap">${jrows.join('')}</div>`); 1715 // 5 · Jugadores destacados (por distancia en los partidos seleccionados) 1716 const mids = (state.rptMatches && state.rptMatches.length) ? state.rptMatches : teamReportMatches(code); 1717 const byP = {}; 1718 mids.forEach(mid => (recsByMatch[mid] || []).forEach(r => { 1719 if (r.teamCode !== code || (r.metrics.TimePlayed || 0) <= 0) return; 1720 const e = byP[r.playerId] || (byP[r.playerId] = { p: players[r.playerId], dist: 0, min: 0 }); 1721 e.dist += r.metrics.TotalDistance || 0; e.min += r.metrics.TimePlayed || 0; 1722 })); 1723 const top = Object.values(byP).filter(e => e.p).sort((a, b) => b.dist - a.dist).slice(0, 5); 1724 if (top.length) { 1725 const distDef = METRICS.find(d => d.key === 'TotalDistance'); 1726 slides.push(`<p class="rpt-title fadeup">ð ${t('physMvp') || 'Destacados'}</p> 1727 <div class="rpt-matches">${top.map((e, i) => ` 1728 <div class="pm-match fadeup" style="animation-delay:${.3 + i * .2}s"> 1729 <span class="mlabel"><b>${e.p.name}</b><small>${Math.round(e.min)}'</small></span> 1730 <span class="mval">${fmtValue(distDef, e.dist, false)}</span> 1731 </div>`).join('')}</div>`); 1732 } 1733 // 6 · Cierre 1734 slides.push(` 1735 <span class="rpt-photo pop" style="font-size:64px">${flag}</span> 1736 <h1 class="rpt-name fadeup" style="animation-delay:.2s">${name}</h1> 1737 <p class="rpt-sub fadeup" style="animation-delay:.4s">â½ FifaPhy · ${t('source')}</p> 1738 <div class="rpt-actions fadeup" style="animation-delay:.6s"> 1739 <button class="quiz-next" onclick="showSlide(0)">${navIcon('replay')} ${t('replay')}</button> 1740 </div>`); 1741 rptSlides = slides; 1742} 1743 1744function openTeamReport(code, from) { 1745 state.rptMode = 'team'; state.rptTeam = code; state.rptMatches = null; 1746 state.teamPageCode = code; // para volver a la ficha al cerrar 1747 state.rptTeamBack = from || state.rptTeamBack || 'team'; 1748 renderRptMatchFilter(); 1749 buildTeamReportSlides(code); 1750 show('report'); showSlide(0); 1751} 1752// Pantalla propia: elegir selección para su informe 1753function goTeamReportPick() { 1754 state.rptTeamBack = 'pick'; 1755 document.getElementById('teamreport-title').innerHTML = `${navIcon('doc')} ${t('teamReport')}`; 1756 const codes = [...new Set(Object.values(APP_DATA.matches).flatMap(m => [m.home, m.away]))] 1757 .filter(c => teamReportMatches(c).length).sort((a, b) => teamName(a).localeCompare(teamName(b))); 1758 document.getElementById('teamreport-body').innerHTML = ` 1759 <div class="panel"><p class="legend">${t('atPickTeam')}</p></div> 1760 <div class="at-teamgrid">${codes.map(c => `<button class="at-team" onclick="openTeamFullReport('${c}')"> 1761 <span class="att-flag">${flagEmoji(c)}</span><b>${teamName(c)}</b></button>`).join('')}</div>`; 1762 show('teamreport'); 1763} 1764 1765function renderRptProgress() { 1766 document.getElementById('rpt-progress').innerHTML = rptSlides.map((_, i) => 1767 `<i class="${i < rptIdx ? 'done' : i === rptIdx ? 'cur' : ''}"><b></b></i>`).join(''); 1768} 1769 1770function animateCounters() { 1771 const els = document.querySelectorAll('#report-slide [data-count]');
1772 els.forEach(el => { 1773 const target = parseFloat(el.dataset.count); 1774 const dec = parseInt(el.dataset.dec || '0', 10); 1775 const suf = el.dataset.suf || ''; 1776 const t0 = Date.now(); 1777 const step = () => { 1778 const k = Math.min(1, (Date.now() - t0) / 1300); 1779 const eased = 1 - Math.pow(1 - k, 3); 1780 el.textContent = (target * eased).toFixed(dec) + suf; 1781 if (k < 1) raf(step); 1782 }; 1783 step(); 1784 }); 1785} 1786 1787function showSlide(i) { 1788 if (rptTimer) { clearTimeout(rptTimer); rptTimer = null; } 1789 rptIdx = Math.max(0, Math.min(i, rptSlides.length - 1)); 1790 document.getElementById('report-slide').innerHTML = rptSlides[rptIdx]; 1791 renderRptProgress(); 1792 animateCounters(); 1793 const scr = document.getElementById('screen-report'); 1794 scr.classList.toggle('last', rptIdx === rptSlides.length - 1); 1795 if (rptIdx < rptSlides.length - 1) rptTimer = setTimeout(() => showSlide(rptIdx + 1), RPT_MS); 1796} 1797function nextSlide() { if (rptIdx < rptSlides.length - 1) showSlide(rptIdx + 1); } 1798function prevSlide() { showSlide(rptIdx - 1); } 1799 1800function openReport(id) { 1801 state.playerId = id; 1802 state.rptMode = 'player'; 1803 state.rptMatches = null; 1804 renderRptMatchFilter(); 1805 buildReportSlides(players[id]); 1806 show('report'); 1807 showSlide(0); 1808} 1809 1810function closeReport() { 1811 if (rptTimer) { clearTimeout(rptTimer); rptTimer = null; } 1812 if (state.rptMode === 'team') { 1813 if (state.rptTeamBack === 'full' && typeof renderTeamFullReport === 'function') { renderTeamFullReport(state.rptTeam); show('teamfull'); return; } 1814 if (state.rptTeamBack === 'pick') { goTeamReportPick(); return; } 1815 if (typeof renderTeamPage === 'function') renderTeamPage(); 1816 show('team'); 1817 } else { 1818 renderPlayer(); 1819 show('player'); 1820 } 1821} 1822 1823/* ââââââââ Once FÃsico ââââââââ */ 1824const FORMATIONS = ['auto', '4-3-3', '4-4-2', '3-5-2', '3-4-3', '5-3-2', '5-4-1', '4-5-1']; 1825 1826// Lateralidad (0=izq, 100=der) de cada posición real, para ordenar a los jugadores en su lÃnea. 1827// LB-LCB-(CB)-RCB-RB en defensa; LW-LMF-CMF-DMF-RMF-RW en el medio; LW-ST-RW arriba. 1828const REAL_X = { 1829 GK: 50, 1830 LB: 12, LCB: 32, CB: 50, RCB: 68, RB: 88, 1831 LW: 10, LMF: 26, LMC: 30, CMF: 44, DMF: 60, AMF: 50, RMF: 74, RW: 90, 1832 ST: 50, 1833}; 1834function realX(label) { return label && label in REAL_X ? REAL_X[label] : 50; } 1835 1836// Molde de huecos por lÃnea (1=DEF,2=MED,3=DEL) según el nº de jugadores de esa lÃnea. 1837// pickEleven() solo asigna cada etiqueta a un jugador cuya posición real coincida de 1838// verdad; nunca se le "cuelga" una etiqueta falsa a un jugador que no la tiene. 1839const LINE_TEMPLATE = { 1840 1: { 3: ['LB', 'CB', 'RB'], 4: ['LB', 'LCB', 'RCB', 'RB'], 5: ['LB', 'LCB', 'CB', 'RCB', 'RB'] }, 1841 2: { 3: ['LMF', 'CMF', 'RMF'], 4: ['LMF', 'CMF', 'DMF', 'RMF'], 5: ['LW', 'LMF', 'CMF', 'RMF', 'RW'] }, 1842 3: { 1: ['ST'], 2: ['ST', 'ST'], 3: ['LW', 'ST', 'RW'] }, 1843}; 1844function lineTemplate(lineIdx, n) { return (LINE_TEMPLATE[lineIdx] && LINE_TEMPLATE[lineIdx][n]) || null; } 1845 1846function pickEleven(cands, formation) { 1847 const byLine = [[], [], [], []]; 1848 cands.forEach(x => byLine[x.pos] && byLine[x.pos].push(x)); 1849 byLine.forEach(l => l.sort((a, b) => b.score - a.score)); 1850 const take = formation && formation !== 'auto' 1851 ? [1, ...formation.split('-').map(Number)] 1852 : [1, 4, 3, 3]; 1853 for (let pos = 0; pos < 4; pos++) take[pos] = Math.min(take[pos], byLine[pos].length); 1854 let deficit = 11 - take.reduce((a, b) => a + b, 0); 1855 const order = [2, 1, 3, 0]; // rellenar huecos con medios, luego defensas, delanteros, portero 1856 while (deficit > 0) { 1857 const pos = order.find(p => byLine[p].length > take[p]); 1858 if (pos === undefined) break; 1859 take[pos]++; deficit--; 1860 } 1861 const lines = byLine.map((l, i) => { 1862 const n = take[i]; 1863 const tmpl = lineTemplate(i, n); 1864 if (!tmpl) { 1865 const picked = l.slice(0, n).sort((a, b) => realX(a.posR) - realX(b.posR) || (a.p && a.p.minutes || 0) - (b.p && b.p.minutes || 0)); 1866 picked.forEach(x => { x.slotLabel = x.posR || posShort(x.pos); }); 1867 return picked; 1868 } 1869 // Cada hueco del molde (p.ej. LB-LCB-CB-RCB-RB) se cubre con el mejor candidato 1870 // cuya posición real coincida EXACTAMENTE con ese hueco â asà dos jugadores con la 1871 // misma posición real (p.ej. dos RB) nunca compiten por huecos que no son suyos. 1872 const used = new Set(); 1873 const slots = tmpl.map(slotLabel => { 1874 const cand = l.find(x => !used.has(x) && x.posR === slotLabel); 1875 if (cand) used.add(cand); 1876 return { label: slotLabel, x: cand || null }; 1877 }); 1878 // Huecos sin coincidencia real exacta: rellenar con los mejores restantes, mostrando 1879 // SU propia posición real (nunca la etiqueta del hueco, que serÃa falsa). 1880 const leftovers = l.filter(x => !used.has(x));
1881 slots.forEach(s => { if (!s.x) s.x = leftovers.shift() || null; }); 1882 return slots.filter(s => s.x).map(s => { 1883 s.x.slotLabel = s.x.posR || s.label; 1884 return s.x; 1885 }); 1886 }); 1887 return { 1888 lines, 1889 formation: take.slice(1).join('-'), 1890 }; 1891} 1892 1893// want: lÃneas requeridas [POR,DEF,MED,DEL] de una táctica fija, o null en modo auto. 1894// En torneo se relaja el mÃnimo de minutos (90'â45'â>0') hasta poder cumplir la táctica pedida. 1895function xiCandidates(want) { 1896 if (state.xiScope === 'match') { 1897 return (recsByMatch[state.xiMatch] || []) 1898 .map(r => ({ id: r.playerId, name: r.name, code: r.teamCode, pos: r.position, score: recScore(r), p: players[r.playerId], 1899 posR: (players[r.playerId] && players[r.playerId].posRealByMatch && players[r.playerId].posRealByMatch[state.xiMatch]) || null })) 1900 .filter(x => x.score != null); 1901 } 1902 const at = th => Object.values(players) 1903 .filter(p => p.minutes >= th && p.score != null) 1904 .map(p => ({ id: p.id, name: p.name, code: p.teamCode, pos: p.mainPos, score: p.score, p, posR: p.posReal })); 1905 const thresholds = [90, 45, 0.01]; 1906 for (const th of thresholds) { 1907 const c = at(th); 1908 if (th === 0.01) return c; 1909 if (want) { 1910 const counts = [0, 0, 0, 0]; 1911 c.forEach(x => { if (x.pos < 4) counts[x.pos]++; }); 1912 if (want.every((w, i) => counts[i] >= w)) return c; 1913 } else if (c.length >= 14) { 1914 return c; 1915 } 1916 } 1917} 1918 1919function goEleven() { 1920 state.xiScope = state.xiScope || 'tour'; 1921 if (!state.xiMatch) { 1922 state.xiMatch = Object.entries(APP_DATA.matches).sort((a, b) => a[1].date.localeCompare(b[1].date))[0][0]; 1923 } 1924 renderEleven(); 1925 show('eleven'); 1926} 1927function setXiScope(s) { state.xiScope = s; renderEleven(); } 1928function setXiMatch(id) { state.xiMatch = id; renderEleven(); } 1929function setXiFormation(f) { state.xiFormation = f; renderEleven(); } 1930 1931const XI_Y = [87, 64, 41, 17]; // % vertical: POR, DEF, MED, DEL (ataque arriba) 1932const xiLastName = name => name.split(/\s+/).slice(-1)[0]; 1933 1934// Chip de jugador en el campo (Once FÃsico y Ratings campo). 1935function xiCardHTML(x, leftPct, topPct, tierClass, onClick, scoreDisplay) { 1936 const photo = x.p && x.p.photo ? `<img src="${x.p.photo}" alt="" loading="lazy" onerror="this.remove()">` : ''; 1937 return ` 1938 <div class="xi-chip" style="left:${leftPct}%;top:${topPct}%" onclick="${onClick}"> 1939 <span class="xi-score ${tierClass}">${scoreDisplay != null ? scoreDisplay : x.score}</span> 1940 <span class="avatar xi-avatar">${xiLastName(x.name)[0]}${photo}</span> 1941 <b>${xiLastName(x.name)}</b> 1942 <small>${flagEmoji(x.code)} ${x.slotLabel}</small> 1943 </div>`; 1944} 1945 1946function renderEleven() { 1947 document.getElementById('eleven-title').innerHTML = `${navIcon('ball')} ${t('elevenM')}`; 1948 const isMatch = state.xiScope === 'match'; 1949 const fmt = state.xiFormation || 'auto'; 1950 const want = fmt !== 'auto' ? [1, ...fmt.split('-').map(Number)] : null; 1951 const xi = pickEleven(xiCandidates(want), fmt); 1952 const allXi = xi.lines.flat(); 1953 const avg = allXi.length ? Math.round(allXi.reduce((s, x) => s + x.score, 0) / allXi.length) : 0; 1954 1955 const matchOpts = Object.entries(APP_DATA.matches) 1956 .sort((a, b) => a[1].date.localeCompare(b[1].date)) 1957 .map(([id, m]) => `<option value="${id}" ${id === state.xiMatch ? 'selected' : ''}>${matchLabel(m)}</option>`).join(''); 1958 1959 document.getElementById('eleven-body').innerHTML = ` 1960 <div class="toggle" style="margin-bottom:10px"> 1961 <button class="${isMatch ? '' : 'on'}" onclick="setXiScope('tour')">${navIcon('trophy')} ${t('quizScopeTour')}</button> 1962 <button class="${isMatch ? 'on' : ''}" onclick="setXiScope('match')">${navIcon('pitch')} ${t('quizScopeMatch')}</button> 1963 </div> 1964 ${isMatch ? `<select class="xiselect" onchange="setXiMatch(this.value)">${matchOpts}</select>` : ''} 1965 <div class="poschips" style="margin-bottom:10px"> 1966 ${FORMATIONS.map(f => `<button class="${f === fmt ? 'on' : ''}" onclick="setXiFormation('${f}')">${f === 'auto' ? 'ð¤ Auto' : f}</button>`).join('')} 1967 </div> 1968 <div class="pitch"> 1969 <svg class="pitch-lines" viewBox="0 0 100 150" preserveAspectRatio="none"> 1970 <rect x="2" y="2" width="96" height="146" rx="2"/> 1971 <line x1="2" y1="75" x2="98" y2="75"/> 1972 <circle cx="50" cy="75" r="12"/> 1973 <rect x="26" y="2" width="48" height="15"/> 1974 <rect x="26" y="133" width="48" height="15"/> 1975 </svg> 1976 ${xi.lines.map((line, li) => line.map((x, i, arr) => 1977 xiCardHTML(x, ((i + 1) / (arr.length + 1)) * 100, XI_Y[li], scoreTier(x.score), `openPlayer('${x.id}', 'eleven')`) 1978 ).join('')).join('')} 1979 </div> 1980 <p class="legend" style="text-align:center">${xi.formation} · â ${avg} · ${TOURNAMENTS[tour].label}${isMatch ? ' · ' + matchLabel(APP_DATA.matches[state.xiMatch]) : ''}</p> 1981 <p class="empty-note">${t('elevenNote')}</p> 1982 <p class="empty-note">${t('elevenDisclaimer')}</p> 1983 <button class="quiz-next" onclick="shareEleven()">${navIcon('share')} ${t('shareBtn')}</button>`; 1984} 1985 1986/* Exportar el once como imagen (canvas, avatares de iniciales para no contaminar el lienzo) */ 1987function shareEleven() { 1988 const fmt = state.xiFormation || 'auto'; 1989 const want = fmt !== 'auto' ? [1, ...fmt.split('-').map(Number)] : null; 1990 const xi = pickEleven(xiCandidates(want), fmt); 1991 const isMatch = state.xiScope === 'match';
1992 const W = 1080, H = 1500; 1993 const cv = document.createElement('canvas'); 1994 cv.width = W; cv.height = H; 1995 const c = cv.getContext('2d'); 1996 1997 c.fillStyle = '#0b1426'; c.fillRect(0, 0, W, H); 1998 c.textAlign = 'center'; 1999 c.fillStyle = '#eaf2ff'; c.font = '900 56px sans-serif'; 2000 c.fillText(`â½ ${t('elevenM').toUpperCase()}`, W / 2, 90); 2001 c.fillStyle = '#8da3c5'; c.font = '600 34px sans-serif'; 2002 c.fillText(`${TOURNAMENTS[tour].label}${isMatch ? ' · ' + matchLabel(APP_DATA.matches[state.xiMatch]) : ''} · ${xi.formation}`, W / 2, 145); 2003 2004 // Campo 2005 const px = 60, py = 190, pw = W - 120, ph = 1150; 2006 const grad = c.createLinearGradient(0, py, 0, py + ph); 2007 grad.addColorStop(0, '#1a7a44'); grad.addColorStop(1, '#125c33'); 2008 c.fillStyle = grad; 2009 c.beginPath(); c.roundRect(px, py, pw, ph, 26); c.fill(); 2010 c.strokeStyle = 'rgba(255,255,255,.4)'; c.lineWidth = 3; 2011 c.strokeRect(px + 20, py + 20, pw - 40, ph - 40); 2012 c.beginPath(); c.moveTo(px + 20, py + ph / 2); c.lineTo(px + pw - 20, py + ph / 2); c.stroke(); 2013 c.beginPath(); c.arc(px + pw / 2, py + ph / 2, 110, 0, 7); c.stroke(); 2014 c.strokeRect(px + pw / 2 - 220, py + 20, 440, 120); 2015 c.strokeRect(px + pw / 2 - 220, py + ph - 140, 440, 120); 2016 2017 xi.lines.forEach((line, li) => { 2018 const y = py + (XI_Y[li] / 100) * ph; 2019 line.forEach((x, i) => { 2020 const cx = px + ((i + 1) / (line.length + 1)) * pw; 2021 c.fillStyle = '#1d3160'; c.beginPath(); c.arc(cx, y, 52, 0, 7); c.fill(); 2022 c.strokeStyle = '#2dd4a8'; c.lineWidth = 5; c.stroke(); 2023 c.fillStyle = '#2dd4a8'; c.font = '900 40px sans-serif'; 2024 c.fillText(x.name.split(/\s+/).map(w => w[0]).slice(0, 2).join(''), cx, y + 14); 2025 // burbuja de nota 2026 const tier = scoreTier(x.score); 2027 c.fillStyle = tier === 'gold' ? '#f0c75e' : tier === 'silver' ? '#cdd7e4' : '#c9905f'; 2028 c.beginPath(); c.arc(cx + 44, y - 40, 28, 0, 7); c.fill(); 2029 c.fillStyle = '#1a1408'; c.font = '900 28px sans-serif'; 2030 c.fillText(x.score, cx + 44, y - 30); 2031 c.fillStyle = '#fff'; c.font = '800 28px sans-serif'; 2032 c.fillText(xiLastName(x.name), cx, y + 96); 2033 c.fillStyle = 'rgba(255,255,255,.75)'; c.font = '600 24px sans-serif'; 2034 c.fillText(`${flagGlyph(x.code)} ${x.slotLabel}`, cx, y + 128); 2035 }); 2036 }); 2037 2038 c.fillStyle = '#8da3c5'; c.font = '600 30px sans-serif'; 2039 c.fillText('â½ FifaPhy · fifaphy.vercel.app', W / 2, H - 90); 2040 c.font = '400 26px sans-serif'; 2041 c.fillText(t('source'), W / 2, H - 45); 2042 2043 cv.toBlob(async blob => { 2044 if (!blob) return; 2045 const file = new File([blob], 'once-fisico.png', { type: 'image/png' }); 2046 if (navigator.canShare && navigator.canShare({ files: [file] })) { 2047 try { await navigator.share({ files: [file], title: t('elevenM') }); return; } catch (e) {} 2048 } 2049 const a = document.createElement('a'); 2050 a.href = URL.createObjectURL(blob); 2051 a.download = file.name; 2052 a.click(); 2053 setTimeout(() => URL.revokeObjectURL(a.href), 5000); 2054 }, 'image/png'); 2055} 2056 2057/* ââââââââ Tarjeta compartible (canvas) ââââââââ */ 2058function loadImage(src) { 2059 return new Promise((res, rej) => { 2060 const img = new Image(); 2061 img.crossOrigin = 'anonymous'; 2062 img.onload = () => res(img); 2063 img.onerror = rej; 2064 img.src = src; 2065 }); 2066} 2067 2068async function shareCard() { 2069 const p = players[state.playerId]; 2070 const W = 720, H = 1080; 2071 const cv = document.createElement('canvas'); 2072 cv.width = W; cv.height = H; 2073 const c = cv.getContext('2d'); 2074 2075 // Fondo y carta según la categorÃa de la nota (oro / plata / bronce) 2076 c.fillStyle = '#0b1426'; c.fillRect(0, 0, W, H); 2077 const score = p.score; 2078 const tier = score != null ? scoreTier(score) : 'bronze'; 2079 const PAL = { 2080 gold: { a: '#f8e08e', b: '#cfa138', txt: '#3a2c00', sub: '#6e5512' }, 2081 silver: { a: '#eef2f8', b: '#a6b5c9', txt: '#1f2c3d', sub: '#48586c' }, 2082 bronze: { a: '#e0a878', b: '#9c6a3a', txt: '#371d05', sub: '#5f3a14' }, 2083 }[tier]; 2084 const cx0 = 50, cy0 = 40, cw = W - 100, ch = 930; 2085 const g = c.createLinearGradient(0, cy0, 0, cy0 + ch); 2086 g.addColorStop(0, PAL.a); g.addColorStop(1, PAL.b); 2087 c.fillStyle = g; 2088 c.beginPath(); c.roundRect(cx0, cy0, cw, ch, 36); c.fill(); 2089 c.strokeStyle = 'rgba(0,0,0,.25)'; c.lineWidth = 3; 2090 c.beginPath(); c.roundRect(cx0 + 14, cy0 + 14, cw - 28, ch - 28, 26); c.stroke(); 2091 2092 // Columna izquierda: nota, posición y bandera 2093 c.textAlign = 'center'; 2094 c.fillStyle = PAL.txt; 2095 c.font = '900 120px sans-serif'; 2096 c.fillText(score != null ? score : 'â', 175, 250); 2097 c.font = '800 44px sans-serif'; 2098 c.fillText(posShort(p.mainPos), 175, 310); 2099 c.font = '60px sans-serif'; 2100 c.fillText(flagGlyph(p.teamCode), 175, 395); 2101 2102 // Foto (si el servidor permite CORS) o iniciales â recorte cuadrado de la parte superior (cara/torso) 2103 const fx = 460, fy = 280, fr = 150; 2104 try { 2105 const img = await loadImage(p.photo); 2106 c.save(); 2107 c.beginPath(); c.arc(fx, fy, fr, 0, 7); c.clip(); 2108 const side = Math.min(img.width, img.height) * 0.62; 2109 const sx = (img.width - side) / 2; 2110 const sy = Math.min(img.height * 0.03, img.height - side); 2111 c.drawImage(img, sx, sy, side, side, fx - fr, fy - fr, fr * 2, fr * 2);
2112 c.restore(); 2113 } catch (e) { 2114 c.fillStyle = 'rgba(0,0,0,.22)'; c.beginPath(); c.arc(fx, fy, fr, 0, 7); c.fill(); 2115 c.fillStyle = PAL.a; c.font = '900 110px sans-serif'; 2116 c.fillText(p.name.split(/\s+/).map(w => w[0]).slice(0, 2).join(''), fx, fy + 38); 2117 } 2118 c.strokeStyle = PAL.txt; c.lineWidth = 5; 2119 c.beginPath(); c.arc(fx, fy, fr + 3, 0, 7); c.stroke(); 2120 2121 // Nombre 2122 c.fillStyle = PAL.txt; 2123 c.font = '900 52px sans-serif'; 2124 c.fillText(p.name.toUpperCase(), W / 2, 530); 2125 c.strokeStyle = 'rgba(0,0,0,.3)'; c.lineWidth = 2; 2126 c.beginPath(); c.moveTo(140, 560); c.lineTo(W - 140, 560); c.stroke(); 2127 2128 // Seis atributos con acrónimos universales estilo FUT (percentiles vs su posición, por 90') 2129 const STAT_ABBR = [ 2130 ['TotalDistance', 'DIS'], ['DistanceHighSpeedRunning', 'HSR'], ['Sprints', 'SPR'], 2131 ['SpeedRuns', 'RUN'], ['TopSpeed', 'TOP'], ['AvgSpeed', 'AVG'], 2132 ]; 2133 STAT_ABBR.forEach(([key, abbr], i) => { 2134 const col = i % 2, row = Math.floor(i / 2); 2135 const x = col === 0 ? 150 : 420, y = 645 + row * 88; 2136 c.textAlign = 'right'; 2137 c.fillStyle = PAL.txt; c.font = '900 54px sans-serif'; 2138 c.fillText(p.pcts ? p.pcts[key] : 'â', x + 80, y); 2139 c.textAlign = 'left'; 2140 c.fillStyle = PAL.sub; c.font = '800 38px sans-serif'; 2141 c.fillText(abbr, x + 100, y - 4); 2142 }); 2143 2144 // Pie de carta y branding 2145 c.textAlign = 'center'; 2146 c.fillStyle = PAL.sub; c.font = '600 28px sans-serif'; 2147 c.fillText(`${TOURNAMENTS[tour].label} · ${Math.round(p.minutes)}' · ${p.matches} ${t('chipPJ')} · ${t('scoreLabel')}`, W / 2, cy0 + ch - 38); 2148 c.fillStyle = '#8da3c5'; c.font = '600 26px sans-serif'; 2149 c.fillText('â½ FifaPhy · fifaphy.vercel.app', W / 2, H - 62); 2150 c.font = '400 24px sans-serif'; 2151 c.fillText(t('source'), W / 2, H - 28); 2152 2153 cv.toBlob(async blob => { 2154 if (!blob) return; 2155 const file = new File([blob], `${p.name.replace(/\s+/g, '_')}.png`, { type: 'image/png' }); 2156 if (navigator.canShare && navigator.canShare({ files: [file] })) { 2157 try { await navigator.share({ files: [file], title: p.name }); return; } catch (e) {} 2158 } 2159 const a = document.createElement('a'); 2160 a.href = URL.createObjectURL(blob); 2161 a.download = file.name; 2162 a.click(); 2163 setTimeout(() => URL.revokeObjectURL(a.href), 5000); 2164 }, 'image/png'); 2165} 2166 2167/* ââââââââ Splash: carga animada ââââââââ */ 2168(function splash() { 2169 injectThemeButtons(); 2170 applyTheme(); 2171 applyStatic(); 2172 checkRecords(); 2173 const bar = document.getElementById('progress-bar'); 2174 const info = document.getElementById('load-info'); 2175 const nPlayers = Object.keys(players).length; 2176 const nTeams = Object.keys(teams).length; 2177 2178 const steps = [ 2179 [25, t('loading')], 2180 [55, t('processed', { n: APP_DATA.records.length })], 2181 [80, t('miniStats', { p: nPlayers, s: nTeams })], 2182 [100, t('ready')], 2183 ]; 2184 steps.forEach(([w, txt], i) => setTimeout(() => { bar.style.width = w + '%'; info.textContent = txt; }, 350 * (i + 1))); 2185 setTimeout(() => (typeof goModes === 'function' ? goModes() : goHome()), 350 * steps.length + 500); 2186 setTimeout(() => { if (typeof maybeShowDonateModal === 'function') maybeShowDonateModal(); }, 350 * steps.length + 1200); 2187})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.