pablogrois Claude Opus 4.8 commited on
Commit
b2931bb
·
1 Parent(s): 75f1b42

Z-score multitag/centros: versión interactiva (Plotly) descargable

Browse files

Junto al PNG/SVG, cada figura de z-score (triple_multitag y triple_centros)
genera un HTML autocontenido con Plotly: al pasar el mouse por un punto muestra
el equipo, el valor de la métrica (xG/tiros/acciones) y el z-score. Incluye los
puntos de todos los equipos (fondo) y el equipo analizado resaltado.

- bundle.FigureArtifact: nuevo campo html_path.
- triple_multitag: builders plotly_triple/plotly_centros + _save_plotly_html
(include_plotlyjs=True → offline). Falla suave: si no se puede, warning y sigue.
- routes_jobs: interactive_url, lookup html, ruta /figure/{name}.interactive.html,
y se agrega al ZIP.
- schemas.FigureSummary.interactive_url; home.html: link "Interactivo".
- pyproject: plotly>=5.18 (el Space lo instala vía pip install -e .).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

pyproject.toml CHANGED
@@ -25,6 +25,7 @@ dependencies = [
25
  "mplsoccer>=1.2.0",
26
  "scipy>=1.12.0",
27
  "openpyxl>=3.1.0",
 
28
  ]
29
 
30
  [project.optional-dependencies]
 
25
  "mplsoccer>=1.2.0",
26
  "scipy>=1.12.0",
27
  "openpyxl>=3.1.0",
28
+ "plotly>=5.18.0",
29
  ]
30
 
31
  [project.optional-dependencies]
src/racing_reports/api/routes_jobs.py CHANGED
@@ -47,6 +47,10 @@ def _bundle_summary(job_id: str, report_type: str, bundle) -> BundleSummary:
47
  section=f.section,
48
  png_url=f"{base}/figure/{f.name}.png",
49
  svg_url=f"{base}/figure/{f.name}.svg" if f.svg_path else None,
 
 
 
 
50
  )
51
  for f in bundle.figures
52
  ],
@@ -90,16 +94,17 @@ def status(job_id: str) -> JobStatus:
90
 
91
 
92
  def _find_artifact(job, report_type: str | None, kind: str, name: str) -> Path:
93
- """Busca un artifact en los bundles del job. ``kind`` ∈ {png,svg,csv}."""
94
  candidates = job.bundles.values() if report_type is None else [job.bundles.get(report_type)]
95
  for bundle in candidates:
96
  if bundle is None:
97
  continue
98
- if kind in ("png", "svg"):
99
  for fig in bundle.figures:
100
  if fig.name != name:
101
  continue
102
- path = fig.png_path if kind == "png" else fig.svg_path
 
103
  if path is None:
104
  raise HTTPException(status_code=404, detail=f"{name}.{kind} no disponible.")
105
  return path
@@ -134,6 +139,19 @@ def figure_svg(job_id: str, name: str):
134
  return FileResponse(path, media_type="image/svg+xml", filename=f"{name}.svg")
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  @router.get(
138
  "/{job_id}/table/{name}.csv",
139
  dependencies=[Depends(require_auth)],
@@ -190,6 +208,8 @@ def zip_report(job_id: str, report: str):
190
  zf.write(fig.png_path, arcname=f"figures/{fig.name}.png")
191
  if fig.svg_path:
192
  zf.write(fig.svg_path, arcname=f"figures/{fig.name}.svg")
 
 
193
  for tbl in bundle.tables:
194
  zf.write(tbl.csv_path, arcname=f"tables/{tbl.name}.csv")
195
  if job.manifest_path:
 
47
  section=f.section,
48
  png_url=f"{base}/figure/{f.name}.png",
49
  svg_url=f"{base}/figure/{f.name}.svg" if f.svg_path else None,
50
+ interactive_url=(
51
+ f"{base}/figure/{f.name}.interactive.html"
52
+ if getattr(f, "html_path", None) else None
53
+ ),
54
  )
55
  for f in bundle.figures
56
  ],
 
94
 
95
 
96
  def _find_artifact(job, report_type: str | None, kind: str, name: str) -> Path:
97
+ """Busca un artifact en los bundles del job. ``kind`` ∈ {png,svg,html,csv}."""
98
  candidates = job.bundles.values() if report_type is None else [job.bundles.get(report_type)]
99
  for bundle in candidates:
100
  if bundle is None:
101
  continue
102
+ if kind in ("png", "svg", "html"):
103
  for fig in bundle.figures:
104
  if fig.name != name:
105
  continue
106
+ path = {"png": fig.png_path, "svg": fig.svg_path,
107
+ "html": getattr(fig, "html_path", None)}[kind]
108
  if path is None:
109
  raise HTTPException(status_code=404, detail=f"{name}.{kind} no disponible.")
110
  return path
 
139
  return FileResponse(path, media_type="image/svg+xml", filename=f"{name}.svg")
140
 
141
 
142
+ @router.get(
143
+ "/{job_id}/figure/{name}.interactive.html",
144
+ response_class=HTMLResponse,
145
+ dependencies=[Depends(require_auth)],
146
+ )
147
+ def figure_interactive(job_id: str, name: str):
148
+ job = jobs_mod.get_job(job_id)
149
+ if job is None:
150
+ raise HTTPException(status_code=404, detail="Job no encontrado.")
151
+ path = _find_artifact(job, None, "html", name)
152
+ return FileResponse(path, media_type="text/html", filename=f"{name}.html")
153
+
154
+
155
  @router.get(
156
  "/{job_id}/table/{name}.csv",
157
  dependencies=[Depends(require_auth)],
 
208
  zf.write(fig.png_path, arcname=f"figures/{fig.name}.png")
209
  if fig.svg_path:
210
  zf.write(fig.svg_path, arcname=f"figures/{fig.name}.svg")
211
+ if getattr(fig, "html_path", None):
212
+ zf.write(fig.html_path, arcname=f"figures/{fig.name}.interactive.html")
213
  for tbl in bundle.tables:
214
  zf.write(tbl.csv_path, arcname=f"tables/{tbl.name}.csv")
215
  if job.manifest_path:
src/racing_reports/api/schemas.py CHANGED
@@ -28,6 +28,7 @@ class FigureSummary(BaseModel):
28
  section: str
29
  png_url: str
30
  svg_url: str | None = None
 
31
 
32
 
33
  class TableSummary(BaseModel):
 
28
  section: str
29
  png_url: str
30
  svg_url: str | None = None
31
+ interactive_url: str | None = None
32
 
33
 
34
  class TableSummary(BaseModel):
src/racing_reports/reports/bundle.py CHANGED
@@ -21,6 +21,7 @@ class FigureArtifact:
21
  section: str
22
  png_path: Path
23
  svg_path: Path | None = None
 
24
  caption: str = ""
25
 
26
 
 
21
  section: str
22
  png_path: Path
23
  svg_path: Path | None = None
24
+ html_path: Path | None = None # versión interactiva (Plotly, autocontenida)
25
  caption: str = ""
26
 
27
 
src/racing_reports/reports/triple_multitag.py CHANGED
@@ -741,6 +741,106 @@ def plot_centros(detail_df: pd.DataFrame, team: str, side_label: str, title_team
741
  return fig
742
 
743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  # Columnas mínimas que necesita el análisis (del preprocessed).
745
  NEEDED_COLS = [
746
  "matchId", "teamId", "TeamName", "possessionId", "time_seconds",
@@ -764,9 +864,19 @@ def _build_report(out_path, report_type, title, plan, home_name, away_name):
764
  if team not in sdf.index and team not in seen:
765
  seen.add(team)
766
  warnings.append(f"{team} sin datos en la liga/temporada; figuras vacías.")
767
- fig = plot_centros(sdf, team, side, team) if kind == "centros" else plot_triple(sdf, team, side, team)
 
768
  png_path, svg_path = save_figure(fig, out_path.parent, name)
769
- figures.append(FigureArtifact(name=name, title=fig_title, section=section, png_path=png_path, svg_path=svg_path))
 
 
 
 
 
 
 
 
 
770
  imgs.append((fig_title, base64.b64encode(Path(png_path).read_bytes()).decode("ascii")))
771
  body = "\n".join(
772
  f'<h2>{t}</h2><img src="data:image/png;base64,{b}" style="width:100%;max-width:1100px"/>' for t, b in imgs
 
741
  return fig
742
 
743
 
744
+ # ── Versión interactiva (Plotly) ─────────────────────────────────────────────
745
+ # Mismos datos que las figuras matplotlib pero con hover: equipo + valor + z-score.
746
+ PLOTLY_METRIC = {
747
+ "xg": ("xG acumulado", METRIC_COLOR["xg"]),
748
+ "tiros": ("Tiros con tag", METRIC_COLOR["tiros"]),
749
+ "acc": ("Acciones totales", METRIC_COLOR["acc"]),
750
+ }
751
+ _METRIC_DY = {"xg": 0.22, "tiros": 0.0, "acc": -0.22}
752
+ _HOVER = ("<b>%{customdata[0]}</b><br>%{customdata[3]}: %{customdata[1]}"
753
+ "<br>z = %{x:.2f}<br>%{customdata[2]}<extra></extra>")
754
+
755
+
756
+ def _z_array(col: pd.Series) -> np.ndarray:
757
+ vals = col.to_numpy(dtype=float)
758
+ mu, sd = np.nanmean(vals), np.nanstd(vals)
759
+ if sd <= 1e-9:
760
+ return np.zeros(len(vals))
761
+ return (vals - mu) / sd
762
+
763
+
764
+ def _fmt_val(metric: str, v: float) -> str:
765
+ return f"{v:.2f} xG" if metric == "xg" else f"{int(round(v))}"
766
+
767
+
768
+ def _plotly_panel(fig, side_df: pd.DataFrame, tags: list[str], team: str, row: int, col: int,
769
+ rng, show_legend: bool) -> None:
770
+ import plotly.graph_objects as go
771
+ n = len(tags)
772
+ ybase = {t: (n - 1 - i) for i, t in enumerate(tags)}
773
+ has_team = team in side_df.index
774
+ for metric, (mlabel, mcolor) in PLOTLY_METRIC.items():
775
+ bx, by, bcd = [], [], []
776
+ for tag in tags:
777
+ c = side_df[(tag, metric)]
778
+ z = _z_array(c)
779
+ yy = ybase[tag] + _METRIC_DY[metric]
780
+ jit = rng.uniform(-0.07, 0.07, len(z))
781
+ for tnm, zz, vv, jj in zip(c.index, z, c.to_numpy(dtype=float), jit):
782
+ bx.append(zz); by.append(yy + jj)
783
+ bcd.append([str(tnm), _fmt_val(metric, vv), tag, mlabel])
784
+ fig.add_trace(go.Scattergl(
785
+ x=bx, y=by, mode="markers", name=mlabel, legendgroup=mlabel,
786
+ showlegend=show_legend, marker=dict(size=6, color=mcolor, opacity=0.28),
787
+ customdata=bcd, hovertemplate=_HOVER), row=row, col=col)
788
+ if has_team:
789
+ hx, hy, hcd = [], [], []
790
+ for tag in tags:
791
+ c = side_df[(tag, metric)]
792
+ z = _z_array(c)
793
+ i = c.index.get_loc(team)
794
+ hx.append(z[i]); hy.append(ybase[tag] + _METRIC_DY[metric])
795
+ hcd.append([team, _fmt_val(metric, float(c.iloc[i])), tag, mlabel])
796
+ fig.add_trace(go.Scatter(
797
+ x=hx, y=hy, mode="markers", legendgroup=mlabel, showlegend=False,
798
+ marker=dict(size=13, color=mcolor, line=dict(width=1.6, color="#222")),
799
+ customdata=hcd, hovertemplate=_HOVER), row=row, col=col)
800
+ fig.add_vline(x=0, line_dash="dash", line_color="#888", line_width=1, row=row, col=col)
801
+ fig.update_yaxes(tickmode="array", tickvals=[ybase[t] for t in tags], ticktext=tags,
802
+ range=[-0.7, n - 0.3], row=row, col=col)
803
+ fig.update_xaxes(title_text="Z-score (vs media de la liga)", range=[-4, 6.5],
804
+ zeroline=False, row=row, col=col)
805
+
806
+
807
+ def plotly_triple(side_df: pd.DataFrame, team: str, side_label: str, title_team: str, seed: int = 7):
808
+ from plotly.subplots import make_subplots
809
+ rng = np.random.default_rng(seed)
810
+ fig = make_subplots(rows=1, cols=1)
811
+ _plotly_panel(fig, side_df, TAGS, team, 1, 1, rng, show_legend=True)
812
+ fig.update_layout(template="plotly_white", hovermode="closest",
813
+ height=max(620, 30 * len(TAGS)),
814
+ title=f"Z-Score Multi-Tag — {side_label} — {title_team}",
815
+ legend=dict(orientation="h", yanchor="bottom", y=1.02))
816
+ return fig
817
+
818
+
819
+ def plotly_centros(detail_df: pd.DataFrame, team: str, side_label: str, title_team: str, seed: int = 7):
820
+ from plotly.subplots import make_subplots
821
+ rng = np.random.default_rng(seed)
822
+ grouped_df = _grouped_centros_df(detail_df)
823
+ fig = make_subplots(rows=1, cols=2, horizontal_spacing=0.16,
824
+ subplot_titles=("Desglose por tipo · lado", "Vista agrupada"))
825
+ _plotly_panel(fig, detail_df, CENTROS_DETAIL, team, 1, 1, rng, show_legend=True)
826
+ _plotly_panel(fig, grouped_df, list(CENTROS_GROUPED), team, 1, 2, rng, show_legend=False)
827
+ fig.update_layout(template="plotly_white", hovermode="closest",
828
+ height=max(620, 30 * len(CENTROS_DETAIL)),
829
+ title=f"Z-Score Centros — {side_label} — {title_team}",
830
+ legend=dict(orientation="h", yanchor="bottom", y=1.02))
831
+ return fig
832
+
833
+
834
+ def _save_plotly_html(fig, out_dir, name: str):
835
+ """Guarda la figura Plotly como HTML autocontenido (plotly.js embebido)."""
836
+ from pathlib import Path
837
+ figs_dir = Path(out_dir) / "figures"
838
+ figs_dir.mkdir(parents=True, exist_ok=True)
839
+ html_path = figs_dir / f"{name}.interactive.html"
840
+ fig.write_html(str(html_path), include_plotlyjs=True, full_html=True)
841
+ return html_path
842
+
843
+
844
  # Columnas mínimas que necesita el análisis (del preprocessed).
845
  NEEDED_COLS = [
846
  "matchId", "teamId", "TeamName", "possessionId", "time_seconds",
 
864
  if team not in sdf.index and team not in seen:
865
  seen.add(team)
866
  warnings.append(f"{team} sin datos en la liga/temporada; figuras vacías.")
867
+ is_centros = kind == "centros"
868
+ fig = plot_centros(sdf, team, side, team) if is_centros else plot_triple(sdf, team, side, team)
869
  png_path, svg_path = save_figure(fig, out_path.parent, name)
870
+ plt.close(fig)
871
+ # Versión interactiva (Plotly, hover con equipo + valor); autocontenida.
872
+ html_path = None
873
+ try:
874
+ pfig = plotly_centros(sdf, team, side, team) if is_centros else plotly_triple(sdf, team, side, team)
875
+ html_path = _save_plotly_html(pfig, out_path.parent, name)
876
+ except Exception as exc: # la interactiva es un extra: no romper el reporte
877
+ warnings.append(f"{name}: no se pudo generar la versión interactiva ({exc}).")
878
+ figures.append(FigureArtifact(name=name, title=fig_title, section=section,
879
+ png_path=png_path, svg_path=svg_path, html_path=html_path))
880
  imgs.append((fig_title, base64.b64encode(Path(png_path).read_bytes()).decode("ascii")))
881
  body = "\n".join(
882
  f'<h2>{t}</h2><img src="data:image/png;base64,{b}" style="width:100%;max-width:1100px"/>' for t, b in imgs
src/racing_reports/web/templates/home.html CHANGED
@@ -245,6 +245,7 @@
245
  <div class="flex gap-2 text-xs">
246
  <a :href="fig.png_url" download class="text-racing-500 hover:underline">PNG</a>
247
  <a :href="fig.svg_url" download class="text-racing-500 hover:underline" x-show="fig.svg_url">SVG</a>
 
248
  </div>
249
  </figcaption>
250
  </figure>
 
245
  <div class="flex gap-2 text-xs">
246
  <a :href="fig.png_url" download class="text-racing-500 hover:underline">PNG</a>
247
  <a :href="fig.svg_url" download class="text-racing-500 hover:underline" x-show="fig.svg_url">SVG</a>
248
+ <a :href="fig.interactive_url" download class="text-racing-500 hover:underline" x-show="fig.interactive_url" title="HTML interactivo: hover muestra equipo y valor">Interactivo</a>
249
  </div>
250
  </figcaption>
251
  </figure>