Spaces:
Running
Running
Commit ·
dc37fa3
1
Parent(s): f89b48b
Top equipos: tarjetas colapsables + radar comparativo del top
Browse files- Cada equipo del top ahora muestra solo el header; se despliega la tabla al tocar
(y se colapsa de nuevo), con chevron.
- Radar (mplsoccer) que compara los top-N equipos en las variables elegidas
(z-score sobre el pool), un polígono por equipo. Se devuelve en /team-vars/search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
src/racing_reports/api/routes_team_vars.py
CHANGED
|
@@ -62,7 +62,14 @@ def search(req: SearchRequest) -> dict:
|
|
| 62 |
if bad:
|
| 63 |
raise HTTPException(status_code=400, detail=f"Dirección inválida: {bad}")
|
| 64 |
top = min(max(req.top, 1), 25)
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
@router.get("/teams", dependencies=[Depends(require_auth)])
|
|
|
|
| 62 |
if bad:
|
| 63 |
raise HTTPException(status_code=400, detail=f"Dirección inválida: {bad}")
|
| 64 |
top = min(max(req.top, 1), 25)
|
| 65 |
+
specs = [s.model_dump() for s in req.specs]
|
| 66 |
+
res = team_vars.search(req.league, req.season, specs, top=top)
|
| 67 |
+
# radar comparando el top (hasta 6 para que sea legible); best-effort
|
| 68 |
+
try:
|
| 69 |
+
res["radar_b64"] = team_vars.search_radar_png(req.league, req.season, specs, top=min(top, 6))
|
| 70 |
+
except Exception:
|
| 71 |
+
res["radar_b64"] = None
|
| 72 |
+
return res
|
| 73 |
|
| 74 |
|
| 75 |
@router.get("/teams", dependencies=[Depends(require_auth)])
|
src/racing_reports/team_vars.py
CHANGED
|
@@ -341,6 +341,65 @@ def radar_png(league: str, season: str, team: str, variables: list[str], scope:
|
|
| 341 |
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 342 |
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
def search(league: str, season: str, specs: list[dict], top: int = 5) -> dict:
|
| 345 |
"""specs = [{"variable", "direction"}]. Devuelve top-N equipos por puntaje."""
|
| 346 |
pool = _pool(league, season)
|
|
|
|
| 341 |
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 342 |
|
| 343 |
|
| 344 |
+
def search_radar_png(league: str, season: str, specs: list[dict], top: int = 5) -> str | None:
|
| 345 |
+
"""Radar comparando los TOP-N equipos de la búsqueda en las variables elegidas.
|
| 346 |
+
Cada eje = una variable (en z-score sobre el pool); un polígono por equipo.
|
| 347 |
+
Devuelve PNG base64 o None si hay <3 variables válidas o sin resultados."""
|
| 348 |
+
import base64
|
| 349 |
+
from io import BytesIO
|
| 350 |
+
|
| 351 |
+
import matplotlib
|
| 352 |
+
matplotlib.use("Agg")
|
| 353 |
+
import matplotlib.pyplot as plt
|
| 354 |
+
from matplotlib.lines import Line2D
|
| 355 |
+
from mplsoccer import Radar
|
| 356 |
+
|
| 357 |
+
pool = _pool(league, season)
|
| 358 |
+
valid = [s for s in specs if s.get("variable") in pool.columns and s.get("direction") in DIRECTIONS]
|
| 359 |
+
variables = list(dict.fromkeys(s["variable"] for s in valid))
|
| 360 |
+
if pool.empty or len(variables) < 3:
|
| 361 |
+
return None
|
| 362 |
+
res = search(league, season, specs, top=top)
|
| 363 |
+
teams = res.get("teams", [])
|
| 364 |
+
if not teams:
|
| 365 |
+
return None
|
| 366 |
+
|
| 367 |
+
zmat, _ = _zmatrix(pool, variables) # z por (fila del pool × variable)
|
| 368 |
+
key = pool[["competencia", "temporada", "equipo"]].astype(str)
|
| 369 |
+
params = [label_of(v) for v in variables]
|
| 370 |
+
# rango por eje = spread de z en el pool (simétrico, recortado a ±3)
|
| 371 |
+
lows = [max(-3.0, float(zmat[v].min())) for v in variables]
|
| 372 |
+
highs = [min(3.0, float(zmat[v].max())) for v in variables]
|
| 373 |
+
lows = [min(l, -0.5) for l in lows]; highs = [max(h, 0.5) for h in highs]
|
| 374 |
+
|
| 375 |
+
colors = ["#00a86b", "#F2C14E", "#E8584E", "#5B9BD5", "#B57EDC", "#E88C30"]
|
| 376 |
+
radar = Radar(params, lows, highs, num_rings=4, ring_width=1, center_circle_radius=1)
|
| 377 |
+
fig, ax = radar.setup_axis(figsize=(8.5, 8.5), facecolor="#0b1721")
|
| 378 |
+
fig.patch.set_facecolor("#0b1721")
|
| 379 |
+
radar.draw_circles(ax=ax, facecolor="#16251e", edgecolor="#33514a")
|
| 380 |
+
legend = []
|
| 381 |
+
for i, t in enumerate(teams):
|
| 382 |
+
m = ((key["competencia"] == str(t["competencia"])) & (key["temporada"] == str(t["temporada"]))
|
| 383 |
+
& (key["equipo"] == str(t["equipo"])))
|
| 384 |
+
idx = key.index[m]
|
| 385 |
+
if len(idx) == 0:
|
| 386 |
+
continue
|
| 387 |
+
vals = [float(min(max(zmat.loc[idx[0], v], lows[j]), highs[j])) for j, v in enumerate(variables)]
|
| 388 |
+
col = colors[i % len(colors)]
|
| 389 |
+
radar.draw_radar_solid(vals, ax=ax, kwargs={"facecolor": col, "alpha": 0.12,
|
| 390 |
+
"edgecolor": col, "linewidth": 2})
|
| 391 |
+
legend.append(Line2D([0], [0], color=col, lw=3, label=f"#{i+1} {t['equipo']}"))
|
| 392 |
+
radar.draw_range_labels(ax=ax, fontsize=8, color="#9fb4ab")
|
| 393 |
+
radar.draw_param_labels(ax=ax, fontsize=10, color="#E8EEEA")
|
| 394 |
+
ax.set_title("Top equipos — comparación por variable (z-score)", color="#E8EEEA", fontsize=12, pad=18)
|
| 395 |
+
ax.legend(handles=legend, loc="upper right", bbox_to_anchor=(1.32, 1.0), fontsize=9,
|
| 396 |
+
labelcolor="#E8EEEA", facecolor="#16251e", edgecolor="#33514a")
|
| 397 |
+
buf = BytesIO()
|
| 398 |
+
fig.savefig(buf, format="png", dpi=110, bbox_inches="tight", facecolor=fig.get_facecolor())
|
| 399 |
+
plt.close(fig)
|
| 400 |
+
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 401 |
+
|
| 402 |
+
|
| 403 |
def search(league: str, season: str, specs: list[dict], top: int = 5) -> dict:
|
| 404 |
"""specs = [{"variable", "direction"}]. Devuelve top-N equipos por puntaje."""
|
| 405 |
pool = _pool(league, season)
|
src/racing_reports/web/templates/home.html
CHANGED
|
@@ -18,6 +18,22 @@
|
|
| 18 |
.table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
|
| 19 |
.table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
|
| 20 |
.table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</style>
|
| 22 |
</head>
|
| 23 |
<body class="bg-ink-900 text-gray-100 min-h-screen" x-data="rrApp()" x-init="init()" x-cloak>
|
|
@@ -283,13 +299,15 @@
|
|
| 283 |
<h2 class="text-lg font-semibold text-racing-500">Top equipos (<span x-text="tvResult?.pool_size"></span> en el pool)</h2>
|
| 284 |
<p class="text-xs text-yellow-200" x-show="tvResult?.warning" x-text="tvResult?.warning"></p>
|
| 285 |
<template x-for="(t, i) in (tvResult?.teams || [])" :key="t.equipo">
|
| 286 |
-
<div class="bg-ink-700 border border-ink-600 rounded-2xl p-4">
|
| 287 |
-
<div class="flex items-center justify-between
|
| 288 |
-
<div class="font-semibold
|
|
|
|
|
|
|
| 289 |
<span class="text-xs text-gray-400" x-text="t.competencia+' · '+t.temporada+' · '+t.partidos+' partidos'"></span></div>
|
| 290 |
<div class="font-bold" :class="t.total>=0 ? 'text-racing-500' : 'text-red-300'" x-text="'Puntaje '+(t.total>=0?'+':'')+t.total"></div>
|
| 291 |
</div>
|
| 292 |
-
<table class="w-full text-xs">
|
| 293 |
<thead><tr class="text-gray-400 text-left">
|
| 294 |
<th class="py-1">Variable</th><th>Dir.</th><th class="text-right">Valor</th><th class="text-right">z</th><th class="text-right">Puntaje</th>
|
| 295 |
</tr></thead>
|
|
@@ -307,6 +325,10 @@
|
|
| 307 |
</table>
|
| 308 |
</div>
|
| 309 |
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
</section>
|
| 311 |
|
| 312 |
<!-- ---- Perfil de un equipo (z-score) ---- -->
|
|
@@ -724,6 +746,146 @@
|
|
| 724 |
</template>
|
| 725 |
</div>
|
| 726 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
</div>
|
| 728 |
</template>
|
| 729 |
|
|
@@ -852,6 +1014,11 @@
|
|
| 852 |
lbPool: [], lbPoolLigaIn:'', lbPoolTeamIn:'', lbEscala:'abs', lbAllKeys: [],
|
| 853 |
lbBubOn: false, lbB: {lado:'of', recurso:'todos', metrica:'n_intentos'},
|
| 854 |
lbSeasons: {}, lbtSeason: '',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
|
| 856 |
async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
|
| 857 |
async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
|
|
@@ -1265,6 +1432,140 @@
|
|
| 1265 |
this.lbtResult = await this._getJSON(`/api/lowblock/team?league=${encodeURIComponent(this.lbtLeague)}&equipo=${encodeURIComponent(this.lbtEquipo)}&season=${encodeURIComponent(this.lbtSeason)}`);
|
| 1266 |
} catch(e){ this.lbtError = 'Error: '+e; }
|
| 1267 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1268 |
async ejRunPost(){
|
| 1269 |
this.ejPostMsg=''; this.ejPostMsgError=false;
|
| 1270 |
if (Object.keys(this.ejPartidos).length && !this.ejPartidos[this.league]){ this.ejPostMsg='Esta liga no tiene artefactos de ejes.'; this.ejPostMsgError=true; return; }
|
|
|
|
| 18 |
.table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
|
| 19 |
.table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
|
| 20 |
.table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
|
| 21 |
+
/* Slider de rango doble (filtro por percentil de los perfiles de saque) */
|
| 22 |
+
.rng { position: relative; height: 20px; }
|
| 23 |
+
.rng .track { position:absolute; top:9px; left:0; right:0; height:3px; background:#374151; border-radius:2px; }
|
| 24 |
+
.rng .fill { position:absolute; top:9px; height:3px; background:#ef4444; border-radius:2px; }
|
| 25 |
+
.rng input[type=range] { position:absolute; top:0; left:0; width:100%; height:20px; margin:0;
|
| 26 |
+
-webkit-appearance:none; appearance:none; background:none; pointer-events:none; }
|
| 27 |
+
.rng input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; pointer-events:auto;
|
| 28 |
+
width:13px; height:13px; border-radius:50%; background:#f3f4f6; border:2px solid #ef4444; cursor:pointer; }
|
| 29 |
+
.rng input[type=range]::-moz-range-thumb { pointer-events:auto;
|
| 30 |
+
width:13px; height:13px; border-radius:50%; background:#f3f4f6; border:2px solid #ef4444; cursor:pointer; }
|
| 31 |
+
/* Lista de similares: scrollbar visible para que se note que hay más abajo */
|
| 32 |
+
.gk-scroll { scrollbar-width: thin; scrollbar-color: #4b5563 transparent; }
|
| 33 |
+
.gk-scroll::-webkit-scrollbar { width: 8px; }
|
| 34 |
+
.gk-scroll::-webkit-scrollbar-track { background: transparent; }
|
| 35 |
+
.gk-scroll::-webkit-scrollbar-thumb { background:#4b5563; border-radius:4px; }
|
| 36 |
+
.gk-scroll::-webkit-scrollbar-thumb:hover { background:#6b7280; }
|
| 37 |
</style>
|
| 38 |
</head>
|
| 39 |
<body class="bg-ink-900 text-gray-100 min-h-screen" x-data="rrApp()" x-init="init()" x-cloak>
|
|
|
|
| 299 |
<h2 class="text-lg font-semibold text-racing-500">Top equipos (<span x-text="tvResult?.pool_size"></span> en el pool)</h2>
|
| 300 |
<p class="text-xs text-yellow-200" x-show="tvResult?.warning" x-text="tvResult?.warning"></p>
|
| 301 |
<template x-for="(t, i) in (tvResult?.teams || [])" :key="t.equipo">
|
| 302 |
+
<div class="bg-ink-700 border border-ink-600 rounded-2xl p-4" x-data="{ open: false }">
|
| 303 |
+
<div class="flex items-center justify-between cursor-pointer select-none" @click="open=!open">
|
| 304 |
+
<div class="font-semibold flex items-center gap-2">
|
| 305 |
+
<span class="text-gray-400 text-xs transition-transform" :class="open ? 'rotate-90' : ''">▶</span>
|
| 306 |
+
<span class="text-racing-500" x-text="'#'+(i+1)"></span> <span x-text="t.equipo"></span>
|
| 307 |
<span class="text-xs text-gray-400" x-text="t.competencia+' · '+t.temporada+' · '+t.partidos+' partidos'"></span></div>
|
| 308 |
<div class="font-bold" :class="t.total>=0 ? 'text-racing-500' : 'text-red-300'" x-text="'Puntaje '+(t.total>=0?'+':'')+t.total"></div>
|
| 309 |
</div>
|
| 310 |
+
<table class="w-full text-xs mt-2" x-show="open" x-cloak>
|
| 311 |
<thead><tr class="text-gray-400 text-left">
|
| 312 |
<th class="py-1">Variable</th><th>Dir.</th><th class="text-right">Valor</th><th class="text-right">z</th><th class="text-right">Puntaje</th>
|
| 313 |
</tr></thead>
|
|
|
|
| 325 |
</table>
|
| 326 |
</div>
|
| 327 |
</template>
|
| 328 |
+
<div x-show="tvResult?.radar_b64" class="bg-ink-700 border border-ink-600 rounded-2xl p-4">
|
| 329 |
+
<div class="text-sm font-semibold text-racing-500 mb-2">Radar comparativo del top</div>
|
| 330 |
+
<img x-show="tvResult?.radar_b64" :src="'data:image/png;base64,'+tvResult?.radar_b64" alt="Radar top equipos" class="w-full max-w-2xl mx-auto rounded-lg">
|
| 331 |
+
</div>
|
| 332 |
</section>
|
| 333 |
|
| 334 |
<!-- ---- Perfil de un equipo (z-score) ---- -->
|
|
|
|
| 746 |
</template>
|
| 747 |
</div>
|
| 748 |
</section>
|
| 749 |
+
|
| 750 |
+
<!-- ---- Perfiles de saque de puerta (nube + radar + similares) ---- -->
|
| 751 |
+
<section class="bg-ink-700 border border-ink-600 rounded-2xl p-5" x-init="gkLoad()">
|
| 752 |
+
<h2 class="text-lg font-semibold text-racing-500 mb-1">6. Perfiles de saque de puerta</h2>
|
| 753 |
+
<p class="text-xs text-gray-400 mb-3">Cada punto es un equipo-temporada, ubicado por su perfil de saque de arco (21 métricas sobre las secuencias que arrancan en saque). Click en un punto para abrir su perfil abajo. La similitud es la distancia entre perfiles sobre los percentiles: 100% = perfil idéntico, 0% = tan distinto como los pares más opuestos que existen. Desclickeá métricas para sacarlas del cálculo.</p>
|
| 754 |
+
|
| 755 |
+
<div class="flex flex-wrap gap-3 items-end mb-3">
|
| 756 |
+
<div>
|
| 757 |
+
<label class="block text-xs text-gray-400 mb-1">Equipo focal</label>
|
| 758 |
+
<input x-model="gkBuscar" @change="gkPick(gkBuscar)" list="gk-teams" placeholder="Buscar equipo..."
|
| 759 |
+
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm w-64">
|
| 760 |
+
<datalist id="gk-teams">
|
| 761 |
+
<template x-for="t in gkTeams" :key="t.key"><option :value="t.key" x-text="t.liga"></option></template>
|
| 762 |
+
</datalist>
|
| 763 |
+
</div>
|
| 764 |
+
<div>
|
| 765 |
+
<label class="block text-xs text-gray-400 mb-1">Colorear por</label>
|
| 766 |
+
<select x-model="gkColor" @change="gkDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 767 |
+
<option value="cluster">Cluster de estilo</option>
|
| 768 |
+
<option value="liga">Liga</option>
|
| 769 |
+
<option value="none">Sin color</option>
|
| 770 |
+
</select>
|
| 771 |
+
</div>
|
| 772 |
+
<div>
|
| 773 |
+
<label class="block text-xs text-gray-400 mb-1">Resaltar liga</label>
|
| 774 |
+
<select x-model="gkLiga" @change="gkDraw()" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 775 |
+
<option value="">— todas —</option>
|
| 776 |
+
<template x-for="l in gkLigas" :key="l"><option :value="l" x-text="l"></option></template>
|
| 777 |
+
</select>
|
| 778 |
+
</div>
|
| 779 |
+
<button @click="gkRandom()" class="bg-ink-600 hover:bg-ink-500 px-4 py-2 rounded-lg text-sm">Equipo al azar</button>
|
| 780 |
+
<button @click="gkShowFilters=!gkShowFilters"
|
| 781 |
+
:class="gkNFilters() ? 'border-racing-500 text-racing-500' : 'border-ink-600 text-gray-300'"
|
| 782 |
+
class="border bg-ink-800 hover:bg-ink-600 px-4 py-2 rounded-lg text-sm"
|
| 783 |
+
x-text="(gkShowFilters?'Ocultar':'Filtrar por métrica')+(gkNFilters()?` (${gkNFilters()})`:'')"></button>
|
| 784 |
+
<span class="text-xs text-gray-500" x-show="gkTeams.length" x-text="`${gkTeams.length} equipos-temporada · ${gkLigas.length} ligas`"></span>
|
| 785 |
+
</div>
|
| 786 |
+
|
| 787 |
+
<p class="text-xs text-yellow-200 mb-2" x-show="gkError" x-text="gkError"></p>
|
| 788 |
+
|
| 789 |
+
<!-- filtro por percentil: marca a los que cumplen, no oculta al resto -->
|
| 790 |
+
<div x-show="gkShowFilters" class="bg-ink-800 border border-ink-600 rounded-xl p-4 mb-4">
|
| 791 |
+
<div class="flex items-start justify-between mb-1">
|
| 792 |
+
<div>
|
| 793 |
+
<h3 class="font-semibold text-sm">Filtrar por métrica</h3>
|
| 794 |
+
<p class="text-xs text-gray-400">Poné un rango de percentil en las métricas que quieras: los equipos que cumplan <strong>todos</strong> los rangos quedan marcados en la nube con un anillo celeste. Dejar una métrica en 0–100 es ignorarla. No se oculta a nadie.</p>
|
| 795 |
+
</div>
|
| 796 |
+
<button @click="gkClearFilters()" class="text-xs text-gray-400 hover:underline whitespace-nowrap ml-3">Limpiar filtros</button>
|
| 797 |
+
</div>
|
| 798 |
+
<p class="text-xs mb-3" :class="gkNFilters() ? 'text-cyan-300' : 'text-gray-500'"
|
| 799 |
+
x-text="gkNFilters() ? `${gkMatches.length} equipos cumplen los ${gkNFilters()} filtros` : 'Sin filtros activos'"></p>
|
| 800 |
+
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
| 801 |
+
<template x-for="m in gkMetrics" :key="'f'+m">
|
| 802 |
+
<div class="rounded-lg border p-3"
|
| 803 |
+
:class="gkFilters[m] ? 'border-racing-500/50 bg-racing-500/5' : 'border-ink-600 bg-ink-900/40'">
|
| 804 |
+
<div class="flex items-center justify-between gap-2 mb-2">
|
| 805 |
+
<span class="text-xs truncate" x-text="gkLabels[m]||m"></span>
|
| 806 |
+
<span class="flex items-center gap-1 whitespace-nowrap">
|
| 807 |
+
<span class="text-xs font-mono" :class="gkFilters[m]?'text-racing-500':'text-gray-500'"
|
| 808 |
+
x-text="`${gkRange(m)[0]}–${gkRange(m)[1]}`"></span>
|
| 809 |
+
<button x-show="gkFilters[m]" @click="gkClearOne(m)" class="text-xs text-gray-400 hover:text-gray-100">✕</button>
|
| 810 |
+
</span>
|
| 811 |
+
</div>
|
| 812 |
+
<div class="rng">
|
| 813 |
+
<div class="track"></div>
|
| 814 |
+
<div class="fill" :style="`left:${gkRange(m)[0]}%;width:${gkRange(m)[1]-gkRange(m)[0]}%`"></div>
|
| 815 |
+
<input type="range" min="0" max="100" :value="gkRange(m)[0]"
|
| 816 |
+
@input="gkSetRange(m, +$event.target.value, null)">
|
| 817 |
+
<input type="range" min="0" max="100" :value="gkRange(m)[1]"
|
| 818 |
+
@input="gkSetRange(m, null, +$event.target.value)">
|
| 819 |
+
</div>
|
| 820 |
+
</div>
|
| 821 |
+
</template>
|
| 822 |
+
</div>
|
| 823 |
+
</div>
|
| 824 |
+
|
| 825 |
+
<div class="relative bg-ink-800 border border-ink-600 rounded-xl p-2 mb-2" x-ref="gkWrap">
|
| 826 |
+
<svg x-ref="gkSvg" viewBox="0 0 980 460" class="w-full"></svg>
|
| 827 |
+
<div x-ref="gkTip" class="absolute hidden pointer-events-none bg-ink-900 border border-ink-600 rounded-lg px-3 py-2 text-xs shadow-xl" style="display:none"></div>
|
| 828 |
+
</div>
|
| 829 |
+
<div class="flex flex-wrap gap-4 text-xs text-gray-400 mb-4">
|
| 830 |
+
<span><span class="inline-block w-3 h-3 rounded-full mr-1 align-middle" style="background:#f59e0b"></span>Equipo focal</span>
|
| 831 |
+
<span><span class="inline-block w-3 h-3 rounded-full mr-1 align-middle" style="background:#ef4444"></span>Top 20 más similares</span>
|
| 832 |
+
<span x-show="gkNFilters()"><span class="inline-block w-3 h-3 rounded-full mr-1 align-middle" style="background:transparent;border:2px solid #22d3ee"></span>Cumple los filtros</span>
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
<!-- métricas activables -->
|
| 836 |
+
<div class="mb-4">
|
| 837 |
+
<div class="flex items-center justify-between mb-2">
|
| 838 |
+
<h3 class="font-semibold text-sm">Métricas en el cálculo de similitud</h3>
|
| 839 |
+
<div class="flex gap-2">
|
| 840 |
+
<button @click="gkOff={}; gkSim()" class="text-xs text-racing-500 hover:underline">Todas</button>
|
| 841 |
+
<button @click="gkNone()" class="text-xs text-gray-400 hover:underline">Ninguna</button>
|
| 842 |
+
</div>
|
| 843 |
+
</div>
|
| 844 |
+
<div class="flex flex-wrap gap-2">
|
| 845 |
+
<template x-for="m in gkMetrics" :key="m">
|
| 846 |
+
<button @click="gkToggle(m)"
|
| 847 |
+
:class="gkOff[m] ? 'bg-ink-800 text-gray-500 border-ink-600 line-through' : 'bg-racing-500/15 text-racing-500 border-racing-500/40'"
|
| 848 |
+
class="border rounded-full px-3 py-1 text-xs"
|
| 849 |
+
x-text="gkLabels[m]||m"></button>
|
| 850 |
+
</template>
|
| 851 |
+
</div>
|
| 852 |
+
</div>
|
| 853 |
+
|
| 854 |
+
<div class="grid md:grid-cols-2 gap-4" x-show="gkFocal">
|
| 855 |
+
<!-- radar del equipo focal -->
|
| 856 |
+
<div class="bg-ink-800 border border-ink-600 rounded-xl p-4">
|
| 857 |
+
<div class="flex items-baseline justify-between mb-1">
|
| 858 |
+
<h3 class="font-semibold" x-text="gkFocal ? gkFocal.team : ''"></h3>
|
| 859 |
+
<span class="text-xs text-gray-400" x-text="gkFocal ? `${gkFocal.liga} ${gkFocal.temporada} · ${gkFocal.n_saques} saques` : ''"></span>
|
| 860 |
+
</div>
|
| 861 |
+
<svg x-ref="gkRadar" viewBox="0 0 420 420" class="w-full"></svg>
|
| 862 |
+
<div class="flex flex-wrap gap-3 text-xs text-gray-400 mt-1">
|
| 863 |
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style="background:#22c55e"></span>Elite (top 10%)</span>
|
| 864 |
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style="background:#86efac"></span>Sobre la media</span>
|
| 865 |
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style="background:#fbbf24"></span>Media</span>
|
| 866 |
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style="background:#f87171"></span>Bajo la media</span>
|
| 867 |
+
</div>
|
| 868 |
+
</div>
|
| 869 |
+
|
| 870 |
+
<!-- similares -->
|
| 871 |
+
<div class="bg-ink-800 border border-ink-600 rounded-xl p-4">
|
| 872 |
+
<h3 class="font-semibold mb-1">Equipos más similares</h3>
|
| 873 |
+
<p class="text-xs text-gray-400 mb-3" x-text="`Distancia sobre ${gkMetrics.length - Object.keys(gkOff).length} métricas. Click para cambiar el equipo focal.`"></p>
|
| 874 |
+
<div class="space-y-1 max-h-[420px] overflow-y-auto gk-scroll pr-1">
|
| 875 |
+
<template x-for="(s,i) in gkSimilar" :key="s.key">
|
| 876 |
+
<button @click="gkPick(s.key)" class="w-full flex items-center gap-3 px-2 py-1.5 rounded-lg hover:bg-ink-700 text-left">
|
| 877 |
+
<span class="w-6 text-xs text-gray-500" x-text="i+1"></span>
|
| 878 |
+
<span class="flex-1 min-w-0">
|
| 879 |
+
<span class="block text-sm truncate" x-text="`${s.team} (${s.temporada})`"></span>
|
| 880 |
+
<span class="block text-xs text-gray-500 truncate" x-text="`${s.liga} · ${s.n_saques} saques`"></span>
|
| 881 |
+
</span>
|
| 882 |
+
<span class="text-sm font-semibold" :style="`color:${gkSimColor(s.sim)}`" x-text="s.sim.toFixed(1)+'%'"></span>
|
| 883 |
+
</button>
|
| 884 |
+
</template>
|
| 885 |
+
</div>
|
| 886 |
+
</div>
|
| 887 |
+
</div>
|
| 888 |
+
</section>
|
| 889 |
</div>
|
| 890 |
</template>
|
| 891 |
|
|
|
|
| 1014 |
lbPool: [], lbPoolLigaIn:'', lbPoolTeamIn:'', lbEscala:'abs', lbAllKeys: [],
|
| 1015 |
lbBubOn: false, lbB: {lado:'of', recurso:'todos', metrica:'n_intentos'},
|
| 1016 |
lbSeasons: {}, lbtSeason: '',
|
| 1017 |
+
// Perfiles de saque de puerta (nube global + radar + similares)
|
| 1018 |
+
gkTeams: [], gkMetrics: [], gkLabels: {}, gkLigas: [], gkOff: {},
|
| 1019 |
+
gkFocal: null, gkSimilar: [], gkColor: 'cluster', gkLiga: '', gkBuscar: '',
|
| 1020 |
+
gkError: '', gkLoaded: false,
|
| 1021 |
+
gkShowFilters: false, gkFilters: {}, gkMatches: [],
|
| 1022 |
|
| 1023 |
async init() { await this.loadLeagues(); await this.loadSeasons(false); await this.loadTeams(); this._refreshHealth(); },
|
| 1024 |
async _getJSON(u){ const r=await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail); return r.json(); },
|
|
|
|
| 1432 |
this.lbtResult = await this._getJSON(`/api/lowblock/team?league=${encodeURIComponent(this.lbtLeague)}&equipo=${encodeURIComponent(this.lbtEquipo)}&season=${encodeURIComponent(this.lbtSeason)}`);
|
| 1433 |
} catch(e){ this.lbtError = 'Error: '+e; }
|
| 1434 |
},
|
| 1435 |
+
|
| 1436 |
+
// ── Perfiles de saque de puerta ────────────────────���──────────────────
|
| 1437 |
+
async gkLoad(){
|
| 1438 |
+
if (this.gkLoaded) return;
|
| 1439 |
+
try {
|
| 1440 |
+
const d = await this._getJSON('/api/goalkicks/cloud');
|
| 1441 |
+
this.gkTeams=d.teams; this.gkMetrics=d.metrics; this.gkLabels=d.labels; this.gkLigas=d.ligas;
|
| 1442 |
+
this.gkLoaded=true;
|
| 1443 |
+
const racing = this.gkTeams.find(t=>t.team==='Racing de Santander');
|
| 1444 |
+
this.$nextTick(()=>{ this.gkDraw(); if (racing) this.gkPick(racing.key); });
|
| 1445 |
+
} catch(e){ this.gkError='No hay artefactos de saque de puerta: '+e; }
|
| 1446 |
+
},
|
| 1447 |
+
gkActive(){ return this.gkMetrics.filter(m=>!this.gkOff[m]); },
|
| 1448 |
+
gkToggle(m){ if (this.gkOff[m]) delete this.gkOff[m]; else this.gkOff[m]=true; this.gkOff={...this.gkOff}; this.gkSim(); },
|
| 1449 |
+
gkNone(){ const o={}; this.gkMetrics.forEach(m=>o[m]=true); this.gkOff=o; this.gkSimilar=[]; },
|
| 1450 |
+
// ── filtro por rango de percentil ──
|
| 1451 |
+
gkRange(m){ return this.gkFilters[m] || [0,100]; },
|
| 1452 |
+
gkNFilters(){ return Object.keys(this.gkFilters).length; },
|
| 1453 |
+
gkSetRange(m, lo, hi){
|
| 1454 |
+
const cur=this.gkRange(m);
|
| 1455 |
+
let a = lo===null ? cur[0] : lo, b = hi===null ? cur[1] : hi;
|
| 1456 |
+
if (a>b){ if(lo===null) a=b; else b=a; } // no cruzar los thumbs
|
| 1457 |
+
if (a===0 && b===100) delete this.gkFilters[m]; // 0-100 = ignorar
|
| 1458 |
+
else this.gkFilters[m]=[a,b];
|
| 1459 |
+
this.gkFilters={...this.gkFilters};
|
| 1460 |
+
this.gkApplyFilters();
|
| 1461 |
+
},
|
| 1462 |
+
gkClearOne(m){ delete this.gkFilters[m]; this.gkFilters={...this.gkFilters}; this.gkApplyFilters(); },
|
| 1463 |
+
gkClearFilters(){ this.gkFilters={}; this.gkApplyFilters(); },
|
| 1464 |
+
gkApplyFilters(){
|
| 1465 |
+
const fs=Object.entries(this.gkFilters);
|
| 1466 |
+
this.gkMatches = !fs.length ? [] :
|
| 1467 |
+
this.gkTeams.filter(t=>fs.every(([m,[a,b]])=>{ const v=t.p[m]; return v!=null && v>=a && v<=b; })).map(t=>t.key);
|
| 1468 |
+
this.gkDraw();
|
| 1469 |
+
},
|
| 1470 |
+
gkRandom(){ if(!this.gkTeams.length) return; this.gkPick(this.gkTeams[Math.floor(Math.random()*this.gkTeams.length)].key); },
|
| 1471 |
+
gkSimColor(v){ return v>=85?'#22c55e' : v>=70?'#86efac' : v>=50?'#fbbf24' : '#f87171'; },
|
| 1472 |
+
async gkPick(key){
|
| 1473 |
+
const t = this.gkTeams.find(x=>x.key===key);
|
| 1474 |
+
if (!t) return;
|
| 1475 |
+
this.gkFocal=t; this.gkBuscar=key;
|
| 1476 |
+
await this.gkSim();
|
| 1477 |
+
this.$nextTick(()=>{ this.gkDraw(); this.gkRadar(); });
|
| 1478 |
+
},
|
| 1479 |
+
async gkSim(){
|
| 1480 |
+
if (!this.gkFocal) return;
|
| 1481 |
+
const act=this.gkActive();
|
| 1482 |
+
if (!act.length){ this.gkSimilar=[]; return; }
|
| 1483 |
+
try {
|
| 1484 |
+
const d = await this._getJSON(`/api/goalkicks/similar?team=${encodeURIComponent(this.gkFocal.key)}&metrics=${encodeURIComponent(act.join(','))}&k=20`);
|
| 1485 |
+
this.gkSimilar=d.similar;
|
| 1486 |
+
this.$nextTick(()=>this.gkDraw());
|
| 1487 |
+
} catch(e){ this.gkError='Error en similitud: '+e; }
|
| 1488 |
+
},
|
| 1489 |
+
gkDraw(){
|
| 1490 |
+
const svg=this.$refs.gkSvg, wrap=this.$refs.gkWrap, tip=this.$refs.gkTip;
|
| 1491 |
+
if (!svg || !this.gkTeams.length) return;
|
| 1492 |
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
| 1493 |
+
const NS='http://www.w3.org/2000/svg', W=980, H=460, M=14;
|
| 1494 |
+
const xs=this.gkTeams.map(t=>t.x), ys=this.gkTeams.map(t=>t.y);
|
| 1495 |
+
const pad=(a,b)=>{const d=(b-a)||1; return [a-d*0.05, b+d*0.05];};
|
| 1496 |
+
const [x0,x1]=pad(Math.min(...xs),Math.max(...xs)), [y0,y1]=pad(Math.min(...ys),Math.max(...ys));
|
| 1497 |
+
const X=v=>M+(v-x0)/(x1-x0)*(W-2*M), Y=v=>H-M-(v-y0)/(y1-y0)*(H-2*M);
|
| 1498 |
+
const el=(tag,attrs)=>{const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);svg.appendChild(e);return e;};
|
| 1499 |
+
// 8 tonos para clusters/ligas
|
| 1500 |
+
const PAL=['#60a5fa','#f472b6','#34d399','#fbbf24','#a78bfa','#fb923c','#22d3ee','#f87171'];
|
| 1501 |
+
const ligaIdx={}; this.gkLigas.forEach((l,i)=>ligaIdx[l]=i);
|
| 1502 |
+
const simBy={}; this.gkSimilar.forEach(s=>simBy[s.key]=s.sim);
|
| 1503 |
+
const matchSet=new Set(this.gkMatches);
|
| 1504 |
+
const focal=this.gkFocal;
|
| 1505 |
+
for (const t of this.gkTeams){
|
| 1506 |
+
const isFocal = focal && t.key===focal.key;
|
| 1507 |
+
const isSim = simBy[t.key]!==undefined;
|
| 1508 |
+
const isMatch = matchSet.has(t.key);
|
| 1509 |
+
const dim = this.gkLiga && t.liga!==this.gkLiga;
|
| 1510 |
+
let fill='#9ca3af';
|
| 1511 |
+
if (this.gkColor==='cluster') fill=PAL[t.cluster%PAL.length];
|
| 1512 |
+
else if (this.gkColor==='liga') fill=PAL[(ligaIdx[t.liga]||0)%PAL.length];
|
| 1513 |
+
if (isSim) fill='#ef4444';
|
| 1514 |
+
if (isFocal) fill='#f59e0b';
|
| 1515 |
+
// el anillo celeste del filtro convive con el color de similar/cluster
|
| 1516 |
+
const stroke = isMatch ? '#22d3ee' : (isFocal ? '#111827' : 'none');
|
| 1517 |
+
const sw = isMatch ? 2.5 : (isFocal ? 2 : 0);
|
| 1518 |
+
const c=el('circle',{cx:X(t.x),cy:Y(t.y),r:isFocal?9:(isSim||isMatch?5.5:3.5),fill,
|
| 1519 |
+
'fill-opacity':dim?0.12:(isFocal||isSim||isMatch?1:0.55),
|
| 1520 |
+
stroke, 'stroke-width':sw});
|
| 1521 |
+
c.style.cursor='pointer';
|
| 1522 |
+
c.addEventListener('mousemove',ev=>{
|
| 1523 |
+
const rct=wrap.getBoundingClientRect();
|
| 1524 |
+
tip.style.display='block';
|
| 1525 |
+
tip.style.left=Math.min(ev.clientX-rct.left+14,rct.width-240)+'px';
|
| 1526 |
+
tip.style.top=(ev.clientY-rct.top+10)+'px';
|
| 1527 |
+
const s = simBy[t.key]!==undefined ? `<div class="text-racing-500">${simBy[t.key].toFixed(1)}% similar</div>` : '';
|
| 1528 |
+
const f = isMatch ? `<div class="text-cyan-300">cumple los filtros</div>` : '';
|
| 1529 |
+
tip.innerHTML=`<div class="font-semibold">${t.team}</div><div class="text-gray-400">${t.liga} ${t.temporada}</div><div class="text-gray-500">${t.n_saques} saques</div>${s}${f}`;
|
| 1530 |
+
});
|
| 1531 |
+
c.addEventListener('mouseleave',()=>{ tip.style.display='none'; });
|
| 1532 |
+
c.addEventListener('click',()=>this.gkPick(t.key));
|
| 1533 |
+
}
|
| 1534 |
+
if (focal){
|
| 1535 |
+
const l=el('text',{x:X(focal.x)+12,y:Y(focal.y)+4,'font-size':'12','font-weight':'700',fill:'#fbbf24'});
|
| 1536 |
+
l.textContent=focal.team;
|
| 1537 |
+
}
|
| 1538 |
+
},
|
| 1539 |
+
gkRadar(){
|
| 1540 |
+
const svg=this.$refs.gkRadar;
|
| 1541 |
+
if (!svg || !this.gkFocal) return;
|
| 1542 |
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
| 1543 |
+
const NS='http://www.w3.org/2000/svg', C=210, R0=42, R1=175;
|
| 1544 |
+
const el=(tag,attrs)=>{const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);svg.appendChild(e);return e;};
|
| 1545 |
+
const ms=this.gkMetrics, n=ms.length, step=2*Math.PI/n;
|
| 1546 |
+
const col=p=>p>=90?'#22c55e':p>=65?'#86efac':p>=35?'#fbbf24':'#f87171';
|
| 1547 |
+
// anillos de referencia
|
| 1548 |
+
[0.25,0.5,0.75,1].forEach(f=>el('circle',{cx:C,cy:C,r:R0+(R1-R0)*f,fill:'none',stroke:'#1f2937','stroke-width':1}));
|
| 1549 |
+
ms.forEach((m,i)=>{
|
| 1550 |
+
const p=this.gkFocal.p[m]??0;
|
| 1551 |
+
const a0=i*step-Math.PI/2+0.04, a1=(i+1)*step-Math.PI/2-0.04;
|
| 1552 |
+
const r=R0+(R1-R0)*(p/100);
|
| 1553 |
+
const pt=(rad,ang)=>[C+rad*Math.cos(ang), C+rad*Math.sin(ang)];
|
| 1554 |
+
const [ax,ay]=pt(R0,a0),[bx,by]=pt(r,a0),[cx,cy]=pt(r,a1),[dx,dy]=pt(R0,a1);
|
| 1555 |
+
const off=this.gkOff[m];
|
| 1556 |
+
el('path',{d:`M${ax},${ay} L${bx},${by} A${r},${r} 0 0 1 ${cx},${cy} L${dx},${dy} A${R0},${R0} 0 0 0 ${ax},${ay} Z`,
|
| 1557 |
+
fill:off?'#374151':col(p),'fill-opacity':off?0.35:0.85,stroke:'#111827','stroke-width':1});
|
| 1558 |
+
// valor
|
| 1559 |
+
const [tx,ty]=pt(r-13,(a0+a1)/2);
|
| 1560 |
+
const v=el('text',{x:tx,y:ty+3,'font-size':'9','text-anchor':'middle',fill:'#0b1220','font-weight':'700'});
|
| 1561 |
+
v.textContent=Math.round(p);
|
| 1562 |
+
// label
|
| 1563 |
+
const [lx,ly]=pt(R1+16,(a0+a1)/2);
|
| 1564 |
+
const mid=(a0+a1)/2, anchor=Math.cos(mid)>0.25?'start':(Math.cos(mid)<-0.25?'end':'middle');
|
| 1565 |
+
const t=el('text',{x:lx,y:ly+3,'font-size':'8','text-anchor':anchor,fill:off?'#4b5563':'#9ca3af'});
|
| 1566 |
+
t.textContent=(this.gkLabels[m]||m).replace(/^% /,'').slice(0,26);
|
| 1567 |
+
});
|
| 1568 |
+
},
|
| 1569 |
async ejRunPost(){
|
| 1570 |
this.ejPostMsg=''; this.ejPostMsgError=false;
|
| 1571 |
if (Object.keys(this.ejPartidos).length && !this.ejPartidos[this.league]){ this.ejPostMsg='Esta liga no tiene artefactos de ejes.'; this.ejPostMsgError=true; return; }
|