|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | network-force-directed: Force-Directed Graph |
3 | | -Library: plotly 6.5.0 | Python 3.13.11 |
4 | | -Quality: 92/100 | Created: 2025-12-23 |
| 3 | +Library: plotly 6.7.0 | Python 3.14.4 |
| 4 | +Quality: 89/100 | Updated: 2026-04-26 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import numpy as np |
8 | 10 | import plotly.graph_objects as go |
9 | 11 |
|
10 | 12 |
|
11 | | -# Set seed for reproducibility |
| 13 | +# Theme-adaptive chrome tokens |
| 14 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 15 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 16 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 17 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 18 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 19 | + |
| 20 | +# Okabe-Ito categorical palette (positions 1-3) |
| 21 | +OKABE_ITO = ["#009E73", "#D55E00", "#0072B2"] |
| 22 | + |
12 | 23 | np.random.seed(42) |
13 | 24 |
|
14 | | -# Data: A social network with 50 nodes in 3 communities |
15 | | -# Demonstrates force-directed layout with clear community structure |
| 25 | +# A social network with 50 nodes in 3 communities |
16 | 26 | nodes = [] |
17 | 27 | edges = [] |
18 | 28 |
|
19 | | -# Create 3 communities |
20 | | -community_sizes = [18, 17, 15] # Total: 50 nodes |
| 29 | +community_sizes = [18, 17, 15] |
21 | 30 | community_names = ["Engineering", "Marketing", "Sales"] |
22 | 31 | node_id = 0 |
23 | 32 |
|
|
27 | 36 | node_id += 1 |
28 | 37 |
|
29 | 38 | # Intra-community edges (dense connections within communities) |
30 | | -# Engineering: nodes 0-17 |
31 | 39 | for i in range(18): |
32 | 40 | for j in range(i + 1, 18): |
33 | 41 | if np.random.random() < 0.3: |
34 | 42 | edges.append((i, j)) |
35 | 43 |
|
36 | | -# Marketing: nodes 18-34 |
37 | 44 | for i in range(18, 35): |
38 | 45 | for j in range(i + 1, 35): |
39 | 46 | if np.random.random() < 0.3: |
40 | 47 | edges.append((i, j)) |
41 | 48 |
|
42 | | -# Sales: nodes 35-49 |
43 | 49 | for i in range(35, 50): |
44 | 50 | for j in range(i + 1, 50): |
45 | 51 | if np.random.random() < 0.3: |
46 | 52 | edges.append((i, j)) |
47 | 53 |
|
48 | | -# Inter-community edges (sparse bridges between communities) |
| 54 | +# Inter-community bridge edges |
49 | 55 | bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)] |
50 | 56 | edges.extend(bridge_edges) |
51 | 57 |
|
52 | | -# Force-directed layout algorithm (Fruchterman-Reingold) |
| 58 | +# Force-directed layout (Fruchterman-Reingold) |
53 | 59 | n = len(nodes) |
54 | | -positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions |
| 60 | +positions = np.random.rand(n, 2) * 2 - 1 |
55 | 61 |
|
56 | | -# Optimal distance parameter |
57 | 62 | k = 0.5 |
58 | 63 | iterations = 200 |
59 | 64 |
|
60 | 65 | for iteration in range(iterations): |
61 | 66 | displacement = np.zeros((n, 2)) |
62 | 67 |
|
63 | | - # Repulsive forces between all node pairs (nodes push apart) |
64 | 68 | for i in range(n): |
65 | 69 | for j in range(i + 1, n): |
66 | 70 | diff = positions[i] - positions[j] |
|
69 | 73 | displacement[i] += repulsive_force |
70 | 74 | displacement[j] -= repulsive_force |
71 | 75 |
|
72 | | - # Attractive forces along edges (connected nodes pull together) |
73 | 76 | for src, tgt in edges: |
74 | 77 | diff = positions[src] - positions[tgt] |
75 | 78 | dist = max(np.linalg.norm(diff), 0.01) |
76 | 79 | attractive_force = (dist * dist / k) * (diff / dist) |
77 | 80 | displacement[src] -= attractive_force |
78 | 81 | displacement[tgt] += attractive_force |
79 | 82 |
|
80 | | - # Apply displacement with cooling (decreasing temperature) |
81 | 83 | temperature = 1 - iteration / iterations |
82 | 84 | for i in range(n): |
83 | 85 | disp_norm = np.linalg.norm(displacement[i]) |
84 | 86 | if disp_norm > 0: |
85 | | - # Limit movement by temperature |
86 | 87 | positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature) |
87 | 88 |
|
88 | | -# Normalize positions to [0.05, 0.95] range |
89 | 89 | pos_min = positions.min(axis=0) |
90 | 90 | pos_max = positions.max(axis=0) |
91 | 91 | positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05 |
92 | 92 | pos = {node["id"]: positions[i] for i, node in enumerate(nodes)} |
93 | 93 |
|
94 | | -# Calculate node degrees (number of connections) |
| 94 | +# Node degrees |
95 | 95 | degrees = {node["id"]: 0 for node in nodes} |
96 | 96 | for src, tgt in edges: |
97 | 97 | degrees[src] += 1 |
98 | 98 | degrees[tgt] += 1 |
99 | 99 |
|
100 | | -# Community colors (Python Blue first, then Python Yellow, then accessible third color) |
101 | | -community_colors = ["#306998", "#FFD43B", "#FF6B6B"] |
102 | | - |
103 | | -# Create figure |
104 | 100 | fig = go.Figure() |
105 | 101 |
|
106 | | -# Draw edges first |
| 102 | +# Edge trace (single trace via None separators — classic plotly network pattern) |
107 | 103 | edge_x = [] |
108 | 104 | edge_y = [] |
109 | 105 | for src, tgt in edges: |
|
117 | 113 | x=edge_x, |
118 | 114 | y=edge_y, |
119 | 115 | mode="lines", |
120 | | - line={"width": 1.5, "color": "#AAAAAA"}, |
121 | | - opacity=0.4, |
| 116 | + line={"width": 1.5, "color": INK_SOFT}, |
| 117 | + opacity=0.35, |
122 | 118 | hoverinfo="none", |
123 | 119 | showlegend=False, |
124 | 120 | ) |
125 | 121 | ) |
126 | 122 |
|
127 | | -# Draw nodes by community (for legend grouping) |
| 123 | +# Nodes grouped by community for legend |
128 | 124 | for comm_idx, comm_name in enumerate(community_names): |
129 | 125 | comm_nodes = [node for node in nodes if node["community"] == comm_idx] |
130 | 126 | x_vals = [pos[node["id"]][0] for node in comm_nodes] |
131 | 127 | y_vals = [pos[node["id"]][1] for node in comm_nodes] |
132 | | - sizes = [20 + degrees[node["id"]] * 5 for node in comm_nodes] # Scale size by connections |
133 | | - |
134 | | - # Hover text showing degree |
| 128 | + sizes = [20 + degrees[node["id"]] * 5 for node in comm_nodes] |
135 | 129 | hover_text = [f"Node {node['id']}<br>Connections: {degrees[node['id']]}" for node in comm_nodes] |
136 | 130 |
|
137 | 131 | fig.add_trace( |
|
141 | 135 | mode="markers", |
142 | 136 | marker={ |
143 | 137 | "size": sizes, |
144 | | - "color": community_colors[comm_idx], |
145 | | - "line": {"width": 2, "color": "#333333"}, |
146 | | - "opacity": 0.85, |
| 138 | + "color": OKABE_ITO[comm_idx], |
| 139 | + "line": {"width": 2, "color": PAGE_BG}, |
| 140 | + "opacity": 0.9, |
147 | 141 | }, |
148 | 142 | name=comm_name, |
149 | 143 | text=hover_text, |
150 | 144 | hoverinfo="text", |
151 | 145 | ) |
152 | 146 | ) |
153 | 147 |
|
154 | | -# Add "Hub" labels for high-degree nodes |
| 148 | +# Hub annotations: label only the single highest-degree node per community |
155 | 149 | hub_annotations = [] |
156 | | -for node in nodes: |
157 | | - if degrees[node["id"]] >= 7: |
158 | | - x, y = pos[node["id"]] |
159 | | - hub_annotations.append( |
160 | | - { |
161 | | - "x": x, |
162 | | - "y": y + 0.04, |
163 | | - "text": "Hub", |
164 | | - "showarrow": False, |
165 | | - "font": {"size": 14, "color": "#333333", "family": "Arial Black"}, |
166 | | - "xanchor": "center", |
167 | | - "yanchor": "bottom", |
168 | | - } |
169 | | - ) |
| 150 | +for comm_idx, comm_name in enumerate(community_names): |
| 151 | + comm_nodes = [node for node in nodes if node["community"] == comm_idx] |
| 152 | + top_node = max(comm_nodes, key=lambda node: degrees[node["id"]]) |
| 153 | + x, y = pos[top_node["id"]] |
| 154 | + hub_annotations.append( |
| 155 | + { |
| 156 | + "x": x, |
| 157 | + "y": y + 0.04, |
| 158 | + "text": f"{comm_name} hub", |
| 159 | + "showarrow": False, |
| 160 | + "font": {"size": 16, "color": INK, "family": "Arial Black"}, |
| 161 | + "bgcolor": ELEVATED_BG, |
| 162 | + "bordercolor": INK_SOFT, |
| 163 | + "borderwidth": 1, |
| 164 | + "borderpad": 4, |
| 165 | + "xanchor": "center", |
| 166 | + "yanchor": "bottom", |
| 167 | + } |
| 168 | + ) |
170 | 169 |
|
171 | | -# Layout |
172 | 170 | fig.update_layout( |
173 | | - title={"text": "network-force-directed · plotly · pyplots.ai", "font": {"size": 28}, "x": 0.5, "xanchor": "center"}, |
| 171 | + title={ |
| 172 | + "text": "network-force-directed · plotly · anyplot.ai", |
| 173 | + "font": {"size": 28, "color": INK}, |
| 174 | + "x": 0.5, |
| 175 | + "xanchor": "center", |
| 176 | + }, |
| 177 | + paper_bgcolor=PAGE_BG, |
| 178 | + plot_bgcolor=PAGE_BG, |
| 179 | + font={"color": INK}, |
174 | 180 | xaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-0.05, 1.05]}, |
175 | 181 | yaxis={ |
176 | 182 | "showgrid": False, |
|
180 | 186 | "scaleanchor": "x", |
181 | 187 | "scaleratio": 1, |
182 | 188 | }, |
183 | | - template="plotly_white", |
184 | 189 | legend={ |
185 | | - "title": {"text": "Teams", "font": {"size": 20}}, |
186 | | - "font": {"size": 18}, |
| 190 | + "title": {"text": "Teams", "font": {"size": 20, "color": INK}}, |
| 191 | + "font": {"size": 16, "color": INK_SOFT}, |
187 | 192 | "x": 0.02, |
188 | 193 | "y": 0.98, |
189 | | - "bgcolor": "rgba(255,255,255,0.9)", |
190 | | - "bordercolor": "#333333", |
| 194 | + "bgcolor": ELEVATED_BG, |
| 195 | + "bordercolor": INK_SOFT, |
191 | 196 | "borderwidth": 1, |
192 | 197 | }, |
193 | 198 | annotations=hub_annotations, |
194 | 199 | margin={"l": 20, "r": 20, "t": 80, "b": 20}, |
195 | 200 | ) |
196 | 201 |
|
197 | | -# Save as PNG and HTML |
198 | | -fig.write_image("plot.png", width=1600, height=900, scale=3) |
199 | | -fig.write_html("plot.html", include_plotlyjs="cdn") |
| 202 | +fig.write_image(f"plot-{THEME}.png", width=1600, height=900, scale=3) |
| 203 | +fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn") |
0 commit comments