|
| 1 | +""" pyplots.ai |
| 2 | +line-win-probability: Win Probability Chart |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 84/100 | Created: 2026-03-20 |
| 5 | +""" |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +import pygal |
| 9 | +from pygal.style import Style |
| 10 | + |
| 11 | + |
| 12 | +# Data - Simulated NFL game: Eagles vs Cowboys |
| 13 | +np.random.seed(42) |
| 14 | + |
| 15 | +plays = 120 |
| 16 | + |
| 17 | +# Key waypoints: (play, win_probability) |
| 18 | +waypoints = [ |
| 19 | + (0, 0.50), |
| 20 | + (15, 0.38), # DAL FG (3-0) |
| 21 | + (32, 0.62), # PHI TD (7-3) |
| 22 | + (48, 0.35), # DAL TD (10-7) |
| 23 | + (60, 0.40), # Halftime drift |
| 24 | + (72, 0.65), # PHI TD (14-10) |
| 25 | + (85, 0.74), # PHI FG (17-10) |
| 26 | + (95, 0.45), # DAL TD (17-17) |
| 27 | + (112, 0.88), # PHI TD (24-17) |
| 28 | + (120, 0.97), # Final |
| 29 | +] |
| 30 | + |
| 31 | +scoring_events = { |
| 32 | + 15: "DAL FG\n3-0", |
| 33 | + 32: "PHI TD\n7-3", |
| 34 | + 48: "DAL TD\n10-7", |
| 35 | + 72: "PHI TD\n14-10", |
| 36 | + 85: "PHI FG\n17-10", |
| 37 | + 95: "DAL TD\n17-17", |
| 38 | + 112: "PHI TD\n24-17", |
| 39 | +} |
| 40 | + |
| 41 | +# Generate smooth win probability by interpolating between waypoints with noise |
| 42 | +win_pct = np.zeros(plays + 1) |
| 43 | +for i in range(len(waypoints) - 1): |
| 44 | + p1, v1 = waypoints[i] |
| 45 | + p2, v2 = waypoints[i + 1] |
| 46 | + n = p2 - p1 |
| 47 | + t = np.linspace(0, 1, n, endpoint=False) |
| 48 | + # Smooth interpolation with slight S-curve |
| 49 | + interp = v1 + (v2 - v1) * (3 * t**2 - 2 * t**3) |
| 50 | + noise = np.random.normal(0, 0.012, n) * (1 - 0.7 * t) # less noise near events |
| 51 | + win_pct[p1:p2] = np.clip(interp + noise, 0.02, 0.98) |
| 52 | +win_pct[plays] = 0.97 |
| 53 | + |
| 54 | +# Snap scoring event values exactly |
| 55 | +for play, _ in scoring_events.items(): |
| 56 | + for p, v in waypoints: |
| 57 | + if p == play: |
| 58 | + win_pct[play] = v |
| 59 | + |
| 60 | +# Convert to percentage |
| 61 | +win_pct_list = [round(float(p) * 100, 1) for p in win_pct] |
| 62 | + |
| 63 | +# Split fills: Eagles area above 50%, Cowboys area below 50% |
| 64 | +# This avoids the muddy overlap from both series filling from 0 |
| 65 | +eagles_above = [max(pct, 50.0) for pct in win_pct_list] # Clamp at 50 minimum |
| 66 | +cowboys_below = [min(pct, 50.0) for pct in win_pct_list] # Clamp at 50 maximum |
| 67 | + |
| 68 | +# Custom style - Eagles green (#00843D) vs Cowboys blue (#003594) for high contrast |
| 69 | +custom_style = Style( |
| 70 | + background="white", |
| 71 | + plot_background="white", |
| 72 | + foreground="#2d2d2d", |
| 73 | + foreground_strong="#2d2d2d", |
| 74 | + foreground_subtle="#e0e0e0", |
| 75 | + colors=("#003594", "#00843D", "#003594", "#00843D", "#333333", "#c0392b"), |
| 76 | + font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 77 | + title_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 78 | + title_font_size=56, |
| 79 | + label_font_size=34, |
| 80 | + major_label_font_size=42, |
| 81 | + value_font_size=28, |
| 82 | + legend_font_size=34, |
| 83 | + legend_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 84 | + label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 85 | + major_label_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 86 | + value_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 87 | + opacity=0.50, |
| 88 | + opacity_hover=0.65, |
| 89 | + guide_stroke_color="#e0e0e0", |
| 90 | + guide_stroke_dasharray="3,3", |
| 91 | + major_guide_stroke_color="#cccccc", |
| 92 | + major_guide_stroke_dasharray="6,3", |
| 93 | + stroke_opacity=1.0, |
| 94 | + stroke_opacity_hover=1.0, |
| 95 | + tooltip_font_size=28, |
| 96 | + tooltip_font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
| 97 | + tooltip_border_radius=8, |
| 98 | +) |
| 99 | + |
| 100 | +# Chart |
| 101 | +chart = pygal.Line( |
| 102 | + width=4800, |
| 103 | + height=2700, |
| 104 | + title="Eagles vs Cowboys (24-17) \u00b7 line-win-probability \u00b7 pygal \u00b7 pyplots.ai", |
| 105 | + x_title="Game Progression", |
| 106 | + y_title="Win Probability (%)", |
| 107 | + style=custom_style, |
| 108 | + fill=False, |
| 109 | + show_dots=False, |
| 110 | + stroke_style={"width": 4}, |
| 111 | + show_y_guides=True, |
| 112 | + show_x_guides=False, |
| 113 | + show_legend=True, |
| 114 | + legend_at_bottom=True, |
| 115 | + legend_box_size=28, |
| 116 | + value_formatter=lambda x: f"{x:.0f}%", |
| 117 | + range=(0, 100), |
| 118 | + min_scale=5, |
| 119 | + max_scale=10, |
| 120 | + margin_bottom=100, |
| 121 | + margin_left=100, |
| 122 | + margin_right=60, |
| 123 | + margin_top=60, |
| 124 | + spacing=12, |
| 125 | + tooltip_border_radius=8, |
| 126 | + tooltip_fancy_mode=True, |
| 127 | + show_minor_x_labels=True, |
| 128 | + x_label_rotation=45, |
| 129 | +) |
| 130 | + |
| 131 | +# Series 1: Cowboys fill (constant 50% line, fills 0-50 in blue) — legend entry |
| 132 | +chart.add("DAL Cowboys", [50.0] * len(win_pct_list), fill=True, show_dots=False, stroke_style={"width": 0}) |
| 133 | + |
| 134 | +# Series 2: Eagles fill (clamped above 50%, fills green above the 50% line) — legend entry |
| 135 | +chart.add("PHI Eagles", eagles_above, fill=True, show_dots=False, stroke_style={"width": 0}) |
| 136 | + |
| 137 | +# Series 3: Cowboys advantage area (clamped below 50%) — hidden from legend |
| 138 | +cowboys_below_data = [{"value": v, "label": ""} for v in cowboys_below] |
| 139 | +chart.add(None, cowboys_below_data, fill=True, show_dots=False, stroke_style={"width": 0}) |
| 140 | + |
| 141 | +# Series 4: Win probability line (Eagles green, on top) — hidden from legend |
| 142 | +chart.add(None, win_pct_list, fill=False, show_dots=False, stroke_style={"width": 5}) |
| 143 | + |
| 144 | +# Series 5: 50% baseline reference line |
| 145 | +baseline = [50] * len(win_pct_list) |
| 146 | +chart.add("50% Line", baseline, fill=False, show_dots=False, stroke_style={"width": 9, "dasharray": "20, 8"}) |
| 147 | + |
| 148 | +# Series 6: Scoring event markers with tooltip labels |
| 149 | +event_series = [None] * len(win_pct_list) |
| 150 | +for idx, label in scoring_events.items(): |
| 151 | + event_series[idx] = {"value": win_pct_list[idx], "label": label} |
| 152 | +chart.add("Scoring Events", event_series, fill=False, show_dots=True, dots_size=24, stroke=False) |
| 153 | + |
| 154 | +# X-axis labels: quarter markers + scoring event annotations |
| 155 | +label_map = {0: "Kickoff", 30: "Q2", 60: "Halftime", 90: "Q4", 120: "Final"} |
| 156 | +# Add scoring event short labels to x-axis for visible annotations in PNG |
| 157 | +scoring_labels = { |
| 158 | + 15: "DAL FG 3-0", |
| 159 | + 32: "PHI TD 7-3", |
| 160 | + 48: "DAL TD 10-7", |
| 161 | + 72: "PHI TD 14-10", |
| 162 | + 85: "PHI FG 17-10", |
| 163 | + 95: "DAL TD 17-17", |
| 164 | + 112: "PHI TD 24-17", |
| 165 | +} |
| 166 | +label_map.update(scoring_labels) |
| 167 | + |
| 168 | +chart.x_labels = [label_map.get(i, "") for i in range(plays + 1)] |
| 169 | +chart.x_labels_major = list(label_map.values()) |
| 170 | +chart.truncate_label = -1 |
| 171 | + |
| 172 | +# Save |
| 173 | +chart.render_to_file("plot.html") |
| 174 | +chart.render_to_png("plot.png") |
0 commit comments