Skip to content

Commit ff0f36b

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

2 files changed

Lines changed: 484 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
""" pyplots.ai
2+
line-win-probability: Win Probability Chart
3+
Library: plotly 6.6.0 | Python 3.14.3
4+
Quality: 91/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import plotly.graph_objects as go
9+
10+
11+
# Data - simulated NFL game: Eagles vs Cowboys
12+
np.random.seed(42)
13+
14+
play_count = 120
15+
plays = np.arange(play_count)
16+
17+
# Build win probability through game events
18+
win_prob = np.zeros(play_count)
19+
win_prob[0] = 0.50
20+
21+
# Quarter boundaries
22+
q1_end, q2_end, q3_end = 30, 60, 90
23+
24+
# Key scoring events (play_index, prob_shift, label)
25+
events = [
26+
(10, 0.15, "Eagles TD\n7-0"),
27+
(25, -0.10, "Cowboys FG\n7-3"),
28+
(40, 0.16, "Eagles TD\n14-3"),
29+
(55, -0.18, "Cowboys TD\n14-10"),
30+
(68, 0.12, "Eagles FG\n17-10"),
31+
(80, -0.22, "Cowboys TD\n17-17"),
32+
(98, 0.20, "Eagles TD\n24-17"),
33+
(112, 0.10, "Eagles FG\n27-17"),
34+
]
35+
36+
event_plays = {e[0]: e[1] for e in events}
37+
38+
for i in range(1, play_count):
39+
if i in event_plays:
40+
drift = event_plays[i]
41+
else:
42+
drift = np.random.normal(0, 0.012)
43+
win_prob[i] = np.clip(win_prob[i - 1] + drift, 0.03, 0.97)
44+
45+
# Final plays ramp to victory
46+
win_prob[-1] = 1.0
47+
win_prob[-2] = 0.96
48+
win_prob[-3] = 0.92
49+
50+
# Convert to percentage
51+
win_pct = win_prob * 100
52+
53+
# Team colors - high contrast for accessibility
54+
home_color = "#00875A" # Eagles green (brighter, distinct)
55+
away_color = "#003594" # Cowboys blue (brighter, distinct)
56+
home_fill = "rgba(0,135,90,0.30)" # Green fill - clearly distinguishable
57+
away_fill = "rgba(0,53,148,0.30)" # Blue fill - clearly distinguishable
58+
59+
# Plot
60+
fig = go.Figure()
61+
62+
# Fill above 50% (home team)
63+
win_above = np.clip(win_pct, 50, 100)
64+
fig.add_trace(go.Scatter(x=plays, y=win_above, mode="lines", line={"width": 0}, showlegend=False, hoverinfo="skip"))
65+
fig.add_trace(
66+
go.Scatter(
67+
x=plays,
68+
y=np.full(play_count, 50),
69+
mode="lines",
70+
line={"width": 0},
71+
fill="tonexty",
72+
fillcolor=home_fill,
73+
showlegend=False,
74+
hoverinfo="skip",
75+
)
76+
)
77+
78+
# Fill below 50% (away team)
79+
win_below = np.clip(win_pct, 0, 50)
80+
fig.add_trace(go.Scatter(x=plays, y=win_below, mode="lines", line={"width": 0}, showlegend=False, hoverinfo="skip"))
81+
fig.add_trace(
82+
go.Scatter(
83+
x=plays,
84+
y=np.full(play_count, 50),
85+
mode="lines",
86+
line={"width": 0},
87+
fill="tonexty",
88+
fillcolor=away_fill,
89+
showlegend=False,
90+
hoverinfo="skip",
91+
)
92+
)
93+
94+
# Main win probability line with smoothing
95+
fig.add_trace(
96+
go.Scatter(
97+
x=plays,
98+
y=win_pct,
99+
mode="lines",
100+
line={"width": 3.5, "color": "#2a2a2a", "shape": "spline", "smoothing": 0.8},
101+
name="Win Probability",
102+
hovertemplate="Play %{x}<br>Win Prob: %{y:.1f}%<extra></extra>",
103+
)
104+
)
105+
106+
# 50% reference line
107+
fig.add_hline(y=50, line_dash="dash", line_color="rgba(0,0,0,0.35)", line_width=2)
108+
109+
# Quarter dividers
110+
for q_play, q_label in [(q1_end, "Q2"), (q2_end, "Q3"), (q3_end, "Q4")]:
111+
fig.add_vline(x=q_play, line_dash="dot", line_color="rgba(0,0,0,0.2)", line_width=1.5)
112+
fig.add_annotation(
113+
x=q_play, y=100, text=f"<b>{q_label}</b>", showarrow=False, font={"size": 16, "color": "#888"}, yshift=12
114+
)
115+
116+
# Q1 label
117+
fig.add_annotation(x=0, y=100, text="<b>Q1</b>", showarrow=False, font={"size": 16, "color": "#888"}, yshift=12)
118+
119+
# Annotate scoring events
120+
for play_idx, _, label in events:
121+
label_clean = label.replace("\n", "<br>")
122+
is_home = "Eagles" in label
123+
marker_color = home_color if is_home else away_color
124+
y_val = win_pct[play_idx]
125+
126+
fig.add_trace(
127+
go.Scatter(
128+
x=[play_idx],
129+
y=[y_val],
130+
mode="markers",
131+
marker={"size": 14, "color": marker_color, "line": {"color": "white", "width": 2}},
132+
showlegend=False,
133+
hovertemplate=f"{label_clean}<br>Play {play_idx}<br>Win Prob: {y_val:.1f}%<extra></extra>",
134+
)
135+
)
136+
137+
ay_offset = -60 if y_val > 55 else 60
138+
ax_offset = 0
139+
# Stagger annotations to reduce overlap in crowded regions
140+
if play_idx == 68:
141+
ax_offset = 55
142+
ay_offset = -45
143+
elif play_idx == 80:
144+
ax_offset = -55
145+
ay_offset = 50
146+
elif play_idx == 98:
147+
ax_offset = 45
148+
ay_offset = -55
149+
elif play_idx == 112:
150+
ax_offset = 55
151+
ay_offset = -40
152+
153+
fig.add_annotation(
154+
x=play_idx,
155+
y=y_val,
156+
text=f"<b>{label_clean}</b>",
157+
showarrow=True,
158+
arrowhead=2,
159+
arrowwidth=1.5,
160+
arrowcolor=marker_color,
161+
ax=ax_offset,
162+
ay=ay_offset,
163+
font={"size": 15, "color": marker_color},
164+
bgcolor="rgba(255,255,255,0.94)",
165+
bordercolor=marker_color,
166+
borderwidth=1.5,
167+
borderpad=5,
168+
)
169+
170+
# Team legend annotations
171+
fig.add_annotation(
172+
x=0.01,
173+
y=0.98,
174+
xref="paper",
175+
yref="paper",
176+
text="<b>▲ PHI Eagles</b>",
177+
showarrow=False,
178+
font={"size": 18, "color": home_color},
179+
bgcolor="rgba(255,255,255,0.85)",
180+
borderpad=6,
181+
)
182+
183+
fig.add_annotation(
184+
x=0.01,
185+
y=0.02,
186+
xref="paper",
187+
yref="paper",
188+
text="<b>▼ DAL Cowboys</b>",
189+
showarrow=False,
190+
font={"size": 18, "color": away_color},
191+
bgcolor="rgba(255,255,255,0.85)",
192+
borderpad=6,
193+
)
194+
195+
# Final score subtitle
196+
fig.add_annotation(
197+
x=0.5,
198+
y=1.08,
199+
xref="paper",
200+
yref="paper",
201+
text="Final Score: Eagles 27 – Cowboys 17",
202+
showarrow=False,
203+
font={"size": 20, "color": "#555"},
204+
)
205+
206+
# Style
207+
fig.update_layout(
208+
title={
209+
"text": "line-win-probability · plotly · pyplots.ai",
210+
"font": {"size": 28, "color": "#2a2a2a"},
211+
"x": 0.5,
212+
"xanchor": "center",
213+
"y": 0.97,
214+
},
215+
template="plotly_white",
216+
plot_bgcolor="rgba(248,249,252,1)",
217+
paper_bgcolor="white",
218+
xaxis={
219+
"title": {"text": "Play Number", "font": {"size": 22, "color": "#444"}},
220+
"tickfont": {"size": 18, "color": "#666"},
221+
"showline": True,
222+
"linewidth": 1,
223+
"linecolor": "rgba(0,0,0,0.18)",
224+
"range": [-2, play_count + 2],
225+
},
226+
yaxis={
227+
"title": {"text": "Win Probability (%)", "font": {"size": 22, "color": "#444"}},
228+
"tickfont": {"size": 18, "color": "#666"},
229+
"tickvals": [0, 25, 50, 75, 100],
230+
"ticktext": ["0%", "25%", "50%", "75%", "100%"],
231+
"range": [0, 100],
232+
"showline": True,
233+
"linewidth": 1,
234+
"linecolor": "rgba(0,0,0,0.18)",
235+
"showgrid": True,
236+
"gridwidth": 1,
237+
"gridcolor": "rgba(0,0,0,0.06)",
238+
},
239+
showlegend=False,
240+
margin={"l": 80, "r": 40, "t": 130, "b": 70},
241+
)
242+
243+
# Add custom hover mode for better interactivity in HTML
244+
fig.update_layout(hovermode="x unified")
245+
246+
# Add play-by-play spike lines for HTML interactivity
247+
fig.update_xaxes(showspikes=True, spikecolor="rgba(0,0,0,0.3)", spikethickness=1, spikedash="dot")
248+
249+
# Save
250+
fig.write_image("plot.png", width=1600, height=900, scale=3)
251+
fig.write_html("plot.html", include_plotlyjs="cdn")

0 commit comments

Comments
 (0)