Spaces:
Running
Z-score multitag/centros: versión interactiva (Plotly) descargable
Browse filesJunto 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>
|
@@ -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]
|
|
@@ -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
|
|
|
|
| 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:
|
|
@@ -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):
|
|
@@ -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 |
|
|
@@ -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 |
-
|
|
|
|
| 768 |
png_path, svg_path = save_figure(fig, out_path.parent, name)
|
| 769 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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>
|