|
1 | 1 | """ anyplot.ai |
2 | 2 | network-force-directed: Force-Directed Graph |
3 | | -Library: matplotlib 3.10.9 | Python 3.14.4 |
4 | | -Quality: 85/100 | Updated: 2026-04-26 |
| 3 | +Library: matplotlib 3.11.0 | Python 3.13.14 |
| 4 | +Quality: 86/100 | Updated: 2026-07-01 |
5 | 5 | """ |
6 | 6 |
|
7 | 7 | import os |
| 8 | +import pathlib |
8 | 9 |
|
9 | 10 | import matplotlib.pyplot as plt |
10 | 11 | import numpy as np |
11 | 12 | from matplotlib.collections import LineCollection |
12 | 13 |
|
13 | 14 |
|
| 15 | +OUTPUT_DIR = pathlib.Path(__file__).parent |
| 16 | + |
| 17 | + |
14 | 18 | # Theme tokens (see prompts/default-style-guide.md) |
15 | 19 | THEME = os.getenv("ANYPLOT_THEME", "light") |
16 | 20 | PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17" |
|
19 | 23 | INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0" |
20 | 24 | INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F" |
21 | 25 |
|
22 | | -# Okabe-Ito palette — first series is always #009E73 |
| 26 | +# Imprint palette — first series is always #009E73 |
23 | 27 | COMMUNITY_COLORS = ["#009E73", "#C475FD", "#4467A3"] |
24 | 28 | COMMUNITY_NAMES = ["Engineering", "Marketing", "Sales"] |
25 | 29 |
|
26 | | -# Data: a 50-person company social network with 3 departments |
| 30 | +# Data: 50-person company social network with 3 departments |
27 | 31 | np.random.seed(42) |
28 | 32 | community_sizes = [18, 17, 15] |
29 | 33 |
|
30 | 34 | nodes = [] |
31 | | -node_id = 0 |
| 35 | +nid_counter = 0 |
32 | 36 | for comm_idx, size in enumerate(community_sizes): |
33 | 37 | for _ in range(size): |
34 | | - nodes.append({"id": node_id, "community": comm_idx}) |
35 | | - node_id += 1 |
| 38 | + nodes.append({"id": nid_counter, "community": comm_idx}) |
| 39 | + nid_counter += 1 |
36 | 40 |
|
37 | | -edges = [] |
38 | | -# Intra-community edges (dense connections within departments) |
| 41 | +intra_edges = [] |
39 | 42 | ranges = [(0, 18), (18, 35), (35, 50)] |
40 | 43 | for start, stop in ranges: |
41 | 44 | for i in range(start, stop): |
42 | 45 | for j in range(i + 1, stop): |
43 | 46 | if np.random.random() < 0.3: |
44 | | - edges.append((i, j)) |
| 47 | + intra_edges.append((i, j)) |
45 | 48 |
|
46 | | -# Inter-community edges (sparse bridges between departments) |
| 49 | +# Sparse cross-department bridge edges |
47 | 50 | bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)] |
48 | | -edges.extend(bridge_edges) |
| 51 | +all_edges = intra_edges + bridge_edges |
49 | 52 |
|
50 | 53 | # Force-directed layout (Fruchterman-Reingold) |
51 | 54 | n = len(nodes) |
52 | 55 | positions = np.random.rand(n, 2) * 2 - 1 |
53 | 56 | k = 0.5 |
54 | | -iterations = 200 |
55 | 57 |
|
56 | | -for iteration in range(iterations): |
| 58 | +for iteration in range(200): |
57 | 59 | displacement = np.zeros((n, 2)) |
58 | 60 |
|
59 | | - # Repulsive forces between all node pairs |
60 | 61 | for i in range(n): |
61 | 62 | for j in range(i + 1, n): |
62 | 63 | diff = positions[i] - positions[j] |
|
65 | 66 | displacement[i] += repulsive_force |
66 | 67 | displacement[j] -= repulsive_force |
67 | 68 |
|
68 | | - # Attractive forces along edges |
69 | | - for src, tgt in edges: |
| 69 | + for src, tgt in all_edges: |
70 | 70 | diff = positions[src] - positions[tgt] |
71 | 71 | dist = max(np.linalg.norm(diff), 0.01) |
72 | 72 | attractive_force = (dist * dist / k) * (diff / dist) |
73 | 73 | displacement[src] -= attractive_force |
74 | 74 | displacement[tgt] += attractive_force |
75 | 75 |
|
76 | | - # Apply displacement with cooling |
77 | | - temperature = 1 - iteration / iterations |
| 76 | + temperature = 1 - iteration / 200 |
78 | 77 | for i in range(n): |
79 | 78 | disp_norm = np.linalg.norm(displacement[i]) |
80 | 79 | if disp_norm > 0: |
81 | 80 | positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature) |
82 | 81 |
|
83 | | -# Normalize positions to [0.08, 0.92] for comfortable margins |
84 | 82 | pos_min = positions.min(axis=0) |
85 | 83 | pos_max = positions.max(axis=0) |
86 | 84 | positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.84 + 0.08 |
87 | 85 | pos = {node["id"]: positions[i] for i, node in enumerate(nodes)} |
88 | 86 |
|
89 | | -# Node degrees |
90 | 87 | degrees = {node["id"]: 0 for node in nodes} |
91 | | -for src, tgt in edges: |
| 88 | +for src, tgt in all_edges: |
92 | 89 | degrees[src] += 1 |
93 | 90 | degrees[tgt] += 1 |
94 | 91 |
|
95 | | -# Plot |
96 | | -fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG) |
| 92 | +# Canvas: 3200×1800 px (landscape 16:9) |
| 93 | +fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG) |
97 | 94 | ax.set_facecolor(PAGE_BG) |
98 | 95 |
|
99 | | -# Edges (behind nodes), theme-adaptive low-contrast color |
100 | | -edge_lines = [[(pos[src][0], pos[src][1]), (pos[tgt][0], pos[tgt][1])] for src, tgt in edges] |
101 | | -lc = LineCollection(edge_lines, colors=INK_SOFT, linewidths=1.5, alpha=0.30, zorder=1) |
102 | | -ax.add_collection(lc) |
| 96 | +# Intra-community edges (solid, subtle — dense within-team connections) |
| 97 | +intra_lines = [(pos[src], pos[tgt]) for src, tgt in intra_edges] |
| 98 | +lc_intra = LineCollection(intra_lines, colors=INK_SOFT, linewidths=0.7, alpha=0.22, zorder=1) |
| 99 | +ax.add_collection(lc_intra) |
| 100 | + |
| 101 | +# Bridge edges (dashed, more visible — sparse cross-team connections reveal structure) |
| 102 | +bridge_lines = [(pos[src], pos[tgt]) for src, tgt in bridge_edges] |
| 103 | +lc_bridge = LineCollection(bridge_lines, colors=INK_MUTED, linewidths=1.2, alpha=0.65, linestyle="dashed", zorder=1) |
| 104 | +ax.add_collection(lc_bridge) |
103 | 105 |
|
104 | 106 | # Nodes sized by degree |
105 | 107 | node_sizes = {} |
106 | 108 | for node in nodes: |
107 | 109 | x, y = pos[node["id"]] |
108 | 110 | degree = degrees[node["id"]] |
109 | | - size = 450 + degree * 120 |
| 111 | + size = 80 + degree * 12 |
110 | 112 | node_sizes[node["id"]] = size |
111 | 113 | 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) |
| 114 | + ax.scatter(x, y, s=size, c=color, edgecolors=PAGE_BG, linewidths=1.2, alpha=0.92, zorder=2) |
113 | 115 |
|
114 | | -# Label top 2 hubs per community, with size-aware offset to prevent overlap |
| 116 | +# Label top 2 hubs per community |
115 | 117 | top_hubs = [] |
116 | 118 | for comm_idx in range(3): |
117 | 119 | comm_degrees = [(node["id"], degrees[node["id"]]) for node in nodes if node["community"] == comm_idx] |
118 | 120 | comm_degrees.sort(key=lambda x: x[1], reverse=True) |
119 | | - top_hubs.extend([node_id for node_id, _ in comm_degrees[:2]]) |
| 121 | + top_hubs.extend([nid for nid, _ in comm_degrees[:2]]) |
120 | 122 |
|
121 | 123 | for node in nodes: |
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]) |
| 124 | + nid = node["id"] |
| 125 | + if nid in top_hubs: |
| 126 | + x, y = pos[nid] |
| 127 | + offset = 0.008 + 0.0007 * np.sqrt(node_sizes[nid]) |
127 | 128 | team_initial = COMMUNITY_NAMES[node["community"]][0] |
128 | 129 | ax.text( |
129 | 130 | x, |
130 | 131 | y + offset, |
131 | 132 | f"Hub ({team_initial})", |
132 | | - fontsize=14, |
| 133 | + fontsize=8, |
133 | 134 | fontweight="bold", |
134 | 135 | ha="center", |
135 | 136 | va="bottom", |
136 | 137 | color=INK, |
137 | 138 | zorder=4, |
138 | | - bbox={"facecolor": ELEVATED_BG, "edgecolor": "none", "boxstyle": "round,pad=0.25", "alpha": 0.85}, |
| 139 | + bbox={"facecolor": ELEVATED_BG, "edgecolor": "none", "boxstyle": "round,pad=0.2", "alpha": 0.85}, |
139 | 140 | ) |
140 | 141 |
|
141 | | -# Title and frame |
142 | | -ax.set_title("network-force-directed · matplotlib · anyplot.ai", fontsize=24, fontweight="medium", color=INK, pad=20) |
| 142 | +title = "network-force-directed · python · matplotlib · anyplot.ai" |
| 143 | +title_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12 |
| 144 | +ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=10) |
143 | 145 | ax.set_xlim(-0.02, 1.02) |
144 | 146 | ax.set_ylim(-0.02, 1.02) |
145 | 147 | ax.axis("off") |
146 | 148 |
|
147 | | -# Legend |
148 | 149 | legend_handles = [ |
149 | | - ax.scatter([], [], c=color, s=600, edgecolors=PAGE_BG, linewidths=2.0, label=name) |
| 150 | + ax.scatter([], [], c=color, s=80, edgecolors=PAGE_BG, linewidths=1.2, label=name) |
150 | 151 | for color, name in zip(COMMUNITY_COLORS, COMMUNITY_NAMES, strict=True) |
151 | 152 | ] |
152 | 153 | leg = ax.legend( |
153 | 154 | handles=legend_handles, |
154 | 155 | loc="upper left", |
155 | | - fontsize=16, |
| 156 | + fontsize=8, |
156 | 157 | title="Teams", |
157 | | - title_fontsize=18, |
| 158 | + title_fontsize=10, |
158 | 159 | framealpha=0.95, |
159 | 160 | fancybox=True, |
160 | 161 | ) |
|
163 | 164 | leg.get_title().set_color(INK) |
164 | 165 | plt.setp(leg.get_texts(), color=INK_SOFT) |
165 | 166 |
|
166 | | -# Footer |
167 | 167 | fig.text( |
168 | 168 | 0.5, |
169 | | - 0.02, |
170 | | - f"50 nodes · {len(edges)} edges · node size scales with degree", |
| 169 | + 0.01, |
| 170 | + f"50 nodes · {len(all_edges)} edges · node size ∝ degree · dashed = cross-team bridges", |
171 | 171 | ha="center", |
172 | 172 | va="bottom", |
173 | | - fontsize=12, |
| 173 | + fontsize=8, |
174 | 174 | color=INK_MUTED, |
175 | 175 | ) |
176 | 176 |
|
177 | | -plt.tight_layout() |
178 | | -plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG) |
| 177 | +fig.subplots_adjust(left=0.03, right=0.97, top=0.93, bottom=0.07) |
| 178 | +plt.savefig(OUTPUT_DIR / f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG) |
0 commit comments