Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 59 additions & 56 deletions plots/scatter-marginal/implementations/python/pygal.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
""" pyplots.ai
""" anyplot.ai
scatter-marginal: Scatter Plot with Marginal Distributions
Library: pygal 3.1.0 | Python 3.13.11
Quality: 88/100 | Created: 2025-12-26
Library: pygal 3.1.0 | Python 3.13.13
Quality: 90/100 | Updated: 2026-05-09
"""

import io
import os

import numpy as np
import pygal
from PIL import Image, ImageDraw, ImageFont
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-adaptive colors from default-style-guide.md
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette
BRAND = "#009E73" # First categorical series
SECONDARY = "#D55E00" # For marginals if colored

# Data - correlated bivariate data with realistic measurement context
np.random.seed(42)
n_points = 150
Expand All @@ -22,68 +35,60 @@
correlation = np.corrcoef(x, y)[0, 1]

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

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

# Dimensions for layout - optimized spacing
# Dimensions for layout
total_width = 4800
total_height = 2700
margin_plot_size = 450 # Slightly smaller marginals
margin_plot_size = 450
title_height = 100
gap = 15
corner_size = margin_plot_size # Size for corner annotation

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

# Shared margins for alignment
left_margin = 100
bottom_margin = 80
top_margin = 20
right_margin = 20

# Custom style for main scatter
# Custom style for main scatter - theme-adaptive
scatter_style = Style(
background="#ffffff",
plot_background="#fafafa",
foreground="#333333",
foreground_strong="#333333",
foreground_subtle="#666666",
colors=("#306998",),
background=PAGE_BG,
plot_background=PAGE_BG,
foreground=INK,
foreground_strong=INK,
foreground_subtle=INK_MUTED,
colors=(BRAND,), # Okabe-Ito first series
title_font_size=48,
label_font_size=36,
major_label_font_size=32,
legend_font_size=32,
tooltip_font_size=24,
opacity=0.65,
opacity_hover=0.9,
guide_stroke_color="#e0e0e0",
major_guide_stroke_color="#cccccc",
)

# Custom style for marginal histograms - subtle color to not distract from main scatter
# Custom style for marginal histograms - theme-adaptive, subtle color
marginal_style = Style(
background="#ffffff",
plot_background="#f8f8f8",
foreground="#333333",
foreground_strong="#333333",
foreground_subtle="#666666",
colors=("#a8c5db",), # Subtle, lighter blue for marginals
background=PAGE_BG,
plot_background=PAGE_BG,
foreground=INK,
foreground_strong=INK,
foreground_subtle=INK_MUTED,
colors=(INK_SOFT,), # Subtle gray for marginals
title_font_size=32,
label_font_size=32,
major_label_font_size=30,
legend_font_size=28,
opacity=0.6, # More transparency for subtle appearance
guide_stroke_color="#e0e0e0",
opacity=0.6,
)

# Create main scatter plot with explicit axis ranges matching marginal histograms
# Create main scatter plot
scatter = pygal.XY(
width=scatter_width,
height=scatter_height,
Expand All @@ -102,22 +107,20 @@
margin_right=right_margin,
margin_bottom=bottom_margin,
margin_left=left_margin,
range=(y_min - 5, y_max + 5), # Y range with slight padding
xrange=(x_min - 5, x_max + 5), # X range with slight padding
range=(y_min - 5, y_max + 5),
xrange=(x_min - 5, x_max + 5),
)

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

# Create top marginal histogram (X distribution)
# Use bar chart with x_labels matching scatter X range bins
x_margin = pygal.Bar(
width=scatter_width,
height=margin_plot_size,
style=marginal_style,
show_legend=False,
show_x_labels=False, # Hide to avoid clutter
show_x_labels=False,
show_y_labels=True,
show_y_guides=True,
show_x_guides=False,
Expand All @@ -126,12 +129,11 @@
margin_bottom=20,
margin_left=left_margin,
explicit_size=True,
spacing=2, # Tighter spacing for better visual continuity
spacing=2,
)
x_margin.add("X Distribution", [float(h) for h in x_hist])

# Create right marginal histogram (Y distribution) - horizontal bars
# Use same margin settings as scatter for better alignment
# Create right marginal histogram (Y distribution)
y_margin = pygal.HorizontalBar(
width=margin_plot_size,
height=scatter_height,
Expand All @@ -143,12 +145,11 @@
show_x_guides=False,
margin_top=top_margin,
margin_right=30,
margin_bottom=bottom_margin, # Match scatter bottom margin
margin_bottom=bottom_margin,
margin_left=10,
explicit_size=True,
spacing=2, # Tighter spacing matching X marginal
spacing=2,
)
# Reverse order to match scatter Y axis orientation (pygal HorizontalBar goes top-to-bottom)
y_margin.add("Y Distribution", [float(h) for h in y_hist[::-1]])

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

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

# Calculate positions - aligned for better visual coherence
# Calculate positions
scatter_x = gap
scatter_y = title_height + margin_plot_size + gap
x_margin_x = gap
Expand All @@ -179,7 +180,7 @@

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

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

# Draw subtle background for stats box
# Draw subtle background for stats box with theme-adaptive colors
elevated_bg = "#FFFDF6" if THEME == "light" else "#242420"
box_border = INK_MUTED

stats_box = [(corner_x, corner_y), (corner_x + corner_width, corner_y + corner_height)]
draw.rounded_rectangle(stats_box, radius=15, fill="#f5f5f5", outline="#c0c0c0", width=2)
draw.rounded_rectangle(stats_box, radius=15, fill=elevated_bg, outline=box_border, width=2)

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

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

# Save final image
final_img.save("plot.png", "PNG")
# Save final image and HTML
final_img.save(f"plot-{THEME}.png", "PNG")

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