Skip to content

Commit 9aad200

Browse files
Merge branch 'main' into implementation/network-force-directed/seaborn
2 parents 4471ea6 + 0dbc792 commit 9aad200

4 files changed

Lines changed: 322 additions & 285 deletions

File tree

Lines changed: 51 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
""" anyplot.ai
22
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
55
"""
66

77
import os
8+
import pathlib
89

910
import matplotlib.pyplot as plt
1011
import numpy as np
1112
from matplotlib.collections import LineCollection
1213

1314

15+
OUTPUT_DIR = pathlib.Path(__file__).parent
16+
17+
1418
# Theme tokens (see prompts/default-style-guide.md)
1519
THEME = os.getenv("ANYPLOT_THEME", "light")
1620
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
@@ -19,44 +23,41 @@
1923
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
2024
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
2125

22-
# Okabe-Ito palette — first series is always #009E73
26+
# Imprint palette — first series is always #009E73
2327
COMMUNITY_COLORS = ["#009E73", "#C475FD", "#4467A3"]
2428
COMMUNITY_NAMES = ["Engineering", "Marketing", "Sales"]
2529

26-
# Data: a 50-person company social network with 3 departments
30+
# Data: 50-person company social network with 3 departments
2731
np.random.seed(42)
2832
community_sizes = [18, 17, 15]
2933

3034
nodes = []
31-
node_id = 0
35+
nid_counter = 0
3236
for comm_idx, size in enumerate(community_sizes):
3337
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
3640

37-
edges = []
38-
# Intra-community edges (dense connections within departments)
41+
intra_edges = []
3942
ranges = [(0, 18), (18, 35), (35, 50)]
4043
for start, stop in ranges:
4144
for i in range(start, stop):
4245
for j in range(i + 1, stop):
4346
if np.random.random() < 0.3:
44-
edges.append((i, j))
47+
intra_edges.append((i, j))
4548

46-
# Inter-community edges (sparse bridges between departments)
49+
# Sparse cross-department bridge edges
4750
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
4952

5053
# Force-directed layout (Fruchterman-Reingold)
5154
n = len(nodes)
5255
positions = np.random.rand(n, 2) * 2 - 1
5356
k = 0.5
54-
iterations = 200
5557

56-
for iteration in range(iterations):
58+
for iteration in range(200):
5759
displacement = np.zeros((n, 2))
5860

59-
# Repulsive forces between all node pairs
6061
for i in range(n):
6162
for j in range(i + 1, n):
6263
diff = positions[i] - positions[j]
@@ -65,96 +66,96 @@
6566
displacement[i] += repulsive_force
6667
displacement[j] -= repulsive_force
6768

68-
# Attractive forces along edges
69-
for src, tgt in edges:
69+
for src, tgt in all_edges:
7070
diff = positions[src] - positions[tgt]
7171
dist = max(np.linalg.norm(diff), 0.01)
7272
attractive_force = (dist * dist / k) * (diff / dist)
7373
displacement[src] -= attractive_force
7474
displacement[tgt] += attractive_force
7575

76-
# Apply displacement with cooling
77-
temperature = 1 - iteration / iterations
76+
temperature = 1 - iteration / 200
7877
for i in range(n):
7978
disp_norm = np.linalg.norm(displacement[i])
8079
if disp_norm > 0:
8180
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
8281

83-
# Normalize positions to [0.08, 0.92] for comfortable margins
8482
pos_min = positions.min(axis=0)
8583
pos_max = positions.max(axis=0)
8684
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.84 + 0.08
8785
pos = {node["id"]: positions[i] for i, node in enumerate(nodes)}
8886

89-
# Node degrees
9087
degrees = {node["id"]: 0 for node in nodes}
91-
for src, tgt in edges:
88+
for src, tgt in all_edges:
9289
degrees[src] += 1
9390
degrees[tgt] += 1
9491

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)
9794
ax.set_facecolor(PAGE_BG)
9895

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)
103105

104106
# Nodes sized by degree
105107
node_sizes = {}
106108
for node in nodes:
107109
x, y = pos[node["id"]]
108110
degree = degrees[node["id"]]
109-
size = 450 + degree * 120
111+
size = 80 + degree * 12
110112
node_sizes[node["id"]] = size
111113
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)
113115

114-
# Label top 2 hubs per community, with size-aware offset to prevent overlap
116+
# Label top 2 hubs per community
115117
top_hubs = []
116118
for comm_idx in range(3):
117119
comm_degrees = [(node["id"], degrees[node["id"]]) for node in nodes if node["community"] == comm_idx]
118120
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]])
120122

121123
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])
127128
team_initial = COMMUNITY_NAMES[node["community"]][0]
128129
ax.text(
129130
x,
130131
y + offset,
131132
f"Hub ({team_initial})",
132-
fontsize=14,
133+
fontsize=8,
133134
fontweight="bold",
134135
ha="center",
135136
va="bottom",
136137
color=INK,
137138
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},
139140
)
140141

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)
143145
ax.set_xlim(-0.02, 1.02)
144146
ax.set_ylim(-0.02, 1.02)
145147
ax.axis("off")
146148

147-
# Legend
148149
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)
150151
for color, name in zip(COMMUNITY_COLORS, COMMUNITY_NAMES, strict=True)
151152
]
152153
leg = ax.legend(
153154
handles=legend_handles,
154155
loc="upper left",
155-
fontsize=16,
156+
fontsize=8,
156157
title="Teams",
157-
title_fontsize=18,
158+
title_fontsize=10,
158159
framealpha=0.95,
159160
fancybox=True,
160161
)
@@ -163,16 +164,15 @@
163164
leg.get_title().set_color(INK)
164165
plt.setp(leg.get_texts(), color=INK_SOFT)
165166

166-
# Footer
167167
fig.text(
168168
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",
171171
ha="center",
172172
va="bottom",
173-
fontsize=12,
173+
fontsize=8,
174174
color=INK_MUTED,
175175
)
176176

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

Comments
 (0)