|
| 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