Skip to content

Commit ad825cb

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

2 files changed

Lines changed: 236 additions & 216 deletions

File tree

Lines changed: 69 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,88 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
network-force-directed: Force-Directed Graph
3-
Library: altair 6.0.0 | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: altair 6.1.0 | Python 3.14.4
4+
Quality: 85/100 | Updated: 2026-04-26
55
"""
66

7+
import os
8+
79
import altair as alt
810
import numpy as np
911
import pandas as pd
1012

1113

12-
# Set seed for reproducibility
13-
np.random.seed(42)
14+
# Theme tokens
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+
EDGE_COLOR = "#6B6A63" if THEME == "light" else "#A8A79F"
1421

15-
# Data: A social network with 50 nodes in 3 communities
16-
# Demonstrates force-directed layout with clear community structure
17-
nodes = []
18-
edges = []
22+
# Okabe-Ito categorical palette (first series is always #009E73)
23+
OKABE_ITO = ["#009E73", "#D55E00", "#0072B2"]
24+
25+
# Data: a 50-node organisational network with three communities
26+
np.random.seed(42)
1927

20-
# Create 3 communities
21-
community_sizes = [18, 17, 15] # Total: 50 nodes
28+
community_sizes = [18, 17, 15]
2229
community_names = ["Engineering", "Marketing", "Sales"]
23-
node_id = 0
2430

31+
nodes = []
32+
node_id = 0
2533
for comm_idx, size in enumerate(community_sizes):
2634
for _ in range(size):
2735
nodes.append({"id": node_id, "community": community_names[comm_idx]})
2836
node_id += 1
2937

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)
50-
bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
51-
edges.extend(bridge_edges)
52-
53-
# Force-directed layout algorithm (Fruchterman-Reingold)
54-
n = len(nodes)
55-
positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions
38+
edges = []
39+
# Intra-community edges (dense)
40+
for start, end in [(0, 18), (18, 35), (35, 50)]:
41+
for i in range(start, end):
42+
for j in range(i + 1, end):
43+
if np.random.random() < 0.3:
44+
edges.append((i, j))
5645

57-
# Optimal distance parameter
46+
# Inter-community bridges (sparse)
47+
edges.extend([(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)])
48+
49+
# Fruchterman-Reingold force-directed layout
50+
n = len(nodes)
51+
positions = np.random.rand(n, 2) * 2 - 1
5852
k = 0.5
5953
iterations = 200
6054

6155
for iteration in range(iterations):
6256
displacement = np.zeros((n, 2))
63-
64-
# Repulsive forces between all node pairs (nodes push apart)
6557
for i in range(n):
6658
for j in range(i + 1, n):
6759
diff = positions[i] - positions[j]
6860
dist = max(np.linalg.norm(diff), 0.01)
69-
repulsive_force = (k * k / dist) * (diff / dist)
70-
displacement[i] += repulsive_force
71-
displacement[j] -= repulsive_force
72-
73-
# Attractive forces along edges (connected nodes pull together)
61+
repulsive = (k * k / dist) * (diff / dist)
62+
displacement[i] += repulsive
63+
displacement[j] -= repulsive
7464
for src, tgt in edges:
7565
diff = positions[src] - positions[tgt]
7666
dist = max(np.linalg.norm(diff), 0.01)
77-
attractive_force = (dist * dist / k) * (diff / dist)
78-
displacement[src] -= attractive_force
79-
displacement[tgt] += attractive_force
80-
81-
# Apply displacement with cooling (decreasing temperature)
67+
attractive = (dist * dist / k) * (diff / dist)
68+
displacement[src] -= attractive
69+
displacement[tgt] += attractive
8270
temperature = 1 - iteration / iterations
8371
for i in range(n):
8472
disp_norm = np.linalg.norm(displacement[i])
8573
if disp_norm > 0:
86-
# Limit movement by temperature
8774
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
8875

89-
# Normalize positions to [0.05, 0.95] range
9076
pos_min = positions.min(axis=0)
9177
pos_max = positions.max(axis=0)
9278
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05
9379

94-
# Calculate node degrees (number of connections)
80+
# Node-level summary
9581
degrees = {node["id"]: 0 for node in nodes}
9682
for src, tgt in edges:
9783
degrees[src] += 1
9884
degrees[tgt] += 1
9985

100-
# Create node dataframe with positions and attributes
10186
node_df = pd.DataFrame(
10287
{
10388
"id": [node["id"] for node in nodes],
@@ -107,67 +92,71 @@
10792
"degree": [degrees[node["id"]] for node in nodes],
10893
}
10994
)
110-
111-
# Scale node size by degree for visualization
11295
node_df["size"] = node_df["degree"] * 30 + 200
11396

114-
# Create edge dataframe for line segments
97+
# Edge segments (long-form, two rows per edge)
11598
edge_data = []
11699
for src, tgt in edges:
117100
edge_data.append({"edge_id": f"{src}-{tgt}", "x": positions[src][0], "y": positions[src][1], "order": 0})
118101
edge_data.append({"edge_id": f"{src}-{tgt}", "x": positions[tgt][0], "y": positions[tgt][1], "order": 1})
119102
edge_df = pd.DataFrame(edge_data)
120103

121-
# Community color mapping (Python Blue, Python Yellow, and colorblind-safe coral)
122-
community_colors = ["#306998", "#FFD43B", "#FF6B6B"]
104+
# Label only the four most-connected nodes to avoid clutter
105+
hub_df = node_df.nlargest(4, "degree").copy()
106+
hub_df["label"] = "Hub " + hub_df["id"].astype(str)
123107

124-
# Create edges layer
108+
# Edges layer
125109
edges_chart = (
126110
alt.Chart(edge_df)
127-
.mark_line(strokeWidth=1.5, opacity=0.4)
111+
.mark_line(strokeWidth=1.4, opacity=0.55)
128112
.encode(
129113
x=alt.X("x:Q", axis=None),
130114
y=alt.Y("y:Q", axis=None),
131115
detail="edge_id:N",
132116
order="order:O",
133-
color=alt.value("#AAAAAA"),
117+
color=alt.value(EDGE_COLOR),
134118
)
135119
)
136120

137-
# Create nodes layer
121+
# Nodes layer
138122
nodes_chart = (
139123
alt.Chart(node_df)
140-
.mark_circle(stroke="#333333", strokeWidth=1.5, opacity=0.85)
124+
.mark_circle(stroke=PAGE_BG, strokeWidth=1.5, opacity=0.95)
141125
.encode(
142126
x=alt.X("x:Q", axis=None),
143127
y=alt.Y("y:Q", axis=None),
144-
size=alt.Size("size:Q", legend=None, scale=alt.Scale(range=[200, 800])),
128+
size=alt.Size("size:Q", legend=None, scale=alt.Scale(range=[200, 900])),
145129
color=alt.Color(
146130
"community:N",
147-
scale=alt.Scale(domain=["Engineering", "Marketing", "Sales"], range=community_colors),
148-
legend=alt.Legend(title="Teams", titleFontSize=18, labelFontSize=16, symbolSize=400),
131+
scale=alt.Scale(domain=community_names, range=OKABE_ITO),
132+
legend=alt.Legend(title="Team", titleFontSize=18, labelFontSize=16, symbolSize=400),
149133
),
150-
tooltip=["community:N", "degree:Q"],
134+
tooltip=[alt.Tooltip("community:N", title="Team"), alt.Tooltip("degree:Q", title="Connections")],
151135
)
152136
)
153137

154-
# Label high-degree nodes (hubs)
155-
hub_df = node_df[node_df["degree"] >= 7].copy()
156-
hub_df["label"] = "Hub"
157-
138+
# Hub labels
158139
hub_labels = (
159140
alt.Chart(hub_df)
160-
.mark_text(fontSize=14, fontWeight="bold", color="#333333", dy=-15)
141+
.mark_text(fontSize=15, fontWeight="bold", color=INK, dy=-22)
161142
.encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), text="label:N")
162143
)
163144

164-
# Combine all layers
165145
chart = (
166146
(edges_chart + nodes_chart + hub_labels)
167-
.properties(width=1600, height=900, title=alt.Title("network-force-directed · altair · pyplots.ai", fontSize=28))
168-
.configure_view(strokeWidth=0)
147+
.properties(
148+
width=1600,
149+
height=900,
150+
background=PAGE_BG,
151+
title=alt.Title(
152+
"network-force-directed · altair · anyplot.ai", fontSize=28, color=INK, anchor="start", offset=20
153+
),
154+
)
155+
.configure_view(fill=PAGE_BG, strokeWidth=0)
156+
.configure_legend(
157+
fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK, padding=12, cornerRadius=4
158+
)
169159
)
170160

171-
# Save as PNG (4800x2700 at scale_factor=3) and HTML
172-
chart.save("plot.png", scale_factor=3.0)
173-
chart.save("plot.html")
161+
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
162+
chart.save(f"plot-{THEME}.html")

0 commit comments

Comments
 (0)