Skip to content

Commit 6537035

Browse files
feat(matplotlib): implement network-force-directed (#5432)
## Implementation: `network-force-directed` - python/matplotlib Implements the **python/matplotlib** version of `network-force-directed`. **File:** `plots/network-force-directed/implementations/python/matplotlib.py` **Parent Issue:** #990 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24952381723)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 416faf4 commit 6537035

2 files changed

Lines changed: 257 additions & 209 deletions

File tree

Lines changed: 83 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,62 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
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
55
"""
66

7+
import os
8+
79
import matplotlib.pyplot as plt
810
import numpy as np
911
from matplotlib.collections import LineCollection
1012

1113

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
1327
np.random.seed(42)
28+
community_sizes = [18, 17, 15]
1429

15-
# Data: A social network with 50 nodes in 3 communities
16-
# Demonstrates force-directed layout with clear community structure
1730
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"]
2331
node_id = 0
24-
2532
for comm_idx, size in enumerate(community_sizes):
2633
for _ in range(size):
2734
nodes.append({"id": node_id, "community": comm_idx})
2835
node_id += 1
2936

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)
5047
bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
5148
edges.extend(bridge_edges)
5249

53-
# Force-directed layout algorithm (Fruchterman-Reingold)
50+
# Force-directed layout (Fruchterman-Reingold)
5451
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
5853
k = 0.5
5954
iterations = 200
6055

6156
for iteration in range(iterations):
6257
displacement = np.zeros((n, 2))
6358

64-
# Repulsive forces between all node pairs (nodes push apart)
59+
# Repulsive forces between all node pairs
6560
for i in range(n):
6661
for j in range(i + 1, n):
6762
diff = positions[i] - positions[j]
@@ -70,99 +65,114 @@
7065
displacement[i] += repulsive_force
7166
displacement[j] -= repulsive_force
7267

73-
# Attractive forces along edges (connected nodes pull together)
68+
# Attractive forces along edges
7469
for src, tgt in edges:
7570
diff = positions[src] - positions[tgt]
7671
dist = max(np.linalg.norm(diff), 0.01)
7772
attractive_force = (dist * dist / k) * (diff / dist)
7873
displacement[src] -= attractive_force
7974
displacement[tgt] += attractive_force
8075

81-
# Apply displacement with cooling (decreasing temperature)
76+
# Apply displacement with cooling
8277
temperature = 1 - iteration / iterations
8378
for i in range(n):
8479
disp_norm = np.linalg.norm(displacement[i])
8580
if disp_norm > 0:
86-
# Limit movement by temperature
8781
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
8882

89-
# Normalize positions to [0.08, 0.92] range for better margins
83+
# Normalize positions to [0.08, 0.92] for comfortable margins
9084
pos_min = positions.min(axis=0)
9185
pos_max = positions.max(axis=0)
9286
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.84 + 0.08
9387
pos = {node["id"]: positions[i] for i, node in enumerate(nodes)}
9488

95-
# Calculate node degrees (number of connections)
89+
# Node degrees
9690
degrees = {node["id"]: 0 for node in nodes}
9791
for src, tgt in edges:
9892
degrees[src] += 1
9993
degrees[tgt] += 1
10094

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

107-
# Draw edges first (behind nodes)
99+
# Edges (behind nodes), theme-adaptive low-contrast color
108100
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)
110102
ax.add_collection(lc)
111103

112-
# Draw nodes sized by degree
104+
# Nodes sized by degree
105+
node_sizes = {}
113106
for node in nodes:
114107
x, y = pos[node["id"]]
115108
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)
119113

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
122115
top_hubs = []
123116
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]
126118
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]])
128120

129121
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]
133128
ax.text(
134129
x,
135-
y + 0.045,
130+
y + offset,
136131
f"Hub ({team_initial})",
137132
fontsize=14,
138133
fontweight="bold",
139134
ha="center",
140135
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},
143139
)
144140

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)
147143
ax.set_xlim(-0.02, 1.02)
148144
ax.set_ylim(-0.02, 1.02)
149145
ax.axis("off")
150146

151-
# Legend for communities
147+
# Legend
152148
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)
155151
]
156-
ax.legend(
152+
leg = ax.legend(
157153
handles=legend_handles,
158154
loc="upper left",
159155
fontsize=16,
160-
framealpha=0.95,
161156
title="Teams",
162157
title_fontsize=18,
163-
edgecolor="#CCCCCC",
158+
framealpha=0.95,
164159
fancybox=True,
165160
)
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+
)
166176

167177
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

Comments
 (0)