import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Patch
from matplotlib.lines import Line2D
from IPython.display import Markdown
import gridiron.play as gplay
from gridiron import Play, Player, offense, defense, routes as r, show_animation, style, draw_marker
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
FIELD_W = 160 / 3
ODD, EVEN = style.OFFENSE_DARK, style.THIRD # outside-breaking vs inside-breaking routes
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where receivers stand and crowd the labels)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def ruler(fld, play, top=40, fs=6.5):
"""Depth labels (yards past the line of scrimmage) down the left edge of the window, so the
faint 5-yard lines read as a scale. gridiron has no ruler, so it is drawn here."""
x_left = FIELD_W - fld.y1 + 0.4
for d in range(5, top + 1, 5):
y = play.los + d
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
fld.ax.text(x_left, y, f"{d}" + (" yd" if d == 5 else ""), fontsize=fs, color=style.INK_2,
ha="left", va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def tag(fld, play, text, at, ha="center", fs=7.0, ec=style.BASELINE, color=style.INK, bold=False):
"""A boxed label at (d, w) yards; `ec` colours the box edge (used to key route families)."""
x, y = fld.xy(*play.to_abs(*at))
fld.ax.text(float(x), float(y), text, ha=ha, va="center", fontsize=fs, color=color, zorder=12,
fontweight="bold" if bold else "normal",
bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=ec, lw=0.9))
def dotted(fld, play, pts, color=style.OFFENSE, lw=1.4, arrow=True, head=9):
"""A dotted optional branch through (d, w) points, with an arrowhead."""
xy = [tuple(float(v) for v in fld.xy(*play.to_abs(*q))) for q in pts]
xs, ys = zip(*xy)
fld.ax.plot(xs, ys, color=color, lw=lw, ls=(0, (1, 1.5)), zorder=4)
if arrow:
fld.ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=head, color=color,
lw=0, zorder=4, shrinkA=0, shrinkB=0))
def faded_route(fld, play, pts, color=style.MUTED, lw=1.6):
"""A route drawn in grey: it is on the play but is not the point of the picture."""
xy = [tuple(float(v) for v in fld.xy(*play.to_abs(*q))) for q in pts]
xs, ys = zip(*xy)
fld.ax.plot(xs, ys, color=color, lw=lw, zorder=3, solid_capstyle="round")
fld.ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=9, color=color,
lw=0, zorder=3, shrinkA=0, shrinkB=0))
def protect(p, rushers=("SDE", "SDT", "WDT", "WDE")):
"""Five linemen set and four rushers engage them: enough to say 'pass', no more."""
for k in ("LT", "LG", "C", "RG", "RT"):
if k in p.players:
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
if k in p.players:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
class ball_speed:
"""Temporarily set gridiron's ball speed (yd/s). Its default, 18, is an average over a whole
flight; a firm 20-30 yard line drive to the sideline travels faster, so the timing figures use
20-22. Used as `with ball_speed(22): ...` so other figures keep the default."""
def __init__(self, v):
self.v = v
def __enter__(self):
self.old = gplay.BALL_SPEED
gplay.BALL_SPEED = self.v
def __exit__(self, *a):
gplay.BALL_SPEED = self.old
# ---------------------------------------------------------------- snapshots (print stand-in for video)
def snapshot(play, t, ax=None, lateral=(-6, 26.6), window=(-9, 20), width_in=6.5, notes=(), rings=(),
show_ruler=False, numbers_off=True):
"""Everyone where he is at time t, faint trails of where they came from, and the ball in flight."""
pos = {k: play.position(k, t) for k in play.players}
lo, hi = sorted(lateral)
moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()
if lo + 0.8 <= float(pos[k][1]) <= hi - 0.8] # drop anyone outside the window
q = Play([m for m in moved if m.side == "off"], [m for m in moved if m.side == "def"],
los=play.los, ball_y=play.ball_y, to_go=play.to_go)
q.highlights = dict(play.highlights)
for k in rings:
q.highlight(k)
fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
if numbers_off:
no_numbers(ax)
if show_ruler:
ruler(fld, play, fs=5.5)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
t0 = play._compile()[1]
for k, pl in play.players.items():
if pl.pos in ("LT", "LG", "C", "RG", "RT", "DE", "DT", "NT"):
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(t0, t, 40)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = P(pts[:, 0], pts[:, 1])
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=1.2, alpha=0.5, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), zorder=3)
bx, by = P(*play.ball_position(t))
thrown = False
for kind, _, t_rel in play.ball_events:
if kind == "pass" and t > t_rel:
thrown = True
sx, sy = P(*play.ball_position(t_rel))
ax.add_patch(FancyArrowPatch((float(sx), float(sy)), (float(bx), float(by)), arrowstyle="-|>",
mutation_scale=8, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9))
if not thrown and t > 0 and "QB" in play.players:
# still in the quarterback's hands: draw it beside him (on the emptier side), not on his "Q"
qd = play.position("QB", t)
others = [pos[k] for k, pl in play.players.items() if pl.side == "off" and k != "QB"]
cands = [qd + np.array([0.0, 1.3]), qd + np.array([0.0, -1.3])]
best = max(cands, key=lambda c: min(float(np.linalg.norm(c - o)) for o in others) if others else 0)
bx, by = P(*best)
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
for text, at in notes:
tag(fld, play, text, at, fs=6.3)
return fld
def strip(plays, times, labels, lateral=(-6, 26.6), window=(-9, 20), notes=None, rings=(), width_in=6.5,
ncols=3, legend=True, rulers=True):
"""Panels at chosen moments. `plays` is one Play (used for every panel) or one per row."""
n = len(times)
nrows = int(np.ceil(n / ncols))
if isinstance(plays, Play):
plays = [plays] * nrows
pw = width_in / ncols
h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.34)
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
for ax in axes.flat[n:]:
ax.axis("off")
for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
snapshot(plays[i // ncols], t, ax=ax, lateral=lateral, window=window,
notes=(notes or {}).get(i, ()), rings=rings, show_ruler=rulers and (i % ncols == 0))
ax.set_title(lab, fontsize=7.8, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.5, w_pad=0.3)
if legend:
h_ = [Line2D([], [], color=style.OFFENSE, lw=1.2, alpha=0.6, label="Receiver's path so far"),
Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="Defender's path so far"),
Line2D([], [], color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), label="Ball in flight")]
fig.legend(handles=h_, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=3, fontsize=7.5, frameon=False)
return fig
def show_videos(plays, titles):
"""Web build: several animations side by side in ONE output, so Quarto keeps a single figure
(two separate show_animation calls would be split into sub-figures)."""
import shutil
from IPython.display import HTML, display
from gridiron.animate import animate
parts = []
for pl, ttl in zip(plays, titles):
anim = animate(pl, title=ttl, to_go=pl.to_go)
if shutil.which("ffmpeg"):
html = anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
else:
html = anim.to_jshtml(default_mode="once")
plt.close(anim._fig)
parts.append(f'<div style="flex:1 1 300px;min-width:0">{html}</div>')
display(HTML('<div style="display:flex;gap:8px;flex-wrap:wrap">' + "".join(parts) + "</div>"))