|
1 | 1 | """ anyplot.ai |
2 | 2 | network-force-directed: Force-Directed Graph |
3 | | -Library: seaborn 0.13.2 | Python 3.14.4 |
4 | | -Quality: 86/100 | Updated: 2026-04-26 |
| 3 | +Library: seaborn 0.13.2 | Python 3.13.14 |
| 4 | +Quality: 88/100 | Updated: 2026-07-01 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
8 | 8 |
|
9 | 9 | import matplotlib.pyplot as plt |
10 | 10 | import numpy as np |
| 11 | +import pandas as pd |
11 | 12 | import seaborn as sns |
12 | 13 |
|
13 | 14 |
|
|
39 | 40 |
|
40 | 41 | np.random.seed(42) |
41 | 42 |
|
42 | | -# Okabe-Ito categorical palette (first series always #009E73) |
| 43 | +# Imprint categorical palette — canonical order, first series always #009E73 |
43 | 44 | IMPRINT = ["#009E73", "#C475FD", "#4467A3"] |
44 | 45 |
|
45 | | -# Sample social network: organization with 3 departments |
| 46 | +# Data — organizational social network: 3 departments |
46 | 47 | num_nodes = 37 |
47 | 48 | community_sizes = [15, 12, 10] |
48 | 49 | community_names = ["Engineering", "Marketing", "Sales"] |
|
69 | 70 | degrees[src] += 1 |
70 | 71 | degrees[tgt] += 1 |
71 | 72 |
|
72 | | -# Identify bridge nodes (nodes with cross-community edges) |
| 73 | +# Identify bridge nodes (cross-community edges) |
73 | 74 | bridge_nodes = set() |
74 | 75 | for src, tgt, _ in edges: |
75 | 76 | if communities[src] != communities[tgt]: |
|
80 | 81 | n = num_nodes |
81 | 82 | k = 0.5 |
82 | 83 | iterations = 150 |
83 | | - |
84 | 84 | pos = np.random.rand(n, 2) * 2 - 1 |
85 | 85 | t = 1.0 |
86 | 86 | dt = t / (iterations + 1) |
|
95 | 95 | force_vec = (delta / dist) * force |
96 | 96 | disp[i] += force_vec |
97 | 97 | disp[j] -= force_vec |
98 | | - |
99 | 98 | for src, tgt, _ in edges: |
100 | 99 | delta = pos[src] - pos[tgt] |
101 | 100 | dist = max(np.linalg.norm(delta), 0.01) |
102 | 101 | force = (dist * dist) / k |
103 | 102 | force_vec = (delta / dist) * force |
104 | 103 | disp[src] -= force_vec |
105 | 104 | disp[tgt] += force_vec |
106 | | - |
107 | 105 | for i in range(n): |
108 | 106 | disp_norm = max(np.linalg.norm(disp[i]), 0.01) |
109 | 107 | pos[i] += (disp[i] / disp_norm) * min(disp_norm, t) |
110 | | - |
111 | 108 | t -= dt |
112 | 109 |
|
113 | | -# Normalize positions |
| 110 | +# Normalize positions to [-1, 1] |
114 | 111 | pos -= pos.mean(axis=0) |
115 | 112 | max_coord = np.abs(pos).max() |
116 | 113 | if max_coord > 0: |
|
119 | 116 | x_coords = pos[:, 0] |
120 | 117 | y_coords = pos[:, 1] |
121 | 118 |
|
122 | | -# Node sizes based on degree |
123 | | -node_sizes = [150 + degree * 60 for degree in degrees] |
| 119 | +# Node DataFrame for seaborn three-way encoding (hue + size + style) |
| 120 | +node_df = pd.DataFrame( |
| 121 | + { |
| 122 | + "x": x_coords, |
| 123 | + "y": y_coords, |
| 124 | + "department": [community_names[c] for c in communities], |
| 125 | + "degree": degrees, |
| 126 | + "node_type": ["Bridge" if i in bridge_nodes else "Member" for i in range(num_nodes)], |
| 127 | + } |
| 128 | +) |
124 | 129 |
|
125 | | -fig, ax = plt.subplots(figsize=(16, 9)) |
| 130 | +# Plot — 3200×1800 px landscape (figsize=(8,4.5) × dpi=400) |
| 131 | +fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG) |
| 132 | +ax.set_facecolor(PAGE_BG) |
126 | 133 |
|
127 | | -# Draw edges |
| 134 | +# Edges |
128 | 135 | for src, tgt, weight in edges: |
129 | 136 | x0, y0 = pos[src] |
130 | 137 | x1, y1 = pos[tgt] |
131 | | - ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=1 + weight * 2, alpha=0.25, zorder=1) |
| 138 | + ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=0.5 + weight * 1.0, alpha=0.22, zorder=1) |
132 | 139 |
|
133 | | -# Draw nodes via seaborn (Okabe-Ito palette) |
| 140 | +# Nodes via seaborn three-way encoding: hue=department, size=degree, style=node_type |
| 141 | +# Diamond markers distinguish bridge nodes (cross-community connectors) from regular members |
| 142 | +palette = {name: IMPRINT[i] for i, name in enumerate(community_names)} |
134 | 143 | sns.scatterplot( |
135 | | - x=x_coords, |
136 | | - y=y_coords, |
137 | | - hue=communities, |
138 | | - palette=IMPRINT, |
139 | | - size=node_sizes, |
140 | | - sizes=(150, 800), |
141 | | - alpha=0.9, |
| 144 | + data=node_df, |
| 145 | + x="x", |
| 146 | + y="y", |
| 147 | + hue="department", |
| 148 | + hue_order=community_names, |
| 149 | + palette=palette, |
| 150 | + size="degree", |
| 151 | + sizes=(80, 400), |
| 152 | + style="node_type", |
| 153 | + style_order=["Member", "Bridge"], |
| 154 | + markers={"Member": "o", "Bridge": "D"}, |
| 155 | + alpha=0.90, |
142 | 156 | edgecolor=PAGE_BG, |
143 | | - linewidth=2, |
| 157 | + linewidth=0.8, |
144 | 158 | ax=ax, |
145 | 159 | legend=False, |
146 | 160 | zorder=2, |
147 | 161 | ) |
148 | 162 |
|
149 | | -# Emphasize bridge nodes (cross-community connectors) with a ring outline |
150 | | -bridge_x = [pos[i, 0] for i in bridge_nodes] |
151 | | -bridge_y = [pos[i, 1] for i in bridge_nodes] |
152 | | -bridge_sizes = [(node_sizes[i] + 200) for i in bridge_nodes] |
153 | | -ax.scatter(bridge_x, bridge_y, s=bridge_sizes, facecolors="none", edgecolors=INK, linewidth=1.8, alpha=0.65, zorder=3) |
154 | | - |
155 | | -# Hub labels (75th-percentile degree threshold) |
156 | | -degree_threshold = np.percentile(degrees, 75) |
157 | | -for node in range(num_nodes): |
158 | | - if degrees[node] >= degree_threshold: |
159 | | - ax.annotate( |
160 | | - f"Node {node}", |
161 | | - (pos[node, 0], pos[node, 1]), |
162 | | - fontsize=16, |
163 | | - ha="center", |
164 | | - va="bottom", |
165 | | - xytext=(0, 12), |
166 | | - textcoords="offset points", |
167 | | - fontweight="bold", |
168 | | - color=INK_SOFT, |
169 | | - ) |
| 163 | +# Label top 4 hubs by degree only — prevents center-cluster label overlap |
| 164 | +top_hubs = sorted(range(num_nodes), key=lambda i: degrees[i], reverse=True)[:4] |
| 165 | +for node in top_hubs: |
| 166 | + ax.annotate( |
| 167 | + f"Node {node}", |
| 168 | + (pos[node, 0], pos[node, 1]), |
| 169 | + fontsize=8, |
| 170 | + ha="center", |
| 171 | + va="bottom", |
| 172 | + xytext=(0, 7), |
| 173 | + textcoords="offset points", |
| 174 | + fontweight="bold", |
| 175 | + color=INK_SOFT, |
| 176 | + ) |
170 | 177 |
|
171 | | -# Custom community legend |
| 178 | +# Legend — departments (circle) + bridge node indicator (diamond) |
172 | 179 | legend_elements = [] |
173 | 180 | for idx, name in enumerate(community_names): |
174 | 181 | count = community_sizes[idx] |
175 | 182 | legend_elements.append( |
176 | | - plt.scatter([], [], c=IMPRINT[idx], s=300, label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=2) |
| 183 | + plt.scatter( |
| 184 | + [], [], c=IMPRINT[idx], s=80, marker="o", label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=0.8 |
| 185 | + ) |
177 | 186 | ) |
178 | 187 | legend_elements.append( |
179 | | - plt.scatter([], [], s=300, facecolors="none", edgecolors=INK, linewidth=1.8, label="Bridge node") |
| 188 | + plt.scatter([], [], c=INK_SOFT, s=80, marker="D", edgecolor=PAGE_BG, linewidth=0.8, label="Bridge node") |
180 | 189 | ) |
181 | 190 |
|
182 | 191 | ax.legend( |
183 | 192 | handles=legend_elements, |
184 | 193 | loc="upper left", |
185 | | - fontsize=16, |
| 194 | + fontsize=8, |
186 | 195 | title="Department", |
187 | | - title_fontsize=18, |
| 196 | + title_fontsize=9, |
188 | 197 | frameon=True, |
189 | 198 | labelcolor=INK, |
190 | 199 | ) |
191 | 200 |
|
192 | | -# Titles & labels |
193 | | -ax.set_title("network-force-directed · seaborn · anyplot.ai", fontsize=24, fontweight="bold", pad=20, color=INK) |
194 | | -ax.set_xlabel("Force-Directed X Position", fontsize=20, color=INK) |
195 | | -ax.set_ylabel("Force-Directed Y Position", fontsize=20, color=INK) |
196 | | -ax.tick_params(axis="both", labelsize=16) |
| 201 | +# Network summary inside the axes (bottom centre) |
| 202 | +total_edges = len(edges) |
| 203 | +avg_degree = sum(degrees) / num_nodes |
| 204 | +stats_text = ( |
| 205 | + f"Nodes: {num_nodes} · Edges: {total_edges} · Avg degree: {avg_degree:.1f} · Bridges: {len(bridge_nodes)}" |
| 206 | +) |
| 207 | +ax.text(0.5, 0.02, stats_text, transform=ax.transAxes, fontsize=7, ha="center", va="bottom", color=INK_MUTED) |
| 208 | + |
| 209 | +# Style |
| 210 | +title = "network-force-directed · python · seaborn · anyplot.ai" |
| 211 | +ax.set_title(title, fontsize=12, fontweight="medium", pad=10, color=INK) |
| 212 | +ax.set_xlabel("Force-directed X", fontsize=10, color=INK) |
| 213 | +ax.set_ylabel("Force-directed Y", fontsize=10, color=INK) |
197 | 214 | ax.set_xticks([]) |
198 | 215 | ax.set_yticks([]) |
199 | | - |
200 | | -# Spine treatment: keep L-shape only, themed color |
201 | 216 | ax.spines["top"].set_visible(False) |
202 | 217 | ax.spines["right"].set_visible(False) |
203 | 218 | ax.spines["left"].set_color(INK_SOFT) |
204 | 219 | ax.spines["bottom"].set_color(INK_SOFT) |
205 | 220 |
|
206 | | -# Network statistics annotation |
207 | | -total_edges = len(edges) |
208 | | -avg_degree = sum(degrees) / num_nodes |
209 | | -stats_text = f"Nodes: {num_nodes} | Edges: {total_edges} | Avg Degree: {avg_degree:.1f} | Bridges: {len(bridge_nodes)}" |
210 | | -ax.text(0.5, -0.08, stats_text, transform=ax.transAxes, fontsize=14, ha="center", va="top", color=INK_MUTED) |
211 | | - |
212 | | -plt.tight_layout() |
213 | | -plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
| 221 | +plt.subplots_adjust(left=0.07, right=0.97, top=0.91, bottom=0.10) |
| 222 | +plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG) |
0 commit comments