Skip to content

Commit b4dc4ea

Browse files
feat(pygal): implement swarm-basic (#9939)
## Implementation: `swarm-basic` - python/pygal Implements the **python/pygal** version of `swarm-basic`. **File:** `plots/swarm-basic/implementations/python/pygal.py` **Parent Issue:** #974 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30192192064)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 1da8d57 commit b4dc4ea

2 files changed

Lines changed: 171 additions & 152 deletions

File tree

plots/swarm-basic/implementations/python/pygal.py

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
""" anyplot.ai
22
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
55
"""
66

77
import os
@@ -11,65 +11,86 @@
1111
from pygal.style import Style
1212

1313

14-
# Theme tokens
14+
# Theme tokens (see prompts/default-style-guide.md "Theme-adaptive Chrome")
1515
THEME = os.getenv("ANYPLOT_THEME", "light")
1616
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
1717
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
1818
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
1919

20-
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")
20+
IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233")
2121

22-
# Data - Employee performance scores by department
22+
# Data - employee performance scores by department (clamped to a plausible 0-100 scale)
2323
np.random.seed(42)
2424
categories = ["Engineering", "Marketing", "Sales", "Operations"]
2525
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),
3030
}
3131

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)
3337
custom_style = Style(
3438
background=PAGE_BG,
3539
plot_background=PAGE_BG,
3640
foreground=INK,
3741
foreground_strong=INK,
3842
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,
4650
opacity_hover=1.0,
51+
stroke_width=2.5,
4752
)
4853

4954
# Plot
5055
chart = pygal.XY(
51-
width=4800,
52-
height=2700,
56+
width=3200,
57+
height=1800,
5358
style=custom_style,
54-
title="swarm-basic · pygal · anyplot.ai",
59+
title="swarm-basic · python · pygal · anyplot.ai",
5560
x_title="Department",
5661
y_title="Performance Score",
5762
show_legend=True,
5863
legend_at_bottom=True,
5964
stroke=False,
60-
dots_size=12,
65+
dots_size=10,
6166
show_x_guides=False,
6267
show_y_guides=True,
6368
xrange=(0, 5),
64-
range=(40, 115),
65-
margin=50,
69+
range=(Y_MIN, Y_MAX),
70+
margin=40,
71+
margin_right=20,
6672
)
6773

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+
6992
for cat_idx, (category, values) in enumerate(data.items()):
7093
center_x = cat_idx + 1
71-
point_radius = 0.1
72-
spacing = 0.05
7394

7495
sorted_indices = np.argsort(values)
7596
placed = []
@@ -87,8 +108,7 @@
87108
for px, py in placed:
88109
dist_y = abs(y - py)
89110
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:
92112
overlap = True
93113
break
94114
if not overlap:
@@ -98,13 +118,17 @@
98118
direction = -1
99119
else:
100120
direction = 1
101-
offset += point_radius + spacing / 2
121+
offset += step_x
102122

103123
placed.append((x, y))
104124
swarm_points.append((x, y))
105125

106126
chart.add(category, swarm_points)
107127

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+
108132
# x-axis category labels
109133
chart.x_labels = ["", "Engineering", "Marketing", "Sales", "Operations", ""]
110134

0 commit comments

Comments
 (0)