Skip to content

Commit 874efac

Browse files
Merge remote-tracking branch 'origin/main'
2 parents 3a8093e + f9fae61 commit 874efac

6 files changed

Lines changed: 571 additions & 89 deletions

File tree

.github/ISSUE_TEMPLATE/report-impl-issue.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ body:
4242
label: Category
4343
description: What type of issue is this?
4444
options:
45+
- Please select...
4546
- Visual (design, clarity, hard to read)
4647
- Data (unrealistic values, inappropriate context)
4748
- Functional (doesn't work as expected)

.github/ISSUE_TEMPLATE/report-spec-issue.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ body:
2626
label: Category
2727
description: What type of issue is this?
2828
options:
29+
- Please select...
2930
- Visual (design, clarity, hard to read)
3031
- Data (unrealistic values, inappropriate context)
3132
- Functional (doesn't work as expected)
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
""" pyplots.ai
2+
linked-views-selection: Multiple Linked Views with Selection Sync
3+
Library: plotnine 0.15.2 | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-08
5+
"""
6+
7+
import os
8+
import tempfile
9+
10+
import numpy as np
11+
import pandas as pd
12+
from PIL import Image, ImageDraw, ImageFont
13+
from plotnine import (
14+
aes,
15+
element_blank,
16+
element_line,
17+
element_rect,
18+
element_text,
19+
geom_bar,
20+
geom_histogram,
21+
geom_point,
22+
geom_vline,
23+
ggplot,
24+
labs,
25+
position_dodge,
26+
scale_alpha_identity,
27+
scale_color_manual,
28+
scale_fill_manual,
29+
theme,
30+
theme_minimal,
31+
)
32+
33+
34+
# Data - Multivariate dataset with 3 clusters
35+
np.random.seed(42)
36+
n_per_cluster = 50
37+
38+
categories = np.repeat(["Cluster A", "Cluster B", "Cluster C"], n_per_cluster)
39+
40+
x = np.concatenate(
41+
[
42+
np.random.normal(2.5, 0.6, n_per_cluster),
43+
np.random.normal(5.5, 0.7, n_per_cluster),
44+
np.random.normal(4.0, 0.8, n_per_cluster),
45+
]
46+
)
47+
48+
y = np.concatenate(
49+
[
50+
np.random.normal(3.0, 0.5, n_per_cluster),
51+
np.random.normal(5.5, 0.6, n_per_cluster),
52+
np.random.normal(2.0, 0.7, n_per_cluster),
53+
]
54+
)
55+
56+
value = np.concatenate(
57+
[
58+
np.random.normal(30, 6, n_per_cluster),
59+
np.random.normal(55, 8, n_per_cluster),
60+
np.random.normal(42, 7, n_per_cluster),
61+
]
62+
)
63+
64+
# Selection: x > 4.5 (selects Cluster B and part of Cluster C)
65+
selection_threshold = 4.5
66+
selected = x > selection_threshold
67+
68+
# Create main dataframe
69+
df = pd.DataFrame(
70+
{
71+
"x": x,
72+
"y": y,
73+
"category": categories,
74+
"value": value,
75+
"selected": selected,
76+
"Selection": np.where(selected, "Selected", "Unselected"),
77+
"point_alpha": np.where(selected, 0.9, 0.35),
78+
}
79+
)
80+
81+
n_selected = int(selected.sum())
82+
n_total = len(df)
83+
84+
# Colors - Python Blue/Yellow colorblind-safe
85+
color_selected = "#306998"
86+
color_unselected = "#AAAAAA"
87+
color_threshold = "#FFD43B"
88+
colors = {"Selected": color_selected, "Unselected": color_unselected}
89+
90+
# Common theme for all plots with larger sizes for visibility
91+
base_theme = theme_minimal() + theme(
92+
figure_size=(8, 7),
93+
text=element_text(size=16),
94+
axis_title=element_text(size=22),
95+
axis_text=element_text(size=16),
96+
plot_title=element_text(size=24, weight="bold"),
97+
legend_position="none",
98+
panel_grid_major=element_line(color="#E0E0E0", size=0.4),
99+
panel_grid_minor=element_blank(),
100+
plot_background=element_rect(fill="white", color="white"),
101+
panel_background=element_rect(fill="white"),
102+
)
103+
104+
# View 1: Scatter Plot (x vs y) - Selection source
105+
p1 = (
106+
ggplot(df, aes("x", "y", color="Selection", alpha="point_alpha"))
107+
+ geom_point(size=5)
108+
+ geom_vline(xintercept=selection_threshold, linetype="dashed", color=color_threshold, size=2)
109+
+ scale_color_manual(values=colors)
110+
+ scale_alpha_identity()
111+
+ labs(title="Scatter Plot (X vs Y)", x="X Value", y="Y Value")
112+
+ base_theme
113+
)
114+
115+
# View 2: Histogram of Value distribution
116+
p2 = (
117+
ggplot(df, aes("value", fill="Selection"))
118+
+ geom_histogram(bins=15, position="identity", color="white", size=0.4, alpha=0.75)
119+
+ scale_fill_manual(values=colors)
120+
+ labs(title="Value Distribution", x="Value", y="Count")
121+
+ base_theme
122+
)
123+
124+
# View 3: Bar chart by category showing selection breakdown
125+
bar_data = df.groupby(["category", "Selection"]).size().reset_index(name="count")
126+
127+
p3 = (
128+
ggplot(bar_data, aes("category", "count", fill="Selection"))
129+
+ geom_bar(stat="identity", position=position_dodge(width=0.8), width=0.7, color="white", size=0.6)
130+
+ scale_fill_manual(values=colors)
131+
+ labs(title="Category Breakdown", x="Category", y="Count")
132+
+ base_theme
133+
+ theme(axis_text_x=element_text(size=16))
134+
)
135+
136+
# Save individual plots to temp files
137+
temp_dir = tempfile.mkdtemp()
138+
p1.save(os.path.join(temp_dir, "p1.png"), dpi=200, verbose=False)
139+
p2.save(os.path.join(temp_dir, "p2.png"), dpi=200, verbose=False)
140+
p3.save(os.path.join(temp_dir, "p3.png"), dpi=200, verbose=False)
141+
142+
# Load images
143+
img1 = Image.open(os.path.join(temp_dir, "p1.png"))
144+
img2 = Image.open(os.path.join(temp_dir, "p2.png"))
145+
img3 = Image.open(os.path.join(temp_dir, "p3.png"))
146+
147+
# Target size: 4800 x 2700 (16:9)
148+
final_width = 4800
149+
final_height = 2700
150+
151+
# Create final canvas
152+
final = Image.new("RGB", (final_width, final_height), "white")
153+
154+
# Layout: top row has 2 plots side by side, bottom row has 1 centered plot
155+
# Calculate panel sizes to fill canvas well
156+
top_panel_width = 2200
157+
top_panel_height = 1250
158+
bottom_panel_width = 2800
159+
bottom_panel_height = 1100
160+
161+
# Resize panels maintaining aspect ratio by using LANCZOS
162+
img1_resized = img1.resize((top_panel_width, top_panel_height), Image.Resampling.LANCZOS)
163+
img2_resized = img2.resize((top_panel_width, top_panel_height), Image.Resampling.LANCZOS)
164+
img3_resized = img3.resize((bottom_panel_width, bottom_panel_height), Image.Resampling.LANCZOS)
165+
166+
# Position panels
167+
title_space = 180
168+
panel_gap = 50
169+
170+
# Top row: two panels side by side
171+
left_margin = (final_width - 2 * top_panel_width - panel_gap) // 2
172+
final.paste(img1_resized, (left_margin, title_space))
173+
final.paste(img2_resized, (left_margin + top_panel_width + panel_gap, title_space))
174+
175+
# Bottom row: one centered panel
176+
bottom_left = (final_width - bottom_panel_width) // 2
177+
final.paste(img3_resized, (bottom_left, title_space + top_panel_height + panel_gap))
178+
179+
# Add title and subtitle using PIL
180+
draw = ImageDraw.Draw(final)
181+
182+
# Load fonts (with fallback)
183+
try:
184+
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 64)
185+
subtitle_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 40)
186+
legend_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 36)
187+
except OSError:
188+
title_font = ImageFont.load_default()
189+
subtitle_font = title_font
190+
legend_font = title_font
191+
192+
# Draw main title
193+
title = "linked-views-selection · plotnine · pyplots.ai"
194+
title_bbox = draw.textbbox((0, 0), title, font=title_font)
195+
title_width = title_bbox[2] - title_bbox[0]
196+
draw.text(((final_width - title_width) // 2, 30), title, fill="#333333", font=title_font)
197+
198+
# Draw subtitle with selection info
199+
subtitle = f"Selection: x > {selection_threshold} highlights {n_selected}/{n_total} points across all views"
200+
subtitle_bbox = draw.textbbox((0, 0), subtitle, font=subtitle_font)
201+
subtitle_width = subtitle_bbox[2] - subtitle_bbox[0]
202+
draw.text(((final_width - subtitle_width) // 2, 105), subtitle, fill="#666666", font=subtitle_font)
203+
204+
# Add legend at bottom center
205+
legend_y = final_height - 70
206+
legend_box_size = 35
207+
208+
# Calculate centered legend position
209+
legend_text_selected = "Selected"
210+
legend_text_unselected = "Unselected"
211+
selected_bbox = draw.textbbox((0, 0), legend_text_selected, font=legend_font)
212+
unselected_bbox = draw.textbbox((0, 0), legend_text_unselected, font=legend_font)
213+
total_legend_width = (
214+
legend_box_size
215+
+ 10
216+
+ (selected_bbox[2] - selected_bbox[0])
217+
+ 80
218+
+ legend_box_size
219+
+ 10
220+
+ (unselected_bbox[2] - unselected_bbox[0])
221+
)
222+
legend_x = (final_width - total_legend_width) // 2
223+
224+
# Selected legend item
225+
draw.rectangle([legend_x, legend_y, legend_x + legend_box_size, legend_y + legend_box_size], fill=color_selected)
226+
draw.text((legend_x + legend_box_size + 10, legend_y - 2), legend_text_selected, fill="#333333", font=legend_font)
227+
228+
# Unselected legend item
229+
unselected_x = legend_x + legend_box_size + 10 + (selected_bbox[2] - selected_bbox[0]) + 80
230+
draw.rectangle(
231+
[unselected_x, legend_y, unselected_x + legend_box_size, legend_y + legend_box_size], fill=color_unselected
232+
)
233+
draw.text((unselected_x + legend_box_size + 10, legend_y - 2), legend_text_unselected, fill="#333333", font=legend_font)
234+
235+
# Save final composite image
236+
final.save("plot.png")
237+
238+
# Cleanup temp files
239+
for fname in ["p1.png", "p2.png", "p3.png"]:
240+
try:
241+
os.remove(os.path.join(temp_dir, fname))
242+
except OSError:
243+
pass
244+
try:
245+
os.rmdir(temp_dir)
246+
except OSError:
247+
pass

0 commit comments

Comments
 (0)