Expone window.buildMZTITORViewer() que monta el visor completo
Detta skript bör inte installeras direkt. Det är ett bibliotek för andra skript att inkludera med meta-direktivet // @require https://update.greasyfork.org/scripts/585646/1867797/mztitor-viewer.js
// ============================================================
// 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();
}
})();