Skip to content

Commit faf79ce

Browse files
feat(altair): implement network-directed (#2882)
## Implementation: `network-directed` - altair Implements the **altair** version of `network-directed`. **File:** `plots/network-directed/implementations/altair.py` **Parent Issue:** #2858 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20608486085)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 0f89a2c commit faf79ce

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
""" pyplots.ai
2+
network-directed: Directed Network Graph
3+
Library: altair 6.0.0 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-30
5+
"""
6+
7+
import altair as alt
8+
import numpy as np
9+
import pandas as pd
10+
11+
12+
np.random.seed(42)
13+
14+
# Data: Software package dependency graph
15+
nodes = [
16+
{"id": "app", "label": "App", "group": "main"},
17+
{"id": "api", "label": "API", "group": "core"},
18+
{"id": "auth", "label": "Auth", "group": "core"},
19+
{"id": "database", "label": "Database", "group": "core"},
20+
{"id": "cache", "label": "Cache", "group": "service"},
21+
{"id": "logger", "label": "Logger", "group": "util"},
22+
{"id": "config", "label": "Config", "group": "util"},
23+
{"id": "utils", "label": "Utils", "group": "util"},
24+
{"id": "models", "label": "Models", "group": "data"},
25+
{"id": "schemas", "label": "Schemas", "group": "data"},
26+
{"id": "router", "label": "Router", "group": "core"},
27+
{"id": "middleware", "label": "Middleware", "group": "core"},
28+
]
29+
30+
# Directed edges: (source, target) - arrows point from source to target
31+
# Includes some bidirectional pairs to demonstrate curved edge handling
32+
edges = [
33+
("app", "api"),
34+
("app", "auth"),
35+
("app", "router"),
36+
("api", "database"),
37+
("api", "cache"),
38+
("api", "models"),
39+
("auth", "database"),
40+
("auth", "cache"),
41+
("auth", "logger"),
42+
("database", "config"),
43+
("database", "logger"),
44+
("cache", "config"),
45+
("cache", "logger"),
46+
("router", "middleware"),
47+
("router", "api"),
48+
("middleware", "auth"),
49+
("middleware", "logger"),
50+
("models", "schemas"),
51+
("models", "utils"),
52+
("schemas", "utils"),
53+
("logger", "config"),
54+
("utils", "config"),
55+
# Bidirectional edges (mutual dependencies)
56+
("api", "auth"), # API also depends on Auth
57+
("cache", "database"), # Cache also depends on Database
58+
]
59+
60+
# Node positions using hierarchical layout based on dependency depth
61+
# Calculate depth for each node (topological sort-like approach)
62+
depths = {"app": 0}
63+
for _ in range(len(nodes)):
64+
for source, target in edges:
65+
if source in depths:
66+
current_depth = depths.get(target, -1)
67+
depths[target] = max(current_depth, depths[source] + 1)
68+
69+
# Assign default depth for any disconnected nodes
70+
for node in nodes:
71+
if node["id"] not in depths:
72+
depths[node["id"]] = 0
73+
74+
# Group nodes by depth for horizontal positioning
75+
depth_groups = {}
76+
for node_id, depth in depths.items():
77+
if depth not in depth_groups:
78+
depth_groups[depth] = []
79+
depth_groups[depth].append(node_id)
80+
81+
# Calculate positions
82+
positions = {}
83+
max_depth = max(depths.values()) if depths else 0
84+
for depth, node_ids in depth_groups.items():
85+
n_nodes = len(node_ids)
86+
for i, node_id in enumerate(node_ids):
87+
x = depth / max(max_depth, 1) # Normalize x to [0, 1]
88+
y = (i + 0.5) / n_nodes # Distribute vertically
89+
positions[node_id] = (x, y)
90+
91+
# Create node DataFrame
92+
node_df = pd.DataFrame(
93+
[
94+
{
95+
"id": n["id"],
96+
"label": n["label"],
97+
"group": n["group"],
98+
"x": positions[n["id"]][0],
99+
"y": positions[n["id"]][1],
100+
}
101+
for n in nodes
102+
]
103+
)
104+
105+
# Identify bidirectional edge pairs for curved edge handling
106+
edge_set = set(edges)
107+
bidirectional_pairs = set()
108+
for source, target in edges:
109+
if (target, source) in edge_set:
110+
bidirectional_pairs.add(tuple(sorted([source, target])))
111+
112+
# Create edge DataFrame with arrow coordinates
113+
# Use curves for bidirectional edges to avoid overlap
114+
edge_data = []
115+
curved_edge_data = []
116+
for source, target in edges:
117+
sx, sy = positions[source]
118+
tx, ty = positions[target]
119+
120+
# Check if this edge is part of a bidirectional pair
121+
is_bidirectional = tuple(sorted([source, target])) in bidirectional_pairs
122+
123+
# Shorten edge slightly so arrows don't overlap nodes
124+
dx, dy = tx - sx, ty - sy
125+
length = np.sqrt(dx**2 + dy**2)
126+
if length > 0:
127+
# Move endpoints slightly inward
128+
offset = 0.03
129+
sx_adj = sx + dx / length * offset
130+
sy_adj = sy + dy / length * offset
131+
tx_adj = tx - dx / length * offset
132+
ty_adj = ty - dy / length * offset
133+
else:
134+
sx_adj, sy_adj = sx, sy
135+
tx_adj, ty_adj = tx, ty
136+
137+
if is_bidirectional:
138+
# Create curved path using control points
139+
# Offset perpendicular to the edge direction
140+
perp_x, perp_y = -dy / length * 0.05, dx / length * 0.05
141+
mid_x, mid_y = (sx + tx) / 2 + perp_x, (sy + ty) / 2 + perp_y
142+
143+
# Generate points along a quadratic bezier curve
144+
for t in np.linspace(0, 1, 10):
145+
t_next = min(t + 0.1, 1)
146+
# Quadratic bezier formula
147+
bx1 = (1 - t) ** 2 * sx_adj + 2 * (1 - t) * t * mid_x + t**2 * tx_adj
148+
by1 = (1 - t) ** 2 * sy_adj + 2 * (1 - t) * t * mid_y + t**2 * ty_adj
149+
bx2 = (1 - t_next) ** 2 * sx_adj + 2 * (1 - t_next) * t_next * mid_x + t_next**2 * tx_adj
150+
by2 = (1 - t_next) ** 2 * sy_adj + 2 * (1 - t_next) * t_next * mid_y + t_next**2 * ty_adj
151+
curved_edge_data.append({"x": bx1, "y": by1, "x2": bx2, "y2": by2, "edge_id": f"{source}-{target}"})
152+
else:
153+
edge_data.append({"source": source, "target": target, "x": sx_adj, "y": sy_adj, "x2": tx_adj, "y2": ty_adj})
154+
155+
edge_df = pd.DataFrame(edge_data)
156+
curved_edge_df = pd.DataFrame(curved_edge_data) if curved_edge_data else pd.DataFrame(columns=["x", "y", "x2", "y2"])
157+
158+
# Create arrow head data (triangular markers at edge endpoints)
159+
arrow_data = []
160+
for source, target in edges:
161+
sx, sy = positions[source]
162+
tx, ty = positions[target]
163+
164+
dx, dy = tx - sx, ty - sy
165+
length = np.sqrt(dx**2 + dy**2)
166+
if length > 0:
167+
is_bidirectional = tuple(sorted([source, target])) in bidirectional_pairs
168+
169+
if is_bidirectional:
170+
# For curved edges, adjust arrow position and angle
171+
perp_x, perp_y = -dy / length * 0.05, dx / length * 0.05
172+
mid_x, mid_y = (sx + tx) / 2 + perp_x, (sy + ty) / 2 + perp_y
173+
174+
# Arrow tip position at end of curve (t=0.95)
175+
t = 0.95
176+
offset = 0.03
177+
sx_adj = sx + dx / length * offset
178+
sy_adj = sy + dy / length * offset
179+
tx_adj = tx - dx / length * offset
180+
ty_adj = ty - dy / length * offset
181+
182+
ax = (1 - t) ** 2 * sx_adj + 2 * (1 - t) * t * mid_x + t**2 * tx_adj
183+
ay = (1 - t) ** 2 * sy_adj + 2 * (1 - t) * t * mid_y + t**2 * ty_adj
184+
185+
# Tangent direction at arrow tip
186+
t_prev = 0.9
187+
ax_prev = (1 - t_prev) ** 2 * sx_adj + 2 * (1 - t_prev) * t_prev * mid_x + t_prev**2 * tx_adj
188+
ay_prev = (1 - t_prev) ** 2 * sy_adj + 2 * (1 - t_prev) * t_prev * mid_y + t_prev**2 * ty_adj
189+
angle = np.degrees(np.arctan2(ay - ay_prev, ax - ax_prev))
190+
else:
191+
# Arrow tip position (slightly before target node)
192+
offset = 0.04
193+
ax = tx - dx / length * offset
194+
ay = ty - dy / length * offset
195+
angle = np.degrees(np.arctan2(dy, dx))
196+
197+
arrow_data.append({"x": ax, "y": ay, "angle": angle})
198+
199+
arrow_df = pd.DataFrame(arrow_data)
200+
201+
# Color palette for groups
202+
group_colors = {
203+
"main": "#306998", # Python Blue
204+
"core": "#FFD43B", # Python Yellow
205+
"service": "#4ECDC4",
206+
"util": "#95A5A6",
207+
"data": "#E74C3C",
208+
}
209+
210+
# Add colors to node dataframe
211+
node_df["color"] = node_df["group"].map(group_colors)
212+
213+
# Create the visualization
214+
# Straight edges as rules (lines)
215+
edges_chart = (
216+
alt.Chart(edge_df)
217+
.mark_rule(strokeWidth=2, opacity=0.6, color="#666666")
218+
.encode(
219+
x=alt.X("x:Q", scale=alt.Scale(domain=[-0.1, 1.1]), axis=None),
220+
y=alt.Y("y:Q", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),
221+
x2="x2:Q",
222+
y2="y2:Q",
223+
)
224+
)
225+
226+
# Curved edges (for bidirectional connections)
227+
curved_edges_chart = (
228+
alt.Chart(curved_edge_df)
229+
.mark_rule(strokeWidth=2, opacity=0.6, color="#666666")
230+
.encode(
231+
x=alt.X("x:Q", scale=alt.Scale(domain=[-0.1, 1.1]), axis=None),
232+
y=alt.Y("y:Q", scale=alt.Scale(domain=[-0.05, 1.05]), axis=None),
233+
x2="x2:Q",
234+
y2="y2:Q",
235+
)
236+
)
237+
238+
# Arrow heads as triangular points
239+
arrows_chart = (
240+
alt.Chart(arrow_df)
241+
.mark_point(shape="triangle", size=150, filled=True, color="#666666", opacity=0.8)
242+
.encode(
243+
x=alt.X("x:Q", scale=alt.Scale(domain=[-0.1, 1.1])),
244+
y=alt.Y("y:Q", scale=alt.Scale(domain=[-0.05, 1.05])),
245+
angle=alt.Angle("angle:Q"),
246+
)
247+
)
248+
249+
# Nodes as circles
250+
nodes_chart = (
251+
alt.Chart(node_df)
252+
.mark_circle(size=800, stroke="#ffffff", strokeWidth=2)
253+
.encode(
254+
x=alt.X("x:Q", scale=alt.Scale(domain=[-0.1, 1.1])),
255+
y=alt.Y("y:Q", scale=alt.Scale(domain=[-0.05, 1.05])),
256+
color=alt.Color(
257+
"group:N",
258+
scale=alt.Scale(domain=list(group_colors.keys()), range=list(group_colors.values())),
259+
legend=alt.Legend(title="Module Type", titleFontSize=18, labelFontSize=16, orient="right"),
260+
),
261+
tooltip=["label:N", "group:N"],
262+
)
263+
)
264+
265+
# Node labels (fontSize 18 for better legibility at full resolution)
266+
labels_chart = (
267+
alt.Chart(node_df)
268+
.mark_text(fontSize=18, fontWeight="bold", dy=-28)
269+
.encode(
270+
x=alt.X("x:Q", scale=alt.Scale(domain=[-0.1, 1.1])),
271+
y=alt.Y("y:Q", scale=alt.Scale(domain=[-0.05, 1.05])),
272+
text="label:N",
273+
)
274+
)
275+
276+
# Combine all layers (include curved edges for bidirectional connections)
277+
chart = (
278+
(edges_chart + curved_edges_chart + arrows_chart + nodes_chart + labels_chart)
279+
.properties(
280+
width=1600,
281+
height=900,
282+
title=alt.Title(
283+
text="network-directed · altair · pyplots.ai",
284+
subtitle="Software Package Dependencies (curved edges show bidirectional dependencies)",
285+
fontSize=28,
286+
subtitleFontSize=18,
287+
anchor="middle",
288+
),
289+
)
290+
.configure_view(strokeWidth=0)
291+
)
292+
293+
# Save as PNG and HTML
294+
chart.save("plot.png", scale_factor=3.0)
295+
chart.save("plot.html")
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
library: altair
2+
specification_id: network-directed
3+
created: '2025-12-30T23:54:55Z'
4+
updated: '2025-12-31T00:11:25Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20608486085
7+
issue: 2858
8+
python_version: 3.13.11
9+
library_version: 6.0.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/network-directed/altair/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/network-directed/altair/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/network-directed/altair/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent hierarchical layout algorithm that calculates node depths based on topological
17+
ordering
18+
- Clever handling of bidirectional edges using curved bezier paths to avoid overlap
19+
- Clean color palette with five distinct, colorblind-safe colors for module groupings
20+
- Proper use of Altair layered chart composition (edges + curved edges + arrows
21+
+ nodes + labels)
22+
- Thoughtful arrow positioning using angle encoding for correct orientation along
23+
edges
24+
- Interactive tooltips showing node label and group on hover
25+
weaknesses:
26+
- Edge lines are relatively thin (strokeWidth=2) making some dependency paths harder
27+
to follow in the dense central area
28+
- Layout results in vertical clustering at certain depth levels creating visual
29+
congestion
30+
- Does not utilize Altair built-in interactivity features like selection highlighting
31+
or zoom/pan that would help explore the network

0 commit comments

Comments
 (0)