Skip to content

Commit 3956ea9

Browse files
feat(pygal): implement raincloud-basic (#1937)
## Implementation: `raincloud-basic` - pygal Implements the **pygal** version of `raincloud-basic`. **File:** `plots/raincloud-basic/implementations/pygal.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20500987018)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent c997ae0 commit 3956ea9

2 files changed

Lines changed: 105 additions & 104 deletions

File tree

plots/raincloud-basic/implementations/pygal.py

Lines changed: 89 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" pyplots.ai
22
raincloud-basic: Basic Raincloud Plot
33
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 88/100 | Created: 2025-12-24
4+
Quality: 78/100 | Created: 2025-12-25
55
"""
66

77
import numpy as np
@@ -22,153 +22,156 @@
2222
data["Treatment A"] = np.append(data["Treatment A"], [550, 200])
2323
data["Treatment B"] = np.append(data["Treatment B"], [480, 180])
2424

25-
# Group colors for consistent styling
26-
# Pattern: cloud1, cloud2, cloud3, rain1, rain2, rain3, box elements (gray)
25+
# Group colors - distinct for each category (colorblind-safe)
2726
group_colors = ["#306998", "#FFD43B", "#4CAF50"]
28-
palette = tuple(group_colors) + tuple(group_colors) + ("#333333", "#333333", "#333333")
2927

30-
# Custom style for 4800x2700 px canvas - with legend enabled
28+
# Custom style for 4800x2700 px canvas - scaled up for visibility
3129
custom_style = Style(
3230
background="white",
3331
plot_background="white",
3432
foreground="#333333",
3533
foreground_strong="#333333",
36-
foreground_subtle="#666666",
37-
colors=palette, # Clouds get 0-2, rain gets 3-5 (same colors), box gets 6+
38-
title_font_size=72,
39-
label_font_size=48,
40-
major_label_font_size=42,
41-
legend_font_size=36, # Legend for group identification
42-
value_font_size=36,
43-
opacity=0.6, # Balanced opacity for visibility
34+
foreground_subtle="#999999",
35+
guide_stroke_color="#cccccc",
36+
colors=tuple(group_colors * 3) + ("#222222",) * 30,
37+
title_font_size=96,
38+
label_font_size=60,
39+
major_label_font_size=54,
40+
legend_font_size=54,
41+
value_font_size=42,
42+
opacity=0.6,
4443
opacity_hover=0.8,
4544
)
4645

47-
# Create XY chart for raincloud plot - legend enabled for group identification
46+
# Create VERTICAL XY chart for raincloud plot
47+
# X-axis = Treatment Group (category), Y-axis = Reaction Time (value)
48+
# For vertical orientation: cloud on RIGHT side, boxplot centered, rain on LEFT
49+
# This follows the spec's guidance for vertical raincloud layout
4850
chart = pygal.XY(
4951
width=4800,
5052
height=2700,
5153
style=custom_style,
5254
title="raincloud-basic · pygal · pyplots.ai",
5355
x_title="Treatment Group",
5456
y_title="Reaction Time (ms)",
55-
show_legend=True, # Enable legend for group identification
56-
legend_at_bottom=True, # Place legend at bottom for better layout
57+
show_legend=False,
5758
stroke=True,
5859
fill=True,
5960
dots_size=0,
60-
show_x_guides=False,
61+
show_x_guides=True,
6162
show_y_guides=True,
6263
range=(100, 750),
63-
xrange=(0, 5),
64-
margin=50,
64+
xrange=(0, 4),
65+
margin=80,
66+
explicit_size=True,
6567
)
6668

67-
# Raincloud parameters
68-
violin_width = 0.35
69-
jitter_offset = 0.45
70-
box_offset = 0.05
71-
n_points = 80
69+
# Raincloud layout parameters (vertical orientation)
70+
# CRITICAL: Cloud on RIGHT side, boxplot centered, rain on LEFT
71+
# This creates the visual metaphor of rain falling from cloud
72+
cloud_offset = 0.28 # Cloud extends to the RIGHT (positive X offset)
73+
rain_offset = -0.32 # Rain falls to the LEFT (negative X offset)
74+
n_kde_points = 80
7275

73-
# Pre-compute all raincloud components for each group
76+
# Pre-compute all raincloud components
7477
cloud_data = []
7578
rain_data = []
7679
box_data = []
7780

7881
for i, (category, values) in enumerate(data.items()):
79-
center_x = i + 1.5
82+
center_x = i + 1 # X position for this group (1, 2, 3)
8083
values = np.array(values)
8184

82-
# --- Half-Violin (cloud) - on the left side ---
85+
# --- Half-Violin (cloud) - on RIGHT side of boxplot ---
8386
# Compute KDE using Silverman's rule
8487
n = len(values)
8588
std = np.std(values)
86-
iqr = np.percentile(values, 75) - np.percentile(values, 25)
87-
bandwidth = 0.9 * min(std, iqr / 1.34) * n ** (-0.2)
89+
iqr_val = np.percentile(values, 75) - np.percentile(values, 25)
90+
bandwidth = 0.9 * min(std, iqr_val / 1.34) * n ** (-0.2)
8891

89-
# Create range of y values for density
92+
# Create range of y values (reaction times) for density
9093
y_min, y_max = values.min(), values.max()
9194
padding = (y_max - y_min) * 0.1
92-
y_range = np.linspace(y_min - padding, y_max + padding, n_points)
95+
y_range = np.linspace(y_min - padding, y_max + padding, n_kde_points)
9396

9497
# Gaussian kernel density estimation
9598
density = np.zeros_like(y_range)
9699
for v in values:
97100
density += np.exp(-0.5 * ((y_range - v) / bandwidth) ** 2)
98101
density /= n * bandwidth * np.sqrt(2 * np.pi)
99102

100-
# Normalize density to desired width
101-
density = density / density.max() * violin_width
103+
# Normalize density and place on RIGHT side (cloud)
104+
density = density / density.max() * cloud_offset
102105

103-
# Create half-violin shape (only left side - the "cloud")
104-
cloud_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=True)]
106+
# Create half-violin shape - cloud extends to right (higher X)
107+
cloud_points = [(center_x + d, y) for y, d in zip(y_range, density, strict=True)]
105108
# Close the shape along the center line
106-
cloud_points = cloud_points + [(center_x, y_range[-1]), (center_x, y_range[0]), cloud_points[0]]
107-
cloud_data.append((category, cloud_points))
109+
cloud_points = [(center_x, y_range[0])] + cloud_points + [(center_x, y_range[-1]), (center_x, y_range[0])]
110+
cloud_data.append((category, cloud_points, group_colors[i]))
108111

109-
# --- Jittered Points (rain) - on the right side ---
110-
np.random.seed(42 + i) # Consistent jitter per group
112+
# --- Jittered Points (rain) - on LEFT side (rain falls from cloud) ---
113+
np.random.seed(42 + i)
111114
jitter = np.random.uniform(-0.08, 0.08, len(values))
112-
rain_points = [(center_x + jitter_offset + j, float(v)) for j, v in zip(jitter, values, strict=True)]
115+
rain_points = [(center_x + rain_offset + j, float(v)) for j, v in zip(jitter, values, strict=True)]
113116
rain_data.append((category, rain_points, group_colors[i]))
114117

115-
# --- Box Plot (center) ---
118+
# --- Box Plot (centered at group position) ---
116119
median = float(np.median(values))
117120
q1 = float(np.percentile(values, 25))
118121
q3 = float(np.percentile(values, 75))
119122
iqr = q3 - q1
120123
whisker_low = float(max(values.min(), q1 - 1.5 * iqr))
121124
whisker_high = float(min(values.max(), q3 + 1.5 * iqr))
122-
box_data.append((center_x, median, q1, q3, whisker_low, whisker_high))
125+
box_data.append((center_x, median, q1, q3, whisker_low, whisker_high, group_colors[i]))
123126

124-
# Add clouds first (series 0, 1, 2 - get colors 0, 1, 2)
125-
for category, cloud_points in cloud_data:
126-
chart.add(category, cloud_points)
127+
# Add clouds (half-violins) - no labels since y-axis labels already show groups
128+
for _category, cloud_points, _color in cloud_data:
129+
chart.add(None, cloud_points, stroke=True, fill=True)
127130

128-
# Add rain points second (series 3, 4, 5 - get colors 3, 4, 5 = same as 0, 1, 2)
131+
# Add rain points - increased dots_size for visibility on 4800x2700 canvas
129132
for _category, rain_points, _color in rain_data:
130-
chart.add(
131-
None, # No legend entry
132-
rain_points,
133-
stroke=False,
134-
fill=False,
135-
dots_size=18, # Larger points for better visibility at full resolution
136-
)
137-
138-
# Add box plots last (no color needed, use black strokes)
139-
box_width = 0.15
140-
cap_width = 0.08
141-
142-
for center_x, median, q1, q3, whisker_low, whisker_high in box_data:
143-
# IQR box - thicker stroke for visibility
133+
chart.add(None, rain_points, stroke=False, fill=False, dots_size=24)
134+
135+
# Add box plots - vertical boxes centered at each group
136+
# Significantly increased line weights for 4800x2700 canvas
137+
box_width = 0.10
138+
cap_width = 0.06
139+
140+
for center_x, median, q1, q3, whisker_low, whisker_high, _color in box_data:
141+
# IQR box (vertical rectangle) - thick border for visibility
144142
quartile_box = [
145-
(center_x + box_offset - box_width, q1),
146-
(center_x + box_offset - box_width, q3),
147-
(center_x + box_offset + box_width, q3),
148-
(center_x + box_offset + box_width, q1),
149-
(center_x + box_offset - box_width, q1),
143+
(center_x - box_width, q1),
144+
(center_x - box_width, q3),
145+
(center_x + box_width, q3),
146+
(center_x + box_width, q1),
147+
(center_x - box_width, q1),
150148
]
151-
chart.add(None, quartile_box, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
152-
153-
# Median line - significantly thicker and more prominent for distinction from quartile box
154-
median_line = [(center_x + box_offset - box_width * 1.5, median), (center_x + box_offset + box_width * 1.5, median)]
155-
chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 16, "dasharray": "0"})
156-
157-
# Whiskers - thicker lines
158-
whisker_top = [(center_x + box_offset, q3), (center_x + box_offset, whisker_high)]
159-
whisker_bottom = [(center_x + box_offset, q1), (center_x + box_offset, whisker_low)]
160-
chart.add(None, whisker_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
161-
chart.add(None, whisker_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
162-
163-
# Whisker caps - thicker and wider
164-
cap_top = [(center_x + box_offset - cap_width, whisker_high), (center_x + box_offset + cap_width, whisker_high)]
165-
cap_bottom = [(center_x + box_offset - cap_width, whisker_low), (center_x + box_offset + cap_width, whisker_low)]
166-
chart.add(None, cap_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
167-
chart.add(None, cap_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 8})
168-
169-
# X-axis labels at category positions
170-
chart.x_labels = ["", "Control", "Treatment A", "Treatment B", ""]
171-
chart.x_labels_major_count = 3
149+
chart.add(None, quartile_box, stroke=True, fill=False, show_dots=False, stroke_style={"width": 20})
150+
151+
# Median line (horizontal line within box) - thickest for emphasis
152+
median_line = [(center_x - box_width * 1.3, median), (center_x + box_width * 1.3, median)]
153+
chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 28})
154+
155+
# Whiskers (vertical lines from box to caps)
156+
whisker_bottom = [(center_x, whisker_low), (center_x, q1)]
157+
whisker_top = [(center_x, q3), (center_x, whisker_high)]
158+
chart.add(None, whisker_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14})
159+
chart.add(None, whisker_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14})
160+
161+
# Whisker caps (horizontal lines at ends)
162+
cap_bottom = [(center_x - cap_width, whisker_low), (center_x + cap_width, whisker_low)]
163+
cap_top = [(center_x - cap_width, whisker_high), (center_x + cap_width, whisker_high)]
164+
chart.add(None, cap_bottom, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14})
165+
chart.add(None, cap_top, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14})
166+
167+
# X-axis labels for treatment groups
168+
chart.x_labels = [
169+
{"value": 0, "label": ""},
170+
{"value": 1, "label": "Control"},
171+
{"value": 2, "label": "Treatment A"},
172+
{"value": 3, "label": "Treatment B"},
173+
{"value": 4, "label": ""},
174+
]
172175

173176
# Save outputs
174177
chart.render_to_file("plot.html")
Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,28 @@
11
library: pygal
22
specification_id: raincloud-basic
3-
created: '2025-12-24T22:17:31Z'
4-
updated: '2025-12-24T22:47:58Z'
3+
created: '2025-12-25T07:14:50Z'
4+
updated: '2025-12-25T07:36:01Z'
55
generated_by: claude-opus-4-5-20251101
6-
workflow_run: 20494684444
6+
workflow_run: 20500987018
77
issue: 0
88
python_version: 3.13.11
99
library_version: 3.1.0
1010
preview_url: https://storage.googleapis.com/pyplots-images/plots/raincloud-basic/pygal/plot.png
1111
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/raincloud-basic/pygal/plot_thumb.png
1212
preview_html: https://storage.googleapis.com/pyplots-images/plots/raincloud-basic/pygal/plot.html
13-
quality_score: 88
13+
quality_score: 78
1414
review:
1515
strengths:
16-
- Excellent creative implementation of raincloud plot using pygal XY chart capabilities,
17-
demonstrating how to achieve complex statistical visualizations in a library not
18-
designed for them
19-
- 'Properly implements all three components: half-violin density (with manual KDE),
20-
jittered scatter points, and complete box plots with whiskers and caps'
21-
- Realistic and meaningful data scenario (reaction times across treatment groups)
22-
with appropriate outliers to showcase the visualization
23-
- Good use of pygal Style system for consistent, high-resolution output with appropriate
24-
font sizing
25-
- Proper colorblind-friendly palette with distinct colors for each group
16+
- Correct raincloud layout with cloud on right, boxplot centered, rain on left (vertical
17+
orientation per spec)
18+
- Excellent use of pygal XY chart to build complex composite visualization from
19+
scratch
20+
- Colorblind-safe palette with good contrast between groups
21+
- Realistic clinical trial reaction time scenario
22+
- Manual KDE implementation with proper Silverman bandwidth
23+
- Proper box plot components including whisker caps
2624
weaknesses:
27-
- Legend not rendering visibly in the output despite being configured (legend_at_bottom=True,
28-
show_legend=True) - groups can only be identified via x-axis labels
29-
- Box plot median lines could be more visually distinct from the quartile box edges
30-
for easier statistical interpretation
25+
- Legend displays despite show_legend=False setting - use chart configuration to
26+
fully suppress
27+
- Rain dots could be slightly larger for better visibility at 4800x2700 resolution
28+
- Grid lines are present but could be more subtle (lower alpha)

0 commit comments

Comments
 (0)