Skip to content

Commit 2198f80

Browse files
feat(pygal): implement scatter-marginal (#6129)
## Implementation: `scatter-marginal` - python/pygal Implements the **python/pygal** version of `scatter-marginal`. **File:** `plots/scatter-marginal/implementations/python/pygal.py` **Parent Issue:** #2005 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25592866414)* --------- 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 0a37ee9 commit 2198f80

2 files changed

Lines changed: 300 additions & 84 deletions

File tree

Lines changed: 59 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
scatter-marginal: Scatter Plot with Marginal Distributions
3-
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 88/100 | Created: 2025-12-26
3+
Library: pygal 3.1.0 | Python 3.13.13
4+
Quality: 90/100 | Updated: 2026-05-09
55
"""
66

77
import io
8+
import os
89

910
import numpy as np
1011
import pygal
1112
from PIL import Image, ImageDraw, ImageFont
1213
from pygal.style import Style
1314

1415

16+
THEME = os.getenv("ANYPLOT_THEME", "light")
17+
18+
# Theme-adaptive colors from default-style-guide.md
19+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
20+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
21+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
22+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
23+
24+
# Okabe-Ito palette
25+
BRAND = "#009E73" # First categorical series
26+
SECONDARY = "#D55E00" # For marginals if colored
27+
1528
# Data - correlated bivariate data with realistic measurement context
1629
np.random.seed(42)
1730
n_points = 150
@@ -22,68 +35,60 @@
2235
correlation = np.corrcoef(x, y)[0, 1]
2336

2437
# Calculate histogram data for marginals
25-
# Use same number of bins and explicit ranges for better alignment
2638
n_bins = 10
2739
x_min, x_max = np.floor(x.min() / 5) * 5, np.ceil(x.max() / 5) * 5
2840
y_min, y_max = np.floor(y.min() / 5) * 5, np.ceil(y.max() / 5) * 5
2941

3042
x_hist, x_edges = np.histogram(x, bins=n_bins, range=(x_min, x_max))
3143
y_hist, y_edges = np.histogram(y, bins=n_bins, range=(y_min, y_max))
3244

33-
# Dimensions for layout - optimized spacing
45+
# Dimensions for layout
3446
total_width = 4800
3547
total_height = 2700
36-
margin_plot_size = 450 # Slightly smaller marginals
48+
margin_plot_size = 450
3749
title_height = 100
3850
gap = 15
39-
corner_size = margin_plot_size # Size for corner annotation
4051

41-
# Calculate main scatter dimensions
4252
scatter_width = total_width - margin_plot_size - gap * 3
4353
scatter_height = total_height - margin_plot_size - title_height - gap * 3
4454

45-
# Shared margins for alignment
4655
left_margin = 100
4756
bottom_margin = 80
4857
top_margin = 20
4958
right_margin = 20
5059

51-
# Custom style for main scatter
60+
# Custom style for main scatter - theme-adaptive
5261
scatter_style = Style(
53-
background="#ffffff",
54-
plot_background="#fafafa",
55-
foreground="#333333",
56-
foreground_strong="#333333",
57-
foreground_subtle="#666666",
58-
colors=("#306998",),
62+
background=PAGE_BG,
63+
plot_background=PAGE_BG,
64+
foreground=INK,
65+
foreground_strong=INK,
66+
foreground_subtle=INK_MUTED,
67+
colors=(BRAND,), # Okabe-Ito first series
5968
title_font_size=48,
6069
label_font_size=36,
6170
major_label_font_size=32,
6271
legend_font_size=32,
63-
tooltip_font_size=24,
6472
opacity=0.65,
6573
opacity_hover=0.9,
66-
guide_stroke_color="#e0e0e0",
67-
major_guide_stroke_color="#cccccc",
6874
)
6975

70-
# Custom style for marginal histograms - subtle color to not distract from main scatter
76+
# Custom style for marginal histograms - theme-adaptive, subtle color
7177
marginal_style = Style(
72-
background="#ffffff",
73-
plot_background="#f8f8f8",
74-
foreground="#333333",
75-
foreground_strong="#333333",
76-
foreground_subtle="#666666",
77-
colors=("#a8c5db",), # Subtle, lighter blue for marginals
78+
background=PAGE_BG,
79+
plot_background=PAGE_BG,
80+
foreground=INK,
81+
foreground_strong=INK,
82+
foreground_subtle=INK_MUTED,
83+
colors=(INK_SOFT,), # Subtle gray for marginals
7884
title_font_size=32,
7985
label_font_size=32,
8086
major_label_font_size=30,
8187
legend_font_size=28,
82-
opacity=0.6, # More transparency for subtle appearance
83-
guide_stroke_color="#e0e0e0",
88+
opacity=0.6,
8489
)
8590

86-
# Create main scatter plot with explicit axis ranges matching marginal histograms
91+
# Create main scatter plot
8792
scatter = pygal.XY(
8893
width=scatter_width,
8994
height=scatter_height,
@@ -102,22 +107,20 @@
102107
margin_right=right_margin,
103108
margin_bottom=bottom_margin,
104109
margin_left=left_margin,
105-
range=(y_min - 5, y_max + 5), # Y range with slight padding
106-
xrange=(x_min - 5, x_max + 5), # X range with slight padding
110+
range=(y_min - 5, y_max + 5),
111+
xrange=(x_min - 5, x_max + 5),
107112
)
108113

109-
# Add scatter data
110114
scatter_points = [(float(xi), float(yi)) for xi, yi in zip(x, y, strict=True)]
111115
scatter.add("Data", scatter_points)
112116

113117
# Create top marginal histogram (X distribution)
114-
# Use bar chart with x_labels matching scatter X range bins
115118
x_margin = pygal.Bar(
116119
width=scatter_width,
117120
height=margin_plot_size,
118121
style=marginal_style,
119122
show_legend=False,
120-
show_x_labels=False, # Hide to avoid clutter
123+
show_x_labels=False,
121124
show_y_labels=True,
122125
show_y_guides=True,
123126
show_x_guides=False,
@@ -126,12 +129,11 @@
126129
margin_bottom=20,
127130
margin_left=left_margin,
128131
explicit_size=True,
129-
spacing=2, # Tighter spacing for better visual continuity
132+
spacing=2,
130133
)
131134
x_margin.add("X Distribution", [float(h) for h in x_hist])
132135

133-
# Create right marginal histogram (Y distribution) - horizontal bars
134-
# Use same margin settings as scatter for better alignment
136+
# Create right marginal histogram (Y distribution)
135137
y_margin = pygal.HorizontalBar(
136138
width=margin_plot_size,
137139
height=scatter_height,
@@ -143,12 +145,11 @@
143145
show_x_guides=False,
144146
margin_top=top_margin,
145147
margin_right=30,
146-
margin_bottom=bottom_margin, # Match scatter bottom margin
148+
margin_bottom=bottom_margin,
147149
margin_left=10,
148150
explicit_size=True,
149-
spacing=2, # Tighter spacing matching X marginal
151+
spacing=2,
150152
)
151-
# Reverse order to match scatter Y axis orientation (pygal HorizontalBar goes top-to-bottom)
152153
y_margin.add("Y Distribution", [float(h) for h in y_hist[::-1]])
153154

154155
# Render each chart to PNG in memory
@@ -161,10 +162,10 @@
161162
x_margin_img = Image.open(io.BytesIO(x_margin_png))
162163
y_margin_img = Image.open(io.BytesIO(y_margin_png))
163164

164-
# Create final composite image
165-
final_img = Image.new("RGB", (total_width, total_height), "white")
165+
# Create final composite image with theme-adaptive background
166+
final_img = Image.new("RGB", (total_width, total_height), PAGE_BG)
166167

167-
# Calculate positions - aligned for better visual coherence
168+
# Calculate positions
168169
scatter_x = gap
169170
scatter_y = title_height + margin_plot_size + gap
170171
x_margin_x = gap
@@ -179,7 +180,7 @@
179180

180181
# Add title and corner annotation
181182
draw = ImageDraw.Draw(final_img)
182-
title_text = "scatter-marginal · pygal · pyplots.ai"
183+
title_text = "scatter-marginal · pygal · anyplot.ai"
183184
try:
184185
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 60)
185186
stats_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
@@ -194,33 +195,35 @@
194195
text_width = bbox[2] - bbox[0]
195196
text_x = (total_width - text_width) // 2
196197
text_y = 30
197-
draw.text((text_x, text_y), title_text, fill="#333333", font=title_font)
198+
draw.text((text_x, text_y), title_text, fill=INK, font=title_font)
198199

199200
# Add statistics in the corner space (top-right empty area)
200-
# This corner is at: x = right of top marginal, y = below title and above right marginal
201-
corner_x = y_margin_x + 30 # Right side where y_margin is
202-
corner_y = title_height + 30 # Just below title area
201+
corner_x = y_margin_x + 30
202+
corner_y = title_height + 30
203203
corner_width = margin_plot_size - 60
204204
corner_height = margin_plot_size - 80
205205

206-
# Draw subtle background for stats box
206+
# Draw subtle background for stats box with theme-adaptive colors
207+
elevated_bg = "#FFFDF6" if THEME == "light" else "#242420"
208+
box_border = INK_MUTED
209+
207210
stats_box = [(corner_x, corner_y), (corner_x + corner_width, corner_y + corner_height)]
208-
draw.rounded_rectangle(stats_box, radius=15, fill="#f5f5f5", outline="#c0c0c0", width=2)
211+
draw.rounded_rectangle(stats_box, radius=15, fill=elevated_bg, outline=box_border, width=2)
209212

210-
# Add statistics text - centered in box
213+
# Add statistics text
211214
stats_title = "Summary"
212-
draw.text((corner_x + 35, corner_y + 25), stats_title, fill="#333333", font=stats_font_bold)
215+
draw.text((corner_x + 35, corner_y + 25), stats_title, fill=INK, font=stats_font_bold)
213216

214217
stats_lines = [f"n = {n_points}", f"r = {correlation:.3f}", f"A̅ = {np.mean(x):.1f}", f"B̅ = {np.mean(y):.1f}"]
215218
line_y = corner_y + 85
216219
for line in stats_lines:
217-
draw.text((corner_x + 35, line_y), line, fill="#555555", font=stats_font)
220+
draw.text((corner_x + 35, line_y), line, fill=INK_SOFT, font=stats_font)
218221
line_y += 50
219222

220-
# Save final image
221-
final_img.save("plot.png", "PNG")
223+
# Save final image and HTML
224+
final_img.save(f"plot-{THEME}.png", "PNG")
222225

223226
# Also save the scatter SVG as HTML for interactivity
224227
scatter_svg_full = scatter.render().decode("utf-8")
225-
with open("plot.html", "w", encoding="utf-8") as f:
228+
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
226229
f.write(scatter_svg_full)

0 commit comments

Comments
 (0)