Spaces:
Running
Running
Commit ·
8433548
1
Parent(s): dc37fa3
Equipos y variables: pool por liga-temporada, similitud %, autocompletar variables
Browse files- Top equipos: pool por liga-temporada (chips) + selector de N (5/10/20/30).
- Similares: mismo pool por liga-temporada; el score ahora es similitud 0-100%
(exp(-dist/sqrt(#vars))) en vez de distancia.
- Variables: validación al escribir — solo permite variables existentes, autocompleta
a la más parecida o limpia con aviso.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
src/racing_reports/api/routes_team_vars.py
CHANGED
|
@@ -21,6 +21,7 @@ class SearchRequest(BaseModel):
|
|
| 21 |
season: str
|
| 22 |
specs: list[VarSpec]
|
| 23 |
top: int = 5
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class ProfileRequest(BaseModel):
|
|
@@ -41,6 +42,7 @@ class SimilarRequest(BaseModel):
|
|
| 41 |
scope: str = "all" # "all" (todas las ligas/temporadas) | "liga"
|
| 42 |
top: int = 8
|
| 43 |
leagues: list[str] = [] # pool de ligas elegidas (prioridad sobre scope)
|
|
|
|
| 44 |
z_mode: str = "pool" # "pool" (la selección) | "todas" | "liga" (cada uno vs su liga)
|
| 45 |
|
| 46 |
|
|
@@ -61,12 +63,13 @@ def search(req: SearchRequest) -> dict:
|
|
| 61 |
bad = [s.direction for s in req.specs if s.direction not in team_vars.DIRECTIONS]
|
| 62 |
if bad:
|
| 63 |
raise HTTPException(status_code=400, detail=f"Dirección inválida: {bad}")
|
| 64 |
-
top = min(max(req.top, 1),
|
| 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,
|
|
|
|
| 70 |
except Exception:
|
| 71 |
res["radar_b64"] = None
|
| 72 |
return res
|
|
@@ -96,4 +99,5 @@ def similar(req: SimilarRequest) -> dict:
|
|
| 96 |
z_mode = req.z_mode if req.z_mode in ("pool", "todas", "liga") else "pool"
|
| 97 |
top = min(max(req.top, 1), 30)
|
| 98 |
return team_vars.similar_teams(req.league, req.season, req.team, req.variables, scope,
|
| 99 |
-
top=top, leagues=req.leagues or None, z_mode=z_mode
|
|
|
|
|
|
| 21 |
season: str
|
| 22 |
specs: list[VarSpec]
|
| 23 |
top: int = 5
|
| 24 |
+
pool: list[str] = [] # pool por liga-temporada ['Liga|Temp', ...] (prioridad)
|
| 25 |
|
| 26 |
|
| 27 |
class ProfileRequest(BaseModel):
|
|
|
|
| 42 |
scope: str = "all" # "all" (todas las ligas/temporadas) | "liga"
|
| 43 |
top: int = 8
|
| 44 |
leagues: list[str] = [] # pool de ligas elegidas (prioridad sobre scope)
|
| 45 |
+
pool: list[str] = [] # pool por liga-temporada ['Liga|Temp', ...] (máxima prioridad)
|
| 46 |
z_mode: str = "pool" # "pool" (la selección) | "todas" | "liga" (cada uno vs su liga)
|
| 47 |
|
| 48 |
|
|
|
|
| 63 |
bad = [s.direction for s in req.specs if s.direction not in team_vars.DIRECTIONS]
|
| 64 |
if bad:
|
| 65 |
raise HTTPException(status_code=400, detail=f"Dirección inválida: {bad}")
|
| 66 |
+
top = min(max(req.top, 1), 30)
|
| 67 |
specs = [s.model_dump() for s in req.specs]
|
| 68 |
+
res = team_vars.search(req.league, req.season, specs, top=top, pool_pairs=req.pool or None)
|
| 69 |
# radar comparando el top (hasta 6 para que sea legible); best-effort
|
| 70 |
try:
|
| 71 |
+
res["radar_b64"] = team_vars.search_radar_png(req.league, req.season, specs,
|
| 72 |
+
top=min(top, 6), pool_pairs=req.pool or None)
|
| 73 |
except Exception:
|
| 74 |
res["radar_b64"] = None
|
| 75 |
return res
|
|
|
|
| 99 |
z_mode = req.z_mode if req.z_mode in ("pool", "todas", "liga") else "pool"
|
| 100 |
top = min(max(req.top, 1), 30)
|
| 101 |
return team_vars.similar_teams(req.league, req.season, req.team, req.variables, scope,
|
| 102 |
+
top=top, leagues=req.leagues or None, z_mode=z_mode,
|
| 103 |
+
pool_pairs=req.pool or None)
|
src/racing_reports/team_vars.py
CHANGED
|
@@ -108,6 +108,30 @@ def _pool(league: str, season: str) -> pd.DataFrame:
|
|
| 108 |
return p[p["n_partidos"] >= MIN_PARTIDOS].copy()
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
def teams(league: str, season: str) -> list[str]:
|
| 112 |
"""Equipos de una liga+temporada (para el selector del perfil)."""
|
| 113 |
d = _df()
|
|
@@ -202,13 +226,15 @@ def _zmatrix_per_league(pool: pd.DataFrame, variables: list[str]) -> pd.DataFram
|
|
| 202 |
|
| 203 |
def similar_teams(league: str, season: str, team: str, variables: list[str],
|
| 204 |
scope: str = "all", top: int = 8,
|
| 205 |
-
leagues: list[str] | None = None, z_mode: str = "pool"
|
|
|
|
| 206 |
"""Equipos más parecidos por DISTANCIA EUCLÍDEA en el espacio de z-scores.
|
| 207 |
|
| 208 |
-
Pool: ``
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
| 212 |
"""
|
| 213 |
d = _df()
|
| 214 |
if d.empty:
|
|
@@ -219,7 +245,11 @@ def similar_teams(league: str, season: str, team: str, variables: list[str],
|
|
| 219 |
return {"teams": [], "error": f"No se encontró {team} en {league} {season}."}
|
| 220 |
row_idx = row.index[0]
|
| 221 |
row = row.iloc[0]
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
pool = d[(d["n_partidos"] >= MIN_PARTIDOS) & (d["competencia"].astype(str).isin([str(x) for x in leagues]))]
|
| 224 |
scope_label = "ligas: " + ", ".join(leagues)
|
| 225 |
elif scope == "all":
|
|
@@ -257,12 +287,17 @@ def similar_teams(league: str, season: str, team: str, variables: list[str],
|
|
| 257 |
& (pool["equipo"].astype(str) == str(team)))
|
| 258 |
dist = dist[~is_self.values]
|
| 259 |
order = dist.sort_values().head(top).index
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
teams = [{
|
| 261 |
"equipo": str(pool.loc[i, "equipo"]),
|
| 262 |
"competencia": str(pool.loc[i, "competencia"]),
|
| 263 |
"temporada": str(pool.loc[i, "temporada"]),
|
| 264 |
"partidos": int(pool.loc[i, "n_partidos"]),
|
| 265 |
"distancia": round(float(dist[i]), 2),
|
|
|
|
| 266 |
} for i in order]
|
| 267 |
return {"equipo": str(team), "competencia": str(league), "temporada": str(season),
|
| 268 |
"scope": scope_label,
|
|
@@ -341,7 +376,8 @@ 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_radar_png(league: str, season: str, specs: list[dict], top: int = 5
|
|
|
|
| 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."""
|
|
@@ -354,12 +390,13 @@ def search_radar_png(league: str, season: str, specs: list[dict], top: int = 5)
|
|
| 354 |
from matplotlib.lines import Line2D
|
| 355 |
from mplsoccer import Radar
|
| 356 |
|
| 357 |
-
|
|
|
|
| 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
|
|
@@ -400,11 +437,19 @@ def search_radar_png(league: str, season: str, specs: list[dict], top: int = 5)
|
|
| 400 |
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 401 |
|
| 402 |
|
| 403 |
-
def search(league: str, season: str, specs: list[dict], top: int = 5
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
if pool.empty:
|
| 409 |
return {"teams": [], "pool_size": 0,
|
| 410 |
"warning": f"Sin equipos con ≥{MIN_PARTIDOS} partidos en {lg_lbl} · {se_lbl}."}
|
|
|
|
| 108 |
return p[p["n_partidos"] >= MIN_PARTIDOS].copy()
|
| 109 |
|
| 110 |
|
| 111 |
+
def _parse_pairs(pool: list[str] | None) -> list[tuple[str, str]]:
|
| 112 |
+
"""['Liga|25-26', ...] -> [('Liga','25-26'), ...]. Ignora entradas mal formadas."""
|
| 113 |
+
out: list[tuple[str, str]] = []
|
| 114 |
+
for item in pool or []:
|
| 115 |
+
s = str(item)
|
| 116 |
+
if "|" in s:
|
| 117 |
+
lg, se = s.split("|", 1)
|
| 118 |
+
lg, se = lg.strip(), se.strip()
|
| 119 |
+
if lg and se:
|
| 120 |
+
out.append((lg, se))
|
| 121 |
+
return out
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _pool_from_pairs(pairs: list[tuple[str, str]]) -> pd.DataFrame:
|
| 125 |
+
"""Pool = unión de las (liga, temporada) elegidas (con ≥MIN_PARTIDOS)."""
|
| 126 |
+
d = _df()
|
| 127 |
+
if d.empty or not pairs:
|
| 128 |
+
return d.iloc[0:0] if not d.empty else d
|
| 129 |
+
keyset = {(lg, se) for lg, se in pairs}
|
| 130 |
+
key = list(zip(d["competencia"].astype(str), d["temporada"].astype(str)))
|
| 131 |
+
mask = pd.Series([k in keyset for k in key], index=d.index)
|
| 132 |
+
return d[mask & (d["n_partidos"] >= MIN_PARTIDOS)].copy()
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def teams(league: str, season: str) -> list[str]:
|
| 136 |
"""Equipos de una liga+temporada (para el selector del perfil)."""
|
| 137 |
d = _df()
|
|
|
|
| 226 |
|
| 227 |
def similar_teams(league: str, season: str, team: str, variables: list[str],
|
| 228 |
scope: str = "all", top: int = 8,
|
| 229 |
+
leagues: list[str] | None = None, z_mode: str = "pool",
|
| 230 |
+
pool_pairs: list[str] | None = None) -> dict:
|
| 231 |
"""Equipos más parecidos por DISTANCIA EUCLÍDEA en el espacio de z-scores.
|
| 232 |
|
| 233 |
+
Pool (por prioridad): ``pool_pairs`` (['Liga|Temp', ...]) > ``leagues`` (ligas,
|
| 234 |
+
todas sus temporadas) > ``scope='all'`` (todo) / ``'liga'`` (su liga-temporada).
|
| 235 |
+
Devuelve ``similitud`` en 0-100% (además de la distancia).
|
| 236 |
+
z_mode: ``'pool'`` = stats de la selección; ``'todas'`` = todas las ligas;
|
| 237 |
+
``'liga'`` = cada equipo estandarizado contra SU liga-temporada.
|
| 238 |
"""
|
| 239 |
d = _df()
|
| 240 |
if d.empty:
|
|
|
|
| 245 |
return {"teams": [], "error": f"No se encontró {team} en {league} {season}."}
|
| 246 |
row_idx = row.index[0]
|
| 247 |
row = row.iloc[0]
|
| 248 |
+
pairs = _parse_pairs(pool_pairs)
|
| 249 |
+
if pairs:
|
| 250 |
+
pool = _pool_from_pairs(pairs)
|
| 251 |
+
scope_label = ", ".join(f"{lg} {se}" for lg, se in pairs)
|
| 252 |
+
elif leagues:
|
| 253 |
pool = d[(d["n_partidos"] >= MIN_PARTIDOS) & (d["competencia"].astype(str).isin([str(x) for x in leagues]))]
|
| 254 |
scope_label = "ligas: " + ", ".join(leagues)
|
| 255 |
elif scope == "all":
|
|
|
|
| 287 |
& (pool["equipo"].astype(str) == str(team)))
|
| 288 |
dist = dist[~is_self.values]
|
| 289 |
order = dist.sort_values().head(top).index
|
| 290 |
+
# Similitud 0-100%: normaliza la distancia por variable (RMS de z) y la pasa por
|
| 291 |
+
# una exponencial. 100% = idéntico; ~1 desvío promedio por variable ≈ 37%.
|
| 292 |
+
n_vars = max(1, zmat.shape[1])
|
| 293 |
+
import math
|
| 294 |
teams = [{
|
| 295 |
"equipo": str(pool.loc[i, "equipo"]),
|
| 296 |
"competencia": str(pool.loc[i, "competencia"]),
|
| 297 |
"temporada": str(pool.loc[i, "temporada"]),
|
| 298 |
"partidos": int(pool.loc[i, "n_partidos"]),
|
| 299 |
"distancia": round(float(dist[i]), 2),
|
| 300 |
+
"similitud": round(100.0 * math.exp(-float(dist[i]) / (n_vars ** 0.5)), 1),
|
| 301 |
} for i in order]
|
| 302 |
return {"equipo": str(team), "competencia": str(league), "temporada": str(season),
|
| 303 |
"scope": scope_label,
|
|
|
|
| 376 |
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 377 |
|
| 378 |
|
| 379 |
+
def search_radar_png(league: str, season: str, specs: list[dict], top: int = 5,
|
| 380 |
+
pool_pairs: list[str] | None = None) -> str | None:
|
| 381 |
"""Radar comparando los TOP-N equipos de la búsqueda en las variables elegidas.
|
| 382 |
Cada eje = una variable (en z-score sobre el pool); un polígono por equipo.
|
| 383 |
Devuelve PNG base64 o None si hay <3 variables válidas o sin resultados."""
|
|
|
|
| 390 |
from matplotlib.lines import Line2D
|
| 391 |
from mplsoccer import Radar
|
| 392 |
|
| 393 |
+
pairs = _parse_pairs(pool_pairs)
|
| 394 |
+
pool = _pool_from_pairs(pairs) if pairs else _pool(league, season)
|
| 395 |
valid = [s for s in specs if s.get("variable") in pool.columns and s.get("direction") in DIRECTIONS]
|
| 396 |
variables = list(dict.fromkeys(s["variable"] for s in valid))
|
| 397 |
if pool.empty or len(variables) < 3:
|
| 398 |
return None
|
| 399 |
+
res = search(league, season, specs, top=top, pool_pairs=pool_pairs)
|
| 400 |
teams = res.get("teams", [])
|
| 401 |
if not teams:
|
| 402 |
return None
|
|
|
|
| 437 |
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 438 |
|
| 439 |
|
| 440 |
+
def search(league: str, season: str, specs: list[dict], top: int = 5,
|
| 441 |
+
pool_pairs: list[str] | None = None) -> dict:
|
| 442 |
+
"""specs = [{"variable", "direction"}]. Devuelve top-N equipos por puntaje.
|
| 443 |
+
``pool_pairs`` (['Liga|Temp', ...]) define el pool por liga-temporada; si no,
|
| 444 |
+
se usa ``league``/``season`` (o el sentinela ALL)."""
|
| 445 |
+
pairs = _parse_pairs(pool_pairs)
|
| 446 |
+
if pairs:
|
| 447 |
+
pool = _pool_from_pairs(pairs)
|
| 448 |
+
lg_lbl, se_lbl = ", ".join(f"{lg} {se}" for lg, se in pairs), ""
|
| 449 |
+
else:
|
| 450 |
+
pool = _pool(league, season)
|
| 451 |
+
lg_lbl = "todas las ligas" if (not league or league == ALL) else league
|
| 452 |
+
se_lbl = "todas las temporadas" if (not season or season == ALL) else season
|
| 453 |
if pool.empty:
|
| 454 |
return {"teams": [], "pool_size": 0,
|
| 455 |
"warning": f"Sin equipos con ≥{MIN_PARTIDOS} partidos en {lg_lbl} · {se_lbl}."}
|
src/racing_reports/web/templates/home.html
CHANGED
|
@@ -265,6 +265,31 @@
|
|
| 265 |
<datalist id="tv-vars-list">
|
| 266 |
<template x-for="v in tvVarLabels" :key="v"><option :value="v"></option></template>
|
| 267 |
</datalist>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
<div class="flex flex-wrap items-center gap-1 mb-2">
|
| 269 |
<span class="text-xs text-gray-400 mr-1">Agregar tipo:</span>
|
| 270 |
<template x-for="t in tvTypeList" :key="t">
|
|
@@ -275,7 +300,8 @@
|
|
| 275 |
<div class="space-y-2">
|
| 276 |
<template x-for="(sp, i) in tvSpecs" :key="i">
|
| 277 |
<div class="flex gap-2 items-center">
|
| 278 |
-
<input x-model="sp.variable" list="tv-vars-list"
|
|
|
|
| 279 |
class="flex-1 bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm" />
|
| 280 |
<select x-model="sp.direction" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 281 |
<option value="alto">Alto</option>
|
|
@@ -455,10 +481,9 @@
|
|
| 455 |
</select>
|
| 456 |
</div>
|
| 457 |
<div>
|
| 458 |
-
<label class="block text-xs text-gray-400 mb-1">…o
|
| 459 |
-
<input list="
|
| 460 |
-
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm w-
|
| 461 |
-
<datalist id="tvsligalist"><template x-for="l in tvLeagueList.filter(x=>x!=='__all__')" :key="'tvsl'+l"><option :value="l"></option></template></datalist>
|
| 462 |
</div>
|
| 463 |
<div>
|
| 464 |
<label class="block text-xs text-gray-400 mb-1">Z-score contra</label>
|
|
@@ -472,20 +497,18 @@
|
|
| 472 |
:class="tvsLoading ? 'opacity-40 cursor-not-allowed' : 'hover:bg-racing-600'"
|
| 473 |
class="bg-racing-500 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">Buscar similares</button>
|
| 474 |
</div>
|
| 475 |
-
<div class="flex flex-wrap gap-1 mt-2" x-show="
|
| 476 |
-
<template x-for="(
|
| 477 |
-
<span class="text-xs bg-ink-800 border border-ink-600 rounded-full px-2 py-1">
|
| 478 |
-
<
|
| 479 |
-
<button @click="tvsLigas.splice(i,1)" class="text-red-300 ml-1">×</button>
|
| 480 |
-
</span>
|
| 481 |
</template>
|
| 482 |
</div>
|
| 483 |
-
<p class="text-xs text-gray-500 mt-1" x-show="
|
| 484 |
<p class="text-xs text-red-300 mt-2" x-show="tvsError" x-text="tvsError"></p>
|
| 485 |
<div x-show="tvsResult && tvsResult.teams" class="mt-3">
|
| 486 |
<div class="text-xs text-gray-400 mb-1">Más parecidos a <span class="text-gray-200" x-text="tvsResult?.equipo"></span> · vs <span x-text="tvsResult?.scope"></span> (<span x-text="tvsResult?.pool_size"></span> equipos)</div>
|
| 487 |
<table class="w-full text-xs">
|
| 488 |
-
<thead><tr class="text-gray-400 text-left"><th class="py-1">#</th><th>Equipo</th><th>Liga</th><th>Temp.</th><th class="text-right">
|
| 489 |
<tbody>
|
| 490 |
<template x-for="(t, i) in (tvsResult?.teams || [])" :key="t.competencia+t.temporada+t.equipo">
|
| 491 |
<tr class="border-t border-ink-600">
|
|
@@ -493,7 +516,7 @@
|
|
| 493 |
<td x-text="t.equipo"></td>
|
| 494 |
<td class="text-gray-400" x-text="t.competencia"></td>
|
| 495 |
<td class="text-gray-400" x-text="t.temporada"></td>
|
| 496 |
-
<td class="text-right font-semibold" x-text="t.distancia"></td>
|
| 497 |
</tr>
|
| 498 |
</template>
|
| 499 |
</tbody>
|
|
@@ -992,6 +1015,8 @@
|
|
| 992 |
tvLeagues: {}, tvLeagueList: [], tvLeague: '__all__', tvSeason: '__all__', tvSeasons: [], tvAllSeasons: [],
|
| 993 |
tvVarLabels: [], tvLabelToCol: {}, tvColToLabel: {}, tvTypeCols: {}, tvTypeList: [],
|
| 994 |
tvSpecs: [{variable:'', direction:'alto'}], tvResult: null, tvLoading: false, tvError: '', tvLoaded: false,
|
|
|
|
|
|
|
| 995 |
// Perfil de un equipo (z-score)
|
| 996 |
tvpLeague:'', tvpSeason:'', tvpSeasons:[], tvpTeam:'', tvpTeams:[], tvpScope:'liga',
|
| 997 |
tvpVars:[''], tvpResult:null, tvpLoading:false, tvpError:'', tvpZmax:3, tvpShowMean:false, tvpShowBest:false,
|
|
@@ -1042,6 +1067,13 @@
|
|
| 1042 |
this.tvTypeCols = o.types || {}; this.tvTypeList = Object.keys(this.tvTypeCols);
|
| 1043 |
this.tvLeagueList = Object.keys(this.tvLeagues);
|
| 1044 |
this.tvAllSeasons = [...new Set([].concat(...Object.values(this.tvLeagues)))].sort().reverse();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
this.tvOnLeague();
|
| 1046 |
// panel de perfil: liga/temporada/equipo concretos por default
|
| 1047 |
this.tvpLeague = this.tvLeagueList.includes('Spanish Segunda Division') ? 'Spanish Segunda Division' : (this.tvLeagueList[0]||'');
|
|
@@ -1101,7 +1133,7 @@
|
|
| 1101 |
try {
|
| 1102 |
const r = await fetch('/api/team-vars/similar', {
|
| 1103 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1104 |
-
body: JSON.stringify({league:this.tvpLeague, season:this.tvpSeason, team:this.tvpTeam, variables:vars, scope:this.tvsScope, top:10, leagues:this.tvsLigas, z_mode:this.tvsZmode}),
|
| 1105 |
});
|
| 1106 |
if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
|
| 1107 |
const res = await r.json();
|
|
@@ -1142,16 +1174,32 @@
|
|
| 1142 |
if(!this.tvpVars.length) this.tvpVars.push('');
|
| 1143 |
},
|
| 1144 |
tvRemoveVar(i){ this.tvSpecs.splice(i,1); if(!this.tvSpecs.length) this.tvAddVar(); },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1145 |
async tvSearch(){
|
| 1146 |
this.tvError=''; this.tvResult=null;
|
| 1147 |
const specs = this.tvSpecs.filter(s=>s.variable).map(s=>({variable:(this.tvLabelToCol[s.variable]||s.variable), direction:s.direction}));
|
| 1148 |
if (!specs.length){ this.tvError='Elegí al menos una variable.'; return; }
|
| 1149 |
-
|
|
|
|
| 1150 |
this.tvLoading=true;
|
| 1151 |
try {
|
| 1152 |
const r = await fetch('/api/team-vars/search', {
|
| 1153 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1154 |
-
body: JSON.stringify({league:this.tvLeague, season:this.tvSeason, specs, top:
|
| 1155 |
});
|
| 1156 |
if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
|
| 1157 |
this.tvResult = await r.json();
|
|
|
|
| 265 |
<datalist id="tv-vars-list">
|
| 266 |
<template x-for="v in tvVarLabels" :key="v"><option :value="v"></option></template>
|
| 267 |
</datalist>
|
| 268 |
+
<datalist id="tv-ls-list">
|
| 269 |
+
<template x-for="o in tvLsOptions" :key="o.value"><option :value="o.label"></option></template>
|
| 270 |
+
</datalist>
|
| 271 |
+
<!-- Pool por liga-temporada -->
|
| 272 |
+
<div class="flex flex-wrap items-end gap-3 mb-2">
|
| 273 |
+
<div>
|
| 274 |
+
<label class="block text-xs text-gray-400 mb-1">Pool: elegí liga-temporada/s</label>
|
| 275 |
+
<input list="tv-ls-list" x-model="tvPoolIn" @change="tvAddPool()" placeholder="Ej: Spanish La Liga · 25-26"
|
| 276 |
+
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm w-72">
|
| 277 |
+
</div>
|
| 278 |
+
<div>
|
| 279 |
+
<label class="block text-xs text-gray-400 mb-1">Mostrar</label>
|
| 280 |
+
<select x-model.number="tvTop" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 281 |
+
<option :value="5">5 equipos</option><option :value="10">10 equipos</option>
|
| 282 |
+
<option :value="20">20 equipos</option><option :value="30">30 equipos</option>
|
| 283 |
+
</select>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
<div class="flex flex-wrap gap-1 mb-2" x-show="tvPool.length">
|
| 287 |
+
<template x-for="(p,i) in tvPool" :key="'tvpool'+p.value">
|
| 288 |
+
<span class="text-xs bg-ink-800 border border-ink-600 rounded-full px-2 py-1"><span x-text="p.label"></span>
|
| 289 |
+
<button @click="tvPool.splice(i,1)" class="text-red-300 ml-1">×</button></span>
|
| 290 |
+
</template>
|
| 291 |
+
</div>
|
| 292 |
+
<p class="text-xs text-gray-500 mb-2" x-show="tvPool.length">Con pool armado, el ranking sale de esas ligas-temporadas (se ignora el selector de arriba).</p>
|
| 293 |
<div class="flex flex-wrap items-center gap-1 mb-2">
|
| 294 |
<span class="text-xs text-gray-400 mr-1">Agregar tipo:</span>
|
| 295 |
<template x-for="t in tvTypeList" :key="t">
|
|
|
|
| 300 |
<div class="space-y-2">
|
| 301 |
<template x-for="(sp, i) in tvSpecs" :key="i">
|
| 302 |
<div class="flex gap-2 items-center">
|
| 303 |
+
<input x-model="sp.variable" list="tv-vars-list" @change="tvValidateVar(i)" @blur="tvValidateVar(i)"
|
| 304 |
+
placeholder="Escribí para buscar una variable…"
|
| 305 |
class="flex-1 bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm" />
|
| 306 |
<select x-model="sp.direction" class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm">
|
| 307 |
<option value="alto">Alto</option>
|
|
|
|
| 481 |
</select>
|
| 482 |
</div>
|
| 483 |
<div>
|
| 484 |
+
<label class="block text-xs text-gray-400 mb-1">…o armá un pool de liga-temporada/s</label>
|
| 485 |
+
<input list="tv-ls-list" x-model="tvsPoolIn" @change="tvsAddPool()" placeholder="Ej: Spanish La Liga · 25-26"
|
| 486 |
+
class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm w-72">
|
|
|
|
| 487 |
</div>
|
| 488 |
<div>
|
| 489 |
<label class="block text-xs text-gray-400 mb-1">Z-score contra</label>
|
|
|
|
| 497 |
:class="tvsLoading ? 'opacity-40 cursor-not-allowed' : 'hover:bg-racing-600'"
|
| 498 |
class="bg-racing-500 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">Buscar similares</button>
|
| 499 |
</div>
|
| 500 |
+
<div class="flex flex-wrap gap-1 mt-2" x-show="tvsPool.length">
|
| 501 |
+
<template x-for="(p,i) in tvsPool" :key="'tvspool'+p.value">
|
| 502 |
+
<span class="text-xs bg-ink-800 border border-ink-600 rounded-full px-2 py-1"><span x-text="p.label"></span>
|
| 503 |
+
<button @click="tvsPool.splice(i,1)" class="text-red-300 ml-1">×</button></span>
|
|
|
|
|
|
|
| 504 |
</template>
|
| 505 |
</div>
|
| 506 |
+
<p class="text-xs text-gray-500 mt-1" x-show="tvsPool.length">Con pool armado, "Buscar en" queda ignorado: los similares se buscan en esas ligas-temporadas.</p>
|
| 507 |
<p class="text-xs text-red-300 mt-2" x-show="tvsError" x-text="tvsError"></p>
|
| 508 |
<div x-show="tvsResult && tvsResult.teams" class="mt-3">
|
| 509 |
<div class="text-xs text-gray-400 mb-1">Más parecidos a <span class="text-gray-200" x-text="tvsResult?.equipo"></span> · vs <span x-text="tvsResult?.scope"></span> (<span x-text="tvsResult?.pool_size"></span> equipos)</div>
|
| 510 |
<table class="w-full text-xs">
|
| 511 |
+
<thead><tr class="text-gray-400 text-left"><th class="py-1">#</th><th>Equipo</th><th>Liga</th><th>Temp.</th><th class="text-right">Similitud</th></tr></thead>
|
| 512 |
<tbody>
|
| 513 |
<template x-for="(t, i) in (tvsResult?.teams || [])" :key="t.competencia+t.temporada+t.equipo">
|
| 514 |
<tr class="border-t border-ink-600">
|
|
|
|
| 516 |
<td x-text="t.equipo"></td>
|
| 517 |
<td class="text-gray-400" x-text="t.competencia"></td>
|
| 518 |
<td class="text-gray-400" x-text="t.temporada"></td>
|
| 519 |
+
<td class="text-right font-semibold text-racing-500" x-text="(t.similitud!=null? t.similitud+'%' : t.distancia)"></td>
|
| 520 |
</tr>
|
| 521 |
</template>
|
| 522 |
</tbody>
|
|
|
|
| 1015 |
tvLeagues: {}, tvLeagueList: [], tvLeague: '__all__', tvSeason: '__all__', tvSeasons: [], tvAllSeasons: [],
|
| 1016 |
tvVarLabels: [], tvLabelToCol: {}, tvColToLabel: {}, tvTypeCols: {}, tvTypeList: [],
|
| 1017 |
tvSpecs: [{variable:'', direction:'alto'}], tvResult: null, tvLoading: false, tvError: '', tvLoaded: false,
|
| 1018 |
+
tvLsOptions: [], tvPool: [], tvPoolIn: '', tvTop: 10, // pool por liga-temporada + nº de equipos
|
| 1019 |
+
tvsPool: [], tvsPoolIn: '', // pool por liga-temporada (similares)
|
| 1020 |
// Perfil de un equipo (z-score)
|
| 1021 |
tvpLeague:'', tvpSeason:'', tvpSeasons:[], tvpTeam:'', tvpTeams:[], tvpScope:'liga',
|
| 1022 |
tvpVars:[''], tvpResult:null, tvpLoading:false, tvpError:'', tvpZmax:3, tvpShowMean:false, tvpShowBest:false,
|
|
|
|
| 1067 |
this.tvTypeCols = o.types || {}; this.tvTypeList = Object.keys(this.tvTypeCols);
|
| 1068 |
this.tvLeagueList = Object.keys(this.tvLeagues);
|
| 1069 |
this.tvAllSeasons = [...new Set([].concat(...Object.values(this.tvLeagues)))].sort().reverse();
|
| 1070 |
+
// opciones de pool por liga-temporada (para "Top equipos" y "Similares")
|
| 1071 |
+
this.tvLsOptions = [];
|
| 1072 |
+
this.tvLeagueList.filter(l=>l!=='__all__').forEach(lg=>{
|
| 1073 |
+
(this.tvLeagues[lg]||[]).slice().sort().reverse().forEach(se=>{
|
| 1074 |
+
this.tvLsOptions.push({label: lg+' · '+se, value: lg+'|'+se});
|
| 1075 |
+
});
|
| 1076 |
+
});
|
| 1077 |
this.tvOnLeague();
|
| 1078 |
// panel de perfil: liga/temporada/equipo concretos por default
|
| 1079 |
this.tvpLeague = this.tvLeagueList.includes('Spanish Segunda Division') ? 'Spanish Segunda Division' : (this.tvLeagueList[0]||'');
|
|
|
|
| 1133 |
try {
|
| 1134 |
const r = await fetch('/api/team-vars/similar', {
|
| 1135 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1136 |
+
body: JSON.stringify({league:this.tvpLeague, season:this.tvpSeason, team:this.tvpTeam, variables:vars, scope:this.tvsScope, top:10, leagues:this.tvsLigas, pool:this.tvsPool.map(p=>p.value), z_mode:this.tvsZmode}),
|
| 1137 |
});
|
| 1138 |
if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
|
| 1139 |
const res = await r.json();
|
|
|
|
| 1174 |
if(!this.tvpVars.length) this.tvpVars.push('');
|
| 1175 |
},
|
| 1176 |
tvRemoveVar(i){ this.tvSpecs.splice(i,1); if(!this.tvSpecs.length) this.tvAddVar(); },
|
| 1177 |
+
// Solo permite variables que existan; completa a la más parecida o limpia.
|
| 1178 |
+
tvValidateVar(i){
|
| 1179 |
+
const val=(this.tvSpecs[i].variable||'').trim();
|
| 1180 |
+
if(!val) return;
|
| 1181 |
+
if(this.tvVarLabels.includes(val)){ this.tvError=''; return; }
|
| 1182 |
+
const low=val.toLowerCase();
|
| 1183 |
+
const m=this.tvVarLabels.find(l=>l.toLowerCase()===low)
|
| 1184 |
+
|| this.tvVarLabels.find(l=>l.toLowerCase().startsWith(low))
|
| 1185 |
+
|| this.tvVarLabels.find(l=>l.toLowerCase().includes(low));
|
| 1186 |
+
if(m){ this.tvSpecs[i].variable=m; this.tvError=''; }
|
| 1187 |
+
else { this.tvSpecs[i].variable=''; this.tvError='"'+val+'" no es una variable válida — elegila de la lista.'; }
|
| 1188 |
+
},
|
| 1189 |
+
// Pool por liga-temporada
|
| 1190 |
+
tvAddPool(){ const o=this.tvLsOptions.find(x=>x.label===this.tvPoolIn); if(o && !this.tvPool.some(p=>p.value===o.value)) this.tvPool.push(o); this.tvPoolIn=''; },
|
| 1191 |
+
tvsAddPool(){ const o=this.tvLsOptions.find(x=>x.label===this.tvsPoolIn); if(o && !this.tvsPool.some(p=>p.value===o.value)) this.tvsPool.push(o); this.tvsPoolIn=''; },
|
| 1192 |
async tvSearch(){
|
| 1193 |
this.tvError=''; this.tvResult=null;
|
| 1194 |
const specs = this.tvSpecs.filter(s=>s.variable).map(s=>({variable:(this.tvLabelToCol[s.variable]||s.variable), direction:s.direction}));
|
| 1195 |
if (!specs.length){ this.tvError='Elegí al menos una variable.'; return; }
|
| 1196 |
+
const pool = this.tvPool.map(p=>p.value);
|
| 1197 |
+
if (!pool.length && (!this.tvLeague || !this.tvSeason)){ this.tvError='Elegí liga y temporada, o armá un pool.'; return; }
|
| 1198 |
this.tvLoading=true;
|
| 1199 |
try {
|
| 1200 |
const r = await fetch('/api/team-vars/search', {
|
| 1201 |
method:'POST', headers:{'Content-Type':'application/json'},
|
| 1202 |
+
body: JSON.stringify({league:this.tvLeague, season:this.tvSeason, specs, top:(this.tvTop||10), pool}),
|
| 1203 |
});
|
| 1204 |
if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
|
| 1205 |
this.tvResult = await r.json();
|