|
1 | | -""" pyplots.ai |
| 1 | +""" anyplot.ai |
2 | 2 | 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 |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import os |
| 8 | + |
7 | 9 | import altair as alt |
8 | 10 | import numpy as np |
9 | 11 | import pandas as pd |
10 | 12 |
|
11 | 13 |
|
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" |
14 | 21 |
|
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) |
19 | 27 |
|
20 | | -# Create 3 communities |
21 | | -community_sizes = [18, 17, 15] # Total: 50 nodes |
| 28 | +community_sizes = [18, 17, 15] |
22 | 29 | community_names = ["Engineering", "Marketing", "Sales"] |
23 | | -node_id = 0 |
24 | 30 |
|
| 31 | +nodes = [] |
| 32 | +node_id = 0 |
25 | 33 | for comm_idx, size in enumerate(community_sizes): |
26 | 34 | for _ in range(size): |
27 | 35 | nodes.append({"id": node_id, "community": community_names[comm_idx]}) |
28 | 36 | node_id += 1 |
29 | 37 |
|
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)) |
56 | 45 |
|
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 |
58 | 52 | k = 0.5 |
59 | 53 | iterations = 200 |
60 | 54 |
|
61 | 55 | for iteration in range(iterations): |
62 | 56 | displacement = np.zeros((n, 2)) |
63 | | - |
64 | | - # Repulsive forces between all node pairs (nodes push apart) |
65 | 57 | for i in range(n): |
66 | 58 | for j in range(i + 1, n): |
67 | 59 | diff = positions[i] - positions[j] |
68 | 60 | 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 |
74 | 64 | for src, tgt in edges: |
75 | 65 | diff = positions[src] - positions[tgt] |
76 | 66 | 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 |
82 | 70 | temperature = 1 - iteration / iterations |
83 | 71 | for i in range(n): |
84 | 72 | disp_norm = np.linalg.norm(displacement[i]) |
85 | 73 | if disp_norm > 0: |
86 | | - # Limit movement by temperature |
87 | 74 | positions[i] += (displacement[i] / disp_norm) * min(disp_norm, 0.15 * temperature) |
88 | 75 |
|
89 | | -# Normalize positions to [0.05, 0.95] range |
90 | 76 | pos_min = positions.min(axis=0) |
91 | 77 | pos_max = positions.max(axis=0) |
92 | 78 | positions = (positions - pos_min) / (pos_max - pos_min + 1e-6) * 0.9 + 0.05 |
93 | 79 |
|
94 | | -# Calculate node degrees (number of connections) |
| 80 | +# Node-level summary |
95 | 81 | degrees = {node["id"]: 0 for node in nodes} |
96 | 82 | for src, tgt in edges: |
97 | 83 | degrees[src] += 1 |
98 | 84 | degrees[tgt] += 1 |
99 | 85 |
|
100 | | -# Create node dataframe with positions and attributes |
101 | 86 | node_df = pd.DataFrame( |
102 | 87 | { |
103 | 88 | "id": [node["id"] for node in nodes], |
|
107 | 92 | "degree": [degrees[node["id"]] for node in nodes], |
108 | 93 | } |
109 | 94 | ) |
110 | | - |
111 | | -# Scale node size by degree for visualization |
112 | 95 | node_df["size"] = node_df["degree"] * 30 + 200 |
113 | 96 |
|
114 | | -# Create edge dataframe for line segments |
| 97 | +# Edge segments (long-form, two rows per edge) |
115 | 98 | edge_data = [] |
116 | 99 | for src, tgt in edges: |
117 | 100 | edge_data.append({"edge_id": f"{src}-{tgt}", "x": positions[src][0], "y": positions[src][1], "order": 0}) |
118 | 101 | edge_data.append({"edge_id": f"{src}-{tgt}", "x": positions[tgt][0], "y": positions[tgt][1], "order": 1}) |
119 | 102 | edge_df = pd.DataFrame(edge_data) |
120 | 103 |
|
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) |
123 | 107 |
|
124 | | -# Create edges layer |
| 108 | +# Edges layer |
125 | 109 | edges_chart = ( |
126 | 110 | alt.Chart(edge_df) |
127 | | - .mark_line(strokeWidth=1.5, opacity=0.4) |
| 111 | + .mark_line(strokeWidth=1.4, opacity=0.55) |
128 | 112 | .encode( |
129 | 113 | x=alt.X("x:Q", axis=None), |
130 | 114 | y=alt.Y("y:Q", axis=None), |
131 | 115 | detail="edge_id:N", |
132 | 116 | order="order:O", |
133 | | - color=alt.value("#AAAAAA"), |
| 117 | + color=alt.value(EDGE_COLOR), |
134 | 118 | ) |
135 | 119 | ) |
136 | 120 |
|
137 | | -# Create nodes layer |
| 121 | +# Nodes layer |
138 | 122 | nodes_chart = ( |
139 | 123 | 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) |
141 | 125 | .encode( |
142 | 126 | x=alt.X("x:Q", axis=None), |
143 | 127 | 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])), |
145 | 129 | color=alt.Color( |
146 | 130 | "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), |
149 | 133 | ), |
150 | | - tooltip=["community:N", "degree:Q"], |
| 134 | + tooltip=[alt.Tooltip("community:N", title="Team"), alt.Tooltip("degree:Q", title="Connections")], |
151 | 135 | ) |
152 | 136 | ) |
153 | 137 |
|
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 |
158 | 139 | hub_labels = ( |
159 | 140 | 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) |
161 | 142 | .encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), text="label:N") |
162 | 143 | ) |
163 | 144 |
|
164 | | -# Combine all layers |
165 | 145 | chart = ( |
166 | 146 | (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 | + ) |
169 | 159 | ) |
170 | 160 |
|
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