{"spec_id":"line-win-probability","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nline-win-probability: Win Probability Chart\nLibrary: bokeh 3.9.1 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-06-21\n\"\"\"\n\nimport sys\n\n\n# Running as `python bokeh.py` inserts the script directory into sys.path[0],\n# shadowing the installed bokeh package. Remove it before any bokeh imports.\nsys.path.pop(0)\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import (\n    BoxAnnotation,\n    ColumnDataSource,\n    CustomJS,\n    HoverTool,\n    Label,\n    Legend,\n    LegendItem,\n    NumeralTickFormatter,\n    Span,\n)\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (Imprint palette / theme-adaptive chrome)\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Team colors — semantic exception: real NFL team identity colors\nEAGLES_COLOR = \"#004C54\"  # Eagles midnight green\nCOWBOYS_COLOR = \"#869397\"  # Cowboys silver\n\n# Data — simulated NFL game: Eagles vs Cowboys\nnp.random.seed(42)\n\nplays = np.arange(0, 121)\nwin_prob = np.full(121, 0.50)\n\nevents = {\n    8: (\"FG Eagles 3-0\", 0.62),\n    22: (\"TD Cowboys 3-7\", 0.38),\n    35: (\"TD Eagles 10-7\", 0.58),\n    48: (\"FG Cowboys 10-10\", 0.50),\n    55: (\"TD Eagles 17-10\", 0.68),\n    72: (\"TD Cowboys 17-17\", 0.48),\n    85: (\"FG Eagles 20-17\", 0.63),\n    95: (\"INT Eagles\", 0.72),\n    105: (\"TD Cowboys 20-24\", 0.30),\n    112: (\"TD Eagles 27-24\", 0.88),\n    118: (\"Turnover on downs\", 0.97),\n}\n\ncurrent_prob = 0.50\nfor i in range(1, 121):\n    if i in events:\n        current_prob = events[i][1]\n    else:\n        drift = np.random.normal(0, 0.015)\n        current_prob = np.clip(current_prob + drift, 0.03, 0.97)\n    win_prob[i] = current_prob\n\nwin_prob[120] = 1.0\n\nwin_prob_smooth = np.convolve(win_prob, np.ones(3) / 3, mode=\"same\")\nwin_prob_smooth[0] = 0.50\nwin_prob_smooth[120] = 1.0\nfor play in events:\n    win_prob_smooth[play] = win_prob[play]\n\nupper = np.maximum(win_prob_smooth, 0.50)\nlower = np.minimum(win_prob_smooth, 0.50)\n\nsource = ColumnDataSource(\n    data={\n        \"play\": plays,\n        \"win_prob\": win_prob_smooth,\n        \"upper\": upper,\n        \"lower\": lower,\n        \"baseline\": np.full(121, 0.50),\n        \"pct\": win_prob_smooth * 100,\n    }\n)\n\n# Title — 50 chars, well under 67 baseline, no scaling needed\ntitle = \"line-win-probability · python · bokeh · anyplot.ai\"\n\n# Plot — 3200×1800 landscape; toolbar_location=None keeps canvas at exact height\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Play Number\",\n    y_axis_label=\"Win Probability (%)\",\n    y_range=(-0.02, 1.02),\n    x_range=(-3, 126),\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# Quarter alternating bands — BoxAnnotation is idiomatic Bokeh\nquarter_boundaries = [(0, 30), (30, 60), (60, 90), (90, 120)]\nquarter_names = [\"Q1\", \"Q2\", \"Q3\", \"Q4\"]\nfor idx, (q_start, q_end) in enumerate(quarter_boundaries):\n    if idx % 2 == 1:\n        p.add_layout(BoxAnnotation(left=q_start, right=q_end, fill_color=INK, fill_alpha=0.03, line_color=None))\n\n# Area fills — Eagles above 50%, Cowboys below 50%\neagles_fill = p.varea(x=\"play\", y1=\"baseline\", y2=\"upper\", source=source, fill_color=EAGLES_COLOR, fill_alpha=0.25)\ncowboys_fill = p.varea(x=\"play\", y1=\"lower\", y2=\"baseline\", source=source, fill_color=COWBOYS_COLOR, fill_alpha=0.30)\n\n# Main probability line\np.line(x=\"play\", y=\"win_prob\", source=source, line_color=INK, line_width=4)\n\n# Invisible scatter layer for hover targeting\nhover_scatter = p.scatter(x=\"play\", y=\"win_prob\", source=source, size=22, fill_alpha=0, line_alpha=0)\n\n# Hover tool with styled HTML tooltip\nhover = HoverTool(\n    renderers=[hover_scatter],\n    tooltips=\"\"\"\n    <div style=\"background:#2a2a2a; padding:12px 16px; border-radius:8px; color:white; font-size:16px; line-height:1.6;\">\n        <span style=\"font-weight:bold; font-size:18px;\">Play @play</span><br>\n        <span style=\"color:#66ccbb;\">Win Prob: @pct{0.1}%</span>\n    </div>\n    \"\"\",\n    mode=\"vline\",\n)\np.add_tools(hover)\n\n# CustomJS crosshair on hover — distinctive Bokeh interactivity\ncrosshair_v = Span(location=0, dimension=\"height\", line_color=EAGLES_COLOR, line_width=2, line_alpha=0.4)\np.add_layout(crosshair_v)\nhover.callback = CustomJS(\n    args={\"span\": crosshair_v}, code=\"const geometry = cb_data.geometry; span.location = geometry.x;\"\n)\n\n# 50% reference line\np.add_layout(Span(location=0.5, dimension=\"width\", line_color=INK_MUTED, line_width=2, line_dash=[12, 6]))\n\n# Quarter boundary lines\nfor q_start, _ in quarter_boundaries[1:]:\n    p.add_layout(Span(location=q_start, dimension=\"height\", line_color=INK_MUTED, line_width=2, line_dash=\"dotted\"))\n\n# Quarter labels\nfor (q_start, q_end), q_name in zip(quarter_boundaries, quarter_names, strict=False):\n    p.add_layout(\n        Label(\n            x=(q_start + q_end) / 2,\n            y=0.97,\n            text=q_name,\n            text_font_size=\"22pt\",\n            text_color=INK_MUTED,\n            text_align=\"center\",\n            text_font_style=\"bold\",\n        )\n    )\n\n# Team name labels near the 50% midline\np.add_layout(\n    Label(\n        x=2,\n        y=0.52,\n        text=\"EAGLES\",\n        text_font_size=\"20pt\",\n        text_color=EAGLES_COLOR,\n        text_font_style=\"bold\",\n        text_alpha=0.6,\n    )\n)\np.add_layout(\n    Label(\n        x=2,\n        y=0.44,\n        text=\"COWBOYS\",\n        text_font_size=\"20pt\",\n        text_color=COWBOYS_COLOR,\n        text_font_style=\"bold\",\n        text_alpha=0.6,\n    )\n)\n\n# Key scoring event annotations — spaced to avoid Q4 crowding\n# Format: (play_num, text, y_offset, x_offset)\nannotations = [\n    (35, \"TD Eagles 10-7\", -18, 14),\n    (55, \"TD Eagles 17-10\", 0, 14),\n    (105, \"TD Cowboys 20-24\", 0, -82),  # left side: Q4, lower probability\n    (112, \"TD Eagles 27-24\", -20, 14),  # right side: Q4, high probability\n]\n\nevent_x = [a[0] for a in annotations]\nevent_y = [win_prob_smooth[a[0]] for a in annotations]\np.scatter(x=event_x, y=event_y, size=14, fill_color=INK, line_color=PAGE_BG, line_width=3, alpha=0.9)\nfor play_num, text, y_off, x_off in annotations:\n    p.add_layout(\n        Label(\n            x=play_num,\n            y=win_prob_smooth[play_num],\n            text=text,\n            text_font_size=\"19pt\",\n            text_color=INK,\n            text_font_style=\"bold\",\n            x_offset=x_off,\n            y_offset=y_off,\n            background_fill_color=ELEVATED_BG,\n            background_fill_alpha=0.88,\n        )\n    )\n\n# Final score annotation\np.add_layout(\n    Label(\n        x=50,\n        y=0.07,\n        text=\"Final: Eagles 27 — Cowboys 24\",\n        text_font_size=\"26pt\",\n        text_color=EAGLES_COLOR,\n        text_font_style=\"bold\",\n        background_fill_color=ELEVATED_BG,\n        background_fill_alpha=0.90,\n    )\n)\n\n# Legend\nlegend = Legend(\n    items=[LegendItem(label=\"Eagles\", renderers=[eagles_fill]), LegendItem(label=\"Cowboys\", renderers=[cowboys_fill])],\n    location=\"top_left\",\n    label_text_font_size=\"26pt\",\n    label_text_color=INK_SOFT,\n    glyph_height=28,\n    glyph_width=38,\n    spacing=12,\n    border_line_color=INK_SOFT,\n    background_fill_color=ELEVATED_BG,\n    background_fill_alpha=0.85,\n    padding=20,\n)\np.add_layout(legend)\n\n# Y-axis percentage formatter\np.yaxis.ticker = [0, 0.25, 0.50, 0.75, 1.0]\np.yaxis.formatter = NumeralTickFormatter(format=\"0%\")\n\n# Text sizing — canonical values for 3200×1800\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\n# Grid — subtle horizontal emphasis\np.xgrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.08\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.12\np.ygrid.grid_line_dash = [4, 4]\n\n# Chrome — theme-adaptive\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = None\np.yaxis.major_tick_line_color = None\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\n# Save — HTML (interactive) + PNG via headless Chrome (Selenium Manager auto-resolves driver)\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nimport base64\n\n\nW, H = 3200, 1800\n# Set the window larger than the figure so the full chart fits in the viewport;\n# CDP clip captures exactly WxH regardless of browser chrome overhead.\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H + 200}\",\n    \"--hide-scrollbars\",\n    \"--force-device-scale-factor=1\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\n# Capture exactly WxH from the page origin via Chrome DevTools Protocol\nresult = driver.execute_cdp_cmd(\n    \"Page.captureScreenshot\", {\"format\": \"png\", \"clip\": {\"x\": 0, \"y\": 0, \"width\": W, \"height\": H, \"scale\": 1}}\n)\nwith open(f\"plot-{THEME}.png\", \"wb\") as fout:\n    fout.write(base64.b64decode(result[\"data\"]))\ndriver.quit()\n"}