|
1 | 1 | """ pyplots.ai |
2 | 2 | bubble-packed: Basic Packed Bubble Chart |
3 | | -Library: seaborn 0.13.2 | Python 3.13.11 |
4 | | -Quality: 90/100 | Created: 2025-12-23 |
| 3 | +Library: seaborn 0.13.2 | Python 3.14.3 |
| 4 | +Quality: 90/100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
7 | | -import matplotlib.patches as mpatches |
8 | 7 | import matplotlib.pyplot as plt |
9 | 8 | import numpy as np |
10 | 9 | import pandas as pd |
11 | 10 | import seaborn as sns |
12 | 11 |
|
13 | 12 |
|
14 | 13 | # Data - Company market values by sector (billions USD) |
15 | | -np.random.seed(42) |
16 | | -data = { |
| 14 | +sectors = { |
17 | 15 | "Technology": [("Apple", 180), ("Microsoft", 160), ("Google", 120), ("NVIDIA", 95), ("Meta", 75)], |
18 | 16 | "Finance": [("JPMorgan", 85), ("Visa", 70), ("Mastercard", 55), ("Goldman Sachs", 45)], |
19 | 17 | "Healthcare": [("UnitedHealth", 90), ("J&J", 65), ("Merck", 50), ("Pfizer", 40)], |
20 | 18 | "Retail": [("Amazon", 140), ("Walmart", 60), ("Costco", 45), ("Target", 30)], |
21 | 19 | } |
22 | 20 |
|
23 | | -# Prepare circles sorted by size (largest first for better packing) |
24 | | -all_circles = [] |
25 | | -for group, items in data.items(): |
26 | | - for name, value in items: |
27 | | - radius = np.sqrt(value) * 4 # Scale by area |
28 | | - all_circles.append({"name": name, "radius": radius, "group": group, "value": value}) |
29 | | - |
30 | | -all_circles.sort(key=lambda x: -x["radius"]) |
| 21 | +records = [] |
| 22 | +for sector, companies in sectors.items(): |
| 23 | + for name, value in companies: |
| 24 | + records.append({"name": name, "value": value, "sector": sector, "radius": np.sqrt(value) * 4}) |
| 25 | + |
| 26 | +df = pd.DataFrame(records).sort_values("radius", ascending=False).reset_index(drop=True) |
| 27 | + |
| 28 | +# Circle packing - place circles greedily closest to center without overlap |
| 29 | +placed_x, placed_y, placed_r = [], [], [] |
| 30 | + |
| 31 | +for _, row in df.iterrows(): |
| 32 | + r = row["radius"] |
| 33 | + |
| 34 | + if not placed_x: |
| 35 | + placed_x.append(0.0) |
| 36 | + placed_y.append(0.0) |
| 37 | + placed_r.append(r) |
| 38 | + continue |
| 39 | + |
| 40 | + best_pos, best_dist = None, float("inf") |
| 41 | + px_arr, py_arr, pr_arr = np.array(placed_x), np.array(placed_y), np.array(placed_r) |
| 42 | + |
| 43 | + for i in range(len(placed_x)): |
| 44 | + for angle in np.linspace(0, 2 * np.pi, 72, endpoint=False): |
| 45 | + gap = placed_r[i] + r + 2 |
| 46 | + tx = placed_x[i] + gap * np.cos(angle) |
| 47 | + ty = placed_y[i] + gap * np.sin(angle) |
| 48 | + |
| 49 | + dists = np.sqrt((px_arr - tx) ** 2 + (py_arr - ty) ** 2) |
| 50 | + if np.all(dists >= pr_arr + r + 1): |
| 51 | + cdist = np.sqrt(tx**2 + ty**2) |
| 52 | + if cdist < best_dist: |
| 53 | + best_dist = cdist |
| 54 | + best_pos = (tx, ty) |
| 55 | + |
| 56 | + bx, by = best_pos if best_pos else (0.0, 0.0) |
| 57 | + placed_x.append(bx) |
| 58 | + placed_y.append(by) |
| 59 | + placed_r.append(r) |
| 60 | + |
| 61 | +df["x"] = placed_x |
| 62 | +df["y"] = placed_y |
| 63 | + |
| 64 | +# Recenter coordinates into positive space |
| 65 | +pad = 20 |
| 66 | +df["x"] = df["x"] - (df["x"] - df["radius"]).min() + pad |
| 67 | +df["y"] = df["y"] - (df["y"] - df["radius"]).min() + pad |
| 68 | +plot_w = (df["x"] + df["radius"]).max() + pad |
| 69 | +plot_h = (df["y"] + df["radius"]).max() + pad |
| 70 | + |
| 71 | +# Seaborn styling - distinctive context and style management |
| 72 | +sns.set_context("poster", font_scale=0.85) |
| 73 | +sns.set_style("white") |
31 | 74 |
|
32 | | -# Circle packing - place circles one by one without overlap (inline, no functions) |
33 | | -placed_circles = [] |
34 | | -cx, cy = 0, 0 |
| 75 | +# Custom colorblind-safe palette anchored on Python Blue (#306998) |
| 76 | +sector_order = list(sectors.keys()) |
| 77 | +base_colors = ["#306998", "#DE8F05", "#029E73", "#CC78BC"] |
| 78 | +sector_palette = dict(zip(sector_order, sns.color_palette(base_colors), strict=True)) |
35 | 79 |
|
36 | | -for circle in all_circles: |
37 | | - new_radius = circle["radius"] |
| 80 | +# Square canvas for better packing utilization (bubbles pack roughly circular) |
| 81 | +fig, ax = plt.subplots(figsize=(12, 12)) |
| 82 | +ax.set_xlim(0, plot_w) |
| 83 | +ax.set_ylim(0, plot_h) |
| 84 | +ax.set_aspect("equal") |
38 | 85 |
|
39 | | - if not placed_circles: |
40 | | - # First circle at origin |
41 | | - best_x, best_y = cx, cy |
42 | | - else: |
43 | | - # Try positions around existing circles, find closest to center |
44 | | - best_pos = None |
45 | | - best_dist = float("inf") |
46 | | - |
47 | | - for p in placed_circles: |
48 | | - for angle in np.linspace(0, 2 * np.pi, 72, endpoint=False): |
49 | | - dist = p["radius"] + new_radius + 2 |
50 | | - test_x = p["x"] + dist * np.cos(angle) |
51 | | - test_y = p["y"] + dist * np.sin(angle) |
52 | | - |
53 | | - # Check overlap with all placed circles |
54 | | - valid = True |
55 | | - for other in placed_circles: |
56 | | - d = np.sqrt((test_x - other["x"]) ** 2 + (test_y - other["y"]) ** 2) |
57 | | - if d < other["radius"] + new_radius + 1: |
58 | | - valid = False |
59 | | - break |
60 | | - |
61 | | - if valid: |
62 | | - center_dist = np.sqrt((test_x - cx) ** 2 + (test_y - cy) ** 2) |
63 | | - if center_dist < best_dist: |
64 | | - best_dist = center_dist |
65 | | - best_pos = (test_x, test_y) |
66 | | - |
67 | | - best_x, best_y = best_pos if best_pos else (cx, cy) |
68 | | - |
69 | | - placed_circles.append( |
70 | | - { |
71 | | - "x": best_x, |
72 | | - "y": best_y, |
73 | | - "radius": new_radius, |
74 | | - "name": circle["name"], |
75 | | - "group": circle["group"], |
76 | | - "value": circle["value"], |
77 | | - } |
78 | | - ) |
79 | | - |
80 | | -# Calculate bounds and recenter |
81 | | -all_x = [c["x"] for c in placed_circles] |
82 | | -all_y = [c["y"] for c in placed_circles] |
83 | | -all_r = [c["radius"] for c in placed_circles] |
84 | | - |
85 | | -min_x = min(x - r for x, r in zip(all_x, all_r, strict=True)) |
86 | | -max_x = max(x + r for x, r in zip(all_x, all_r, strict=True)) |
87 | | -min_y = min(y - r for y, r in zip(all_y, all_r, strict=True)) |
88 | | -max_y = max(y + r for y, r in zip(all_y, all_r, strict=True)) |
89 | | - |
90 | | -# Offset to center in plot area |
91 | | -padding = 20 |
92 | | -offset_x = -min_x + padding |
93 | | -offset_y = -min_y + padding |
94 | | - |
95 | | -for c in placed_circles: |
96 | | - c["x"] += offset_x |
97 | | - c["y"] += offset_y |
98 | | - |
99 | | -plot_width = max_x - min_x + 2 * padding |
100 | | -plot_height = max_y - min_y + 2 * padding |
101 | | - |
102 | | -# Create DataFrame for seaborn |
103 | | -df = pd.DataFrame(placed_circles) |
104 | | -# Scale marker size for scatterplot (s parameter uses area in points^2) |
105 | | -df["marker_size"] = (df["radius"] * 2) ** 2 * 3.14 # Convert radius to area for proper sizing |
106 | | - |
107 | | -# Set seaborn style |
108 | | -sns.set_style("white") |
109 | | -palette = sns.color_palette("Set2", n_colors=len(data)) |
110 | | -group_colors = {group: palette[i] for i, group in enumerate(data.keys())} |
| 86 | +# Convert data-unit radii to scatter marker sizes (points²) |
| 87 | +fig.canvas.draw() |
| 88 | +px_per_unit = ax.transData.transform((1, 0))[0] - ax.transData.transform((0, 0))[0] |
| 89 | +pts_per_unit = px_per_unit * 72 / fig.dpi |
| 90 | +df["marker_size"] = (df["radius"] * 2 * pts_per_unit) ** 2 |
111 | 91 |
|
112 | | -# Create figure |
113 | | -fig, ax = plt.subplots(figsize=(16, 9)) |
| 92 | +# Categorical ordering for consistent palette mapping |
| 93 | +df["sector"] = pd.Categorical(df["sector"], categories=sector_order, ordered=True) |
114 | 94 |
|
115 | | -# Use seaborn scatterplot for the bubbles |
| 95 | +# Draw bubbles with seaborn scatterplot and hue mapping |
116 | 96 | sns.scatterplot( |
117 | 97 | data=df, |
118 | 98 | x="x", |
119 | 99 | y="y", |
120 | | - hue="group", |
| 100 | + hue="sector", |
121 | 101 | size="marker_size", |
122 | 102 | sizes=(df["marker_size"].min(), df["marker_size"].max()), |
123 | | - palette="Set2", |
124 | | - alpha=0.9, |
| 103 | + hue_order=sector_order, |
| 104 | + palette=sector_palette, |
| 105 | + alpha=0.92, |
125 | 106 | edgecolor="white", |
126 | 107 | linewidth=3, |
127 | | - legend=False, |
| 108 | + legend="brief", |
128 | 109 | ax=ax, |
129 | 110 | ) |
130 | 111 |
|
131 | | -# Add labels for all circles using annotations |
132 | | -# Sort by x position to manage external annotation placement |
133 | | -df_sorted = df.sort_values("x") |
134 | | -used_y_positions = [] # Track y positions for external labels to avoid overlap |
| 112 | +# Filter legend to sector entries only, then reposition with sns.move_legend |
| 113 | +handles, labels = ax.get_legend_handles_labels() |
| 114 | +sector_h = [h for h, lab in zip(handles, labels, strict=False) if lab in sector_order] |
| 115 | +sector_lab = [lab for lab in labels if lab in sector_order] |
| 116 | +ax.legend(sector_h, sector_lab) |
| 117 | +sns.move_legend( |
| 118 | + ax, |
| 119 | + loc="upper center", |
| 120 | + bbox_to_anchor=(0.5, -0.02), |
| 121 | + ncol=4, |
| 122 | + fontsize=16, |
| 123 | + framealpha=0.95, |
| 124 | + title="Sector", |
| 125 | + title_fontsize=18, |
| 126 | + edgecolor="#CCCCCC", |
| 127 | +) |
135 | 128 |
|
136 | | -for _, row in df_sorted.iterrows(): |
| 129 | +# Labels with value annotations for data storytelling |
| 130 | +for _, row in df.iterrows(): |
| 131 | + r = row["radius"] |
137 | 132 | name = row["name"] |
138 | | - # Abbreviate long names for internal labels |
139 | | - short_name = name if len(name) <= 10 else name[:9] + "." |
140 | | - |
141 | | - if row["radius"] > 38: |
142 | | - # Large circles - full name with large font |
143 | | - ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=18, fontweight="bold", color="white") |
144 | | - elif row["radius"] > 32: |
145 | | - # Medium-large circles |
146 | | - ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=14, fontweight="bold", color="white") |
147 | | - elif row["radius"] > 26: |
148 | | - # Medium circles - smaller font |
149 | | - ax.text(row["x"], row["y"], short_name, ha="center", va="center", fontsize=11, fontweight="bold", color="white") |
| 133 | + value = row["value"] |
| 134 | + |
| 135 | + if r > 38: |
| 136 | + fs_name, max_chars, show_val = 20, 12, True |
| 137 | + elif r > 30: |
| 138 | + fs_name, max_chars, show_val = 16, 12, True |
| 139 | + elif r > 24: |
| 140 | + fs_name, max_chars, show_val = 12, 10, True |
150 | 141 | else: |
151 | | - # Small circles - external annotation with arrow |
152 | | - # Determine label position (alternate left/right based on position) |
153 | | - if row["x"] < plot_width / 2: |
154 | | - # Left side - annotate to the left |
155 | | - offset_x = -row["radius"] - 20 |
156 | | - ha = "right" |
157 | | - else: |
158 | | - # Right side - annotate to the right |
159 | | - offset_x = row["radius"] + 20 |
160 | | - ha = "left" |
161 | | - |
162 | | - # Adjust y to avoid overlapping labels |
163 | | - target_y = row["y"] |
164 | | - for used_y in used_y_positions: |
165 | | - if abs(target_y - used_y) < 25: |
166 | | - target_y = used_y + 25 if target_y >= used_y else used_y - 25 |
167 | | - used_y_positions.append(target_y) |
168 | | - |
169 | | - ax.annotate( |
| 142 | + fs_name, max_chars, show_val = 9, 8, False |
| 143 | + |
| 144 | + if len(name) > max_chars: |
| 145 | + name = name[: max_chars - 1] + "." |
| 146 | + |
| 147 | + if show_val: |
| 148 | + y_off = r * 0.13 |
| 149 | + ax.text( |
| 150 | + row["x"], |
| 151 | + row["y"] + y_off, |
170 | 152 | name, |
171 | | - xy=(row["x"], row["y"]), |
172 | | - xytext=(row["x"] + offset_x, target_y), |
173 | | - fontsize=11, |
| 153 | + ha="center", |
| 154 | + va="center", |
| 155 | + fontsize=fs_name, |
174 | 156 | fontweight="bold", |
175 | | - color="#444444", |
176 | | - arrowprops={"arrowstyle": "->", "color": "#888888", "lw": 1.5, "connectionstyle": "arc3,rad=0.1"}, |
177 | | - ha=ha, |
| 157 | + color="white", |
| 158 | + ) |
| 159 | + ax.text( |
| 160 | + row["x"], |
| 161 | + row["y"] - y_off * 2, |
| 162 | + f"${value}B", |
| 163 | + ha="center", |
178 | 164 | va="center", |
| 165 | + fontsize=fs_name - 4, |
| 166 | + color="white", |
| 167 | + alpha=0.8, |
179 | 168 | ) |
| 169 | + else: |
| 170 | + ax.text(row["x"], row["y"], name, ha="center", va="center", fontsize=fs_name, fontweight="bold", color="white") |
180 | 171 |
|
181 | | -# Configure axes |
182 | | -ax.set_xlim(0, plot_width) |
183 | | -ax.set_ylim(0, plot_height) |
184 | | -ax.set_aspect("equal") |
185 | 172 | ax.axis("off") |
186 | 173 |
|
187 | 174 | # Title |
188 | | -ax.set_title("bubble-packed · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20) |
189 | | - |
190 | | -# Create legend - position below the plot to avoid any overlap with data |
191 | | -legend_elements = [ |
192 | | - mpatches.Patch(facecolor=group_colors[group], edgecolor="white", linewidth=2, label=group) for group in data.keys() |
193 | | -] |
194 | | -ax.legend( |
195 | | - handles=legend_elements, |
196 | | - loc="upper center", |
197 | | - bbox_to_anchor=(0.5, -0.02), |
198 | | - ncol=4, |
199 | | - fontsize=14, |
200 | | - framealpha=0.95, |
201 | | - title="Sector", |
202 | | - title_fontsize=16, |
203 | | - edgecolor="gray", |
| 175 | +ax.set_title( |
| 176 | + "Market Capitalization by Sector\nbubble-packed \u00b7 seaborn \u00b7 pyplots.ai", |
| 177 | + fontsize=26, |
| 178 | + fontweight="medium", |
| 179 | + pad=25, |
| 180 | + linespacing=1.4, |
204 | 181 | ) |
205 | 182 |
|
| 183 | +sns.despine(left=True, bottom=True) |
206 | 184 | plt.tight_layout() |
207 | 185 | plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
0 commit comments