Skip to content

Commit 31668c1

Browse files
feat(matplotlib): implement network-force-directed
Regen from quality 85. Addressed: - Canvas fixed to figsize=(8,4.5) dpi=400 (3200×1800 px); was 16×9 @300dpi (4800×2700) - Removed bbox_inches="tight" from savefig (was causing canvas drift) - Font sizes corrected for 3200×1800 canvas: title 12pt, legend 8pt, hub labels 8pt, footer 8pt - Node scatter sizes scaled down proportionally (80–200 pt²; was 450–1000 pt²) - Added visual differentiation: intra-community edges solid/subtle, bridge edges dashed/INK_MUTED - Fixed palette comment from Okabe-Ito to Imprint palette - Added pathlib OUTPUT_DIR (__file__-relative) to avoid naming conflict when run from repo root
1 parent d2a57b9 commit 31668c1

1 file changed

Lines changed: 51 additions & 52 deletions

File tree

Lines changed: 51 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
""" anyplot.ai
1+
"""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 | Python
54
"""
65

76
import os
7+
import pathlib
88

99
import matplotlib.pyplot as plt
1010
import numpy as np
1111
from matplotlib.collections import LineCollection
1212

1313

14+
OUTPUT_DIR = pathlib.Path(__file__).parent
15+
16+
1417
# Theme tokens (see prompts/default-style-guide.md)
1518
THEME = os.getenv("ANYPLOT_THEME", "light")
1619
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
@@ -19,44 +22,41 @@
1922
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
2023
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
2124

22-
# Okabe-Ito palette — first series is always #009E73
25+
# Imprint palette — first series is always #009E73
2326
COMMUNITY_COLORS = ["#009E73", "#C475FD", "#4467A3"]
2427
COMMUNITY_NAMES = ["Engineering", "Marketing", "Sales"]
2528

26-
# Data: a 50-person company social network with 3 departments
29+
# Data: 50-person company social network with 3 departments
2730
np.random.seed(42)
2831
community_sizes = [18, 17, 15]
2932

3033
nodes = []
31-
node_id = 0
34+
nid_counter = 0
3235
for comm_idx, size in enumerate(community_sizes):
3336
for _ in range(size):
34-
nodes.append({"id": node_id, "community": comm_idx})
35-
node_id += 1
37+
nodes.append({"id": nid_counter, "community": comm_idx})
38+
nid_counter += 1
3639

37-
edges = []
38-
# Intra-community edges (dense connections within departments)
40+
intra_edges = []
3941
ranges = [(0, 18), (18, 35), (35, 50)]
4042
for start, stop in ranges:
4143
for i in range(start, stop):
4244
for j in range(i + 1, stop):
4345
if np.random.random() < 0.3:
44-
edges.append((i, j))
46+
intra_edges.append((i, j))
4547

46-
# Inter-community edges (sparse bridges between departments)
48+
# Sparse cross-department bridge edges
4749
bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
48-
edges.extend(bridge_edges)
50+
all_edges = intra_edges + bridge_edges
4951

5052
# Force-directed layout (Fruchterman-Reingold)
5153
n = len(nodes)
5254
positions = np.random.rand(n, 2) * 2 - 1
5355
k = 0.5
54-
iterations = 200
5556

56-
for iteration in range(iterations):
57+
for iteration in range(200):
5758
displacement = np.zeros((n, 2))
5859

59-
# Repulsive forces between all node pairs
6060
for i in range(n):
6161
for j in range(i + 1, n):
6262
diff = positions[i] - positions[j]
@@ -65,96 +65,96 @@
6565
displacement[i] += repulsive_force
6666
displacement[j] -= repulsive_force
6767

68-
# Attractive forces along edges
69-
for src, tgt in edges:
68+
for src, tgt in all_edges:
7069
diff = positions[src] - positions[tgt]
7170
dist = max(np.linalg.norm(diff), 0.01)
7271
attractive_force = (dist * dist / k) * (diff / dist)
7372
displacement[src] -= attractive_force
7473
displacement[tgt] += attractive_force
7574

76-
# Apply displacement with cooling
77-
temperature = 1 - iteration / iterations
75+
temperature = 1 - iteration / 200
7876
for i in range(n):
7977
disp_norm = np.linalg.norm(displacement[i])
8078
if disp_norm > 0:
8179
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
8280

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

89-
# Node degrees
9086
degrees = {node["id"]: 0 for node in nodes}
91-
for src, tgt in edges:
87+
for src, tgt in all_edges:
9288
degrees[src] += 1
9389
degrees[tgt] += 1
9490

95-
# Plot
96-
fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG)
91+
# Canvas: 3200×1800 px (landscape 16:9)
92+
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
9793
ax.set_facecolor(PAGE_BG)
9894

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)
95+
# Intra-community edges (solid, subtle — dense within-team connections)
96+
intra_lines = [(pos[src], pos[tgt]) for src, tgt in intra_edges]
97+
lc_intra = LineCollection(intra_lines, colors=INK_SOFT, linewidths=0.7, alpha=0.22, zorder=1)
98+
ax.add_collection(lc_intra)
99+
100+
# Bridge edges (dashed, more visible — sparse cross-team connections reveal structure)
101+
bridge_lines = [(pos[src], pos[tgt]) for src, tgt in bridge_edges]
102+
lc_bridge = LineCollection(bridge_lines, colors=INK_MUTED, linewidths=1.2, alpha=0.65, linestyle="dashed", zorder=1)
103+
ax.add_collection(lc_bridge)
103104

104105
# Nodes sized by degree
105106
node_sizes = {}
106107
for node in nodes:
107108
x, y = pos[node["id"]]
108109
degree = degrees[node["id"]]
109-
size = 450 + degree * 120
110+
size = 80 + degree * 12
110111
node_sizes[node["id"]] = size
111112
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)
113+
ax.scatter(x, y, s=size, c=color, edgecolors=PAGE_BG, linewidths=1.2, alpha=0.92, zorder=2)
113114

114-
# Label top 2 hubs per community, with size-aware offset to prevent overlap
115+
# Label top 2 hubs per community
115116
top_hubs = []
116117
for comm_idx in range(3):
117118
comm_degrees = [(node["id"], degrees[node["id"]]) for node in nodes if node["community"] == comm_idx]
118119
comm_degrees.sort(key=lambda x: x[1], reverse=True)
119-
top_hubs.extend([node_id for node_id, _ in comm_degrees[:2]])
120+
top_hubs.extend([nid for nid, _ in comm_degrees[:2]])
120121

121122
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])
123+
nid = node["id"]
124+
if nid in top_hubs:
125+
x, y = pos[nid]
126+
offset = 0.008 + 0.0007 * np.sqrt(node_sizes[nid])
127127
team_initial = COMMUNITY_NAMES[node["community"]][0]
128128
ax.text(
129129
x,
130130
y + offset,
131131
f"Hub ({team_initial})",
132-
fontsize=14,
132+
fontsize=8,
133133
fontweight="bold",
134134
ha="center",
135135
va="bottom",
136136
color=INK,
137137
zorder=4,
138-
bbox={"facecolor": ELEVATED_BG, "edgecolor": "none", "boxstyle": "round,pad=0.25", "alpha": 0.85},
138+
bbox={"facecolor": ELEVATED_BG, "edgecolor": "none", "boxstyle": "round,pad=0.2", "alpha": 0.85},
139139
)
140140

141-
# Title and frame
142-
ax.set_title("network-force-directed · matplotlib · anyplot.ai", fontsize=24, fontweight="medium", color=INK, pad=20)
141+
title = "network-force-directed · matplotlib · anyplot.ai"
142+
title_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12
143+
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK, pad=10)
143144
ax.set_xlim(-0.02, 1.02)
144145
ax.set_ylim(-0.02, 1.02)
145146
ax.axis("off")
146147

147-
# Legend
148148
legend_handles = [
149-
ax.scatter([], [], c=color, s=600, edgecolors=PAGE_BG, linewidths=2.0, label=name)
149+
ax.scatter([], [], c=color, s=80, edgecolors=PAGE_BG, linewidths=1.2, label=name)
150150
for color, name in zip(COMMUNITY_COLORS, COMMUNITY_NAMES, strict=True)
151151
]
152152
leg = ax.legend(
153153
handles=legend_handles,
154154
loc="upper left",
155-
fontsize=16,
155+
fontsize=8,
156156
title="Teams",
157-
title_fontsize=18,
157+
title_fontsize=10,
158158
framealpha=0.95,
159159
fancybox=True,
160160
)
@@ -163,16 +163,15 @@
163163
leg.get_title().set_color(INK)
164164
plt.setp(leg.get_texts(), color=INK_SOFT)
165165

166-
# Footer
167166
fig.text(
168167
0.5,
169-
0.02,
170-
f"50 nodes · {len(edges)} edges · node size scales with degree",
168+
0.01,
169+
f"50 nodes · {len(all_edges)} edges · node size degree · dashed = cross-team bridges",
171170
ha="center",
172171
va="bottom",
173-
fontsize=12,
172+
fontsize=8,
174173
color=INK_MUTED,
175174
)
176175

177-
plt.tight_layout()
178-
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)
176+
fig.subplots_adjust(left=0.03, right=0.97, top=0.93, bottom=0.07)
177+
plt.savefig(OUTPUT_DIR / f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

0 commit comments

Comments
 (0)