|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
7 | | -import numpy as np |
8 | | -import pygal |
9 | | -from pygal.style import Style |
| 7 | +import sys |
| 8 | +from pathlib import Path |
10 | 9 |
|
11 | 10 |
|
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 |
13 | 33 | np.random.seed(42) |
14 | 34 |
|
15 | | -# Data: A social network with 50 nodes in 3 communities |
| 35 | +# Data: A corporate social network with 50 nodes in 3 departments |
16 | 36 | # Demonstrates force-directed layout with clear community structure |
17 | 37 | nodes = [] |
18 | 38 | edges = [] |
19 | 39 |
|
20 | | -# Create 3 communities |
21 | 40 | community_sizes = [18, 17, 15] # Total: 50 nodes |
22 | 41 | community_names = ["Engineering", "Marketing", "Sales"] |
23 | 42 | node_id = 0 |
|
50 | 69 | bridge_edges = [(0, 18), (5, 20), (10, 25), (18, 35), (22, 40), (30, 45), (8, 38), (15, 48)] |
51 | 70 | edges.extend(bridge_edges) |
52 | 71 |
|
53 | | -# Force-directed layout algorithm (Fruchterman-Reingold) |
| 72 | +# Force-directed layout (Fruchterman-Reingold) |
54 | 73 | n = len(nodes) |
55 | 74 | positions = np.random.rand(n, 2) * 2 - 1 # Initial random positions |
56 | 75 |
|
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 |
60 | 78 |
|
61 | 79 | for iteration in range(iterations): |
62 | 80 | displacement = np.zeros((n, 2)) |
63 | 81 |
|
64 | | - # Repulsive forces between all node pairs (nodes push apart) |
| 82 | + # Repulsive forces between all node pairs |
65 | 83 | for i in range(n): |
66 | 84 | for j in range(i + 1, n): |
67 | 85 | diff = positions[i] - positions[j] |
|
70 | 88 | displacement[i] += repulsive_force |
71 | 89 | displacement[j] -= repulsive_force |
72 | 90 |
|
73 | | - # Attractive forces along edges (connected nodes pull together) |
| 91 | + # Attractive forces along edges |
74 | 92 | for src, tgt in edges: |
75 | 93 | diff = positions[src] - positions[tgt] |
76 | 94 | dist = max(np.linalg.norm(diff), 0.01) |
77 | 95 | attractive_force = (dist * dist / k) * (diff / dist) |
78 | 96 | displacement[src] -= attractive_force |
79 | 97 | displacement[tgt] += attractive_force |
80 | 98 |
|
81 | | - # Apply displacement with cooling (decreasing temperature) |
| 99 | + # Apply displacement with cooling |
82 | 100 | temperature = 1 - iteration / iterations |
83 | 101 | for i in range(n): |
84 | 102 | disp_norm = np.linalg.norm(displacement[i]) |
85 | 103 | if disp_norm > 0: |
86 | | - # Limit movement by temperature |
87 | 104 | positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature) |
88 | 105 |
|
89 | | -# Normalize positions to [1, 11] range for pygal (with padding) |
| 106 | +# Normalize positions to a padded plotting range |
90 | 107 | pos_min = positions.min(axis=0) |
91 | 108 | pos_max = positions.max(axis=0) |
92 | 109 | positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 10 + 1 |
93 | 110 | pos = {node["id"]: positions[i] for i, node in enumerate(nodes)} |
94 | 111 |
|
95 | | -# Calculate node degrees (number of connections) |
| 112 | +# Node degrees (for tooltip context) |
96 | 113 | degrees = {node["id"]: 0 for node in nodes} |
97 | 114 | for src, tgt in edges: |
98 | 115 | degrees[src] += 1 |
99 | 116 | degrees[tgt] += 1 |
100 | 117 |
|
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 |
103 | 121 |
|
104 | | -# Custom style for the chart |
105 | 122 | 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, |
112 | 129 | title_font_size=72, |
113 | 130 | label_font_size=40, |
114 | 131 | major_label_font_size=36, |
115 | | - legend_font_size=40, |
| 132 | + legend_font_size=44, |
116 | 133 | value_font_size=32, |
117 | 134 | stroke_width=2, |
118 | | - opacity=0.85, |
| 135 | + opacity=0.9, |
119 | 136 | opacity_hover=1.0, |
| 137 | + tooltip_font_size=28, |
| 138 | + font_family="DejaVu Sans, Helvetica, Arial, sans-serif", |
120 | 139 | ) |
121 | 140 |
|
122 | | -# Create XY chart |
123 | 141 | chart = pygal.XY( |
124 | 142 | width=4800, |
125 | 143 | height=2700, |
126 | 144 | style=custom_style, |
127 | | - title="network-force-directed · pygal · pyplots.ai", |
| 145 | + title="network-force-directed · pygal · anyplot.ai", |
128 | 146 | show_legend=True, |
129 | | - x_title="", |
130 | | - y_title="", |
131 | 147 | show_x_guides=False, |
132 | 148 | show_y_guides=False, |
133 | 149 | show_x_labels=False, |
134 | 150 | show_y_labels=False, |
135 | 151 | stroke=True, |
136 | | - dots_size=25, |
| 152 | + dots_size=28, |
137 | 153 | stroke_style={"width": 1.5, "linecap": "round"}, |
138 | 154 | legend_at_bottom=True, |
139 | 155 | legend_at_bottom_columns=4, |
| 156 | + legend_box_size=36, |
| 157 | + margin=80, |
140 | 158 | range=(0, 12), |
141 | 159 | xrange=(0, 12), |
142 | 160 | ) |
143 | 161 |
|
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 |
146 | 163 | edge_points = [] |
147 | 164 | for src, tgt in edges: |
148 | 165 | x1, y1 = pos[src] |
149 | 166 | x2, y2 = pos[tgt] |
150 | 167 | edge_points.append((x1, y1)) |
151 | 168 | edge_points.append((x2, y2)) |
152 | | - edge_points.append(None) # Break the line for next edge |
| 169 | + edge_points.append(None) |
153 | 170 |
|
154 | 171 | chart.add("Connections", edge_points, stroke=True, show_dots=False, fill=False) |
155 | 172 |
|
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 |
157 | 177 | for comm_idx, comm_name in enumerate(community_names): |
158 | 178 | comm_nodes = [node for node in nodes if node["community"] == comm_idx] |
159 | | - # Create points with labels for tooltips showing degree |
160 | 179 | node_points = [] |
161 | 180 | for node in comm_nodes: |
162 | 181 | x, y = pos[node["id"]] |
163 | 182 | degree = degrees[node["id"]] |
| 183 | + radius = min_radius + (max_radius - min_radius) * (degree / max_degree) |
164 | 184 | label = f"Node {node['id']} | {degree} connections" |
165 | 185 | if degree >= 7: |
166 | 186 | 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)}}) |
168 | 188 | chart.add(comm_name, node_points, stroke=False) |
169 | 189 |
|
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