Skip to content

Commit d84bcba

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

2 files changed

Lines changed: 237 additions & 190 deletions

File tree

Lines changed: 67 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,42 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
network-force-directed: Force-Directed Graph
3-
Library: pygal 3.1.0 | Python 3.13.11
4-
Quality: 92/100 | Created: 2025-12-17
3+
Library: pygal 3.1.0 | Python 3.14.4
4+
Quality: 83/100 | Created: 2026-04-26
55
"""
66

7-
import numpy as np
8-
import pygal
9-
from pygal.style import Style
7+
import sys
8+
from pathlib import Path
109

1110

12-
# Set seed for reproducibility
11+
# Remove script directory from path to avoid name collision with pygal package
12+
_script_dir = str(Path(__file__).parent)
13+
sys.path = [p for p in sys.path if p != _script_dir]
14+
15+
import os # noqa: E402
16+
17+
import numpy as np # noqa: E402
18+
import pygal # noqa: E402
19+
from pygal.style import Style # noqa: E402
20+
21+
22+
# Theme-adaptive tokens
23+
THEME = os.getenv("ANYPLOT_THEME", "light")
24+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
25+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
26+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
27+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
28+
EDGE_COLOR = "#9A988F" if THEME == "light" else "#5A5852"
29+
30+
OKABE_ITO = ("#009E73", "#D55E00", "#0072B2", "#CC79A7", "#E69F00", "#56B4E9", "#F0E442")
31+
32+
# Reproducibility
1333
np.random.seed(42)
1434

15-
# Data: A social network with 50 nodes in 3 communities
35+
# Data: A corporate social network with 50 nodes in 3 departments
1636
# Demonstrates force-directed layout with clear community structure
1737
nodes = []
1838
edges = []
1939

20-
# Create 3 communities
2140
community_sizes = [18, 17, 15] # Total: 50 nodes
2241
community_names = ["Engineering", "Marketing", "Sales"]
2342
node_id = 0
@@ -50,18 +69,17 @@
5069
bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)]
5170
edges.extend(bridge_edges)
5271

53-
# Force-directed layout algorithm (Fruchterman-Reingold)
72+
# Force-directed layout (Fruchterman-Reingold)
5473
n = len(nodes)
5574
positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions
5675

57-
# Optimal distance parameter
58-
k = 0.5
59-
iterations = 200
76+
k = 0.95 # Optimal distance — larger to reduce dense-cluster node overlap
77+
iterations = 320
6078

6179
for iteration in range(iterations):
6280
displacement = np.zeros((n, 2))
6381

64-
# Repulsive forces between all node pairs (nodes push apart)
82+
# Repulsive forces between all node pairs
6583
for i in range(n):
6684
for j in range(i + 1, n):
6785
diff = positions[i] - positions[j]
@@ -70,126 +88,108 @@
7088
displacement[i] += repulsive_force
7189
displacement[j] -= repulsive_force
7290

73-
# Attractive forces along edges (connected nodes pull together)
91+
# Attractive forces along edges
7492
for src, tgt in edges:
7593
diff = positions[src] - positions[tgt]
7694
dist = max(np.linalg.norm(diff), 0.01)
7795
attractive_force = (dist * dist / k) * (diff / dist)
7896
displacement[src] -= attractive_force
7997
displacement[tgt] += attractive_force
8098

81-
# Apply displacement with cooling (decreasing temperature)
99+
# Apply displacement with cooling
82100
temperature = 1 - iteration / iterations
83101
for i in range(n):
84102
disp_norm = np.linalg.norm(displacement[i])
85103
if disp_norm > 0:
86-
# Limit movement by temperature
87104
positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature)
88105

89-
# Normalize positions to [1, 11] range for pygal (with padding)
106+
# Normalize positions to a padded plotting range
90107
pos_min = positions.min(axis=0)
91108
pos_max = positions.max(axis=0)
92109
positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 10 + 1
93110
pos = {node["id"]: positions[i] for i, node in enumerate(nodes)}
94111

95-
# Calculate node degrees (number of connections)
112+
# Node degrees (for tooltip context)
96113
degrees = {node["id"]: 0 for node in nodes}
97114
for src, tgt in edges:
98115
degrees[src] += 1
99116
degrees[tgt] += 1
100117

101-
# Community colors
102-
community_colors = ["#306998", "#FFD43B", "#FF6B6B"]
118+
# Style — first data series is the edge "Connections" (muted), then communities use Okabe-Ito 1..3
119+
community_colors = OKABE_ITO[: len(community_names)]
120+
series_colors = (EDGE_COLOR,) + community_colors
103121

104-
# Custom style for the chart
105122
custom_style = Style(
106-
background="white",
107-
plot_background="white",
108-
foreground="#333333",
109-
foreground_strong="#333333",
110-
foreground_subtle="#666666",
111-
colors=("#AAAAAA",) + tuple(community_colors),
123+
background=PAGE_BG,
124+
plot_background=PAGE_BG,
125+
foreground=INK,
126+
foreground_strong=INK,
127+
foreground_subtle=INK_MUTED,
128+
colors=series_colors,
112129
title_font_size=72,
113130
label_font_size=40,
114131
major_label_font_size=36,
115-
legend_font_size=40,
132+
legend_font_size=44,
116133
value_font_size=32,
117134
stroke_width=2,
118-
opacity=0.85,
135+
opacity=0.9,
119136
opacity_hover=1.0,
137+
tooltip_font_size=28,
138+
font_family="DejaVu Sans, Helvetica, Arial, sans-serif",
120139
)
121140

122-
# Create XY chart
123141
chart = pygal.XY(
124142
width=4800,
125143
height=2700,
126144
style=custom_style,
127-
title="network-force-directed · pygal · pyplots.ai",
145+
title="network-force-directed · pygal · anyplot.ai",
128146
show_legend=True,
129-
x_title="",
130-
y_title="",
131147
show_x_guides=False,
132148
show_y_guides=False,
133149
show_x_labels=False,
134150
show_y_labels=False,
135151
stroke=True,
136-
dots_size=25,
152+
dots_size=28,
137153
stroke_style={"width": 1.5, "linecap": "round"},
138154
legend_at_bottom=True,
139155
legend_at_bottom_columns=4,
156+
legend_box_size=36,
157+
margin=80,
140158
range=(0, 12),
141159
xrange=(0, 12),
142160
)
143161

144-
# Add edges as a single series with lines connecting pairs
145-
# Each edge is represented as two points connected, with None to break between edges
162+
# Edges as a single XY series with None breaks between segments
146163
edge_points = []
147164
for src, tgt in edges:
148165
x1, y1 = pos[src]
149166
x2, y2 = pos[tgt]
150167
edge_points.append((x1, y1))
151168
edge_points.append((x2, y2))
152-
edge_points.append(None) # Break the line for next edge
169+
edge_points.append(None)
153170

154171
chart.add("Connections", edge_points, stroke=True, show_dots=False, fill=False)
155172

156-
# Add nodes grouped by community
173+
# Nodes grouped by community — radius scales with node degree (visual encoding)
174+
# pygal supports per-point SVG attribute overrides via the "node" dict
175+
max_degree = max(degrees.values())
176+
min_radius, max_radius = 18, 52
157177
for comm_idx, comm_name in enumerate(community_names):
158178
comm_nodes = [node for node in nodes if node["community"] == comm_idx]
159-
# Create points with labels for tooltips showing degree
160179
node_points = []
161180
for node in comm_nodes:
162181
x, y = pos[node["id"]]
163182
degree = degrees[node["id"]]
183+
radius = min_radius + (max_radius - min_radius) * (degree / max_degree)
164184
label = f"Node {node['id']} | {degree} connections"
165185
if degree >= 7:
166186
label += " (Hub)"
167-
node_points.append({"value": (x, y), "label": label})
187+
node_points.append({"value": (x, y), "label": label, "node": {"r": round(radius, 1)}})
168188
chart.add(comm_name, node_points, stroke=False)
169189

170-
# Save outputs
171-
chart.render_to_file("plot.svg")
172-
chart.render_to_png("plot.png")
173-
174-
# Also save HTML for interactive version
175-
with open("plot.html", "w") as f:
176-
f.write(
177-
"""<!DOCTYPE html>
178-
<html>
179-
<head>
180-
<title>network-force-directed · pygal · pyplots.ai</title>
181-
<style>
182-
body { margin: 0; padding: 20px; background: #f5f5f5; }
183-
.container { max-width: 100%; margin: 0 auto; }
184-
object { width: 100%; height: auto; }
185-
</style>
186-
</head>
187-
<body>
188-
<div class="container">
189-
<object type="image/svg+xml" data="plot.svg">
190-
Force-directed network graph not supported
191-
</object>
192-
</div>
193-
</body>
194-
</html>"""
195-
)
190+
# Save outputs (theme-aware filenames)
191+
chart.render_to_file(f"plot-{THEME}.svg")
192+
chart.render_to_png(f"plot-{THEME}.png")
193+
194+
with open(f"plot-{THEME}.html", "wb") as f:
195+
f.write(chart.render())

0 commit comments

Comments
 (0)