import os
import logging
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch, FancyArrowPatch, Ellipse
from matplotlib.lines import Line2D
from gridiron import Play, 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
HALF = FIELD_W / 2 # 26.7 yards from the middle of the field to either sideline
# ---------------------------------------------------------------- alignments used all chapter
def c2_shell(off, corner_d=5.5, corner_out=1.3, safety_d=13.0, safety_w=9.5, press=False):
"""Nickel (4-2-5) two-high shell against `off`. Corners squat 5-7 yards off, a step outside
the widest receiver on each side (outside leverage); safeties deep and between the hash and
the numbers; the Will and the nickel ($) split the difference between the tackles and the
slots; the Mike sits over the ball. press=True walks the corners up to a yard off."""
wide = [p for p in off if p.pos in ("WR", "TE") and abs(p.w) > 6]
wl = min(p.w for p in wide if p.w < 0)
wr = max(p.w for p in wide if p.w > 0)
cd = 1.2 if press else corner_d
moves = {"LCB": (cd, wl - corner_out), "RCB": (cd, wr + corner_out),
"NB": (5.0, 7.0), "WILL": (5.0, -6.5), "MIKE": (4.5, 0.5),
"FS": (safety_d, -safety_w), "SS": (safety_d, safety_w)}
out = []
for p in defense("nickel", "two_high", off):
if p.key in moves:
p = p.moved(d=moves[p.key][0], w=moves[p.key][1])
if p.pos == "CB":
p = p.moved(label="CB")
out.append(p)
return out
def protect(p, rushers=("SDE", "SDT", "WDT", "WDE")):
"""Five linemen set, four rushers engage them: enough to show it is a pass, no more."""
for k in ("LT", "LG", "C", "RG", "RT"):
p.block(k, pts=[(-1.2, 0.0)])
for k in rushers:
p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
def no_numbers(ax):
"""Remove the painted yard-line numbers (they sit where wide receivers and corners stand)."""
for t in list(ax.texts):
if t.get_alpha() == 0.55:
t.remove()
def ruler(fld, play, widths=False, fs=6.5, side="left"):
"""Depth labels (yards past the line of scrimmage) down the left edge of the window, and
optionally where the hash marks and the painted numbers are along the bottom edge.
gridiron has no ruler, so it is drawn here on the Field's axes."""
ax = fld.ax
x_edge = FIELD_W - fld.y1 + 0.35 if side == "left" else FIELD_W - fld.y0 - 0.35
for d in range(5, 41, 5):
y = play.los + d
if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
ax.text(x_edge, y, f"{d}" + (" yd" if d == 5 else ""), fontsize=fs, color=style.INK_2,
ha=side, va="center", zorder=12,
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
if widths:
y = fld.x0 + 0.9
for yb, txt in ((23.58, "hash"), (FIELD_W - 23.58, "hash"), (13.0, "numbers"), (FIELD_W - 13.0, "numbers")):
if fld.y0 + 2.5 <= yb <= fld.y1 - 2.5:
ax.text(FIELD_W - yb, y, txt, fontsize=fs, color=style.INK_2, ha="center", va="center",
zorder=12, style="italic",
bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))
def pointer(fld, play, text, text_at, to, ha="center", fs=7.5):
"""A labelled arrow from a text box to a spot on the field (both in (d, w) yards)."""
tx, ty = fld.xy(*play.to_abs(*text_at))
px, py = fld.xy(*play.to_abs(*to))
fld.ax.annotate(text, xy=(px, py), xytext=(tx, ty), ha=ha, va="center", fontsize=fs, color=style.INK,
zorder=12, arrowprops=dict(arrowstyle="-|>", color=style.INK_2, lw=0.9, shrinkB=6),
bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=style.BASELINE, lw=0.6))
def say(fld, play, text, at, fs=7.0, color=None, ha="center", weight="normal", box=True):
"""Plain on-field text at (d, w)."""
x, y = fld.xy(*play.to_abs(*at))
fld.ax.text(x, y, text, ha=ha, va="center", fontsize=fs, color=color or style.INK, zorder=12,
fontweight=weight,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec="none", alpha=0.95) if box else None)
def trim(ax):
"""Drop text labels whose anchor lies outside the drawn window (players cropped out of view)."""
x0, x1 = sorted(ax.get_xlim())
y0, y1 = sorted(ax.get_ylim())
for t in list(ax.texts):
x, y = t.get_position()
if t.get_transform() == ax.transData and not (x0 <= x <= x1 and y0 <= y <= y1):
t.remove()
def zlabel(fld, play, text, at, fs=7.0):
"""An italic zone name at (d, w), placed by hand so it never sits on a player."""
x, y = fld.xy(*play.to_abs(*at))
fld.ax.text(x, y, text, ha="center", va="center", fontsize=fs, color=style.INK_2, style="italic", zorder=3)
def hole(fld, play, center, size, text, fs=7.2):
"""A green 'thin spot' in the coverage, with its label."""
cx, cy = fld.xy(*play.to_abs(*center))
fld.ax.add_patch(Ellipse((cx, cy), size[1], size[0], facecolor=style.THIRD, alpha=0.16,
edgecolor=style.THIRD, lw=1.2, zorder=2))
fld.ax.text(cx, cy, text, ha="center", va="center", fontsize=fs, color=style.INK, fontweight="bold",
zorder=6)
# ---------------------------------------------------------------- moments of a play, for print
def snapshot(play, t, ax=None, lateral="full", window=(-8.5, 24.0), width_in=6.5, notes=(), show_ruler=False,
ruler_side="left"):
"""Everyone where he is at time t, with faint trails of where he has been and the ball's flight."""
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)
if show_ruler:
ruler(fld, play, fs=5.5, side=ruler_side)
P = lambda d, w: fld.xy(*play.to_abs(d, w)) # noqa: E731
start = play._compile()[1]
for k, pl in play.players.items():
if pl.side == "off" and pl.pos not in ("WR", "TE", "RB"):
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=1.0, alpha=0.45, 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))
for kind, _, t_rel in play.ball_events:
if kind == "pass" and t > t_rel:
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))
passes = [t_rel for kind, _, t_rel in play.ball_events if kind == "pass"]
if not passes or t > passes[0]: # before the throw the ball is in the QB's hands: don't hide him
draw_marker(ax, float(bx), float(by), "ball", "", ppy)
for text, at in notes:
say(fld, play, text, at, fs=6.4)
trim(ax)
return fld
def strip(play, times, labels, lateral=(-HALF, HALF), window=(-8.5, 24.0), notes_at=None, width_in=6.5, ncols=2,
ruler_side="left", extras=None):
"""The print version of an animation: one panel per chosen moment. `extras` maps a panel index to a
function f(fld) that draws anything else on that panel (a shaded spot, an arrow)."""
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.32)
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)):
fld = snapshot(play, t, ax=ax, lateral=lateral, window=window, notes=(notes_at or {}).get(i, ()),
show_ruler=(i == 0), ruler_side=ruler_side)
if extras and i in extras:
extras[i](fld)
ax.set_title(lab, fontsize=8.5, color=style.INK, loc="left")
fig.tight_layout(h_pad=0.6, w_pad=0.4)
h = [Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="Defender's path so far"),
Line2D([], [], color=style.OFFENSE, lw=1.0, alpha=0.6, label="Receiver's path so far"),
Line2D([], [], color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), label="The ball")]
fig.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=3, fontsize=7.5, frameon=False)
return fig