|
1 | 1 | """ anyplot.ai |
2 | 2 | swarm-basic: Basic Swarm Plot |
3 | | -Library: pygal 3.1.0 | Python 3.13.13 |
4 | | -Quality: 80/100 | Updated: 2026-05-05 |
| 3 | +Library: pygal 3.1.3 | Python 3.13.14 |
| 4 | +Quality: 88/100 | Updated: 2026-07-26 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
|
11 | 11 | from pygal.style import Style |
12 | 12 |
|
13 | 13 |
|
14 | | -# Theme tokens |
| 14 | +# Theme tokens (see prompts/default-style-guide.md "Theme-adaptive Chrome") |
15 | 15 | THEME = os.getenv("ANYPLOT_THEME", "light") |
16 | 16 | PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
17 | 17 | INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
18 | 18 | INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
19 | 19 |
|
20 | | -IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477") |
| 20 | +IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233") |
21 | 21 |
|
22 | | -# Data - Employee performance scores by department |
| 22 | +# Data - employee performance scores by department (clamped to a plausible 0-100 scale) |
23 | 23 | np.random.seed(42) |
24 | 24 | categories = ["Engineering", "Marketing", "Sales", "Operations"] |
25 | 25 | data = { |
26 | | - "Engineering": np.random.normal(82, 8, 45), |
27 | | - "Marketing": np.random.normal(75, 12, 50), |
28 | | - "Sales": np.random.normal(78, 15, 40), |
29 | | - "Operations": np.random.normal(70, 10, 55), |
| 26 | + "Engineering": np.clip(np.random.normal(82, 7, 45), 0, 100), |
| 27 | + "Marketing": np.clip(np.random.normal(75, 9, 50), 0, 100), |
| 28 | + "Sales": np.clip(np.random.normal(78, 10, 40), 0, 100), |
| 29 | + "Operations": np.clip(np.random.normal(70, 8, 55), 0, 100), |
30 | 30 | } |
31 | 31 |
|
32 | | -# Style |
| 32 | +all_values = np.concatenate(list(data.values())) |
| 33 | +Y_MIN = 10 * np.floor(all_values.min() / 10) |
| 34 | +Y_MAX = 10 * np.ceil(all_values.max() / 10) |
| 35 | + |
| 36 | +# Style - source-pixel sizes for a 3200x1800 canvas (see prompts/library/pygal.md) |
33 | 37 | custom_style = Style( |
34 | 38 | background=PAGE_BG, |
35 | 39 | plot_background=PAGE_BG, |
36 | 40 | foreground=INK, |
37 | 41 | foreground_strong=INK, |
38 | 42 | foreground_subtle=INK_MUTED, |
39 | | - colors=IMPRINT, |
40 | | - title_font_size=72, |
41 | | - label_font_size=48, |
42 | | - major_label_font_size=42, |
43 | | - legend_font_size=42, |
44 | | - tooltip_font_size=36, |
45 | | - opacity=0.85, |
| 43 | + colors=IMPRINT_PALETTE + (INK,), # last color reserved for the Group Mean marker |
| 44 | + title_font_size=66, |
| 45 | + label_font_size=56, |
| 46 | + major_label_font_size=44, |
| 47 | + legend_font_size=44, |
| 48 | + value_font_size=36, |
| 49 | + opacity=0.75, |
46 | 50 | opacity_hover=1.0, |
| 51 | + stroke_width=2.5, |
47 | 52 | ) |
48 | 53 |
|
49 | 54 | # Plot |
50 | 55 | chart = pygal.XY( |
51 | | - width=4800, |
52 | | - height=2700, |
| 56 | + width=3200, |
| 57 | + height=1800, |
53 | 58 | style=custom_style, |
54 | | - title="swarm-basic · pygal · anyplot.ai", |
| 59 | + title="swarm-basic · python · pygal · anyplot.ai", |
55 | 60 | x_title="Department", |
56 | 61 | y_title="Performance Score", |
57 | 62 | show_legend=True, |
58 | 63 | legend_at_bottom=True, |
59 | 64 | stroke=False, |
60 | | - dots_size=12, |
| 65 | + dots_size=10, |
61 | 66 | show_x_guides=False, |
62 | 67 | show_y_guides=True, |
63 | 68 | xrange=(0, 5), |
64 | | - range=(40, 115), |
65 | | - margin=50, |
| 69 | + range=(Y_MIN, Y_MAX), |
| 70 | + margin=40, |
| 71 | + margin_right=20, |
66 | 72 | ) |
67 | 73 |
|
68 | | -# Beeswarm algorithm - spreads points horizontally to avoid overlap |
| 74 | +# Beeswarm algorithm - spreads points horizontally to avoid overlap. |
| 75 | +# Collision thresholds are derived per-axis from the actual rendered dot |
| 76 | +# footprint (dots_size in px) against each axis's own data-unit-per-pixel |
| 77 | +# scale, so a 10px dot compares correctly whether it's 0.03 x-units wide |
| 78 | +# (category axis spans 5 units over ~2900 plot px) or ~0.6 y-units tall |
| 79 | +# (value axis spans Y_MAX-Y_MIN over ~1270 plot px) - not one flat number |
| 80 | +# for both axes. |
| 81 | +PLOT_WIDTH_PX = 2900 |
| 82 | +PLOT_HEIGHT_PX = 1270 |
| 83 | +DOT_RADIUS_PX = 10 |
| 84 | +SPACING_PX = 4 |
| 85 | + |
| 86 | +x_unit_per_px = 5 / PLOT_WIDTH_PX |
| 87 | +y_unit_per_px = (Y_MAX - Y_MIN) / PLOT_HEIGHT_PX |
| 88 | +min_dist_x = 2 * DOT_RADIUS_PX * x_unit_per_px + SPACING_PX * x_unit_per_px |
| 89 | +min_dist_y = 2 * DOT_RADIUS_PX * y_unit_per_px + SPACING_PX * y_unit_per_px |
| 90 | +step_x = DOT_RADIUS_PX * x_unit_per_px + SPACING_PX * x_unit_per_px / 2 |
| 91 | + |
69 | 92 | for cat_idx, (category, values) in enumerate(data.items()): |
70 | 93 | center_x = cat_idx + 1 |
71 | | - point_radius = 0.1 |
72 | | - spacing = 0.05 |
73 | 94 |
|
74 | 95 | sorted_indices = np.argsort(values) |
75 | 96 | placed = [] |
|
87 | 108 | for px, py in placed: |
88 | 109 | dist_y = abs(y - py) |
89 | 110 | dist_x = abs(test_x - px) |
90 | | - min_dist = 2 * point_radius + spacing |
91 | | - if dist_y < min_dist and dist_x < min_dist: |
| 111 | + if dist_y < min_dist_y and dist_x < min_dist_x: |
92 | 112 | overlap = True |
93 | 113 | break |
94 | 114 | if not overlap: |
|
98 | 118 | direction = -1 |
99 | 119 | else: |
100 | 120 | direction = 1 |
101 | | - offset += point_radius + spacing / 2 |
| 121 | + offset += step_x |
102 | 122 |
|
103 | 123 | placed.append((x, y)) |
104 | 124 | swarm_points.append((x, y)) |
105 | 125 |
|
106 | 126 | chart.add(category, swarm_points) |
107 | 127 |
|
| 128 | +# Group mean markers - subtle reference points per category (neutral anchor color) |
| 129 | +mean_points = [(cat_idx + 1, float(np.mean(values))) for cat_idx, (_, values) in enumerate(data.items())] |
| 130 | +chart.add("Group Mean", mean_points, dots_size=20) |
| 131 | + |
108 | 132 | # x-axis category labels |
109 | 133 | chart.x_labels = ["", "Engineering", "Marketing", "Sales", "Operations", ""] |
110 | 134 |
|
|
0 commit comments