File size: 12,086 Bytes
ab93d31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d8329b
ab93d31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d8329b
 
 
 
 
 
 
 
 
 
 
ab93d31
 
 
 
 
8d8329b
ab93d31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""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>")