Skip to content

Commit 94f5d21

Browse files
feat(seaborn): implement network-force-directed (#5433)
## Implementation: `network-force-directed` - python/seaborn Implements the **python/seaborn** version of `network-force-directed`. **File:** `plots/network-force-directed/implementations/python/seaborn.py` **Parent Issue:** #990 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24952470496)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent ad825cb commit 94f5d21

2 files changed

Lines changed: 278 additions & 198 deletions

File tree

Lines changed: 83 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,52 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
network-force-directed: Force-Directed Graph
3-
Library: seaborn 0.13.2 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: seaborn 0.13.2 | Python 3.14.4
4+
Quality: 86/100 | Updated: 2026-04-26
55
"""
66

7+
import os
8+
79
import matplotlib.pyplot as plt
810
import numpy as np
911
import seaborn as sns
1012

1113

12-
# Set seaborn style
13-
sns.set_theme(style="white")
14+
# Theme-adaptive chrome
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+
EDGE_COLOR = "#4A4A44" if THEME == "light" else "#B8B7B0"
22+
23+
sns.set_theme(
24+
style="ticks",
25+
rc={
26+
"figure.facecolor": PAGE_BG,
27+
"axes.facecolor": PAGE_BG,
28+
"axes.edgecolor": INK_SOFT,
29+
"axes.labelcolor": INK,
30+
"text.color": INK,
31+
"xtick.color": INK_SOFT,
32+
"ytick.color": INK_SOFT,
33+
"grid.color": INK,
34+
"grid.alpha": 0.10,
35+
"legend.facecolor": ELEVATED_BG,
36+
"legend.edgecolor": INK_SOFT,
37+
},
38+
)
1439

1540
np.random.seed(42)
1641

17-
# Create sample social network data - organization with 3 departments
42+
# Okabe-Ito categorical palette (first series always #009E73)
43+
OKABE_ITO = ["#009E73", "#D55E00", "#0072B2"]
44+
45+
# Sample social network: organization with 3 departments
1846
num_nodes = 37
1947
community_sizes = [15, 12, 10]
2048
community_names = ["Engineering", "Marketing", "Sales"]
2149
communities = []
22-
23-
# Assign nodes to communities
2450
for comm_idx, size in enumerate(community_sizes):
2551
communities.extend([comm_idx] * size)
2652

@@ -29,12 +55,10 @@
2955
for i in range(num_nodes):
3056
for j in range(i + 1, num_nodes):
3157
if communities[i] == communities[j]:
32-
# Higher connection probability within community
3358
if np.random.random() < 0.35:
3459
weight = np.random.uniform(0.5, 1.0)
3560
edges.append((i, j, weight))
3661
else:
37-
# Lower connection probability between communities
3862
if np.random.random() < 0.05:
3963
weight = np.random.uniform(0.3, 0.7)
4064
edges.append((i, j, weight))
@@ -45,22 +69,24 @@
4569
degrees[src] += 1
4670
degrees[tgt] += 1
4771

48-
# Force-directed layout (Fruchterman-Reingold algorithm inline)
72+
# Identify bridge nodes (nodes with cross-community edges)
73+
bridge_nodes = set()
74+
for src, tgt, _ in edges:
75+
if communities[src] != communities[tgt]:
76+
bridge_nodes.add(src)
77+
bridge_nodes.add(tgt)
78+
79+
# Force-directed layout (Fruchterman-Reingold inline)
4980
n = num_nodes
5081
k = 0.5
5182
iterations = 150
5283

53-
# Initialize random positions
5484
pos = np.random.rand(n, 2) * 2 - 1
55-
56-
# Temperature for simulated annealing
5785
t = 1.0
5886
dt = t / (iterations + 1)
5987

6088
for _ in range(iterations):
61-
# Calculate repulsive forces between all pairs
6289
disp = np.zeros((n, 2))
63-
6490
for i in range(n):
6591
for j in range(i + 1, n):
6692
delta = pos[i] - pos[j]
@@ -70,7 +96,6 @@
7096
disp[i] += force_vec
7197
disp[j] -= force_vec
7298

73-
# Calculate attractive forces along edges
7499
for src, tgt, _ in edges:
75100
delta = pos[src] - pos[tgt]
76101
dist = max(np.linalg.norm(delta), 0.01)
@@ -79,80 +104,80 @@
79104
disp[src] -= force_vec
80105
disp[tgt] += force_vec
81106

82-
# Apply displacements with temperature limiting
83107
for i in range(n):
84108
disp_norm = max(np.linalg.norm(disp[i]), 0.01)
85109
pos[i] += (disp[i] / disp_norm) * min(disp_norm, t)
86110

87-
# Cool down
88111
t -= dt
89112

90-
# Normalize positions to [-1, 1]
113+
# Normalize positions
91114
pos -= pos.mean(axis=0)
92115
max_coord = np.abs(pos).max()
93116
if max_coord > 0:
94117
pos /= max_coord
95118

96-
positions = pos
97-
98-
# Extract positions
99-
x_coords = positions[:, 0]
100-
y_coords = positions[:, 1]
119+
x_coords = pos[:, 0]
120+
y_coords = pos[:, 1]
101121

102-
# Node sizes based on degree (hub nodes are larger)
122+
# Node sizes based on degree
103123
node_sizes = [150 + degree * 60 for degree in degrees]
104124

105-
# Create figure
106125
fig, ax = plt.subplots(figsize=(16, 9))
107126

108-
# Define community colors using colorblind-safe palette
109-
community_colors = ["#306998", "#FFD43B", "#E74C3C"] # Python Blue, Yellow, Red
110-
111127
# Draw edges
112128
for src, tgt, weight in edges:
113-
x0, y0 = positions[src]
114-
x1, y1 = positions[tgt]
115-
ax.plot([x0, x1], [y0, y1], color="#CCCCCC", linewidth=1 + weight * 2, alpha=0.5, zorder=1)
129+
x0, y0 = pos[src]
130+
x1, y1 = pos[tgt]
131+
ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=1 + weight * 2, alpha=0.25, zorder=1)
116132

117-
# Draw nodes using seaborn scatterplot
133+
# Draw nodes via seaborn (Okabe-Ito palette)
118134
sns.scatterplot(
119135
x=x_coords,
120136
y=y_coords,
121137
hue=communities,
122-
palette=community_colors,
138+
palette=OKABE_ITO,
123139
size=node_sizes,
124140
sizes=(150, 800),
125-
alpha=0.85,
126-
edgecolor="white",
141+
alpha=0.9,
142+
edgecolor=PAGE_BG,
127143
linewidth=2,
128144
ax=ax,
129145
legend=False,
130146
zorder=2,
131147
)
132148

133-
# Add labels for high-degree nodes (hubs)
149+
# Emphasize bridge nodes (cross-community connectors) with a ring outline
150+
bridge_x = [pos[i, 0] for i in bridge_nodes]
151+
bridge_y = [pos[i, 1] for i in bridge_nodes]
152+
bridge_sizes = [(node_sizes[i] + 200) for i in bridge_nodes]
153+
ax.scatter(bridge_x, bridge_y, s=bridge_sizes, facecolors="none", edgecolors=INK, linewidth=1.8, alpha=0.65, zorder=3)
154+
155+
# Hub labels (75th-percentile degree threshold)
134156
degree_threshold = np.percentile(degrees, 75)
135157
for node in range(num_nodes):
136158
if degrees[node] >= degree_threshold:
137159
ax.annotate(
138160
f"Node {node}",
139-
(positions[node, 0], positions[node, 1]),
140-
fontsize=12,
161+
(pos[node, 0], pos[node, 1]),
162+
fontsize=16,
141163
ha="center",
142164
va="bottom",
143-
xytext=(0, 10),
165+
xytext=(0, 12),
144166
textcoords="offset points",
145167
fontweight="bold",
146-
color="#333333",
168+
color=INK_SOFT,
147169
)
148170

149-
# Create custom legend for communities
171+
# Custom community legend
150172
legend_elements = []
151173
for idx, name in enumerate(community_names):
152174
count = community_sizes[idx]
153175
legend_elements.append(
154-
plt.scatter([], [], c=community_colors[idx], s=300, label=f"{name} ({count})", edgecolor="white", linewidth=2)
176+
plt.scatter([], [], c=OKABE_ITO[idx], s=300, label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=2)
155177
)
178+
legend_elements.append(
179+
plt.scatter([], [], s=300, facecolors="none", edgecolors=INK, linewidth=1.8, label="Bridge node")
180+
)
156181

157182
ax.legend(
158183
handles=legend_elements,
@@ -161,30 +186,28 @@
161186
title="Department",
162187
title_fontsize=18,
163188
frameon=True,
164-
fancybox=True,
165-
shadow=True,
189+
labelcolor=INK,
166190
)
167191

168-
# Styling
169-
ax.set_title("network-force-directed · seaborn · pyplots.ai", fontsize=24, fontweight="bold", pad=20)
170-
ax.set_xlabel("Force-Directed X Position", fontsize=20)
171-
ax.set_ylabel("Force-Directed Y Position", fontsize=20)
192+
# Titles & labels
193+
ax.set_title("network-force-directed · seaborn · anyplot.ai", fontsize=24, fontweight="bold", pad=20, color=INK)
194+
ax.set_xlabel("Force-Directed X Position", fontsize=20, color=INK)
195+
ax.set_ylabel("Force-Directed Y Position", fontsize=20, color=INK)
172196
ax.tick_params(axis="both", labelsize=16)
173-
174-
# Remove axis ticks for cleaner network visualization
175197
ax.set_xticks([])
176198
ax.set_yticks([])
177199

178-
# Add subtle border
179-
for spine in ax.spines.values():
180-
spine.set_color("#CCCCCC")
181-
spine.set_linewidth(2)
200+
# Spine treatment: keep L-shape only, themed color
201+
ax.spines["top"].set_visible(False)
202+
ax.spines["right"].set_visible(False)
203+
ax.spines["left"].set_color(INK_SOFT)
204+
ax.spines["bottom"].set_color(INK_SOFT)
182205

183-
# Add network statistics annotation
206+
# Network statistics annotation
184207
total_edges = len(edges)
185208
avg_degree = sum(degrees) / num_nodes
186-
stats_text = f"Nodes: {num_nodes} | Edges: {total_edges} | Avg Degree: {avg_degree:.1f}"
187-
ax.text(0.5, -0.08, stats_text, transform=ax.transAxes, fontsize=14, ha="center", va="top", color="#666666")
209+
stats_text = f"Nodes: {num_nodes} | Edges: {total_edges} | Avg Degree: {avg_degree:.1f} | Bridges: {len(bridge_nodes)}"
210+
ax.text(0.5, -0.08, stats_text, transform=ax.transAxes, fontsize=14, ha="center", va="top", color=INK_MUTED)
188211

189212
plt.tight_layout()
190-
plt.savefig("plot.png", dpi=300, bbox_inches="tight", facecolor="white")
213+
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

0 commit comments

Comments
 (0)