PageSourceSearch

https://fifaphy.vercel.app/app.js

js fifaphy.vercel.app collected 2026-10-03 06:56:26 UTC 112,960 bytes, 2,187 lines download raw bytes

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=&quot;flag-emoji&quot;>${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=&quot;flag-emoji&quot;>${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>&nbsp;${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.