Skip to content

Commit ad67208

Browse files
feat(altair): implement line-win-probability (#5093)
## Implementation: `line-win-probability` - altair Implements the **altair** version of `line-win-probability`. **File:** `plots/line-win-probability/implementations/altair.py` **Parent Issue:** #4418 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23342814454)* --------- 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 7a7f872 commit ad67208

2 files changed

Lines changed: 397 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: altair 6.0.0 | Python 3.14.3
4+
Quality: 91/100 | Created: 2026-03-20
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
# Data - Simulated NFL game: Eagles vs Cowboys
13+
np.random.seed(42)
14+
15+
quarters = [0, 15, 30, 45, 60]
16+
quarter_labels = ["Kickoff", "Q2", "Q3", "Q4", "Final"]
17+
18+
plays = np.linspace(0, 60, 200)
19+
prob = np.full_like(plays, 0.5)
20+
events = []
21+
22+
scoring_plays = [
23+
(5, -0.10, "FG Cowboys 0-3"),
24+
(12, 0.20, "TD Eagles 7-3"),
25+
(18, -0.18, "TD Cowboys 7-10"),
26+
(24, 0.15, "FG Eagles 10-10"),
27+
(31, 0.18, "TD Eagles 17-10"),
28+
(37, -0.22, "TD Cowboys 17-17"),
29+
(40, -0.12, "FG Cowboys 17-20"),
30+
(48, 0.28, "TD Eagles 24-20"),
31+
(53, 0.10, "FG Eagles 27-20"),
32+
(58, 0.08, "INT Eagles seal it"),
33+
]
34+
35+
for i in range(1, len(plays)):
36+
drift = 0.0
37+
for event_time, shift, label in scoring_plays:
38+
if plays[i - 1] < event_time <= plays[i]:
39+
drift += shift
40+
events.append((event_time, label))
41+
noise = np.random.normal(0, 0.008)
42+
prob[i] = np.clip(prob[i - 1] + drift + noise, 0.01, 0.99)
43+
44+
prob[-1] = 1.0
45+
prob[-2] = 0.98
46+
prob[-3] = 0.95
47+
48+
df = pd.DataFrame({"minute": plays, "win_prob": prob})
49+
df["win_pct"] = df["win_prob"] * 100
50+
df["above_50"] = df["win_pct"].clip(lower=50)
51+
df["below_50"] = df["win_pct"].clip(upper=50)
52+
53+
df_events = pd.DataFrame(events, columns=["minute", "label"])
54+
df_events["win_pct"] = [np.interp(m, df["minute"], df["win_pct"]) for m in df_events["minute"]]
55+
56+
# Manual label y-offsets tuned per event to avoid overlap
57+
# Positive = above point, negative = below point
58+
label_nudges = [8, -12, -12, -10, 7, 10, -10, -14, 7, 7]
59+
df_events["label_y"] = np.clip(df_events["win_pct"] + label_nudges, 5, 97)
60+
61+
# Split events into early/late for different label alignment
62+
df_events_left = df_events[df_events["minute"] <= 50].copy()
63+
df_events_right = df_events[df_events["minute"] > 50].copy()
64+
65+
df_quarters = pd.DataFrame({"minute": quarters, "label": quarter_labels})
66+
67+
# Plot
68+
base = alt.Chart(df)
69+
70+
baseline = base.mark_rule(strokeDash=[6, 4], strokeWidth=2, color="#888888").encode(y=alt.datum(50))
71+
72+
area_home = base.mark_area(interpolate="monotone", opacity=0.4, color="#004C54").encode(
73+
x=alt.X("minute:Q", title="Game Time (minutes)", scale=alt.Scale(domain=[0, 60])),
74+
y=alt.Y("above_50:Q", title="Win Probability (%)", scale=alt.Scale(domain=[0, 100])),
75+
y2=alt.datum(50),
76+
)
77+
78+
area_away = base.mark_area(interpolate="monotone", opacity=0.4, color="#7B2D26").encode(
79+
x="minute:Q", y=alt.Y("below_50:Q", scale=alt.Scale(domain=[0, 100])), y2=alt.datum(50)
80+
)
81+
82+
# Use dark charcoal line instead of Python Blue to harmonize with team-colored fills
83+
line = base.mark_line(interpolate="monotone", strokeWidth=3.5, color="#2D2D2D").encode(
84+
x="minute:Q",
85+
y=alt.Y("win_pct:Q"),
86+
tooltip=[
87+
alt.Tooltip("minute:Q", title="Minute", format=".1f"),
88+
alt.Tooltip("win_pct:Q", title="Win Prob %", format=".1f"),
89+
],
90+
)
91+
92+
# Interactive nearest-point selection for crosshair effect
93+
nearest = alt.selection_point(nearest=True, on="pointerover", fields=["minute"], empty=False)
94+
95+
# Invisible voronoi layer to capture mouse position
96+
selectors = base.mark_point(size=1, opacity=0).encode(x="minute:Q").add_params(nearest)
97+
98+
# Crosshair vertical rule that follows cursor
99+
crosshair_rule = base.mark_rule(color="#666666", strokeWidth=1, strokeDash=[3, 3]).encode(
100+
x="minute:Q", opacity=alt.condition(nearest, alt.value(0.7), alt.value(0))
101+
)
102+
103+
# Highlight dot on line at nearest point
104+
highlight_dot = base.mark_circle(size=180, color="#2D2D2D", stroke="white", strokeWidth=2).encode(
105+
x="minute:Q", y="win_pct:Q", opacity=alt.condition(nearest, alt.value(1), alt.value(0))
106+
)
107+
108+
# Event markers: use gold/amber for clear contrast against both team fills
109+
event_points = (
110+
alt.Chart(df_events)
111+
.mark_circle(size=220, color="#F5A623", stroke="#2D2D2D", strokeWidth=2)
112+
.encode(
113+
x="minute:Q",
114+
y="win_pct:Q",
115+
tooltip=[alt.Tooltip("label:N", title="Event"), alt.Tooltip("minute:Q", title="Minute", format=".0f")],
116+
)
117+
)
118+
119+
# Staggered event labels to prevent overlap - split into left/right aligned groups
120+
event_labels_left = (
121+
alt.Chart(df_events_left)
122+
.mark_text(fontSize=15, fontWeight="bold", align="left", dx=12, color="#2D2D2D")
123+
.encode(x="minute:Q", y="label_y:Q", text="label:N")
124+
)
125+
126+
event_labels_right = (
127+
alt.Chart(df_events_right)
128+
.mark_text(fontSize=15, fontWeight="bold", align="right", dx=-12, color="#2D2D2D")
129+
.encode(x="minute:Q", y="label_y:Q", text="label:N")
130+
)
131+
132+
quarter_rules = (
133+
alt.Chart(df_quarters[1:-1]).mark_rule(strokeDash=[4, 3], strokeWidth=1.5, color="#aaaaaa").encode(x="minute:Q")
134+
)
135+
136+
quarter_text = (
137+
alt.Chart(df_quarters)
138+
.mark_text(fontSize=16, fontWeight="bold", dy=-14, color="#666666")
139+
.encode(x="minute:Q", y=alt.datum(100), text="label:N")
140+
)
141+
142+
chart = (
143+
(
144+
area_home
145+
+ area_away
146+
+ baseline
147+
+ line
148+
+ event_points
149+
+ event_labels_left
150+
+ event_labels_right
151+
+ quarter_rules
152+
+ quarter_text
153+
+ selectors
154+
+ crosshair_rule
155+
+ highlight_dot
156+
)
157+
.properties(
158+
width=1600,
159+
height=900,
160+
title=alt.Title(
161+
"Eagles vs Cowboys · line-win-probability · altair · pyplots.ai",
162+
fontSize=28,
163+
subtitle="Final Score: Eagles 27 - Cowboys 20",
164+
subtitleFontSize=20,
165+
subtitleColor="#555555",
166+
),
167+
)
168+
.configure_axis(labelFontSize=18, titleFontSize=22, grid=False, domainColor="#cccccc")
169+
.configure_view(strokeWidth=0)
170+
)
171+
172+
# Save
173+
chart.save("plot.png", scale_factor=3.0)
174+
chart.save("plot.html")
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
library: altair
2+
specification_id: line-win-probability
3+
created: '2026-03-20T12:32:22Z'
4+
updated: '2026-03-20T12:47:07Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 23342814454
7+
issue: 4418
8+
python_version: 3.14.3
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/line-win-probability/altair/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent spec compliance with all required features implemented
17+
- Strong data storytelling with annotated scoring events and game narrative
18+
- Thoughtful team color palette (teal/muted red) avoiding accessibility issues
19+
- Good use of Altair interactive features (selection, conditional encoding) for
20+
HTML output
21+
- Manual label positioning shows care in avoiding text collisions
22+
weaknesses:
23+
- TD Eagles 24-20 label positioned far from its data point creating visual disconnect
24+
- Event annotation font size (15pt) could be slightly larger for better readability
25+
image_description: 'The plot displays a win probability chart for an Eagles vs Cowboys
26+
NFL game. The x-axis shows "Game Time (minutes)" from 0 to 60, and the y-axis
27+
shows "Win Probability (%)" from 0 to 100. A dashed horizontal reference line
28+
sits at 50%. The area above 50% is filled with a muted teal (Eagles) and below
29+
50% with a muted brownish-red (Cowboys). A dark charcoal line traces the probability
30+
over time. Gold/amber circle markers with dark outlines highlight 10 scoring events,
31+
each labeled in bold text (e.g., "FG Cowboys 0-3", "TD Eagles 7-3"). Dashed vertical
32+
lines mark quarter boundaries labeled "Kickoff", "Q2", "Q3", "Q4", "Final" at
33+
the top. The title reads "Eagles vs Cowboys · line-win-probability · altair ·
34+
pyplots.ai" with subtitle "Final Score: Eagles 27 - Cowboys 20". The chart tells
35+
a story of a back-and-forth game with the Eagles ultimately pulling away in the
36+
4th quarter.'
37+
criteria_checklist:
38+
visual_quality:
39+
score: 28
40+
max: 30
41+
items:
42+
- id: VQ-01
43+
name: Text Legibility
44+
score: 7
45+
max: 8
46+
passed: true
47+
comment: All font sizes explicitly set; event labels at 15pt slightly small
48+
but readable
49+
- id: VQ-02
50+
name: No Overlap
51+
score: 5
52+
max: 6
53+
passed: true
54+
comment: Manual nudges prevent most overlap; TD Eagles 24-20 label positioned
55+
far from data point
56+
- id: VQ-03
57+
name: Element Visibility
58+
score: 6
59+
max: 6
60+
passed: true
61+
comment: Line 3.5px, area opacity 0.4, event markers size 220 with stroke
62+
- all well adapted
63+
- id: VQ-04
64+
name: Color Accessibility
65+
score: 4
66+
max: 4
67+
passed: true
68+
comment: Teal vs brownish-red colorblind-safe, gold markers provide strong
69+
contrast
70+
- id: VQ-05
71+
name: Layout & Canvas
72+
score: 4
73+
max: 4
74+
passed: true
75+
comment: 1600x900 canvas well-utilized with balanced margins
76+
- id: VQ-06
77+
name: Axis Labels & Title
78+
score: 2
79+
max: 2
80+
passed: true
81+
comment: 'Descriptive with units: Game Time (minutes), Win Probability (%)'
82+
design_excellence:
83+
score: 15
84+
max: 20
85+
items:
86+
- id: DE-01
87+
name: Aesthetic Sophistication
88+
score: 6
89+
max: 8
90+
passed: true
91+
comment: Strong design with team color palette, gold markers, dark charcoal
92+
line
93+
- id: DE-02
94+
name: Visual Refinement
95+
score: 4
96+
max: 6
97+
passed: true
98+
comment: Grid disabled, view stroke removed, subtle dashed lines
99+
- id: DE-03
100+
name: Data Storytelling
101+
score: 5
102+
max: 6
103+
passed: true
104+
comment: Annotated events, quarter markers, and subtitle guide viewer through
105+
game narrative
106+
spec_compliance:
107+
score: 15
108+
max: 15
109+
items:
110+
- id: SC-01
111+
name: Plot Type
112+
score: 5
113+
max: 5
114+
passed: true
115+
comment: Correct win probability line chart with area fills
116+
- id: SC-02
117+
name: Required Features
118+
score: 4
119+
max: 4
120+
passed: true
121+
comment: 'All spec features present: 50% line, team fills, annotations, final
122+
score, quarter markers'
123+
- id: SC-03
124+
name: Data Mapping
125+
score: 3
126+
max: 3
127+
passed: true
128+
comment: X=game time, Y=win probability, full 0-100% range
129+
- id: SC-04
130+
name: Title & Legend
131+
score: 3
132+
max: 3
133+
passed: true
134+
comment: Correct title format with game context, no legend needed
135+
data_quality:
136+
score: 15
137+
max: 15
138+
items:
139+
- id: DQ-01
140+
name: Feature Coverage
141+
score: 6
142+
max: 6
143+
passed: true
144+
comment: Momentum swings both ways, multiple event types, lead changes, decisive
145+
ending
146+
- id: DQ-02
147+
name: Realistic Context
148+
score: 5
149+
max: 5
150+
passed: true
151+
comment: NFL game with realistic teams, scoring, and game flow
152+
- id: DQ-03
153+
name: Appropriate Scale
154+
score: 4
155+
max: 4
156+
passed: true
157+
comment: Win probability 0-100%, 60-minute game, realistic scores
158+
code_quality:
159+
score: 10
160+
max: 10
161+
items:
162+
- id: CQ-01
163+
name: KISS Structure
164+
score: 3
165+
max: 3
166+
passed: true
167+
comment: Linear flow, no functions or classes
168+
- id: CQ-02
169+
name: Reproducibility
170+
score: 2
171+
max: 2
172+
passed: true
173+
comment: np.random.seed(42) set
174+
- id: CQ-03
175+
name: Clean Imports
176+
score: 2
177+
max: 2
178+
passed: true
179+
comment: Only altair, numpy, pandas - all used
180+
- id: CQ-04
181+
name: Code Elegance
182+
score: 2
183+
max: 2
184+
passed: true
185+
comment: Clean code, interactive elements are native Altair features
186+
- id: CQ-05
187+
name: Output & API
188+
score: 1
189+
max: 1
190+
passed: true
191+
comment: Saves as plot.png and plot.html, current API
192+
library_mastery:
193+
score: 8
194+
max: 10
195+
items:
196+
- id: LM-01
197+
name: Idiomatic Usage
198+
score: 5
199+
max: 5
200+
passed: true
201+
comment: 'Expert declarative grammar: layered composition, encoding types,
202+
alt.datum, alt.Title'
203+
- id: LM-02
204+
name: Distinctive Features
205+
score: 3
206+
max: 5
207+
passed: true
208+
comment: selection_point, alt.condition, voronoi hover - distinctive but only
209+
visible in HTML
210+
verdict: APPROVED
211+
impl_tags:
212+
dependencies: []
213+
techniques:
214+
- annotations
215+
- layer-composition
216+
- hover-tooltips
217+
- html-export
218+
patterns:
219+
- data-generation
220+
dataprep:
221+
- interpolation
222+
styling:
223+
- alpha-blending

0 commit comments

Comments
 (0)