HAWK-ONE / render.py
ageraustine's picture
inits
8d8329b verified
Raw History Blame Contribute Delete
12.1 kB
"""HTML for the Film Room result panels (verdict, numbers, distribution, reasons, Path Lab)."""
from __future__ import annotations
import html
CSS = """
<style>
:root{--hk-navy:#071731;--hk-line:#1f3a6b;--hk-red:#d50a0a;--hk-cyan:#22d3ee}
.hk{font-family:Inter,system-ui,sans-serif;color:#e8eefb;background:#071731;border:1px solid #1f3a6b;border-radius:10px;overflow:hidden}
.hk *{box-sizing:border-box}
.hk .vd{display:flex;align-items:center;gap:16px;padding:14px 18px;border-bottom:3px solid var(--hk-red);background:linear-gradient(90deg,#0b2347,#071731)}
.hk .vd .tag{font:800 26px 'Barlow Condensed','Arial Narrow',Impact,sans-serif;letter-spacing:1.4px;text-transform:uppercase;padding:2px 14px;border-radius:4px}
.hk .vd.better .tag{background:#0f7a4f}.hk .vd.worse .tag{background:#a31212}.hk .vd.same .tag{background:#3a4a6b}
.hk .vd .big{font:800 34px 'Barlow Condensed','Arial Narrow',Impact,sans-serif;letter-spacing:.5px}
.hk .vd .sm{color:#9db0cf;font-size:12.5px;line-height:1.4}
.hk .grid{display:grid;grid-template-columns:1.05fr 1fr;gap:0}
@media(max-width:900px){.hk .grid{grid-template-columns:1fr}}
.hk h5{margin:0;padding:8px 18px;font:700 14px 'Barlow Condensed','Arial Narrow',Impact,sans-serif;letter-spacing:1.6px;text-transform:uppercase;color:#bcd0f3;background:#08172f;border-top:1px solid var(--hk-line);border-bottom:1px solid var(--hk-line)}
.hk table.kp{width:100%;border-collapse:collapse;font-size:13px}
.hk table.kp th{font:600 10px Inter,sans-serif;letter-spacing:1.2px;color:#9db0cf;text-transform:uppercase;text-align:right;padding:6px 14px}
.hk table.kp th:first-child{text-align:left;padding-left:18px}
.hk table.kp td{color:#e8eefb;padding:7px 14px;border-top:1px solid #12294f;text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
.hk table.kp td:first-child{text-align:left;padding-left:18px;font-weight:500;color:#cdd9ef}
.hk .up{color:#4ade80}.hk .dn{color:#f87171}.hk .nu{color:#9db0cf}
.hk .why{list-style:none;margin:0;padding:6px 18px 12px}
.hk .why li{display:flex;gap:10px;padding:7px 0;border-top:1px solid #12294f;line-height:1.45;font-size:13px}
.hk .why li:first-child{border-top:0}
.hk .why .ic{flex:none;width:74px;font:700 11px Inter,sans-serif;letter-spacing:1px;text-transform:uppercase;color:#7f93b8;padding-top:2px}
.hk .note{padding:10px 18px;color:#8195b8;font-size:11.5px;line-height:1.5;border-top:1px solid var(--hk-line)}
.hk .legend span{display:inline-block;width:12px;height:12px;border-radius:2px;vertical-align:middle;margin:0 5px 0 12px}
.hk .rank{display:flex;align-items:center;gap:14px;padding:11px 18px;border-top:1px solid #12294f}
.hk .rank .n{font:800 30px 'Barlow Condensed','Arial Narrow',Impact,sans-serif;color:var(--hk-red);width:26px}
.hk .rank .nm{font:700 17px 'Barlow Condensed','Arial Narrow',Impact,sans-serif;letter-spacing:.5px;text-transform:uppercase}
.hk .rank .mt{color:#9db0cf;font-size:12px}
.hk .rank .d{margin-left:auto;font:800 24px 'Barlow Condensed','Arial Narrow',Impact,sans-serif}
.hk .pill{display:inline-block;border-radius:999px;padding:1px 9px;font-size:10.5px;font-weight:700;margin-left:6px}
.hk .pill.ok{background:#0f5a3f;color:#9af0cc}.hk .pill.bad{background:#7a1414;color:#ffb4b4}
#hawk_bridge{position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;overflow:hidden!important;opacity:0}
</style>
"""
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"<tr><td>{label}</td><td>-</td><td>-</td><td class='nu'>-</td></tr>"
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"<tr><td>{label}</td><td>{f(a)}</td><td>{f(b)}</td><td class='{cls}'>{dd}</td></tr>"
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"<svg viewBox='0 0 {W} {H + 22}' style='width:100%;display:block;background:#071a36'>"]
for g in range(0, 5):
y = 8 + (H - 20) * (1 - g / 4)
out.append(f"<line x1='{pad}' x2='{W - 8}' y1='{y:.1f}' y2='{y:.1f}' stroke='#16315d'/>"
f"<text x='{pad - 4}' y='{y + 3:.1f}' font-size='9' fill='#7f93b8' text-anchor='end'>{m * g / 4 * 100:.0f}%</text>")
for i in range(n):
x = pad + i * bw
for k, (p, c) in enumerate(((hb["p"][i], "#8aa0c8"), (ha["p"][i], "#22d3ee"))):
h = (H - 20) * p / m
out.append(f"<rect x='{x + 2 + k * (bw - 4) / 2:.1f}' y='{H - 12 - h:.1f}' width='{(bw - 4) / 2:.1f}' "
f"height='{h:.1f}' fill='{c}' opacity='.95'/>")
lo = hb["lo"] + i * hb["step"]
lab = f"{lo}" if 0 < i < n - 1 else (f"<{lo + hb['step']}" if i == 0 else f"{lo}+")
out.append(f"<text x='{x + bw / 2:.1f}' y='{H + 3}' font-size='9' fill='#9db0cf' text-anchor='middle'>{lab}</text>")
out.append(f"<text x='{W / 2}' y='{H + 17}' font-size='9.5' fill='#7f93b8' text-anchor='middle'>yards gained (per play)</text></svg>")
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"<li><span class='ic'>{ICON.get(k, k)}</span><span>{html.escape(t)}</span></li>" for k, t in c["reasons"])
feas = ""
for pid, f in c["feas"].items():
ok = not f["over"]
feas += (f"<li><span class='ic'>{html.escape(pid)}</span><span>Path needs up to {f['worst']:.1f} yd/s; top speed "
f"{f['vmax']:.1f} yd/s ({f['mph']} mph) <span class='pill {'ok' if ok else 'bad'}'>"
f"{'within his speed' if ok else 'beyond his speed'}</span></span></li>")
cr = ""
if c.get("credit"):
tot = sum(x["d"] for x in c["credit"])
inter = c["total_d"] - tot
body = "".join(
f"<tr><td>{html.escape(x['label'])}</td><td class='{'up' if x['d'] > 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} <span style='color:#7f93b8;font-weight:400'>&plusmn;{2 * x['se']:.1f}</span></td></tr>" for x in c["credit"])
body += (f"<tr><td>Only in combination</td><td class='nu'>{'+' if inter >= 0 else ''}{inter:.1f}</td></tr>"
f"<tr><td><b>All edits together</b></td><td><b>{'+' if c['total_d'] >= 0 else ''}{c['total_d']:.1f}</b></td></tr>")
cr = (f"<h5>Who gets the credit &middot; each edit on its own</h5>"
f"<table class='kp'><tr><th>Edit</th><th>Yards per play</th></tr>{body}</table>")
return (CSS + f"""<div class='hk'>
<div class='vd {v}'><span class='tag'>{word}</span><span class='big'>{sign}{c['d_mean']:.1f} yds</span>
<span class='sm'>per play, original vs your path<br>{sub}</span></div>
<div class='grid'><div>
<h5>The numbers &middot; {c['n']} runs each</h5>
<table class='kp'><tr><th></th><th>Original</th><th>Your edit</th><th>Change</th></tr>{rows}</table>{cr}</div>
<div><h5>Where the plays ended up<span class='legend'><span style='background:#8aa0c8'></span>original<span style='background:#22d3ee'></span>your path</span></h5>
{_hist(sb, sa)}<h5>Why</h5><ul class='why'>{why}</ul>
<h5>Can he run it?</h5><ul class='why'>{feas or "<li><span class='ic'>-</span><span>No edited path.</span></li>"}</ul></div></div>
<div class='note'><b>How to read this.</b> 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.</div></div>""")
def lab_html(pid, label, sg):
if not sg["rows"]:
return CSS + "<div class='hk'><div class='note'>No candidates.</div></div>"
rows = ""
never = sg.get("base_tgt", 1) == 0 and all(r["tgt"] == 0 for r in sg["rows"])
if never:
rows += ("<div class='note' style='border-top:0;color:#ffd98a'>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).</div>")
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"<div class='rank'><div class='n'>{i}</div><div><div class='nm'>{html.escape(r['name'])}"
f"<span class='pill {'ok' if r['feasible'] else 'bad'}'>{'he can run it' if r['feasible'] else 'beyond his speed'}</span></div>"
f"<div class='mt'>{r['avg']:.1f} yds per play &middot; {pc} &middot; turnover {r['pto'] * 100:.1f}% "
f"&middot; targeted {r['tgt'] * 100:.0f}%</div></div>"
f"<div class='d {cls}'>{'+' if d >= 0 else ''}{d:.1f}</div></div>")
return (CSS + f"<div class='hk'><div class='vd same'><span class='tag'>Path Lab</span><span class='big'>{html.escape(label)}</span>"
f"<span class='sm'>Candidate paths for this player, each run on the same seeds.<br>Score = yards minus the cost of turnovers and flags "
f"(original path scores {sg['base_value']:.1f}).</span></div>{rows}"
f"<div class='note'>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.</div></div>")