import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Patch
from matplotlib.lines import Line2D
from gridiron import Play, Player, style, draw_marker, show_animation, routes as r
from gridiron.play import MARKER_R
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
PURPLE = style.SERIES[6] # the spy: a ring colour that is neither offense blue nor defense orange
SPLIT = 1.6 # center-to-guard and guard-to-tackle spacing, as in the rest of the book
FIELD_W = 160 / 3
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 oline():
"""The five offensive linemen; only the center is labelled (wide pictures overprint the rest)."""
return [O(k, k, -0.7, (i - 2) * SPLIT, "C" if k == "C" else "")
for i, k in enumerate(["LT", "LG", "C", "RG", "RT"])]
def spread(rb_w=-1.6):
"""11 personnel, shotgun 2x2. X and Z on the line, H (slot) and Y (the tight end, flexed out
like a second slot) a step off it: seven on the line (five linemen, X and Z)."""
return oline() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, rb_w, "R"),
O("X", "WR", -0.7, -15.0), O("H", "WR", -1.5, -9.0),
O("Y", "TE", -1.5, 9.0), O("Z", "WR", -0.7, 15.0)]
def trips():
"""11 personnel, shotgun 3x1 to the right. X alone on the left, on the line; the tight end Y attached
to the right tackle, on the line (so he is the end); H and Z off the ball outside him. Numbered from
the sideline in, #1 is Z, #2 is H, #3 is Y. Seven on the line: five linemen, X and Y."""
return oline() + [O("QB", "QB", -5.0, 0.0, "Q"), O("RB", "RB", -5.0, -1.6, "R"),
O("X", "WR", -0.7, -15.0), O("Y", "TE", -0.7, 4.8),
O("H", "WR", -1.5, 10.0), O("Z", "WR", -1.5, 16.0)]
def four_down(lde=-5.6, ldt=-0.7, rdt=2.35, rde=5.6):
"""Four defensive linemen: ends outside the tackles, a 1-technique and a 3-technique."""
return [D("LDE", "DE", 1.0, lde, "E"), D("LDT", "DT", 1.0, ldt, "T"), D("RDT", "DT", 1.0, rdt, "T"),
D("RDE", "DE", 1.0, rde, "E")]
def P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def say(fld, p, d, w, text, color=style.INK, fontsize=6.8, ha="center"):
"""A short boxed 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,
bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))
def point_to(fld, p, text_at, target, text, fontsize=6.8, ha="center", shrink=7):
"""A boxed label with a thin leader line to the player or spot it names (both in (d, w))."""
(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=style.INK, ha=ha, va="center", zorder=13,
arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=2, shrinkB=shrink),
bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=style.BASELINE, lw=0.5))
def clean(ax):
"""Drop the painted yard-line numbers (they sit where receivers and safeties stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def ruler(fld, play, fs=6.3, upto=40):
"""Depth labels (yards past the line of scrimmage) down the left edge of the field window, so the
faint 5-yard lines read as a scale. Drawn on the Field's ax: gridiron has no ruler."""
ax = fld.ax
x_left = FIELD_W - fld.y1 + 0.35
first = True
for d in range(5, upto + 1, 5):
y = play.los + d
if play.to_go is not None and abs(d - play.to_go) < 1.0:
continue # the orange line to gain would run through the label
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
ax.text(x_left, y, f"{d}" + (" yd" if first 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))
first = False
def timed(p, key, way, kind="path"):
"""Script a player: `way` = [(t, d, w), ...] absolute spots reached at time t (s after the snap).
Standing still between two times is allowed. (gridiron's actions are speed-based; this turns
'be here at this moment' into the speed and delay each leg needs.)"""
cur = np.array(p._cur(key), dtype=float)
tc, wait = 0.0, 0.0
for t, d, w in way:
nxt = np.array([d, w], dtype=float)
dist = float(np.hypot(*(nxt - cur)))
if dist < 1e-3:
wait += t - tc
else:
p._add(kind, key, [tuple(cur), tuple(nxt)], speed=dist / (t - tc), delay=wait)
wait = 0.0
cur = nxt
tc = t
return p
def protect(p, keys=("LT", "LG", "C", "RG", "RT"), back=1.2):
"""Five linemen set back in pass protection: enough to say 'pass', no more."""
for k in keys:
p.block(k, pts=[(-back, 0.0)])
# --------------------------------------------------------------- frame strips for print
def snapshot(play, t, ax=None, lateral=(-21, 21), window=(-9.0, 17.0), width_in=6.5, notes=(), trail_from=None,
ruler_fs=5.2):
"""Everyone where he is at time t, with faint trails of where he has been (since `trail_from`, if given),
and the depth ruler down the left edge."""
pos = {k: play.position(k, t) for k in play.players}
mv = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()]
q = Play([m for m in mv if m.side == "off"], [m for m in mv if m.side == "def"],
los=play.los, ball_y=play.ball_y, to_go=play.to_go)
q.highlights = dict(play.highlights)
fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
clean(ax)
if ruler_fs:
ruler(fld, play, fs=ruler_fs, upto=int(window[1]))
PP = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
start = play._compile()[1] if trail_from is None else trail_from
for k, pl in play.players.items():
if pl.side == "off" and pl.pos in ("LT", "LG", "C", "RG", "RT"):
continue
if t <= start:
continue
pts = np.array([play.position(k, tt) for tt in np.linspace(start, t, 30)])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = PP(pts[:, 0], pts[:, 1])
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=0.9, alpha=0.45, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.1, ls=(0, (3, 2)), zorder=3)
handed = any(kind == "handoff" and t >= te for kind, _, te in play.ball_events)
if t <= 0 or handed: # in the quarterback's hands the ball would hide his label
bx, by = PP(*play.ball_position(t))
draw_marker(ax, float(bx) - (0.9 if handed else 0.0), float(by), "ball", "", ppy)
for note in notes: # (text, (d, w)) or (text, (d, w), (d, w) of the player it names)
text, (d, w) = note[0], note[1]
x, y = PP(d, w)
box = dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=style.BASELINE, lw=0.5)
if len(note) > 2:
xp, yp = PP(*note[2])
ax.annotate(text, (float(xp), float(yp)), xytext=(float(x), float(y)), fontsize=6.0, color=style.INK,
ha="center", va="center", zorder=13, bbox=box,
arrowprops=dict(arrowstyle="-", color=style.INK_2, lw=0.7, shrinkA=1, shrinkB=5))
else:
ax.text(float(x), float(y), text, fontsize=6.0, color=style.INK, ha="center", va="center", zorder=13,
bbox=box)
return fld
def strip(play, times, labels, notes_at=None, ncols=2, width_in=6.5, lateral=(-21, 21), window=(-9.0, 17.0),
trails_from=None):
"""The print version of an animation: panels at chosen moments of one simulated play.
`trails_from` = {panel index: time} starts that panel's trails later, so only the late movement shows."""
n = len(times)
nrows = int(np.ceil(n / ncols))
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(play, t, ax=ax, lateral=lateral, window=window, notes=(notes_at or {}).get(i, ()),
trail_from=(trails_from or {}).get(i))
ax.set_title(lab, fontsize=8.2, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
return fig
pct = lambda x, k=0: f"{100 * x:.{k}f}%" # noqa: E731
def ordinal(n):
n = int(n)
if n == 1:
return "first"
if n == 2:
return "second"
if n == 3:
return "third"
return f"{n}{'th' if 10 <= n % 100 <= 20 else {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')}"