|
1 | 1 | """ pyplots.ai |
2 | 2 | violin-basic: Basic Violin Plot |
3 | | -Library: pygal 3.1.0 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: pygal 3.1.0 | Python 3.14.3 |
| 4 | +Quality: 85/100 | Updated: 2026-02-21 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import pygal |
9 | 9 | from pygal.style import Style |
10 | 10 |
|
11 | 11 |
|
12 | | -# Data - Generate distributions for different categories |
| 12 | +# Data - Test scores across 4 class groups with distinct distribution shapes |
13 | 13 | np.random.seed(42) |
14 | 14 | data = { |
15 | | - "Engineering": np.random.normal(85, 12, 200), |
16 | | - "Marketing": np.random.normal(72, 15, 200), |
17 | | - "Sales": np.random.normal(78, 20, 200), |
18 | | - "Operations": np.random.normal(65, 10, 200), |
| 15 | + "Honors": np.clip(np.random.normal(88, 6, 200), 50, 100), |
| 16 | + "Standard": np.clip(60 + np.random.gamma(3.5, 4, 200), 40, 100), |
| 17 | + "Remedial": np.clip(np.random.normal(62, 8, 200), 30, 100), |
| 18 | + "Advanced": np.clip(np.concatenate([np.random.normal(75, 6, 120), np.random.normal(93, 4, 80)]), 45, 100), |
19 | 19 | } |
20 | 20 |
|
21 | | -# Custom style for 4800x2700 px canvas |
| 21 | +# Colors: 3 series per violin (fill, IQR fill, median line) |
| 22 | +# Purple replaces green for deuteranopia accessibility; gold emphasizes bimodal Advanced |
| 23 | +violin_colors = ["#306998", "#E8875B", "#8B6FBF", "#D4A017"] |
| 24 | +iqr_colors = ["#1d3f5c", "#a35a38", "#5A3F82", "#8a6a10"] |
| 25 | +median_color = "#FFFFFF" |
| 26 | +palette = [] |
| 27 | +for vc, ic in zip(violin_colors, iqr_colors, strict=True): |
| 28 | + palette.extend([vc, ic, median_color]) |
| 29 | + |
22 | 30 | custom_style = Style( |
23 | 31 | background="white", |
24 | 32 | plot_background="white", |
25 | | - foreground="#333333", |
| 33 | + foreground="#555555", |
26 | 34 | foreground_strong="#333333", |
27 | | - foreground_subtle="#666666", |
28 | | - colors=("#306998", "#FFD43B", "#4CAF50", "#FF5722", "#666666", "#999999", "#333333", "#AAAAAA"), |
| 35 | + foreground_subtle="#e0e0e0", |
| 36 | + colors=tuple(palette), |
29 | 37 | title_font_size=72, |
30 | 38 | label_font_size=48, |
31 | 39 | major_label_font_size=42, |
32 | 40 | legend_font_size=36, |
33 | 41 | value_font_size=36, |
34 | | - opacity=0.7, |
35 | | - opacity_hover=0.9, |
| 42 | + opacity=0.78, |
| 43 | + opacity_hover=0.92, |
| 44 | + transition="200ms ease-in", |
36 | 45 | ) |
37 | 46 |
|
38 | 47 | # Create XY chart for violin plot (pygal has no native violin) |
|
41 | 50 | height=2700, |
42 | 51 | style=custom_style, |
43 | 52 | title="violin-basic · pygal · pyplots.ai", |
44 | | - x_title="Category", |
45 | | - y_title="Performance Score", |
46 | | - show_legend=True, |
47 | | - legend_at_bottom=True, |
| 53 | + x_title="Class Group", |
| 54 | + y_title="Test Score (%)", |
| 55 | + show_legend=False, |
48 | 56 | stroke=True, |
49 | 57 | fill=True, |
50 | 58 | dots_size=0, |
51 | 59 | show_x_guides=False, |
52 | 60 | show_y_guides=True, |
53 | | - range=(20, 130), |
54 | | - xrange=(0, 6), |
| 61 | + range=(33, 103), |
| 62 | + xrange=(0, 5.0), |
55 | 63 | margin=50, |
| 64 | + value_formatter=lambda x: f"{x:.0f}%", |
| 65 | + x_value_formatter=lambda x: "", |
| 66 | + tooltip_border_radius=10, |
| 67 | + tooltip_fancy_mode=True, |
| 68 | + human_readable=True, |
| 69 | + pretty_print=True, |
56 | 70 | ) |
57 | 71 |
|
58 | | -# Parameters for violin shapes |
59 | | -violin_width = 0.4 |
| 72 | +# Violin widths — Advanced is wider to visually highlight its bimodal shape |
| 73 | +base_width = 0.38 |
| 74 | +widths = {"Honors": base_width, "Standard": base_width, "Remedial": base_width, "Advanced": 0.46} |
60 | 75 | n_points = 100 |
61 | 76 |
|
62 | | -# Add violins for each category |
| 77 | +# Build violins with quartile markers and median lines |
63 | 78 | for i, (category, values) in enumerate(data.items()): |
64 | | - center_x = i + 1.5 |
| 79 | + center_x = i + 1.0 |
| 80 | + violin_width = widths[category] |
65 | 81 |
|
66 | | - # Compute KDE using Silverman's rule |
| 82 | + # KDE using Silverman's rule |
67 | 83 | n = len(values) |
68 | 84 | std = np.std(values) |
69 | | - iqr = np.percentile(values, 75) - np.percentile(values, 25) |
70 | | - bandwidth = 0.9 * min(std, iqr / 1.34) * n ** (-0.2) |
| 85 | + iqr_val = np.percentile(values, 75) - np.percentile(values, 25) |
| 86 | + bandwidth = 0.9 * min(std, iqr_val / 1.34) * n ** (-0.2) |
71 | 87 |
|
72 | | - # Create range of y values for density |
| 88 | + # Y values for density estimation |
73 | 89 | y_min, y_max = values.min(), values.max() |
74 | | - y_range = np.linspace(y_min - 5, y_max + 5, n_points) |
| 90 | + y_range = np.linspace(y_min - 2, y_max + 2, n_points) |
75 | 91 |
|
76 | 92 | # Gaussian kernel density estimation |
77 | 93 | density = np.zeros_like(y_range) |
|
82 | 98 | # Normalize density to desired width |
83 | 99 | density = density / density.max() * violin_width |
84 | 100 |
|
85 | | - # Create violin shape (mirrored density) |
| 101 | + # Mirrored violin shape with tooltip showing statistics |
| 102 | + median_val = float(np.median(values)) |
| 103 | + q1 = float(np.percentile(values, 25)) |
| 104 | + q3 = float(np.percentile(values, 75)) |
| 105 | + tooltip = f"{category} — Median: {median_val:.1f}%, Q1: {q1:.1f}%, Q3: {q3:.1f}%" |
| 106 | + |
86 | 107 | left_points = [(center_x - d, y) for y, d in zip(y_range, density, strict=True)] |
87 | 108 | right_points = [(center_x + d, y) for y, d in zip(y_range[::-1], density[::-1], strict=True)] |
88 | 109 | violin_points = left_points + right_points + [left_points[0]] |
| 110 | + chart.add(category, violin_points, formatter=lambda x, t=tooltip: t, stroke_style={"width": 2}) |
89 | 111 |
|
90 | | - chart.add(category, violin_points) |
91 | | - |
92 | | - # Calculate quartiles and median for inner box |
93 | | - median = float(np.median(values)) |
94 | | - q1 = float(np.percentile(values, 25)) |
95 | | - q3 = float(np.percentile(values, 75)) |
96 | | - box_width = 0.06 |
97 | | - |
98 | | - # Quartile box (IQR) |
| 112 | + # Quartile markers — filled box in darker shade for clear visibility |
| 113 | + box_w = 0.16 |
99 | 114 | quartile_box = [ |
100 | | - (center_x - box_width, q1), |
101 | | - (center_x - box_width, q3), |
102 | | - (center_x + box_width, q3), |
103 | | - (center_x + box_width, q1), |
104 | | - (center_x - box_width, q1), |
| 115 | + (center_x - box_w, q1), |
| 116 | + (center_x - box_w, q3), |
| 117 | + (center_x + box_w, q3), |
| 118 | + (center_x + box_w, q1), |
| 119 | + (center_x - box_w, q1), |
105 | 120 | ] |
106 | | - chart.add(None, quartile_box, stroke=True, fill=False, show_dots=False) |
| 121 | + chart.add(None, quartile_box, stroke=True, fill=True, show_dots=False, stroke_style={"width": 5}) |
107 | 122 |
|
108 | | - # Median line |
109 | | - median_line = [(center_x - box_width * 1.5, median), (center_x + box_width * 1.5, median)] |
110 | | - chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 4}) |
| 123 | + # Median line — thick white for high contrast against dark IQR box |
| 124 | + median_line = [(center_x - box_w * 1.1, median_val), (center_x + box_w * 1.1, median_val)] |
| 125 | + chart.add(None, median_line, stroke=True, fill=False, show_dots=False, stroke_style={"width": 18}) |
111 | 126 |
|
112 | 127 | # X-axis labels at violin positions |
113 | | -chart.x_labels = ["", "Engineering", "Marketing", "Sales", "Operations", ""] |
| 128 | +chart.x_labels = ["", "Honors", "Standard", "Remedial", "Advanced", ""] |
114 | 129 | chart.x_labels_major_count = 4 |
115 | 130 |
|
116 | | -# Save outputs |
| 131 | +# Save |
117 | 132 | chart.render_to_file("plot.html") |
118 | 133 | chart.render_to_png("plot.png") |
0 commit comments