Skip to content

Commit b64626f

Browse files
feat(altair): implement sankey-basic (#5605)
## Implementation: `sankey-basic` - python/altair Implements the **python/altair** version of `sankey-basic`. **File:** `plots/sankey-basic/implementations/python/altair.py` **Parent Issue:** #810 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/25156438466)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent ce2a02e commit b64626f

2 files changed

Lines changed: 212 additions & 177 deletions

File tree

plots/sankey-basic/implementations/python/altair.py

Lines changed: 46 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
sankey-basic: Basic Sankey Diagram
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.13.13
4+
Quality: 82/100 | Updated: 2026-04-30
55
"""
66

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

1012

13+
# Theme tokens
14+
THEME = os.getenv("ANYPLOT_THEME", "light")
15+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
16+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
17+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
18+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
19+
1120
# Data - Energy flow from sources to sectors
1221
flows = [
1322
{"source": "Coal", "target": "Residential", "value": 20},
@@ -26,8 +35,7 @@
2635

2736
df = pd.DataFrame(flows)
2837

29-
# Target output: 4800x2700 px (16:9 aspect ratio) with scale_factor=3.0
30-
# Internal canvas: 1600x900 pixels
38+
# Canvas dimensions: 1600x900 internal → 4800x2700 px at scale_factor=3.0
3139
width = 1600
3240
height = 900
3341
node_width = 80
@@ -37,12 +45,10 @@
3745
sources = df["source"].unique().tolist()
3846
targets = df["target"].unique().tolist()
3947

40-
# Calculate totals for positioning
4148
source_totals = df.groupby("source")["value"].sum().to_dict()
4249
target_totals = df.groupby("target")["value"].sum().to_dict()
4350
total_flow = df["value"].sum()
4451

45-
# Available height for nodes - reserve space for title (top) and margins
4652
top_margin = 100
4753
bottom_margin = 60
4854
available_height = height - top_margin - bottom_margin
@@ -71,14 +77,19 @@
7177
target_positions[tgt] = {"y": current_y, "height": node_height}
7278
current_y += node_height + node_padding
7379

74-
# Color palettes - Python Blue (#306998) as primary, Yellow (#FFD43B) for accent
75-
source_colors = {"Coal": "#306998", "Gas": "#4A8BC6", "Nuclear": "#2D5986", "Renewable": "#FFD43B"}
80+
# Okabe-Ito palette for source colors — distinct, colorblind-safe
81+
source_colors = {
82+
"Coal": "#009E73", # Okabe-Ito #1 (brand green)
83+
"Gas": "#D55E00", # Okabe-Ito #2 (vermillion)
84+
"Nuclear": "#0072B2", # Okabe-Ito #3 (blue)
85+
"Renewable": "#CC79A7", # Okabe-Ito #4 (reddish purple)
86+
}
7687

77-
target_colors = {"Residential": "#4ECDC4", "Commercial": "#95E1D3", "Industrial": "#FF6B6B", "Transport": "#FFA07A"}
88+
# Target node colors — muted, distinct from source palette
89+
target_colors = {"Residential": "#7EC8C8", "Commercial": "#A8D8A8", "Industrial": "#E8C07A", "Transport": "#C8A8E8"}
7890

79-
# Create node rectangles data
91+
# Build node rectangles data
8092
nodes_data = []
81-
8293
for src in sources:
8394
pos = source_positions[src]
8495
nodes_data.append(
@@ -115,12 +126,10 @@
115126

116127
nodes_df = pd.DataFrame(nodes_data)
117128

118-
# Create flow paths using polygons
119-
# Track current position within each node for stacking flows
129+
# Generate smoothstep S-curve polygon points for each flow band
120130
source_y_offsets = {src: source_positions[src]["y"] for src in sources}
121131
target_y_offsets = {tgt: target_positions[tgt]["y"] for tgt in targets}
122132

123-
# Generate polygon points for each flow (closed path)
124133
all_flow_data = []
125134
num_curve_points = 40
126135

@@ -129,34 +138,28 @@
129138
tgt = row["target"]
130139
val = row["value"]
131140

132-
# Flow height proportional to value within each node
133141
src_height = (val / source_totals[src]) * source_positions[src]["height"]
134142
tgt_height = (val / target_totals[tgt]) * target_positions[tgt]["height"]
135143

136-
# Start and end Y positions for this flow band
137144
src_y_top = source_y_offsets[src]
138145
src_y_bottom = src_y_top + src_height
139146
tgt_y_top = target_y_offsets[tgt]
140147
tgt_y_bottom = tgt_y_top + tgt_height
141148

142-
# Update offsets for stacking next flow from same source/target
143149
source_y_offsets[src] += src_height
144150
target_y_offsets[tgt] += tgt_height
145151

146152
x_start = node_width
147153
x_end = width - node_width
148154

149-
# Generate top curve points (left to right) using smoothstep interpolation
150155
top_points = []
151156
for i in range(num_curve_points):
152157
t = i / (num_curve_points - 1)
153158
x = x_start + t * (x_end - x_start)
154-
# Smoothstep creates smooth S-curve for natural flow appearance
155159
bezier_t = t * t * (3 - 2 * t)
156160
y = src_y_top + bezier_t * (tgt_y_top - src_y_top)
157161
top_points.append((x, y))
158162

159-
# Generate bottom curve points (right to left to close the polygon)
160163
bottom_points = []
161164
for i in range(num_curve_points - 1, -1, -1):
162165
t = i / (num_curve_points - 1)
@@ -165,7 +168,6 @@
165168
y = src_y_bottom + bezier_t * (tgt_y_bottom - src_y_bottom)
166169
bottom_points.append((x, y))
167170

168-
# Combine top + bottom into closed polygon for filled area rendering
169171
all_points = top_points + bottom_points
170172
for pt_idx, (x, y) in enumerate(all_points):
171173
all_flow_data.append(
@@ -174,7 +176,7 @@
174176

175177
flows_df = pd.DataFrame(all_flow_data)
176178

177-
# Create flow polygons using mark_line with filled=True
179+
# Flow polygons colored by source
178180
links_chart = (
179181
alt.Chart(flows_df)
180182
.mark_line(filled=True, opacity=0.55, strokeWidth=0)
@@ -184,24 +186,17 @@
184186
color=alt.Color(
185187
"source:N",
186188
scale=alt.Scale(domain=list(source_colors.keys()), range=list(source_colors.values())),
187-
legend=alt.Legend(
188-
title="Energy Source",
189-
titleFontSize=18,
190-
labelFontSize=16,
191-
orient="bottom-right",
192-
titleColor="#333333",
193-
labelColor="#333333",
194-
),
189+
legend=alt.Legend(title="Energy Source", titleFontSize=18, labelFontSize=16, orient="bottom-right"),
195190
),
196191
detail="flow_id:N",
197192
order="order:Q",
198193
)
199194
)
200195

201-
# Create node rectangles
196+
# Node rectangles
202197
nodes_chart = (
203198
alt.Chart(nodes_df)
204-
.mark_rect(stroke="#333333", strokeWidth=2)
199+
.mark_rect(stroke=INK_SOFT, strokeWidth=2)
205200
.encode(
206201
x=alt.X("x:Q", scale=alt.Scale(domain=[0, width])),
207202
y=alt.Y("y:Q", scale=alt.Scale(domain=[0, height])),
@@ -212,51 +207,55 @@
212207
)
213208
)
214209

215-
# Create source labels (right-aligned to the left of nodes)
210+
# Source labels (right of left nodes)
216211
source_labels_df = nodes_df[nodes_df["side"] == "source"]
217212
source_labels = (
218213
alt.Chart(source_labels_df)
219-
.mark_text(fontSize=20, fontWeight="bold", color="#333333", align="left", baseline="middle")
214+
.mark_text(fontSize=20, fontWeight="bold", align="left", baseline="middle")
220215
.encode(
221216
x=alt.X("label_x:Q", scale=alt.Scale(domain=[0, width])),
222217
y=alt.Y("label_y:Q", scale=alt.Scale(domain=[0, height])),
223218
text="name:N",
219+
color=alt.value(INK),
224220
)
225221
)
226222

227-
# Create target labels (left-aligned to the right of nodes)
223+
# Target labels (left of right nodes)
228224
target_labels_df = nodes_df[nodes_df["side"] == "target"]
229225
target_labels = (
230226
alt.Chart(target_labels_df)
231-
.mark_text(fontSize=20, fontWeight="bold", color="#333333", align="right", baseline="middle")
227+
.mark_text(fontSize=20, fontWeight="bold", align="right", baseline="middle")
232228
.encode(
233229
x=alt.X("label_x:Q", scale=alt.Scale(domain=[0, width])),
234230
y=alt.Y("label_y:Q", scale=alt.Scale(domain=[0, height])),
235231
text="name:N",
232+
color=alt.value(INK),
236233
)
237234
)
238235

239-
# Combine all layers
236+
# Compose all layers with theme-adaptive chrome
240237
chart = (
241238
alt.layer(links_chart, nodes_chart, source_labels, target_labels)
242239
.properties(
243240
width=width,
244241
height=height,
242+
background=PAGE_BG,
245243
title=alt.Title(
246-
text="sankey-basic · altair · pyplots.ai",
244+
text="sankey-basic · altair · anyplot.ai",
247245
subtitle="Energy Flow from Sources to Sectors",
248246
fontSize=28,
249247
subtitleFontSize=20,
250248
anchor="middle",
251-
color="#333333",
252-
subtitleColor="#666666",
249+
color=INK,
250+
subtitleColor=INK_SOFT,
253251
),
254-
autosize=alt.AutoSizeParams(type="fit", contains="padding"),
255252
)
256-
.configure_view(strokeWidth=0)
257-
.configure_legend(padding=15, cornerRadius=5, fillColor="#FFFFFF", strokeColor="#DDDDDD")
253+
.configure_view(strokeWidth=0, fill=PAGE_BG)
254+
.configure_legend(
255+
padding=15, cornerRadius=5, fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK
256+
)
258257
)
259258

260-
# Save as PNG (4800x2700 px with scale_factor=3.0) and HTML
261-
chart.save("plot.png", scale_factor=3.0)
262-
chart.save("plot.html")
259+
# Save outputs (PNG at 4800×2700 px + HTML for interactivity)
260+
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
261+
chart.save(f"plot-{THEME}.html")

0 commit comments

Comments
 (0)