|
1 | | -""" pyplots.ai |
| 1 | +"""pyplots.ai |
2 | 2 | raincloud-basic: Basic Raincloud Plot |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-25 |
| 3 | +Library: pygal 3.1.0 | Python 3.14 |
| 4 | +Quality: /100 | Updated: 2026-02-14 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
|
24 | 24 |
|
25 | 25 | # Group colors - distinct for each category (colorblind-safe) |
26 | 26 | group_colors = ["#306998", "#FFD43B", "#4CAF50"] |
| 27 | +box_color = "#000000" |
27 | 28 |
|
28 | | -# Custom style for 4800x2700 px canvas - scaled up for visibility |
| 29 | +# Build color sequence matching series order: |
| 30 | +# Per group: cloud, rain, box, median, whisker_l, whisker_r, cap_l, cap_r |
| 31 | +series_colors = [] |
| 32 | +for gc in group_colors: |
| 33 | + series_colors.extend([gc, gc, box_color, box_color, box_color, box_color, box_color, box_color]) |
| 34 | + |
| 35 | +# Custom style for 4800x2700 px canvas |
29 | 36 | custom_style = Style( |
30 | 37 | background="white", |
31 | 38 | plot_background="white", |
32 | 39 | foreground="#333333", |
33 | 40 | foreground_strong="#333333", |
34 | 41 | foreground_subtle="#999999", |
35 | 42 | guide_stroke_color="#e8e8e8", |
36 | | - colors=tuple(group_colors * 3) + ("#222222",) * 30, |
| 43 | + colors=tuple(series_colors), |
37 | 44 | title_font_size=96, |
38 | 45 | label_font_size=60, |
39 | 46 | major_label_font_size=54, |
40 | 47 | legend_font_size=54, |
41 | 48 | value_font_size=42, |
42 | | - opacity=0.6, |
43 | | - opacity_hover=0.8, |
| 49 | + tooltip_font_size=42, |
| 50 | + opacity=0.75, |
| 51 | + opacity_hover=0.9, |
44 | 52 | ) |
45 | 53 |
|
46 | 54 | # Create HORIZONTAL XY chart for raincloud plot |
47 | 55 | # X-axis = Reaction Time (value), Y-axis = Treatment Group (category) |
48 | | -# HORIZONTAL orientation: cloud on TOP, boxplot centered, rain BELOW |
49 | 56 | chart = pygal.XY( |
50 | 57 | width=4800, |
51 | 58 | height=2700, |
|
54 | 61 | x_title="Reaction Time (ms)", |
55 | 62 | y_title="Treatment Group", |
56 | 63 | show_legend=False, |
57 | | - legend_at_bottom=False, |
58 | | - legend_box_size=0, |
59 | 64 | stroke=True, |
60 | 65 | fill=True, |
61 | 66 | dots_size=0, |
|
68 | 73 | ) |
69 | 74 |
|
70 | 75 | # Raincloud layout parameters (HORIZONTAL orientation) |
71 | | -# Cloud on TOP (positive Y offset), boxplot centered, rain BELOW (negative Y offset) |
72 | | -cloud_offset = 0.28 # Cloud extends UP (positive Y offset) |
73 | | -rain_offset = -0.32 # Rain falls DOWN (negative Y offset) |
| 76 | +# Cloud extends UPWARD (positive Y offset from category line) |
| 77 | +# Rain falls DOWNWARD (negative Y offset from category line) |
| 78 | +cloud_height = 0.28 |
| 79 | +rain_offset = -0.32 |
74 | 80 | n_kde_points = 80 |
75 | | - |
76 | | -# Pre-compute all raincloud components |
77 | | -cloud_data = [] |
78 | | -rain_data = [] |
79 | | -box_data = [] |
| 81 | +box_width = 0.10 |
| 82 | +cap_width = 0.06 |
80 | 83 |
|
81 | 84 | for i, (category, values) in enumerate(data.items()): |
82 | 85 | center_y = i + 1 # Y position for this group (1, 2, 3) |
83 | 86 | values = np.array(values) |
84 | 87 |
|
85 | | - # --- Half-Violin (cloud) - on TOP of boxplot --- |
86 | | - # Compute KDE using Silverman's rule |
| 88 | + # --- Half-Violin (cloud) - extends UPWARD from category line --- |
87 | 89 | n = len(values) |
88 | 90 | std = np.std(values) |
89 | 91 | iqr_val = np.percentile(values, 75) - np.percentile(values, 25) |
90 | 92 | bandwidth = 0.9 * min(std, iqr_val / 1.34) * n ** (-0.2) |
91 | 93 |
|
92 | | - # Create range of x values (reaction times) for density |
93 | 94 | x_min, x_max = values.min(), values.max() |
94 | 95 | padding = (x_max - x_min) * 0.1 |
95 | 96 | x_range = np.linspace(x_min - padding, x_max + padding, n_kde_points) |
96 | 97 |
|
97 | | - # Gaussian kernel density estimation |
| 98 | + # Gaussian KDE |
98 | 99 | density = np.zeros_like(x_range) |
99 | 100 | for v in values: |
100 | 101 | density += np.exp(-0.5 * ((x_range - v) / bandwidth) ** 2) |
101 | 102 | density /= n * bandwidth * np.sqrt(2 * np.pi) |
102 | 103 |
|
103 | | - # Normalize density and place on TOP (positive Y direction = cloud) |
104 | | - density = density / density.max() * cloud_offset |
| 104 | + # Normalize and place cloud ABOVE category line (positive Y) |
| 105 | + density_scaled = density / density.max() * cloud_height |
105 | 106 |
|
106 | | - # Create half-violin shape - cloud extends UP (higher Y) |
107 | | - cloud_points = [(x, center_y + d) for x, d in zip(x_range, density, strict=True)] |
108 | | - # Close the shape along the center line |
109 | | - cloud_points = [(x_range[0], center_y)] + cloud_points + [(x_range[-1], center_y), (x_range[0], center_y)] |
110 | | - cloud_data.append((category, cloud_points, group_colors[i])) |
| 107 | + # Half-violin shape: baseline at center_y, cloud rises upward |
| 108 | + cloud_points = [(float(x_range[0]), center_y)] |
| 109 | + cloud_points += [ |
| 110 | + {"value": (float(x), center_y + float(d)), "label": f"{category} density"} |
| 111 | + for x, d in zip(x_range, density_scaled, strict=True) |
| 112 | + ] |
| 113 | + cloud_points += [(float(x_range[-1]), center_y), (float(x_range[0]), center_y)] |
| 114 | + chart.add(f"{category} cloud", cloud_points, stroke=True, fill=True) |
111 | 115 |
|
112 | | - # --- Jittered Points (rain) - BELOW (rain falls from cloud) --- |
| 116 | + # --- Jittered Points (rain) - falls DOWNWARD from category line --- |
113 | 117 | np.random.seed(42 + i) |
114 | 118 | jitter = np.random.uniform(-0.08, 0.08, len(values)) |
115 | | - rain_points = [(float(v), center_y + rain_offset + j) for j, v in zip(jitter, values, strict=True)] |
116 | | - rain_data.append((category, rain_points, group_colors[i])) |
| 119 | + rain_points = [ |
| 120 | + {"value": (float(v), center_y + rain_offset + float(j)), "label": f"{category}: {v:.0f} ms"} |
| 121 | + for j, v in zip(jitter, values, strict=True) |
| 122 | + ] |
| 123 | + chart.add(f"{category} rain", rain_points, stroke=False, fill=False, dots_size=32) |
117 | 124 |
|
118 | | - # --- Box Plot (centered at group position) --- |
| 125 | + # --- Box Plot (centered at category line) --- |
119 | 126 | median = float(np.median(values)) |
120 | 127 | q1 = float(np.percentile(values, 25)) |
121 | 128 | q3 = float(np.percentile(values, 75)) |
122 | 129 | iqr = q3 - q1 |
123 | 130 | whisker_low = float(max(values.min(), q1 - 1.5 * iqr)) |
124 | 131 | whisker_high = float(min(values.max(), q3 + 1.5 * iqr)) |
125 | | - box_data.append((center_y, median, q1, q3, whisker_low, whisker_high, group_colors[i])) |
126 | 132 |
|
127 | | -# Add clouds (half-violins) |
128 | | -for _category, cloud_points, _color in cloud_data: |
129 | | - chart.add("", cloud_points, stroke=True, fill=True) |
130 | | - |
131 | | -# Add rain points - increased dots_size for better visibility |
132 | | -for _category, rain_points, _color in rain_data: |
133 | | - chart.add("", rain_points, stroke=False, fill=False, dots_size=32) |
134 | | - |
135 | | -# Add box plots - HORIZONTAL boxes centered at each group |
136 | | -box_width = 0.10 |
137 | | -cap_width = 0.06 |
138 | | - |
139 | | -for center_y, median, q1, q3, whisker_low, whisker_high, _color in box_data: |
140 | 133 | # IQR box (horizontal rectangle) |
141 | 134 | quartile_box = [ |
142 | 135 | (q1, center_y - box_width), |
|
145 | 138 | (q3, center_y - box_width), |
146 | 139 | (q1, center_y - box_width), |
147 | 140 | ] |
148 | | - chart.add("", quartile_box, stroke=True, fill=False, show_dots=False, stroke_style={"width": 20}) |
| 141 | + chart.add("", quartile_box, stroke=True, fill=False, show_dots=False, stroke_style={"width": 36}) |
149 | 142 |
|
150 | 143 | # Median line (vertical line within box) |
151 | | - median_line = [(median, center_y - box_width * 1.3), (median, center_y + box_width * 1.3)] |
152 | | - chart.add("", median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 28}) |
| 144 | + median_line = [ |
| 145 | + {"value": (median, center_y - box_width * 1.3), "label": f"{category} median: {median:.0f} ms"}, |
| 146 | + (median, center_y + box_width * 1.3), |
| 147 | + ] |
| 148 | + chart.add("", median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 44}) |
153 | 149 |
|
154 | | - # Whiskers (horizontal lines from box to caps) |
| 150 | + # Whiskers |
155 | 151 | whisker_left = [(whisker_low, center_y), (q1, center_y)] |
156 | 152 | whisker_right = [(q3, center_y), (whisker_high, center_y)] |
157 | | - chart.add("", whisker_left, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14}) |
158 | | - chart.add("", whisker_right, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14}) |
| 153 | + chart.add("", whisker_left, stroke=True, fill=False, show_dots=False, stroke_style={"width": 22}) |
| 154 | + chart.add("", whisker_right, stroke=True, fill=False, show_dots=False, stroke_style={"width": 22}) |
159 | 155 |
|
160 | | - # Whisker caps (vertical lines at ends) |
| 156 | + # Whisker caps |
161 | 157 | cap_left = [(whisker_low, center_y - cap_width), (whisker_low, center_y + cap_width)] |
162 | 158 | cap_right = [(whisker_high, center_y - cap_width), (whisker_high, center_y + cap_width)] |
163 | | - chart.add("", cap_left, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14}) |
164 | | - chart.add("", cap_right, stroke=True, fill=False, show_dots=False, stroke_style={"width": 14}) |
| 159 | + chart.add("", cap_left, stroke=True, fill=False, show_dots=False, stroke_style={"width": 22}) |
| 160 | + chart.add("", cap_right, stroke=True, fill=False, show_dots=False, stroke_style={"width": 22}) |
165 | 161 |
|
166 | | -# Y-axis labels for treatment groups (categories on Y-axis for horizontal) |
| 162 | +# Y-axis labels for treatment groups |
167 | 163 | chart.y_labels = [ |
168 | 164 | {"value": 0, "label": ""}, |
169 | 165 | {"value": 1, "label": "Control"}, |
|
0 commit comments