Skip to content

Commit ecccfbb

Browse files
feat(pygal): implement line-win-probability (#5099)
## Implementation: `line-win-probability` - pygal Implements the **pygal** version of `line-win-probability`. **File:** `plots/line-win-probability/implementations/pygal.py` **Parent Issue:** #4418 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23342814683)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 00a6a97 commit ecccfbb

2 files changed

Lines changed: 408 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)