pablogrois Claude Opus 4.8 commited on
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), 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
@@ -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") -> dict:
 
206
  """Equipos más parecidos por DISTANCIA EUCLÍDEA en el espacio de z-scores.
207
 
208
- Pool: ``leagues`` (lista de ligas elegidas, todas sus temporadas) tiene prioridad;
209
- si no, ``scope='all'`` (todo) o ``'liga'`` (su liga-temporada).
210
- z_mode: ``'pool'`` = stats de la selección; ``'todas'`` = stats de todas las
211
- ligas/temporadas; ``'liga'`` = cada equipo estandarizado contra SU liga-temporada.
 
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
- if leagues:
 
 
 
 
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) -> 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."""
@@ -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
- 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
@@ -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) -> dict:
404
- """specs = [{"variable", "direction"}]. Devuelve top-N equipos por puntaje."""
405
- pool = _pool(league, season)
406
- lg_lbl = "todas las ligas" if (not league or league == ALL) else league
407
- se_lbl = "todas las temporadas" if (not season or season == ALL) else season
 
 
 
 
 
 
 
 
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" placeholder="Escribí para buscar una variable…"
 
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 sumar ligas al pool</label>
459
- <input list="tvsligalist" x-model="tvsLigaIn" @change="tvsAddLiga()" placeholder="Liga"
460
- class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-sm w-52">
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="tvsLigas.length">
476
- <template x-for="(lg,i) in tvsLigas" :key="'tvschip'+i">
477
- <span class="text-xs bg-ink-800 border border-ink-600 rounded-full px-2 py-1">
478
- <span x-text="lg"></span>
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="tvsLigas.length">Con ligas en el pool, "Buscar en" queda ignorado: los similares se buscan en esas ligas.</p>
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">Distancia</th></tr></thead>
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
- if (!this.tvLeague || !this.tvSeason){ this.tvError='Elegí liga y temporada.'; return; }
 
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:5}),
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();