import os
import logging
import textwrap
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import animation
from matplotlib.patches import Circle, Ellipse, FancyArrowPatch, Polygon, RegularPolygon, Rectangle
from matplotlib.lines import Line2D
import gridiron.play as gplay
from gridiron import Play, Player, Field, style
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
# gridiron's default ball speed (18 yd/s) averages a whole flight; the firm 20-25 yard throws in this
# chapter travel a little faster, so the simulated plays use 20.
gplay.BALL_SPEED = 20.0
GREEN = "#0f7a55" # darker step of the aqua highlight: reads, the quarterback's moves
PURPLE = style.SERIES[6] # a ring for the defender who decides the read
RED = style.SERIES[7] # danger: a rush that got home, an illegal throw
SPLIT = 1.6 # center-to-guard and guard-to-tackle spacing, as in the rest of the book
OL_KEYS = ["LT", "LG", "C", "RG", "RT"]
NONROUTE = {"LT", "LG", "C", "RG", "RT", "DE", "DT", "NT"}
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 (only the center is lettered, to keep the box readable)."""
return [O(k, k, -0.7, (i - 2) * SPLIT, "C" if k == "C" else "") for i, k in enumerate(OL_KEYS)]
def four_rush():
"""A generic four-man rush: two ends outside the tackles, two tackles inside."""
return [D("LE", "DE", 1.0, -5.4, "E"), D("NT", "DT", 1.0, -0.7, "T"), D("DT", "DT", 1.0, 2.35, "T"),
D("RE", "DE", 1.0, 5.4, "E")]
def timed(p, key, way, kind="path"):
"""Script a player exactly: `way` = [(t, d, w), ...] absolute spots reached at t seconds after the
snap. Repeating a spot means 'stand there until t'. (gridiron's route helpers run at a fixed speed;
the quarterback's clock is the subject of this chapter, so here every move is timed by hand.)"""
cur = np.array(p._cur(key), dtype=float)
tc, wait = 0.0, 0.0
if any(a.key == key for a in p.actions): # continue from where his earlier moves end
tc = max(0.0, float(p._compile()[0][key][-1, 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 frame_field(ax, p, lateral, window):
ylo, yhi = sorted((p.ball_y - lateral[0], p.ball_y - lateral[1]))
return Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=False, hashes=False,
lateral=(ylo, yhi), to_go=p.to_go)
def XY(fld, p, d, w):
x, y = fld.xy(*p.to_abs(d, w))
return float(x), float(y)
def dot(ax, x, y, side, label, ppy, r=0.9, ring=None, alpha=1.0, z=8):
"""One player: offense a filled blue circle, defense an outlined orange square."""
if side == "off":
ax.add_patch(Circle((x, y), r, fc=style.OFFENSE, ec="white", lw=0.6, zorder=z, alpha=alpha))
tc = "white"
else:
ax.add_patch(RegularPolygon((x, y), 4, radius=r * 1.25, orientation=np.pi / 4, fc=style.DEFENSE_TINT,
ec=style.DEFENSE, lw=1.0, zorder=z, alpha=alpha))
tc = style.INK
if ring:
ax.add_patch(Circle((x, y), r * 1.65, fc="none", ec=ring, lw=1.7, zorder=z - 0.5))
if label:
fs = max(4.6, min(8.5, ppy * (1.15 if len(label) <= 1 else 0.85)))
ax.text(x, y, label, ha="center", va="center", fontsize=fs, color=tc, fontweight="bold", zorder=z + 2)
def place_labels(ax, xs, ys, labels, fs=6.8, offs=None):
"""Greedy label placement for scatter charts: try offsets around each point until the label hits no
point, no earlier label and stays inside the axes."""
fig = ax.figure
fig.canvas.draw()
ren = fig.canvas.get_renderer()
pts = ax.transData.transform(np.column_stack([xs, ys]))
box = ax.get_window_extent(ren)
offs = offs or [(5, 3), (5, -9), (-5, 3), (-5, -9), (6, -3), (-6, -3), (0, 6), (0, -12), (9, 8), (-9, 8),
(9, -13), (-9, -13), (12, 0), (-12, 0)]
placed = [t.get_window_extent(ren) for t in ax.texts] # never cover text already on the chart
if ax.get_legend() is not None:
placed.append(ax.get_legend().get_window_extent(ren))
for x, y, lab in zip(xs, ys, labels):
for dx, dy in offs:
t = ax.annotate(lab, (x, y), xytext=(dx, dy), textcoords="offset points", fontsize=fs, color=style.INK,
ha="left" if dx > 0 else ("right" if dx < 0 else "center"), zorder=4)
bb = t.get_window_extent(ren).expanded(1.04, 1.1)
ok = (not any(bb.contains(px, py) for px, py in pts) and not any(bb.overlaps(o) for o in placed)
and box.contains(bb.x0, bb.y0) and box.contains(bb.x1, bb.y1))
if ok:
placed.append(bb)
break
t.remove()
return placed
def football(ax, x, y):
ax.add_patch(Ellipse((x, y), 1.0, 0.62, fc=style.BALL, ec="white", lw=0.6, zorder=11))
def arrow(fld, p, pts, color=GREEN, lw=1.8, ls="-", head=10, z=9, alpha=1.0):
"""A path through (d, w) points with an arrowhead at the end."""
xy = [XY(fld, p, *q) for q in pts]
xs, ys = zip(*xy)
fld.ax.plot(xs[:-1] + (xs[-1],), ys[:-1] + (ys[-1],), color=color, lw=lw, ls=ls, zorder=z,
solid_capstyle="round", alpha=alpha)
fld.ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=head, color=color, lw=0,
shrinkA=0, shrinkB=0, zorder=z, alpha=alpha))
def say(fld, p, d, w, text, color=style.INK, fs=7.0, ha="center", weight="normal", box=True):
x, y = XY(fld, p, d, w)
fld.ax.text(x, y, text, fontsize=fs, color=color, ha=ha, va="center", zorder=12, fontweight=weight,
linespacing=1.15,
bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec="none", alpha=0.92) if box else None)
def badge(ax, x, y, n, state, fs=8.0):
"""A numbered read. wait: not looked at yet; now: his eyes are here; done: looked at and passed over."""
fc, ec, tc = {"wait": (style.SURFACE, style.MUTED, style.INK_2), "now": (GREEN, GREEN, "white"),
"done": (style.GRID, style.BASELINE, style.MUTED)}[state]
ax.text(x, y, str(n), ha="center", va="center", fontsize=fs, fontweight="bold", color=tc, zorder=13,
bbox=dict(boxstyle="circle,pad=0.22", fc=fc, ec=ec, lw=1.1))
def clean(ax):
"""Static diagrams drawn with Play.draw: drop hash ticks and yard numbers, clip 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 t.get_text().isdigit() and t.get_alpha() == 0.55:
t.remove()
else:
t.set_clip_on(True)
def moment(ax, p, t, lateral, window, rings=None, plan=None, ghosts=(), trail_keys=None, r=0.9):
"""Everyone where he is at time t, faint trails of where he has been, the ball, and (optionally)
the called routes as faint grey dashes, so a single panel shows both the plan and the present."""
fld = frame_field(ax, p, lateral, window)
ppy = fld.pts_per_yard()
lo, hi = sorted(lateral)
inside = lambda d, w: window[0] + 0.6 <= d <= window[1] - 0.6 and lo + 0.6 <= w <= hi - 0.6 # noqa: E731
for pts in (plan or {}).values():
xy = [XY(fld, p, *q) for q in pts]
xs, ys = zip(*xy)
ax.plot(xs, ys, color=style.MUTED, lw=0.9, ls=(0, (2, 2)), alpha=0.75, zorder=2)
ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=6, color=style.MUTED, lw=0,
alpha=0.75, zorder=2))
if t > 0:
ts = np.linspace(0, t, 32)
for k, pl in p.players.items():
if (pl.pos in NONROUTE) if trail_keys is None else (k not in trail_keys):
continue
pts = np.array([p.position(k, tt) for tt in ts])
if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
continue
xs, ys = fld.xy(*p.to_abs(pts[:, 0], pts[:, 1]))
if pl.side == "off":
ax.plot(xs, ys, color=style.OFFENSE, lw=1.1, alpha=0.5, zorder=3)
else:
ax.plot(xs, ys, color=style.DEFENSE, lw=1.1, ls=(0, (3, 2)), alpha=0.8, zorder=3)
for d, w, text in ghosts:
x, y = XY(fld, p, d, w)
ax.add_patch(Circle((x, y), 0.72, fc="none", ec=style.OFFENSE, lw=1.0, ls=(0, (2, 1.5)), zorder=6))
if text:
ax.text(x, y - 1.45, text, ha="center", va="top", fontsize=max(5.0, ppy * 0.7), color=style.INK_2,
zorder=6)
for k, pl in p.players.items():
d, w = p.position(k, t)
if inside(d, w):
dot(ax, *XY(fld, p, d, w), pl.side, pl.label, ppy, r=r, ring=(rings or {}).get(k))
thrown = [(to, te) for kind, to, te in p.ball_events if kind == "pass" and t > te]
if thrown:
bd, bw = p.ball_position(t)
sd, sw = p.ball_position(thrown[0][1])
rd, rw = p.position(thrown[0][0], t)
caught = np.hypot(bd - rd, bw - rw) < 0.4
ax.add_patch(FancyArrowPatch(XY(fld, p, sd, sw), XY(fld, p, bd, bw), arrowstyle="-|>", mutation_scale=8,
color=style.BALL, lw=1.3, ls=(0, (2, 1.5)), zorder=9, shrinkA=0,
shrinkB=9 if caught else 2))
football(ax, *XY(fld, p, bd + (0.3 if caught else 0.0), bw - (1.25 if caught else 0.0)))
elif t > 0.15 and "QB" in p.players:
qd, qw = p.position("QB", t)
football(ax, *XY(fld, p, qd + 0.3, qw - 1.2))
return fld
def eyes(fld, p, t, target, color=GREEN):
"""The quarterback's eyes: a dashed line from him to the player he is reading."""
(qd, qw), (rd, rw) = p.position("QB", t), p.position(target, t)
x0, y0 = XY(fld, p, qd, qw)
x1, y1 = XY(fld, p, rd, rw)
fld.ax.add_patch(FancyArrowPatch((x0, y0), (x1, y1), arrowstyle="-", color=color, lw=1.4, ls=(0, (4, 2)),
shrinkA=7, shrinkB=9, zorder=7))
def strip(draw, times, notes, ncols=2, width_in=6.5, aspect=1.0, note_lines=2):
"""Print stand-in for an animation: numbered panels with a caption under each."""
nrows = int(np.ceil(len(times) / ncols))
pw = width_in / ncols
fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, nrows * (pw * aspect + 0.2 + 0.16 * note_lines)),
squeeze=False)
for ax in axes.flat[len(times):]:
ax.axis("off")
for i, (ax, t) in enumerate(zip(axes.flat, times)):
draw(ax, t)
ax.set_title(f"{i + 1}. " + ("snap" if abs(t) < 1e-6 else f"{t:+.1f} s"), fontsize=8.2,
color=style.INK_2, loc="left", pad=3)
ax.text(0.5, -0.01, notes[i], transform=ax.transAxes, ha="center", va="top", fontsize=7.1,
color=style.INK, linespacing=1.2)
fig.tight_layout(h_pad=1.2, w_pad=0.8)
return fig
def video(draw, times, notes, t_end, width_in=5.6, aspect=1.0):
"""Web version of the strip: every tenth of a second, with the strip's captions as running titles."""
import shutil
from IPython.display import HTML, display
fig, ax = plt.subplots(figsize=(width_in, width_in * aspect + 0.6))
def update(t):
ax.clear()
draw(ax, t)
cur = [n.replace("\n", " ") for tt, n in zip(times, notes) if tt <= t + 1e-6]
head = "snap" if abs(t) < 0.05 else f"{t:+.1f} s"
ax.set_title(textwrap.fill(head + (" " + cur[-1] if cur else ""), 92), fontsize=7.6, color=style.INK,
loc="left")
return []
frames = list(np.round(np.arange(0.0, t_end + 0.05, 0.1), 2))
anim = animation.FuncAnimation(fig, update, frames=frames, interval=100)
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 play_out(draw, times, notes, t_end, ncols=2, aspect=1.0, note_lines=2, width_in=6.5):
"""Video on the website; a numbered, captioned frame strip in the PDF."""
if PDF:
strip(draw, times, notes, ncols=ncols, aspect=aspect, note_lines=note_lines, width_in=width_in)
plt.show()
else:
video(draw, times, notes, t_end, aspect=aspect)
BOX_W = 3.9 # the tackle box: Rule 3-25 puts its edges at the OUTSIDE shoulders of the tackles, not their centers
def tackle_box(ax, fld, p, back):
"""The shaded tackle box (pocket area), from the line back to depth `back`."""
xy = [XY(fld, p, *q) for q in [(-0.7, -BOX_W), (-0.7, BOX_W), (back, BOX_W), (back, -BOX_W)]]
ax.add_patch(Polygon(xy, closed=True, fc=PURPLE, alpha=0.09, ec=PURPLE, lw=0.9, ls=(0, (3, 2)), zorder=1.4))
def pass_set(p, depths=(-2.6, -1.7, -1.4, -1.7, -2.6), t=0.8):
"""The five linemen set (tackles deeper and wider than the interior) and hold."""
for k, dd in zip(OL_KEYS, depths):
w0 = p.players[k].w
timed(p, k, [(t, dd, w0 * 1.15)], "block")
return p