|
1 | | -""" pyplots.ai |
| 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: /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"]) |
31 | | - |
32 | | -# Circle packing - place circles one by one without overlap (inline, no functions) |
33 | | -placed_circles = [] |
34 | | -cx, cy = 0, 0 |
35 | | - |
36 | | -for circle in all_circles: |
37 | | - new_radius = circle["radius"] |
38 | | - |
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())} |
111 | | - |
112 | | -# Create figure |
| 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 one by one, 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 so all coordinates are positive |
| 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 |
| 72 | +sns.set_theme(style="white", context="talk", font_scale=1.2) |
| 73 | +palette = sns.color_palette("Set2", n_colors=len(sectors)) |
| 74 | +sector_colors = dict(zip(sectors.keys(), palette, strict=True)) |
| 75 | + |
| 76 | +# Plot |
113 | 77 | fig, ax = plt.subplots(figsize=(16, 9)) |
| 78 | +ax.set_xlim(0, plot_w) |
| 79 | +ax.set_ylim(0, plot_h) |
| 80 | +ax.set_aspect("equal") |
114 | 81 |
|
115 | | -# Use seaborn scatterplot for the bubbles |
| 82 | +# Compute scatter marker sizes: convert data-unit diameter to points^2 |
| 83 | +# At dpi=100 (default), figsize=(16,9) => 1600x900 pixels for the figure |
| 84 | +# With aspect="equal", the effective data range that fits is limited by the smaller axis |
| 85 | +fig.canvas.draw() |
| 86 | +transform = ax.transData |
| 87 | +# Get scale: how many display points per data unit |
| 88 | +p0 = transform.transform((0, 0)) |
| 89 | +p1 = transform.transform((1, 0)) |
| 90 | +pts_per_unit = (p1[0] - p0[0]) * 72 / fig.dpi # convert pixels to points |
| 91 | +df["marker_size"] = (df["radius"] * 2 * pts_per_unit) ** 2 |
| 92 | + |
| 93 | +# Draw bubbles with seaborn scatterplot |
116 | 94 | sns.scatterplot( |
117 | 95 | data=df, |
118 | 96 | x="x", |
119 | 97 | y="y", |
120 | | - hue="group", |
| 98 | + hue="sector", |
121 | 99 | size="marker_size", |
122 | 100 | sizes=(df["marker_size"].min(), df["marker_size"].max()), |
123 | | - palette="Set2", |
| 101 | + palette=sector_colors, |
124 | 102 | alpha=0.9, |
125 | 103 | edgecolor="white", |
126 | 104 | linewidth=3, |
127 | 105 | legend=False, |
128 | 106 | ax=ax, |
129 | 107 | ) |
130 | 108 |
|
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 |
135 | | - |
136 | | -for _, row in df_sorted.iterrows(): |
137 | | - 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") |
| 109 | +# Labels inside circles - scale font with bubble size, clip long names |
| 110 | +for _, row in df.iterrows(): |
| 111 | + label = row["name"] |
| 112 | + r = row["radius"] |
| 113 | + if r > 38: |
| 114 | + fs, max_chars = 18, 12 |
| 115 | + elif r > 30: |
| 116 | + fs, max_chars = 14, 12 |
| 117 | + elif r > 24: |
| 118 | + fs, max_chars = 11, 10 |
150 | 119 | 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( |
170 | | - name, |
171 | | - xy=(row["x"], row["y"]), |
172 | | - xytext=(row["x"] + offset_x, target_y), |
173 | | - fontsize=11, |
174 | | - fontweight="bold", |
175 | | - color="#444444", |
176 | | - arrowprops={"arrowstyle": "->", "color": "#888888", "lw": 1.5, "connectionstyle": "arc3,rad=0.1"}, |
177 | | - ha=ha, |
178 | | - va="center", |
179 | | - ) |
180 | | - |
181 | | -# Configure axes |
182 | | -ax.set_xlim(0, plot_width) |
183 | | -ax.set_ylim(0, plot_height) |
184 | | -ax.set_aspect("equal") |
| 120 | + fs, max_chars = 8, 7 |
| 121 | + if len(label) > max_chars: |
| 122 | + label = label[: max_chars - 1] + "." |
| 123 | + ax.text(row["x"], row["y"], label, ha="center", va="center", fontsize=fs, fontweight="bold", color="white") |
| 124 | + |
185 | 125 | ax.axis("off") |
186 | 126 |
|
187 | 127 | # Title |
188 | | -ax.set_title("bubble-packed · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20) |
| 128 | +ax.set_title( |
| 129 | + "Market Capitalization by Sector\nbubble-packed \u00b7 seaborn \u00b7 pyplots.ai", |
| 130 | + fontsize=24, |
| 131 | + fontweight="medium", |
| 132 | + pad=20, |
| 133 | + linespacing=1.4, |
| 134 | +) |
189 | 135 |
|
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() |
| 136 | +# Legend with circular markers matching bubble colors |
| 137 | +handles = [ |
| 138 | + plt.Line2D([0], [0], marker="o", color="w", markerfacecolor=sector_colors[s], markersize=14, label=s) |
| 139 | + for s in sectors |
193 | 140 | ] |
194 | 141 | ax.legend( |
195 | | - handles=legend_elements, |
| 142 | + handles=handles, |
196 | 143 | loc="upper center", |
197 | | - bbox_to_anchor=(0.5, -0.02), |
| 144 | + bbox_to_anchor=(0.5, -0.01), |
198 | 145 | ncol=4, |
199 | 146 | fontsize=14, |
200 | 147 | framealpha=0.95, |
|
203 | 150 | edgecolor="gray", |
204 | 151 | ) |
205 | 152 |
|
| 153 | +sns.despine(left=True, bottom=True) |
206 | 154 | plt.tight_layout() |
207 | 155 | plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
0 commit comments