Spaces:
Running
Running
| from __future__ import annotations | |
| import base64 | |
| from pathlib import Path | |
| import numpy as np | |
| import pandas as pd | |
| from racing_reports.utils import slugify | |
| DARK_BG = "#0b1721" | |
| CARD_BG = "#111821" | |
| TEXT = "#f5f5f5" | |
| MUTED = "#9ca3af" | |
| GREEN = "#006b3f" | |
| LIGHT_GREEN = "#00a86b" | |
| AMBER = "#d69a2d" | |
| RED = "#d94b4b" | |
| BLUE = "#5B8DB8" | |
| SHOT_EVENTS = {"Goal", "MissedShots", "SavedShot", "ShotOnPost", "ChanceMissed"} | |
| RECOVERY_EVENTS = {"BallRecovery", "Interception", "Tackle", "BlockedPass", "Aerial"} | |
| ZONE_RECTS = { | |
| "ext izq 3/4": (68, 83, 79, 100), | |
| "int izq 3/4": (68, 83, 63, 79), | |
| "centro 3/4": (68, 83, 37, 63), | |
| "int der 3/4": (68, 83, 21, 37), | |
| "ext der 3/4": (68, 83, 0, 21), | |
| "ext izq area": (83, 100, 79, 100), | |
| "int izq area": (83, 100, 63, 79), | |
| "centro area": (83, 100, 37, 63), | |
| "int der area": (83, 100, 21, 37), | |
| "ext der area": (83, 100, 0, 21), | |
| } | |
| ZONE_ORDER = list(ZONE_RECTS) | |
| def output_match_dir(output_root: Path, league: str, season: str, home: str, away: str, match_id: str) -> Path: | |
| return output_root / slugify(league) / str(season) / f"{slugify(home)}_vs_{slugify(away)}_{match_id}" | |
| def read_preprocessed(path: Path) -> pd.DataFrame: | |
| return pd.read_csv(path, low_memory=False, dtype={"matchId": str, "teamId": str}) | |
| def match_events(df: pd.DataFrame, match_id: str) -> pd.DataFrame: | |
| out = df[df["matchId"].astype(str) == str(match_id)].copy() | |
| if out.empty: | |
| raise ValueError(f"No hay eventos para matchId={match_id}") | |
| return out | |
| def html_page(title: str, subtitle: str, body: str) -> str: | |
| return f"""<!doctype html> | |
| <html lang="es"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>{title}</title> | |
| <style> | |
| :root {{ | |
| --bg: {DARK_BG}; | |
| --card: {CARD_BG}; | |
| --text: {TEXT}; | |
| --muted: {MUTED}; | |
| --green: {GREEN}; | |
| --light-green: {LIGHT_GREEN}; | |
| --border: rgba(255,255,255,.12); | |
| }} | |
| * {{ box-sizing: border-box; }} | |
| body {{ | |
| margin: 0; | |
| background: | |
| radial-gradient(circle at top left, rgba(0,107,63,.30), transparent 34rem), | |
| linear-gradient(180deg, #0b1721 0%, #0d141c 100%); | |
| color: var(--text); | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| }} | |
| .container {{ max-width: 1180px; margin: 0 auto; padding: 30px 22px 46px; }} | |
| header {{ | |
| border: 1px solid var(--border); | |
| border-radius: 20px; | |
| padding: 26px 28px; | |
| background: linear-gradient(120deg, rgba(0,107,63,.92), rgba(13,20,28,.92)); | |
| margin-bottom: 24px; | |
| }} | |
| .badge {{ display:inline-block; padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,.13); color: var(--text); font-weight: 700; font-size: 13px; margin-bottom: 12px; }} | |
| h1 {{ margin: 0 0 8px; font-size: 34px; letter-spacing: -.03em; }} | |
| h2 {{ margin: 0; color: rgba(245,245,245,.78); font-size: 16px; font-weight: 500; line-height: 1.45; }} | |
| h3 {{ font-size: 20px; margin: 0 0 10px; }} | |
| .grid {{ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }} | |
| .card {{ background: rgba(17,24,33,.92); border: 1px solid var(--border); border-radius: 18px; padding: 18px; box-shadow: 0 18px 60px rgba(0,0,0,.18); }} | |
| .wide {{ grid-column: 1 / -1; }} | |
| .muted {{ color: var(--muted); line-height: 1.55; }} | |
| .kpis {{ display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }} | |
| .kpi {{ padding: 14px; background: rgba(255,255,255,.04); border-radius: 14px; border: 1px solid rgba(255,255,255,.08); }} | |
| .kpi span {{ color: var(--muted); display:block; font-size: 12px; margin-bottom: 6px; }} | |
| .kpi strong {{ font-size: 24px; }} | |
| img {{ max-width: 100%; border-radius: 12px; display: block; }} | |
| table {{ width: 100%; border-collapse: collapse; font-size: 14px; }} | |
| th, td {{ padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }} | |
| th {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }} | |
| @media (max-width: 860px) {{ .grid, .kpis {{ grid-template-columns: 1fr; }} h1 {{ font-size: 28px; }} }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div class="badge">Racing Reports</div> | |
| <h1>{title}</h1> | |
| <h2>{subtitle}</h2> | |
| </header> | |
| {body} | |
| </div> | |
| </body> | |
| </html>""" | |
| def add_attack_zone(df: pd.DataFrame) -> pd.DataFrame: | |
| out = df.copy() | |
| out["attack_zone"] = pd.Series(index=out.index, dtype="object") | |
| x = pd.to_numeric(out.get("x"), errors="coerce") | |
| y = pd.to_numeric(out.get("y"), errors="coerce") | |
| for zone, (x0, x1, y0, y1) in ZONE_RECTS.items(): | |
| mask = x.ge(x0) & x.lt(x1) & y.ge(y0) & y.lt(y1) | |
| out.loc[mask, "attack_zone"] = zone | |
| return out | |
| def team_kpis(events: pd.DataFrame, team: str) -> dict[str, float]: | |
| t = events[events["TeamName"].astype(str) == team].copy() | |
| if t.empty: | |
| return {"events": 0, "shots": 0, "xg": 0.0, "pv": 0.0, "goals": 0.0} | |
| xg_source = t["xG"] if "xG" in t.columns else pd.Series(0, index=t.index) | |
| pv_source = t["pvAdded"] if "pvAdded" in t.columns else pd.Series(0, index=t.index) | |
| goal_source = t["goal_int"] if "goal_int" in t.columns else pd.Series(0, index=t.index) | |
| return { | |
| "events": float(len(t)), | |
| "shots": float(t["event_name"].isin(SHOT_EVENTS).sum()) if "event_name" in t.columns else 0.0, | |
| "xg": float(pd.to_numeric(xg_source, errors="coerce").fillna(0).sum()), | |
| "pv": float(pd.to_numeric(pv_source, errors="coerce").fillna(0).sum()), | |
| "goals": float(pd.to_numeric(goal_source, errors="coerce").fillna(0).sum()), | |
| } | |
| def season_team_events(df: pd.DataFrame, team: str, before_date: str | None = None) -> pd.DataFrame: | |
| out = df[df["TeamName"].astype(str) == team].copy() | |
| if before_date and "fecha" in out.columns: | |
| dates = parse_event_dates(out["fecha"]) | |
| out = out[dates < pd.to_datetime(before_date, errors="coerce", utc=True)] | |
| return out | |
| def parse_event_dates(series: pd.Series) -> pd.Series: | |
| cleaned = series.astype(str).str.replace("Z", "", regex=False).str[:10] | |
| return pd.to_datetime(cleaned, errors="coerce", utc=True, format="%Y-%m-%d") | |
| def save_figure(fig, out_dir: Path, name: str, *, svg: bool = True, dpi: int = 160) -> tuple[Path, Path | None]: | |
| """Persiste una figura matplotlib como PNG (y opcionalmente SVG). | |
| Devuelve los paths absolutos. NO cierra la figura: el caller decide | |
| (algunos flujos también la serializan a base64 después). | |
| """ | |
| figs_dir = out_dir / "figures" | |
| figs_dir.mkdir(parents=True, exist_ok=True) | |
| png_path = figs_dir / f"{name}.png" | |
| fig.savefig(png_path, dpi=dpi, bbox_inches="tight", facecolor=fig.get_facecolor()) | |
| svg_path: Path | None = None | |
| if svg: | |
| svg_path = figs_dir / f"{name}.svg" | |
| fig.savefig(svg_path, format="svg", bbox_inches="tight", facecolor=fig.get_facecolor()) | |
| return png_path, svg_path | |
| def save_table(df: pd.DataFrame, out_dir: Path, name: str) -> Path: | |
| tables_dir = out_dir / "tables" | |
| tables_dir.mkdir(parents=True, exist_ok=True) | |
| csv_path = tables_dir / f"{name}.csv" | |
| df.to_csv(csv_path, index=False) | |
| return csv_path | |
| def png_to_data_uri(path: Path) -> str: | |
| return "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode("ascii") | |