Skip to content

Commit b4b8f3d

Browse files
feat(plotly): implement network-force-directed (#5434)
## Implementation: `network-force-directed` - python/plotly Implements the **python/plotly** version of `network-force-directed`. **File:** `plots/network-force-directed/implementations/python/plotly.py` **Parent Issue:** #990 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24952565979)* --------- 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 16169ee commit b4b8f3d

2 files changed

Lines changed: 235 additions & 189 deletions

File tree

Lines changed: 64 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
network-force-directed: Force-Directed Graph
3-
Library: plotly 6.5.0 | Python 3.13.11
4-
Quality: 92/100 | Created: 2025-12-23
3+
Library: plotly 6.7.0 | Python 3.14.4
4+
Quality: 89/100 | Updated: 2026-04-26
55
"""
66

7+
import os
8+
79
import numpy as np
810
import plotly.graph_objects as go
911

1012

11-
# Set seed for reproducibility
13+
# Theme-adaptive chrome tokens
14+
THEME = os.getenv("ANYPLOT_THEME", "light")
15+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
16+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
17+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
18+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
19+
20+
# Okabe-Ito categorical palette (positions 1-3)
21+
OKABE_ITO = ["#009E73", "#D55E00", "#0072B2"]
22+
1223
np.random.seed(42)
1324

14-
# Data: A social network with 50 nodes in 3 communities
15-
# Demonstrates force-directed layout with clear community structure
25+
# A social network with 50 nodes in 3 communities
1626
nodes = []
1727
edges = []
1828

19-
# Create 3 communities
20-
community_sizes = [18, 17, 15] # Total: 50 nodes
29+
community_sizes = [18, 17, 15]
2130
community_names = ["Engineering", "Marketing", "Sales"]
2231
node_id = 0
2332

@@ -27,40 +36,35 @@
2736
node_id += 1
2837

2938
# Intra-community edges (dense connections within communities)
30-
# Engineering: nodes 0-17
3139
for i in range(18):
3240
for j in range(i + 1, 18):
3341
if np.random.random() < 0.3:
3442
edges.append((i, j))
3543

36-
# Marketing: nodes 18-34
3744
for i in range(18, 35):
3845
for j in range(i + 1, 35):
3946
if np.random.random() < 0.3:
4047
edges.append((i, j))
4148

42-
# Sales: nodes 35-49
4349
for i in range(35, 50):
4450
for j in range(i + 1, 50):
4551
if np.random.random() < 0.3:
4652
edges.append((i, j))
4753

48-
# Inter-community edges (sparse bridges between communities)
54+
# Inter-community bridge edges
4955
bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
5056
edges.extend(bridge_edges)
5157

52-
# Force-directed layout algorithm (Fruchterman-Reingold)
58+
# Force-directed layout (Fruchterman-Reingold)
5359
n = len(nodes)
54-
positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions
60+
positions = np.random.rand(n, 2) * 2 - 1
5561

56-
# Optimal distance parameter
5762
k = 0.5
5863
iterations = 200
5964

6065
for iteration in range(iterations):
6166
displacement = np.zeros((n, 2))
6267

63-
# Repulsive forces between all node pairs (nodes push apart)
6468
for i in range(n):
6569
for j in range(i + 1, n):
6670
diff = positions[i] - positions[j]
@@ -69,41 +73,33 @@
6973
displacement[i] += repulsive_force
7074
displacement[j] -= repulsive_force
7175

72-
# Attractive forces along edges (connected nodes pull together)
7376
for src, tgt in edges:
7477
diff = positions[src] - positions[tgt]
7578
dist = max(np.linalg.norm(diff), 0.01)
7679
attractive_force = (dist * dist / k) * (diff / dist)
7780
displacement[src] -= attractive_force
7881
displacement[tgt] += attractive_force
7982

80-
# Apply displacement with cooling (decreasing temperature)
8183
temperature = 1 - iteration / iterations
8284
for i in range(n):
8385
disp_norm = np.linalg.norm(displacement[i])
8486
if disp_norm > 0:
85-
# Limit movement by temperature
8687
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
8788

88-
# Normalize positions to [0.05, 0.95] range
8989
pos_min = positions.min(axis=0)
9090
pos_max = positions.max(axis=0)
9191
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05
9292
pos = {node["id"]: positions[i] for i, node in enumerate(nodes)}
9393

94-
# Calculate node degrees (number of connections)
94+
# Node degrees
9595
degrees = {node["id"]: 0 for node in nodes}
9696
for src, tgt in edges:
9797
degrees[src] += 1
9898
degrees[tgt] += 1
9999

100-
# Community colors (Python Blue first, then Python Yellow, then accessible third color)
101-
community_colors = ["#306998", "#FFD43B", "#FF6B6B"]
102-
103-
# Create figure
104100
fig = go.Figure()
105101

106-
# Draw edges first
102+
# Edge trace (single trace via None separators — classic plotly network pattern)
107103
edge_x = []
108104
edge_y = []
109105
for src, tgt in edges:
@@ -117,21 +113,19 @@
117113
x=edge_x,
118114
y=edge_y,
119115
mode="lines",
120-
line={"width": 1.5, "color": "#AAAAAA"},
121-
opacity=0.4,
116+
line={"width": 1.5, "color": INK_SOFT},
117+
opacity=0.35,
122118
hoverinfo="none",
123119
showlegend=False,
124120
)
125121
)
126122

127-
# Draw nodes by community (for legend grouping)
123+
# Nodes grouped by community for legend
128124
for comm_idx, comm_name in enumerate(community_names):
129125
comm_nodes = [node for node in nodes if node["community"] == comm_idx]
130126
x_vals = [pos[node["id"]][0] for node in comm_nodes]
131127
y_vals = [pos[node["id"]][1] for node in comm_nodes]
132-
sizes = [20 + degrees[node["id"]] * 5 for node in comm_nodes] # Scale size by connections
133-
134-
# Hover text showing degree
128+
sizes = [20 + degrees[node["id"]] * 5 for node in comm_nodes]
135129
hover_text = [f"Node {node['id']}<br>Connections: {degrees[node['id']]}" for node in comm_nodes]
136130

137131
fig.add_trace(
@@ -141,36 +135,48 @@
141135
mode="markers",
142136
marker={
143137
"size": sizes,
144-
"color": community_colors[comm_idx],
145-
"line": {"width": 2, "color": "#333333"},
146-
"opacity": 0.85,
138+
"color": OKABE_ITO[comm_idx],
139+
"line": {"width": 2, "color": PAGE_BG},
140+
"opacity": 0.9,
147141
},
148142
name=comm_name,
149143
text=hover_text,
150144
hoverinfo="text",
151145
)
152146
)
153147

154-
# Add "Hub" labels for high-degree nodes
148+
# Hub annotations: label only the single highest-degree node per community
155149
hub_annotations = []
156-
for node in nodes:
157-
if degrees[node["id"]] >= 7:
158-
x, y = pos[node["id"]]
159-
hub_annotations.append(
160-
{
161-
"x": x,
162-
"y": y + 0.04,
163-
"text": "Hub",
164-
"showarrow": False,
165-
"font": {"size": 14, "color": "#333333", "family": "Arial Black"},
166-
"xanchor": "center",
167-
"yanchor": "bottom",
168-
}
169-
)
150+
for comm_idx, comm_name in enumerate(community_names):
151+
comm_nodes = [node for node in nodes if node["community"] == comm_idx]
152+
top_node = max(comm_nodes, key=lambda node: degrees[node["id"]])
153+
x, y = pos[top_node["id"]]
154+
hub_annotations.append(
155+
{
156+
"x": x,
157+
"y": y + 0.04,
158+
"text": f"{comm_name} hub",
159+
"showarrow": False,
160+
"font": {"size": 16, "color": INK, "family": "Arial Black"},
161+
"bgcolor": ELEVATED_BG,
162+
"bordercolor": INK_SOFT,
163+
"borderwidth": 1,
164+
"borderpad": 4,
165+
"xanchor": "center",
166+
"yanchor": "bottom",
167+
}
168+
)
170169

171-
# Layout
172170
fig.update_layout(
173-
title={"text": "network-force-directed · plotly · pyplots.ai", "font": {"size": 28}, "x": 0.5, "xanchor": "center"},
171+
title={
172+
"text": "network-force-directed · plotly · anyplot.ai",
173+
"font": {"size": 28, "color": INK},
174+
"x": 0.5,
175+
"xanchor": "center",
176+
},
177+
paper_bgcolor=PAGE_BG,
178+
plot_bgcolor=PAGE_BG,
179+
font={"color": INK},
174180
xaxis={"showgrid": False, "zeroline": False, "showticklabels": False, "range": [-0.05, 1.05]},
175181
yaxis={
176182
"showgrid": False,
@@ -180,20 +186,18 @@
180186
"scaleanchor": "x",
181187
"scaleratio": 1,
182188
},
183-
template="plotly_white",
184189
legend={
185-
"title": {"text": "Teams", "font": {"size": 20}},
186-
"font": {"size": 18},
190+
"title": {"text": "Teams", "font": {"size": 20, "color": INK}},
191+
"font": {"size": 16, "color": INK_SOFT},
187192
"x": 0.02,
188193
"y": 0.98,
189-
"bgcolor": "rgba(255,255,255,0.9)",
190-
"bordercolor": "#333333",
194+
"bgcolor": ELEVATED_BG,
195+
"bordercolor": INK_SOFT,
191196
"borderwidth": 1,
192197
},
193198
annotations=hub_annotations,
194199
margin={"l": 20, "r": 20, "t": 80, "b": 20},
195200
)
196201

197-
# Save as PNG and HTML
198-
fig.write_image("plot.png", width=1600, height=900, scale=3)
199-
fig.write_html("plot.html", include_plotlyjs="cdn")
202+
fig.write_image(f"plot-{THEME}.png", width=1600, height=900, scale=3)
203+
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

0 commit comments

Comments
 (0)