Greasy Fork is available in English.

mztitor-viewer

Expone window.buildMZTITORViewer() que monta el visor completo

Αυτός ο κώδικας δεν πρέπει να εγκατασταθεί άμεσα. Είναι μια βιβλιοθήκη για άλλους κώδικες που περιλαμβάνεται μέσω της οδηγίας meta // @require https://update.greasyfork.org/scripts/585646/1867797/mztitor-viewer.js

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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();

  }
})();