Skip to content

Commit 3210f1b

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

2 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
""" pyplots.ai
2+
line-win-probability: Win Probability Chart
3+
Library: letsplot 4.9.0 | Python 3.14.3
4+
Quality: 92/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import * # noqa: F403, F401
10+
from lets_plot.export import ggsave as export_ggsave
11+
12+
13+
LetsPlot.setup_html() # noqa: F405
14+
15+
# Data — simulated NFL game: Eagles vs Cowboys
16+
np.random.seed(42)
17+
total_plays = 120
18+
play_number = np.arange(total_plays)
19+
20+
# Generate win probability with realistic momentum swings
21+
win_prob = np.zeros(total_plays)
22+
win_prob[0] = 0.50
23+
24+
# Scoring events with play index, probability shift, and label
25+
events = [
26+
(8, 0.12, "Eagles FG 3-0"),
27+
(22, -0.15, "Cowboys TD 3-7"),
28+
(35, 0.18, "Eagles TD 10-7"),
29+
(52, 0.08, "Eagles FG 13-7"),
30+
(68, -0.22, "Cowboys TD 13-14"),
31+
(78, 0.15, "Eagles TD 20-14"),
32+
(92, -0.10, "Cowboys FG 20-17"),
33+
(105, 0.20, "Eagles TD 27-17"),
34+
]
35+
36+
event_plays = {e[0]: e[1] for e in events}
37+
38+
for i in range(1, total_plays):
39+
drift = 0.002 if i > 90 else 0.0
40+
noise = np.random.normal(0, 0.02)
41+
shift = event_plays.get(i, 0.0)
42+
win_prob[i] = np.clip(win_prob[i - 1] + shift + noise + drift, 0.02, 0.98)
43+
44+
# Force final convergence to winner
45+
win_prob[-5:] = np.linspace(win_prob[-6], 0.95, 5)
46+
win_prob[-1] = 0.97
47+
48+
# Create main dataframe with helper columns for area fill
49+
df = pd.DataFrame(
50+
{
51+
"play": play_number,
52+
"win_prob": win_prob,
53+
"baseline": 0.5,
54+
"above_50": np.maximum(win_prob, 0.5),
55+
"below_50": np.minimum(win_prob, 0.5),
56+
}
57+
)
58+
59+
# Event annotations — select key swings only, with alternating positions
60+
key_event_indices = [1, 2, 4, 5, 7]
61+
nudge_directions = [-0.08, 0.07, -0.08, 0.07, -0.08] # alternate below/above
62+
key_events = pd.DataFrame(
63+
{
64+
"play": [events[i][0] for i in key_event_indices],
65+
"win_prob": [win_prob[events[i][0]] for i in key_event_indices],
66+
"label": [events[i][2] for i in key_event_indices],
67+
"label_y": [win_prob[events[i][0]] + nudge_directions[j] for j, i in enumerate(key_event_indices)],
68+
}
69+
)
70+
71+
# Team colors
72+
eagles_green = "#004C54"
73+
cowboys_blue = "#869397"
74+
75+
# Plot
76+
plot = (
77+
ggplot(df, aes(x="play")) # noqa: F405
78+
# Area fill — home team above 50%
79+
+ geom_ribbon( # noqa: F405
80+
aes(ymin="baseline", ymax="above_50"), # noqa: F405
81+
fill=eagles_green,
82+
alpha=0.35,
83+
)
84+
# Area fill — away team below 50%
85+
+ geom_ribbon( # noqa: F405
86+
aes(ymin="below_50", ymax="baseline"), # noqa: F405
87+
fill=cowboys_blue,
88+
alpha=0.35,
89+
)
90+
# Win probability line
91+
+ geom_line( # noqa: F405
92+
aes(y="win_prob"), # noqa: F405
93+
color="#1a1a1a",
94+
size=1.8,
95+
tooltips=layer_tooltips() # noqa: F405
96+
.line("Play @play")
97+
.format("win_prob", ".0%")
98+
.line("Win prob: @win_prob"),
99+
)
100+
# 50% reference line
101+
+ geom_hline(yintercept=0.5, color="#888888", size=0.8, linetype="dashed") # noqa: F405
102+
# Quarter dividers
103+
+ geom_vline(xintercept=30, color="#CCCCCC", size=0.6, linetype="dotted") # noqa: F405
104+
+ geom_vline(xintercept=60, color="#CCCCCC", size=0.6, linetype="dotted") # noqa: F405
105+
+ geom_vline(xintercept=90, color="#CCCCCC", size=0.6, linetype="dotted") # noqa: F405
106+
# Key event markers
107+
+ geom_point( # noqa: F405
108+
data=key_events,
109+
mapping=aes(x="play", y="win_prob"), # noqa: F405
110+
size=6,
111+
color="#1a1a1a",
112+
fill="white",
113+
shape=21,
114+
stroke=2.0,
115+
)
116+
# Key event labels with background fill (letsplot geom_label)
117+
+ geom_label( # noqa: F405
118+
data=key_events,
119+
mapping=aes(x="play", y="label_y", label="label"), # noqa: F405
120+
size=10,
121+
color="#1a1a1a",
122+
fill="white",
123+
alpha=0.85,
124+
label_padding=0.4,
125+
label_r=0.2,
126+
label_size=0.5,
127+
)
128+
# Scales
129+
+ scale_y_continuous( # noqa: F405
130+
breaks=[0.0, 0.25, 0.5, 0.75, 1.0], labels=["0%", "25%", "50%", "75%", "100%"]
131+
)
132+
+ coord_cartesian(ylim=[0.0, 1.05]) # noqa: F405
133+
+ scale_x_continuous( # noqa: F405
134+
breaks=[0, 30, 60, 90, 120], labels=["Q1", "Q2", "Q3", "Q4", "End"]
135+
)
136+
# Labels
137+
+ labs( # noqa: F405
138+
x="Game Progress",
139+
y="Eagles Win Probability",
140+
title="line-win-probability \u00b7 letsplot \u00b7 pyplots.ai",
141+
subtitle="Eagles 27 \u2013 Cowboys 17 \u00b7 Eagles recover from Q3 deficit for convincing finish",
142+
)
143+
+ ggsize(1600, 900) # noqa: F405
144+
+ theme_minimal() # noqa: F405
145+
+ flavor_high_contrast_light() # noqa: F405
146+
+ theme( # noqa: F405
147+
axis_text=element_text(size=16), # noqa: F405
148+
axis_title=element_text(size=20), # noqa: F405
149+
plot_title=element_text(size=24), # noqa: F405
150+
plot_subtitle=element_text(size=16, color="#555555"), # noqa: F405
151+
panel_grid_major_y=element_line(color="#E0E0E0", size=0.3), # noqa: F405
152+
panel_grid_major_x=element_blank(), # noqa: F405
153+
panel_grid_minor=element_blank(), # noqa: F405
154+
plot_margin=[40, 60, 20, 20],
155+
)
156+
)
157+
158+
# Save
159+
export_ggsave(plot, filename="plot.png", path=".", scale=3)
160+
export_ggsave(plot, filename="plot.html", path=".")
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
library: letsplot
2+
specification_id: line-win-probability
3+
created: '2026-03-20T12:32:06Z'
4+
updated: '2026-03-20T12:44:04Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 23342814729
7+
issue: 4418
8+
python_version: 3.14.3
9+
library_version: 4.9.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/letsplot/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/letsplot/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/letsplot/plot.html
13+
quality_score: 92
14+
review:
15+
strengths:
16+
- Excellent data storytelling with narrative subtitle and well-placed event annotations
17+
guiding viewer through game arc
18+
- Clean visual design with muted team-colored area fills, minimal grid, and high-contrast
19+
light theme
20+
- 'Full spec compliance with all required features: area fills, 50% reference line,
21+
quarter markers, event annotations, final score'
22+
- Realistic NFL game data with plausible scoring progression and multiple momentum
23+
shifts
24+
- Good use of letsplot-specific features (interactive tooltips, flavor theming,
25+
geom_label customization)
26+
weaknesses:
27+
- Away team gray fill could use a slightly more distinctive team color for stronger
28+
two-team visual identity
29+
- The 50% baseline dashed line could be slightly more prominent to better anchor
30+
the visual
31+
- Could explore additional letsplot-distinctive features for higher library mastery
32+
image_description: 'The plot displays a win probability chart for an Eagles vs Cowboys
33+
NFL game. A bold dark line traces the Eagles'' win probability from Q1 through
34+
the end of the game across 120 plays. The x-axis shows game progress with quarter
35+
labels (Q1, Q2, Q3, Q4, End) separated by dotted vertical dividers. The y-axis
36+
ranges from 0% to 100% with a dashed horizontal reference line at 50%. The area
37+
above 50% is filled with a muted dark teal (Eagles green) and the area below 50%
38+
is filled with a subtle gray. Five key scoring events are annotated with white
39+
circle markers and labeled boxes: Cowboys TD 3-7, Eagles TD 10-7, Cowboys TD 13-14,
40+
Eagles TD 20-14, and Eagles TD 27-17. Labels alternate above and below the line
41+
to avoid overlap. The title reads "line-win-probability · letsplot · pyplots.ai"
42+
with a narrative subtitle "Eagles 27 – Cowboys 17 · Eagles recover from Q3 deficit
43+
for convincing finish". The plot uses a high-contrast light theme with only subtle
44+
horizontal grid lines, no x-axis grid, and generous whitespace.'
45+
criteria_checklist:
46+
visual_quality:
47+
score: 29
48+
max: 30
49+
items:
50+
- id: VQ-01
51+
name: Text Legibility
52+
score: 8
53+
max: 8
54+
passed: true
55+
comment: 'All font sizes explicitly set: title=24, subtitle=16, axis_title=20,
56+
axis_text=16'
57+
- id: VQ-02
58+
name: No Overlap
59+
score: 6
60+
max: 6
61+
passed: true
62+
comment: Annotation labels use alternating vertical nudge to avoid collisions
63+
- id: VQ-03
64+
name: Element Visibility
65+
score: 6
66+
max: 6
67+
passed: true
68+
comment: Line width 1.8, markers size=6 with white fill and dark stroke, area
69+
fills at alpha=0.35
70+
- id: VQ-04
71+
name: Color Accessibility
72+
score: 4
73+
max: 4
74+
passed: true
75+
comment: Dark teal vs gray is colorblind-safe with strong luminance contrast
76+
- id: VQ-05
77+
name: Layout & Canvas
78+
score: 3
79+
max: 4
80+
passed: true
81+
comment: Good proportions; minor empty space at bottom of y-range (0-25% unused)
82+
- id: VQ-06
83+
name: Axis Labels & Title
84+
score: 2
85+
max: 2
86+
passed: true
87+
comment: 'Descriptive labels: Eagles Win Probability, Game Progress'
88+
design_excellence:
89+
score: 16
90+
max: 20
91+
items:
92+
- id: DE-01
93+
name: Aesthetic Sophistication
94+
score: 6
95+
max: 8
96+
passed: true
97+
comment: Thoughtful team-colored palette, narrative subtitle, high-contrast
98+
light flavor. Above defaults but gray away-team fill lacks strong identity
99+
- id: DE-02
100+
name: Visual Refinement
101+
score: 5
102+
max: 6
103+
passed: true
104+
comment: Minimal grid (y-major only), x-grid removed, generous margins, clean
105+
theme
106+
- id: DE-03
107+
name: Data Storytelling
108+
score: 5
109+
max: 6
110+
passed: true
111+
comment: Narrative subtitle, annotations highlight key momentum shifts, clear
112+
game arc visible
113+
spec_compliance:
114+
score: 15
115+
max: 15
116+
items:
117+
- id: SC-01
118+
name: Plot Type
119+
score: 5
120+
max: 5
121+
passed: true
122+
comment: Correct line chart with area fill for win probability
123+
- id: SC-02
124+
name: Required Features
125+
score: 4
126+
max: 4
127+
passed: true
128+
comment: 'All spec features present: area fills, 50% line, quarter markers,
129+
annotations, final score'
130+
- id: SC-03
131+
name: Data Mapping
132+
score: 3
133+
max: 3
134+
passed: true
135+
comment: X=play number (game progression), Y=win probability as percentage
136+
- id: SC-04
137+
name: Title & Legend
138+
score: 3
139+
max: 3
140+
passed: true
141+
comment: Title format correct, no legend needed for single-line with area
142+
fills
143+
data_quality:
144+
score: 15
145+
max: 15
146+
items:
147+
- id: DQ-01
148+
name: Feature Coverage
149+
score: 6
150+
max: 6
151+
passed: true
152+
comment: Multiple lead changes, momentum swings, scoring from both teams,
153+
final convergence
154+
- id: DQ-02
155+
name: Realistic Context
156+
score: 5
157+
max: 5
158+
passed: true
159+
comment: NFL Eagles vs Cowboys game with realistic scoring events and plausible
160+
final score
161+
- id: DQ-03
162+
name: Appropriate Scale
163+
score: 4
164+
max: 4
165+
passed: true
166+
comment: 120 plays realistic for NFL, probability bounds and scoring progression
167+
plausible
168+
code_quality:
169+
score: 10
170+
max: 10
171+
items:
172+
- id: CQ-01
173+
name: KISS Structure
174+
score: 3
175+
max: 3
176+
passed: true
177+
comment: 'Clean linear flow: imports, data, plot, save. No functions/classes'
178+
- id: CQ-02
179+
name: Reproducibility
180+
score: 2
181+
max: 2
182+
passed: true
183+
comment: np.random.seed(42) set at start
184+
- id: CQ-03
185+
name: Clean Imports
186+
score: 2
187+
max: 2
188+
passed: true
189+
comment: All imports used, wildcard with noqa is idiomatic for letsplot
190+
- id: CQ-04
191+
name: Code Elegance
192+
score: 2
193+
max: 2
194+
passed: true
195+
comment: Clean, well-organized, no fake UI or over-engineering
196+
- id: CQ-05
197+
name: Output & API
198+
score: 1
199+
max: 1
200+
passed: true
201+
comment: Saves as plot.png via export_ggsave with scale=3
202+
library_mastery:
203+
score: 7
204+
max: 10
205+
items:
206+
- id: LM-01
207+
name: Idiomatic Usage
208+
score: 4
209+
max: 5
210+
passed: true
211+
comment: 'Fluent ggplot grammar: aes, geom_ribbon, geom_label, scale_*, coord_cartesian,
212+
theme composition'
213+
- id: LM-02
214+
name: Distinctive Features
215+
score: 3
216+
max: 5
217+
passed: true
218+
comment: Uses layer_tooltips(), flavor_high_contrast_light(), geom_label with
219+
padding/radius params, HTML export
220+
verdict: APPROVED
221+
impl_tags:
222+
dependencies: []
223+
techniques:
224+
- annotations
225+
- layer-composition
226+
- hover-tooltips
227+
- html-export
228+
patterns:
229+
- data-generation
230+
dataprep:
231+
- cumulative-sum
232+
styling:
233+
- alpha-blending
234+
- grid-styling

0 commit comments

Comments
 (0)