import os
import logging
import shutil
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Rectangle, RegularPolygon
from matplotlib.lines import Line2D
from gridiron import Play, Player, Field, style, draw_marker, routes as r
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
RED = style.SERIES[7] # ring: the opponent's "best thing", the target of the plan
PURPLE = style.SERIES[6] # ring: a defender the game plan moved or gave a special job
GREEN = style.THIRD # ring: the player to watch
GREEN_TXT = "#0f7a55" # darker aqua, for text on the light page
OD, DD = style.OFFENSE_DARK, style.DEFENSE_DARK
LINE_D, OFF_BALL, GUN, UC = -0.7, -1.5, -5.0, -2.2 # UC: a QB under center, far enough back that his disc
# (radius 0.75 yd) doesn't cover the center's letter
def opp(n):
"""Line of scrimmage (BDB x) for a ball on the opponent's n-yard line. The goal line is x = 110."""
return 110 - n
def own(n):
"""Line of scrimmage (BDB x) for a ball on the offense's own n-yard line."""
return 10 + n
# ---------------------------------------------------------------- players
def O(key, pos, d, w, label=None):
return Player(key, pos, "off", d, w, label=key if label is None else label)
def D(key, pos, d, w, label):
return Player(key, pos, "def", d, w, label=label)
def line5(split=1.6, names=False, d=LINE_D):
"""Five offensive linemen; only the center is lettered unless names=True."""
return [O(k, k, d, m * split, k if names else ("C" if k == "C" else ""))
for k, m in (("LT", -2), ("LG", -1), ("C", 0), ("RG", 1), ("RT", 2))]
def go_to(p, key, *pts, kind="path", delay=0.0, speed=None, target=None):
"""Move `key` through absolute (d, w) points, as a route, run, path, block or rush."""
start = np.array(p._cur(key))
rel = [tuple(np.array(q) - start) for q in pts]
if kind == "route":
return p.route(key, rel, frame="field", delay=delay, speed=speed)
if kind == "block":
return p.block(key, pts=rel, delay=delay, speed=speed)
if kind == "rush":
return p.rush(key, pts=rel, delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, rel, delay=delay, speed=speed)
# ---------------------------------------------------------------- drawing helpers
def P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def clean(ax, numbers=False):
"""Drop hash ticks and painted yard numbers (they sit where receivers stand); clip all text to the window."""
for ln in list(ax.lines):
xs, ys = ln.get_data()
if len(xs) == 2 and ln.get_linewidth() == 0.5 and np.hypot(xs[1] - xs[0], ys[1] - ys[0]) < 1.0:
ln.remove()
for t in list(ax.texts):
if not numbers and t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def draw(p, ax=None, width_in=5.8, **kw):
fld = p.draw(ax=ax, width_in=width_in, title=False, **kw)
clean(fld.ax)
return fld
def say(fld, p, d, w, text, color=style.INK_2, fontsize=6.8, ha="center", weight="normal", box=True):
"""A short on-field label at (d, w)."""
x, y = P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color=color, ha=ha, va="center", zorder=13,
fontweight=weight, linespacing=1.12,
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92) if box else None)
def point_to(fld, p, text_at, target, text, color=style.INK_2, fontsize=6.6, ha="center"):
"""A label with a thin leader line to the player or spot it names."""
(xt, yt), (xp, yp) = P(fld, p, *text_at), P(fld, p, *target)
fld.ax.annotate(text, (float(xp), float(yp)), xytext=(float(xt), float(yt)), fontsize=fontsize,
color=color, ha=ha, va="center", zorder=13, linespacing=1.12,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=7),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def ghost(fld, p, pts, color=style.DEFENSE, lw=1.4, alpha=0.75, end="arrow", z=5):
"""A dashed path through absolute (d, w) points: a defender's reaction, or what a player would do."""
xs, ys = P(fld, p, np.array([q[0] for q in pts]), np.array([q[1] for q in pts]))
fld.ax.plot(xs, ys, color=color, lw=lw, ls=(0, (3, 2)), alpha=alpha, zorder=z, solid_capstyle="round")
if end == "arrow":
fld.ax.add_patch(FancyArrowPatch((xs[-2], ys[-2]), (xs[-1], ys[-1]), arrowstyle="-|>", mutation_scale=10,
color=color, lw=lw, alpha=alpha, zorder=z))
def throw(fld, p, a, b, rad=-0.12):
"""The ball's flight from spot a to spot b (brown dashes)."""
(x0, y0), (x1, y1) = P(fld, p, *a), P(fld, p, *b)
fld.ax.add_patch(FancyArrowPatch((float(x0), float(y0)), (float(x1), float(y1)), arrowstyle="-|>",
mutation_scale=10, color=style.BALL, lw=1.4, ls=(0, (2, 2)), zorder=6,
connectionstyle=f"arc3,rad={rad}"))
def oval(fld, p, center, size, color=style.DEFENSE, label=None, alpha=0.12, fs=6.4):
"""A shaded area (depth, width), optionally with an italic label at its centre."""
x, y = P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(x), float(y)), size[1], size[0], fc=color, alpha=alpha, ec=color, lw=0.9,
ls=(0, (3, 2)), zorder=1.6))
if label:
fld.ax.text(float(x), float(y), label, ha="center", va="center", fontsize=fs, color=style.INK_2,
style="italic", zorder=3, clip_on=True)
def band(fld, p, d0, d1, w0, w1, color=style.DEFENSE, alpha=0.10, hatch=None):
"""A shaded rectangle between depths d0..d1 and widths w0..w1."""
(xa, ya), (xb, yb) = P(fld, p, d0, w0), P(fld, p, d1, w1)
fld.ax.add_patch(Rectangle((float(min(xa, xb)), float(min(ya, yb))), float(abs(xb - xa)), float(abs(yb - ya)),
fc=color, alpha=alpha, ec="none", zorder=1.5, hatch=hatch))
def ring(fld, p, d, w, color, ls="-", r_=1.15):
x, y = P(fld, p, d, w)
fld.ax.add_patch(Circle((float(x), float(y)), r_, fc="none", ec=color, lw=1.7, ls=ls, zorder=7))
def badge(fld, p, d, w, text, color=RED, fontsize=6.8):
"""A small white-on-colour disc that ties a spot to a note in the caption."""
x, y = P(fld, p, d, w)
fld.ax.text(float(x), float(y), text, fontsize=fontsize, color="white", ha="center", va="center", zorder=14,
fontweight="bold", bbox=dict(boxstyle="circle,pad=0.2", fc=color, ec="white", lw=0.6))
def panel_title(ax, text, fs=8.6):
ax.set_title(text, fontsize=fs, loc="left", color=style.INK, fontweight="bold", pad=3)
def under_note(ax, text, fs=6.9):
ax.text(0.5, -0.02, text, transform=ax.transAxes, ha="center", va="top", fontsize=fs, color=style.INK,
linespacing=1.2)
# ---------------------------------------------------------------- motion: keyframes, frame strips, video
def set_tracks(p, tracks, t_end=None):
"""Hand-made keyframes {key: [(t, d, w), ...]} for some players, after all routes are added.
gridiron's man() ends on top of the receiver and cannot be slowed by a jam, so the jammed
receiver, the jamming corner and the defender who jumps the route are keyframed by hand."""
kf, t0, t1 = p._compile()
kf = dict(kf)
for key, rows in tracks.items():
q = p.players[key]
rows = [(min(t0, -1.0), q.d, q.w), (0.0, q.d, q.w)] + [tuple(r_) for r_ in rows]
kf[key] = np.array(rows, dtype=float)
p._compiled = (kf, t0, max(t1, t_end or 0.0, *[rows[-1][0] for rows in tracks.values()]))
def ease(points, step=0.05):
"""Smooth keyframes through (t, d, w) waypoints (ease in and out on each leg)."""
rows = []
for (ta, da, wa), (tb, db, wb) in zip(points[:-1], points[1:]):
for tt in np.arange(ta, tb, step):
f = (tt - ta) / (tb - ta)
f = f * f * (3 - 2 * f)
rows.append((round(float(tt), 3), da + (db - da) * f, wa + (wb - wa) * f))
rows.append(tuple(points[-1]))
return rows
def catch_time(p, which=0):
"""When the ball arrives, the way gridiron times a pass (ball speed and the receiver's path)."""
from gridiron.play import BALL_SPEED, _interp
kf, _, _ = p._compile()
kind, to, te = [e for e in p.ball_events if e[0] == "pass"][which]
start, t_arr = p.ball_position(te), te + 0.5
for _ in range(4):
t_arr = te + float(np.linalg.norm(_interp(kf[to], t_arr) - start)) / BALL_SPEED
return round(t_arr, 2)
def frame(ax, p, t, lateral, window, rings=(), ltg=False, mr=0.7):
"""Everyone at time t, with faint trails of where they have been and the ball's flight so far."""
ylo, yhi = sorted((p.ball_y - lateral[0], p.ball_y - lateral[1]))
fld = Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=False, hashes=False,
lateral=(ylo, yhi), to_go=p.to_go if ltg else None)
ppy = fld.pts_per_yard()
wl, wr = sorted(lateral)
ring_of = dict(rings)
for k, q in p.players.items():
d, w = p.position(k, t)
if not (window[0] + 0.4 < d < window[1] - 0.4 and wl + 0.4 < w < wr - 0.4):
continue
if t > 0:
pts = np.array([p.position(k, tt) for tt in np.linspace(0.0, t, 40)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) > 0.3:
xs, ys = P(fld, p, pts[:, 0], pts[:, 1])
ax.plot(xs, ys, color=style.OFFENSE if q.side == "off" else style.DEFENSE, lw=1.0,
alpha=0.5, zorder=3)
x, y = P(fld, p, d, w)
draw_marker(ax, float(x), float(y), q.side, q.label, ppy, ring=ring_of.get(k), r=mr)
for kind, to, te in p.ball_events:
if kind == "pass" and t > te:
ts = np.linspace(te, t, 20)
b = np.array([p.ball_position(tt) for tt in ts])
xs, ys = P(fld, p, b[:, 0], b[:, 1])
ax.plot(xs, ys, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9)
ax.add_patch(Ellipse((float(xs[-1]) - 0.9, float(ys[-1])), 1.0, 0.62, fc=style.BALL, ec="white",
lw=0.5, zorder=11))
clean(ax)
return fld
def strip_grid(p, times, notes, lateral, window, rings=(), ncols=2, width_in=6.5, deco=None, fs=6.9, mr=0.7):
"""Print stand-in for an animation: numbered panels, each with a sentence underneath."""
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
ph = pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0])
fig, axes = plt.subplots(nrows, ncols, squeeze=False, figsize=(width_in, nrows * (ph + 0.85)))
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
fld = frame(ax, p, t, lateral, window, rings, mr=mr)
if deco:
deco(t, fld, p)
lab = "before the snap" if t < -1e-6 else ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s")
panel_title(ax, f"{i + 1}. {lab}")
under_note(ax, notes[i])
fig.tight_layout(h_pad=3.0, w_pad=0.8)
return fig
def video(p, lateral, window, t_end, rings=(), width_in=5.6, t_start=-0.3, notes=None, times=None, deco=None,
mr=0.7):
"""Web version of a strip: the same frame drawn every tenth of a second, the strip's notes as captions."""
from IPython.display import HTML, display
span_w, span_d = abs(lateral[1] - lateral[0]), window[1] - window[0]
fig, ax = plt.subplots(figsize=(width_in, width_in * span_d / span_w + 0.5))
ts = np.round(np.arange(t_start, t_end + 1e-9, 0.1), 2)
def update(i):
ax.clear()
t = float(ts[i])
fld = frame(ax, p, max(0.0, t), lateral, window, rings, mr=mr)
if deco:
deco(max(0.0, t), fld, p)
txt = "before the snap" if t < -1e-6 else ("snap" if abs(t) < 1e-6 else f"+{t:.1f} s")
if notes and times:
cur = [n for tt, n in zip(times, notes) if tt <= t + 1e-6]
if cur:
txt += " " + cur[-1].replace("\n", " ")
ax.set_title(txt, fontsize=8, loc="left", color=style.INK)
return []
fig.tight_layout()
anim = animation.FuncAnimation(fig, update, frames=len(ts), interval=100, blit=False)
html = (anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
if shutil.which("ffmpeg") else anim.to_jshtml(default_mode="once"))
plt.close(fig)
display(HTML(html))
def pct(x, nd=0):
return f"{100 * x:.{nd}f}%"