|
1 | 1 | """ pyplots.ai |
2 | 2 | raincloud-basic: Basic Raincloud Plot |
3 | 3 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
|
22 | 22 | data["Treatment A"] = np.append(data["Treatment A"], [550, 200]) |
23 | 23 | data["Treatment B"] = np.append(data["Treatment B"], [480, 180]) |
24 | 24 |
|
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) |
27 | 26 | group_colors = ["#306998", "#FFD43B", "#4CAF50"] |
28 | | -palette = tuple(group_colors) + tuple(group_colors) + ("#333333", "#333333", "#333333") |
29 | 27 |
|
30 | | -# Custom style for 4800x2700 px canvas - with legend enabled |
| 28 | +# Custom style for 4800x2700 px canvas - scaled up for visibility |
31 | 29 | custom_style = Style( |
32 | 30 | background="white", |
33 | 31 | plot_background="white", |
34 | 32 | foreground="#333333", |
35 | 33 | 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, |
44 | 43 | opacity_hover=0.8, |
45 | 44 | ) |
46 | 45 |
|
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 |
48 | 50 | chart = pygal.XY( |
49 | 51 | width=4800, |
50 | 52 | height=2700, |
51 | 53 | style=custom_style, |
52 | 54 | title="raincloud-basic · pygal · pyplots.ai", |
53 | 55 | x_title="Treatment Group", |
54 | 56 | 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, |
57 | 58 | stroke=True, |
58 | 59 | fill=True, |
59 | 60 | dots_size=0, |
60 | | - show_x_guides=False, |
| 61 | + show_x_guides=True, |
61 | 62 | show_y_guides=True, |
62 | 63 | range=(100, 750), |
63 | | - xrange=(0, 5), |
64 | | - margin=50, |
| 64 | + xrange=(0, 4), |
| 65 | + margin=80, |
| 66 | + explicit_size=True, |
65 | 67 | ) |
66 | 68 |
|
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 |
72 | 75 |
|
73 | | -# Pre-compute all raincloud components for each group |
| 76 | +# Pre-compute all raincloud components |
74 | 77 | cloud_data = [] |
75 | 78 | rain_data = [] |
76 | 79 | box_data = [] |
77 | 80 |
|
78 | 81 | 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) |
80 | 83 | values = np.array(values) |
81 | 84 |
|
82 | | - # --- Half-Violin (cloud) - on the left side --- |
| 85 | + # --- Half-Violin (cloud) - on RIGHT side of boxplot --- |
83 | 86 | # Compute KDE using Silverman's rule |
84 | 87 | n = len(values) |
85 | 88 | 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) |
88 | 91 |
|
89 | | - # Create range of y values for density |
| 92 | + # Create range of y values (reaction times) for density |
90 | 93 | y_min, y_max = values.min(), values.max() |
91 | 94 | 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) |
93 | 96 |
|
94 | 97 | # Gaussian kernel density estimation |
95 | 98 | density = np.zeros_like(y_range) |
96 | 99 | for v in values: |
97 | 100 | density += np.exp(-0.5 * ((y_range - v) / bandwidth) ** 2) |
98 | 101 | density /= n * bandwidth * np.sqrt(2 * np.pi) |
99 | 102 |
|
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 |
102 | 105 |
|
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)] |
105 | 108 | # 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])) |
108 | 111 |
|
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) |
111 | 114 | 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)] |
113 | 116 | rain_data.append((category, rain_points, group_colors[i])) |
114 | 117 |
|
115 | | - # --- Box Plot (center) --- |
| 118 | + # --- Box Plot (centered at group position) --- |
116 | 119 | median = float(np.median(values)) |
117 | 120 | q1 = float(np.percentile(values, 25)) |
118 | 121 | q3 = float(np.percentile(values, 75)) |
119 | 122 | iqr = q3 - q1 |
120 | 123 | whisker_low = float(max(values.min(), q1 - 1.5 * iqr)) |
121 | 124 | 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])) |
123 | 126 |
|
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) |
127 | 130 |
|
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 |
129 | 132 | 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 |
144 | 142 | 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), |
150 | 148 | ] |
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 | +] |
172 | 175 |
|
173 | 176 | # Save outputs |
174 | 177 | chart.render_to_file("plot.html") |
|
0 commit comments