Skip to content

Commit ce2a02e

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

2 files changed

Lines changed: 232 additions & 230 deletions

File tree

Lines changed: 65 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,152 +1,129 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
sankey-basic: Basic Sankey Diagram
3-
Library: highcharts unknown | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: highcharts unknown | Python 3.13.13
4+
Quality: 88/100 | Updated: 2026-04-30
55
"""
66

7+
import os
78
import tempfile
89
import time
910
import urllib.request
1011
from pathlib import Path
1112

12-
import numpy as np
1313
from highcharts_core.chart import Chart
1414
from highcharts_core.options import HighchartsOptions
15-
from PIL import Image
1615
from selenium import webdriver
1716
from selenium.webdriver.chrome.options import Options
1817

1918

20-
# Reproducibility
21-
np.random.seed(42)
19+
# Theme tokens
20+
THEME = os.getenv("ANYPLOT_THEME", "light")
21+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
22+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
23+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
2224

23-
# Data - Energy flow from sources to sectors (values in TWh - Terawatt-hours)
24-
# Format: [source, target, value]
25+
# Data - U.S. Energy flow from sources to sectors (values in TWh)
2526
flows = [
26-
# Coal flows
2727
["Coal", "Electricity", 150],
2828
["Coal", "Industrial", 80],
29-
# Natural Gas flows
3029
["Natural Gas", "Electricity", 120],
3130
["Natural Gas", "Residential", 90],
3231
["Natural Gas", "Commercial", 60],
3332
["Natural Gas", "Industrial", 50],
34-
# Nuclear flows
3533
["Nuclear", "Electricity", 200],
36-
# Petroleum flows
3734
["Petroleum", "Transportation", 280],
3835
["Petroleum", "Industrial", 70],
3936
["Petroleum", "Residential", 30],
40-
# Renewable flows
4137
["Renewable", "Electricity", 100],
4238
["Renewable", "Transportation", 20],
43-
# Electricity flows to end uses
4439
["Electricity", "Residential", 180],
4540
["Electricity", "Commercial", 160],
4641
["Electricity", "Industrial", 140],
4742
]
4843

49-
# Collect unique nodes
50-
nodes_set = set()
51-
for source, target, _ in flows:
52-
nodes_set.add(source)
53-
nodes_set.add(target)
54-
nodes = list(nodes_set)
44+
# Node colors - Okabe-Ito palette (canonical order for source nodes)
45+
OKABE_ITO = ["#009E73", "#D55E00", "#0072B2", "#CC79A7", "#E69F00", "#56B4E9", "#F0E442"]
5546

56-
# Colorblind-safe colors for nodes - all dark enough for white text contrast
5747
node_colors = {
58-
# Sources (energy sources) - dark tones for white text readability
59-
"Coal": "#1A3A5C", # Dark Blue
60-
"Natural Gas": "#6B4E12", # Darker Goldenrod (darkened for contrast)
61-
"Nuclear": "#5B2E8F", # Dark Purple
62-
"Petroleum": "#0A6B78", # Dark Cyan
63-
"Renewable": "#155415", # Dark Green
64-
# Intermediate - dark for contrast
65-
"Electricity": "#4D2A22", # Dark Brown
66-
# End uses - darker shades for white text visibility
67-
"Residential": "#8B3A6B", # Dark Rose
68-
"Commercial": "#3A3A3A", # Darker Gray
69-
"Industrial": "#5B5C0A", # Dark Olive
70-
"Transportation": "#994D00", # Darker Orange
48+
"Coal": OKABE_ITO[0],
49+
"Natural Gas": OKABE_ITO[1],
50+
"Nuclear": OKABE_ITO[2],
51+
"Petroleum": OKABE_ITO[3],
52+
"Renewable": OKABE_ITO[4],
53+
"Electricity": OKABE_ITO[5],
54+
"Transportation": OKABE_ITO[6],
55+
"Industrial": OKABE_ITO[0],
56+
"Residential": OKABE_ITO[1],
57+
"Commercial": OKABE_ITO[2],
7158
}
7259

73-
# Create nodes data with colors
74-
nodes_data = [{"id": node, "name": node, "color": node_colors.get(node, "#306998")} for node in nodes]
60+
nodes_set = set()
61+
for source, target, _ in flows:
62+
nodes_set.add(source)
63+
nodes_set.add(target)
7564

76-
# Create links data
65+
nodes_data = [{"id": node, "name": node, "color": node_colors.get(node, OKABE_ITO[0])} for node in nodes_set]
7766
links_data = [{"from": source, "to": target, "weight": value} for source, target, value in flows]
7867

79-
# Create chart
68+
# Chart
8069
chart = Chart(container="container")
8170
chart.options = HighchartsOptions()
8271

83-
# Chart configuration with margins to prevent label cutoff at edges
8472
chart.options.chart = {
8573
"type": "sankey",
8674
"width": 4800,
8775
"height": 2700,
88-
"backgroundColor": "#ffffff",
76+
"backgroundColor": PAGE_BG,
8977
"marginLeft": 180,
9078
"marginRight": 180,
9179
"marginTop": 160,
92-
"marginBottom": 80,
80+
"marginBottom": 160,
9381
}
9482

95-
# Title
9683
chart.options.title = {
97-
"text": "sankey-basic · highcharts · pyplots.ai",
98-
"style": {"fontSize": "64px", "fontWeight": "bold", "color": "#333333"},
84+
"text": "sankey-basic · highcharts · anyplot.ai",
85+
"style": {"fontSize": "64px", "fontWeight": "bold", "color": INK},
9986
}
10087

101-
# Subtitle with units info
102-
chart.options.subtitle = {"text": "U.S. Energy Flow (values in TWh)", "style": {"fontSize": "40px", "color": "#666666"}}
88+
chart.options.subtitle = {"text": "U.S. Energy Flow (values in TWh)", "style": {"fontSize": "40px", "color": INK_SOFT}}
10389

104-
# Tooltip with units
10590
chart.options.tooltip = {
10691
"style": {"fontSize": "36px"},
10792
"nodeFormat": "{point.name}: {point.sum} TWh",
10893
"pointFormat": "{point.fromNode.name} → {point.toNode.name}: {point.weight} TWh",
10994
}
11095

111-
# Sankey series configuration
112-
series_config = {
113-
"type": "sankey",
114-
"name": "Energy Flow",
115-
"keys": ["from", "to", "weight"],
116-
"nodes": nodes_data,
117-
"data": links_data,
118-
"dataLabels": {
119-
"enabled": True,
120-
"style": {"fontSize": "36px", "fontWeight": "bold", "color": "#FFFFFF", "textOutline": "3px #333333"},
121-
"nodeFormat": "{point.name}",
122-
},
123-
"nodeWidth": 50,
124-
"nodePadding": 35,
125-
"linkOpacity": 0.5,
126-
"curveFactor": 0.5,
127-
"colorByPoint": True,
128-
"linkColorMode": "from",
129-
}
130-
131-
chart.options.series = [series_config]
96+
chart.options.series = [
97+
{
98+
"type": "sankey",
99+
"name": "Energy Flow",
100+
"keys": ["from", "to", "weight"],
101+
"nodes": nodes_data,
102+
"data": links_data,
103+
"dataLabels": {
104+
"enabled": True,
105+
"style": {"fontSize": "36px", "fontWeight": "bold", "color": "#FFFFFF", "textOutline": "3px #333333"},
106+
"nodeFormat": "{point.name}",
107+
},
108+
"nodeWidth": 50,
109+
"nodePadding": 35,
110+
"linkOpacity": 0.5,
111+
"curveFactor": 0.5,
112+
"colorByPoint": True,
113+
"linkColorMode": "from",
114+
}
115+
]
132116

133-
# Disable legend for sankey (nodes are labeled)
134117
chart.options.legend = {"enabled": False}
135-
136-
# Disable credits
137118
chart.options.credits = {"enabled": False}
138119

139-
# Download Highcharts JS and sankey module
140-
highcharts_url = "https://code.highcharts.com/highcharts.js"
141-
sankey_url = "https://code.highcharts.com/modules/sankey.js"
142-
143-
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
120+
# Download Highcharts JS and sankey module inline (required for headless Chrome)
121+
with urllib.request.urlopen("https://cdn.jsdelivr.net/npm/highcharts@latest/highcharts.js", timeout=30) as response:
144122
highcharts_js = response.read().decode("utf-8")
145123

146-
with urllib.request.urlopen(sankey_url, timeout=30) as response:
124+
with urllib.request.urlopen("https://cdn.jsdelivr.net/npm/highcharts@latest/modules/sankey.js", timeout=30) as response:
147125
sankey_js = response.read().decode("utf-8")
148126

149-
# Generate HTML with inline scripts
150127
html_str = chart.to_js_literal()
151128
html_content = f"""<!DOCTYPE html>
152129
<html>
@@ -155,30 +132,17 @@
155132
<script>{highcharts_js}</script>
156133
<script>{sankey_js}</script>
157134
</head>
158-
<body style="margin:0;">
159-
<div id="container" style="width: 4800px; height: 2700px;"></div>
160-
<script>{html_str}</script>
161-
</body>
162-
</html>"""
163-
164-
# Save HTML for interactive version (use CDN for standalone) - fixed dimensions like PNG
165-
standalone_html = f"""<!DOCTYPE html>
166-
<html>
167-
<head>
168-
<meta charset="utf-8">
169-
<script src="https://code.highcharts.com/highcharts.js"></script>
170-
<script src="https://code.highcharts.com/modules/sankey.js"></script>
171-
</head>
172-
<body style="margin:0; overflow:auto;">
135+
<body style="margin:0; background:{PAGE_BG};">
173136
<div id="container" style="width: 4800px; height: 2700px;"></div>
174137
<script>{html_str}</script>
175138
</body>
176139
</html>"""
177140

178-
with open("plot.html", "w", encoding="utf-8") as f:
179-
f.write(standalone_html)
141+
# Save HTML artifact
142+
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
143+
f.write(html_content)
180144

181-
# Write temp HTML and take screenshot
145+
# Screenshot via headless Chrome
182146
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
183147
f.write(html_content)
184148
temp_path = f.name
@@ -188,18 +152,12 @@
188152
chrome_options.add_argument("--no-sandbox")
189153
chrome_options.add_argument("--disable-dev-shm-usage")
190154
chrome_options.add_argument("--disable-gpu")
191-
chrome_options.add_argument("--window-size=4800,2900")
155+
chrome_options.add_argument("--window-size=4800,2700")
192156

193157
driver = webdriver.Chrome(options=chrome_options)
194158
driver.get(f"file://{temp_path}")
195-
time.sleep(5) # Wait for chart to render
196-
driver.save_screenshot("plot_raw.png")
159+
time.sleep(5)
160+
driver.save_screenshot(f"plot-{THEME}.png")
197161
driver.quit()
198162

199-
# Crop to exact 4800x2700 dimensions
200-
img = Image.open("plot_raw.png")
201-
img_cropped = img.crop((0, 0, 4800, 2700))
202-
img_cropped.save("plot.png")
203-
Path("plot_raw.png").unlink()
204-
205-
Path(temp_path).unlink() # Clean up temp file
163+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)