|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import io |
| 8 | +import os |
8 | 9 |
|
9 | 10 | import numpy as np |
10 | 11 | import pygal |
11 | 12 | from PIL import Image, ImageDraw, ImageFont |
12 | 13 | from pygal.style import Style |
13 | 14 |
|
14 | 15 |
|
| 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 | + |
15 | 28 | # Data - correlated bivariate data with realistic measurement context |
16 | 29 | np.random.seed(42) |
17 | 30 | n_points = 150 |
|
22 | 35 | correlation = np.corrcoef(x, y)[0, 1] |
23 | 36 |
|
24 | 37 | # Calculate histogram data for marginals |
25 | | -# Use same number of bins and explicit ranges for better alignment |
26 | 38 | n_bins = 10 |
27 | 39 | x_min, x_max = np.floor(x.min() / 5) * 5, np.ceil(x.max() / 5) * 5 |
28 | 40 | y_min, y_max = np.floor(y.min() / 5) * 5, np.ceil(y.max() / 5) * 5 |
29 | 41 |
|
30 | 42 | x_hist, x_edges = np.histogram(x, bins=n_bins, range=(x_min, x_max)) |
31 | 43 | y_hist, y_edges = np.histogram(y, bins=n_bins, range=(y_min, y_max)) |
32 | 44 |
|
33 | | -# Dimensions for layout - optimized spacing |
| 45 | +# Dimensions for layout |
34 | 46 | total_width = 4800 |
35 | 47 | total_height = 2700 |
36 | | -margin_plot_size = 450 # Slightly smaller marginals |
| 48 | +margin_plot_size = 450 |
37 | 49 | title_height = 100 |
38 | 50 | gap = 15 |
39 | | -corner_size = margin_plot_size # Size for corner annotation |
40 | 51 |
|
41 | | -# Calculate main scatter dimensions |
42 | 52 | scatter_width = total_width - margin_plot_size - gap * 3 |
43 | 53 | scatter_height = total_height - margin_plot_size - title_height - gap * 3 |
44 | 54 |
|
45 | | -# Shared margins for alignment |
46 | 55 | left_margin = 100 |
47 | 56 | bottom_margin = 80 |
48 | 57 | top_margin = 20 |
49 | 58 | right_margin = 20 |
50 | 59 |
|
51 | | -# Custom style for main scatter |
| 60 | +# Custom style for main scatter - theme-adaptive |
52 | 61 | 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 |
59 | 68 | title_font_size=48, |
60 | 69 | label_font_size=36, |
61 | 70 | major_label_font_size=32, |
62 | 71 | legend_font_size=32, |
63 | | - tooltip_font_size=24, |
64 | 72 | opacity=0.65, |
65 | 73 | opacity_hover=0.9, |
66 | | - guide_stroke_color="#e0e0e0", |
67 | | - major_guide_stroke_color="#cccccc", |
68 | 74 | ) |
69 | 75 |
|
70 | | -# Custom style for marginal histograms - subtle color to not distract from main scatter |
| 76 | +# Custom style for marginal histograms - theme-adaptive, subtle color |
71 | 77 | 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 |
78 | 84 | title_font_size=32, |
79 | 85 | label_font_size=32, |
80 | 86 | major_label_font_size=30, |
81 | 87 | legend_font_size=28, |
82 | | - opacity=0.6, # More transparency for subtle appearance |
83 | | - guide_stroke_color="#e0e0e0", |
| 88 | + opacity=0.6, |
84 | 89 | ) |
85 | 90 |
|
86 | | -# Create main scatter plot with explicit axis ranges matching marginal histograms |
| 91 | +# Create main scatter plot |
87 | 92 | scatter = pygal.XY( |
88 | 93 | width=scatter_width, |
89 | 94 | height=scatter_height, |
|
102 | 107 | margin_right=right_margin, |
103 | 108 | margin_bottom=bottom_margin, |
104 | 109 | 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), |
107 | 112 | ) |
108 | 113 |
|
109 | | -# Add scatter data |
110 | 114 | scatter_points = [(float(xi), float(yi)) for xi, yi in zip(x, y, strict=True)] |
111 | 115 | scatter.add("Data", scatter_points) |
112 | 116 |
|
113 | 117 | # Create top marginal histogram (X distribution) |
114 | | -# Use bar chart with x_labels matching scatter X range bins |
115 | 118 | x_margin = pygal.Bar( |
116 | 119 | width=scatter_width, |
117 | 120 | height=margin_plot_size, |
118 | 121 | style=marginal_style, |
119 | 122 | show_legend=False, |
120 | | - show_x_labels=False, # Hide to avoid clutter |
| 123 | + show_x_labels=False, |
121 | 124 | show_y_labels=True, |
122 | 125 | show_y_guides=True, |
123 | 126 | show_x_guides=False, |
|
126 | 129 | margin_bottom=20, |
127 | 130 | margin_left=left_margin, |
128 | 131 | explicit_size=True, |
129 | | - spacing=2, # Tighter spacing for better visual continuity |
| 132 | + spacing=2, |
130 | 133 | ) |
131 | 134 | x_margin.add("X Distribution", [float(h) for h in x_hist]) |
132 | 135 |
|
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) |
135 | 137 | y_margin = pygal.HorizontalBar( |
136 | 138 | width=margin_plot_size, |
137 | 139 | height=scatter_height, |
|
143 | 145 | show_x_guides=False, |
144 | 146 | margin_top=top_margin, |
145 | 147 | margin_right=30, |
146 | | - margin_bottom=bottom_margin, # Match scatter bottom margin |
| 148 | + margin_bottom=bottom_margin, |
147 | 149 | margin_left=10, |
148 | 150 | explicit_size=True, |
149 | | - spacing=2, # Tighter spacing matching X marginal |
| 151 | + spacing=2, |
150 | 152 | ) |
151 | | -# Reverse order to match scatter Y axis orientation (pygal HorizontalBar goes top-to-bottom) |
152 | 153 | y_margin.add("Y Distribution", [float(h) for h in y_hist[::-1]]) |
153 | 154 |
|
154 | 155 | # Render each chart to PNG in memory |
|
161 | 162 | x_margin_img = Image.open(io.BytesIO(x_margin_png)) |
162 | 163 | y_margin_img = Image.open(io.BytesIO(y_margin_png)) |
163 | 164 |
|
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) |
166 | 167 |
|
167 | | -# Calculate positions - aligned for better visual coherence |
| 168 | +# Calculate positions |
168 | 169 | scatter_x = gap |
169 | 170 | scatter_y = title_height + margin_plot_size + gap |
170 | 171 | x_margin_x = gap |
|
179 | 180 |
|
180 | 181 | # Add title and corner annotation |
181 | 182 | draw = ImageDraw.Draw(final_img) |
182 | | -title_text = "scatter-marginal · pygal · pyplots.ai" |
| 183 | +title_text = "scatter-marginal · pygal · anyplot.ai" |
183 | 184 | try: |
184 | 185 | title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 60) |
185 | 186 | stats_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36) |
|
194 | 195 | text_width = bbox[2] - bbox[0] |
195 | 196 | text_x = (total_width - text_width) // 2 |
196 | 197 | 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) |
198 | 199 |
|
199 | 200 | # 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 |
203 | 203 | corner_width = margin_plot_size - 60 |
204 | 204 | corner_height = margin_plot_size - 80 |
205 | 205 |
|
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 | + |
207 | 210 | 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) |
209 | 212 |
|
210 | | -# Add statistics text - centered in box |
| 213 | +# Add statistics text |
211 | 214 | 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) |
213 | 216 |
|
214 | 217 | stats_lines = [f"n = {n_points}", f"r = {correlation:.3f}", f"A̅ = {np.mean(x):.1f}", f"B̅ = {np.mean(y):.1f}"] |
215 | 218 | line_y = corner_y + 85 |
216 | 219 | 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) |
218 | 221 | line_y += 50 |
219 | 222 |
|
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") |
222 | 225 |
|
223 | 226 | # Also save the scatter SVG as HTML for interactivity |
224 | 227 | 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: |
226 | 229 | f.write(scatter_svg_full) |
0 commit comments