"""HTML for the Film Room result panels (verdict, numbers, distribution, reasons, Path Lab)."""
from __future__ import annotations
import html
CSS = """
"""
def _pct(v):
return "-" if v is None else f"{v * 100:.0f}%"
def _num(v, d=1):
return "-" if v is None else f"{v:.{d}f}"
def _row(label, a, b, fmt, higher_good=True, thr=0.0):
if a is None or b is None:
return f"
{label}
-
-
-
"
d = b - a
cls = "nu" if abs(d) <= thr else ("up" if (d > 0) == higher_good else "dn")
sign = "+" if d > 0 else ""
dd = f"{sign}{d * 100:.0f} pts" if fmt == "pct" else f"{sign}{d:.1f}"
f = _pct if fmt == "pct" else _num
return f"
{label}
{f(a)}
{f(b)}
{dd}
"
def _hist(sb, sa):
hb, ha = sb["hist"], sa["hist"]
n = len(hb["p"])
W, H, pad = 420, 150, 26
m = max(max(hb["p"]), max(ha["p"]), 0.05)
bw = (W - 2 * pad) / n
out = [f"")
return "".join(out)
ICON = {"result": "Result", "target": "Target", "catch": "Catch", "space": "Space", "timing": "Timing",
"pressure": "Pressure", "reaction": "Reaction", "consistency": "Steadiness", "turnover": "Turnovers", "flags": "Flags", "feasibility": "Body"}
def outcome_html(c, need=10):
sb, sa = c["base"], c["alt"]
v = c["verdict"]
word = {"better": "Better", "worse": "Worse", "same": "No clear difference"}[v]
sign = "+" if c["d_mean"] >= 0 else ""
sub = ("Your path beat the original beyond what luck explains." if v == "better" else
"Your path cost yards beyond what luck explains." if v == "worse" else
"The difference is within the noise of this many runs; try more runs or a bigger change.")
caution = ""
dc = sa["pchain"] - sb["pchain"]
if v == "better" and dc <= -0.08:
caution = f" But it moves the chains {abs(dc) * 100:.0f} pts less often: a boom-or-bust change."
elif v == "worse" and dc >= 0.08:
caution = f" It does move the chains {dc * 100:.0f} pts more often, so it is more consistent."
sub += caution
rows = "".join([
_row("Average gain (yds)", sb["avg"], sa["avg"], "num", thr=0.3),
_row("Median gain (yds)", sb["med"], sa["med"], "num", thr=0.3),
_row(f"Gains {need}+ yds (moves the chains)", sb["pchain"], sa["pchain"], "pct", thr=0.02),
_row("Gains 5+ yds", sb["p5"], sa["p5"], "pct", thr=0.02),
_row("Touchdown", sb["ptd"], sa["ptd"], "pct", thr=0.01),
_row("Completion when thrown", sb["pcomp"], sa["pcomp"], "pct", thr=0.02),
_row("Turnover", sb["pto"], sa["pto"], "pct", higher_good=False, thr=0.005),
_row("Sack", sb["psack"], sa["psack"], "pct", higher_good=False, thr=0.01),
_row("QB pressured", sb["ppress"], sa["ppress"], "pct", higher_good=False, thr=0.02),
_row("Penalty flag", sb["pflag"], sa["pflag"], "pct", higher_good=False, thr=0.01),
_row("Nearest defender at ball arrival (yds)", sb["sep_throw"], sa["sep_throw"], "num", thr=0.2),
])
why = "".join(f"
{ICON.get(k, k)}{html.escape(t)}
" for k, t in c["reasons"])
feas = ""
for pid, f in c["feas"].items():
ok = not f["over"]
feas += (f"
{html.escape(pid)}Path needs up to {f['worst']:.1f} yd/s; top speed "
f"{f['vmax']:.1f} yd/s ({f['mph']} mph) "
f"{'within his speed' if ok else 'beyond his speed'}
")
cr = ""
if c.get("credit"):
tot = sum(x["d"] for x in c["credit"])
inter = c["total_d"] - tot
body = "".join(
f"
{html.escape(x['label'])}
2 * x['se'] and x['d'] > .2 else 'dn' if x['d'] < -2 * x['se'] and x['d'] < -.2 else 'nu'}'>"
f"{'+' if x['d'] >= 0 else ''}{x['d']:.1f} ±{2 * x['se']:.1f}
" for x in c["credit"])
body += (f"
Only in combination
{'+' if inter >= 0 else ''}{inter:.1f}
"
f"
All edits together
{'+' if c['total_d'] >= 0 else ''}{c['total_d']:.1f}
")
cr = (f"
Who gets the credit · each edit on its own
"
f"
Edit
Yards per play
{body}
")
return (CSS + f"""
{word}{sign}{c['d_mean']:.1f} ydsper play, original vs your path {sub}
The numbers · {c['n']} runs each
Original
Your edit
Change
{rows}
{cr}
Where the plays ended uporiginalyour path
{_hist(sb, sa)}
Why
{why}
Can he run it?
{feas or "
-No edited path.
"}
How to read this. Both sides replay the same {c['n']} random seeds, so the gap comes from the path you changed, not luck.
The original is the recorded path replayed through the same engine. Everyone else keeps playing by the rules: defenders react to the new route,
blockers keep blocking, and the physics limits what the edited player's body can do. The model is plausible, not fitted to league data,
so use the direction and the reasons more than the exact yardage.
""")
def lab_html(pid, label, sg):
if not sg["rows"]:
return CSS + "
No candidates.
"
rows = ""
never = sg.get("base_tgt", 1) == 0 and all(r["tgt"] == 0 for r in sg["rows"])
if never:
rows += ("
The QB never throws to this player on this play, so a new path "
"cannot change the result unless it changes how the defense plays. Pick the target (or a blocker or defender whose "
"path can change what happens).
")
for i, r in enumerate(sg["rows"], 1):
d = r["value"] - sg["base_value"]
cls = "up" if d > 0.3 else "dn" if d < -0.3 else "nu"
pc = "-" if r["pcomp"] is None else f"{r['pcomp'] * 100:.0f}% complete"
rows += (f"
{i}
{html.escape(r['name'])}"
f"{'he can run it' if r['feasible'] else 'beyond his speed'}
"
f"
{r['avg']:.1f} yds per play · {pc} · turnover {r['pto'] * 100:.1f}% "
f"· targeted {r['tgt'] * 100:.0f}%
"
f"
{'+' if d >= 0 else ''}{d:.1f}
")
return (CSS + f"
Path Lab{html.escape(label)}"
f"Candidate paths for this player, each run on the same seeds. Score = yards minus the cost of turnovers and flags "
f"(original path scores {sg['base_value']:.1f}).
{rows}"
f"
Press an Apply button to load a path into the viewer and run the full comparison. "
f"Candidates are simple reshapes of the recorded path (deeper, shorter, wider, tighter, mirrored); the coach decides what is realistic to coach.