pablogrois Claude Opus 4.8 commited on
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
- return team_vars.search(req.league, req.season, [s.model_dump() for s in req.specs], top=top)
 
 
 
 
 
 
 
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 mb-2">
288
- <div class="font-semibold"><span class="text-racing-500" x-text="'#'+(i+1)"></span> <span x-text="t.equipo"></span>
 
 
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; }