import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, FancyArrowPatch, RegularPolygon, Patch
from matplotlib.lines import Line2D
from matplotlib.colors import ListedColormap, BoundaryNorm, LinearSegmentedColormap, TwoSlopeNorm
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")
GREEN = "#0f7a55" # darker step of the aqua highlight: text, the QB's eyes, open windows
OD, DD = style.OFFENSE_DARK, style.DEFENSE_DARK
LOS = 40 # the offense's own 30: room for deep routes
SPLIT = 1.6 # center-to-guard and guard-to-tackle spacing, as in the rest of the book
# --------------------------------------------------------------- players and alignments
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 line():
"""The five offensive linemen, 1.6 yards apart; only the center is lettered."""
keys = ["LT", "LG", "C", "RG", "RT"]
return [O(k, k, -0.7, (i - 2) * SPLIT, "C" if k == "C" else "") for i, k in enumerate(keys)]
def spread(rb_w=-1.6, **moves):
"""11 personnel, shotgun 2x2: X and Z on the line outside (seven on the line with the five
linemen), H (left slot) and Y (a tight end flexed into the right slot) a step off it."""
off = line() + [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)]
return [p.moved(d=moves[p.key][0], w=moves[p.key][1]) if p.key in moves else p for p in off]
SHELLS = {
# two-high: safeties 13 deep a few yards outside the hashes, corners 7 off with outside leverage
"two": {"FS": (13.0, -8.0), "SS": (13.0, 8.0), "LCB": (7.0, -15.8), "RCB": (7.0, 15.8)},
# Cover 2 look: squat corners 5 off, safeties a little wider
"c2": {"FS": (13.0, -8.5), "SS": (13.0, 8.5), "LCB": (5.0, -16.3), "RCB": (5.0, 16.3)},
# one-high: free safety in the middle, strong safety down over Y
"one": {"FS": (13.5, 0.0), "SS": (7.0, 8.4), "LCB": (7.0, -15.8), "RCB": (7.0, 15.8), "NB": (5.0, -8.6)},
# man-free: pressed corners, nickel tight on H, strong safety over Y, free safety deep
"man": {"FS": (14.0, 0.0), "SS": (5.0, 8.6), "LCB": (1.2, -14.4), "RCB": (1.2, 14.4), "NB": (1.6, -8.4)},
}
def nickel(shell="two", **moves):
"""A 4-2-5 nickel against the 2x2: ends outside the tackles, a 1-technique and a 3-technique,
Mike and Will at 4.5 yards, the nickel ($) over H; the secondary from SHELLS[shell].
`moves` = {key: (d, w)} overrides any spot."""
base = {"LDE": (1.0, -5.5), "LDT": (1.0, -0.7), "RDT": (1.0, 2.35), "RDE": (1.0, 5.5),
"WILL": (4.5, -2.6), "MIKE": (4.5, 1.8), "NB": (5.0, -8.6)}
base.update(SHELLS[shell])
base.update(moves)
lab = {"LDE": ("DE", "E"), "LDT": ("DT", "T"), "RDT": ("DT", "T"), "RDE": ("DE", "E"),
"WILL": ("WILL", "W"), "MIKE": ("MIKE", "M"), "NB": ("NB", "$"), "LCB": ("CB", "CB"),
"RCB": ("CB", "CB"), "FS": ("FS", "FS"), "SS": ("SS", "SS")}
return [D(k, lab[k][0], *base[k], lab[k][1]) for k in lab]
def keep(players, lo, hi):
"""Only the players whose width is inside (lo, hi): half-field plates leave the rest out, because
gridiron draws a marker even when it falls outside a zoomed window."""
return [q for q in players if lo <= q.w <= hi]
# --------------------------------------------------------------- scripting movement
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
a timetable into them.)"""
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 route_to(p, key, *pts, speed=None, delay=0.0):
"""A route through absolute (d, w) points."""
return p.route(key, list(pts), absolute=True, speed=speed, delay=delay)
def protect(p, t=0.7):
"""Five linemen set, four rushers engage them: enough to show it is a pass, no more."""
for k, w in zip(["LT", "LG", "C", "RG", "RT"], (-3.9, -1.7, 0.0, 1.7, 3.9)):
if k in p.players:
timed(p, k, [(t, -1.7, w)], "block")
for k, (d, w) in {"LDE": (-1.4, -4.7), "LDT": (-0.6, -1.0), "RDT": (-0.6, 2.0), "RDE": (-1.4, 4.7)}.items():
if k in p.players:
timed(p, k, [(t, d, w)], "rush")
def presnap(p):
"""gridiron starts the clock at the first pre-snap movement: a tiny Mike shuffle gives the play
pre-snap frames to draw, while nobody else moves."""
m = p.players["MIKE"]
p.motion("MIKE", to=(m.d, m.w - 0.2), speed=0.12)
# --------------------------------------------------------------- drawing helpers
def _P(fld, p, d, w):
return fld.xy(*p.to_abs(d, w))
def no_numbers(ax):
"""Remove 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 say(fld, p, d, w, text, color=style.INK_2, fontsize=7.0, 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=12,
fontweight=weight,
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.8, shrink=7, 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=12,
arrowprops=dict(arrowstyle="-", color=color, lw=0.7, shrinkA=2, shrinkB=shrink),
bbox=dict(boxstyle="round,pad=0.18", fc=style.SURFACE, ec="none", alpha=0.92))
def opening(fld, p, center, size, color=style.THIRD, alpha=0.22):
"""A green patch: the window the throw goes into. size = (depth, width) in yards."""
cx, cy = _P(fld, p, *center)
fld.ax.add_patch(Ellipse((float(cx), float(cy)), size[1], size[0], fc=color, alpha=alpha, ec=color,
lw=0.9, ls=(0, (2, 1.5)), zorder=2.2))
def num(fld, p, n, at, color=GREEN, fs=7.4):
"""A progression number in a small circle."""
x, y = _P(fld, p, *at)
fld.ax.text(float(x), float(y), str(n), fontsize=fs, color="white", ha="center", va="center", zorder=13,
fontweight="bold", bbox=dict(boxstyle="circle,pad=0.18", fc=color, ec="none"))
def ruler(fld, p, fs=6.0, top=30):
"""Depth labels (yards past the line of scrimmage) down the left edge of the window."""
W = 160 / 3
x_left = W - fld.y1 + 0.35
for d in range(5, top + 1, 5):
y = p.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 eyes(fld, p, frm, to, frac=0.62, color=GREEN, label="eyes", fs=5.6, label_off=(1.4, 0.0)):
"""A dotted arrow from the quarterback toward where he is looking (gridiron's read line runs
player to player; this one can point at an area)."""
a, b = np.array(frm, float), np.array(to, float)
tip = a + (b - a) * frac
(x0, y0), (x1, y1) = _P(fld, p, a[0] + 0.9, a[1]), _P(fld, p, *tip)
fld.ax.annotate("", (float(x1), float(y1)), xytext=(float(x0), float(y0)), zorder=8,
arrowprops=dict(arrowstyle="-|>", color=color, lw=1.3, ls=(0, (1, 1.4))))
if label:
say(fld, p, tip[0] + label_off[0], tip[1] + label_off[1], label, color=color, fontsize=fs)
def ghost_marker(ax, x, y, label, ppy, color=style.DEFENSE):
"""A hollow, dashed defender: where he stood before the snap."""
ax.add_patch(RegularPolygon((x, y), 4, radius=MARKER_R * 1.25, orientation=np.pi / 4,
facecolor="none", edgecolor=color, lw=1.1, ls=(0, (2, 1.2)), zorder=6))
if label:
ax.text(x, y, label, ha="center", va="center", color=color, fontweight="bold", zorder=6,
fontsize=max(4.0, min(8.0, ppy * 0.7)))
def draw(p, ax=None, lateral=(-21, 21), window=(-8.0, 24.0), width_in=6.5, rule=True, legend=False):
"""A static plate: the Play's own diagram, without the painted numbers, with a depth ruler."""
fld = p.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False, legend=legend)
no_numbers(fld.ax)
if rule:
ruler(fld, p)
return fld
# --------------------------------------------------------------- frame strips for print
def snapshot(play, t, ax=None, ghosts=(), ghost_t=0.0, lateral=(-21, 21), window=(-9.0, 21.0),
width_in=6.5, notes=(), look=None, look_off=(1.4, 0.0), look_label="eyes", opens=(), trail_from=None):
"""Everyone where he is at time t, with faint trails, hollow markers where `ghosts` stood at
`ghost_t`, an optional QB eye-line `look`=(d, w), green windows `opens` [(center, size)] and
`notes` [(text, (d, w)) or (text, (d, w), (d, w) of the player it names)]."""
pos = {k: play.position(k, t) for k in play.players}
moved = [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 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)
fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
ax, ppy = fld.ax, fld.pts_per_yard()
no_numbers(ax)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
for c, s in opens:
opening(fld, play, c, s)
start = trail_from if trail_from is not None else play._compile()[1]
for k, pl in play.players.items():
if pl.side == "off" and pl.pos in ("LT", "LG", "C", "RG", "RT"):
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 = P(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)
if t > ghost_t + 0.05:
for k in ghosts:
x0, y0 = P(*play.position(k, ghost_t))
ghost_marker(ax, float(x0), float(y0), play.players[k].label, ppy)
if look is not None:
eyes(fld, play, play.position("QB", t), look, label=look_label, label_off=look_off)
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.3, ls=(0, (2, 1.5)), zorder=9))
if thrown or t <= 0: # in the quarterback's hands the ball would hide his label
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
for note in notes:
text, (d, w) = note[0], note[1]
x, y = P(d, w)
box = dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=style.BASELINE, lw=0.5)
if len(note) > 2:
xp, yp = P(*note[2])
ax.annotate(text, (float(xp), float(yp)), xytext=(float(x), float(y)), fontsize=5.8, 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=5.8, color=style.INK, ha="center", va="center", zorder=13,
bbox=box)
return fld
STRIP_KEY = [Patch(facecolor="none", edgecolor=style.DEFENSE, lw=1.1, ls=(0, (2, 1.2)),
label="Dashed outline: where he stood before the snap"),
Line2D([], [], color=GREEN, lw=1.3, ls=(0, (1, 1.4)), label="The quarterback's eyes"),
Line2D([], [], color=style.DEFENSE, lw=1.1, ls=(0, (3, 2)), label="Defender's path so far"),
Line2D([], [], color=style.OFFENSE, lw=1.0, alpha=0.6, label="Offensive player's path so far"),
Line2D([], [], color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), label="The pass"),
Patch(facecolor=style.THIRD, alpha=0.3, edgecolor=style.THIRD, label="The window the ball goes to")]
def strip(play, times, labels, per_panel=None, ncols=2, width_in=6.5, lateral=(-21, 21), window=(-9.0, 21.0),
legend=STRIP_KEY, **kw):
"""The print version of an animation: panels at chosen moments of one simulated play.
`per_panel` = {i: dict(notes=..., look=..., opens=...)}."""
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)):
extra = (per_panel or {}).get(i, {})
snapshot(play, t, ax=ax, lateral=lateral, window=window, **kw, **extra)
ax.set_title(lab, fontsize=8.0, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
if legend:
fig.legend(handles=legend, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=2, fontsize=7.2,
frameon=False)
return fig
def animation_or_strip(play, times, labels, web_times=None, **kw):
"""A video on the website; the numbered frame strip in print."""
if PDF:
strip(play, times, labels, **kw)
plt.show()
else:
show_animation(play, times=web_times or times)