Skip to content

Commit f2bbb98

Browse files
feat(altair): implement violin-swarm (#3548)
## Implementation: `violin-swarm` - altair Implements the **altair** version of `violin-swarm`. **File:** `plots/violin-swarm/implementations/altair.py` **Parent Issue:** #3526 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20858842406)* --------- 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 8975052 commit f2bbb98

File tree

2 files changed

+355
-0
lines changed

2 files changed

+355
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
""" pyplots.ai
2+
violin-swarm: Violin Plot with Overlaid Swarm Points
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-09
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
from scipy import stats
11+
12+
13+
# Data: Reaction times (ms) across 4 experimental conditions
14+
np.random.seed(42)
15+
16+
conditions = ["Condition A", "Condition B", "Condition C", "Condition D"]
17+
n_per_group = 50
18+
19+
data = []
20+
# Different distributions to show variety
21+
for i, condition in enumerate(conditions):
22+
if i == 0:
23+
# Normal distribution
24+
values = np.random.normal(320, 40, n_per_group)
25+
elif i == 1:
26+
# Slightly skewed with higher values
27+
values = np.random.gamma(8, 30, n_per_group) + 200
28+
elif i == 2:
29+
# Bimodal-ish (mix of two normals)
30+
values = np.concatenate(
31+
[np.random.normal(280, 25, n_per_group // 2), np.random.normal(380, 30, n_per_group // 2)]
32+
)
33+
else:
34+
# Higher mean, tighter spread
35+
values = np.random.normal(400, 25, n_per_group)
36+
37+
for v in values:
38+
data.append({"Condition": condition, "Reaction Time (ms)": v})
39+
40+
df = pd.DataFrame(data)
41+
42+
# Compute kernel density estimates for violin shapes
43+
violin_data = []
44+
y_min = df["Reaction Time (ms)"].min() - 20
45+
y_max = df["Reaction Time (ms)"].max() + 20
46+
y_range = np.linspace(y_min, y_max, 100)
47+
48+
for condition in conditions:
49+
subset = df[df["Condition"] == condition]["Reaction Time (ms)"]
50+
kde = stats.gaussian_kde(subset, bw_method=0.3)
51+
density = kde(y_range)
52+
# Normalize density to create symmetric violin width
53+
density_norm = density / density.max() * 0.4
54+
55+
for y_val, d in zip(y_range, density_norm, strict=True):
56+
violin_data.append({"Condition": condition, "y": y_val, "width": d})
57+
58+
violin_df = pd.DataFrame(violin_data)
59+
60+
# Add jitter for swarm-like point distribution
61+
np.random.seed(42)
62+
df["jitter"] = np.random.uniform(-0.2, 0.2, len(df))
63+
64+
# Map conditions to x positions
65+
condition_to_x = {c: i for i, c in enumerate(conditions)}
66+
df["x"] = df["Condition"].map(condition_to_x)
67+
df["x_jittered"] = df["x"] + df["jitter"]
68+
violin_df["x"] = violin_df["Condition"].map(condition_to_x)
69+
violin_df["x_left"] = violin_df["x"] - violin_df["width"]
70+
violin_df["x_right"] = violin_df["x"] + violin_df["width"]
71+
72+
# Colors for each condition
73+
colors = ["#306998", "#FFD43B", "#4B8BBE", "#E67E22"]
74+
color_scale = alt.Scale(domain=conditions, range=colors)
75+
76+
# Y axis scale
77+
y_scale = alt.Scale(domain=[y_min - 10, y_max + 10])
78+
79+
# X axis scale (fixed with padding)
80+
x_scale = alt.Scale(domain=[-0.6, 3.6])
81+
82+
# Violin shapes using area marks (filled, no stroke lines)
83+
violin = (
84+
alt.Chart(violin_df)
85+
.mark_area(opacity=0.4, interpolate="monotone", line=False)
86+
.encode(
87+
y=alt.Y(
88+
"y:Q",
89+
title="Reaction Time (ms)",
90+
scale=y_scale,
91+
axis=alt.Axis(labelFontSize=16, titleFontSize=20, grid=True, gridOpacity=0.3),
92+
),
93+
x=alt.X("x_left:Q", scale=x_scale, axis=None),
94+
x2="x_right:Q",
95+
color=alt.Color("Condition:N", scale=color_scale, legend=None),
96+
)
97+
)
98+
99+
# Swarm-like points
100+
points = (
101+
alt.Chart(df)
102+
.mark_circle(size=80, opacity=0.85, color="#2d2d2d")
103+
.encode(
104+
y=alt.Y("Reaction Time (ms):Q", scale=y_scale),
105+
x=alt.X("x_jittered:Q", scale=x_scale, axis=None),
106+
tooltip=[
107+
alt.Tooltip("Condition:N", title="Condition"),
108+
alt.Tooltip("Reaction Time (ms):Q", title="Time (ms)", format=".1f"),
109+
],
110+
)
111+
)
112+
113+
# X-axis labels as text marks
114+
label_df = pd.DataFrame({"x": [0, 1, 2, 3], "label": conditions, "y": [y_min - 30] * 4})
115+
116+
x_labels = (
117+
alt.Chart(label_df)
118+
.mark_text(fontSize=18, fontWeight="bold")
119+
.encode(
120+
x=alt.X("x:Q", scale=x_scale),
121+
y=alt.value(820), # Position below the chart
122+
text="label:N",
123+
)
124+
)
125+
126+
# Combine layers
127+
chart = (
128+
alt.layer(violin, points, x_labels)
129+
.properties(
130+
width=1400, height=800, title=alt.Title("violin-swarm · altair · pyplots.ai", fontSize=28, anchor="middle")
131+
)
132+
.configure_view(strokeWidth=0)
133+
.configure_axis(labelFontSize=16, titleFontSize=20, gridOpacity=0.3)
134+
)
135+
136+
# Save
137+
chart.save("plot.png", scale_factor=3.0)
138+
chart.save("plot.html")
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
library: altair
2+
specification_id: violin-swarm
3+
created: '2026-01-09T16:50:28Z'
4+
updated: '2026-01-09T16:54:57Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20858842406
7+
issue: 3526
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/violin-swarm/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/violin-swarm/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/violin-swarm/altair/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent data variety showing different distribution shapes (normal, skewed,
17+
bimodal, tight)
18+
- Creative manual KDE implementation to create proper violin shapes in Altair
19+
- Good use of layer composition with area marks and circle marks
20+
- Clean color palette with distinct, accessible colors
21+
- Interactive tooltips add value
22+
- Well-formatted title following spec requirements
23+
weaknesses:
24+
- Jitter is uniform random rather than true beeswarm algorithm (points not confined
25+
to violin boundaries)
26+
- No explicit legend for condition colors (labels partially compensate)
27+
- Grid only on Y-axis, not configurable via legend
28+
- strict=True in zip is Python 3.10+ specific
29+
image_description: 'The plot displays a violin plot with overlaid swarm points showing
30+
reaction time distributions across four experimental conditions (A, B, C, D).
31+
Each condition has a distinct colored violin shape with semi-transparent fill:
32+
Condition A (blue, #306998), Condition B (yellow, #FFD43B), Condition C (light
33+
blue, #4B8BBE), and Condition D (orange, #E67E22). Dark gray circular points are
34+
scattered within each violin representing individual data observations. The Y-axis
35+
shows "Reaction Time (ms)" ranging from approximately 220 to 720 ms. The X-axis
36+
shows condition labels in bold text. The title "violin-swarm · altair · pyplots.ai"
37+
is centered at the top. A subtle gray grid is visible on the Y-axis. The distributions
38+
show variety: Condition A has a normal-looking distribution centered around 320ms,
39+
Condition B shows a gamma-like skewed distribution with higher values, Condition
40+
C displays a clear bimodal pattern with peaks around 280ms and 380ms, and Condition
41+
D has a tighter distribution centered around 400ms.'
42+
criteria_checklist:
43+
visual_quality:
44+
score: 36
45+
max: 40
46+
items:
47+
- id: VQ-01
48+
name: Text Legibility
49+
score: 10
50+
max: 10
51+
passed: true
52+
comment: Title at 28pt, axis labels at 20pt, ticks at 16pt, all perfectly
53+
readable
54+
- id: VQ-02
55+
name: No Overlap
56+
score: 8
57+
max: 8
58+
passed: true
59+
comment: No overlapping text elements, condition labels well spaced
60+
- id: VQ-03
61+
name: Element Visibility
62+
score: 6
63+
max: 8
64+
passed: true
65+
comment: Points visible but some overlap within dense areas; point size (80)
66+
is reasonable for 50 points per group
67+
- id: VQ-04
68+
name: Color Accessibility
69+
score: 5
70+
max: 5
71+
passed: true
72+
comment: Four distinct colors with good contrast; no red-green confusion
73+
- id: VQ-05
74+
name: Layout Balance
75+
score: 5
76+
max: 5
77+
passed: true
78+
comment: Plot fills canvas well, good margins, balanced whitespace
79+
- id: VQ-06
80+
name: Axis Labels
81+
score: 2
82+
max: 2
83+
passed: true
84+
comment: Y-axis has descriptive label with units Reaction Time (ms)
85+
- id: VQ-07
86+
name: Grid & Legend
87+
score: 0
88+
max: 2
89+
passed: false
90+
comment: Grid subtle (0.3 opacity), but no legend for colors
91+
spec_compliance:
92+
score: 23
93+
max: 25
94+
items:
95+
- id: SC-01
96+
name: Plot Type
97+
score: 8
98+
max: 8
99+
passed: true
100+
comment: Correct violin plot with overlaid swarm points
101+
- id: SC-02
102+
name: Data Mapping
103+
score: 5
104+
max: 5
105+
passed: true
106+
comment: Categories on X-axis, values on Y-axis as expected
107+
- id: SC-03
108+
name: Required Features
109+
score: 4
110+
max: 5
111+
passed: true
112+
comment: Has violin with transparency (0.4), swarm points, contrasting point
113+
color; jitter is uniform random rather than true swarm
114+
- id: SC-04
115+
name: Data Range
116+
score: 3
117+
max: 3
118+
passed: true
119+
comment: All data visible within axis range
120+
- id: SC-05
121+
name: Legend Accuracy
122+
score: 1
123+
max: 2
124+
passed: false
125+
comment: No explicit legend, but condition labels are clear
126+
- id: SC-06
127+
name: Title Format
128+
score: 2
129+
max: 2
130+
passed: true
131+
comment: Uses correct format violin-swarm · altair · pyplots.ai
132+
data_quality:
133+
score: 19
134+
max: 20
135+
items:
136+
- id: DQ-01
137+
name: Feature Coverage
138+
score: 7
139+
max: 8
140+
passed: true
141+
comment: Shows normal, skewed (gamma), bimodal, and tight distributions; excellent
142+
variety
143+
- id: DQ-02
144+
name: Realistic Context
145+
score: 7
146+
max: 7
147+
passed: true
148+
comment: Reaction times across experimental conditions is a real, neutral
149+
scientific scenario
150+
- id: DQ-03
151+
name: Appropriate Scale
152+
score: 5
153+
max: 5
154+
passed: true
155+
comment: Reaction times 220-720ms are realistic for experimental psychology
156+
research
157+
code_quality:
158+
score: 9
159+
max: 10
160+
items:
161+
- id: CQ-01
162+
name: KISS Structure
163+
score: 3
164+
max: 3
165+
passed: true
166+
comment: 'Linear flow: imports, data, plot, save, no functions/classes'
167+
- id: CQ-02
168+
name: Reproducibility
169+
score: 3
170+
max: 3
171+
passed: true
172+
comment: Uses np.random.seed(42)
173+
- id: CQ-03
174+
name: Clean Imports
175+
score: 2
176+
max: 2
177+
passed: true
178+
comment: All imports used (altair, numpy, pandas, scipy.stats)
179+
- id: CQ-04
180+
name: No Deprecated API
181+
score: 0
182+
max: 1
183+
passed: false
184+
comment: Uses strict=True in zip which is Python 3.10+ syntax
185+
- id: CQ-05
186+
name: Output Correct
187+
score: 1
188+
max: 1
189+
passed: true
190+
comment: Saves as plot.png and plot.html
191+
library_features:
192+
score: 4
193+
max: 5
194+
items:
195+
- id: LF-01
196+
name: Distinctive Features
197+
score: 4
198+
max: 5
199+
passed: true
200+
comment: Uses layer composition, area marks for violins, tooltips for interactivity,
201+
HTML export; good use of Altair declarative style
202+
verdict: APPROVED
203+
impl_tags:
204+
dependencies:
205+
- scipy
206+
techniques:
207+
- layer-composition
208+
- hover-tooltips
209+
- html-export
210+
patterns:
211+
- data-generation
212+
- iteration-over-groups
213+
dataprep:
214+
- kde
215+
styling:
216+
- alpha-blending
217+
- grid-styling

0 commit comments

Comments
 (0)