mztitor-viewer

Expone window.buildMZTITORViewer() que monta el visor completo

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/585646/1867797/mztitor-viewer.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ============================================================
// MZTITOR VIEWER — módulo generado automáticamente desde index.html
// NO EDITAR A MANO: regenerar con build_viewer_module.py
// Expone window.buildMZTITORViewer() que monta el visor completo.
// ============================================================
(function(){
  'use strict';

  const MZTITOR_CSS  = "\n:root{\n  --ui-bg:#0a0a0f;--ui-panel:#13131a;--ui-panel2:#16161e;--ui-border:#252530;\n  --ui-text:#dde0f0;--ui-muted:#9a9ab8;--ui-dim:#84848f;--accent:#facc15;\n  --pass-ok:#1aff8a;--pass-fail:#ff4444;--shot:#ff9500;--header-shot:#c084fc;\n  --font:'Courier New',monospace;\n  /* Base font scale \u2014 multiply all text sizes by 1.25 */\n  --fs-xs:10px;   /* was 8px  */\n  --fs-sm:11px;   /* was 9px  */\n  --fs-md:12px;   /* was 10px */\n  --fs-lg:14px;   /* was 11px */\n  --fs-xl:18px;   /* was 14px */\n  --fs-score:28px;/* was 22px */\n}\n*{box-sizing:border-box;margin:0;padding:0;}\nhtml,body{height:100%;overflow:hidden;}\nbody{background:var(--ui-bg);color:var(--ui-text);font-family:var(--font);display:flex;flex-direction:column;}\n\n/* \u2500\u2500 TOPBAR \u2500\u2500 */\n#topbar{height:52px;background:var(--ui-panel);border-bottom:1px solid var(--ui-border);display:flex;align-items:center;gap:5px;padding:0 10px;flex-shrink:0;overflow-x:auto;}\n#topbar::-webkit-scrollbar{height:2px;}#topbar::-webkit-scrollbar-thumb{background:var(--ui-border);}\n.tb-btn{background:#1a1a26;color:var(--ui-text);border:1px solid var(--ui-border);padding:5px 11px;cursor:pointer;border-radius:4px;font-size:var(--fs-lg);font-family:var(--font);white-space:nowrap;transition:background .1s;}\n.tb-btn:hover{background:#222230;}.tb-btn.active{background:var(--accent);color:#111;border-color:var(--accent);}\n#timeline{flex:1;min-width:60px;max-width:260px;accent-color:var(--accent);cursor:pointer;}\n#tb-info{font-size:var(--fs-md);color:var(--ui-muted);white-space:nowrap;}#tb-info span{color:var(--accent);}\n.tb-sep{width:1px;height:28px;background:var(--ui-border);flex-shrink:0;}\n#matchInput{font-size:var(--fs-md);color:var(--ui-muted);font-family:var(--font);cursor:pointer;}\n#matchInput::file-selector-button{background:#1a261a;color:#1aff8a;border:1px solid #1a3a1a;padding:4px 8px;border-radius:3px;cursor:pointer;font-size:var(--fs-md);margin-right:5px;}\n.half-btn{background:#1a1a26;color:var(--ui-muted);border:1px solid var(--ui-border);padding:3px 8px;\n  cursor:pointer;border-radius:3px;font-size:var(--fs-xs);font-family:var(--font);white-space:nowrap;transition:background .1s;}\n.half-btn:hover{background:#222230;}\n.half-btn.active{background:var(--accent);color:#111;border-color:var(--accent);}\n#time-window{display:flex;align-items:center;gap:3px;margin-left:4px;\n  background:#13131a;border:1px solid var(--ui-border);border-radius:4px;padding:2px 4px;}\n.tw-arrow{background:#1a1a26;color:var(--ui-muted);border:1px solid var(--ui-border);\n  border-radius:3px;cursor:pointer;font-size:var(--fs-sm);padding:1px 6px;line-height:1.1;\n  font-family:var(--font);transition:background .1s;}\n.tw-arrow:hover{background:#2a2a3a;color:var(--accent);}\n#tw-label{font-size:var(--fs-xs);color:var(--accent);min-width:56px;text-align:center;font-weight:bold;}\n\n/* \u2500\u2500 BARRA DE RELATO / CONTROLES SUPERIORES \u2500\u2500 */\n#relato-bar{height:30px;background:#0d0d14;border-bottom:1px solid var(--ui-border);\n  display:flex;align-items:center;flex-shrink:0;overflow:hidden;padding:0 8px;gap:8px;}\n#rb-left,#rb-right{flex:0 0 auto;display:flex;align-items:center;}\n#rb-left{justify-content:flex-start;}\n#rb-right{justify-content:flex-end;}\n#rb-center{flex:1;display:flex;align-items:center;justify-content:center;min-width:0;overflow:hidden;}\n#relato-txt{font-family:var(--font);font-size:var(--fs-md);font-weight:bold;\n  letter-spacing:.5px;white-space:nowrap;transition:opacity .2s;overflow:hidden;text-overflow:ellipsis;}\n#relato-txt.hidden{display:none;}\n#relato-txt.gol{font-size:var(--fs-lg);color:var(--accent)!important;letter-spacing:1px;}\n/* Botones globales compactos para la barra */\n#relato-bar .tp-momentum-btn-global,#relato-bar .tp-goalsmap-btn-global{\n  padding:4px 10px;border-radius:4px;cursor:pointer;font-family:var(--font);\n  font-size:var(--fs-sm);white-space:nowrap;transition:background .1s;}\n#relato-bar .tp-momentum-btn-global{background:#10202e;color:#5dade8;border:1px solid #1d5a8a;}\n#relato-bar .tp-momentum-btn-global:hover{background:#16304a;}\n#relato-bar .tp-momentum-btn-global.active{background:#3a8fd4;color:#06131c;border-color:#3a8fd4;}\n#relato-bar .tp-goalsmap-btn-global{background:#1a1410;color:#facc15;border:1px solid #6a5610;}\n#relato-bar .tp-goalsmap-btn-global:hover{background:#2a2014;}\n#relato-bar .tp-goalsmap-btn-global.active{background:#d4a814;color:#1a1404;border-color:#d4a814;}\n/* half-selector ahora vive en el centro de la barra */\n#half-selector{display:none;gap:3px;}\n#half-selector.visible{display:flex;}\n#map-label{font-size:var(--fs-xs);letter-spacing:2px;color:var(--ui-muted);white-space:nowrap;}\n\n/* \u2500\u2500 MAIN AREA \u2500\u2500 */\n#main{flex:1;display:flex;overflow:hidden;min-height:0;}\n/* Columna derecha: barra de relato arriba + \u00e1rea de juego abajo. La columna\n   PARTIDO (stats-panel) queda a la izquierda ocupando toda la altura, as\u00ed la\n   barra de relato arranca reci\u00e9n donde empieza el equipo local y el relato\n   queda centrado sobre la porter\u00eda. */\n#main-right{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;}\n#game-area{flex:1;display:flex;overflow:hidden;min-height:0;}\n/* Paneles de settings t\u00e1cticos vigentes (entre formaci\u00f3n y campo). Angostos,\n   con los 3 settings apilados. Glow temporal cuando un setting cambia. */\n.tactic-settings{width:100px;flex-shrink:0;background:var(--ui-panel);border-left:1px solid var(--ui-border);\n  border-right:1px solid var(--ui-border);display:flex;flex-direction:column;gap:10px;padding:8px 6px;overflow-y:auto;}\n/* Botones de mapa (Pases/Zonas) en la parte alta de cada columna */\n.ts-mapbtns{display:flex;flex-direction:column;gap:4px;flex-shrink:0;}\n.ts-mapbtns .tp-network-btn{width:100%;font-size:var(--fs-xs);padding:4px 6px;}\n/* Bloque de valores t\u00e1cticos, centrado en el espacio restante */\n.ts-values{flex:1;display:flex;flex-direction:column;justify-content:center;gap:14px;}\n.ts-item{display:flex;flex-direction:column;gap:2px;text-align:center;}\n.ts-label{font-size:9px;letter-spacing:1px;color:var(--ui-dim);text-transform:uppercase;}\n.ts-value{font-size:var(--fs-sm);font-weight:bold;color:var(--ui-text);font-family:var(--font);\n  padding:2px 4px;border-radius:3px;transition:background .4s,box-shadow .4s,color .4s;}\n.ts-value.changed{color:#fff;background:rgba(250,204,21,.18);\n  box-shadow:0 0 10px 2px rgba(250,204,21,.55),inset 0 0 6px rgba(250,204,21,.3);\n  animation:tsGlow 1.6s ease-out;}\n@keyframes tsGlow{\n  0%{box-shadow:0 0 16px 4px rgba(250,204,21,.9),inset 0 0 10px rgba(250,204,21,.5);}\n  100%{box-shadow:0 0 10px 2px rgba(250,204,21,.55),inset 0 0 6px rgba(250,204,21,.3);}\n}\n/* Valor total y edad promedio del once en cancha (debajo de los settings) */\n.ts-squad{flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding-top:8px;\n  margin-top:4px;border-top:1px solid var(--ui-border);}\n.ts-squad:empty{display:none;}\n.ts-sva-val{font-size:var(--fs-sm);font-weight:bold;color:#7dd3fc;font-family:var(--font);\n  padding:2px 4px;border-radius:3px;transition:background .4s,box-shadow .4s,color .4s;}\n.ts-sva-val.changed{color:#fff;background:rgba(250,204,21,.22);\n  box-shadow:0 0 14px 3px rgba(250,204,21,.7),inset 0 0 8px rgba(250,204,21,.4);\n  animation:svaGlow 2.6s ease-out;}\n@keyframes svaGlow{\n  0%{background:rgba(250,204,21,.55);box-shadow:0 0 22px 7px rgba(250,204,21,1),inset 0 0 14px rgba(250,204,21,.7);transform:scale(1.18);}\n  25%{transform:scale(1.06);}\n  100%{background:rgba(250,204,21,.0);box-shadow:0 0 0 0 rgba(250,204,21,0);transform:scale(1);}\n}\n\n/* \u2500\u2500 MATCH STATS PANEL (far left, fixed) \u2014 wider to fit larger text \u2500\u2500 */\n#stats-panel{width:340px;flex-shrink:0;background:var(--ui-panel);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;overflow:hidden;}\n.sp-title{font-size:var(--fs-xs);letter-spacing:2px;color:var(--ui-muted);padding:7px 10px 5px;text-transform:uppercase;border-bottom:1px solid var(--ui-border);}\n#score-box{padding:10px 8px;text-align:center;border-bottom:1px solid var(--ui-border);}\n#score-line{display:flex;align-items:center;justify-content:center;gap:8px;margin-bottom:4px;}\n.sc-team{font-size:var(--fs-xs);color:var(--ui-muted);max-width:90px;text-align:center;line-height:1.2;word-break:break-word;}\n.sc-badge{width:26px;height:26px;object-fit:contain;flex-shrink:0;}\n.sc-num{font-size:var(--fs-score);font-weight:bold;color:var(--accent);}\n.sc-sep{font-size:var(--fs-xl);color:var(--ui-muted);}\n/* Zona inferior dividida en dos columnas: stats | goleadores */\n#sp-body{flex:1;display:flex;min-height:0;overflow:hidden;}\n#match-stats-rows{flex:1;overflow-y:auto;padding:4px 0;border-right:1px solid var(--ui-border);}\n#match-stats-rows::-webkit-scrollbar{width:3px;}\n/* Stat rows: value | label | value */\n.ms-row{padding:3px 8px 4px;border-bottom:1px solid #1a1a22;}\n.ms-row.has-sub{border-bottom:none;padding-bottom:1px;}\n.ms-line{display:flex;justify-content:space-between;align-items:center;font-size:var(--fs-sm);}\n.ms-label{color:var(--ui-muted);text-align:center;flex:1;}\n.ms-v0{color:var(--accent);font-weight:bold;min-width:30px;text-align:right;}\n.ms-v1{color:var(--accent);font-weight:bold;min-width:30px;text-align:left;}\n/* Sub-l\u00ednea (precisi\u00f3n, a gol, etc.) m\u00e1s chica y tenue */\n.ms-sub{display:flex;justify-content:space-between;align-items:center;font-size:var(--fs-xs);color:var(--ui-dim);margin-top:1px;}\n.ms-sub .ms-sub-c{flex:1;text-align:center;}\n/* Barra comparativa */\n.ms-bar{display:flex;height:3px;margin-top:3px;border-radius:2px;overflow:hidden;background:#1a1a22;}\n.ms-bar-0{background:var(--accent);}\n.ms-bar-1{background:#c0653a;}\n/* Sub-fila de desglose (ej. \"A gol\"/\"Afuera\" bajo \"Disparos\"): sin barra\n   propia, sin borde, texto m\u00e1s chico y tenue \u2014 es detalle de la fila\n   anterior, no una stat independiente. */\n.ms-subrow{display:flex;justify-content:space-between;align-items:center;\n  font-size:var(--fs-xs);color:var(--ui-dim);padding:1px 8px 1px 18px;}\n.ms-subrow .ms-v0,.ms-subrow .ms-v1{color:#b8b8c8;font-weight:normal;min-width:24px;}\n.ms-subrow .ms-label{color:var(--ui-dim);}\n.ms-subrow:last-of-type{padding-bottom:4px;border-bottom:1px solid #1a1a22;}\n.ms-note{font-size:10px;line-height:1.4;color:var(--ui-dim);padding:6px 8px;font-style:italic;}\n/* Columna de goleadores a la derecha */\n#scorers-col{width:130px;flex-shrink:0;overflow-y:auto;padding:4px 0;}\n#scorers-col::-webkit-scrollbar{width:3px;}\n.sc-section-title{font-size:var(--fs-xs);letter-spacing:1px;color:var(--ui-muted);padding:5px 8px 3px;text-transform:uppercase;}\n.sc-entry{font-size:var(--fs-xs);padding:3px 8px;cursor:pointer;transition:background .1s;}\n.sc-entry:hover{background:#1a1a22;}\n.sc-entry-l1{display:flex;justify-content:space-between;gap:4px;color:var(--ui-muted);}\n.sc-entry-l1 span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;}\n.sc-entry .sc-time{color:var(--accent);flex-shrink:0;}\n.sc-entry-l2{color:var(--ui-dim);padding-left:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n.sc-assist{font-size:var(--fs-xs);padding:2px 8px;color:var(--ui-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n.sc-assist .sc-cnt{color:var(--ui-dim);}\n#legend-box{border-top:1px solid var(--ui-border);padding:6px 8px;flex-shrink:0;display:none;}\n#legend-box.visible{display:block;}\n.leg-row{display:flex;align-items:center;gap:5px;padding:2px 0;font-size:var(--fs-sm);color:var(--ui-muted);}\n.leg-line{width:20px;height:2px;border-radius:1px;flex-shrink:0;}\n\n/* \u2500\u2500 TEAM PANELS \u2014 wider to fit larger text \u2500\u2500 */\n.team-panel{width:175px;flex-shrink:0;background:var(--ui-panel2);display:flex;flex-direction:column;overflow:hidden;position:relative;}\n#team-local{border-right:1px solid var(--ui-border);}\n#team-visitor{border-left:1px solid var(--ui-border);}\n.tp-header{padding:7px 9px;border-bottom:1px solid var(--ui-border);flex-shrink:0;}\n.tp-team-name{font-size:var(--fs-sm);font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.5px;}\n.tp-subtitle{font-size:var(--fs-xs);color:var(--ui-muted);margin-top:2px;}\n.tp-btn-row{display:flex;gap:4px;margin-top:6px;}\n.tp-network-btn{flex:1;background:#0f2e26;color:#5dcaa5;border:1px solid #1d6a4e;\n  padding:4px 4px;border-radius:3px;cursor:pointer;font-family:var(--font);font-size:var(--fs-xs);\n  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .1s;}\n.tp-network-btn:hover{background:#15402f;}\n.tp-network-btn.active{background:#1d9e75;color:#04342c;border-color:#1d9e75;}\n.tp-territory-btn{background:#2e2410;color:#facc15;border-color:#6a5610;}\n.tp-territory-btn:hover{background:#403315;}\n.tp-territory-btn.active{background:#d4a814;color:#2c2204;border-color:#d4a814;}\n.tp-shots-btn{background:#2e1a10;color:#ff9500;border-color:#7a4410;}\n.tp-shots-btn:hover{background:#402515;}\n.tp-shots-btn.active{background:#ff9500;color:#2c1604;border-color:#ff9500;}\n.tp-tackles-btn{background:#10241a;color:#4ade80;border-color:#1a5a3a;}\n.tp-tackles-btn:hover{background:#153524;}\n.tp-tackles-btn.active{background:#4ade80;color:#04240f;border-color:#4ade80;}\n.tp-momentum-btn{margin-top:4px;width:100%;background:#10202e;color:#5dade8;border:1px solid #1d5a8a;\n  padding:4px 6px;border-radius:3px;cursor:pointer;font-family:var(--font);font-size:var(--fs-xs);\n  transition:background .1s;}\n.tp-momentum-btn:hover{background:#16304a;}\n.tp-momentum-btn.active{background:#3a8fd4;color:#06131c;border-color:#3a8fd4;}\n/* Navegador de jugadas: flotante y arrastrable, aparece centrado en el campo. */\n#goalsmap-nav{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:6;\n  align-items:center;gap:6px;background:rgba(19,19,26,.95);border:1px solid #252530;border-radius:8px;\n  padding:5px 6px;box-shadow:0 4px 14px rgba(0,0,0,.5);cursor:grab;user-select:none;}\n#goalsmap-nav.dragging{cursor:grabbing;}\n.gm-handle{color:#5a5a66;font-size:13px;padding:0 3px;cursor:grab;line-height:1;}\n.gm-info{font-family:var(--font);font-size:var(--fs-xs);color:var(--ui-text);min-width:160px;text-align:center;}\n.gm-arrow{background:#1a1a26;border:1px solid var(--ui-border);border-radius:4px;color:#bbb;font-size:16px;cursor:pointer;padding:1px 8px;line-height:1.2;}\n.gm-arrow:hover{color:#facc15;border-color:#facc15;}\n/* Bot\u00f3n y mini-campo de t\u00e1ctica inicial */\n.tp-tactic-btn{margin-top:4px;width:100%;background:#13241a;color:#5dca8a;border:1px solid #1d5a3a;\n  padding:4px 6px;border-radius:3px;cursor:pointer;font-family:var(--font);font-size:var(--fs-xs);\n  transition:background .1s;}\n.tp-tactic-btn:hover{background:#193322;}\n.tp-tactic-btn.active{background:#2f9e5e;color:#06160c;border-color:#2f9e5e;}\n.tp-tactic-pitch{margin-top:6px;}\n.tp-tactic-pitch svg{width:100%;height:auto;display:block;border-radius:4px;}\n/* tp-list: scrollable so players at bottom are reachable */\n.tp-list{flex:1;overflow-y:auto;padding:2px 0;}\n.tp-list::-webkit-scrollbar{width:4px;}\n.tp-list::-webkit-scrollbar-thumb{background:var(--ui-border);border-radius:2px;}\n.tp-player{padding:5px 9px;cursor:pointer;display:flex;align-items:flex-start;gap:7px;transition:background .1s;}\n/* Separadores entre grupos de posici\u00f3n: punteada entre Arq/Def/Med/Del,\n   s\u00f3lida y m\u00e1s gruesa antes de Suplentes. */\n.tp-divider{height:0;margin:5px 10px;border-top:1px dashed var(--ui-border);}\n.tp-divider.thick{border-top:2px solid var(--ui-border);margin:7px 10px;}\n.tp-rating{margin-left:auto;flex-shrink:0;align-self:center;min-width:30px;text-align:center;\n  font-size:var(--fs-md);font-weight:bold;color:#fff;padding:2px 5px;border-radius:4px;\n  font-family:var(--font);letter-spacing:.5px;}\n.tp-player:hover{background:#1c1c28;}\n.tp-player.active{background:#1e1e30;}\n.tp-shirt{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:var(--fs-xs);font-weight:bold;flex-shrink:0;border:2px solid #888;margin-top:1px;}\n.tp-name{font-size:var(--fs-sm);line-height:1.3;flex:1;min-width:0;}\n.tp-badges{display:flex;gap:2px;flex-wrap:wrap;margin-top:2px;}\n.tp-badge{font-size:var(--fs-xs);padding:1px 4px;border-radius:2px;border:1px solid var(--ui-border);}\n.tp-badge.g{border-color:#1aff8a;color:#1aff8a;}\n.tp-badge.y{border-color:#facc15;color:#facc15;}\n.tp-badge.r{border-color:#ff4444;color:#ff4444;}\n.tp-badge.s{border-color:#38bdf8;color:#38bdf8;}\n\n/* \u2500\u2500 DROPDOWN: inline flow (pushes players down) \u2500\u2500 */\n.tp-dropdown{\n  /* No longer absolute/overlapping \u2014 sits inline in column flow */\n  background:#1a1a28;\n  border-left:2px solid var(--accent);\n  border-bottom:1px solid var(--ui-border);\n}\n.dd-item{padding:6px 12px;font-size:var(--fs-sm);cursor:pointer;display:flex;align-items:center;gap:7px;transition:background .1s;white-space:nowrap;}\n/* Encabezado de nota dentro del dropdown */\n.rt-head{padding:8px 12px;border-bottom:1px solid var(--ui-border);background:#15151f;}\n.rt-main{display:flex;align-items:center;gap:9px;margin-bottom:6px;}\n.rt-big{min-width:42px;text-align:center;font-size:var(--fs-xl);font-weight:bold;color:#fff;\n  padding:3px 6px;border-radius:5px;font-family:var(--font);}\n.rt-meta{display:flex;flex-direction:column;line-height:1.2;}\n.rt-pos{font-size:var(--fs-sm);color:var(--ui-text);font-weight:bold;}\n.rt-cap{font-size:var(--fs-xs);color:var(--ui-dim);}\n.rt-breakdown{display:flex;flex-direction:column;gap:1px;}\n.rt-chip{display:flex;justify-content:space-between;gap:10px;font-size:var(--fs-xs);\n  color:var(--ui-muted);padding:1px 0;}\n.dd-item:hover{background:#26263a;color:var(--accent);}\n.dd-item .dd-icon{font-size:var(--fs-lg);width:16px;text-align:center;}\n/* Sub-event list inside dropdown */\n.dd-sublist{background:#14141e;border-left:2px solid var(--ui-border);font-size:var(--fs-xs);max-height:160px;overflow-y:auto;display:none;}\n.dd-sublist.open{display:block;}\n.dd-sublist::-webkit-scrollbar{width:3px;}\n.dd-sublist::-webkit-scrollbar-thumb{background:var(--ui-border);}\n.dd-sub-item{padding:3px 14px;cursor:pointer;color:var(--ui-muted);display:flex;gap:7px;align-items:center;}\n.dd-sub-item:hover{color:var(--ui-text);}\n\n/* \u2500\u2500 CANVAS AREA \u2500\u2500 */\n#canvas-wrap{flex:1;display:flex;align-items:center;justify-content:center;background:#07070e;overflow:hidden;position:relative;}\ncanvas{display:block;}\n\n/* \u2500\u2500 ANALYTIC MODAL (draggable, resizable) \u2500\u2500 */\n#analytic-modal{\n  position:fixed;top:80px;left:50%;transform:translateX(-50%);\n  width:480px;min-width:280px;min-height:200px;\n  /* Max height so it never goes off screen \u2014 list scrolls inside */\n  max-height:calc(100vh - 100px);\n  background:var(--ui-panel2);border:1px solid var(--ui-border);\n  border-radius:8px;box-shadow:0 12px 40px rgba(0,0,0,.75);\n  display:none;flex-direction:column;z-index:1000;\n  resize:both;overflow:hidden;\n}\n#analytic-modal.visible{display:flex;}\n/* Drag handle = title bar \u2014 always visible at top */\n#am-titlebar{\n  background:#1c1c2e;border-bottom:1px solid var(--ui-border);\n  padding:8px 12px;display:flex;align-items:center;gap:8px;\n  cursor:move;user-select:none;flex-shrink:0;\n}\n#am-title{font-size:var(--fs-lg);font-weight:bold;flex:1;color:var(--accent);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n/* Close button \u2014 always visible, styled clearly */\n#am-close{\n  background:#2a1a1a;border:1px solid #ff4444;color:#ff4444;\n  padding:3px 10px;border-radius:3px;cursor:pointer;\n  font-family:var(--font);font-size:var(--fs-md);\n  flex-shrink:0; /* never gets squished */\n  white-space:nowrap;\n}\n#am-close:hover{background:#3a2020;}\n/* Escape hint next to close */\n#am-esc-hint{font-size:var(--fs-xs);color:var(--ui-muted);flex-shrink:0;}\n/* Filter bar \u2014 sticky, never scrolls away */\n#am-filters{padding:8px 12px;border-bottom:1px solid var(--ui-border);display:flex;gap:10px;flex-wrap:wrap;align-items:center;font-size:var(--fs-sm);flex-shrink:0;}\n.am-filter-group{display:flex;align-items:center;gap:5px;}\n.am-filter-group label{color:var(--ui-muted);cursor:pointer;}\n.am-filter-group input[type=checkbox]{accent-color:var(--accent);cursor:pointer;}\n.am-outcome-select{background:#1a1a26;border:1px solid var(--ui-border);color:var(--ui-text);\n  padding:3px 7px;border-radius:3px;font-family:var(--font);font-size:var(--fs-sm);cursor:pointer;}\n/* Event list \u2014 scrollable, takes all available vertical space */\n#am-list{flex:1;overflow-y:auto;padding:4px 0;min-height:80px;}\n#am-list::-webkit-scrollbar{width:5px;}\n#am-list::-webkit-scrollbar-track{background:transparent;}\n#am-list::-webkit-scrollbar-thumb{background:var(--ui-border);border-radius:3px;}\n.am-event-row{padding:5px 12px;display:flex;align-items:center;gap:8px;cursor:pointer;\n  border-bottom:1px solid #1a1a22;font-size:var(--fs-sm);transition:background .1s;}\n.am-event-row:hover{background:#1e1e2e;}\n.am-event-row.selected{background:#1e2e1e;border-left:3px solid var(--pass-ok);}\n.am-event-row.selected.fail{background:#2e1e1e;border-left-color:var(--pass-fail);}\n.am-event-time{color:var(--accent);min-width:42px;font-size:var(--fs-md);}\n.am-event-desc{flex:1;color:var(--ui-muted);}\n.am-event-badge{font-size:var(--fs-xs);padding:1px 5px;border-radius:3px;}\n.am-event-badge.ok{background:#1a2a1a;color:var(--pass-ok);border:1px solid var(--pass-ok);}\n.am-event-badge.fail{background:#2a1a1a;color:var(--pass-fail);border:1px solid var(--pass-fail);}\n/* Footer \u2014 sticky at bottom, never scrolls away */\n#am-footer{padding:7px 12px;border-top:1px solid var(--ui-border);display:flex;gap:10px;align-items:center;font-size:var(--fs-sm);flex-shrink:0;}\n#am-footer button{background:#1a1a26;border:1px solid var(--ui-border);color:var(--ui-text);\n  padding:3px 9px;border-radius:3px;cursor:pointer;font-family:var(--font);font-size:var(--fs-sm);}\n#am-footer button:hover{background:#26263a;color:var(--accent);}\n";
  const MZTITOR_HTML = "\n\n<!-- TOPBAR -->\n<div id=\"topbar\">\n  <button class=\"tb-btn\" id=\"playBtn\">\u25b6</button>\n  <button class=\"tb-btn\" id=\"pauseBtn\">\u23f8</button>\n  <button class=\"tb-btn\" id=\"backBtn\">\u23ea\u221250</button>\n  <div class=\"tb-sep\"></div>\n  <button class=\"tb-btn speed-btn active\" data-speed=\"0.5\">\u00d70.5</button>\n  <button class=\"tb-btn speed-btn\" data-speed=\"1\">\u00d71</button>\n  <button class=\"tb-btn speed-btn\" data-speed=\"2\">\u00d72</button>\n  <button class=\"tb-btn speed-btn\" data-speed=\"5\">\u00d75</button>\n  <button class=\"tb-btn speed-btn\" data-speed=\"10\">\u00d710</button>\n  <div class=\"tb-sep\"></div>\n  <input type=\"range\" id=\"timeline\" min=\"0\" max=\"100\" value=\"0\">\n  <div id=\"tb-info\">Frame: <span id=\"frameLabel\">0</span>/<span id=\"frameTotal\">0</span></div>\n  <div class=\"tb-sep\"></div>\n  <input type=\"file\" id=\"matchInput\" accept=\".gz,.xml,.json\" multiple title=\"Seleccion\u00e1 data\u2026.gz, stats\u2026.xml.gz y (opcional) playerlist_\u2026.xml juntos\">\n</div>\n\n<div id=\"main\">\n\n  <!-- FAR LEFT: Match Stats -->\n  <div id=\"stats-panel\">\n    <div class=\"sp-title\">Partido</div>\n    <div id=\"score-box\">\n      <div id=\"score-line\">\n        <img class=\"sc-badge\" id=\"sc-b0\" alt=\"\" onerror=\"this.style.visibility='hidden'\">\n        <div class=\"sc-team\" id=\"sc-t0\">\u2014</div>\n        <div class=\"sc-num\" id=\"sc-n0\">0</div>\n        <div class=\"sc-sep\">:</div>\n        <div class=\"sc-num\" id=\"sc-n1\">0</div>\n        <div class=\"sc-team\" id=\"sc-t1\">\u2014</div>\n        <img class=\"sc-badge\" id=\"sc-b1\" alt=\"\" onerror=\"this.style.visibility='hidden'\">\n      </div>\n    </div>\n    <div id=\"sp-body\">\n      <div id=\"match-stats-rows\"></div>\n      <div id=\"scorers-col\"></div>\n    </div>\n    <div id=\"legend-box\">\n      <div class=\"leg-row\"><div class=\"leg-line\" style=\"background:var(--pass-ok)\"></div>Pase exitoso</div>\n      <div class=\"leg-row\"><div class=\"leg-line\" style=\"background:var(--pass-fail)\"></div>Pase fallido</div>\n      <div class=\"leg-row\"><div class=\"leg-line\" style=\"background:var(--pass-ok);height:4px\"></div>Pase largo</div>\n      <div class=\"leg-row\"><div class=\"leg-line\" style=\"background:var(--pass-ok);height:1px\"></div>Pase corto</div>\n      <div class=\"leg-row\" id=\"leg-shot\"><div class=\"leg-line\" style=\"background:var(--shot)\"></div>Disparo (pie)</div>\n      <div class=\"leg-row\" id=\"leg-hdr\"><div class=\"leg-line\" style=\"background:var(--header-shot)\"></div>Disparo (cabeza)</div>\n    </div>\n  </div>\n\n  <!-- COLUMNA DERECHA: barra de relato + \u00e1rea de juego -->\n  <div id=\"main-right\">\n    <div id=\"relato-bar\">\n      <div id=\"rb-left\">\n        <button class=\"tp-momentum-btn-global\" id=\"global-momentum-btn\" title=\"Momentum del partido completo (ambos equipos)\">\ud83d\udcc8 Momentum</button>\n      </div>\n      <div id=\"rb-center\">\n        <span id=\"relato-txt\" class=\"hidden\"></span>\n        <span id=\"map-label\"></span>\n        <div id=\"half-selector\">\n          <button class=\"half-btn active\" data-half=\"all\">Todo</button>\n          <button class=\"half-btn\" data-half=\"1\">1T</button>\n          <button class=\"half-btn\" data-half=\"2\">2T</button>\n          <button class=\"half-btn\" data-half=\"w\">Tramos</button>\n          <div id=\"time-window\" style=\"display:none\">\n            <button class=\"tw-arrow\" id=\"tw-qprev\" title=\"Cuarto anterior\">\u00ab</button>\n            <button class=\"tw-arrow\" id=\"tw-prev\" title=\"-5 min\">\u2039</button>\n            <span id=\"tw-label\">0\u20135'</span>\n            <button class=\"tw-arrow\" id=\"tw-next\" title=\"+5 min\">\u203a</button>\n            <button class=\"tw-arrow\" id=\"tw-qnext\" title=\"Cuarto siguiente\">\u00bb</button>\n          </div>\n        </div>\n      </div>\n      <div id=\"rb-right\">\n        <button class=\"tp-goalsmap-btn-global\" id=\"global-goalsmap-btn\" title=\"Mapa de goles y atajadas del partido\">\ud83e\udd45 Goles y atajadas</button>\n      </div>\n    </div>\n    <div id=\"game-area\">\n\n  <!-- LOCAL TEAM -->\n  <div class=\"team-panel\" id=\"team-local\">\n    <div class=\"tp-header\">\n      <div class=\"tp-team-name\" id=\"local-name\">Local</div>\n      <div class=\"tp-subtitle\" id=\"local-sub\">\u2014</div>\n      <button class=\"tp-tactic-btn\" id=\"local-tactic-btn\" title=\"Formaci\u00f3n inicial seg\u00fan coordenadas de origen\">\u26bd T\u00e1ctica inicial</button>\n      <div class=\"tp-tactic-pitch\" id=\"local-tactic-pitch\" hidden></div>\n    </div>\n    <div class=\"tp-list\" id=\"local-list\"></div>\n  </div>\n\n  <!-- SETTINGS T\u00c1CTICOS LOCAL (entre formaci\u00f3n local y campo) -->\n  <div class=\"tactic-settings\" id=\"tactic-settings-local\">\n    <div class=\"ts-mapbtns\">\n      <button class=\"tp-network-btn\" id=\"local-network-btn\" title=\"Red de pases del equipo\">\ud83d\udd17 Pases</button>\n      <button class=\"tp-network-btn tp-territory-btn\" id=\"local-territory-btn\" title=\"Territorio por zonas\">\u26f3 Zonas</button>\n      <button class=\"tp-network-btn tp-shots-btn\" id=\"local-shots-btn\" title=\"Mapa de tiros (estimaci\u00f3n desde el binario)\">\ud83c\udfaf Tiros</button>\n      <button class=\"tp-network-btn tp-tackles-btn\" id=\"local-tackles-btn\" title=\"Mapa de entradas/tackles (estimaci\u00f3n desde el binario)\">\ud83d\udee1 Entradas</button>\n    </div>\n    <div class=\"ts-values\" id=\"ts-values-local\"></div>\n    <div class=\"ts-squad\" id=\"ts-squad-local\"></div>\n  </div>\n\n  <!-- CANVAS -->\n  <div id=\"canvas-wrap\">\n    <div id=\"goalsmap-nav\" style=\"display:none\">\n      <span class=\"gm-handle\" title=\"Arrastrar\">\u283f</span>\n      <button class=\"gm-arrow\" id=\"gm-prev\" title=\"Anterior\">\u2039</button>\n      <span class=\"gm-info\"></span>\n      <button class=\"gm-arrow\" id=\"gm-next\" title=\"Siguiente\">\u203a</button>\n    </div>\n    <canvas id=\"canvas\"></canvas>\n  </div>\n\n  <!-- SETTINGS T\u00c1CTICOS VISITANTE (entre campo y formaci\u00f3n visitante) -->\n  <div class=\"tactic-settings\" id=\"tactic-settings-visitor\">\n    <div class=\"ts-mapbtns\">\n      <button class=\"tp-network-btn\" id=\"visitor-network-btn\" title=\"Red de pases del equipo\">\ud83d\udd17 Pases</button>\n      <button class=\"tp-network-btn tp-territory-btn\" id=\"visitor-territory-btn\" title=\"Territorio por zonas\">\u26f3 Zonas</button>\n      <button class=\"tp-network-btn tp-shots-btn\" id=\"visitor-shots-btn\" title=\"Mapa de tiros (estimaci\u00f3n desde el binario)\">\ud83c\udfaf Tiros</button>\n      <button class=\"tp-network-btn tp-tackles-btn\" id=\"visitor-tackles-btn\" title=\"Mapa de entradas/tackles (estimaci\u00f3n desde el binario)\">\ud83d\udee1 Entradas</button>\n    </div>\n    <div class=\"ts-values\" id=\"ts-values-visitor\"></div>\n    <div class=\"ts-squad\" id=\"ts-squad-visitor\"></div>\n  </div>\n\n  <!-- VISITOR TEAM -->\n  <div class=\"team-panel\" id=\"team-visitor\">\n    <div class=\"tp-header\">\n      <div class=\"tp-team-name\" id=\"visitor-name\">Visitante</div>\n      <div class=\"tp-subtitle\" id=\"visitor-sub\">\u2014</div>\n      <button class=\"tp-tactic-btn\" id=\"visitor-tactic-btn\" title=\"Formaci\u00f3n inicial seg\u00fan coordenadas de origen\">\u26bd T\u00e1ctica inicial</button>\n      <div class=\"tp-tactic-pitch\" id=\"visitor-tactic-pitch\" hidden></div>\n    </div>\n    <div class=\"tp-list\" id=\"visitor-list\"></div>\n  </div>\n\n  </div><!-- /game-area -->\n  </div><!-- /main-right -->\n</div><!-- /main -->\n\n<!-- \u2500\u2500 ANALYTIC MODAL: draggable, resizable, filter checkboxes \u2500\u2500 -->\n<div id=\"analytic-modal\" role=\"dialog\" aria-label=\"Ventana anal\u00edtica\">\n  <div id=\"am-titlebar\">\n    <span id=\"am-title\">Eventos</span>\n    <span id=\"am-esc-hint\">ESC</span>\n    <button id=\"am-close\" title=\"Cerrar (Escape)\">\u2715 Cerrar</button>\n  </div>\n  <div id=\"am-filters\">\n    <div class=\"am-filter-group\">\n      <label for=\"am-outcome\">Mostrar:</label>\n      <select id=\"am-outcome\" class=\"am-outcome-select\">\n        <option value=\"all\">Todos</option>\n        <option value=\"ok\">Exitosos</option>\n        <option value=\"fail\">Fallidos</option>\n      </select>\n    </div>\n    <div class=\"am-filter-group\">\n      <input type=\"checkbox\" id=\"am-check-all\" title=\"Marcar/desmarcar todos\">\n      <label for=\"am-check-all\">Todos</label>\n    </div>\n    <span id=\"am-sel-count\" style=\"color:var(--ui-muted);font-size:var(--fs-xs);margin-left:auto;\"></span>\n  </div>\n  <div id=\"am-list\"></div>\n  <div id=\"am-footer\">\n    <button id=\"am-btn-apply\">Aplicar selecci\u00f3n al mapa</button>\n    <button id=\"am-btn-clear\">Limpiar selecci\u00f3n</button>\n  </div>\n</div>\n\n\n\n";
  const MZTITOR_TITLE = "MZ Match Viewer";

  // Monta el visor en el documento actual: título, CSS y markup, y luego
  // ejecuta la lógica. Pensado para una pestaña en blanco (about:blank o
  // una página servida por el propio userscript).
  window.buildMZTITORViewer = function buildMZTITORViewer(){
    document.title = MZTITOR_TITLE;
    // CSS
    const style = document.createElement('style');
    style.textContent = MZTITOR_CSS;
    document.head.appendChild(style);
    // HTML (markup del body original)
    document.body.innerHTML = MZTITOR_HTML;
    // Lógica: se ejecuta ahora que el DOM ya tiene todos los ids.
    runMZTITORLogic();
  };

  // La lógica original del index.html, encapsulada. Los getElementById siguen
  // funcionando porque buildMZTITORViewer ya inyectó el markup con esos ids.
  function runMZTITORLogic(){

    // ═══════════════════════════════════════════════════
    // GLOBALS
    // ═══════════════════════════════════════════════════
    const canvas=document.getElementById('canvas');
    const ctx=canvas.getContext('2d');
    const wrap=document.getElementById('canvas-wrap');

    const DX0=0,DX1=750,DY0=0,DY1=1000,RATIO=1.331; // MZ Analyzer ratio medido directamente del campo real: 639/480 = 1.331
    let fieldX=0,fieldY=0,fieldW=0,fieldH=0;

    // ── Escala real del campo: metros por unidad MZ ──
    // Calculada empíricamente comparando la distancia recorrida real en el binario
    // (suma de desplazamientos frame a frame, descartando saltos de teletransporte)
    // contra distancesCovered del XML (confirmado en metros) para 8 jugadores distintos
    // del partido data1601550710. Resultado consistente entre 0.0964 y 0.0980 → promedio 0.0972.
    // Esto implica un campo de ~72.9m × 97.2m reales (750×1000 unidades MZ).
    // NOTA: no se usa para calcular velocidad — ver distanceRankPercentile() para el motivo.
    // Queda documentada por si sirve a futuro para otras métricas de distancia.
    const MZ_METERS_PER_UNIT=0.0972;
    // Duración real de cada frame del binario, en segundos (usada por frameToClock).
    const FRAME_SECONDS=0.6667;

    let frames=[],teams={},playerMap={},idToInternalId={};
    let half2StartFrame=null; // frame exacto de inicio del 2do tiempo (del XML), null si se desconoce
    let currentFrame=0,playing=false,speed=0.5;
    let mapMode=null; // null=live | 'passes'|'shots'|'goals'|'interceptions'|'tackles'|'saves'
    let activePlayer=null;
    let selectedHalf='all'; // 'all'|'1'|'2' — filtro de mitad para red de pases y mapa de calor
    let teamAttackUp={}; // internalId-team-key -> true if attacks toward Y=0

    let allPasses=[],allShots=[],allGoals=[],allSaves=[],allCards=[],allSubs=[],allTactics=[];

    // ═══════════════════════════════════════════════════
    // VALOR Y EDAD DE JUGADORES (playerlist pública de MZ)
    // ═══════════════════════════════════════════════════
    // Tabla de conversión a USD (fuente: ManagerZone_Control base currency).
    // value_usd = value * MZ_TO_USD[teamCurrency]. Monedas sin tasa → sin convertir.
    const MZ_TO_USD={
      'SEK':0.13470916,'EUR':1.23629334,'USD':1.0,'GBP':1.79870006,
      'DKK':0.16639545,'NOK':0.14446884,'CHF':0.7903885,'CAD':0.76905326,
      'AUD':0.76379961,'R$':0.35373144,'ILS':0.22837244,'MXN':0.09237816,
      'ARS':0.35623165,'BOB':0.1264919,'PYG':0.00017633,'UYU':0.03461527,
      'RUB':0.03544602,'PLN':0.26305736,'ISK':0.01405421,'BGN':0.63412722,
      'ZAR':0.16667969,'US$':1.0,'THB':0.02300698,'SIT':0.00524827,
      'SKK':0.03360455,'JPY':0.00808255,'INR':0.02290056,
      'MZ':0.13470916,'MM':0.13470916,'点':0.13470916,
    };
    // playerExtra[id] = {age, valueUsd, valueLocal, currency, converted}
    let playerExtra={};

    // Trae la playerlist pública de un equipo y guarda age/value por id de jugador.
    // URL pública: no requiere auth, pero puede fallar por CORS en file:// → se
    // captura el error y se sigue sin estos datos (el resto del visor funciona igual).
    async function fetchPlayerList(teamId){
      const url=`https://managerzone.com/xml/team_playerlist.php?sport_id=1&team_id=${teamId}`;
      try{
        const res=await fetch(url);
        if(!res.ok)throw new Error('HTTP '+res.status);
        const txt=await res.text();
        const doc=new DOMParser().parseFromString(txt,'text/xml');
        const tp=doc.querySelector('TeamPlayers');
        const currency=tp?tp.getAttribute('teamCurrency'):null;
        const rate=currency&&MZ_TO_USD[currency]!==undefined?MZ_TO_USD[currency]:null;
        doc.querySelectorAll('Player').forEach(p=>{
          const id=p.getAttribute('id');
          const age=+p.getAttribute('age')||null;
          const valueLocal=+p.getAttribute('value')||0;
          playerExtra[id]={
            age,
            valueLocal,
            currency,
            valueUsd: rate!=null ? Math.round(valueLocal*rate) : null,
            converted: rate!=null
          };
        });
        return true;
      }catch(e){
        console.warn('No se pudo cargar playerlist de',teamId,'-',e.message);
        return false;
      }
    }

    // Carga la playerlist de ambos equipos del partido actual y refresca la UI.
    async function loadPlayerExtras(){
      playerExtra={};
      const teamIds=[...new Set(Object.values(teams).map(t=>t.id).filter(Boolean))];
      await Promise.all(teamIds.map(fetchPlayerList));
      renderTacticSettings();       // repinta valor + edad del 11
      buildTeamPanels();            // refresca dropdowns con la edad
    }

    // ═══════════════════════════════════════════════════
    // COLOR UTILS
    // ═══════════════════════════════════════════════════
    const hex=h=>'#'+h;
    function lum(h){const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);return 0.2126*r+0.7152*g+0.0722*b;}
    const txtClr=h=>lum(h)>128?'#111':'#fff';

    /**
     * legibleOnDark — color de equipo como TEXTO sobre el fondo oscuro de la app
     * (paneles, no chips). Si el color es muy oscuro (negro, azul marino, etc.)
     * se pierde contra el fondo; en vez de caer a blanco plano (que borra la
     * identidad del equipo), se aclara mezclando hacia blanco hasta alcanzar
     * contraste mínimo, conservando el matiz. Colores ya legibles quedan igual.
     */
    function mixWhite(h,t){
      const r=parseInt(h.slice(0,2),16),g=parseInt(h.slice(2,4),16),b=parseInt(h.slice(4,6),16);
      const mr=Math.round(r+(255-r)*t),mg=Math.round(g+(255-g)*t),mb=Math.round(b+(255-b)*t);
      return [mr,mg,mb].map(x=>x.toString(16).padStart(2,'0')).join('');
    }
    function legibleOnDark(h,minLum=110){
      h=(h||'888888').replace('#','');
      let cur=h;
      for(let t=0;t<=1;t+=0.05){
        cur=mixWhite(h,t);
        if(lum(cur)>=minLum)return '#'+cur;
      }
      return '#'+cur;
    }

    // ═══════════════════════════════════════════════════
    // FIELD GEOMETRY
    // ═══════════════════════════════════════════════════
    function computeField(){
      const ww=wrap.clientWidth,wh=wrap.clientHeight,m=18;
      let fw=ww-m*2,fh=fw*RATIO;
      if(fh>wh-m*2){fh=wh-m*2;fw=fh/RATIO;}
      fieldW=fw;fieldH=fh;
      canvas.width=ww;canvas.height=wh;
      fieldX=(ww-fw)/2;fieldY=(wh-fh)/2;
    }
    // Raw field coords -> canvas
    const nx=x=>fieldX+((x-DX0)/(DX1-DX0))*fieldW;
    const ny=y=>fieldY+((y-DY0)/(DY1-DY0))*fieldH;
    const dist=(x1,y1,x2,y2)=>Math.sqrt((x1-x2)**2+(y1-y2)**2);

    // Map coords with optional flip (for oriented maps: always bottom=own goal, top=rival)
    // flipY=true -> y'=DY1-y (attacks toward Y=0 in raw -> attacks toward bottom in map)
    function mapNX(x,flipY){return nx(x);}
    function mapNY(y,flipY){return flipY?ny(DY1-y):ny(y);}

    // Determine attack direction for a team from first frame
    // Returns true if team attacks toward Y=0 (their avg Y in first frame > 500)
    function calcAttackUp(){
      if(!frames.length) return;
      const f=frames[0];
      const teamY={'0':[],'1':[]};
      f.players.forEach(p=>{
        if(p.y<50||p.y>950)return; // skip players at/near goal line (overflow) or inactive
        const xp=playerMap[String(p.id)];
        const vis=xp?xp.visiting:p.team;
        (teamY[vis]=teamY[vis]||[]).push(p.y);
      });
      for(const vis of ['0','1']){
        const ys=teamY[vis]||[];
        const avg=ys.length?ys.reduce((a,b)=>a+b,0)/ys.length:500;
        teamAttackUp[vis]=avg>500; // starts bottom half → attacks toward Y=0
      }
    }

    // ═══════════════════════════════════════════════════
    // FÓRMULA MAESTRA DE ORIENTACIÓN (Paso 0 — validada en 14 partidos / 28 equipos)
    // ═══════════════════════════════════════════════════
    // Dirección de ataque de un equipo en el marco binario, sin heurísticas frágiles:
    //   local (visiting=0)    → ataca hacia Y=0     (su arquero en origin Y=0)
    //   visitante (visiting=1) → ataca hacia Y=1000  (su arquero en origin Y=1000)
    // En el 2º tiempo cambian de lado, así que el destino se invierte.
    // Coincidió al 100% con la posición real del arquero y con los goles en 28 equipos.
    function attackGoalY(visiting,frame){
      let dir=(visiting==='0'||visiting===0)?DY0:DY1;
      if(half2StartFrame!=null && frame>=half2StartFrame) dir=DY1-dir;
      return dir;
    }

    // ═══════════════════════════════════════════════════
    // RANKING DE DISTANCIA RECORRIDA (relativo, basado en XML)
    // ═══════════════════════════════════════════════════
    // Nota sobre velocidad: se evaluó calcular una "velocidad máxima en km/h"
    // desde el binario (desplazamiento frame a frame × escala real del campo),
    // pero el motor de MZ tiene un tope de movimiento por frame que, traducido
    // a esa escala, da ~5.6 km/h — muy por debajo de un sprint real de fútbol.
    // Esto indica que el motor no mueve a los jugadores como un cuerpo físico
    // real entre frames, así que cualquier conversión a km/h sería un número
    // inventado sin respaldo. Por eso usamos en cambio un ranking relativo de
    // distancia recorrida (sí validada en metros, ver MZ_METERS_PER_UNIT arriba).

    /**
     * distanceRankPercentile — percentil de un jugador según distancia
     * recorrida (s.distance, en metros) comparado con TODOS los jugadores
     * con datos válidos del partido actual. 100 = el que más corrió.
     */
    function distanceRankPercentile(internalId,myDistance){
      const all=Object.values(playerMap).filter(p=>p.stats?.distance>0).map(p=>p.stats.distance);
      if(all.length<2)return null;
      const below=all.filter(d=>d<=myDistance).length;
      return Math.round((below/all.length)*100);
    }

    // ═══════════════════════════════════════════════════
    // XML PARSING
    // ═══════════════════════════════════════════════════
    function parseXML(xmlText){
      const doc=new DOMParser().parseFromString(xmlText,'text/xml');
      allGoals=[];allSaves=[];allCards=[];allSubs=[];allTactics=[];idToInternalId={};

      doc.querySelectorAll('Team').forEach(t=>{
        const id=t.getAttribute('id'),vis=t.getAttribute('visiting');
        teams[id]={
          id,visiting:vis,
          name:t.getAttribute('name'),shortname:t.getAttribute('shortname'),
          priRgb:t.getAttribute('shirtPriRgb')||'888888',
          secRgb:t.getAttribute('shirtSecRgb')||'cccccc',
          priGk:t.getAttribute('shirtPriRgbGoalie')||'0b5394',
          secGk:t.getAttribute('shirtSecRgbGoalie')||'ffffff',
          tactic:t.getAttribute('tactic'),playstyle:t.getAttribute('playstyle'),
          aggression:t.getAttribute('aggression'),
          country:t.getAttribute('country')
        };
        const st=t.querySelector('Statistics');
        if(st) teams[id].stats={
          goals:+st.getAttribute('goals')||0,shots:+st.getAttribute('shotsOnGoal')||0,
          shotsWide:+st.getAttribute('shotsWide')||0,passes:+st.getAttribute('goodPasses')||0,
          intercepted:+st.getAttribute('interceptedPasses')||0,tackles:+st.getAttribute('tacklesWon')||0,
          tacklesLost:+st.getAttribute('tacklesLost')||0,
          possession:+st.getAttribute('possession')||0,corners:+st.getAttribute('corners')||0,
          freekicks:+st.getAttribute('freekicks')||0,yellowCards:+st.getAttribute('yellowCards')||0,
          redCards:+st.getAttribute('redCards')||0,saves:+st.getAttribute('saves')||0,
          offsides:+st.getAttribute('offsides')||0,throwins:+st.getAttribute('throwins')||0,
          injuries:+st.getAttribute('injuries')||0,penaltyshots:+st.getAttribute('penaltyshots')||0,
          possessionTicks:+st.getAttribute('possessionTicks')||0
        };
        const hs=t.querySelector('HalfStatistics');
        if(hs) teams[id].halfStats={
          goals:hs.getAttribute('goals'),possession:+hs.getAttribute('possession')||0,
          shots:hs.getAttribute('shotsOnGoal'),passes:hs.getAttribute('goodPasses'),
          possessionTicks:+hs.getAttribute('possessionTicks')||0
        };
        const m=t.querySelector('MetaData');
        if(m){teams[id].manager=m.getAttribute('manager');teams[id].series=m.getAttribute('series_name');}
      });

      doc.querySelectorAll('Player').forEach(p=>{
        const iid=p.getAttribute('internalId'),tid=p.getAttribute('teamId');
        const t=teams[tid]||{};const isGK=p.getAttribute('goalkeeper')==='1';
        const origin=p.getAttribute('origin')||'';
        const originY=origin.includes(',')?+origin.split(',')[1]:null;
        const originX=origin.includes(',')?+origin.split(',')[0]:null;
        const obj={
          internalId:iid,id:p.getAttribute('id'),teamId:tid,visiting:(t.visiting!==undefined?t.visiting:'0'),
          name:p.getAttribute('name'),shirt:p.getAttribute('shirtno'),gk:isGK,originY,originX,
          priRgb:isGK?(t.priGk||t.priRgb||'888888'):(t.priRgb||'888888'),
          secRgb:isGK?(t.secGk||t.secRgb||'cccccc'):(t.secRgb||'cccccc'),
          goals:[],saves:[],cards:[],misses:[],assists:[],stats:{}
        };
        // assister en <Goal> referencia el atributo "id" del jugador (no su internalId).
        // Guardamos id->internalId para poder resolverlo después.
        idToInternalId[obj.id]=iid;
        p.querySelectorAll('Goal').forEach(g=>{
          const gd={frame:+g.getAttribute('frame'),clock:g.getAttribute('clock'),assister:g.getAttribute('assister')};
          obj.goals.push(gd);
          allGoals.push({...gd,scorerId:iid,scorerName:obj.name,scorerShirt:obj.shirt,visiting:obj.visiting,teamId:tid});
        });
        p.querySelectorAll('Save').forEach(s=>{const sd={frame:+s.getAttribute('frame'),clock:s.getAttribute('clock')};obj.saves.push(sd);allSaves.push({...sd,keeper:obj.name,visiting:obj.visiting,keeperId:iid});});
        p.querySelectorAll('Card').forEach(c=>{const cd={frame:+c.getAttribute('frame'),type:c.getAttribute('type')};obj.cards.push(cd);allCards.push({...cd,player:obj.name,visiting:obj.visiting});});
        p.querySelectorAll('Miss').forEach(m=>obj.misses.push({frame:+m.getAttribute('frame'),clock:m.getAttribute('clock')}));
        const st=p.querySelector('Statistics');
        if(st) obj.stats={
          goals:+st.getAttribute('goals')||0,shots:+st.getAttribute('shotsOnGoal')||0,
          passes:+st.getAttribute('goodPasses')||0,tackles:+st.getAttribute('tacklesWon')||0,
          tacklesLost:+st.getAttribute('tacklesLost')||0,
          interceptions:+st.getAttribute('interceptions')||0,saves:+st.getAttribute('saves')||0,
          yellowCards:+st.getAttribute('yellowCards')||0,redCards:+st.getAttribute('redCards')||0,
          shotsWide:+st.getAttribute('shotsWide')||0,freekicks:+st.getAttribute('freekicks')||0,
          interceptedPasses:+st.getAttribute('interceptedPasses')||0,
          distance:+(st.getAttribute('distancesCovered')||'0,0').split(',')[0]||0,
          passDistance:+st.getAttribute('passDistances')||0
        };
        // El XML no trae "interceptions" (acción defensiva) a nivel de equipo,
        // solo por jugador — lo acumulamos acá para poder calcular PPDA.
        if(teams[tid]){
          if(teams[tid].stats) teams[tid].stats.interceptionsTotal=(teams[tid].stats.interceptionsTotal||0)+(obj.stats.interceptions||0);
        }
        // Count assists from allGoals assister field
        obj.assists=[];
        playerMap[iid]=obj;
      });
      // Link assists — g.assister es el "id" del jugador; lo resolvemos a internalId
      // para poder usarlo como clave de playerMap (indexado por internalId en todo el resto del código).
      allGoals.forEach(g=>{
        if(!g.assister) return;
        const assisterIid=idToInternalId[g.assister];
        if(assisterIid && playerMap[assisterIid]){
          g.assisterIid=assisterIid; // útil para mostrar el nombre sin volver a resolver
          playerMap[assisterIid].assists.push(g);
        }
      });

      doc.querySelectorAll('Substitution').forEach(s=>{
        allSubs.push({frame:+s.getAttribute('frame'),clock:s.getAttribute('clock'),teamId:s.getAttribute('teamId'),inPlayer:s.getAttribute('playerId'),outPlayer:s.getAttribute('substitutedId')});
      });
      allGoals.sort((a,b)=>a.frame-b.frame);

      // ── Frame exacto de inicio del 2do tiempo ──
      // <Period name="half2" start="49:24.0" .../> usa la misma base temporal que
      // el atributo "time" de <Whistle>/<Goal> (NO "clock", que se pausa en
      // ciertas interrupciones). Preferimos el frame de un <Whistle> cuyo time
      // coincide exactamente con el start del Period (es un dato directo, sin
      // redondeos); si no aparece, usamos time/FRAME_SECONDS como respaldo.
      half2StartFrame=null;
      const half2=Array.from(doc.querySelectorAll('Period')).find(p=>p.getAttribute('name')==='half2');
      if(half2){
        const startTime=half2.getAttribute('start'); // "49:24.0"
        const matchingWhistle=Array.from(doc.querySelectorAll('Whistle')).find(w=>w.getAttribute('time')===startTime);
        if(matchingWhistle){
          half2StartFrame=+matchingWhistle.getAttribute('frame');
        } else {
          const m=startTime.match(/^(\d+):([\d.]+)$/);
          if(m) half2StartFrame=Math.round(((+m[1])*60+(+m[2]))/FRAME_SECONDS);
        }
      }
      // El heatmap depende de half2StartFrame (vía normalizeXY) — si cambió,
      // cualquier grilla calculada antes con el valor previo queda obsoleta.
      _heatmapCache={};

      // ── Settings tácticos: estado inicial + cambios <Tactic> durante el partido ──
      // <Tactic teamId time type new_setting>, type ∈ {tactic, playstyle, aggression}.
      // time está en segundos reales (mismo origen que los Whistle) → frame = time/FRAME_SECONDS.
      allTactics=[];
      doc.querySelectorAll('Tactic').forEach(tc=>{
        const time=tc.getAttribute('time');const m=time&&time.match(/^(\d+):([\d.]+)$/);
        const frame=m?Math.round(((+m[1])*60+(+m[2]))/FRAME_SECONDS):0;
        allTactics.push({teamId:tc.getAttribute('teamId'),frame,time,
          type:tc.getAttribute('type'),value:tc.getAttribute('new_setting')});
      });
      allTactics.sort((a,b)=>a.frame-b.frame);
      _tsPrev={'0':null,'1':null}; // sin glow falso al cargar un partido nuevo
      _svaPrev={'0':null,'1':null};
      _relatoPrevId=null;
      if(typeof clearGolSeq==='function')clearGolSeq();
      _golSeqShown=new Set();

      const t0=Object.values(teams).find(t=>t.visiting==='0');
      const t1=Object.values(teams).find(t=>t.visiting==='1');
      document.getElementById('sc-t0').textContent=t0?.shortname||t0?.name||'—';
      document.getElementById('sc-t1').textContent=t1?.shortname||t1?.name||'—';
      // Escudos: misma versión reducida que usa MZ en los posts del guestbook.
      const b0=document.getElementById('sc-b0'), b1=document.getElementById('sc-b1');
      if(t0?.id){b0.style.visibility='visible';b0.src=`https://www.managerzone.com/dynimg/badge.php?team_id=${t0.id}&sport=soccer&location=guestbook_post`;}
      else b0.style.visibility='hidden';
      if(t1?.id){b1.style.visibility='visible';b1.src=`https://www.managerzone.com/dynimg/badge.php?team_id=${t1.id}&sport=soccer&location=guestbook_post`;}
      else b1.style.visibility='hidden';

      renderMatchStats();
      buildTeamPanels();
      calcAttackUp();
      render();
    }

    // ═══════════════════════════════════════════════════
    // MATCH STATS PANEL
    // ═══════════════════════════════════════════════════
    function renderMatchStats(){
      const t0=Object.values(teams).find(t=>t.visiting==='0');
      const t1=Object.values(teams).find(t=>t.visiting==='1');
      const s0=t0?.stats||{},s1=t1?.stats||{};
      const el=document.getElementById('match-stats-rows');

      const pct=(g,tot)=>tot?Math.round(g/tot*100):0;

      /**
       * msRow — fila de stat PRINCIPAL, con barra comparativa proporcional y,
       * opcionalmente, una sub-línea de precisión (ej. "70% a gol").
       */
      function msRow(lbl,v0,v1,sub,hasSub){
        const a=+v0||0,b=+v1||0,tot=a+b;
        const w0=tot?(a/tot*100):50, w1=tot?(b/tot*100):50;
        return `<div class="ms-row${hasSub?' has-sub':''}">
          <div class="ms-line"><span class="ms-v0">${v0}</span><span class="ms-label">${lbl}</span><span class="ms-v1">${v1}</span></div>
          <div class="ms-bar"><div class="ms-bar-0" style="width:${w0}%"></div><div class="ms-bar-1" style="width:${w1}%"></div></div>
          ${sub?`<div class="ms-sub"><span>${sub[0]}</span><span class="ms-sub-c">${sub[1]||''}</span><span>${sub[2]}</span></div>`:''}
        </div>`;
      }

      /**
       * msSubRow — desglose SUBORDINADO de la fila principal anterior (ej. "A
       * gol"/"Afuera" bajo "Disparos"). Sin barra propia y visualmente más
       * chico/indentado, para que no compita en jerarquía con las stats
       * principales — es detalle de la fila de arriba, no una stat aparte.
       */
      function msSubRow(lbl,v0,v1){
        return `<div class="ms-subrow"><span class="ms-v0">${v0}</span><span class="ms-label">${lbl}</span><span class="ms-v1">${v1}</span></div>`;
      }

      // Totales y precisiones
      const sh0=(s0.shots||0)+(s0.shotsWide||0), sh1=(s1.shots||0)+(s1.shotsWide||0);
      const pa0=(s0.passes||0)+(s0.intercepted||0), pa1=(s1.passes||0)+(s1.intercepted||0);
      const ta0=(s0.tackles||0)+(s0.tacklesLost||0), ta1=(s1.tackles||0)+(s1.tacklesLost||0);

      // ── PPDA (Passes Per Defensive Action) ──
      // Mide intensidad de presión: pases que el RIVAL completa antes de que
      // este equipo haga una acción defensiva (tackle ganado o intercepción).
      // Menor PPDA = presiona más rápido/intenso.
      //
      // Nota de escala: el motor de MZ genera muchas más acciones defensivas
      // por minuto que un partido real, así que el PPDA acá sale en un rango
      // bajo (0.5-2 aprox.) que NO es comparable al PPDA real de fútbol
      // (8-15). Sigue siendo válido para comparar AMBOS EQUIPOS DEL MISMO
      // PARTIDO entre sí — es una medida relativa, no un benchmark absoluto.
      const defActions0=(s0.tackles||0)+(s0.interceptionsTotal||0);
      const defActions1=(s1.tackles||0)+(s1.interceptionsTotal||0);
      const ppda0=defActions0?(s1.passes||0)/defActions0:0; // presión del equipo 0 sobre los pases del equipo 1
      const ppda1=defActions1?(s0.passes||0)/defActions1:0; // presión del equipo 1 sobre los pases del equipo 0

      el.innerHTML=
        msRow('Posesión',(s0.possession||0)+'%',(s1.possession||0)+'%')+
        msRow('Disparos',sh0,sh1,[`${pct(s0.shots,sh0)}% a gol`,'precisión',`${pct(s1.shots,sh1)}% a gol`],true)+
        msSubRow('A gol',s0.shots||0,s1.shots||0)+
        msSubRow('Afuera',s0.shotsWide||0,s1.shotsWide||0)+
        msRow('Pases',pa0,pa1,[`${pct(s0.passes,pa0)}%`,'precisión',`${pct(s1.passes,pa1)}%`],true)+
        msSubRow('Completados',s0.passes||0,s1.passes||0)+
        msSubRow('Interceptados',s0.intercepted||0,s1.intercepted||0)+
        msRow('Entradas',ta0,ta1,[`${pct(s0.tackles,ta0)}% ganadas`,'',`${pct(s1.tackles,ta1)}% ganadas`])+
        msRow('PPDA',ppda0.toFixed(2),ppda1.toFixed(2),[`${s1.passes||0}p/${defActions0}acc`,'presión*',`${s0.passes||0}p/${defActions1}acc`])+
        msRow('Atajadas',s0.saves||0,s1.saves||0)+
        msRow('Faltas',s0.freekicks||0,s1.freekicks||0)+
        msRow('Offsides',s0.offsides||0,s1.offsides||0)+
        msRow('Corners',s0.corners||0,s1.corners||0)+
        msRow('Amarillas',s0.yellowCards||0,s1.yellowCards||0)+
        msRow('Rojas',s0.redCards||0,s1.redCards||0)+
        msRow('Lesionados',s0.injuries||0,s1.injuries||0)+
        `<div class="ms-note">* PPDA = pases rival / acciones defensivas propias.<br>Menor = más presión. Escala MZ, no comparable a fútbol real.</div>`;

      renderScorersColumn();
    }

    /**
     * renderScorersColumn — columna derecha del panel: lista de goles (con
     * asistente) ordenados por minuto, y debajo el ranking de asistencias.
     */
    function renderScorersColumn(){
      const col=document.getElementById('scorers-col');
      if(!col)return;
      let html='';

      // ── Goles ──
      if(allGoals.length){
        html+=`<div class="sc-section-title">⚽ Goles</div>`;
        allGoals.forEach((g,i)=>{
          const t=teams[g.teamId]||{};
          const assXml=playerMap[g.assisterIid];
          html+=`<div class="sc-entry" data-gi="${i}">
            <div class="sc-entry-l1"><span class="sc-time">${(g.clock||'').split('.')[0]}</span><span style="color:${legibleOnDark(t.priRgb||'888')}">${g.scorerName.split(' ').pop()}</span></div>
            ${assXml?`<div class="sc-entry-l2">→${assXml.name.split(' ').pop()}</div>`:''}
          </div>`;
        });
      }

      // ── Ranking de asistencias ──
      const assistCount={};
      allGoals.forEach(g=>{
        if(g.assisterIid&&playerMap[g.assisterIid]){
          const nm=playerMap[g.assisterIid].name;
          assistCount[nm]=(assistCount[nm]||0)+1;
        }
      });
      const assists=Object.entries(assistCount).sort((a,b)=>b[1]-a[1]);
      if(assists.length){
        html+=`<div class="sc-section-title" style="margin-top:6px">🅰 Asistencias</div>`;
        assists.forEach(([nm,c])=>{
          html+=`<div class="sc-assist">${nm.split(' ').pop()} <span class="sc-cnt">×${c}</span></div>`;
        });
      }

      if(!html) html=`<div class="sc-section-title">Sin goles</div>`;
      col.innerHTML=html;

      // Click en un gol → croquis
      col.querySelectorAll('.sc-entry').forEach(e=>{
        e.onclick=()=>{const gi=+e.dataset.gi;if(allGoals[gi])showGoalCroquis(allGoals[gi]);};
      });
    }

    function updateScore(){
      const fi=Math.floor(currentFrame);
      const t0id=Object.keys(teams).find(k=>teams[k].visiting==='0');
      const t1id=Object.keys(teams).find(k=>teams[k].visiting==='1');
      let s0=0,s1=0;
      allGoals.forEach(g=>{
        if(g.frame<=fi){
          if(g.teamId===t0id)s0++;
          else if(g.teamId===t1id)s1++;
        }
      });
      if(!allGoals.length){
        for(let i=0;i<=fi&&i<frames.length;i++){
          (frames[i].events||[]).forEach(e=>{if(e.type==='GOAL'){if(e.team===t0id)s0++;else if(e.team===t1id)s1++;}});
        }
      }
      document.getElementById('sc-n0').textContent=s0;
      document.getElementById('sc-n1').textContent=s1;
    }

    // ═══════════════════════════════════════════════════
    // TEAM PANELS
    // ═══════════════════════════════════════════════════
    // ═══════════════════════════════════════════════════
    // RATING DE JUGADOR (estilo Sofascore) — FASE 1
    // ═══════════════════════════════════════════════════
    // Nota de partido COMPLETO derivada solo de datos 100% confiables del XML
    // (goles, asistencias, pases, intercepciones, tackles, tarjetas, paradas).
    // NO usa el detector de eventos del binario (impreciso) ni atribuye culpa de
    // goles a jugadores de campo (MZ no marca de quién fue el error), así que un
    // defensa del equipo goleado no se hunde por goles que no sabemos si causó.
    //
    // Base 6.5. Acumulador lineal sin tope, comprimido al mostrar con tanh para
    // que acercarse a 9-10 sea progresivamente más difícil (9+ = excepcional).
    // Eventos de alto volumen (pases, intercepciones, paradas) escalan sublineal
    // (count^0.78): más siempre suma más, pero un outlier de volumen no iguala a
    // un goleador. Multiplicador por línea para que un defensa valga por su
    // trabajo defensivo y un delantero no se hunda por no defender.
    const RATING_BASE=6.5, RATING_A=3.0, RATING_SUB_EXP=0.78;
    const RATING_PESOS={
      gol:{v:1.30,cat:'ataque',sub:false}, asistencia:{v:0.90,cat:'ataque',sub:false},
      pase_ok:{v:0.07,cat:'ataque',sub:true}, pase_perdido:{v:-0.10,cat:'ataque',sub:true},
      tiro_al_arco:{v:0.14,cat:'ataque',sub:true}, tiro_afuera:{v:-0.05,cat:'ataque',sub:true},
      // Entradas: alto volumen (llegan a 12+), por eso sublineales como pases e
      // intercepciones — si no, un defensa con muchas entradas dispara la nota.
      tackle_ok:{v:0.22,cat:'defensa',sub:true}, tackle_fail:{v:-0.13,cat:'defensa',sub:true},
      intercepcion:{v:0.14,cat:'defensa',sub:true},
      amarilla:{v:-0.30,cat:'neutral',sub:false}, roja:{v:-1.50,cat:'neutral',sub:false},
      parada:{v:0.22,cat:'porteria',sub:true}, gol_concedido:{v:-0.20,cat:'porteria',sub:false},
    };
    const RATING_MULT={
      DEF:{ataque:0.7,defensa:1.4,porteria:0,neutral:1},
      MID:{ataque:1.0,defensa:1.0,porteria:0,neutral:1},
      ATT:{ataque:1.2,defensa:0.7,porteria:0,neutral:1},
      GK:{ataque:0.3,defensa:0.5,porteria:1.5,neutral:1},
    };
    // Línea para el rating: DEF/MID/ATT/GK. Reusa la MISMA clasificación que el
    // orden del plantel (playerTier, que ya invierte la Y según el arquero del
    // equipo), para que etiqueta, multiplicador de rating y orden no se
    // desincronicen. Mapea los dos sub-grupos de mediocampo a un único 'MID'.
    function ratingLine(p){
      const t=playerTier(p); // 0 GK,1 DEF,2 MID-def,3 MID-off,4 ATT,5 suplente
      if(t===0)return'GK';
      if(t===1)return'DEF';
      if(t===4)return'ATT';
      if(t===5)return p.gk?'GK':'MID'; // suplente: usa flag de arquero si lo es
      return'MID';
    }
    function ratingCompress(raw){return RATING_BASE+RATING_A*Math.tanh((raw-RATING_BASE)/RATING_A);}
    function ratingFinal(raw){return Math.round(Math.max(1,Math.min(10,ratingCompress(raw)))*10)/10;}

    let _playerRatings={}; // iid -> {nota, pos, contrib}
    function computePlayerRatings(){
      _playerRatings={};
      if(!Object.keys(teams).length)return;
      // Goles concedidos por equipo (los que metió el rival)
      const goalsAgainst={};
      Object.values(teams).forEach(t=>{goalsAgainst[t.id]=allGoals.filter(g=>g.teamId!==t.id).length;});
      // Jugadores que entraron de cambio (su id aparece como playerId de una sustitución)
      const enteredAsSub=new Set();
      allSubs.forEach(sub=>{if(sub.inPlayer!=null)enteredAsSub.add(String(sub.inPlayer));});
      // ¿Participó? Titular (tiene originY) O entró de cambio O registró alguna acción.
      // Esto da nota a suplentes que jugaron y NO se la da a suplentes (incluido
      // arquero suplente) que nunca entraron.
      function played(p){
        if(p.originY!=null)return true;                 // titular
        if(enteredAsSub.has(String(p.id)))return true;  // entró de cambio
        const s=p.stats||{};
        const acted=(s.goals||0)+(s.passes||0)+(s.interceptedPasses||0)+(s.shots||0)+(s.shotsWide||0)
          +(s.tackles||0)+(s.tacklesLost||0)+(s.interceptions||0)+(s.saves||0)+(p.assists?.length||0);
        return acted>0;                                 // tuvo acciones registradas
      }
      Object.values(playerMap).forEach(p=>{
        if(!played(p))return;
        const pos=ratingLine(p);
        const m=RATING_MULT[pos];
        const s=p.stats||{};
        let raw=RATING_BASE;const contrib={};
        function add(tipo,count){
          if(!count)return;const def=RATING_PESOS[tipo];
          const c=def.sub?Math.pow(count,RATING_SUB_EXP):count;
          const imp=def.v*m[def.cat]*c;raw+=imp;contrib[tipo]=+imp.toFixed(2);
        }
        add('gol',s.goals||0);
        add('asistencia',p.assists?.length||0);
        add('pase_ok',s.passes||0);
        add('pase_perdido',s.interceptedPasses||0);
        add('tiro_al_arco',Math.max(0,(s.shots||0)-(s.goals||0)));
        add('tiro_afuera',s.shotsWide||0);
        add('tackle_ok',s.tackles||0);
        add('tackle_fail',s.tacklesLost||0);
        add('intercepcion',s.interceptions||0);
        add('amarilla',s.yellowCards||0);
        add('roja',s.redCards||0);
        if(pos==='GK'){
          add('parada',s.saves||0);
          const conceded=goalsAgainst[p.teamId]||0;
          const shotsFaced=(s.saves||0)+conceded;
          const workloadFactor=shotsFaced>0?Math.max(0.65,conceded/shotsFaced):1;
          const impGoles=RATING_PESOS.gol_concedido.v*m.porteria*conceded*workloadFactor;
          raw+=impGoles;contrib.gol_concedido=+impGoles.toFixed(2);
        }
        _playerRatings[p.internalId]={nota:ratingFinal(raw),pos,contrib,raw:+raw.toFixed(2)};
      });
    }
    // Color del chip de nota: rojo (bajo) → gris (neutro) → verde (alto)
    function ratingColor(n){
      if(n>=8.0)return'#1aa35a';
      if(n>=7.0)return'#3a8f4a';
      if(n>=6.5)return'#5a6a55';
      if(n>=6.0)return'#7a5a3a';
      return'#a33a3a';
    }

    // ═══════════════════════════════════════════════════
    // ORDEN DEL PLANTEL POR POSICIÓN (Arquero/Defensores/Mediocampistas/
    // Delanteros/Suplentes), derecha→izquierda dentro de cada grupo
    // ═══════════════════════════════════════════════════
    // "Titular" = tiene origin (XML solo lo completa para los 11 que arrancan;
    // confirmado cruzando con <Substitution>: los que entran de cambio también
    // tienen origin vacío, igual que los que nunca jugaron). Por eso el corte
    // titular/suplente se decide ANTES de mirar la posición — un arquero
    // suplente (goalkeeper=1 pero sin origin) cae en SUPLENTES, no en ARQUERO.
    //
    // Sub-corte de mediocampo (defensivo/ofensivo): Y=490 viene de la brecha
    // real más grande encontrada en una muestra de 12 mediocampistas titulares
    // de 4 planteles (datos en 423-608, la brecha más ancha cae justo ahí).
    // Muestra chica — si con más partidos se ve raro, se recalibra.
    // Sub-corte de mediocampo y umbrales de línea (en marco canónico "ataca hacia
    // arriba", Y alto = zona ofensiva). Valores definidos por Tor.
    const TIER_DEF_MAX=334;   // < 334  → defensor
    const TIER_MIDDEF_MAX=500;// 334–499 → mediocampista defensivo
    const TIER_MIDOFF_MAX=664;// 500–664 → mediocampista ofensivo ; > 664 → delantero

    // Cada equipo arranca en un extremo opuesto del campo en coordenadas origin.
    // La señal inequívoca de hacia dónde defiende cada equipo es dónde arranca SU
    // arquero (siempre en su propio arco): si su origin Y > 500, el equipo defiende
    // el arco de arriba y hay que invertir Y para llevarlo al marco canónico.
    // (No se usa calcAttackUp/getFlip acá: su base de frame0 da señales que
    // contradicen la posición real del arquero para este propósito.)
    let _teamDefendsHighY={}; // vis -> bool
    function computeTeamDirections(){
      _teamDefendsHighY={};
      ['0','1'].forEach(vis=>{
        const gk=Object.values(playerMap).find(p=>p.visiting===vis&&p.gk&&p.originY!=null);
        // Fallback si el arquero titular no tuviera origin: el visitante (vis=1)
        // arranca en el arco de arriba según la convención observada del XML.
        _teamDefendsHighY[vis]=gk?gk.originY>500:(vis==='1');
      });
    }
    // Y canónica: alto = zona ofensiva del equipo, sin importar a qué arco juega.
    function canonOriginY(p){
      if(p.originY==null)return null;
      return _teamDefendsHighY[p.visiting]?(DY1-p.originY):p.originY;
    }

    // PLAYER_TIER: 0=arquero 1=defensor 2=mediocampista def. 3=mediocampista of.
    // 4=delantero 5=suplente. PLAYER_VGROUP colapsa 2 y 3 en un solo "grupo
    // visible" (mediocampo) para que no se dibuje línea entre esos dos.
    function playerTier(p){
      if(p.originY==null)return 5;
      if(p.gk)return 0;
      const cy=canonOriginY(p);
      if(cy<TIER_DEF_MAX)return 1;
      if(cy<TIER_MIDDEF_MAX)return 2;
      if(cy<=TIER_MIDOFF_MAX)return 3;
      return 4;
    }
    const PLAYER_VGROUP={0:0,1:1,2:2,3:2,4:3,5:4};

    /**
     * canonOriginX — posición X de origin en la orientación canónica.
     * La inversión de X se aplica al equipo que NO defiende el arco de arriba (el
     * local, que no recibe la rotación de Y): su X cruda queda espejada respecto a
     * cómo MZ dibuja la formación. El equipo que defiende arriba ya queda bien sin
     * invertir. Verificado contra la formación oficial de la MZ Analyzer.
     */
    function canonOriginX(p){
      if(p.originX==null)return null;
      return _teamDefendsHighY[p.visiting]?p.originX:(DX1-p.originX);
    }

    /** sortSquad — titular agrupado por posición (derecha→izquierda por canonX), suplentes al final por camiseta. */
    function sortSquad(players){
      return players.slice().sort((a,b)=>{
        const ta=playerTier(a),tb=playerTier(b);
        if(ta!==tb)return ta-tb;
        if(ta===5)return(+a.shirt)-(+b.shirt);
        return canonOriginX(b)-canonOriginX(a);
      });
    }

    // ═══════════════════════════════════════════════════
    // MINI-CAMPO DE TÁCTICA INICIAL (según coordenadas origin)
    // ═══════════════════════════════════════════════════
    // Dibuja los 11 titulares en el marco canónico ya validado (arquero abajo,
    // equipo atacando hacia arriba) usando canonOriginX/canonOriginY, así ambos
    // equipos se ven con la misma orientación sin importar a qué arco juegan.
    const TP_W=750, TP_H=1000; // mismo sistema que origin (X 0-750, Y 0-1000)
    function renderTacticPitch(vis,containerId){
      const cont=document.getElementById(containerId);
      if(!cont)return;
      const titulares=Object.values(playerMap).filter(p=>p.visiting===vis&&p.originY!=null);
      if(!titulares.length){cont.innerHTML='<div style="font-size:var(--fs-xs);color:var(--ui-dim);padding:6px">Sin datos de formación</div>';return;}
      const t=Object.values(teams).find(tt=>tt.visiting===vis)||{};
      const pri=hex(t.priRgb||'888888'), sec=hex(t.secRgb||'cccccc'), txt=txtClr(t.priRgb||'888888');

      // margen para que los dorsales de los bordes no se corten
      const M=55, VW=TP_W+M*2, VH=TP_H+M*2;
      // Y canónica: arquero abajo (Y grande en pantalla), ataque hacia arriba.
      // canonOriginY da alto=ofensivo; en SVG el 0 es arriba, así que un jugador
      // ofensivo (canonY alto) debe ir arriba => screenY = TP_H - canonY.
      const node=p=>{
        const cx=canonOriginX(p), cy=canonOriginY(p);
        const sx=M+cx, sy=M+(TP_H-cy);
        const r=42;
        return `<g>
          <circle cx="${sx}" cy="${sy}" r="${r}" fill="${pri}" stroke="${sec}" stroke-width="4"/>
          <text x="${sx}" y="${sy}" text-anchor="middle" dominant-baseline="central" fill="${txt}" font-size="40" font-family="var(--font)" font-weight="bold">${p.shirt}</text>
        </g>`;
      };

      const ln='stroke="#3a6e4a" stroke-width="3" fill="none"';
      const pitch=`
      <svg viewBox="0 0 ${VW} ${VH}" xmlns="http://www.w3.org/2000/svg">
        <rect x="0" y="0" width="${VW}" height="${VH}" fill="#1a3d24"/>
        <rect x="${M}" y="${M}" width="${TP_W}" height="${TP_H}" fill="#1f4a2c" stroke="#3a6e4a" stroke-width="4"/>
        <line x1="${M}" y1="${M+TP_H/2}" x2="${M+TP_W}" y2="${M+TP_H/2}" ${ln}/>
        <circle cx="${M+TP_W/2}" cy="${M+TP_H/2}" r="90" ${ln}/>
        <circle cx="${M+TP_W/2}" cy="${M+TP_H/2}" r="4" fill="#3a6e4a"/>
        <!-- área inferior (arco propio) -->
        <rect x="${M+TP_W/2-180}" y="${M+TP_H-150}" width="360" height="150" ${ln}/>
        <rect x="${M+TP_W/2-80}" y="${M+TP_H-55}" width="160" height="55" ${ln}/>
        <!-- área superior (arco rival) -->
        <rect x="${M+TP_W/2-180}" y="${M}" width="360" height="150" ${ln}/>
        <rect x="${M+TP_W/2-80}" y="${M}" width="160" height="55" ${ln}/>
        ${titulares.map(node).join('')}
      </svg>`;
      cont.innerHTML=pitch;
    }

    function toggleTacticPitch(vis,btnId,pitchId){
      const btn=document.getElementById(btnId), pitch=document.getElementById(pitchId);
      if(!btn||!pitch)return;
      const show=pitch.hidden;
      pitch.hidden=!show;
      btn.classList.toggle('active',show);
      if(show)renderTacticPitch(vis,pitchId);
    }

    function buildTeamPanels(){
      computeTeamDirections();
      computePlayerRatings();
      buildTeamPanel('0','local-list','local-name','local-sub');
      buildTeamPanel('1','visitor-list','visitor-name','visitor-sub');
      // Si algún mini-campo de táctica está abierto, redibujarlo con los datos nuevos.
      [['0','local-tactic-pitch'],['1','visitor-tactic-pitch']].forEach(([vis,pid])=>{
        const el=document.getElementById(pid);
        if(el&&!el.hidden)renderTacticPitch(vis,pid);
      });
    }
    function buildTeamPanel(vis,listId,nameId,subId){
      const t=Object.values(teams).find(t=>t.visiting===vis);
      if(t){
        document.getElementById(nameId).textContent=t.name||'—';
        document.getElementById(nameId).style.color=legibleOnDark(t.priRgb||'888');
        document.getElementById(subId).textContent=(t.tactic||'')+(t.series?' · '+t.series:'');
      }
      const list=document.getElementById(listId);
      list.innerHTML='';
      let players=Object.values(playerMap).filter(p=>p.visiting===vis);
      if(!players.length){
        // Fallback from frames
        if(!frames.length)return;
        const seen={};
        frames[0].players.forEach(p=>{
          const vis2=p.team;if(vis2!==vis)return;
          if(!seen[p.id])seen[p.id]={internalId:String(p.id),name:'#'+p.id,shirt:String(p.id),visiting:vis,priRgb:'888888',secRgb:'cccccc',goals:[],saves:[],cards:[],assists:[],misses:[],stats:{}};
        });
        players.push(...Object.values(seen));
      }
      players=sortSquad(players);
      let lastVGroup=null;
      players.forEach(p=>{
        const vg=PLAYER_VGROUP[playerTier(p)];
        if(lastVGroup!==null&&vg!==lastVGroup){
          const div=document.createElement('div');
          div.className='tp-divider'+(vg===4?' thick':'');
          list.appendChild(div);
        }
        lastVGroup=vg;
        list.appendChild(makePlayerRow(p,vis));
      });
    }

    function makePlayerRow(p,vis){
      const row=document.createElement('div');
      row.className='tp-player';row.dataset.iid=p.internalId;
      const badges=[];
      if(p.stats?.goals>0)badges.push(`<span class="tp-badge g">⚽${p.stats.goals}</span>`);
      if(p.assists?.length>0)badges.push(`<span class="tp-badge g">🅰${p.assists.length}</span>`);
      if(p.cards?.some(c=>c.type.includes('red')||c.type.includes('second')))badges.push(`<span class="tp-badge r">🟥</span>`);
      else if(p.cards?.some(c=>c.type.includes('yellow')))badges.push(`<span class="tp-badge y">🟨</span>`);
      if(p.stats?.saves>0)badges.push(`<span class="tp-badge s">🧤${p.stats.saves}</span>`);
      const rt=_playerRatings[p.internalId];
      const ratingChip=rt?`<div class="tp-rating" style="background:${ratingColor(rt.nota)}" title="Nota de partido (${rt.pos})">${rt.nota.toFixed(1)}</div>`:'';
      row.innerHTML=`
        <div class="tp-shirt" style="background:${hex(p.priRgb)};border-color:${hex(p.secRgb)};color:${txtClr(p.priRgb)}">${p.shirt}</div>
        <div class="tp-name">
          <div>${p.name}</div>
          ${badges.length?`<div class="tp-badges">${badges.join('')}</div>`:''}
        </div>
        ${ratingChip}`;
      row.onclick=(e)=>{e.stopPropagation();toggleDropdown(p,row,vis);}
      return row;
    }

    let openDropdown=null;

    // Convert frame number to integer minute string (e.g. "23'")
    function frameToClock(frame){
      if(!frame)return'0\'';
      const totalSec=Math.round(frame*FRAME_SECONDS);
      const m=Math.floor(totalSec/60);
      return `${m}'`;
    }

    // ═══════════════════════════════════════════════════
    // BARRA DE RELATO — jugador que conduce + grito de gol
    // ═══════════════════════════════════════════════════
    // En vivo (sin mapa) el centro muestra: [N°] — [Inicial]. [Apellido] del que
    // conduce, con el color del equipo. Al marcarse un gol, una secuencia de 3
    // mensajes lo anuncia. Con un mapa activo, el centro lo ocupa el label/half-
    // selector (setMapMode oculta el relato), así que acá no se toca.
    let _relatoPrevId=null;
    let _golSeqShown=new Set();// claves de goles ya anunciados (no repetir)
    let _golTimers=[];         // timeouts de la secuencia, para cancelarlos

    function clearGolSeq(){
      _golTimers.forEach(t=>clearTimeout(t));_golTimers=[];
    }

    let _golSeqActive=false; // true mientras corre la secuencia de mensajes de gol

    function clearGolSeq(){
      _golTimers.forEach(t=>clearTimeout(t));_golTimers=[];
      _golSeqActive=false;
    }

    function triggerGolRelato(g){
      const bar=document.getElementById('relato-txt');if(!bar)return;
      clearGolSeq();
      _golSeqActive=true;
      const t=teams[g.teamId]||{};
      const scorer=playerMap[g.scorerId]||{};
      const scorerNum=scorer.shirt||g.scorerShirt||'';
      const scorerName=scorer.name||g.scorerName||'';
      const ass=g.assisterIid?playerMap[g.assisterIid]:null;
      const msgs=[
        {txt:`¡GOOOL! de ${t.name||''}`,gol:true},
        {txt:`Convierte ${scorerNum} ${scorerName}`,gol:false},
      ];
      if(ass)msgs.push({txt:`Asistió ${ass.shirt} ${ass.name}`,gol:false});
      const teamColor=legibleOnDark(t.priRgb||'888888');
      function show(i){
        if(i>=msgs.length){
          // Secuencia terminada: liberar el relato normal.
          _golSeqActive=false;_golTimers=[];
          bar.classList.remove('gol');
          _relatoPrevId=null; // fuerza a updateRelato a repintar el conductor actual
          return;
        }
        const m=msgs[i];
        bar.textContent=m.txt;
        bar.classList.remove('hidden');
        bar.classList.toggle('gol',m.gol);
        bar.style.color=m.gol?'':teamColor;
        _golTimers.push(setTimeout(()=>show(i+1),i===msgs.length-1?2600:2200));
      }
      show(0);
    }

    function updateRelato(){
      const bar=document.getElementById('relato-txt');if(!bar)return;
      if(mapMode){bar.classList.add('hidden');return;} // mapa activo: el centro es del map-label
      const fi=Math.floor(currentFrame);
      if(!frames.length||fi>=frames.length){bar.classList.add('hidden');return;}

      // ── Grito de gol: ¿hay un gol nuevo en una ventana cercana al frame actual? ──
      for(const g of allGoals){
        const key=g.frame+'_'+(g.scorerId||g.scorerShirt);
        if(_golSeqShown.has(key))continue;
        if(fi>=g.frame && fi<=g.frame+30){
          _golSeqShown.add(key);
          triggerGolRelato(g);
          return;
        }
      }
      // Mientras la secuencia de gol corre, no pisarla con el relato normal.
      if(_golSeqActive)return;

      // ── Relato normal: jugador que conduce ──
      const f=frames[fi];
      const{player:p}=closestPlayer(f,50);
      if(!p){
        if(_relatoPrevId!==null){bar.classList.add('hidden');_relatoPrevId=null;}
        return;
      }
      if(p.id===_relatoPrevId && !bar.classList.contains('gol'))return;
      _relatoPrevId=p.id;
      const xp=playerMap[p.id];
      if(!xp){bar.classList.add('hidden');return;}
      const parts=xp.name.trim().split(/\s+/);
      const apellido=parts[parts.length-1];
      const inicial=parts.length>1?parts[0][0]+'.':'';
      const label=`${xp.shirt} — ${inicial}${inicial?' ':''}${apellido}`;
      bar.classList.remove('gol');
      bar.textContent=label;
      bar.style.color=legibleOnDark(xp.priRgb||'888888');
      bar.classList.remove('hidden');
    }

    // ═══════════════════════════════════════════════════
    // SETTINGS TÁCTICOS VIGENTES (en vivo, con glow al cambiar)
    // ═══════════════════════════════════════════════════
    const TS_LABELS={tactic:'Táctica',playstyle:'Estilo',aggression:'Agresividad'};
    // Traducción de los valores del XML a etiquetas legibles en español.
    const TS_VALUES={
      wings:'Bandas',shortpass:'Pase corto',longball:'Pase largo',longpass:'Pase largo',
      normal:'Normal',offensive:'Ofensivo',defensive:'Defensivo',
      passive:'Pasivo',aggressive:'Agresivo',
    };
    function tsVal(v){return v?(TS_VALUES[v]||v):'—';}

    // Settings vigentes de un equipo en el frame dado: arranca con los iniciales
    // del <Team> y aplica los <Tactic> cuyo frame ya pasó.
    function tacticSettingsAt(teamId,frame){
      const t=teams[teamId]||{};
      const cur={tactic:t.tactic,playstyle:t.playstyle,aggression:t.aggression};
      allTactics.forEach(tc=>{
        if(tc.teamId===teamId&&tc.frame<=frame&&cur[tc.type]!==undefined)cur[tc.type]=tc.value;
      });
      return cur;
    }

    // Estado previo por panel, para detectar cambios y disparar el glow.
    let _tsPrev={'0':null,'1':null};
    // Los 11 jugadores en cancha de un equipo en el frame dado: arranca con los
    // titulares (originY!=null) y aplica las sustituciones cuyo frame ya pasó
    // (sale outPlayer, entra inPlayer). Devuelve array de objetos playerMap.
    function elevenOnPitch(vis,frame){
      const t=Object.values(teams).find(tt=>tt.visiting===vis);
      if(!t)return [];
      // titulares por id
      const onPitch=new Set(
        Object.values(playerMap).filter(p=>p.visiting===vis&&p.originY!=null).map(p=>String(p.id))
      );
      allSubs.filter(s=>s.teamId===t.id&&s.frame<=frame)
        .sort((a,b)=>a.frame-b.frame)
        .forEach(s=>{
          if(s.outPlayer!=null)onPitch.delete(String(s.outPlayer));
          if(s.inPlayer!=null)onPitch.add(String(s.inPlayer));
        });
      return Object.values(playerMap).filter(p=>onPitch.has(String(p.id)));
    }

    // Valor total (USD) y edad promedio del 11 en cancha. Si algún jugador no tiene
    // datos de la playerlist, se cuenta solo con los que sí (y se marca parcial).
    function squadValueAge(vis,frame){
      const eleven=elevenOnPitch(vis,frame);
      let totalUsd=0,ageSum=0,ageN=0,valN=0,anyUnconverted=false,missing=false;
      eleven.forEach(p=>{
        const ex=playerExtra[String(p.id)];
        if(!ex){missing=true;return;}
        if(ex.age){ageSum+=ex.age;ageN++;}
        if(ex.valueUsd!=null){totalUsd+=ex.valueUsd;valN++;}
        else if(ex.valueLocal){anyUnconverted=true;}
      });
      return {
        totalUsd, valN,
        avgAge: ageN? (ageSum/ageN):null,
        hasData: valN>0||ageN>0,
        partial: missing||anyUnconverted
      };
    }

    // Formatea un valor grande en USD de forma compacta: 12.3M, 985k, etc.
    function fmtUsd(v){
      if(v>=1e6)return '$'+(v/1e6).toFixed(2).replace(/\.?0+$/,'')+'M';
      if(v>=1e3)return '$'+Math.round(v/1e3)+'k';
      return '$'+v;
    }

    // Estado previo del valor/edad por panel, para detectar cambios (glow) y no
    // repintar de más. Guardamos valor, edad y cuántas subs se aplicaron, para
    // destellar en el momento exacto de una sustitución aunque el texto redondee igual.
    let _svaPrev={'0':null,'1':null};
    function subsAppliedCount(vis,frame){
      const t=Object.values(teams).find(tt=>tt.visiting===vis);
      if(!t)return 0;
      return allSubs.filter(s=>s.teamId===t.id&&s.frame<=frame).length;
    }
    function renderSquadValueAge(){
      ['0','1'].forEach(vis=>{
        const cont=document.getElementById(vis==='0'?'ts-squad-local':'ts-squad-visitor');
        if(!cont)return;
        const fi=Math.floor(currentFrame);
        const sva=squadValueAge(vis,fi);
        if(!sva.hasData){cont.innerHTML='';_svaPrev[vis]=null;return;}
        const valStr=sva.valN?fmtUsd(sva.totalUsd)+(sva.partial?'*':''):'—';
        const ageStr=sva.avgAge!=null?sva.avgAge.toFixed(1)+' años':'—';
        const subCount=subsAppliedCount(vis,fi);
        const prev=_svaPrev[vis];
        // Sin cambios (mismo texto y mismas subs aplicadas) → no tocar el DOM.
        if(prev&&prev.valStr===valStr&&prev.ageStr===ageStr&&prev.subCount===subCount)return;
        // Destellar si cambió el valor/edad O si se acaba de aplicar una nueva sub.
        const newSub=prev&&subCount>prev.subCount;
        const valChanged=prev&&(prev.valStr!==valStr||newSub);
        const ageChanged=prev&&(prev.ageStr!==ageStr||newSub);
        _svaPrev[vis]={valStr,ageStr,subCount};
        cont.innerHTML=`
          <div class="ts-item">
            <span class="ts-label">Valor 11</span>
            <span class="ts-sva-val${valChanged?' changed':''}" title="Valor total del once en cancha (USD)${sva.partial?' — parcial: faltan datos de algún jugador':''}">${valStr}</span>
          </div>
          <div class="ts-item">
            <span class="ts-label">Edad prom.</span>
            <span class="ts-sva-val${ageChanged?' changed':''}">${ageStr}</span>
          </div>`;
      });
    }

    function renderTacticSettings(){
      ['0','1'].forEach(vis=>{
        const cont=document.getElementById(vis==='0'?'ts-values-local':'ts-values-visitor');
        if(!cont)return;
        const t=Object.values(teams).find(tt=>tt.visiting===vis);
        if(!t){cont.innerHTML='';_tsPrev[vis]=null;return;}
        const fi=Math.floor(currentFrame);
        const cur=tacticSettingsAt(t.id,fi);
        const prev=_tsPrev[vis];
        // Solo tocar el DOM si algo cambió (evita reiniciar el glow en cada frame).
        if(prev&&prev.tactic===cur.tactic&&prev.playstyle===cur.playstyle&&prev.aggression===cur.aggression){
          renderSquadValueAge();
          return;
        }
        cont.innerHTML=['tactic','playstyle','aggression'].map(type=>{
          const changed=prev&&prev[type]!==cur[type];
          return `<div class="ts-item">
            <span class="ts-label">${TS_LABELS[type]}</span>
            <span class="ts-value${changed?' changed':''}">${tsVal(cur[type])}</span>
          </div>`;
        }).join('');
        _tsPrev[vis]=cur;
      });
      renderSquadValueAge();
    }

    function toggleDropdown(p,row,vis){
      // If same player clicked again — close and deselect
      if(openDropdown){
        openDropdown.remove();openDropdown=null;
        if(activePlayer?.internalId===p.internalId){
          activePlayer=null;
          document.querySelectorAll('.tp-player').forEach(r=>r.classList.remove('active'));
          return;
        }
      }
      activePlayer=p;
      document.querySelectorAll('.tp-player').forEach(r=>r.classList.remove('active'));
      row.classList.add('active');

      const dd=document.createElement('div');
      dd.className='tp-dropdown';

      // ── Encabezado: nota del partido + desglose de aportes ──
      const rt=_playerRatings[p.internalId];
      if(rt){
        const labels={gol:'Goles',asistencia:'Asistencias',pase_ok:'Pases',pase_perdido:'Pases perdidos',
          tiro_al_arco:'Tiros al arco',tiro_afuera:'Tiros afuera',tackle_ok:'Entradas',tackle_fail:'Entradas falladas',
          intercepcion:'Intercepciones',amarilla:'Amarilla',roja:'Roja',parada:'Paradas',gol_concedido:'Goles recibidos'};
        const items=Object.entries(rt.contrib).filter(([k])=>!k.startsWith('_')&&labels[k])
          .sort((a,b)=>Math.abs(b[1])-Math.abs(a[1]));
        const chips=items.map(([k,v])=>{
          const pos=v>=0;
          return `<div class="rt-chip"><span>${labels[k]}</span><span style="color:${pos?'#5dca8a':'#e07a7a'}">${pos?'+':''}${v.toFixed(2)}</span></div>`;
        }).join('');
        const head=document.createElement('div');
        head.className='rt-head';
        const _ex=playerExtra[String(p.id)];
        const ageStr=_ex&&_ex.age?` · ${_ex.age} años`:'';
        head.innerHTML=`
          <div class="rt-main">
            <div class="rt-big" style="background:${ratingColor(rt.nota)}">${rt.nota.toFixed(1)}</div>
            <div class="rt-meta"><div class="rt-pos">${rt.pos}</div><div class="rt-cap">Nota del partido${ageStr}</div></div>
          </div>
          <div class="rt-breakdown">${chips||'<div class="rt-cap" style="padding:2px 0">Sin acciones registradas</div>'}</div>`;
        dd.appendChild(head);
      }

      /**
       * dedupeEvents — elimina duplicados de una lista de eventos detectados.
       * Dos eventos son duplicados si tienen el mismo fromPlayer y están a
       * menos de MIN_GAP frames de distancia (misma acción detectada dos veces).
       */
      function dedupeEvents(evs, minGap=15){
        const out=[];
        evs.forEach(ev=>{
          // Buscar si ya hay un evento del mismo jugador muy cercano en el tiempo
          const isDup=out.some(e=>
            String(e.fromPlayer?.id||e.player?.id) === String(ev.fromPlayer?.id||ev.player?.id)
            && Math.abs(e.frame - ev.frame) < minGap
          );
          if(!isDup) out.push(ev);
        });
        return out;
      }

      // ── Use XML stats as source of truth for counts ──
      const s=p.stats||{};
      const myGoals=allGoals.filter(g=>g.scorerId===p.internalId);
      const myAssists=p.assists||[];
      // Deduplicar eventos detectados del binario
      const myPasses=dedupeEvents(allPasses.filter(q=>q.fromPlayer.id==p.internalId));
      const myShots=dedupeEvents(allShots.filter(q=>q.fromPlayer.id==p.internalId));
      const myInter=dedupeEvents(allInterceptions.filter(q=>q.player.id==p.internalId));
      const mySaves=allSaves.filter(q=>q.keeperId===p.internalId);

      // XML counts (ground truth for labels)
      const xmlGoals=s.goals||myGoals.length;
      const xmlAssists=myAssists.length;
      const xmlInter=s.interceptions||0;
      const xmlSaves=s.saves||0;

      // ── Desglose (total, buenos, malos, %) según XML — fuente de verdad ──
      // Formato pedido: "Total (buenos/malos pct%)" donde pct = buenos/total redondeado.
      // PASES:    buenos=goodPasses,   malos=interceptedPasses (los pases del jugador que fueron cortados)
      // ENTRADAS: buenos=tacklesWon,   malos=tacklesLost
      // DISPAROS: buenos=shotsOnGoal,  malos=shotsWide (afuera)
      function breakdown(good,bad){
        const total=good+bad;
        const pct=total?Math.round(good/total*100):0;
        return {total,good,bad,pct,text:total?`${total} (${good}/${bad} ${pct}%)`:'0'};
      }
      const bdPasses=breakdown(s.passes||0, s.interceptedPasses||0);
      const bdTackles=breakdown(s.tackles||0, s.tacklesLost||0);
      const bdShots=breakdown(s.shots||0, s.shotsWide||0);

      const xmlPasses=bdPasses.total;
      const xmlShots=bdShots.total;
      const xmlTackles=bdTackles.total;

      /**
       * makeEventList — builds a category row + collapsible sub-list.
       * Clicking the row opens the analytic modal for that event type.
       * The ▾ toggle collapses/expands the per-minute sub-list.
       */
      function makeEventList(icon,label,count,events,clickFn,allFn,eventType,labelText){
        if(!count&&!events.length)return;

        // labelText permite mostrar el desglose "16 (9/7 56%)" en vez de solo "(16)".
        const countDisplay=labelText!=null?labelText:`(${count})`;

        // Main category row
        const item=document.createElement('div');item.className='dd-item';
        item.innerHTML=`
          <span class="dd-icon">${icon}</span>
          <span style="flex:1">${label} ${countDisplay}</span>
          <span class="dd-toggle" title="Ver lista" style="color:var(--ui-muted);cursor:pointer;padding:0 4px">▾</span>`;

        // Clicking the row (not the toggle arrow) → open analytic window
        item.addEventListener('click',e=>{
          if(e.target.classList.contains('dd-toggle')){
            // Toggle the per-minute sub-list
            const sub=item.nextElementSibling;
            if(sub&&sub.classList.contains('dd-sublist')){
              sub.classList.toggle('open');
              e.target.textContent=sub.classList.contains('open')?'▴':'▾';
            }
            return;
          }
          openAnalyticModal(`${icon} ${label}`,events,eventType,allFn,vis,p);
        });

        // Per-minute sub-list (hidden by default)
        const sub=document.createElement('div');sub.className='dd-sublist';
        events.forEach(ev=>{
          const clock=ev.clock||frameToClock(ev.frame);
          const si=document.createElement('div');si.className='dd-sub-item';
          si.innerHTML=`<span style="color:var(--accent);min-width:38px">${clock}</span><span>${ev.label||''}</span>`;
          si.addEventListener('click',e=>{e.stopPropagation();clickFn(ev);});
          sub.appendChild(si);
        });

        dd.appendChild(item);
        dd.appendChild(sub);
      }

      // ── Build category options (only shown if player has data) ──
      if(xmlGoals>0){
        const evs=myGoals.map(g=>({...g,label:`⚽ ${playerMap[g.assisterIid]?.name?.split(' ').pop()||''}`,clock:g.clock,frame:g.frame}));
        makeEventList('⚽','Goles',xmlGoals,evs,ev=>showGoalCroquis(ev),()=>showGoalsCroquis(p,myGoals),'goals');
      }
      if(xmlAssists>0){
        const evs=myAssists.map(g=>({...g,label:`→ ${g.scorerName?.split(' ').pop()||''}`,clock:g.clock,frame:g.frame}));
        makeEventList('🅰','Asistencias',xmlAssists,evs,ev=>showGoalCroquis(ev),()=>showAssistsCroquis(p,myAssists),'assists');
      }
      if(xmlShots>0){
        const evs=myShots.map(q=>({...q,label:q.isHeader?'cabeza':'pie',clock:frameToClock(q.frame),frame:q.frame,success:true}));
        makeEventList('🎯','Disparos',xmlShots,evs,ev=>showSingleShot(p,ev,vis),()=>showPlayerMap(p,'shots',vis),'shots',bdShots.text);
      }
      if(xmlPasses>0){
        const evs=myPasses.map(q=>({...q,label:q.success?'✓':'✗',clock:frameToClock(q.frame),frame:q.frame}));
        makeEventList('🔁','Pases',xmlPasses,evs,ev=>showSinglePass(p,ev,vis),()=>showPlayerMap(p,'passes',vis),'passes',bdPasses.text);
      }
      if(xmlInter>0){
        const evs=myInter.slice(0,xmlInter).map(q=>({...q,label:'intercepción',clock:frameToClock(q.frame),frame:q.frame,success:true}));
        makeEventList('✋','Interceptaciones',xmlInter,evs,ev=>showSingleInterception(p,ev,vis),()=>showPlayerMap(p,'interceptions',vis),'interceptions');
      }
      if(xmlTackles>0){
        const tackleEvs=allTackleEvents.filter(q=>q.player.id==p.internalId).slice(0,xmlTackles);
        const evs=tackleEvs.map(q=>({...q,label:'entrada',clock:frameToClock(q.frame),frame:q.frame,success:true}));
        makeEventList('🦵','Entradas',xmlTackles,evs,ev=>showSingleInterception(p,ev,vis),()=>showPlayerMap(p,'tackles',vis),'tackles',bdTackles.text);
      }
      if(xmlSaves>0&&p.gk){
        const evs=mySaves.map(sv=>({...sv,label:'parada',clock:sv.clock||frameToClock(sv.frame),frame:sv.frame,success:true}));
        makeEventList('🧤','Paradas',xmlSaves,evs,ev=>showSingleSave(p,ev),()=>showSavesCroquis(p,mySaves),'saves');
      }

      /**
       * makeSimpleItem — fila clickeable sin sub-lista desplegable (no hay
       * "eventos discretos" que listar, ej. mapa de calor de posición).
       */
      function makeSimpleItem(icon,label,onClick){
        const item=document.createElement('div');item.className='dd-item';
        item.innerHTML=`<span class="dd-icon">${icon}</span><span style="flex:1">${label}</span>`;
        item.addEventListener('click',onClick);
        dd.appendChild(item);
      }

      // ── Mapa de calor de posición (siempre disponible si hay frames cargados) ──
      if(frames.length){
        makeSimpleItem('🔥','Mapa de calor',()=>showHeatmap(p,vis));
      }

      // ── Distancia recorrida (XML, confirmado en metros) ──
      // Nota: el binario tiene un tope de movimiento por frame que, traducido
      // con la escala real del campo, da ~5.6 km/h — muy por debajo de un sprint
      // real de fútbol (25-35 km/h). Eso indica que el motor de MZ no mueve a
      // los jugadores como un cuerpo físico real entre frames, así que NO
      // calculamos una "velocidad máxima en km/h" — sería un número inventado.
      // En su lugar mostramos distancia (sólida, confirmada en metros) y un
      // ranking relativo de intensidad contra el resto de jugadores del partido.
      if(s.distance>0){
        const distKm=(s.distance/1000).toFixed(2);
        const rank=distanceRankPercentile(p.internalId,s.distance);
        const rankTxt=rank!=null?` · top ${rank}% del partido`:'';
        const infoItem=document.createElement('div');infoItem.className='dd-item';
        infoItem.style.cursor='default';
        infoItem.innerHTML=`<span class="dd-icon">🏃</span><span style="flex:1;color:var(--ui-muted)">${distKm} km recorridos${rankTxt}</span>`;
        dd.appendChild(infoItem);
      }


      if(!dd.children.length){
        dd.innerHTML=`<div class="dd-item" style="color:var(--ui-muted)">Sin datos disponibles</div>`;
      }

      // ── Insert INLINE after the row (no absolute positioning) ──
      row.after(dd);
      openDropdown=dd;

      // Close on click outside dropdown and its triggering row
      setTimeout(()=>{
        document.addEventListener('click',closeDD,{once:true});
      },10);
    }

    /**
     * closeDD — removes inline dropdown on outside click.
     * Checks that the click is not inside the open dropdown.
     */
    function closeDD(e){
      if(openDropdown&&!openDropdown.contains(e?.target)){
        openDropdown.remove();openDropdown=null;
      }
    }

    // ═══════════════════════════════════════════════════
    // ANALYTIC MODAL — draggable, resizable, filterable
    // ═══════════════════════════════════════════════════
    let amCurrentEvents=[]; // all events for current category
    let amSelectedIds=new Set(); // indices of checked events

    /**
     * openAnalyticModal — populates and shows the floating analytic window.
     * @param {string} title  Category title (e.g. "🔁 Pases")
     * @param {Array}  events All events for this category
     * @param {string} type   Event type key
     * @param {Function} allFn Function to render all events on canvas
     * @param {string} vis   Team side
     * @param {Object} player Player object
     */
    function openAnalyticModal(title,events,type,allFn,vis,player){
      amCurrentEvents=events;
      amSelectedIds=new Set(events.map((_,i)=>i)); // start all selected

      const modal=document.getElementById('analytic-modal');
      document.getElementById('am-title').textContent=title;
      document.getElementById('am-outcome').value='all';
      document.getElementById('am-check-all').checked=true;
      modal.classList.add('visible');

      renderAmList('all');

      // "Aplicar selección": renders only checked events on canvas
      document.getElementById('am-btn-apply').onclick=()=>{
        const selected=events.filter((_,i)=>amSelectedIds.has(i));
        if(!selected.length)return;
        // Temporarily override map data with selection
        setMapMode(type,player,vis,selected);
        renderFilteredMap(type,player,vis,selected);
      };

      // "Limpiar selección": deselect all
      document.getElementById('am-btn-clear').onclick=()=>{
        amSelectedIds.clear();
        document.getElementById('am-check-all').checked=false;
        renderAmList(document.getElementById('am-outcome').value);
        updateAmSelCount();
      };

      // Outcome filter change
      document.getElementById('am-outcome').onchange=(e)=>{
        renderAmList(e.target.value);
      };

      // Select-all checkbox
      document.getElementById('am-check-all').onchange=(e)=>{
        const outcome=document.getElementById('am-outcome').value;
        const visible=getFilteredEvents(outcome);
        visible.forEach(({idx})=>{
          if(e.target.checked) amSelectedIds.add(idx);
          else amSelectedIds.delete(idx);
        });
        renderAmList(outcome);
        updateAmSelCount();
      };
    }

    /** Returns events filtered by outcome ('all'|'ok'|'fail') with original index */
    function getFilteredEvents(outcome){
      return amCurrentEvents.map((ev,idx)=>({ev,idx})).filter(({ev})=>{
        if(outcome==='all')return true;
        if(outcome==='ok')return ev.success!==false;
        if(outcome==='fail')return ev.success===false;
        return true;
      });
    }

    /** Renders the event list inside the modal */
    function renderAmList(outcome){
      const list=document.getElementById('am-list');
      list.innerHTML='';
      const filtered=getFilteredEvents(outcome);

      if(!filtered.length){
        list.innerHTML=`<div class="am-event-row" style="color:var(--ui-muted)">Sin eventos para este filtro</div>`;
        return;
      }

      filtered.forEach(({ev,idx})=>{
        const row=document.createElement('div');
        const isOk=ev.success!==false;
        row.className='am-event-row'+(amSelectedIds.has(idx)?' selected':'')+(isOk?'':' fail');

        const clock=ev.clock||frameToClock(ev.frame);
        const badge=isOk
          ?`<span class="am-event-badge ok">✓</span>`
          :`<span class="am-event-badge fail">✗</span>`;

        // Individual checkbox for this event
        const cb=document.createElement('input');
        cb.type='checkbox';cb.checked=amSelectedIds.has(idx);
        cb.style.accentColor='var(--accent)';cb.style.cursor='pointer';
        cb.addEventListener('change',e=>{
          e.stopPropagation();
          if(e.target.checked)amSelectedIds.add(idx);
          else amSelectedIds.delete(idx);
          row.classList.toggle('selected',e.target.checked);
          updateAmSelCount();
        });

        row.innerHTML=`
          <span class="am-event-time">${clock}</span>
          <span class="am-event-desc">${ev.label||''}</span>
          ${badge}`;
        row.prepend(cb);

        // Clicking the row (not checkbox) → show that single event on canvas
        row.addEventListener('click',e=>{
          if(e.target===cb)return;
          // single event preview — implemented per type via currentMapData
        });

        list.appendChild(row);
      });
      updateAmSelCount();
    }

    function updateAmSelCount(){
      const total=amCurrentEvents.length;
      document.getElementById('am-sel-count').textContent=`${amSelectedIds.size}/${total} seleccionados`;
    }

    /** Renders only the selected subset of events on the canvas */
    function renderFilteredMap(type,player,vis,selectedEvents){
      // Temporarily swap the detected event arrays for render
      const origPasses=allPasses,origShots=allShots,origInter=allInterceptions;
      if(type==='passes')allPasses=selectedEvents;
      else if(type==='shots')allShots=selectedEvents;
      else if(type==='interceptions')allInterceptions=selectedEvents;
      render();
      allPasses=origPasses;allShots=origShots;allInterceptions=origInter;
    }

    // ── Drag logic for analytic modal ──
    (()=>{
      const modal=document.getElementById('analytic-modal');
      const titlebar=document.getElementById('am-titlebar');
      let dragging=false,startX=0,startY=0,initL=0,initT=0;

      titlebar.addEventListener('mousedown',e=>{
        dragging=true;
        startX=e.clientX;startY=e.clientY;
        const rect=modal.getBoundingClientRect();
        initL=rect.left;initT=rect.top;
        modal.style.transform='none'; // remove initial centering transform
        modal.style.left=initL+'px';modal.style.top=initT+'px';
        e.preventDefault();
      });
      document.addEventListener('mousemove',e=>{
        if(!dragging)return;
        modal.style.left=(initL+e.clientX-startX)+'px';
        modal.style.top=(initT+e.clientY-startY)+'px';
      });
      document.addEventListener('mouseup',()=>{dragging=false;});

      // Close button
      document.getElementById('am-close').onclick=()=>{
        modal.classList.remove('visible');
      };

      // Escape key closes the modal from anywhere
      document.addEventListener('keydown', e=>{
        if(e.key === 'Escape' && modal.classList.contains('visible')){
          modal.classList.remove('visible');
        }
      });
    })()

    // ═══════════════════════════════════════════════════
    // PASS / SHOT / TACKLE / INTERCEPTION DETECTION
    // ═══════════════════════════════════════════════════
    let allTackleEvents=[],allInterceptions=[],allTackles=[];

    function closestPlayer(frame,maxDist=40,excludeId){
      const b=frame.ball;let best=null,bd=9999;
      for(const p of frame.players){
        if(excludeId&&p.id==excludeId)continue;
        const d=dist(b.x,b.y,p.x,p.y);if(d<bd){bd=d;best=p;}
      }
      return bd<=maxDist?{player:best,dist:bd}:{player:null,dist:bd};
    }
    function closestPlayerXY(players,x,y,maxDist=60,excludeId){
      let best=null,bd=9999;
      for(const p of players){
        if(excludeId&&p.id==excludeId)continue;
        const d=dist(x,y,p.x,p.y);if(d<bd){bd=d;best=p;}
      }
      return bd<=maxDist?{player:best,dist:bd}:{player:null,dist:bd};
    }

    function detectEvents(){
      allPasses=[];allShots=[];allTackleEvents=[];allInterceptions=[];allTackles=[];
      const GTOP=55,GBOT=945;
      let prevBall=frames[0].ball;
      // lastKickFrame: cooldown de 4 frames entre detecciones del MISMO jugador.
      // Evita que la misma patada genere múltiples registros en frames consecutivos
      // sin bloquear pases reales que ocurren a corta distancia temporal.
      const lastKickByPlayer={}; // playerId → último frame registrado

      for(let i=1;i<frames.length-10;i++){
        const f=frames[i],ball=f.ball;
        const vx=ball.x-prevBall.x,vy=ball.y-prevBall.y;
        const spd=Math.sqrt(vx*vx+vy*vy);

        if(spd>12){
          const {player:kicker}=closestPlayer(frames[i-1],35);
          if(!kicker){prevBall=ball;continue;}

          // Cooldown por jugador: ignorar si ya registramos una acción de este
          // jugador hace menos de 4 frames (misma pelota en vuelo)
          const pid=String(kicker.id);
          if(lastKickByPlayer[pid]!==undefined && (i-lastKickByPlayer[pid])<4){
            prevBall=ball;continue;
          }

          const kickPos={x:prevBall.x,y:prevBall.y},kickZ=ball.z;
          lastKickByPlayer[pid]=i;

          for(let j=i+4;j<Math.min(i+70,frames.length-1);j++){
            const fj=frames[j],bj=fj.ball;
            const vx2=bj.x-frames[j-1].ball.x,vy2=bj.y-frames[j-1].ball.y;
            const spd2=Math.sqrt(vx2*vx2+vy2*vy2);

            if((bj.y<GTOP||bj.y>GBOT)&&spd2>4){
              // Validación Paso 0: el balón debe cruzar la línea del arco que el
              // equipo del pateador ATACA, y venir razonablemente centrado. Esto
              // elimina falsos positivos (despejes, salidas por el arco propio).
              const kickerVis=kicker.team; // '0'/'1' (visiting) del binario
              const atkY=attackGoalY(kickerVis,i);
              const endGoalY=bj.y<GTOP?DY0:DY1;
              if(Math.abs(endGoalY-atkY)<60 && Math.abs(bj.x-DX1/2)<160){
                allShots.push({fromPlayer:kicker,from:kickPos,to:{x:bj.x,y:bj.y},z:kickZ,isHeader:kickZ>20,speed:spd,frame:i});
              }
              break;
            }
            if(spd2<7){
              const {player:recv}=closestPlayer(fj,30);
              if(recv&&recv.id!==kicker.id){
                const d=dist(kickPos.x,kickPos.y,bj.x,bj.y);
                const success=recv.team===kicker.team;
                let nearbyTeammate=null;
                if(!success){
                  const {player:nt}=closestPlayerXY(fj.players,bj.x,bj.y,80,recv.id);
                  if(nt&&nt.team===kicker.team)nearbyTeammate=nt;
                }
                allPasses.push({fromPlayer:kicker,toPlayer:recv,from:kickPos,to:{x:bj.x,y:bj.y},
                  distance:d,z:kickZ,isHeader:kickZ>20,success,frame:i,nearbyTeammate,
                  allPlayersAtFrame:fj.players});
                if(!success){
                  allInterceptions.push({player:recv,pos:{x:bj.x,y:bj.y},from:kickPos,kicker,frame:j,allPlayersAtFrame:fj.players});
                  allTackleEvents.push({player:recv,pos:{x:bj.x,y:bj.y},from:kickPos,kicker,frame:j,allPlayersAtFrame:fj.players});
                }
                break;
              }
              if(!recv)break;
            }
          }
        }
        prevBall=ball;
      }
      detectTackles();
    }

    // ═══════════════════════════════════════════════════
    // DETECTOR DE TACKLES (validado: r=0.84, jackknife estable, 13.7% CV)
    // ═══════════════════════════════════════════════════
    // Un TACKLE es un duelo cuerpo a cuerpo: el balón está lento (controlado), dos
    // rivales muy cerca, y la posesión cambia de equipo. Esta firma física se
    // detecta con fiabilidad (a diferencia de las intercepciones, r=0.18, que son
    // ambiguas en el binario y NO se detectan). Params validados en 14 partidos.
    function detectTackles(){
      allTackles=[];
      if(!frames.length)return;
      const DUEL_DIST=28, TACKLE_BALL_SPD=8, COLLAPSE=10;
      const lastT={'0':-99,'1':-99};
      let ownerTeam=null;
      let prevBall=frames[0].ball;
      for(let i=1;i<frames.length;i++){
        const b=frames[i].ball;
        const bspd=dist(prevBall.x,prevBall.y,b.x,b.y);
        // dueño y segundo más cercano al balón
        let best=null,bd=9999,second=null,sd=9999;
        for(const p of frames[i].players){
          const d=dist(b.x,b.y,p.x,p.y);
          if(d<bd){sd=bd;second=best;bd=d;best=p;}
          else if(d<sd){sd=d;second=p;}
        }
        const cur=(bd<=DUEL_DIST)?best:null;
        const curTeam=cur?cur.team:null;
        if(curTeam&&ownerTeam&&curTeam!==ownerTeam){
          // duelo: el segundo más cercano es del equipo que PERDIÓ la pelota
          const contested=second&&sd<DUEL_DIST&&second.team===ownerTeam;
          if(bspd<TACKLE_BALL_SPD&&contested&&(i-lastT[curTeam])>COLLAPSE){
            allTackles.push({team:curTeam,player:cur,pos:{x:b.x,y:b.y},frame:i});
            lastT[curTeam]=i;
          }
        }
        if(curTeam)ownerTeam=curTeam;
        prevBall=b;
      }
    }
    function showSinglePass(p,passEv,vis){setMapMode('single-pass',p,vis,passEv);}
    function showSingleShot(p,shotEv,vis){setMapMode('single-shot',p,vis,shotEv);}
    function showSingleInterception(p,ev,vis){setMapMode('single-interception',p,vis,ev);}
    function showSingleSave(p,ev){setMapMode('single-save',p,p.visiting,ev);}

    // ═══════════════════════════════════════════════════
    // MAP MODE MANAGEMENT
    // ═══════════════════════════════════════════════════
    let currentMapData=null; // {type, player, vis, data}

    function setMapMode(type,player,vis,data){
      mapMode=type;activePlayer=player;currentMapData={type,player,vis,data};
      let label='';
      if(type==='pass-network'){
        const t=Object.values(teams).find(t=>t.visiting===vis);
        label=`[ RED DE PASES · ${t?.name||''} ]`;
      } else if(type==='territory'){
        const t=Object.values(teams).find(t=>t.visiting===vis);
        label=`[ TERRITORIO · ${t?.name||''} ]`;
      } else if(type==='team-shots'){
        const t=Object.values(teams).find(t=>t.visiting===vis);
        label=`[ TIROS · ${t?.name||''} · estimación ]`;
      } else if(type==='team-tackles'){
        const t=Object.values(teams).find(t=>t.visiting===vis);
        label=`[ ENTRADAS · ${t?.name||''} · estimación ]`;
      } else if(type==='momentum'){
        label=`[ MOMENTUM DEL PARTIDO ]`;
      } else if(type){
        label=`[ ${type.toUpperCase()} · ${player?.name||''} ]`;
      }
      document.getElementById('map-label').textContent=label;
      // Selector de mitad: solo tiene sentido en mapas que promedian/acumulan
      // posición a lo largo de todo el partido (red de pases, mapa de calor,
      // territorio por zonas).
      const showHalfSelector=type==='pass-network'||type==='heatmap'||type==='territory';
      document.getElementById('half-selector').classList.toggle('visible',showHalfSelector);
      if(!showHalfSelector){
        // Resetear a "Todo" al salir de estos modos, para no dejar un filtro
        // aplicado de forma invisible la próxima vez que se entre a otro mapa.
        selectedHalf='all';
        timeFilter={mode:'all',half:null,startMin:0,endMin:5};
        document.querySelectorAll('.half-btn').forEach(b=>b.classList.toggle('active',b.dataset.half==='all'));
        const tw=document.getElementById('time-window');if(tw)tw.style.display='none';
      }
      const showLegend=type==='passes'||type==='shots';
      document.getElementById('legend-box').classList.toggle('visible',showLegend);
      document.getElementById('leg-shot').style.display=(type==='shots')?'flex':'none';
      document.getElementById('leg-hdr').style.display=(type==='shots')?'flex':'none';
      // Centro de la barra superior: con un mapa activo se muestra el label del
      // mapa (+ half-selector si aplica) y se oculta el relato. Sin mapa, al revés.
      const relato=document.getElementById('relato-txt');
      if(relato){
        if(type){relato.classList.add('hidden');_relatoPrevId=null;}
        // si type es null, updateRelato() (llamado por render) lo vuelve a mostrar
      }
      render();
    }
    function clearMap(){
      setMapMode(null,null,null,null);
      document.getElementById('map-label').textContent='';
      document.querySelectorAll('.tp-network-btn').forEach(b=>b.classList.remove('active'));
      document.getElementById('global-momentum-btn').classList.remove('active');
      const gb=document.getElementById('global-goalsmap-btn');if(gb)gb.classList.remove('active');
      const nav=document.getElementById('goalsmap-nav');if(nav)nav.style.display='none';
    }

    // ═══════════════════════════════════════════════════
    // PLAYER MAP (PASSES / SHOTS / INTERCEPTIONS / TACKLES)
    // ═══════════════════════════════════════════════════
    // ── Orientación canónica para análisis estático ──
    //
    // PROBLEMA: en el 2do tiempo los equipos cambian de lado de cancha. Si
    // promediamos/acumulamos coordenadas crudas de todo el partido sin corregir
    // esto, un jugador que se queda fijo en su zona real promedia su posición
    // del 1er tiempo con la del 2do tiempo (que está rotada por el cambio de
    // cancha) y el resultado cae en un punto que nunca ocupó. El arquero, por
    // ejemplo, termina promediado en el centro del campo; un jugador de banda
    // termina con dos manchas de calor en lados opuestos.
    //
    // SOLUCIÓN: rotar 180° (invertir TANTO X como Y) cada punto del 2do tiempo,
    // para expresarlo en la orientación que tenía ese equipo en el 1er tiempo.
    // El cambio de cancha es una rotación completa, no un simple espejo: la
    // cancha mantiene su sistema de coordenadas fijo (X: 0-750, Y: 0-1000), y
    // cuando el equipo da la vuelta, lo que estaba en (x,y) pasa a verse en
    // (750-x, 1000-y). Verificado empíricamente: un jugador de banda derecha
    // (Erauso) está en X≈64 en el 1er tiempo y X≈684 en el 2do; rotar 180°
    // predice X≈686 (error 1 unidad), mientras que invertir solo Y predice
    // X≈64 (error 620). El arquero NO sirve para validar esto porque está en
    // X≈375 (centro), donde 750-x ≈ x y ambas hipótesis coinciden.
    //
    // Esto solo aplica a pantallas ESTÁTICAS de análisis (mapas, redes de pases,
    // calor, croquis). La reproducción en vivo nunca se toca.

    function isSecondHalf(frame){
      if(half2StartFrame!=null) return frame>=half2StartFrame;
      // Fallback sin XML de stats cargado: mitad del binario como aproximación
      return frames.length>0&&frame>frames.length/2;
    }

    // ═══════════════════════════════════════════════════
    // FILTRO TEMPORAL — mitades + ventanas granulares (5 min / cuartos)
    // ═══════════════════════════════════════════════════
    // Estado del selector. mode:
    //   'all'    → todo el partido
    //   'half'   → una mitad ('1' o '2'), compatible con el viejo selectedHalf
    //   'window' → ventana [startMin, endMin) en minutos de reloj de partido
    // Los minutos se convierten a frames con FRAME_SECONDS.
    let timeFilter={mode:'all',half:null,startMin:0,endMin:5};

    function minToFrame(min){return Math.round(min*60/FRAME_SECONDS);}
    function matchDurationMin(){
      return frames.length?Math.ceil(frames.length*FRAME_SECONDS/60):90;
    }
    // Etiqueta legible del filtro temporal actual, para los títulos de los mapas.
    function timeFilterLabel(){
      if(timeFilter.mode==='half')return timeFilter.half==='1'?' (1T)':' (2T)';
      if(timeFilter.mode==='window')return ` (${timeFilter.startMin}–${timeFilter.endMin}')`;
      return '';
    }

    // ¿El frame cae dentro de la ventana temporal seleccionada?
    // Centraliza TODO el filtrado (mitades y ventanas) en un solo lugar.
    function frameInSelectedWindow(frame){
      switch(timeFilter.mode){
        case 'half':
          if(timeFilter.half==='1')return !isSecondHalf(frame);
          if(timeFilter.half==='2')return isSecondHalf(frame);
          return true;
        case 'window':{
          const startF=minToFrame(timeFilter.startMin);
          const endF=minToFrame(timeFilter.endMin);
          return frame>=startF && frame<endF;
        }
        default: return true; // 'all'
      }
    }

    /**
     * normalizeXY — devuelve {x,y} en coordenadas RAW (0-750 / 0-1000, no
     * píxeles de canvas) rotadas 180° si el frame es del 2do tiempo, para
     * dejarlas en la orientación del 1er tiempo de ese equipo. Usar esto ANTES
     * de acumular/promediar cualquier estadística espacial (heatmaps, redes de
     * pases, promedios).
     */
    function normalizeXY(x,y,vis,frame){
      if(isSecondHalf(frame)) return {x:DX1-x,y:DY1-y};
      return {x,y};
    }

    function showPlayerMap(p,type,vis){
      setMapMode(type,p,vis,null);
    }
    function showPassNetwork(vis){
      setMapMode('pass-network',null,vis,null);
    }
    function showTeamShotMap(vis){
      setMapMode('team-shots',null,vis,null);
    }
    function showTeamTackleMap(vis){
      setMapMode('team-tackles',null,vis,null);
    }

    // ═══════════════════════════════════════════════════
    // GOAL CROQUIS (single goal or all goals of player)
    // ═══════════════════════════════════════════════════
    function showGoalCroquis(goalData){setMapMode('goal-single',null,null,goalData);}
    function showGoalsCroquis(p,goals){setMapMode('goals',p,p.visiting,goals);}
    function showAssistsCroquis(p,assists){setMapMode('assists',p,p.visiting,assists);}
    function showSavesCroquis(p,saves){setMapMode('saves',p,p.visiting,saves);}

    // ── Mapa consolidado de goles/atajadas ──
    function showGoalsMap(half,teamFilter){
      _goalsMapState.half=half||'all';
      _goalsMapState.teamFilter=teamFilter??null;
      _goalsMapState.items=buildGoalsMapItems(_goalsMapState.half,_goalsMapState.teamFilter);
      _goalsMapState.idx=0;
      setMapMode('goals-map',null,null,null);
      document.getElementById('map-label').textContent='GOLES Y ATAJADAS';
      // Reset de la posición flotante al centro (por si quedó arrastrado antes).
      const nav=document.getElementById('goalsmap-nav');
      if(nav){nav.style.left='';nav.style.top='';nav.style.transform='translate(-50%,-50%)';}
      updateGoalsMapNav();
    }
    function goalsMapStep(dir){
      const n=_goalsMapState.items.length;if(!n)return;
      _goalsMapState.idx=(_goalsMapState.idx+dir+n)%n;
      updateGoalsMapNav();render();
    }
    function updateGoalsMapNav(){
      const nav=document.getElementById('goalsmap-nav');if(!nav)return;
      const st=_goalsMapState;const it=st.items[st.idx];
      nav.style.display=mapMode==='goals-map'?'flex':'none';
      if(!it){nav.querySelector('.gm-info').textContent=st.items.length?'':'Sin goles ni atajadas';return;}
      const clock=it.clock||frameToClock(it.frame);
      const icon=it.kind==='goal'?'⚽':'🧤';
      nav.querySelector('.gm-info').textContent=`${icon} ${it.name} · ${clock}  (${st.idx+1}/${st.items.length})`;
    }

    // ── orientedXY: coordenadas RAW (binario) -> canvas, en orientación canónica ──
    // Lleva cualquier punto del partido a una orientación de pantalla única, donde
    // cada equipo siempre tiene su arco abajo y ataca hacia arriba, de modo que un
    // jugador que siempre jugó la misma banda aparezca consolidado en esa banda.
    //
    // Dos pasos, AMBOS son rotaciones de 180° (invierten X e Y juntos), porque
    // tanto el cambio de cancha del 2do tiempo como el "voltear la dirección de
    // ataque" son rotaciones, no espejos. Invertir solo Y en cualquiera de los dos
    // pasos deja las bandas izquierda/derecha intercambiadas (el bug del "espejo").
    //   1) normalizeXY(frame): si es 2do tiempo, rota 180° para deshacer el cambio
    //      de cancha y dejar el punto en la orientación del 1er tiempo del equipo.
    //   2) getFlip(vis): si ese equipo atacaba hacia Y=0 en el 1er tiempo, rota
    //      180° para que en el mapa quede atacando hacia arriba con su arco abajo.
    function orientedXY(x,y,vis,refFrame){
      let{x:rx,y:ry}=normalizeXY(x,y,vis,refFrame||0); // paso 1: deshace cambio de cancha del 2do tiempo
      if(getFlip(vis)){ rx=DX1-rx; ry=DY1-ry; }        // paso 2: orientación canónica de ataque (rotación, no espejo)
      return{cx:nx(rx),cy:ny(ry)};
    }

    // ── Single-event croquis ──
    function showSinglePass(p,ev,vis){setMapMode('single-pass',p,vis,ev);}
    function showSingleShot(p,ev,vis){setMapMode('single-shot',p,vis,ev);}
    function showSingleInterception(p,ev,vis){setMapMode('single-interception',p,vis,ev);}
    function showSingleSave(p,ev){setMapMode('single-save',p,p.visiting,ev);}

    function renderSinglePass(p,passEv,vis){
      const PR=Math.max(9,fieldW*.025);
      const{cx:x1,cy:y1}=orientedXY(passEv.from.x,passEv.from.y,vis,passEv.frame);
      const{cx:x2,cy:y2}=orientedXY(passEv.to.x,passEv.to.y,vis,passEv.frame);
      const color=passEv.success?'#1aff8a':'#ff4444';
      const f=frames[Math.min(passEv.frame,frames.length-1)];
      if(f){
        f.players.forEach(fp=>{
          const xp=playerMap[String(fp.id)];
          const isSame=xp?xp.visiting===vis:fp.team===vis;
          const{cx,cy}=orientedXY(fp.x,fp.y,vis,passEv.frame);
          drawCircle(ctx,cx,cy,PR*.7,xp?.priRgb||'888888',xp?.secRgb||'cccccc',String(xp?.shirt||fp.id),false,isSame?.2:.1);
        });
      }
      ctx.strokeStyle=color;ctx.lineWidth=2.5;ctx.globalAlpha=.9;
      ctx.setLineDash(passEv.isHeader?[7,4]:[]);
      ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();
      ctx.setLineDash([]);arrow(ctx,x1,y1,x2,y2,10,color,1);ctx.globalAlpha=1;
      drawCircle(ctx,x1,y1,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      const recv=passEv.toPlayer;
      if(recv){const rxp=playerMap[String(recv.id)];drawCircle(ctx,x2,y2,PR,rxp?.priRgb||'888888',rxp?.secRgb||'cccccc',String(rxp?.shirt||recv.id),!passEv.success,1);}
      if(!passEv.success&&passEv.nearbyTeammate){
        const nt=passEv.nearbyTeammate;const nxp=playerMap[String(nt.id)];
        const{cx,cy}=orientedXY(nt.x,nt.y,vis,passEv.frame);
        drawCircle(ctx,cx,cy,PR*.8,nxp?.priRgb||'888888',nxp?.secRgb||'cccccc',String(nxp?.shirt||nt.id),false,.7);
      }
      const clock=frameToClock(passEv.frame);
      ctx.fillStyle='rgba(250,204,21,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      ctx.fillText(`🔁 ${p.name} — ${passEv.success?'Exitoso':'Interceptado'} (${clock})`,canvas.width/2,fieldY+4);
    }

    function renderSingleShot(p,shotEv,vis){
      const PR=Math.max(9,fieldW*.025);
      const{cx:x1,cy:y1}=orientedXY(shotEv.from.x,shotEv.from.y,vis,shotEv.frame);
      const{cx:x2,cy:y2}=orientedXY(shotEv.to.x,shotEv.to.y,vis,shotEv.frame);
      const color=shotEv.isHeader?'#c084fc':'#ff9500';
      const f=frames[Math.min(shotEv.frame,frames.length-1)];
      if(f){f.players.forEach(fp=>{const xp=playerMap[String(fp.id)];const isSame=xp?xp.visiting===vis:fp.team===vis;const{cx,cy}=orientedXY(fp.x,fp.y,vis,shotEv.frame);drawCircle(ctx,cx,cy,PR*.7,xp?.priRgb||'888888',xp?.secRgb||'cccccc',String(xp?.shirt||fp.id),false,isSame?.2:.1);});}
      ctx.strokeStyle=color;ctx.lineWidth=3;ctx.globalAlpha=.9;
      ctx.setLineDash(shotEv.isHeader?[7,4]:[]);
      ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();
      ctx.setLineDash([]);arrow(ctx,x1,y1,x2,y2,12,color,1);ctx.globalAlpha=1;
      const grd=ctx.createRadialGradient(x2,y2,3,x2,y2,20);grd.addColorStop(0,'rgba(255,149,0,.5)');grd.addColorStop(1,'rgba(255,149,0,0)');
      ctx.beginPath();ctx.arc(x2,y2,20,0,Math.PI*2);ctx.fillStyle=grd;ctx.fill();
      drawCircle(ctx,x1,y1,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      const clock=frameToClock(shotEv.frame);
      ctx.fillStyle='rgba(250,204,21,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      ctx.fillText(`🎯 ${p.name} — ${shotEv.isHeader?'Cabeza':'Pie'} (${clock})`,canvas.width/2,fieldY+4);
    }

    /**
     * renderSingleTackle — croquis táctico de una entrada individual.
     *
     * Muestra:
     *   1. El jugador que hace la entrada (círculo con camiseta, glow)
     *   2. El rival al que se le hace la entrada (círculo rival semitransparente)
     *   3. Una flecha desde el rival hacia nuestro jugador (dirección de la acción)
     *   4. El punto de contacto con un flash naranja
     *   Sin jugadores de fondo — solo los actores de la jugada.
     */
    function renderSingleInterception(p, ev, vis){
      const PR = Math.max(10, fieldW * 0.027);

      // Posición del rival (quien tenía la pelota = ev.kicker)
      // y posición donde ocurrió la entrada (ev.pos = donde cayó la pelota)
      const {cx: rivalX, cy: rivalY} = orientedXY(ev.from.x, ev.from.y, vis, ev.frame);
      const {cx: contactX, cy: contactY} = orientedXY(ev.pos.x, ev.pos.y, vis, ev.frame);

      // Posición de nuestro jugador al momento de la entrada
      // La buscamos en el frame del evento
      const f = frames[Math.min(ev.frame, frames.length-1)];
      let ourPlayerPos = {x: ev.pos.x, y: ev.pos.y}; // fallback = punto de contacto
      if(f){
        const fp = f.players.find(pl => pl.id == p.internalId);
        if(fp) ourPlayerPos = {x: fp.x, y: fp.y};
      }
      const {cx: ourX, cy: ourY} = orientedXY(ourPlayerPos.x, ourPlayerPos.y, vis, ev.frame);

      // ── Flash naranja en el punto de contacto ──
      const grd = ctx.createRadialGradient(contactX, contactY, 2, contactX, contactY, PR * 1.8);
      grd.addColorStop(0, 'rgba(255,149,0,0.55)');
      grd.addColorStop(1, 'rgba(255,149,0,0)');
      ctx.beginPath(); ctx.arc(contactX, contactY, PR * 1.8, 0, Math.PI*2);
      ctx.fillStyle = grd; ctx.fill();

      // ── Trayectoria del pase rival (de dónde venía el balón) ──
      // Línea naranja punteada: origen del pase → punto de contacto
      ctx.save();
      ctx.strokeStyle = '#ff9500';
      ctx.lineWidth = Math.max(1.5, fieldW * 0.003);
      ctx.setLineDash([6, 5]);
      ctx.globalAlpha = 0.55;
      ctx.beginPath();
      ctx.moveTo(rivalX, rivalY);
      ctx.lineTo(contactX, contactY);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.restore();

      // ── Flecha de acción: rival → punto de contacto ──
      arrow(ctx, rivalX, rivalY, contactX, contactY,
        Math.max(8, fieldW * 0.016), '#ff9500', 0.7);

      // ── Círculo del rival (quien pierde la pelota) ──
      if(ev.kicker){
        const kxp = playerMap[String(ev.kicker.id)];
        drawCircle(ctx, rivalX, rivalY,
          PR * 0.85,
          kxp?.priRgb || 'f87171',
          kxp?.secRgb || 'cccccc',
          String(kxp?.shirt || ev.kicker.id),
          false, 0.75);
        // Etiqueta "rival" discreta
        ctx.save();
        ctx.globalAlpha = 0.5;
        ctx.fillStyle = '#ff9500';
        ctx.font = `${Math.max(7, fieldW * 0.012)}px 'Courier New'`;
        ctx.textAlign = 'center';
        ctx.textBaseline = 'bottom';
        ctx.fillText('rival', rivalX, rivalY - PR * 0.85 - 3);
        ctx.restore();
      }

      // ── Círculo de nuestro jugador (quien hace la entrada) — con glow ──
      drawCircle(ctx, ourX, ourY, PR, p.priRgb, p.secRgb, p.shirt, true, 1);

      // ── Línea de acción: nuestro jugador → punto de contacto ──
      ctx.save();
      ctx.strokeStyle = '#1aff8a';
      ctx.lineWidth = Math.max(2, fieldW * 0.004);
      ctx.setLineDash([]);
      ctx.globalAlpha = 0.85;
      ctx.beginPath();
      ctx.moveTo(ourX, ourY);
      ctx.lineTo(contactX, contactY);
      ctx.stroke();
      ctx.restore();
      arrow(ctx, ourX, ourY, contactX, contactY,
        Math.max(9, fieldW * 0.018), '#1aff8a', 0.9);
    }

    /**
     * renderInterceptionsMap — mapa general de todas las entradas/interceptaciones.
     * Cada entrada: círculo del jugador propio + punto de contacto + flecha.
     * El rival que pierde la pelota aparece como círculo pequeño semitransparente.
     */
    function renderInterceptionsMap(p, vis){
      // Usar allTackleEvents si es modo 'tackles', sino allInterceptions
      const mine = allTackleEvents.filter(q => q.player.id == p.internalId);
      const PR = Math.max(8, fieldW * 0.022);
      const PR_sm = Math.max(6, fieldW * 0.016);

      mine.forEach(ev => {
        const {cx: x1, cy: y1} = orientedXY(ev.from.x, ev.from.y, vis, ev.frame);
        const {cx: x2, cy: y2} = orientedXY(ev.pos.x, ev.pos.y, vis, ev.frame);

        // Línea punteada naranja: trayectoria del balón rival
        ctx.save();
        ctx.strokeStyle = '#ff9500';
        ctx.lineWidth = Math.max(1, fieldW * 0.002);
        ctx.setLineDash([4, 4]);
        ctx.globalAlpha = 0.45;
        ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
        ctx.setLineDash([]);
        ctx.restore();

        // Flash en punto de contacto
        ctx.globalAlpha = 0.6;
        ctx.fillStyle = '#ff9500';
        ctx.beginPath();
        ctx.arc(x2, y2, Math.max(4, fieldW * 0.008), 0, Math.PI*2);
        ctx.fill();
        ctx.globalAlpha = 1;

        // Círculo pequeño del rival
        if(ev.kicker){
          const kxp = playerMap[String(ev.kicker.id)];
          drawCircle(ctx, x1, y1, PR_sm,
            kxp?.priRgb || 'f87171',
            kxp?.secRgb || 'cccccc',
            String(kxp?.shirt || ev.kicker.id),
            false, 0.5);
        }
      });

      // Círculo del jugador propio en su posición promedio de entrada
      if(mine.length){
        let sx=0,sy=0;
        mine.forEach(q=>{const{x:nx2,y:ny2}=normalizeXY(q.pos.x,q.pos.y,vis,q.frame);sx+=nx2;sy+=ny2;});
        const ax=sx/mine.length,ay=sy/mine.length;
        const {cx, cy} = orientedXY(ax, ay, vis, 0);
        drawCircle(ctx, cx, cy, PR, p.priRgb, p.secRgb, p.shirt, true, 1);
      }
    }

    function renderSingleSave(p,sv){
      const vis=p.visiting;const PR=Math.max(9,fieldW*.025);
      const f=frames[Math.min(sv.frame,frames.length-1)];if(!f)return;
      const b=f.ball;const{cx,cy}=orientedXY(b.x,b.y,vis,sv.frame);
      f.players.forEach(fp=>{const xp=playerMap[String(fp.id)];const isSame=xp?xp.visiting===vis:fp.team===vis;const{cx:px,cy:py}=orientedXY(fp.x,fp.y,vis,sv.frame);drawCircle(ctx,px,py,PR*.7,xp?.priRgb||'888888',xp?.secRgb||'cccccc',String(xp?.shirt||fp.id),false,isSame?.2:.1);});
      const grd=ctx.createRadialGradient(cx,cy,3,cx,cy,20);grd.addColorStop(0,'rgba(56,189,248,.7)');grd.addColorStop(1,'rgba(56,189,248,0)');
      ctx.beginPath();ctx.arc(cx,cy,20,0,Math.PI*2);ctx.fillStyle=grd;ctx.fill();
      drawCircle(ctx,cx,cy,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      const clock=sv.clock||frameToClock(sv.frame);
      ctx.fillStyle='rgba(56,189,248,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      ctx.fillText(`🧤 ${p.name} — Parada (${clock})`,canvas.width/2,fieldY+4);
    }

    /**
     * analyzeGoalPlay — analiza los frames previos al gol y construye
     * una jugada táctica completa con trayectorias, actores y balón.
     *
     * Retorna un objeto con:
     *   - touches:    Array de toques de balón [{player, frame, pos}]
     *   - actors:     Map(playerId → [{frame, x, y}]) — solo jugadores activos
     *   - ballPath:   Array de {x,y} frame-a-frame del balón (toda la jugada)
     *   - startFrame: primer frame de la jugada
     *
     * Estrategia de filtrado de actores:
     *   1. Se recorren los frames desde (goalFrame - lookback) hasta goalFrame.
     *   2. Se registra cada vez que la pelota cambia de poseedor (closestPlayer).
     *   3. Los jugadores que tocaron el balón son los "actores principales".
     *   4. También se incluye el anotador (scorerId del XML) aunque no aparezca
     *      en los toques (puede haber rematado sin posesión previa).
     *   5. Para cada actor, se guarda su posición en cada frame de la jugada
     *      → esto permite dibujar su trayectoria de desmarque.
     *
     * @param {number} goalFrame  Frame exacto del gol (del XML)
     * @param {string} scorerId   internalId del goleador (del XML, para garantizarlo)
     * @param {number} lookback   Frames a mirar hacia atrás (default 400 ≈ ~4.5 min)
     */
    function analyzeGoalPlay(goalFrame, scorerId, lookback=400){
      const start = Math.max(0, goalFrame - lookback);

      // ── Paso 1: detectar toques de balón (cambios de poseedor) ──
      const touches = [];   // [{player, frame, pos:{x,y}}]
      let lastId = null;

      for(let i = start; i <= goalFrame; i++){
        const f = frames[i]; if(!f) continue;
        const {player: cp} = closestPlayer(f, 40);
        if(cp && String(cp.id) !== lastId){
          touches.push({player: cp, frame: i, pos: {x: f.ball.x, y: f.ball.y}});
          lastId = String(cp.id);
        }
      }

      // Mantener solo los últimos 6 toques (jugada relevante)
      const relevantTouches = touches.slice(-6);

      // ── Paso 2: identificar actores (IDs únicos que tocaron el balón) ──
      const actorIds = new Set(relevantTouches.map(t => String(t.player.id)));

      // Garantizar que el goleador (del XML) esté siempre incluido
      if(scorerId) actorIds.add(String(scorerId));

      // ── Paso 3: construir trayectoria de cada actor frame a frame ──
      // Para cada actor, guardamos su posición en cada frame de la jugada.
      // Esto permite dibujar la "ruta de desmarque" del delantero
      // incluso si recibió un centro y no tenía la pelota antes.
      const actorPaths = new Map(); // actorId → [{x, y, frame}]
      actorIds.forEach(id => actorPaths.set(id, []));

      const seqStart = relevantTouches[0]?.frame ?? start;
      for(let i = seqStart; i <= goalFrame; i++){
        const f = frames[i]; if(!f) continue;
        f.players.forEach(p => {
          if(actorPaths.has(String(p.id))){
            actorPaths.get(String(p.id)).push({x: p.x, y: p.y, frame: i});
          }
        });
      }

      // ── Paso 4: trayectoria del balón (línea sólida naranja) ──
      // Tomamos 1 de cada 3 frames para no saturar el dibujo
      const ballPath = [];
      for(let i = seqStart; i <= goalFrame; i += 3){
        const f = frames[i]; if(!f) continue;
        const b = f.ball;
        // Ignorar frames con overflow de uint16 (pelota fuera del campo)
        if(b.y < 1100) ballPath.push({x: b.x, y: b.y, frame: i});
      }

      return {touches: relevantTouches, actorPaths, ballPath, startFrame: seqStart};
    }

    // ── Función auxiliar: calcula orientación de un jugador ──
    // Compara posición actual con la de N frames atrás para obtener el ángulo.
    function getPlayerAngle(actorPath, currentIdx, lookbackFrames=8){
      if(actorPath.length < 2 || currentIdx === 0) return null;
      const cur = actorPath[currentIdx];
      // Buscar un punto lookbackFrames frames atrás en el array
      let prevIdx = currentIdx;
      for(let k = currentIdx-1; k >= 0; k--){
        if(cur.frame - actorPath[k].frame >= lookbackFrames){ prevIdx = k; break; }
      }
      if(prevIdx === currentIdx) prevIdx = Math.max(0, currentIdx-1);
      const prev = actorPath[prevIdx];
      const dx = cur.x - prev.x, dy = cur.y - prev.y;
      if(Math.sqrt(dx*dx+dy*dy) < 2) return null; // jugador casi estático
      return Math.atan2(dy, dx);
    }

    /**
     * drawDirectionArrow — dibuja una pequeña flecha de orientación
     * dentro del círculo del jugador, indicando su dirección de movimiento.
     *
     * @param {CanvasRenderingContext2D} c
     * @param {number} x, y   Centro del círculo
     * @param {number} r      Radio del círculo
     * @param {number} angle  Ángulo en radianes (null = no dibujar)
     * @param {string} color  Color de la flecha
     */
    function drawDirectionArrow(c, x, y, r, angle, color='rgba(255,255,255,0.85)'){
      if(angle === null) return;
      const len = r * 0.55;
      const ax = x + Math.cos(angle) * len;
      const ay = y + Math.sin(angle) * len;
      const bx = x - Math.cos(angle) * len * 0.4;
      const by = y - Math.sin(angle) * len * 0.4;
      c.strokeStyle = color; c.lineWidth = Math.max(1, r * 0.12);
      c.beginPath(); c.moveTo(bx, by); c.lineTo(ax, ay); c.stroke();
      // Punta de flecha pequeña
      const headLen = r * 0.28;
      const a1 = angle + Math.PI * 0.8;
      const a2 = angle - Math.PI * 0.8;
      c.beginPath();
      c.moveTo(ax, ay);
      c.lineTo(ax + Math.cos(a1)*headLen, ay + Math.sin(a1)*headLen);
      c.moveTo(ax, ay);
      c.lineTo(ax + Math.cos(a2)*headLen, ay + Math.sin(a2)*headLen);
      c.stroke();
    }

    /**
     * renderGoalSingle — croquis táctico completo de una jugada de gol.
     *
     * CAPAS DE DIBUJO (orden de profundidad, fondo → frente):
     *   1. Todos los jugadores en campo como "fantasmas" muy transparentes
     *      (contexto posicional — se ve dónde está cada rival)
     *   2. Trayectoria del balón (línea naranja sólida, gruesa)
     *   3. Rutas de desmarque de actores (líneas punteadas blancas/coloreadas)
     *   4. Flechas de pase entre toques de balón (con color por secuencia)
     *   5. Círculos de actores con número de camiseta y flecha de orientación
     *   6. Flash dorado en el punto del gol
     *   7. Labels (nombre goleador, asistente, minuto)
     *
     * @param {Object} goalData  Objeto de gol del XML {frame, scorerId, clock, assister, ...}
     * @param {string} vis       Team visiting side ('0' o '1')
     */
    function renderGoalSingle(goalData, vis){
      vis = vis || goalData.visiting || '1';
      const goalFrame = goalData.frame;

      // Analizar la jugada — obtiene toques, actores, trayectorias
      const play = analyzeGoalPlay(goalFrame, goalData.scorerId, 400);
      const {touches, actorPaths, ballPath} = play;

      const PR = Math.max(9, fieldW * 0.025); // radio base de círculos

      const lastF = frames[Math.min(goalFrame, frames.length-1)];

      // ── CAPA 2: trayectoria del balón entre toques ──
      // No dibujamos la línea frame-a-frame (genera ruido visual).
      // Las flechas de la CAPA 4 entre toques ya muestran el recorrido del balón
      // de forma limpia. Solo marcamos el punto de inicio con un círculo pequeño.
      if(ballPath.length > 0){
        const first = ballPath[0];
        const {cx: bx0, cy: by0} = orientedXY(first.x, first.y, vis, goalFrame);
        ctx.save();
        ctx.fillStyle = 'rgba(255,136,0,0.5)';
        ctx.beginPath();
        ctx.arc(bx0, by0, Math.max(3, fieldW * 0.006), 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }

      // ── CAPA 3: línea de desmarque de cada actor ──
      // En vez de trazar cada frame (que generaba la mancha fantasmal),
      // dibujamos UNA sola línea desde la posición inicial del actor
      // hasta su posición en el momento que tocó el balón.
      // Esto muestra el movimiento de desmarque de forma limpia.
      const actorColors = ['#60a5fa','#34d399','#f97316','#c084fc','#fb923c','#38bdf8'];
      let colorIdx = 0;
      const actorColorMap = new Map();

      actorPaths.forEach((path, actorId) => {
        if(path.length < 2) return;
        const color = actorColors[colorIdx % actorColors.length];
        actorColorMap.set(actorId, color);
        colorIdx++;

        // Posición inicial del actor al inicio de la jugada
        const startPos = path[0];
        // Posición final (momento de su toque o posición al final de la jugada)
        const endPos = path[path.length - 1];

        // Solo dibujar si el actor se movió significativamente (>20 unidades)
        const moved = Math.sqrt((endPos.x-startPos.x)**2 + (endPos.y-startPos.y)**2);
        if(moved < 20) return;

        const {cx: sx, cy: sy} = orientedXY(startPos.x, startPos.y, vis, goalFrame);
        const {cx: ex, cy: ey} = orientedXY(endPos.x, endPos.y, vis, goalFrame);

        ctx.save();
        ctx.strokeStyle = color;
        ctx.lineWidth = Math.max(1, fieldW * 0.002);
        ctx.setLineDash([4, 5]); // punteado = movimiento de jugador
        ctx.globalAlpha = 0.5;
        ctx.beginPath();
        ctx.moveTo(sx, sy);
        ctx.lineTo(ex, ey);
        ctx.stroke();
        // Punto de origen del desmarque
        ctx.globalAlpha = 0.4;
        ctx.fillStyle = color;
        ctx.beginPath();
        ctx.arc(sx, sy, Math.max(2, fieldW * 0.004), 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      });

      // ── CAPA 4: flechas entre toques de balón (pases de la jugada) ──
      const passColors = ['#a5f3fc','#86efac','#fde68a','#fca5a5','#facc15'];
      touches.forEach((touch, i) => {
        if(i === 0) return;
        const prev = touches[i-1];
        const {cx: x1, cy: y1} = orientedXY(prev.pos.x, prev.pos.y, vis, goalFrame);
        const {cx: x2, cy: y2} = orientedXY(touch.pos.x, touch.pos.y, vis, goalFrame);
        const isLastTouch = (i === touches.length - 1); // remate final

        // El remate final: flecha dorada gruesa
        // Los pases previos: flechas coloreadas más delgadas
        const col = isLastTouch ? '#facc15' : (passColors[i-1] || '#aaa');
        const lw = isLastTouch ? Math.max(2.5, fieldW * 0.006) : Math.max(1.5, fieldW * 0.004);

        ctx.save();
        ctx.strokeStyle = col;
        ctx.lineWidth = lw;
        ctx.setLineDash([]); // línea de pase sólida
        ctx.globalAlpha = isLastTouch ? 1 : 0.8;
        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.stroke();
        // Punta de flecha
        arrow(ctx, x1, y1, x2, y2,
          isLastTouch ? Math.max(10, fieldW * 0.022) : Math.max(7, fieldW * 0.016),
          col, isLastTouch ? 1 : 0.85);
        ctx.restore();
      });

      // ── CAPA 5: círculos de actores con orientación ──
      // Orden: primero rivales actores (detrás), luego propios (encima)
      [false, true].forEach(drawOwn => {
        actorPaths.forEach((path, actorId) => {
          if(!path.length) return;
          const xp = playerMap[actorId];
          const isOwn = xp ? xp.visiting === vis : false;
          if(isOwn !== drawOwn) return;

          // Posición final del actor en la jugada (último frame registrado)
          const lastPos = path[path.length - 1];
          const {cx, cy} = orientedXY(lastPos.x, lastPos.y, vis, goalFrame);

          // ¿Es el goleador?
          const isScorer = String(actorId) === String(goalData.scorerId);
          const touchIdx = touches.findIndex(t => String(t.player.id) === actorId);

          // Color del círculo: goleador → dorado, resto → color de equipo
          const pri = isScorer ? 'facc15' : (xp?.priRgb || '888888');
          const sec = xp?.secRgb || 'cccccc';
          const shirt = xp?.shirt || actorId;

          // Dibujar círculo (más grande para goleador)
          const r = isScorer ? PR * 1.1 : PR;
          drawCircle(ctx, cx, cy, r, pri, sec, String(shirt), isScorer, 1);

          // Flecha de orientación
          const angle = getPlayerAngle(path, path.length - 1, 10);
          const arrowColor = isOwn ? `rgba(255,255,255,0.9)` : `rgba(255,180,180,0.9)`;
          drawDirectionArrow(ctx, cx, cy, r, angle, arrowColor);
        });
      });

      // ── CAPA 6: flash dorado en punto del gol ──
      const gb = lastF?.ball;
      if(gb){
        const {cx: gx, cy: gy} = orientedXY(gb.x, gb.y, vis, goalFrame);
        const grd = ctx.createRadialGradient(gx, gy, 2, gx, gy, 22);
        grd.addColorStop(0, 'rgba(250,204,21,0.85)');
        grd.addColorStop(1, 'rgba(250,204,21,0)');
        ctx.beginPath(); ctx.arc(gx, gy, 22, 0, Math.PI*2);
        ctx.fillStyle = grd; ctx.fill();
        // Cruz de impacto
        ctx.save();
        ctx.strokeStyle = '#facc15'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7;
        const cs = 8;
        ctx.beginPath();
        ctx.moveTo(gx-cs, gy); ctx.lineTo(gx+cs, gy);
        ctx.moveTo(gx, gy-cs); ctx.lineTo(gx, gy+cs);
        ctx.stroke();
        ctx.restore();
      }

      // ── CAPA 7: sin labels de texto en el campo ──
      // El título del croquis se muestra en el panel lateral, no en el canvas
    }

    // Keep analyzeGoalSequence as alias for backward compat with other callers
    function analyzeGoalSequence(goalFrame, lookback=400){
      const play = analyzeGoalPlay(goalFrame, null, lookback);
      return play.touches;
    }

    function renderMultiGoals(p,goals,vis){goals.forEach(g=>renderGoalSingle(g,vis));}
    function renderAssistsCroquis(p,assists,vis){assists.forEach(g=>renderGoalSingle(g,vis));}
    function renderSavesCroquis(p,saves){saves.forEach(sv=>renderSingleSave(p,sv));}

    // ═══════════════════════════════════════════════════
    // MAPA CONSOLIDADO DE GOLES Y ATAJADAS (estilo FotMob, datos 100% del XML)
    // ═══════════════════════════════════════════════════
    // Goles y atajadas vienen del XML con frame exacto; la posición se lee del
    // balón en ese frame (dato directo del binario, no heurística). Muestra todos
    // los eventos del partido a la vez; el navegador resalta uno. NO es un "mapa
    // de remates" (que requeriría detección sobre el binario, aún no confiable);
    // es un mapa de goles+atajadas reales.
    let _goalsMapState={items:[],idx:0,half:'all',teamFilter:null};

    function buildGoalsMapItems(half,teamFilter){
      const items=[];
      allGoals.forEach((g,i)=>{
        if(half==='1'&&isSecondHalf(g.frame))return;
        if(half==='2'&&!isSecondHalf(g.frame))return;
        if(teamFilter!=null&&g.visiting!==teamFilter)return;
        items.push({kind:'goal',frame:g.frame,vis:g.visiting,name:g.scorerName,shirt:g.scorerShirt,clock:g.clock,ref:g,gi:i});
      });
      allSaves.forEach(s=>{
        if(half==='1'&&isSecondHalf(s.frame))return;
        if(half==='2'&&!isSecondHalf(s.frame))return;
        // la atajada es del arquero que defiende; su "vis" es el equipo del arquero
        if(teamFilter!=null&&s.visiting!==teamFilter)return;
        items.push({kind:'save',frame:s.frame,vis:s.visiting,name:s.keeper,clock:s.clock,ref:s});
      });
      items.sort((a,b)=>a.frame-b.frame);
      return items;
    }

    function ballPosAtFrame(frame,vis){
      const f=frames[Math.min(frame,frames.length-1)];
      if(!f)return null;
      return orientedXY(f.ball.x,f.ball.y,vis,frame);
    }

    function renderGoalsMap(){
      const st=_goalsMapState;
      const PR=Math.max(8,fieldW*.022);
      // Capa 1: todos los eventos como puntos tenues
      st.items.forEach((it,i)=>{
        const pos=ballPosAtFrame(it.frame,it.vis);if(!pos)return;
        const sel=i===st.idx;
        const t=Object.values(teams).find(tt=>tt.visiting===it.vis)||{};
        const col=t.priRgb||(it.vis==='0'?'38bdf8':'f87171');
        ctx.save();
        if(it.kind==='goal'){
          // gol: círculo relleno del color del equipo
          ctx.globalAlpha=sel?1:.45;
          drawCircle(ctx,pos.cx,pos.cy,sel?PR*1.15:PR,col,t.secRgb||'fff',it.shirt||'',sel,1);
        }else{
          // atajada: anillo (guante)
          ctx.globalAlpha=sel?1:.4;
          ctx.beginPath();ctx.arc(pos.cx,pos.cy,sel?PR:PR*.85,0,Math.PI*2);
          ctx.lineWidth=sel?3.5:2.2;ctx.strokeStyle='#38bdf8';ctx.stroke();
        }
        if(sel){
          // halo del seleccionado
          const grd=ctx.createRadialGradient(pos.cx,pos.cy,3,pos.cx,pos.cy,26);
          const hc=it.kind==='goal'?'255,205,50':'56,189,248';
          grd.addColorStop(0,`rgba(${hc},.55)`);grd.addColorStop(1,`rgba(${hc},0)`);
          ctx.beginPath();ctx.arc(pos.cx,pos.cy,26,0,Math.PI*2);ctx.fillStyle=grd;ctx.fill();
        }
        ctx.restore();
      });
      // Etiqueta del evento seleccionado arriba
      const it=st.items[st.idx];
      if(it){
        const clock=it.clock||frameToClock(it.frame);
        const txt=it.kind==='goal'?`⚽ ${it.name} — Gol (${clock})`:`🧤 ${it.name} — Parada (${clock})`;
        ctx.fillStyle=it.kind==='goal'?'rgba(255,205,50,.95)':'rgba(56,189,248,.95)';
        ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
        ctx.textAlign='center';ctx.textBaseline='top';
        ctx.fillText(txt,canvas.width/2,fieldY+4);
      }
    }
    // ═══════════════════════════════════════════════════
    /**
     * drawField — campo exacto según MZ Analyzer (ratio H/W = 1.331)
     *
     * Proporciones medidas pixel a pixel sobre screenshot real del MZ Analyzer
     * (campo de 480×639px, mismo frame del saque inicial usado para calibrar):
     *   Portería:        ancho= 9.8% fw,  profundidad=2.3% fh
     *   Área grande:     ancho=53.8% fw,  alto=16.4% fh
     *   Punto penal:     11.4% fh desde línea de gol
     *   Semicírculo:     radio=12.1% fw — SOLO la parte fuera del área
     *   Círculo central: radio=12.3% fw
     *   Arcos de esquina: radio=2.2% fw (sin referencia medida, se mantiene)
     *
     * Área chica: el MZ Analyzer visual no la dibuja, pero SÍ existe en las
     * coordenadas reales del juego. Medida a partir de 4 posiciones de pelota
     * colocada exactamente en los vértices del área chica (data1601550710.gz):
     *   frame 5191 → (455,948) vértice derecho, área inferior
     *   frame 3130 → (293,946) vértice izquierdo, área inferior
     *   frame 7142 → (453, 51) vértice derecho, área superior
     *   frame 4041 → (291, 53) vértice izquierdo, área superior
     *   → ancho = 162 unidades raw (455-293, 453-291) → 162/750 = 21.6% fw
     *   → profundidad ≈ 52.5 unidades raw (1000-948, 1000-946, 51, 53) → 52.5/1000 = 5.25% fh
     */
    function drawField(c, fx, fy, fw, fh, linesOnly){
      // ── Césped con textura de estadio ──
      // Franjas horizontales alternando dos verdes (corte de máquina cortadora).
      // Cada franja tiene un gradiente sutil que simula el brillo del pasto inclinado.
      if(!linesOnly){
        const STRIPES=10; // número de franjas (par = simétrico)
        const sh=fh/STRIPES;
        for(let i=0;i<STRIPES;i++){
          const sy=fy+i*sh;
          // Dos verdes base intercalados
          const [dark,mid,light]=i%2===0
            ?['#155a28','#1a7a34','#1d8a3c']  // franja A (más oscura)
            :['#13501f','#186c2c','#1a7830']; // franja B (más clara)
          // Gradiente horizontal dentro de cada franja para simular el brillo
          const g=c.createLinearGradient(fx,sy,fx,sy+sh);
          g.addColorStop(0,dark);
          g.addColorStop(0.45,light);
          g.addColorStop(0.55,light);
          g.addColorStop(1,dark);
          c.fillStyle=g;
          c.fillRect(fx,sy,fw,sh);
        }
        // Vignette sutil (oscurece los bordes del campo para dar profundidad)
        const vg=c.createRadialGradient(fx+fw/2,fy+fh/2,fw*.2,fx+fw/2,fy+fh/2,fw*.82);
        vg.addColorStop(0,'rgba(0,0,0,0)');
        vg.addColorStop(1,'rgba(0,0,0,0.22)');
        c.fillStyle=vg;
        c.fillRect(fx,fy,fw,fh);
      }

      const lw = Math.max(1, fw * 0.003);
      c.strokeStyle = 'rgba(255,255,255,0.85)';
      c.lineWidth = lw;

      // ── Borde del campo ──
      c.strokeRect(fx, fy, fw, fh);

      // ── Línea del medio ──
      c.beginPath();
      c.moveTo(fx, fy + fh/2);
      c.lineTo(fx + fw, fy + fh/2);
      c.stroke();

      // ── Círculo central (radio = 12.3% fw) ──
      const cr = fw * 0.123;
      c.beginPath();
      c.arc(fx + fw/2, fy + fh/2, cr, 0, Math.PI*2);
      c.stroke();
      // Punto central
      c.beginPath();
      c.arc(fx + fw/2, fy + fh/2, fw * 0.007, 0, Math.PI*2);
      c.fillStyle = 'white'; c.fill();

      // ── Áreas (dibujadas top y bottom) ──
      const paW = fw * 0.538, paH = fh * 0.164;   // área grande
      const sbW = fw * 0.216, sbH = fh * 0.0525;  // área chica (medida con coords reales de pelota)
      c.strokeStyle = 'rgba(255,255,255,0.85)';
      // Top
      c.strokeRect(fx + (fw-paW)/2, fy,        paW, paH);
      c.strokeRect(fx + (fw-sbW)/2, fy,        sbW, sbH);
      // Bottom
      c.strokeRect(fx + (fw-paW)/2, fy+fh-paH, paW, paH);
      c.strokeRect(fx + (fw-sbW)/2, fy+fh-sbH, sbW, sbH);

      // ── Porterías (ancho=9.8% fw, profundidad=2.3% fh) ──
      const gW = fw * 0.098, gH = fh * 0.023;
      c.fillStyle = 'rgba(255,255,255,0.13)';
      c.strokeStyle = 'rgba(255,255,255,0.55)';
      // Top
      c.fillRect(fx + (fw-gW)/2, fy - gH, gW, gH);
      c.strokeRect(fx + (fw-gW)/2, fy - gH, gW, gH);
      // Bottom
      c.fillRect(fx + (fw-gW)/2, fy + fh, gW, gH);
      c.strokeRect(fx + (fw-gW)/2, fy + fh, gW, gH);

      // ── Puntos de penalti (11.4% fh desde línea de gol) ──
      const penY = fh * 0.114;
      c.fillStyle = 'white';
      c.strokeStyle = 'rgba(255,255,255,0.85)';
      [[fw/2, penY], [fw/2, fh - penY]].forEach(([px, py]) => {
        c.beginPath();
        c.arc(fx+px, fy+py, fw * 0.006, 0, Math.PI*2);
        c.fill();
      });

      // ── Semicírculo del área (el "D") ──
      // El arco tiene centro en el punto penal y radio = 12.1% fw.
      // Solo se dibuja la parte que está FUERA del área (hacia el centro del campo).
      //
      // Geometría (canvas: ángulo 0=derecha, π/2=abajo, clockwise):
      //   Cruce con borde del área: sin(θ) = (paH - penY) / arcR
      //   Top: arco visible entre halfAngle y π-halfAngle (pasa por π/2 = abajo)
      //   Bottom: arco visible entre π+halfAngle y -halfAngle  (pasa por 3π/2 = arriba)
      const arcR    = fw * 0.121;
      const sinA    = Math.min(1, (paH - penY) / arcR);
      const halfAng = Math.asin(sinA);

      c.strokeStyle = 'rgba(255,255,255,0.75)';
      c.lineWidth   = lw;

      // Top: arco que sobresale hacia abajo (fuera del área, hacia el centro)
      c.beginPath();
      c.arc(fx + fw/2, fy + penY,        arcR, halfAng,              Math.PI - halfAng);
      c.stroke();

      // Bottom: arco que sobresale hacia arriba (fuera del área inferior, hacia el centro)
      c.beginPath();
      c.arc(fx + fw/2, fy + fh - penY,   arcR, Math.PI + halfAng,    -halfAng);
      c.stroke();

      // ── Arcos de esquina (radio = 2.2% fw) ──
      const ca = fw * 0.022;
      c.strokeStyle = 'rgba(255,255,255,0.7)';
      [[0,0,0,Math.PI/2],
       [1,0,Math.PI/2,Math.PI],
       [1,1,Math.PI,Math.PI*1.5],
       [0,1,Math.PI*1.5,Math.PI*2]].forEach(([xi,yi,a1,a2]) => {
        c.beginPath();
        c.arc(fx + xi*fw, fy + yi*fh, ca, a1, a2);
        c.stroke();
      });
    }

    // ═══════════════════════════════════════════════════
    // PLAYER CIRCLE (reusable)
    // ═══════════════════════════════════════════════════
    function drawCircle(c,x,y,r,pri,sec,label,glow=false,alpha=1){
      const prevAlpha=c.globalAlpha;c.globalAlpha=alpha;
      if(glow){
        const g=c.createRadialGradient(x,y,r*.5,x,y,r+10);
        g.addColorStop(0,'rgba(250,204,21,.5)');g.addColorStop(1,'rgba(250,204,21,0)');
        c.beginPath();c.arc(x,y,r+8,0,Math.PI*2);c.fillStyle=g;c.fill();
      }
      c.beginPath();c.arc(x+1.5,y+2,r,0,Math.PI*2);c.fillStyle='rgba(0,0,0,.4)';c.fill();
      c.beginPath();c.arc(x,y,r,0,Math.PI*2);c.fillStyle=hex(pri);c.fill();
      c.lineWidth=Math.max(1.5,r*.18);c.strokeStyle=hex(sec);c.stroke();
      /* Canvas text scaled ×1.25 (was r*0.72, now r*0.9) */
      const fs=Math.max(8,r*.9);
      c.fillStyle=txtClr(pri);c.font=`bold ${fs}px 'Courier New'`;
      c.textAlign='center';c.textBaseline='middle';c.fillText(label,x,y);
      c.globalAlpha=prevAlpha;
    }

    // ═══════════════════════════════════════════════════
    // ARROW HEAD
    // ═══════════════════════════════════════════════════
    function arrow(c,x1,y1,x2,y2,size,color,alpha){
      const a=Math.atan2(y2-y1,x2-x1);
      c.globalAlpha=alpha;c.fillStyle=color;c.beginPath();
      c.moveTo(x2,y2);
      c.lineTo(x2-size*Math.cos(a-Math.PI/7),y2-size*Math.sin(a-Math.PI/7));
      c.lineTo(x2-size*Math.cos(a+Math.PI/7),y2-size*Math.sin(a+Math.PI/7));
      c.closePath();c.fill();
    }

    // ═══════════════════════════════════════════════════
    // COORD TRANSFORM FOR MAPS (with flip)
    // ═══════════════════════════════════════════════════
    // For maps: "attack up" means team's own goal is at BOTTOM of screen.
    // flip=true means raw Y needs to be inverted (team attacks toward Y=0, 
    // so their own goal is at Y=1000; we flip to put it at bottom)
    function getFlip(vis){
      // attackUp[vis]=true means team attacks toward Y=0 (they start bottom half)
      // For map: own goal at bottom = high Y values at bottom
      // If attackUp=true: team's own goal is at Y=1000 side -> no flip needed (Y=1000=bottom in canvas)
      // If attackUp=false: team's own goal is at Y=0 side -> flip needed
      return !teamAttackUp[vis];
    }
    function mNX(x){return nx(x);}
    function mNY(y,flip){return flip?ny(DY1-y):ny(y);}

    // ═══════════════════════════════════════════════════
    // RENDER PASS MAP
    // ═══════════════════════════════════════════════════
    function renderPassMap(p,vis){
      const myPasses=allPasses.filter(q=>q.fromPlayer.id==p.internalId);
      const PR=Math.max(8,fieldW*.022);
      myPasses.forEach(pass=>{
        const{cx:x1,cy:y1}=orientedXY(pass.from.x,pass.from.y,vis,pass.frame);
        const{cx:x2,cy:y2}=orientedXY(pass.to.x,pass.to.y,vis,pass.frame);
        const color=pass.success?'#1aff8a':'#ff4444';
        const lw=pass.distance>150?Math.max(2,fieldW*.005):Math.max(.8,fieldW*.0015);
        ctx.globalAlpha=.7;ctx.strokeStyle=color;ctx.lineWidth=lw;
        ctx.setLineDash(pass.isHeader?[6,4]:[]);
        ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();ctx.setLineDash([]);
        arrow(ctx,x1,y1,x2,y2,Math.max(5,fieldW*.011),color,.8);
        const recv=pass.toPlayer;
        if(recv){const rxp=playerMap[String(recv.id)];drawCircle(ctx,x2,y2,PR*.75,rxp?.priRgb||'888888',rxp?.secRgb||'cccccc',String(rxp?.shirt||recv.id),false,pass.success?.6:.55);}
        if(!pass.success&&pass.nearbyTeammate){const nt=pass.nearbyTeammate;const nxp=playerMap[String(nt.id)];const{cx:ncx,cy:ncy}=orientedXY(nt.x,nt.y,vis,pass.frame);drawCircle(ctx,ncx,ncy,PR*.65,nxp?.priRgb||'888888',nxp?.secRgb||'cccccc',String(nxp?.shirt||nt.id),false,.35);}
        ctx.globalAlpha=1;
      });
      if(myPasses.length){
        // Normalizar cada punto a orientación canónica ANTES de promediar
        // (si no, pases hechos en distintas mitades del partido desde zonas
        // simétricas se cancelan entre sí, igual que el caso del arquero).
        let sx=0,sy=0;
        myPasses.forEach(q=>{const{x:nx2,y:ny2}=normalizeXY(q.from.x,q.from.y,vis,q.frame);sx+=nx2;sy+=ny2;});
        const ax=sx/myPasses.length,ay=sy/myPasses.length;
        const{cx,cy}=orientedXY(ax,ay,vis,0); // ya canónico -> refFrame=0 (no re-rotar)
        drawCircle(ctx,cx,cy,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      }
      ctx.globalAlpha=1;
    }

    // ═══════════════════════════════════════════════════
    // PASS NETWORK (equipo completo) — estilo Sofascore/FotMob
    // ═══════════════════════════════════════════════════

    /**
     * avgPositionByPlayer — posición promedio en cancha de cada jugador del
     * equipo (vis), calculada a partir de los frames del binario en los que
     * ese jugador estuvo en cancha. Más preciso que el position/origin fijo
     * del XML porque refleja el promedio real durante el partido.
     *
     * Respeta `selectedHalf` ('all'|'1'|'2'): si el jugador cambió de banda
     * entre mitades (algo que ocurre en la práctica por decisiones tácticas),
     * promediar todo el partido junto hace que su posición promedio caiga en
     * un punto intermedio que nunca ocupó realmente. Filtrar por mitad evita
     * ese efecto y muestra la posición real de cada mitad por separado.
     */
    function avgPositionByPlayer(vis){
      const sums={}; // internalId -> {sx,sy,n}
      frames.forEach((f,fi)=>{
        if(!frameInSelectedWindow(fi))return;
        f.players.forEach(fp=>{
          const xp=playerMap[String(fp.id)];
          const pvis=xp?xp.visiting:fp.team;
          if(pvis!==vis)return;
          if(fp.y<10||fp.y>990)return; // descarta posiciones de overflow/arranque atípico
          // Normalizar ANTES de sumar: si no se hace esto, un jugador que se
          // queda fijo cerca de su propio arco (ej. el arquero) promedia
          // "arco de abajo en el 1er tiempo" con "arco de arriba en el 2do
          // tiempo" (porque el equipo cambió de lado) y el resultado cae en
          // el centro del campo — un punto que el jugador nunca ocupó.
          const{x:nx2,y:ny2}=normalizeXY(fp.x,fp.y,pvis,fi);
          const key=String(fp.id);
          if(!sums[key])sums[key]={sx:0,sy:0,n:0};
          sums[key].sx+=nx2;sums[key].sy+=ny2;sums[key].n++;
        });
      });
      const out={};
      Object.entries(sums).forEach(([iid,s])=>{ if(s.n>0) out[iid]={x:s.sx/s.n,y:s.sy/s.n,n:s.n}; });
      return out;
    }

    /**
     * buildPassNetwork — agrupa allPasses EXITOSOS del equipo (vis) en pares
     * ordenados (de->a) con su conteo, para dibujar líneas de grosor variable.
     * Respeta `selectedHalf` igual que avgPositionByPlayer.
     */
    function buildPassNetwork(vis){
      const pairs={}; // "iidA-iidB" -> count (A->B siempre en ese orden)
      allPasses.forEach(pass=>{
        if(!pass.success)return;
        if(!frameInSelectedWindow(pass.frame))return;
        const fromXp=playerMap[String(pass.fromPlayer.id)];
        const fromVis=fromXp?fromXp.visiting:pass.fromPlayer.team;
        if(fromVis!==vis)return;
        const toId=pass.toPlayer?.id;
        if(toId==null)return;
        const key=`${pass.fromPlayer.id}-${toId}`;
        pairs[key]=(pairs[key]||0)+1;
      });
      return pairs;
    }

    /**
     * renderPassNetwork — dibuja la red de pases de un equipo completo:
     *   - nodo por jugador en su posición promedio real (tamaño = nº de pases totales)
     *   - línea entre cada par con pases mutuos (grosor = cantidad de pases)
     * vis: '0'|'1' lado del equipo a mostrar.
     */
    function renderPassNetwork(vis){
      const avgPos=avgPositionByPlayer(vis);
      const pairs=buildPassNetwork(vis);

      // avgPos ya viene en coordenadas NORMALIZADAS (canónicas) desde normalizeXY.
      // Usar orientedXY de nuevo sería una doble transformación que mueve los nodos
      // fuera del campo (bug observado con jugadores de X≈742). Solo aplicar el
      // flip de orientación una vez y convertir a canvas.
      function canonToCanvas(x,y){
        let rx=x,ry=y;
        if(getFlip(vis)){rx=DX1-rx;ry=DY1-ry;}
        return{cx:nx(rx),cy:ny(ry)};
      }

      // Combinar A->B y B->A en una sola línea (no direccional, como Sofascore/FotMob)
      const combined={}; // "iidMin-iidMax" -> {count, a, b}
      Object.entries(pairs).forEach(([key,count])=>{
        const [a,b]=key.split('-');
        const ck=a<b?`${a}-${b}`:`${b}-${a}`;
        if(!combined[ck])combined[ck]={count:0,a:(a<b?a:b),b:(a<b?b:a)};
        combined[ck].count+=count;
      });

      const counts=Object.values(combined).map(c=>c.count);
      const maxCount=counts.length?Math.max(...counts):1;

      // ── Líneas (debajo de los nodos) ──
      Object.values(combined).forEach(({count,a,b})=>{
        const pa=avgPos[a],pb=avgPos[b];
        if(!pa||!pb)return;
        const {cx:x1,cy:y1}=canonToCanvas(pa.x,pa.y);
        const {cx:x2,cy:y2}=canonToCanvas(pb.x,pb.y);
        const lw=Math.max(1,Math.min(8,(count/maxCount)*8));
        ctx.save();
        ctx.strokeStyle='#1aff8a';
        ctx.globalAlpha=Math.max(.25,Math.min(.85,count/maxCount));
        ctx.lineWidth=lw;
        ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();
        ctx.restore();
      });

      // ── Nodos (jugadores) ──
      const totalPassesByPlayer={};
      Object.values(combined).forEach(({count,a,b})=>{
        totalPassesByPlayer[a]=(totalPassesByPlayer[a]||0)+count;
        totalPassesByPlayer[b]=(totalPassesByPlayer[b]||0)+count;
      });
      const maxTotal=Math.max(1,...Object.values(totalPassesByPlayer));

      Object.entries(avgPos).forEach(([iid,pos])=>{
        const xp=playerMap[iid];
        if(!xp)return;
        const{cx,cy}=canonToCanvas(pos.x,pos.y);
        const n=totalPassesByPlayer[iid]||0;
        const PR=Math.max(9,fieldW*.018)+(n/maxTotal)*fieldW*.012;
        drawCircle(ctx,cx,cy,PR,xp.priRgb,xp.secRgb,xp.shirt,false,1);
      });

      // ── Título ──
      const t=Object.values(teams).find(t=>t.visiting===vis);
      ctx.fillStyle='rgba(250,204,21,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      const halfLabel=timeFilterLabel();
      ctx.fillText(`🔗 Red de pases — ${t?.name||''}${halfLabel}`,canvas.width/2,fieldY+4);
    }

    // ═══════════════════════════════════════════════════
    // POSESIÓN POR FRAME (fuente común para Territorio y Momentum)
    // ═══════════════════════════════════════════════════
    // HALLAZGO IMPORTANTE (validado con un partido 0-12): el método naive de
    // "jugador más cercano a la pelota en cada frame" está sesgado a favor del
    // equipo que pierde por mucho. Causa: tras cada gol, la pelota queda en el
    // fondo del arco y el equipo que lo recibió la saca jugando desde atrás —
    // eso genera una racha LARGA y sostenida de "posesión" que en realidad es
    // solo la consecuencia inmediata de haber recibido el gol, no juego
    // construido. Con 12 goles en un partido, esas rachas se acumulan tanto
    // que llegan a INVERTIR el resultado (daban 58%/41% cuando el real era
    // 33%/67%).
    //
    // CORRECCIÓN: se agrupan los frames en "rachas" (mismo equipo siendo el
    // más cercano de forma consecutiva) y se descarta toda racha que ARRANCA
    // con la pelota a menos de 120 unidades de cualquier línea de gol (zona
    // de saque de arco / rebote post-gol). Validado contra dos partidos reales:
    //   - Partido 0-12: 58.5% crudo -> 33.5% corregido (real: 33%)
    //   - Partido 3-3:  48.2% crudo -> 46.9% corregido (real: 48%, sin romper
    //     el caso donde el método crudo ya funcionaba bien)
    const GOAL_LINE_EXCLUSION=120;

    function computePossessionStream(){
      const stream=[]; // [{startFrame, endFrame, vis}] solo rachas válidas
      let curVis=null,curLen=0,curStart=0;
      function flush(endFrame){
        if(curVis===null||curLen<=0)return;
        const startBallY=frames[curStart]?.ball?.y;
        const nearGoalLine=startBallY!=null&&(startBallY<GOAL_LINE_EXCLUSION||startBallY>(DY1-GOAL_LINE_EXCLUSION));
        if(!nearGoalLine) stream.push({startFrame:curStart,endFrame,vis:curVis});
      }
      frames.forEach((f,fi)=>{
        const{player:owner}=closestPlayer(f);
        const xp=owner?playerMap[String(owner.id)]:null;
        const vis=owner?(xp?xp.visiting:owner.team):null;
        if(vis===curVis){curLen++;}
        else{flush(fi-1);curVis=vis;curLen=vis?1:0;curStart=fi;}
      });
      flush(frames.length-1);
      return stream;
    }

    // ═══════════════════════════════════════════════════
    // TERRITORIO POR ZONAS (dominancia de posesión por tercio/banda)
    // ═══════════════════════════════════════════════════
    // Mide en qué zona de la cancha pasa el tiempo la POSESIÓN de cada equipo
    // (no la posición de los jugadores) — es la métrica real de "territorio"
    // que usan FotMob/Sofascore ("field tilt"). Usa computePossessionStream()
    // (ver arriba) para excluir las rachas de saque de arco tras gol que
    // sesgaban el cálculo.
    //
    // 9 zonas = 3 tercios (ofensivo/medio/defensivo, según hacia dónde ataca
    // el equipo en orientación canónica) × 3 bandas (izquierda/centro/derecha).

    function buildTerritoryZones(vis){
      const zones={
        att_izq:0,att_cen:0,att_der:0,
        mid_izq:0,mid_cen:0,mid_der:0,
        def_izq:0,def_cen:0,def_der:0
      };
      let total=0;
      const stream=computePossessionStream();
      stream.forEach(({startFrame,endFrame,vis:ownerVis})=>{
        if(ownerVis!==vis)return;
        for(let fi=startFrame;fi<=endFrame;fi++){
          if(!frameInSelectedWindow(fi))continue;
          const f=frames[fi];
          if(!f)continue;
          // Normalizar la posición de la PELOTA a la orientación canónica del
          // equipo que la tiene (mismo procedimiento que orientedXY, aplicado a
          // coordenadas raw en vez de píxeles de canvas).
          let{x:nx2,y:ny2}=normalizeXY(f.ball.x,f.ball.y,vis,fi);
          if(getFlip(vis)){nx2=DX1-nx2;ny2=DY1-ny2;}
          const third=ny2<DY1/3?'att':ny2<DY1*2/3?'mid':'def';
          const side=nx2<DX1/3?'izq':nx2<DX1*2/3?'cen':'der';
          zones[third+'_'+side]++;
          total++;
        }
      });
      const pct={};
      Object.entries(zones).forEach(([k,v])=>{pct[k]=total?v/total*100:0;});
      return {zones,pct,total};
    }

    function showTerritory(vis){
      setMapMode('territory',null,vis,null);
    }

    /**
     * renderTerritory — pinta 6 franjas sobre el campo (intensidad = % de
     * tiempo de posesión en esa zona) y muestra los porcentajes encima.
     */
    function renderTerritory(vis){
      const{pct}=buildTerritoryZones(vis);
      const thirdH=fieldH/3, thirdW=fieldW/3;
      const cells=[
        ['att_izq',0,0],['att_cen',thirdW,0],['att_der',thirdW*2,0],
        ['mid_izq',0,thirdH],['mid_cen',thirdW,thirdH],['mid_der',thirdW*2,thirdH],
        ['def_izq',0,thirdH*2],['def_cen',thirdW,thirdH*2],['def_der',thirdW*2,thirdH*2]
      ];
      const maxPct=Math.max(1,...Object.values(pct));
      cells.forEach(([key,cx,cyOff])=>{
        const v=pct[key]||0;
        const t=v/maxPct; // intensidad relativa dentro de este equipo
        const[r,g,b]=heatColor(Math.min(1,t));
        ctx.fillStyle=`rgb(${r},${g},${b})`;
        ctx.globalAlpha=Math.min(.7,.12+t*.55);
        ctx.fillRect(fieldX+cx,fieldY+cyOff,thirdW,thirdH);
        ctx.globalAlpha=1;
      });

      drawField(ctx,fieldX,fieldY,fieldW,fieldH,true);

      // Porcentajes encima de cada celda
      ctx.textAlign='center';ctx.textBaseline='middle';
      ctx.font=`bold ${Math.max(11,fieldW*.034)}px 'Courier New'`;
      cells.forEach(([key,cx,cyOff])=>{
        const v=pct[key]||0;
        ctx.fillStyle='rgba(255,255,255,.92)';
        ctx.fillText(`${v.toFixed(0)}%`,fieldX+cx+thirdW/2,fieldY+cyOff+thirdH/2);
      });

      // Líneas verticales separando las 3 bandas (izq/centro/der)
      ctx.strokeStyle='rgba(255,255,255,.25)';ctx.lineWidth=1;
      ctx.beginPath();ctx.moveTo(fieldX+thirdW,fieldY);ctx.lineTo(fieldX+thirdW,fieldY+fieldH);
      ctx.moveTo(fieldX+thirdW*2,fieldY);ctx.lineTo(fieldX+thirdW*2,fieldY+fieldH);ctx.stroke();

      // Título
      const t=Object.values(teams).find(t=>t.visiting===vis);
      ctx.fillStyle='rgba(250,204,21,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      const halfLabel=timeFilterLabel();
      ctx.fillText(`⛳ Territorio — ${t?.name||''}${halfLabel}`,canvas.width/2,fieldY+4);
    }

    // ═══════════════════════════════════════════════════
    // MOMENTUM DEL PARTIDO (dominancia de posesión por tramos de 5 min)
    // ═══════════════════════════════════════════════════
    // A diferencia de Territorio/Pases (por equipo), el momentum es UNA sola
    // vista del partido completo, así que no respeta selectedHalf (no tendría
    // sentido filtrar "solo 1T" en un gráfico que justamente muestra la
    // evolución completa). El botón está disponible en ambos paneles de
    // equipo pero siempre abre la misma vista de partido.
    //
    // Usa computePossessionStream() (ver sección de Territorio más arriba),
    // que ya excluye el sesgo de saques de arco tras gol. Los goles SÍ son
    // 100% confiables (vienen del XML) y se marcan como hitos verticales
    // sobre el gráfico.
    const MOMENTUM_WINDOW_MIN=5;

    /**
     * buildMomentumWindows — serie de posesión por ventanas de 5 min.
     *
     * La causa raíz del sesgo (rachas largas de "posesión" que en realidad
     * eran solo el saque de arco tras recibir un gol) ya se corrige en
     * computePossessionStream(). Esta función agrega, como salvaguarda
     * adicional, un anclaje al possession REAL de cada mitad (del XML) — no
     * porque la fuente corregida esté mal, sino porque el XML sí tiene ese
     * dato exacto y no cuesta nada usarlo de referencia. El 2do tiempo se
     * deriva por resta de possessionTicks (no hay dato real más granular).
     *
     * Método:
     *   1. Nivel real de cada mitad = el possession oficial de esa mitad.
     *   2. Variación entre ventanas DENTRO de cada mitad = desvío de la
     *      serie ya corregida respecto a SU PROPIO promedio de esa mitad
     *      (nunca respecto a 50%), recortado a ±8 puntos — conserva
     *      variación leve y de signo correcto sin fabricar una tendencia
     *      más precisa de la que los datos realmente sostienen.
     */
    function buildMomentumWindows(){
      const t0=Object.values(teams).find(t=>t.visiting==='0');
      const t1=Object.values(teams).find(t=>t.visiting==='1');

      // ── Posesión real por mitad (XML) ──
      const half1Pct0=t0?.halfStats?.possession??50;
      const fullTicks0=t0?.stats?.possessionTicks||0, half1Ticks0=t0?.halfStats?.possessionTicks||0;
      const fullTicks1=t1?.stats?.possessionTicks||0, half1Ticks1=t1?.halfStats?.possessionTicks||0;
      const half2Ticks0=Math.max(0,fullTicks0-half1Ticks0), half2Ticks1=Math.max(0,fullTicks1-half1Ticks1);
      const half2Tot=half2Ticks0+half2Ticks1;
      const half2Pct0=half2Tot?half2Ticks0/half2Tot*100:half1Pct0; // si no hay datos de 2T, usar el de 1T como mejor estimación disponible

      // ── Serie por ventana (computePossessionStream, ya corregida) — se usa
      // tanto para el nivel general como para la variación entre ventanas ──
      const stream=computePossessionStream();
      const rawWindows={};
      stream.forEach(({startFrame,endFrame,vis})=>{
        for(let fi=startFrame;fi<=endFrame;fi++){
          const win=Math.floor((fi*FRAME_SECONDS/60)/MOMENTUM_WINDOW_MIN);
          if(!rawWindows[win])rawWindows[win]={'0':0,'1':0};
          rawWindows[win][vis]=(rawWindows[win][vis]||0)+1;
        }
      });
      const maxWin=Math.max(0,...Object.keys(rawWindows).map(Number));

      // Promedio crudo de pct0 por mitad, para calcular el desvío relativo de
      // cada ventana respecto a la tendencia general de SU mitad (no respecto
      // a 50%, que mezclaría el sesgo de nivel con la variación real).
      function rawPct0(win){
        const c=rawWindows[win]||{'0':0,'1':0};
        const tot=c['0']+c['1'];
        return tot?c['0']/tot*100:null;
      }
      const half2StartWin=half2StartFrame!=null?Math.floor((half2StartFrame*FRAME_SECONDS/60)/MOMENTUM_WINDOW_MIN):Math.ceil((maxWin+1)/2);
      function avgRawPct0(winFrom,winTo){
        const vals=[];
        for(let w=winFrom;w<winTo;w++){const v=rawPct0(w);if(v!=null)vals.push(v);}
        return vals.length?vals.reduce((a,b)=>a+b,0)/vals.length:50;
      }
      const crudoProm1=avgRawPct0(0,half2StartWin);
      const crudoProm2=avgRawPct0(half2StartWin,maxWin+1);

      const MAX_VAR=8; // variación leve máxima en puntos %, alrededor del nivel real
      const clamp=(v,lo,hi)=>Math.max(lo,Math.min(hi,v));

      const out=[];
      for(let w=0;w<=maxWin;w++){
        const isSecondHalfWin=w>=half2StartWin;
        const realLevel=isSecondHalfWin?half2Pct0:half1Pct0;
        const crudoProm=isSecondHalfWin?crudoProm2:crudoProm1;
        const v=rawPct0(w);
        const desvio=v!=null?clamp(v-crudoProm,-MAX_VAR,MAX_VAR):0;
        const pct0=clamp(realLevel+desvio,3,97);
        const tot=(rawWindows[w]?.['0']||0)+(rawWindows[w]?.['1']||0);
        out.push({win:w,startMin:w*MOMENTUM_WINDOW_MIN,pct0,pct1:100-pct0,tot});
      }
      return out;
    }

    function showMomentum(){
      setMapMode('momentum',null,null,null);
    }

    /**
     * renderMomentum — banda espejada horizontal: equipo local crece hacia
     * arriba, visitante hacia abajo, altura proporcional a su % de posesión
     * en ese tramo de 5 min. Goles marcados con línea vertical + etiqueta.
     */
    function renderMomentum(){
      const windows=buildMomentumWindows();
      if(!windows.length)return;
      const t0=Object.values(teams).find(t=>t.visiting==='0');
      const t1=Object.values(teams).find(t=>t.visiting==='1');

      const marginX=Math.max(40,canvas.width*.06), marginTop=60, marginBottom=40;
      const chartW=canvas.width-marginX*2;
      const chartH=canvas.height-marginTop-marginBottom;
      const midY=marginTop+chartH/2;
      const maxBarH=chartH/2;
      const colW=chartW/windows.length;

      // Eje central
      ctx.strokeStyle='rgba(255,255,255,.3)';ctx.lineWidth=1;
      ctx.beginPath();ctx.moveTo(marginX,midY);ctx.lineTo(marginX+chartW,midY);ctx.stroke();

      // Barras: local (vis=0) hacia arriba, visitante (vis=1) hacia abajo
      windows.forEach((w,i)=>{
        const x=marginX+i*colW;
        const h0=(w.pct0/100)*maxBarH;
        const h1=(w.pct1/100)*maxBarH;
        ctx.fillStyle='#5dcaa5';ctx.globalAlpha=.85;
        ctx.fillRect(x+1,midY-h0,colW-2,h0);
        ctx.fillStyle='#f0997b';
        ctx.fillRect(x+1,midY,colW-2,h1);
        ctx.globalAlpha=1;
      });

      // Marcas de gol (100% confiables, vienen del XML)
      allGoals.forEach(g=>{
        const minute=g.frame*FRAME_SECONDS/60;
        const x=marginX+(minute/MOMENTUM_WINDOW_MIN)*colW;
        const scorerVis=g.visiting;
        ctx.strokeStyle='rgba(250,204,21,.7)';ctx.lineWidth=1;ctx.setLineDash([3,3]);
        ctx.beginPath();ctx.moveTo(x,marginTop);ctx.lineTo(x,marginTop+chartH);ctx.stroke();
        ctx.setLineDash([]);
        ctx.fillStyle='#facc15';
        ctx.beginPath();ctx.arc(x,midY,3.5,0,Math.PI*2);ctx.fill();
        ctx.font=`${Math.max(9,canvas.width*.011)}px 'Courier New'`;
        ctx.textAlign='center';
        ctx.fillStyle='rgba(250,204,21,.9)';
        const labelY=scorerVis==='0'?midY-maxBarH-8:midY+maxBarH+16;
        ctx.fillText(`⚽${Math.round(minute)}'`,x,labelY);
      });

      // Eje de tiempo (minutos)
      ctx.fillStyle='rgba(255,255,255,.5)';
      ctx.font=`${Math.max(9,canvas.width*.011)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      const totalMin=windows.length*MOMENTUM_WINDOW_MIN;
      [0,Math.round(totalMin/2),totalMin].forEach(m=>{
        const x=marginX+(m/MOMENTUM_WINDOW_MIN)*colW;
        ctx.fillText(`${m}'`,x,marginTop+chartH+6);
      });

      // Nombres de equipo a los costados
      ctx.textAlign='left';ctx.textBaseline='middle';
      ctx.fillStyle='#5dcaa5';ctx.font=`bold ${Math.max(10,canvas.width*.013)}px 'Courier New'`;
      ctx.fillText(t0?.name||'Local',marginX,marginTop-14);
      ctx.fillStyle='#f0997b';
      ctx.fillText(t1?.name||'Visitante',marginX,marginTop+chartH+22);

      // Título
      ctx.textAlign='center';ctx.fillStyle='rgba(250,204,21,.9)';
      ctx.font=`bold ${Math.max(10,canvas.width*.016)}px 'Courier New'`;
      ctx.fillText(`📈 Momentum del partido — posesión por tramos de ${MOMENTUM_WINDOW_MIN} min`,canvas.width/2,20);

      // Nota: el nivel de cada mitad es el possession real del XML; la
      // variación fina entre ventanas dentro de cada mitad es aproximada
      // (no hay dato oficial más granular que por mitad).
      ctx.font=`${Math.max(8,canvas.width*.0095)}px 'Courier New'`;
      ctx.fillStyle='rgba(120,120,130,.8)';
      ctx.fillText('Nivel por mitad = posesión real (XML). Variación entre tramos: aproximada.',canvas.width/2,canvas.height-8);
    }



    let _heatmapCache={}; // internalId -> grid Float32Array, se resetea junto con frames nuevos
    const HEAT_COLS=30,HEAT_ROWS=40; // resolución de la grilla sobre 750x1000 unidades MZ

    /**
     * buildHeatmapGrid — acumula en una grilla discreta cuánto tiempo pasó el
     * jugador en cada celda de la cancha. Devuelve una grilla normalizada 0..1
     * (1 = celda con más presencia).
     *
     * Respeta `selectedHalf` ('all'|'1'|'2'): si el jugador cambió de banda
     * entre mitades (frecuente por decisiones tácticas), acumular todo el
     * partido junto mezcla dos patrones de movimiento distintos en un solo
     * mapa. Filtrar por mitad muestra el patrón real de cada una por separado.
     * El cache se indexa por internalId+mitad para no devolver un resultado
     * obsoleto al cambiar el filtro.
     *
     * Cada posición se normaliza a orientación canónica (normalizeXY) ANTES de
     * ubicarla en la grilla — si no se hace esto, un jugador que ocupa el mismo
     * costado real de la cancha en ambas mitades (que cambian de arco de
     * ataque) termina con DOS manchas de calor en lados opuestos en vez de una
     * sola consolidada.
     */
    function buildHeatmapGrid(internalId){
      const cacheKey=`${internalId}:${selectedHalf}`;
      if(_heatmapCache[cacheKey]) return _heatmapCache[cacheKey];
      const xp=playerMap[String(internalId)];
      const vis=xp?xp.visiting:null;
      const grid=new Float32Array(HEAT_COLS*HEAT_ROWS);
      frames.forEach((f,fi)=>{
        if(!frameInSelectedWindow(fi))return;
        const fp=f.players.find(pp=>String(pp.id)===String(internalId));
        if(!fp)return;
        if(fp.y<5||fp.y>995)return; // descarta overflow
        const pvis=vis!=null?vis:fp.team;
        const{x:nx2,y:ny2}=normalizeXY(fp.x,fp.y,pvis,fi);
        const col=Math.min(HEAT_COLS-1,Math.max(0,Math.floor((nx2/DX1)*HEAT_COLS)));
        const row=Math.min(HEAT_ROWS-1,Math.max(0,Math.floor((ny2/DY1)*HEAT_ROWS)));
        grid[row*HEAT_COLS+col]++;
      });
      const max=Math.max(1,...grid);
      for(let i=0;i<grid.length;i++) grid[i]/=max;
      _heatmapCache[cacheKey]=grid;
      return grid;
    }

    /**
     * heatColor — gradiente frío→caliente (azul→verde→amarillo→rojo), estilo
     * heatmap clásico de tracking deportivo. t entre 0 y 1.
     */
    function heatColor(t){
      // 4 paradas: azul(0) -> verde(.35) -> amarillo(.7) -> rojo(1)
      const stops=[
        [0.00, 56,189,248],
        [0.35, 26,138,54],
        [0.70, 250,204,21],
        [1.00, 255,69,58]
      ];
      for(let i=0;i<stops.length-1;i++){
        const [t0,r0,g0,b0]=stops[i],[t1,r1,g1,b1]=stops[i+1];
        if(t>=t0&&t<=t1){
          const f=(t-t0)/(t1-t0);
          return [Math.round(r0+(r1-r0)*f),Math.round(g0+(g1-g0)*f),Math.round(b0+(b1-b0)*f)];
        }
      }
      return [255,69,58];
    }

    /**
     * renderHeatmap — pinta la grilla de calor del jugador sobre el campo,
     * orientado siempre con su propio ataque hacia el mismo lado (igual que
     * el resto de los mapas individuales), y dibuja al jugador con su
     * camiseta en su posición promedio para dar contexto.
     */
    function renderHeatmap(p,vis){
      const grid=buildHeatmapGrid(p.internalId);
      const flip=getFlip(vis);
      const cellW=fieldW/HEAT_COLS,cellH=fieldH/HEAT_ROWS;

      ctx.save();
      for(let row=0;row<HEAT_ROWS;row++){
        for(let col=0;col<HEAT_COLS;col++){
          const v=grid[row*HEAT_COLS+col];
          if(v<=0.02)continue; // celdas casi sin presencia se dejan transparentes
          const [r,g,b]=heatColor(v);
          // Cuando el equipo ataca hacia Y=0 en el 1er tiempo (flip=true) hay que
          // rotar la grilla 180° para dejar su arco abajo: se invierten TANTO las
          // filas (Y) como las columnas (X). Invertir solo las filas espejaría las
          // bandas izquierda/derecha (un jugador de banda derecha aparecería a la
          // izquierda), que es justamente el bug que esto corrige.
          const drawRow=flip?(HEAT_ROWS-1-row):row;
          const drawCol=flip?(HEAT_COLS-1-col):col;
          ctx.globalAlpha=Math.min(0.75,0.15+v*0.6);
          ctx.fillStyle=`rgb(${r},${g},${b})`;
          ctx.fillRect(fieldX+drawCol*cellW,fieldY+drawRow*cellH,cellW+0.5,cellH+0.5);
        }
      }
      ctx.restore();

      // Redibuja las líneas del campo POR ENCIMA del calor (igual que en
      // heatmaps reales de Sofascore/FotMob, donde las líneas quedan visibles).
      drawField(ctx,fieldX,fieldY,fieldW,fieldH,true);

      // Posición promedio del jugador, con su camiseta, para dar contexto
      const avgPos=avgPositionByPlayer(vis)[p.internalId];
      if(avgPos){
        const {cx,cy}=orientedXY(avgPos.x,avgPos.y,vis,0);
        const PR=Math.max(9,fieldW*.022);
        drawCircle(ctx,cx,cy,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      }

      ctx.fillStyle='rgba(250,204,21,.9)';ctx.font=`bold ${Math.max(9,fieldW*.0175)}px 'Courier New'`;
      ctx.textAlign='center';ctx.textBaseline='top';
      const halfLabel=timeFilterLabel();
      ctx.fillText(`🔥 Mapa de calor — ${p.name}${halfLabel}`,canvas.width/2,fieldY+4);
    }

    function showHeatmap(p,vis){setMapMode('heatmap',p,vis,null);}

    function renderShotMap(p,vis){
      const myShots=allShots.filter(s=>s.fromPlayer.id==p.internalId);
      const PR=Math.max(8,fieldW*.022);
      myShots.forEach(shot=>{
        const{cx:x1,cy:y1}=orientedXY(shot.from.x,shot.from.y,vis,shot.frame);
        const{cx:x2,cy:y2}=orientedXY(shot.to.x,shot.to.y,vis,shot.frame);
        const color=shot.isHeader?'#c084fc':'#ff9500';
        ctx.globalAlpha=.75;ctx.strokeStyle=color;ctx.lineWidth=Math.max(1.5,fieldW*.004);
        ctx.setLineDash(shot.isHeader?[5,4]:[]);
        ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();ctx.setLineDash([]);
        arrow(ctx,x1,y1,x2,y2,Math.max(6,fieldW*.0163),color,.85);
        ctx.globalAlpha=.8;ctx.fillStyle=color;ctx.beginPath();ctx.arc(x1,y1,Math.max(3,fieldW*.007),0,Math.PI*2);ctx.fill();
        ctx.globalAlpha=1;
      });
      if(myShots.length){
        let sx=0,sy=0;
        myShots.forEach(q=>{const{x:nx2,y:ny2}=normalizeXY(q.from.x,q.from.y,vis,q.frame);sx+=nx2;sy+=ny2;});
        const ax=sx/myShots.length,ay=sy/myShots.length;
        const{cx,cy}=orientedXY(ax,ay,vis,0);
        drawCircle(ctx,cx,cy,PR,p.priRgb,p.secRgb,p.shirt,true,1);
      }
    }

    // Mapa de tiros de TODO un equipo. Conteo real del XML (shotsOnGoal+shotsWide);
    // posiciones estimadas desde el binario (detector validado, r=0.95, 0 falsos
    // positivos, ~25% subdetección → se marca como estimación).
    function renderTeamShotMap(vis){
      const teamShots=allShots.filter(s=>s.fromPlayer.team===vis);
      teamShots.forEach(shot=>{
        const{cx:x1,cy:y1}=orientedXY(shot.from.x,shot.from.y,vis,shot.frame);
        const{cx:x2,cy:y2}=orientedXY(shot.to.x,shot.to.y,vis,shot.frame);
        const color=shot.isHeader?'#c084fc':'#ff9500';
        ctx.globalAlpha=.55;ctx.strokeStyle=color;ctx.lineWidth=Math.max(1.2,fieldW*.0032);
        ctx.setLineDash(shot.isHeader?[5,4]:[]);
        ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke();ctx.setLineDash([]);
        // punto de origen del remate
        ctx.globalAlpha=.85;ctx.fillStyle=color;
        ctx.beginPath();ctx.arc(x1,y1,Math.max(3.5,fieldW*.008),0,Math.PI*2);ctx.fill();
        ctx.globalAlpha=1;
      });
      // Etiqueta con conteo real (XML) y estimación (detector)
      const t=Object.values(teams).find(x=>x.visiting===vis);
      const realShots=(t?.stats?.shots||0)+(t?.stats?.shotsWide||0);
      const label=`Tiros: ${realShots} (XML) · ${teamShots.length} ubicados (est.)`;
      ctx.save();
      ctx.font=`${Math.max(11,fieldW*.028)}px 'Courier New'`;
      ctx.textAlign='center';ctx.fillStyle='#ffcf6b';
      ctx.fillText(label,fieldX+fieldW/2,fieldY+fieldH-8);
      ctx.restore();
    }

    // Mapa de tackles (entradas) de un equipo. Conteo real del XML (tacklesWon);
    // posiciones estimadas desde el binario (detector validado: r=0.84, jackknife
    // estable). Solo tackles — las intercepciones no se detectan con fiabilidad.
    function renderTeamTackleMap(vis){
      const teamTackles=allTackles.filter(t=>t.team===vis);
      teamTackles.forEach(tk=>{
        const{cx,cy}=orientedXY(tk.pos.x,tk.pos.y,vis,tk.frame);
        // marca de tackle: círculo con cruz (choque/duelo)
        const r=Math.max(4,fieldW*.009);
        ctx.globalAlpha=.85;ctx.strokeStyle='#4ade80';ctx.lineWidth=Math.max(1.5,fieldW*.003);
        ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.stroke();
        ctx.beginPath();ctx.moveTo(cx-r*.7,cy-r*.7);ctx.lineTo(cx+r*.7,cy+r*.7);
        ctx.moveTo(cx+r*.7,cy-r*.7);ctx.lineTo(cx-r*.7,cy+r*.7);ctx.stroke();
        ctx.globalAlpha=1;
      });
      const t=Object.values(teams).find(x=>x.visiting===vis);
      const realTackles=t?.stats?.tackles||0;
      const label=`Entradas: ${realTackles} (XML) · ${teamTackles.length} ubicadas (est.)`;
      ctx.save();
      ctx.font=`${Math.max(11,fieldW*.028)}px 'Courier New'`;
      ctx.textAlign='center';ctx.fillStyle='#86efac';
      ctx.fillText(label,fieldX+fieldW/2,fieldY+fieldH-8);
      ctx.restore();
    }
    const PR=()=>Math.max(8,fieldW*.022);
    function drawLivePlayer(p){
      const x=nx(p.x),y=ny(p.y),r=PR();
      const xp=playerMap[String(p.id)];
      const pri=xp?xp.priRgb:(p.team==='0'?'38bdf8':'f87171');
      const sec=xp?xp.secRgb:'ffffff';
      const shirt=xp?xp.shirt:String(p.id);
      const isSel=activePlayer&&String(activePlayer.internalId)===String(p.id);
      drawCircle(ctx,x,y,r,pri,sec,String(shirt),isSel,1);
    }
    function drawBall(ball){
      if(!ball)return;
      const x=nx(ball.x),y=ny(ball.y),z=ball.z||0;
      const h=z/100*fieldH*.05,r=Math.max(4,fieldW*.01)+h*.15;
      ctx.beginPath();ctx.ellipse(x,y,r*.8,r*.4,0,0,Math.PI*2);ctx.fillStyle='rgba(0,0,0,.4)';ctx.fill();
      ctx.beginPath();ctx.arc(x,y-h,r,0,Math.PI*2);ctx.fillStyle='white';ctx.fill();
      ctx.lineWidth=1;ctx.strokeStyle='#333';ctx.stroke();
    }

    // ═══════════════════════════════════════════════════
    // TIMELINE MARKERS
    // ═══════════════════════════════════════════════════
    function drawTimelineMarkers(){
      const max=parseInt(document.getElementById('timeline').max)||1;
      const by=canvas.height-3;
      allGoals.forEach(g=>{const x=fieldX+(g.frame/max)*fieldW;ctx.fillStyle='#facc15';ctx.fillRect(x-1,by-5,3,5);});
      allCards.forEach(c=>{const x=fieldX+(c.frame/max)*fieldW;ctx.fillStyle=c.type.includes('red')||c.type.includes('second')?'#ff4444':'#facc15';ctx.fillRect(x-1,by-4,2,4);});
      allSaves.forEach(s=>{const x=fieldX+(s.frame/max)*fieldW;ctx.globalAlpha=.3;ctx.fillStyle='#38bdf8';ctx.fillRect(x,by-3,1,3);ctx.globalAlpha=1;});
    }

    // ═══════════════════════════════════════════════════
    // MAIN RENDER
    // ═══════════════════════════════════════════════════
    function render(){
      ctx.clearRect(0,0,canvas.width,canvas.height);
      // El momentum es un gráfico de barras temporal, no un mapa sobre la
      // cancha — no dibujamos el campo de fondo para ese modo.
      if(mapMode!=='momentum') drawField(ctx,fieldX,fieldY,fieldW,fieldH);

      if(!mapMode){
        // LIVE MODE
        const frame=frames[Math.floor(currentFrame)];
        if(frame){frame.players.forEach(drawLivePlayer);drawBall(frame.ball);}
        drawTimelineMarkers();
      } else {
        // MAP MODE - no live players in background
        const md=currentMapData;
        if(!md)return;
        const {type,player,vis,data}=md;
        if(type==='passes')renderPassMap(player,vis);
        else if(type==='pass-network')renderPassNetwork(vis);
        else if(type==='heatmap')renderHeatmap(player,vis);
        else if(type==='territory')renderTerritory(vis);
        else if(type==='momentum')renderMomentum();
        else if(type==='shots')renderShotMap(player,vis);
        else if(type==='team-shots')renderTeamShotMap(vis);
        else if(type==='team-tackles')renderTeamTackleMap(vis);
        else if(type==='interceptions'){allTackleEvents=allInterceptions;renderInterceptionsMap(player,vis);}
        else if(type==='tackles')renderInterceptionsMap(player,vis);
        else if(type==='saves')renderSavesCroquis(player,data);
        else if(type==='goal-single')renderGoalSingle(data,data?.visiting);
        else if(type==='goals')renderMultiGoals(player,data,vis);
        else if(type==='assists')renderAssistsCroquis(player,data,vis);
        else if(type==='single-pass')renderSinglePass(player,data,vis);
        else if(type==='single-shot')renderSingleShot(player,data,vis);
        else if(type==='single-interception')renderSingleInterception(player,data,vis);
        else if(type==='single-save')renderSingleSave(player,data);
        else if(type==='goals-map')renderGoalsMap();

        // Hint: click canvas to return to live (small, bottom-right, no obstruction)
        const hintX=mapMode==='momentum'?canvas.width-4:fieldX+fieldW-4;
        const hintY=mapMode==='momentum'?canvas.height-4:fieldY+fieldH-4;
        ctx.fillStyle='rgba(250,204,21,.3)';ctx.font=`9px 'Courier New'`;ctx.textAlign='right';ctx.textBaseline='bottom';
        ctx.fillText('[ click → vivo ]',hintX,hintY);
      }
      document.getElementById('frameLabel').textContent=Math.floor(currentFrame);
      document.getElementById('timeline').value=Math.floor(currentFrame);
      renderTacticSettings();
      updateRelato();
    }

    // Click on canvas -> if in map mode, return to live
    canvas.addEventListener('click',()=>{if(mapMode)clearMap();});

    // ═══════════════════════════════════════════════════
    // ANIMATION LOOP
    // ═══════════════════════════════════════════════════
    let lastT=null;
    function loop(t){
      if(playing&&lastT!==null&&!mapMode){
        const dt=(t-lastT)/1000;
        currentFrame+=speed*30*dt;
        currentFrame=Math.max(0,Math.min(frames.length-1,currentFrame));
        if(currentFrame>=frames.length-1)playing=false;
        render();updateScore();
      }
      lastT=t;
      requestAnimationFrame(loop);
    }
    requestAnimationFrame(loop);

    // ═══════════════════════════════════════════════════
    // CONTROLS
    // ═══════════════════════════════════════════════════
    document.getElementById('playBtn').onclick=()=>{clearMap();playing=true;};
    document.getElementById('pauseBtn').onclick=()=>playing=false;
    document.getElementById('backBtn').onclick=()=>{currentFrame=Math.max(0,currentFrame-50);render();updateScore();};
    document.querySelectorAll('.speed-btn').forEach(btn=>{
      btn.onclick=()=>{speed=parseFloat(btn.dataset.speed);document.querySelectorAll('.speed-btn').forEach(b=>b.classList.remove('active'));btn.classList.add('active');};
    });
    document.getElementById('timeline').addEventListener('input',e=>{currentFrame=+e.target.value;render();updateScore();});

    // Botones de red de pases (panel de cada equipo)
    // Limpia el estado "active" de todos los botones de modo de mapa (Pases,
    // Zonas, Momentum) antes de activar el que se acaba de presionar.
    function clearMapModeButtons(){
      document.querySelectorAll('.tp-network-btn').forEach(b=>b.classList.remove('active'));
      document.getElementById('global-momentum-btn').classList.remove('active');
      document.getElementById('global-goalsmap-btn').classList.remove('active');
      const nav=document.getElementById('goalsmap-nav');if(nav)nav.style.display='none';
    }

    document.getElementById('local-network-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('local-network-btn').classList.add('active');
      showPassNetwork('0');
    };
    document.getElementById('visitor-network-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('visitor-network-btn').classList.add('active');
      showPassNetwork('1');
    };

    // Botones de territorio por zonas (panel de cada equipo)
    document.getElementById('local-territory-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('local-territory-btn').classList.add('active');
      showTerritory('0');
    };
    document.getElementById('visitor-territory-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('visitor-territory-btn').classList.add('active');
      showTerritory('1');
    };

    // Botones de mapa de tiros (panel de cada equipo). Estimación desde el binario.
    document.getElementById('local-shots-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('local-shots-btn').classList.add('active');
      showTeamShotMap('0');
    };
    document.getElementById('visitor-shots-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('visitor-shots-btn').classList.add('active');
      showTeamShotMap('1');
    };

    // Botones de mapa de entradas/tackles (panel de cada equipo). Estimación.
    document.getElementById('local-tackles-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('local-tackles-btn').classList.add('active');
      showTeamTackleMap('0');
    };
    document.getElementById('visitor-tackles-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('visitor-tackles-btn').classList.add('active');
      showTeamTackleMap('1');
    };

    // Botón único de momentum (vista de partido completo, ambos equipos)
    document.getElementById('global-momentum-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('global-momentum-btn').classList.add('active');
      showMomentum();
    };

    // Botón de mapa de goles y atajadas (consolidado, datos del XML)
    document.getElementById('global-goalsmap-btn').onclick=()=>{
      clearMapModeButtons();
      document.getElementById('global-goalsmap-btn').classList.add('active');
      showGoalsMap(selectedHalf||'all',null);
    };
    document.getElementById('gm-prev').onclick=()=>goalsMapStep(-1);
    document.getElementById('gm-next').onclick=()=>goalsMapStep(1);

    // Arrastre del navegador de jugadas (flotante). Aparece centrado por CSS;
    // al arrastrarlo por primera vez se fija en píxeles dentro de canvas-wrap.
    (function(){
      const nav=document.getElementById('goalsmap-nav');
      if(!nav)return;
      let dragging=false,startX=0,startY=0,initL=0,initT=0;
      function begin(e){
        // No arrastrar si se hizo click en una flecha
        if(e.target.classList.contains('gm-arrow'))return;
        dragging=true;nav.classList.add('dragging');
        const pt=e.touches?e.touches[0]:e;
        startX=pt.clientX;startY=pt.clientY;
        const wrapRect=wrap.getBoundingClientRect();
        const r=nav.getBoundingClientRect();
        initL=r.left-wrapRect.left;initT=r.top-wrapRect.top;
        nav.style.transform='none';nav.style.left=initL+'px';nav.style.top=initT+'px';
        e.preventDefault();
      }
      function move(e){
        if(!dragging)return;
        const pt=e.touches?e.touches[0]:e;
        nav.style.left=(initL+pt.clientX-startX)+'px';
        nav.style.top=(initT+pt.clientY-startY)+'px';
      }
      function end(){dragging=false;nav.classList.remove('dragging');}
      nav.addEventListener('mousedown',begin);
      document.addEventListener('mousemove',move);
      document.addEventListener('mouseup',end);
      nav.addEventListener('touchstart',begin,{passive:false});
      document.addEventListener('touchmove',move,{passive:false});
      document.addEventListener('touchend',end);
    })();

    document.getElementById('local-tactic-btn').onclick=()=>toggleTacticPitch('0','local-tactic-btn','local-tactic-pitch');
    document.getElementById('visitor-tactic-btn').onclick=()=>toggleTacticPitch('1','visitor-tactic-btn','visitor-tactic-pitch');

    // Selector de mitad (Todo/1T/2T) — aplica a red de pases y mapa de calor
    // ── Selector de tiempo: mitades, cuartos y ventana de 5 minutos ──
    function twLabel(){
      const a=timeFilter.startMin, b=timeFilter.endMin;
      return `${a}–${b}'`;
    }
    function applyTimeFilter(){
      // Mantener selectedHalf en sync (usado en etiquetas, cache key, goles).
      selectedHalf = timeFilter.mode==='half' ? timeFilter.half
                   : timeFilter.mode==='window' ? `w${timeFilter.startMin}` : 'all';
      const tw=document.getElementById('time-window');
      tw.style.display=(timeFilter.mode==='window')?'flex':'none';
      if(timeFilter.mode==='window')document.getElementById('tw-label').textContent=twLabel();
      if(mapMode==='goals-map'){
        const h=timeFilter.mode==='half'?timeFilter.half:'all';
        showGoalsMap(h,_goalsMapState.teamFilter);
      }
      render();
    }
    // Clamp de la ventana al rango del partido (incluye suplementario).
    function clampWindow(){
      const dur=matchDurationMin();
      const maxStart=Math.max(0,Math.floor(dur/5)*5);
      if(timeFilter.startMin<0)timeFilter.startMin=0;
      if(timeFilter.startMin>maxStart)timeFilter.startMin=maxStart;
      timeFilter.endMin=timeFilter.startMin+5;
    }

    document.querySelectorAll('.half-btn').forEach(btn=>{
      btn.onclick=()=>{
        const h=btn.dataset.half;
        document.querySelectorAll('.half-btn').forEach(b=>b.classList.toggle('active',b===btn));
        if(h==='all'){timeFilter.mode='all';}
        else if(h==='1'||h==='2'){timeFilter.mode='half';timeFilter.half=h;}
        else if(h==='w'){ // modo tramos: ventana de 5 min desde 0
          timeFilter.mode='window';timeFilter.startMin=0;timeFilter.endMin=5;
        }
        applyTimeFilter();
      };
    });

    // Flechas de la ventana de 5 min
    document.getElementById('tw-prev').onclick=()=>{
      timeFilter.startMin-=5;clampWindow();applyTimeFilter();
    };
    document.getElementById('tw-next').onclick=()=>{
      timeFilter.startMin+=5;clampWindow();applyTimeFilter();
    };
    // Flechas dobles: saltar de a un cuarto (≈ duración/4)
    document.getElementById('tw-qprev').onclick=()=>{
      const q=Math.round(matchDurationMin()/4);
      timeFilter.startMin-=q;clampWindow();applyTimeFilter();
    };
    document.getElementById('tw-qnext').onclick=()=>{
      const q=Math.round(matchDurationMin()/4);
      timeFilter.startMin+=q;clampWindow();applyTimeFilter();
    };

    // ═══════════════════════════════════════════════════
    // BINARY GZ PARSER
    // ═══════════════════════════════════════════════════
    async function decompressGZ(ab){
      const ds=new DecompressionStream('gzip');
      const writer=ds.writable.getWriter();const reader=ds.readable.getReader();
      writer.write(new Uint8Array(ab));writer.close();
      const chunks=[];while(true){const{done,value}=await reader.read();if(done)break;chunks.push(value);}
      const total=chunks.reduce((s,c)=>s+c.length,0);
      const result=new Uint8Array(total);let off=0;
      for(const c of chunks){result.set(c,off);off+=c.length;}
      return result.buffer;
    }
    // Lee un archivo que PUEDE estar comprimido en gzip o venir plano. Detecta por
    // el magic number gzip (0x1f 0x8b) en vez de confiar en la extensión, porque
    // algunas fuentes (matchviewer getMatchFiles) devuelven el contenido ya
    // descomprimido aunque el nombre termine en .gz. Devuelve un ArrayBuffer.
    async function readMaybeGZ(file){
      const ab=await file.arrayBuffer();
      const sig=new Uint8Array(ab,0,2);
      return (sig[0]===0x1f&&sig[1]===0x8b) ? await decompressGZ(ab) : ab;
    }
    // Igual que readMaybeGZ pero devuelve texto (para XML).
    async function readMaybeGZText(file){
      const buf=await readMaybeGZ(file);
      return new TextDecoder().decode(buf);
    }
    function parseBinaryData(buffer){
      const dv=new DataView(buffer);
      const playerSlots=dv.getUint32(4,true);
      const FRAME_SIZE=6+playerSlots*7+4;
      const totalFrames=Math.floor((buffer.byteLength-16)/FRAME_SIZE);

      // Build fallback team map from first frame ONLY for IDs not in playerMap
      // Use position: lower Y (y<500 in frame0) = visiting=1 (upper half starts away side)
      // Confirmed: iids 3-13 start y>500 = visiting=0, iids 19-29 start y<500 = visiting=1
      const idTeamFallback={};
      for(let pi=0;pi<playerSlots;pi++){
        const o=16+6+pi*7;const pid=dv.getUint8(o);if(pid===0)continue;
        if(!playerMap[String(pid)]){
          const y=dv.getUint16(o+4,true);
          idTeamFallback[pid]=y>500?'0':'1';
        }
      }

      const parsedFrames=[];
      for(let fi=0;fi<totalFrames;fi++){
        const base=16+fi*FRAME_SIZE;
        // Ball: clamp x e y a 0..750 / 0..1000 (overflow uint16 cuando la pelota
        // o el jugador sale del campo: valores >32768 son negativos = borde 0,
        // valores apenas por encima del máximo = borde máximo).
        let bx=dv.getUint16(base,true);
        let by=dv.getUint16(base+2,true);
        const bz=dv.getUint16(base+4,true);
        if(bx>800)bx=bx>32768?0:DX1; // overflow X -> borde más cercano
        if(by>1100)by=by>32768?0:1000; // overflow Y -> arco más cercano

        const players=[];
        for(let pi=0;pi<playerSlots;pi++){
          const o=base+6+pi*7;const pid=dv.getUint8(o);if(pid===0)continue;
          const xp=playerMap[String(pid)];
          const team=xp?xp.visiting:(idTeamFallback[pid]||'0');
          let px=dv.getUint16(o+2,true);
          let py=dv.getUint16(o+4,true);
          if(px>800)px=px>32768?0:DX1; // mismo overflow de X que la pelota
          if(py>1100)py=py>32768?0:1000;
          players.push({id:String(pid),team,x:px,y:py,s:dv.getUint8(o+6)});
        }
        parsedFrames.push({frame:fi,ball:{x:bx,y:by,z:bz},players});
      }
      return parsedFrames;
    }
    async function loadBinaryGZ(file){
      const buffer=await readMaybeGZ(file);
      frames=parseBinaryData(buffer);
      _heatmapCache={};
      half2StartFrame=null; // se recalculará al cargar el XML de stats correspondiente; null = usa fallback frames.length/2
      document.getElementById('timeline').max=frames.length-1;
      document.getElementById('frameTotal').textContent=frames.length-1;
      currentFrame=0;
      computeField();calcAttackUp();detectEvents();buildTeamPanels();render();updateScore();
    }

    // ═══════════════════════════════════════════════════
    // FILE INPUTS
    // ═══════════════════════════════════════════════════
    // Carga de un archivo de datos del partido (data….gz o JSON de frames).
    async function loadDataFile(f){
      if(f.name.toLowerCase().endsWith('.json')){
        const raw=JSON.parse(await f.text());
        frames=Array.isArray(raw)?raw:(raw.frames||[]);
        if(!Array.isArray(raw)&&raw.teams)Object.entries(raw.teams).forEach(([id,t])=>{if(!teams[id])teams[id]={};Object.assign(teams[id],t);});
        document.getElementById('timeline').max=frames.length-1;
        document.getElementById('frameTotal').textContent=frames.length-1;
        currentFrame=0;
      } else await loadBinaryGZ(f);
    }
    // Carga de un archivo de estadísticas (stats….xml o stats….xml.gz).
    async function loadStatsFile(f){
      parseXML(await readMaybeGZText(f));
    }
    // Distingue stats de data por el nombre: el de stats contiene "stats" (o ".xml").
    // Un archivo playerlist trae valor/edad (nombre playerlist_*.xml o contiene
    // <ManagerZone_PlayerList>). Se distingue ANTES que stats (ambos son .xml).
    function isPlayerListFile(f){
      return /playerlist/i.test(f.name);
    }
    function isStatsFile(f){
      const n=f.name.toLowerCase();
      return n.includes('stats')||n.endsWith('.xml')||n.endsWith('_xml.gz');
    }

    // Parsea un XML de playerlist (local, sin CORS) y llena playerExtra por id.
    async function loadPlayerListFile(f){
      const txt=await readMaybeGZText(f);
      const doc=new DOMParser().parseFromString(txt,'text/xml');
      const tp=doc.querySelector('TeamPlayers');
      const currency=tp?tp.getAttribute('teamCurrency'):null;
      const rate=currency&&MZ_TO_USD[currency]!==undefined?MZ_TO_USD[currency]:null;
      doc.querySelectorAll('Player').forEach(p=>{
        const id=p.getAttribute('id');
        const age=+p.getAttribute('age')||null;
        const valueLocal=+p.getAttribute('value')||0;
        playerExtra[id]={age,valueLocal,currency,
          valueUsd: rate!=null?Math.round(valueLocal*rate):null, converted: rate!=null};
      });
    }

    // Input único: acepta data, stats y playerlists juntos. Orden: stats primero
    // (para tener los equipos), luego data, y las playerlists en cualquier momento.
    // Carga un conjunto de archivos (data, stats, playerlists) — reutilizable desde
    // el input manual y desde una integración externa (Tampermonkey MZTITOR).
    // Acepta objetos File o {name, blob/arrayBuffer}. Devuelve una promesa.
    async function loadMatchFiles(files){
      files=[...files];if(!files.length)return;
      const plFiles=files.filter(isPlayerListFile);
      const rest=files.filter(f=>!isPlayerListFile(f));
      const statsFiles=rest.filter(isStatsFile);
      const dataFiles=rest.filter(f=>!isStatsFile(f));
      for(const f of statsFiles)await loadStatsFile(f);
      for(const f of dataFiles)await loadDataFile(f);
      let plLoaded=false;
      for(const f of plFiles){try{await loadPlayerListFile(f);plLoaded=true;}catch(err){console.warn('playerlist:',err);}}
      if(frames.length){computeField();calcAttackUp();detectEvents();buildTeamPanels();render();updateScore();}
      else if(statsFiles.length){buildTeamPanels();}
      if(plLoaded){renderTacticSettings();buildTeamPanels();}
      else if(Object.keys(teams).length)loadPlayerExtras();
    }

    document.getElementById('matchInput').addEventListener('change',e=>{
      loadMatchFiles(e.target.files);
    });

    // ═══════════════════════════════════════════════════
    // API DE INTEGRACIÓN EXTERNA (Tampermonkey MZTITOR)
    // ═══════════════════════════════════════════════════
    // Permite que un userscript alimente el partido sin selección manual.
    // Uso: window.MZTITOR.loadFiles([{name, blob}, ...])
    //   o: window.MZTITOR.loadFromUrls([{name, url}, ...])  (fetch de object URLs)
    window.MZTITOR = {
      // files: array de File o {name, blob}. Se normaliza a File real (los loaders
      // usan .name, .text(), .arrayBuffer(), todos presentes en File).
      async loadFiles(files){
        const norm=files.map(f=>{
          if(f instanceof File) return f;
          const b=f.blob||f;
          return new File([b], f.name||'archivo', {type:b.type||''});
        });
        await loadMatchFiles(norm);
      },
      // urls: array de {name, url}. Hace fetch de cada url (object URL o http) → File.
      async loadFromUrls(urls){
        const files=[];
        for(const {name,url} of urls){
          const resp=await fetch(url);
          const blob=await resp.blob();
          files.push(new File([blob], name, {type:blob.type||''}));
        }
        await loadMatchFiles(files);
      }
    };

    // Si la URL trae ?mztitor=1, avisar que se espera carga externa (opcional).
    if(new URLSearchParams(location.search).get('mztitor')==='1'){
      console.log('[MZTITOR] Modo integración: esperando datos del partido vía window.MZTITOR');
    }

    // ═══════════════════════════════════════════════════
    // RESIZE
    // ═══════════════════════════════════════════════════
    new ResizeObserver(()=>{computeField();render();}).observe(wrap);
    computeField();render();

  }
})();