|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | network-force-directed: Force-Directed Graph |
3 | | -Library: matplotlib 3.10.8 | Python 3.13.11 |
4 | | -Quality: 92/100 | Created: 2025-12-23 |
| 3 | +Library: matplotlib 3.10.9 | Python 3.14.4 |
| 4 | +Quality: 85/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 | from matplotlib.collections import LineCollection |
10 | 12 |
|
11 | 13 |
|
12 | | -# Set seed for reproducibility |
| 14 | +# Theme tokens (see prompts/default-style-guide.md) |
| 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 | + |
| 22 | +# Okabe-Ito palette — first series is always #009E73 |
| 23 | +COMMUNITY_COLORS = ["#009E73", "#D55E00", "#0072B2"] |
| 24 | +COMMUNITY_NAMES = ["Engineering", "Marketing", "Sales"] |
| 25 | + |
| 26 | +# Data: a 50-person company social network with 3 departments |
13 | 27 | np.random.seed(42) |
| 28 | +community_sizes = [18, 17, 15] |
14 | 29 |
|
15 | | -# Data: A social network with 50 nodes in 3 communities |
16 | | -# Demonstrates force-directed layout with clear community structure |
17 | 30 | nodes = [] |
18 | | -edges = [] |
19 | | - |
20 | | -# Create 3 communities representing company departments |
21 | | -community_sizes = [18, 17, 15] # Total: 50 nodes |
22 | | -community_names = ["Engineering", "Marketing", "Sales"] |
23 | 31 | node_id = 0 |
24 | | - |
25 | 32 | for comm_idx, size in enumerate(community_sizes): |
26 | 33 | for _ in range(size): |
27 | 34 | nodes.append({"id": node_id, "community": comm_idx}) |
28 | 35 | node_id += 1 |
29 | 36 |
|
30 | | -# Intra-community edges (dense connections within communities) |
31 | | -# Engineering: nodes 0-17 |
32 | | -for i in range(18): |
33 | | - for j in range(i + 1, 18): |
34 | | - if np.random.random() < 0.3: |
35 | | - edges.append((i, j)) |
36 | | - |
37 | | -# Marketing: nodes 18-34 |
38 | | -for i in range(18, 35): |
39 | | - for j in range(i + 1, 35): |
40 | | - if np.random.random() < 0.3: |
41 | | - edges.append((i, j)) |
42 | | - |
43 | | -# Sales: nodes 35-49 |
44 | | -for i in range(35, 50): |
45 | | - for j in range(i + 1, 50): |
46 | | - if np.random.random() < 0.3: |
47 | | - edges.append((i, j)) |
48 | | - |
49 | | -# Inter-community edges (sparse bridges between communities) |
| 37 | +edges = [] |
| 38 | +# Intra-community edges (dense connections within departments) |
| 39 | +ranges = [(0, 18), (18, 35), (35, 50)] |
| 40 | +for start, stop in ranges: |
| 41 | + for i in range(start, stop): |
| 42 | + for j in range(i + 1, stop): |
| 43 | + if np.random.random() < 0.3: |
| 44 | + edges.append((i, j)) |
| 45 | + |
| 46 | +# Inter-community edges (sparse bridges between departments) |
50 | 47 | bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)] |
51 | 48 | edges.extend(bridge_edges) |
52 | 49 |
|
53 | | -# Force-directed layout algorithm (Fruchterman-Reingold) |
| 50 | +# Force-directed layout (Fruchterman-Reingold) |
54 | 51 | n = len(nodes) |
55 | | -positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions |
56 | | - |
57 | | -# Optimal distance parameter |
| 52 | +positions = np.random.rand(n, 2) * 2 - 1 |
58 | 53 | k = 0.5 |
59 | 54 | iterations = 200 |
60 | 55 |
|
61 | 56 | for iteration in range(iterations): |
62 | 57 | displacement = np.zeros((n, 2)) |
63 | 58 |
|
64 | | - # Repulsive forces between all node pairs (nodes push apart) |
| 59 | + # Repulsive forces between all node pairs |
65 | 60 | for i in range(n): |
66 | 61 | for j in range(i + 1, n): |
67 | 62 | diff = positions[i] - positions[j] |
|
70 | 65 | displacement[i] += repulsive_force |
71 | 66 | displacement[j] -= repulsive_force |
72 | 67 |
|
73 | | - # Attractive forces along edges (connected nodes pull together) |
| 68 | + # Attractive forces along edges |
74 | 69 | for src, tgt in edges: |
75 | 70 | diff = positions[src] - positions[tgt] |
76 | 71 | dist = max(np.linalg.norm(diff), 0.01) |
77 | 72 | attractive_force = (dist * dist / k) * (diff / dist) |
78 | 73 | displacement[src] -= attractive_force |
79 | 74 | displacement[tgt] += attractive_force |
80 | 75 |
|
81 | | - # Apply displacement with cooling (decreasing temperature) |
| 76 | + # Apply displacement with cooling |
82 | 77 | temperature = 1 - iteration / iterations |
83 | 78 | for i in range(n): |
84 | 79 | disp_norm = np.linalg.norm(displacement[i]) |
85 | 80 | if disp_norm > 0: |
86 | | - # Limit movement by temperature |
87 | 81 | positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature) |
88 | 82 |
|
89 | | -# Normalize positions to [0.08, 0.92] range for better margins |
| 83 | +# Normalize positions to [0.08, 0.92] for comfortable margins |
90 | 84 | pos_min = positions.min(axis=0) |
91 | 85 | pos_max = positions.max(axis=0) |
92 | 86 | positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.84 + 0.08 |
93 | 87 | pos = {node["id"]: positions[i] for i, node in enumerate(nodes)} |
94 | 88 |
|
95 | | -# Calculate node degrees (number of connections) |
| 89 | +# Node degrees |
96 | 90 | degrees = {node["id"]: 0 for node in nodes} |
97 | 91 | for src, tgt in edges: |
98 | 92 | degrees[src] += 1 |
99 | 93 | degrees[tgt] += 1 |
100 | 94 |
|
101 | | -# Create plot |
102 | | -fig, ax = plt.subplots(figsize=(16, 9)) |
103 | | - |
104 | | -# Community colors (Python Blue, Python Yellow, and a colorblind-safe coral) |
105 | | -community_colors = ["#306998", "#FFD43B", "#E07B53"] |
| 95 | +# Plot |
| 96 | +fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG) |
| 97 | +ax.set_facecolor(PAGE_BG) |
106 | 98 |
|
107 | | -# Draw edges first (behind nodes) |
| 99 | +# Edges (behind nodes), theme-adaptive low-contrast color |
108 | 100 | edge_lines = [[(pos[src][0], pos[src][1]), (pos[tgt][0], pos[tgt][1])] for src, tgt in edges] |
109 | | -lc = LineCollection(edge_lines, colors="#888888", linewidths=1.5, alpha=0.35, zorder=1) |
| 101 | +lc = LineCollection(edge_lines, colors=INK_SOFT, linewidths=1.5, alpha=0.30, zorder=1) |
110 | 102 | ax.add_collection(lc) |
111 | 103 |
|
112 | | -# Draw nodes sized by degree |
| 104 | +# Nodes sized by degree |
| 105 | +node_sizes = {} |
113 | 106 | for node in nodes: |
114 | 107 | x, y = pos[node["id"]] |
115 | 108 | degree = degrees[node["id"]] |
116 | | - size = 450 + degree * 120 # Scale size by connections |
117 | | - color = community_colors[node["community"]] |
118 | | - ax.scatter(x, y, s=size, c=color, edgecolors="#333333", linewidths=1.5, alpha=0.85, zorder=2) |
| 109 | + size = 450 + degree * 120 |
| 110 | + node_sizes[node["id"]] = size |
| 111 | + color = COMMUNITY_COLORS[node["community"]] |
| 112 | + ax.scatter(x, y, s=size, c=color, edgecolors=PAGE_BG, linewidths=2.0, alpha=0.92, zorder=2) |
119 | 113 |
|
120 | | -# Label only top hubs (highest degree nodes per community) |
121 | | -# Find top 2 nodes per community to avoid clutter |
| 114 | +# Label top 2 hubs per community, with size-aware offset to prevent overlap |
122 | 115 | top_hubs = [] |
123 | 116 | for comm_idx in range(3): |
124 | | - comm_nodes = [n for n in nodes if n["community"] == comm_idx] |
125 | | - comm_degrees = [(n["id"], degrees[n["id"]]) for n in comm_nodes] |
| 117 | + comm_degrees = [(node["id"], degrees[node["id"]]) for node in nodes if node["community"] == comm_idx] |
126 | 118 | comm_degrees.sort(key=lambda x: x[1], reverse=True) |
127 | | - top_hubs.extend([n_id for n_id, _ in comm_degrees[:2]]) |
| 119 | + top_hubs.extend([node_id for node_id, _ in comm_degrees[:2]]) |
128 | 120 |
|
129 | 121 | for node in nodes: |
130 | | - if node["id"] in top_hubs: |
131 | | - x, y = pos[node["id"]] |
132 | | - team_initial = community_names[node["community"]][0] |
| 122 | + node_id = node["id"] |
| 123 | + if node_id in top_hubs: |
| 124 | + x, y = pos[node_id] |
| 125 | + # Offset scales with marker radius (s is area in pt²) |
| 126 | + offset = 0.012 + 0.0009 * np.sqrt(node_sizes[node_id]) |
| 127 | + team_initial = COMMUNITY_NAMES[node["community"]][0] |
133 | 128 | ax.text( |
134 | 129 | x, |
135 | | - y + 0.045, |
| 130 | + y + offset, |
136 | 131 | f"Hub ({team_initial})", |
137 | 132 | fontsize=14, |
138 | 133 | fontweight="bold", |
139 | 134 | ha="center", |
140 | 135 | va="bottom", |
141 | | - color="#333333", |
142 | | - zorder=3, |
| 136 | + color=INK, |
| 137 | + zorder=4, |
| 138 | + bbox={"facecolor": ELEVATED_BG, "edgecolor": "none", "boxstyle": "round,pad=0.25", "alpha": 0.85}, |
143 | 139 | ) |
144 | 140 |
|
145 | | -# Styling |
146 | | -ax.set_title("network-force-directed · matplotlib · pyplots.ai", fontsize=24, fontweight="bold", pad=20) |
| 141 | +# Title and frame |
| 142 | +ax.set_title("network-force-directed · matplotlib · anyplot.ai", fontsize=24, fontweight="medium", color=INK, pad=20) |
147 | 143 | ax.set_xlim(-0.02, 1.02) |
148 | 144 | ax.set_ylim(-0.02, 1.02) |
149 | 145 | ax.axis("off") |
150 | 146 |
|
151 | | -# Legend for communities |
| 147 | +# Legend |
152 | 148 | legend_handles = [ |
153 | | - ax.scatter([], [], c=color, s=600, edgecolors="#333333", linewidths=1.5, label=name) |
154 | | - for color, name in zip(community_colors, community_names, strict=True) |
| 149 | + ax.scatter([], [], c=color, s=600, edgecolors=PAGE_BG, linewidths=2.0, label=name) |
| 150 | + for color, name in zip(COMMUNITY_COLORS, COMMUNITY_NAMES, strict=True) |
155 | 151 | ] |
156 | | -ax.legend( |
| 152 | +leg = ax.legend( |
157 | 153 | handles=legend_handles, |
158 | 154 | loc="upper left", |
159 | 155 | fontsize=16, |
160 | | - framealpha=0.95, |
161 | 156 | title="Teams", |
162 | 157 | title_fontsize=18, |
163 | | - edgecolor="#CCCCCC", |
| 158 | + framealpha=0.95, |
164 | 159 | fancybox=True, |
165 | 160 | ) |
| 161 | +leg.get_frame().set_facecolor(ELEVATED_BG) |
| 162 | +leg.get_frame().set_edgecolor(INK_SOFT) |
| 163 | +leg.get_title().set_color(INK) |
| 164 | +plt.setp(leg.get_texts(), color=INK_SOFT) |
| 165 | + |
| 166 | +# Footer |
| 167 | +fig.text( |
| 168 | + 0.5, |
| 169 | + 0.02, |
| 170 | + f"50 nodes · {len(edges)} edges · node size scales with degree", |
| 171 | + ha="center", |
| 172 | + va="bottom", |
| 173 | + fontsize=12, |
| 174 | + color=INK_MUTED, |
| 175 | +) |
166 | 176 |
|
167 | 177 | plt.tight_layout() |
168 | | -plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white") |
| 178 | +plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
0 commit comments