|
1 | 1 | """ pyplots.ai |
2 | 2 | bubble-packed: Basic Packed Bubble Chart |
3 | | -Library: plotly 6.5.0 | Python 3.13.11 |
4 | | -Quality: 93/100 | Created: 2025-12-23 |
| 3 | +Library: plotly 6.5.2 | Python 3.14.3 |
| 4 | +Quality: 91/100 | Updated: 2026-02-23 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import numpy as np |
8 | 8 | import plotly.graph_objects as go |
9 | 9 |
|
10 | 10 |
|
11 | | -# Data - department budget allocation |
12 | | -np.random.seed(42) |
13 | | -data = { |
14 | | - "Marketing": 2800000, |
15 | | - "Engineering": 4500000, |
16 | | - "Sales": 3200000, |
17 | | - "Operations": 1800000, |
18 | | - "HR": 950000, |
19 | | - "Finance": 1200000, |
20 | | - "R&D": 3800000, |
21 | | - "Support": 1100000, |
22 | | - "Legal": 650000, |
23 | | - "IT": 2100000, |
24 | | - "Product": 1500000, |
25 | | - "QA": 880000, |
26 | | - "Data Science": 1650000, |
27 | | - "Design": 720000, |
28 | | - "Admin": 450000, |
29 | | -} |
30 | | - |
31 | | -labels = list(data.keys()) |
32 | | -values = list(data.values()) |
33 | | - |
34 | | -# Circle packing simulation using force-directed approach |
| 11 | +# Data — department budgets with functional groupings |
| 12 | +departments = [ |
| 13 | + ("Engineering", 4500000, "Technology"), |
| 14 | + ("R&D", 3800000, "Technology"), |
| 15 | + ("IT", 2100000, "Technology"), |
| 16 | + ("Data Science", 1650000, "Technology"), |
| 17 | + ("QA", 880000, "Technology"), |
| 18 | + ("Sales", 3200000, "Revenue"), |
| 19 | + ("Marketing", 2800000, "Revenue"), |
| 20 | + ("Operations", 1800000, "Operations"), |
| 21 | + ("Finance", 1200000, "Operations"), |
| 22 | + ("Support", 1100000, "Operations"), |
| 23 | + ("Admin", 450000, "Operations"), |
| 24 | + ("HR", 950000, "Corporate"), |
| 25 | + ("Legal", 650000, "Corporate"), |
| 26 | + ("Product", 1500000, "Corporate"), |
| 27 | + ("Design", 720000, "Corporate"), |
| 28 | +] |
| 29 | + |
| 30 | +labels = [d[0] for d in departments] |
| 31 | +values = np.array([d[1] for d in departments]) |
| 32 | +groups = [d[2] for d in departments] |
35 | 33 | n = len(labels) |
| 34 | + |
| 35 | +# Group colors — colorblind-safe palette starting with Python Blue |
| 36 | +group_colors = {"Technology": "#306998", "Revenue": "#E69F00", "Operations": "#009E73", "Corporate": "#CC79A7"} |
| 37 | + |
36 | 38 | # Scale radii by area (sqrt) for accurate visual perception |
37 | | -radii_scale = np.sqrt(np.array(values)) / np.sqrt(max(values)) * 100 |
| 39 | +radii = np.sqrt(values / values.max()) * 110 |
38 | 40 |
|
39 | | -# Initial positions - spread in a circle |
| 41 | +# Circle packing via force simulation |
| 42 | +np.random.seed(42) |
40 | 43 | angles = np.linspace(0, 2 * np.pi, n, endpoint=False) |
41 | | -x_pos = np.cos(angles) * 200 + np.random.randn(n) * 50 |
42 | | -y_pos = np.sin(angles) * 200 + np.random.randn(n) * 50 |
| 44 | +x_pos = np.cos(angles) * 150 + np.random.randn(n) * 30 |
| 45 | +y_pos = np.sin(angles) * 150 + np.random.randn(n) * 30 |
43 | 46 |
|
44 | | -# Force simulation for circle packing |
45 | | -for _ in range(500): |
| 47 | +for _ in range(600): |
46 | 48 | for i in range(n): |
47 | | - fx, fy = 0, 0 |
48 | | - # Centering force |
49 | | - fx -= x_pos[i] * 0.01 |
50 | | - fy -= y_pos[i] * 0.01 |
51 | | - # Repulsion between circles |
| 49 | + fx, fy = -x_pos[i] * 0.01, -y_pos[i] * 0.01 |
52 | 50 | for j in range(n): |
53 | 51 | if i != j: |
54 | 52 | dx = x_pos[i] - x_pos[j] |
55 | 53 | dy = y_pos[i] - y_pos[j] |
56 | 54 | dist = np.sqrt(dx**2 + dy**2) + 0.1 |
57 | | - min_dist = radii_scale[i] + radii_scale[j] + 5 |
| 55 | + min_dist = radii[i] + radii[j] + 4 |
58 | 56 | if dist < min_dist: |
59 | 57 | force = (min_dist - dist) * 0.3 |
60 | 58 | fx += (dx / dist) * force |
61 | 59 | fy += (dy / dist) * force |
62 | 60 | x_pos[i] += fx |
63 | 61 | y_pos[i] += fy |
64 | 62 |
|
65 | | -# Color palette - Python colors first, then colorblind-safe |
66 | | -colors = [ |
67 | | - "#306998", # Python Blue |
68 | | - "#FFD43B", # Python Yellow |
69 | | - "#4E79A7", |
70 | | - "#F28E2B", |
71 | | - "#E15759", |
72 | | - "#76B7B2", |
73 | | - "#59A14F", |
74 | | - "#EDC948", |
75 | | - "#B07AA1", |
76 | | - "#FF9DA7", |
77 | | - "#9C755F", |
78 | | - "#BAB0AC", |
79 | | - "#5778A4", |
80 | | - "#E49444", |
81 | | - "#85B6B2", |
82 | | -] |
| 63 | +# Weight-based centering for better visual balance (larger bubbles pull center) |
| 64 | +area_weights = radii**2 |
| 65 | +x_pos -= np.average(x_pos, weights=area_weights) |
| 66 | +y_pos -= np.average(y_pos, weights=area_weights) |
83 | 67 |
|
84 | | -# Format values for display (inline) |
85 | | -formatted_values = [f"${v / 1000000:.1f}M" if v >= 1000000 else f"${v / 1000:.0f}K" for v in values] |
| 68 | +# Format values for display |
| 69 | +formatted = [f"${v / 1e6:.1f}M" if v >= 1e6 else f"${v / 1e3:.0f}K" for v in values] |
| 70 | +shares = [f"{v / values.sum() * 100:.1f}" for v in values] |
| 71 | +total = f"${values.sum() / 1e6:.1f}M" |
86 | 72 |
|
87 | | -# Create bubble chart |
| 73 | +# Tight axis ranges for better canvas utilization |
| 74 | +pad = 15 |
| 75 | +x_lo = (x_pos - radii).min() - pad |
| 76 | +x_hi = (x_pos + radii).max() + pad |
| 77 | +y_lo = (y_pos - radii).min() - pad |
| 78 | +y_hi = (y_pos + radii).max() + pad |
| 79 | + |
| 80 | +# Convert data-coordinate radii to pixel marker diameters |
| 81 | +fig_w, fig_h = 1600, 900 |
| 82 | +m_l, m_r, m_t, m_b = 35, 35, 85, 85 |
| 83 | +plot_w, plot_h = fig_w - m_l - m_r, fig_h - m_t - m_b |
| 84 | +px_per_unit = min(plot_w / (x_hi - x_lo), plot_h / (y_hi - y_lo)) |
| 85 | +marker_diameters = 2 * radii * px_per_unit |
| 86 | + |
| 87 | +# Text colors for contrast against group backgrounds |
| 88 | +text_colors = [] |
| 89 | +for g in groups: |
| 90 | + c = group_colors[g] |
| 91 | + lum = 0.299 * int(c[1:3], 16) + 0.587 * int(c[3:5], 16) + 0.114 * int(c[5:7], 16) |
| 92 | + text_colors.append("white" if lum < 160 else "#333") |
| 93 | + |
| 94 | +# Build figure — one trace per group for idiomatic Plotly legend |
88 | 95 | fig = go.Figure() |
89 | 96 |
|
90 | | -# Add markers |
91 | | -fig.add_trace( |
92 | | - go.Scatter( |
93 | | - x=x_pos, |
94 | | - y=y_pos, |
95 | | - mode="markers", |
96 | | - marker=dict(size=radii_scale * 2, color=colors[:n], line=dict(color="white", width=2), opacity=0.85), |
97 | | - hovertemplate=[ |
98 | | - f"<b>{lbl}</b><br>{fval}<extra></extra>" for lbl, fval in zip(labels, formatted_values, strict=True) |
99 | | - ], |
| 97 | +for group_name, group_color in group_colors.items(): |
| 98 | + idx = [i for i in range(n) if groups[i] == group_name] |
| 99 | + fig.add_trace( |
| 100 | + go.Scatter( |
| 101 | + x=x_pos[idx], |
| 102 | + y=y_pos[idx], |
| 103 | + mode="markers", |
| 104 | + name=group_name, |
| 105 | + marker={ |
| 106 | + "size": marker_diameters[idx], |
| 107 | + "sizemode": "diameter", |
| 108 | + "color": group_color, |
| 109 | + "opacity": 0.9, |
| 110 | + "line": {"color": "white", "width": 2.5}, |
| 111 | + }, |
| 112 | + text=[labels[i] for i in idx], |
| 113 | + customdata=np.column_stack( |
| 114 | + [[formatted[i] for i in idx], [shares[i] for i in idx], [groups[i] for i in idx]] |
| 115 | + ), |
| 116 | + hovertemplate="<b>%{text}</b> (%{customdata[2]})<br>Budget: %{customdata[0]}<br>Share: %{customdata[1]}%<extra></extra>", |
| 117 | + ) |
100 | 118 | ) |
101 | | -) |
102 | 119 |
|
103 | | -# Add text annotations with size based on bubble radius |
| 120 | +# Text labels inside bubbles — minimum 14pt for readability |
104 | 121 | for i in range(n): |
105 | | - font_size = max(10, min(18, int(radii_scale[i] * 0.2))) |
| 122 | + font_size = max(14, min(20, int(radii[i] * 0.22))) |
| 123 | + label_text = f"<b>{labels[i]}</b><br>{formatted[i]}" if radii[i] > 35 else f"<b>{labels[i]}</b>" |
106 | 124 | fig.add_annotation( |
107 | 125 | x=x_pos[i], |
108 | 126 | y=y_pos[i], |
109 | | - text=f"<b>{labels[i]}</b><br>{formatted_values[i]}", |
| 127 | + text=label_text, |
110 | 128 | showarrow=False, |
111 | | - font=dict(size=font_size, color="white", family="Arial"), |
| 129 | + font={"size": font_size, "color": text_colors[i], "family": "Arial"}, |
112 | 130 | ) |
113 | 131 |
|
114 | 132 | # Layout |
115 | 133 | fig.update_layout( |
116 | | - title=dict( |
117 | | - text="Department Budget Allocation · bubble-packed · plotly · pyplots.ai", |
118 | | - font=dict(size=32, color="#333"), |
119 | | - x=0.5, |
120 | | - xanchor="center", |
121 | | - ), |
122 | | - xaxis=dict( |
123 | | - showgrid=False, zeroline=False, showticklabels=False, title="", range=[min(x_pos) - 150, max(x_pos) + 150] |
124 | | - ), |
125 | | - yaxis=dict( |
126 | | - showgrid=False, |
127 | | - zeroline=False, |
128 | | - showticklabels=False, |
129 | | - title="", |
130 | | - scaleanchor="x", |
131 | | - scaleratio=1, |
132 | | - range=[min(y_pos) - 150, max(y_pos) + 150], |
133 | | - ), |
| 134 | + title={ |
| 135 | + "text": "Department Budget Allocation · bubble-packed · plotly · pyplots.ai", |
| 136 | + "font": {"size": 32, "color": "#333"}, |
| 137 | + "x": 0.5, |
| 138 | + "xanchor": "center", |
| 139 | + }, |
| 140 | + xaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "title": "", "range": [x_lo, x_hi]}, |
| 141 | + yaxis={ |
| 142 | + "showgrid": False, |
| 143 | + "zeroline": False, |
| 144 | + "showticklabels": False, |
| 145 | + "title": "", |
| 146 | + "scaleanchor": "x", |
| 147 | + "scaleratio": 1, |
| 148 | + "range": [y_lo, y_hi], |
| 149 | + }, |
134 | 150 | template="plotly_white", |
135 | | - showlegend=False, |
136 | | - margin=dict(l=50, r=50, t=100, b=50), |
| 151 | + legend={ |
| 152 | + "font": {"size": 16, "family": "Arial"}, |
| 153 | + "orientation": "h", |
| 154 | + "yanchor": "top", |
| 155 | + "y": -0.04, |
| 156 | + "xanchor": "center", |
| 157 | + "x": 0.5, |
| 158 | + "itemsizing": "constant", |
| 159 | + }, |
| 160 | + margin={"l": m_l, "r": m_r, "t": m_t, "b": m_b}, |
137 | 161 | paper_bgcolor="white", |
138 | 162 | plot_bgcolor="white", |
139 | 163 | ) |
140 | 164 |
|
141 | | -# Save outputs |
142 | | -fig.write_image("plot.png", width=1600, height=900, scale=3) |
| 165 | +# Total budget annotation below the cluster |
| 166 | +fig.add_annotation( |
| 167 | + text=f"Total: {total}", |
| 168 | + xref="paper", |
| 169 | + yref="paper", |
| 170 | + x=0.5, |
| 171 | + y=-0.01, |
| 172 | + showarrow=False, |
| 173 | + font={"size": 18, "color": "#666", "family": "Arial"}, |
| 174 | +) |
| 175 | + |
| 176 | +# Save |
| 177 | +fig.write_image("plot.png", width=fig_w, height=fig_h, scale=3) |
143 | 178 | fig.write_html("plot.html", include_plotlyjs=True, full_html=True) |
0 commit comments