|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | network-force-directed: Force-Directed Graph |
3 | | -Library: seaborn 0.13.2 | Python 3.13.11 |
4 | | -Quality: 91/100 | Created: 2025-12-23 |
| 3 | +Library: seaborn 0.13.2 | Python 3.14.4 |
| 4 | +Quality: 86/100 | Updated: 2026-04-26 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import matplotlib.pyplot as plt |
8 | 10 | import numpy as np |
9 | 11 | import seaborn as sns |
10 | 12 |
|
11 | 13 |
|
12 | | -# Set seaborn style |
13 | | -sns.set_theme(style="white") |
| 14 | +# Theme-adaptive chrome |
| 15 | +THEME = os.getenv("ANYPLOT_THEME", "light") |
| 16 | +PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
| 17 | +ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420" |
| 18 | +INK = "#1A1A17" if THEME == "light" else "#F0EFE8" |
| 19 | +INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 20 | +INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
| 21 | +EDGE_COLOR = "#4A4A44" if THEME == "light" else "#B8B7B0" |
| 22 | + |
| 23 | +sns.set_theme( |
| 24 | + style="ticks", |
| 25 | + rc={ |
| 26 | + "figure.facecolor": PAGE_BG, |
| 27 | + "axes.facecolor": PAGE_BG, |
| 28 | + "axes.edgecolor": INK_SOFT, |
| 29 | + "axes.labelcolor": INK, |
| 30 | + "text.color": INK, |
| 31 | + "xtick.color": INK_SOFT, |
| 32 | + "ytick.color": INK_SOFT, |
| 33 | + "grid.color": INK, |
| 34 | + "grid.alpha": 0.10, |
| 35 | + "legend.facecolor": ELEVATED_BG, |
| 36 | + "legend.edgecolor": INK_SOFT, |
| 37 | + }, |
| 38 | +) |
14 | 39 |
|
15 | 40 | np.random.seed(42) |
16 | 41 |
|
17 | | -# Create sample social network data - organization with 3 departments |
| 42 | +# Okabe-Ito categorical palette (first series always #009E73) |
| 43 | +OKABE_ITO = ["#009E73", "#D55E00", "#0072B2"] |
| 44 | + |
| 45 | +# Sample social network: organization with 3 departments |
18 | 46 | num_nodes = 37 |
19 | 47 | community_sizes = [15, 12, 10] |
20 | 48 | community_names = ["Engineering", "Marketing", "Sales"] |
21 | 49 | communities = [] |
22 | | - |
23 | | -# Assign nodes to communities |
24 | 50 | for comm_idx, size in enumerate(community_sizes): |
25 | 51 | communities.extend([comm_idx] * size) |
26 | 52 |
|
|
29 | 55 | for i in range(num_nodes): |
30 | 56 | for j in range(i + 1, num_nodes): |
31 | 57 | if communities[i] == communities[j]: |
32 | | - # Higher connection probability within community |
33 | 58 | if np.random.random() < 0.35: |
34 | 59 | weight = np.random.uniform(0.5, 1.0) |
35 | 60 | edges.append((i, j, weight)) |
36 | 61 | else: |
37 | | - # Lower connection probability between communities |
38 | 62 | if np.random.random() < 0.05: |
39 | 63 | weight = np.random.uniform(0.3, 0.7) |
40 | 64 | edges.append((i, j, weight)) |
|
45 | 69 | degrees[src] += 1 |
46 | 70 | degrees[tgt] += 1 |
47 | 71 |
|
48 | | -# Force-directed layout (Fruchterman-Reingold algorithm inline) |
| 72 | +# Identify bridge nodes (nodes with cross-community edges) |
| 73 | +bridge_nodes = set() |
| 74 | +for src, tgt, _ in edges: |
| 75 | + if communities[src] != communities[tgt]: |
| 76 | + bridge_nodes.add(src) |
| 77 | + bridge_nodes.add(tgt) |
| 78 | + |
| 79 | +# Force-directed layout (Fruchterman-Reingold inline) |
49 | 80 | n = num_nodes |
50 | 81 | k = 0.5 |
51 | 82 | iterations = 150 |
52 | 83 |
|
53 | | -# Initialize random positions |
54 | 84 | pos = np.random.rand(n, 2) * 2 - 1 |
55 | | - |
56 | | -# Temperature for simulated annealing |
57 | 85 | t = 1.0 |
58 | 86 | dt = t / (iterations + 1) |
59 | 87 |
|
60 | 88 | for _ in range(iterations): |
61 | | - # Calculate repulsive forces between all pairs |
62 | 89 | disp = np.zeros((n, 2)) |
63 | | - |
64 | 90 | for i in range(n): |
65 | 91 | for j in range(i + 1, n): |
66 | 92 | delta = pos[i] - pos[j] |
|
70 | 96 | disp[i] += force_vec |
71 | 97 | disp[j] -= force_vec |
72 | 98 |
|
73 | | - # Calculate attractive forces along edges |
74 | 99 | for src, tgt, _ in edges: |
75 | 100 | delta = pos[src] - pos[tgt] |
76 | 101 | dist = max(np.linalg.norm(delta), 0.01) |
|
79 | 104 | disp[src] -= force_vec |
80 | 105 | disp[tgt] += force_vec |
81 | 106 |
|
82 | | - # Apply displacements with temperature limiting |
83 | 107 | for i in range(n): |
84 | 108 | disp_norm = max(np.linalg.norm(disp[i]), 0.01) |
85 | 109 | pos[i] += (disp[i] / disp_norm) * min(disp_norm, t) |
86 | 110 |
|
87 | | - # Cool down |
88 | 111 | t -= dt |
89 | 112 |
|
90 | | -# Normalize positions to [-1, 1] |
| 113 | +# Normalize positions |
91 | 114 | pos -= pos.mean(axis=0) |
92 | 115 | max_coord = np.abs(pos).max() |
93 | 116 | if max_coord > 0: |
94 | 117 | pos /= max_coord |
95 | 118 |
|
96 | | -positions = pos |
97 | | - |
98 | | -# Extract positions |
99 | | -x_coords = positions[:, 0] |
100 | | -y_coords = positions[:, 1] |
| 119 | +x_coords = pos[:, 0] |
| 120 | +y_coords = pos[:, 1] |
101 | 121 |
|
102 | | -# Node sizes based on degree (hub nodes are larger) |
| 122 | +# Node sizes based on degree |
103 | 123 | node_sizes = [150 + degree * 60 for degree in degrees] |
104 | 124 |
|
105 | | -# Create figure |
106 | 125 | fig, ax = plt.subplots(figsize=(16, 9)) |
107 | 126 |
|
108 | | -# Define community colors using colorblind-safe palette |
109 | | -community_colors = ["#306998", "#FFD43B", "#E74C3C"] # Python Blue, Yellow, Red |
110 | | - |
111 | 127 | # Draw edges |
112 | 128 | for src, tgt, weight in edges: |
113 | | - x0, y0 = positions[src] |
114 | | - x1, y1 = positions[tgt] |
115 | | - ax.plot([x0, x1], [y0, y1], color="#CCCCCC", linewidth=1 + weight * 2, alpha=0.5, zorder=1) |
| 129 | + x0, y0 = pos[src] |
| 130 | + x1, y1 = pos[tgt] |
| 131 | + ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=1 + weight * 2, alpha=0.25, zorder=1) |
116 | 132 |
|
117 | | -# Draw nodes using seaborn scatterplot |
| 133 | +# Draw nodes via seaborn (Okabe-Ito palette) |
118 | 134 | sns.scatterplot( |
119 | 135 | x=x_coords, |
120 | 136 | y=y_coords, |
121 | 137 | hue=communities, |
122 | | - palette=community_colors, |
| 138 | + palette=OKABE_ITO, |
123 | 139 | size=node_sizes, |
124 | 140 | sizes=(150, 800), |
125 | | - alpha=0.85, |
126 | | - edgecolor="white", |
| 141 | + alpha=0.9, |
| 142 | + edgecolor=PAGE_BG, |
127 | 143 | linewidth=2, |
128 | 144 | ax=ax, |
129 | 145 | legend=False, |
130 | 146 | zorder=2, |
131 | 147 | ) |
132 | 148 |
|
133 | | -# Add labels for high-degree nodes (hubs) |
| 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) |
134 | 156 | degree_threshold = np.percentile(degrees, 75) |
135 | 157 | for node in range(num_nodes): |
136 | 158 | if degrees[node] >= degree_threshold: |
137 | 159 | ax.annotate( |
138 | 160 | f"Node {node}", |
139 | | - (positions[node, 0], positions[node, 1]), |
140 | | - fontsize=12, |
| 161 | + (pos[node, 0], pos[node, 1]), |
| 162 | + fontsize=16, |
141 | 163 | ha="center", |
142 | 164 | va="bottom", |
143 | | - xytext=(0, 10), |
| 165 | + xytext=(0, 12), |
144 | 166 | textcoords="offset points", |
145 | 167 | fontweight="bold", |
146 | | - color="#333333", |
| 168 | + color=INK_SOFT, |
147 | 169 | ) |
148 | 170 |
|
149 | | -# Create custom legend for communities |
| 171 | +# Custom community legend |
150 | 172 | legend_elements = [] |
151 | 173 | for idx, name in enumerate(community_names): |
152 | 174 | count = community_sizes[idx] |
153 | 175 | legend_elements.append( |
154 | | - plt.scatter([], [], c=community_colors[idx], s=300, label=f"{name} ({count})", edgecolor="white", linewidth=2) |
| 176 | + plt.scatter([], [], c=OKABE_ITO[idx], s=300, label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=2) |
155 | 177 | ) |
| 178 | +legend_elements.append( |
| 179 | + plt.scatter([], [], s=300, facecolors="none", edgecolors=INK, linewidth=1.8, label="Bridge node") |
| 180 | +) |
156 | 181 |
|
157 | 182 | ax.legend( |
158 | 183 | handles=legend_elements, |
|
161 | 186 | title="Department", |
162 | 187 | title_fontsize=18, |
163 | 188 | frameon=True, |
164 | | - fancybox=True, |
165 | | - shadow=True, |
| 189 | + labelcolor=INK, |
166 | 190 | ) |
167 | 191 |
|
168 | | -# Styling |
169 | | -ax.set_title("network-force-directed · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20) |
170 | | -ax.set_xlabel("Force-Directed X Position", fontsize=20) |
171 | | -ax.set_ylabel("Force-Directed Y Position", fontsize=20) |
| 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) |
172 | 196 | ax.tick_params(axis="both", labelsize=16) |
173 | | - |
174 | | -# Remove axis ticks for cleaner network visualization |
175 | 197 | ax.set_xticks([]) |
176 | 198 | ax.set_yticks([]) |
177 | 199 |
|
178 | | -# Add subtle border |
179 | | -for spine in ax.spines.values(): |
180 | | - spine.set_color("#CCCCCC") |
181 | | - spine.set_linewidth(2) |
| 200 | +# Spine treatment: keep L-shape only, themed color |
| 201 | +ax.spines["top"].set_visible(False) |
| 202 | +ax.spines["right"].set_visible(False) |
| 203 | +ax.spines["left"].set_color(INK_SOFT) |
| 204 | +ax.spines["bottom"].set_color(INK_SOFT) |
182 | 205 |
|
183 | | -# Add network statistics annotation |
| 206 | +# Network statistics annotation |
184 | 207 | total_edges = len(edges) |
185 | 208 | avg_degree = sum(degrees) / num_nodes |
186 | | -stats_text = f"Nodes: {num_nodes} | Edges: {total_edges} | Avg Degree: {avg_degree:.1f}" |
187 | | -ax.text(0.5, -0.08, stats_text, transform=ax.transAxes, fontsize=14, ha="center", va="top", color="#666666") |
| 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) |
188 | 211 |
|
189 | 212 | plt.tight_layout() |
190 | | -plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
| 213 | +plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
0 commit comments