Skip to content

Commit d6ab57b

Browse files
feat(seaborn): implement network-force-directed (#9579)
## 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/28486519281)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent 0dbc792 commit d6ab57b

2 files changed

Lines changed: 194 additions & 170 deletions

File tree

Lines changed: 74 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
""" anyplot.ai
22
network-force-directed: Force-Directed Graph
3-
Library: seaborn 0.13.2 | Python 3.14.4
4-
Quality: 86/100 | Updated: 2026-04-26
3+
Library: seaborn 0.13.2 | Python 3.13.14
4+
Quality: 88/100 | Updated: 2026-07-01
55
"""
66

77
import os
88

99
import matplotlib.pyplot as plt
1010
import numpy as np
11+
import pandas as pd
1112
import seaborn as sns
1213

1314

@@ -39,10 +40,10 @@
3940

4041
np.random.seed(42)
4142

42-
# Okabe-Ito categorical palette (first series always #009E73)
43+
# Imprint categorical palette — canonical order, first series always #009E73
4344
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]
4445

45-
# Sample social network: organization with 3 departments
46+
# Data — organizational social network: 3 departments
4647
num_nodes = 37
4748
community_sizes = [15, 12, 10]
4849
community_names = ["Engineering", "Marketing", "Sales"]
@@ -69,7 +70,7 @@
6970
degrees[src] += 1
7071
degrees[tgt] += 1
7172

72-
# Identify bridge nodes (nodes with cross-community edges)
73+
# Identify bridge nodes (cross-community edges)
7374
bridge_nodes = set()
7475
for src, tgt, _ in edges:
7576
if communities[src] != communities[tgt]:
@@ -80,7 +81,6 @@
8081
n = num_nodes
8182
k = 0.5
8283
iterations = 150
83-
8484
pos = np.random.rand(n, 2) * 2 - 1
8585
t = 1.0
8686
dt = t / (iterations + 1)
@@ -95,22 +95,19 @@
9595
force_vec = (delta / dist) * force
9696
disp[i] += force_vec
9797
disp[j] -= force_vec
98-
9998
for src, tgt, _ in edges:
10099
delta = pos[src] - pos[tgt]
101100
dist = max(np.linalg.norm(delta), 0.01)
102101
force = (dist * dist) / k
103102
force_vec = (delta / dist) * force
104103
disp[src] -= force_vec
105104
disp[tgt] += force_vec
106-
107105
for i in range(n):
108106
disp_norm = max(np.linalg.norm(disp[i]), 0.01)
109107
pos[i] += (disp[i] / disp_norm) * min(disp_norm, t)
110-
111108
t -= dt
112109

113-
# Normalize positions
110+
# Normalize positions to [-1, 1]
114111
pos -= pos.mean(axis=0)
115112
max_coord = np.abs(pos).max()
116113
if max_coord > 0:
@@ -119,95 +116,107 @@
119116
x_coords = pos[:, 0]
120117
y_coords = pos[:, 1]
121118

122-
# Node sizes based on degree
123-
node_sizes = [150 + degree * 60 for degree in degrees]
119+
# Node DataFrame for seaborn three-way encoding (hue + size + style)
120+
node_df = pd.DataFrame(
121+
{
122+
"x": x_coords,
123+
"y": y_coords,
124+
"department": [community_names[c] for c in communities],
125+
"degree": degrees,
126+
"node_type": ["Bridge" if i in bridge_nodes else "Member" for i in range(num_nodes)],
127+
}
128+
)
124129

125-
fig, ax = plt.subplots(figsize=(16, 9))
130+
# Plot — 3200×1800 px landscape (figsize=(8,4.5) × dpi=400)
131+
fig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
132+
ax.set_facecolor(PAGE_BG)
126133

127-
# Draw edges
134+
# Edges
128135
for src, tgt, weight in edges:
129136
x0, y0 = pos[src]
130137
x1, y1 = pos[tgt]
131-
ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=1 + weight * 2, alpha=0.25, zorder=1)
138+
ax.plot([x0, x1], [y0, y1], color=EDGE_COLOR, linewidth=0.5 + weight * 1.0, alpha=0.22, zorder=1)
132139

133-
# Draw nodes via seaborn (Okabe-Ito palette)
140+
# Nodes via seaborn three-way encoding: hue=department, size=degree, style=node_type
141+
# Diamond markers distinguish bridge nodes (cross-community connectors) from regular members
142+
palette = {name: IMPRINT[i] for i, name in enumerate(community_names)}
134143
sns.scatterplot(
135-
x=x_coords,
136-
y=y_coords,
137-
hue=communities,
138-
palette=IMPRINT,
139-
size=node_sizes,
140-
sizes=(150, 800),
141-
alpha=0.9,
144+
data=node_df,
145+
x="x",
146+
y="y",
147+
hue="department",
148+
hue_order=community_names,
149+
palette=palette,
150+
size="degree",
151+
sizes=(80, 400),
152+
style="node_type",
153+
style_order=["Member", "Bridge"],
154+
markers={"Member": "o", "Bridge": "D"},
155+
alpha=0.90,
142156
edgecolor=PAGE_BG,
143-
linewidth=2,
157+
linewidth=0.8,
144158
ax=ax,
145159
legend=False,
146160
zorder=2,
147161
)
148162

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)
156-
degree_threshold = np.percentile(degrees, 75)
157-
for node in range(num_nodes):
158-
if degrees[node] >= degree_threshold:
159-
ax.annotate(
160-
f"Node {node}",
161-
(pos[node, 0], pos[node, 1]),
162-
fontsize=16,
163-
ha="center",
164-
va="bottom",
165-
xytext=(0, 12),
166-
textcoords="offset points",
167-
fontweight="bold",
168-
color=INK_SOFT,
169-
)
163+
# Label top 4 hubs by degree only — prevents center-cluster label overlap
164+
top_hubs = sorted(range(num_nodes), key=lambda i: degrees[i], reverse=True)[:4]
165+
for node in top_hubs:
166+
ax.annotate(
167+
f"Node {node}",
168+
(pos[node, 0], pos[node, 1]),
169+
fontsize=8,
170+
ha="center",
171+
va="bottom",
172+
xytext=(0, 7),
173+
textcoords="offset points",
174+
fontweight="bold",
175+
color=INK_SOFT,
176+
)
170177

171-
# Custom community legend
178+
# Legend — departments (circle) + bridge node indicator (diamond)
172179
legend_elements = []
173180
for idx, name in enumerate(community_names):
174181
count = community_sizes[idx]
175182
legend_elements.append(
176-
plt.scatter([], [], c=IMPRINT[idx], s=300, label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=2)
183+
plt.scatter(
184+
[], [], c=IMPRINT[idx], s=80, marker="o", label=f"{name} ({count})", edgecolor=PAGE_BG, linewidth=0.8
185+
)
177186
)
178187
legend_elements.append(
179-
plt.scatter([], [], s=300, facecolors="none", edgecolors=INK, linewidth=1.8, label="Bridge node")
188+
plt.scatter([], [], c=INK_SOFT, s=80, marker="D", edgecolor=PAGE_BG, linewidth=0.8, label="Bridge node")
180189
)
181190

182191
ax.legend(
183192
handles=legend_elements,
184193
loc="upper left",
185-
fontsize=16,
194+
fontsize=8,
186195
title="Department",
187-
title_fontsize=18,
196+
title_fontsize=9,
188197
frameon=True,
189198
labelcolor=INK,
190199
)
191200

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)
196-
ax.tick_params(axis="both", labelsize=16)
201+
# Network summary inside the axes (bottom centre)
202+
total_edges = len(edges)
203+
avg_degree = sum(degrees) / num_nodes
204+
stats_text = (
205+
f"Nodes: {num_nodes} · Edges: {total_edges} · Avg degree: {avg_degree:.1f} · Bridges: {len(bridge_nodes)}"
206+
)
207+
ax.text(0.5, 0.02, stats_text, transform=ax.transAxes, fontsize=7, ha="center", va="bottom", color=INK_MUTED)
208+
209+
# Style
210+
title = "network-force-directed · python · seaborn · anyplot.ai"
211+
ax.set_title(title, fontsize=12, fontweight="medium", pad=10, color=INK)
212+
ax.set_xlabel("Force-directed X", fontsize=10, color=INK)
213+
ax.set_ylabel("Force-directed Y", fontsize=10, color=INK)
197214
ax.set_xticks([])
198215
ax.set_yticks([])
199-
200-
# Spine treatment: keep L-shape only, themed color
201216
ax.spines["top"].set_visible(False)
202217
ax.spines["right"].set_visible(False)
203218
ax.spines["left"].set_color(INK_SOFT)
204219
ax.spines["bottom"].set_color(INK_SOFT)
205220

206-
# Network statistics annotation
207-
total_edges = len(edges)
208-
avg_degree = sum(degrees) / num_nodes
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)
211-
212-
plt.tight_layout()
213-
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)
221+
plt.subplots_adjust(left=0.07, right=0.97, top=0.91, bottom=0.10)
222+
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

0 commit comments

Comments
 (0)