Skip to content

Commit c5ffc62

Browse files
feat(highcharts): implement dumbbell-basic (#5423)
## Implementation: `dumbbell-basic` - python/highcharts Implements the **python/highcharts** version of `dumbbell-basic`. **File:** `plots/dumbbell-basic/implementations/python/highcharts.py` **Parent Issue:** #945 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/24945414160)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent a113900 commit c5ffc62

2 files changed

Lines changed: 363 additions & 202 deletions

File tree

Lines changed: 189 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
""" pyplots.ai
1+
""" anyplot.ai
22
dumbbell-basic: Basic Dumbbell Chart
3-
Library: highcharts unknown | Python 3.13.11
4-
Quality: 91/100 | Created: 2025-12-23
3+
Library: highcharts unknown | Python 3.14.4
4+
Quality: 85/100 | Updated: 2026-04-26
55
"""
66

7+
import base64
78
import json
9+
import os
810
import tempfile
911
import time
1012
import urllib.request
@@ -14,7 +16,20 @@
1416
from selenium.webdriver.chrome.options import Options
1517

1618

17-
# Data - Employee satisfaction scores before and after policy changes
19+
THEME = os.getenv("ANYPLOT_THEME", "light")
20+
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
21+
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
22+
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
23+
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
24+
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
25+
GRID = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"
26+
CONNECTOR = "rgba(26,26,23,0.40)" if THEME == "light" else "rgba(240,239,232,0.40)"
27+
28+
BEFORE_COLOR = "#009E73" # Okabe-Ito 1 — brand
29+
AFTER_COLOR = "#D55E00" # Okabe-Ito 2
30+
31+
# Employee satisfaction scores (0-100) before and after policy changes.
32+
# Mix of strong gains, modest gains, and two declines for full coverage.
1833
categories = [
1934
"Engineering",
2035
"Sales",
@@ -23,90 +38,186 @@
2338
"Human Resources",
2439
"Finance",
2540
"Operations",
26-
"Research & Development",
41+
"Research & Dev",
42+
"Legal",
43+
"IT Support",
2744
]
28-
before_scores = [65, 58, 72, 45, 68, 61, 53, 70]
29-
after_scores = [82, 75, 85, 78, 80, 73, 71, 88]
45+
before_scores = [65, 58, 72, 45, 68, 61, 53, 70, 76, 64]
46+
after_scores = [82, 75, 85, 78, 80, 73, 71, 88, 68, 59]
3047

31-
# Sort by the magnitude of change (descending) to reveal patterns
32-
changes = [after - before for before, after in zip(before_scores, after_scores, strict=True)]
33-
sorted_data = sorted(
34-
zip(categories, before_scores, after_scores, changes, strict=True), key=lambda x: x[3], reverse=True
35-
)
36-
categories = [item[0] for item in sorted_data]
37-
before_scores = [item[1] for item in sorted_data]
38-
after_scores = [item[2] for item in sorted_data]
48+
# Sort by improvement (descending): biggest gains on top, declines at bottom.
49+
changes = [a - b for b, a in zip(before_scores, after_scores, strict=True)]
50+
order = sorted(range(len(categories)), key=lambda i: changes[i], reverse=True)
51+
categories = [categories[i] for i in order]
52+
before_scores = [before_scores[i] for i in order]
53+
after_scores = [after_scores[i] for i in order]
54+
changes = [changes[i] for i in order]
3955

40-
# Prepare data for dumbbell chart
56+
# Build dumbbell points. To keep the green=before / orange=after semantics
57+
# stable even when satisfaction drops (low marker is the smaller value, so
58+
# "after" lands on it), we override per-point colors when before > after.
4159
dumbbell_data = []
4260
for i, (before, after) in enumerate(zip(before_scores, after_scores, strict=True)):
43-
dumbbell_data.append({"x": i, "low": before, "high": after})
61+
low = min(before, after)
62+
high = max(before, after)
63+
if before <= after:
64+
dumbbell_data.append({"x": i, "low": low, "high": high})
65+
else:
66+
dumbbell_data.append(
67+
{
68+
"x": i,
69+
"low": low,
70+
"high": high,
71+
"lowColor": AFTER_COLOR, # smaller value here is "after"
72+
"color": BEFORE_COLOR, # larger value here is "before"
73+
}
74+
)
4475

45-
# Chart options for horizontal dumbbell chart
4676
chart_options = {
4777
"chart": {
4878
"type": "dumbbell",
49-
"inverted": True, # Horizontal orientation
79+
"inverted": True,
5080
"width": 4800,
5181
"height": 2700,
52-
"backgroundColor": "#ffffff",
53-
"marginLeft": 400,
54-
"marginBottom": 150,
55-
"style": {"fontFamily": "Arial, sans-serif"},
82+
"backgroundColor": PAGE_BG,
83+
"marginLeft": 520,
84+
"marginRight": 220,
85+
"marginTop": 320,
86+
"marginBottom": 200,
87+
"spacingTop": 60,
88+
"style": {"fontFamily": "Arial, sans-serif", "color": INK},
5689
},
5790
"title": {
58-
"text": "Employee Satisfaction Before/After · dumbbell-basic · highcharts · pyplots.ai",
59-
"style": {"fontSize": "52px", "fontWeight": "bold"},
91+
"text": "dumbbell-basic · highcharts · anyplot.ai",
92+
"align": "left",
93+
"x": 80,
94+
"style": {"fontSize": "56px", "fontWeight": "500", "color": INK},
6095
},
6196
"subtitle": {
62-
"text": "Satisfaction scores before and after policy changes by department",
63-
"style": {"fontSize": "34px", "color": "#666666"},
97+
"text": "Employee satisfaction scores before and after policy changes (by department)",
98+
"align": "left",
99+
"x": 80,
100+
"style": {"fontSize": "30px", "color": INK_SOFT},
64101
},
65102
"xAxis": {
66103
"categories": categories,
67-
"title": {"text": None}, # Categories are self-explanatory
68-
"labels": {"style": {"fontSize": "32px"}},
104+
"title": {"text": None},
105+
"labels": {"style": {"fontSize": "32px", "color": INK}},
106+
"lineColor": INK_SOFT,
107+
"tickColor": INK_SOFT,
108+
"tickWidth": 0,
109+
"gridLineWidth": 0,
110+
"minPadding": 0.05,
111+
"maxPadding": 0.05,
112+
"startOnTick": False,
113+
"endOnTick": False,
69114
},
70115
"yAxis": {
71-
"min": 40,
116+
"min": 35,
72117
"max": 95,
73-
"title": {"text": "Satisfaction Score", "style": {"fontSize": "36px"}},
74-
"labels": {"style": {"fontSize": "28px"}},
75-
"gridLineColor": "#e0e0e0",
76-
"gridLineDashStyle": "Dash",
118+
"tickInterval": 10,
119+
"title": {"text": "Satisfaction Score (0–100)", "style": {"fontSize": "36px", "color": INK}, "margin": 50},
120+
"labels": {"style": {"fontSize": "30px", "color": INK_SOFT}},
121+
"lineColor": INK_SOFT,
122+
"tickColor": INK_SOFT,
123+
"gridLineColor": GRID,
124+
"gridLineWidth": 1,
125+
"opposite": False,
77126
},
78127
"legend": {
79128
"enabled": True,
80129
"align": "right",
81130
"verticalAlign": "top",
82-
"layout": "vertical",
83-
"x": -50,
84-
"y": 100,
85-
"itemStyle": {"fontSize": "28px"},
86-
"symbolHeight": 20,
87-
"symbolWidth": 40,
131+
"layout": "horizontal",
132+
"x": -20,
133+
"y": 80,
134+
"itemStyle": {"fontSize": "32px", "color": INK, "fontWeight": "500"},
135+
"itemDistance": 70,
136+
"backgroundColor": ELEVATED_BG,
137+
"borderColor": GRID,
138+
"borderWidth": 1,
139+
"borderRadius": 6,
140+
"padding": 22,
141+
"symbolHeight": 30,
142+
"symbolWidth": 30,
143+
"symbolRadius": 15,
88144
},
89145
"plotOptions": {
90146
"dumbbell": {
91-
"connectorWidth": 5,
92-
"connectorColor": "#888888",
93-
"lowColor": "#306998", # Python Blue for "before"
94-
"color": "#FFD43B", # Python Yellow for "after"
95-
"marker": {"radius": 18},
96-
"dataLabels": {
97-
"enabled": True,
98-
"style": {"fontSize": "24px", "fontWeight": "bold", "textOutline": "none"},
99-
"y": 0,
100-
},
101-
}
147+
"connectorWidth": 6,
148+
"connectorColor": CONNECTOR,
149+
"lowColor": BEFORE_COLOR,
150+
"color": AFTER_COLOR,
151+
"marker": {"radius": 24, "lineWidth": 3, "lineColor": PAGE_BG},
152+
"lowMarker": {"radius": 24, "lineWidth": 3, "lineColor": PAGE_BG},
153+
"dataLabels": [
154+
{
155+
"enabled": True,
156+
"format": "{point.high}",
157+
"align": "left",
158+
"x": 38,
159+
"verticalAlign": "middle",
160+
"style": {"fontSize": "28px", "color": INK_SOFT, "fontWeight": "500", "textOutline": "none"},
161+
},
162+
{
163+
"enabled": True,
164+
"format": "{point.low}",
165+
"align": "right",
166+
"x": -38,
167+
"verticalAlign": "middle",
168+
"style": {"fontSize": "28px", "color": INK_SOFT, "fontWeight": "500", "textOutline": "none"},
169+
},
170+
],
171+
},
172+
"scatter": {"marker": {"radius": 18, "lineWidth": 2, "lineColor": PAGE_BG, "symbol": "circle"}},
173+
},
174+
"series": [
175+
{
176+
"type": "scatter",
177+
"name": "Before",
178+
"color": BEFORE_COLOR,
179+
"data": [],
180+
"showInLegend": True,
181+
"marker": {"radius": 18, "symbol": "circle"},
182+
"enableMouseTracking": False,
183+
},
184+
{
185+
"type": "scatter",
186+
"name": "After",
187+
"color": AFTER_COLOR,
188+
"data": [],
189+
"showInLegend": True,
190+
"marker": {"radius": 18, "symbol": "circle"},
191+
"enableMouseTracking": False,
192+
},
193+
{
194+
"type": "dumbbell",
195+
"name": "Satisfaction change",
196+
"data": dumbbell_data,
197+
"lowColor": BEFORE_COLOR,
198+
"color": AFTER_COLOR,
199+
"showInLegend": False,
200+
},
201+
],
202+
"credits": {"enabled": False},
203+
"tooltip": {
204+
"shared": False,
205+
"useHTML": True,
206+
"backgroundColor": ELEVATED_BG,
207+
"borderColor": GRID,
208+
"style": {"color": INK, "fontSize": "20px"},
209+
"headerFormat": "<b>{point.key}</b><br/>",
210+
"pointFormat": (
211+
f"<span style='color:{BEFORE_COLOR}'>●</span> Before: <b>{{point.low}}</b><br/>"
212+
f"<span style='color:{AFTER_COLOR}'>●</span> After: <b>{{point.high}}</b>"
213+
),
102214
},
103-
"series": [{"name": "Before → After", "data": dumbbell_data, "lowColor": "#306998", "color": "#FFD43B"}],
104215
}
105216

106-
# Download Highcharts JS and required modules for inline embedding
107-
highcharts_url = "https://code.highcharts.com/highcharts.js"
108-
highcharts_more_url = "https://code.highcharts.com/highcharts-more.js"
109-
dumbbell_url = "https://code.highcharts.com/modules/dumbbell.js"
217+
# Highcharts core + dumbbell module + highcharts-more (required by dumbbell).
218+
highcharts_url = "https://cdn.jsdelivr.net/npm/highcharts@12/highcharts.js"
219+
highcharts_more_url = "https://cdn.jsdelivr.net/npm/highcharts@12/highcharts-more.js"
220+
dumbbell_url = "https://cdn.jsdelivr.net/npm/highcharts@12/modules/dumbbell.js"
110221

111222
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
112223
highcharts_js = response.read().decode("utf-8")
@@ -115,7 +226,6 @@
115226
with urllib.request.urlopen(dumbbell_url, timeout=30) as response:
116227
dumbbell_js = response.read().decode("utf-8")
117228

118-
# Generate HTML with inline scripts
119229
chart_options_json = json.dumps(chart_options)
120230
html_content = f"""<!DOCTYPE html>
121231
<html>
@@ -125,26 +235,21 @@
125235
<script>{highcharts_more_js}</script>
126236
<script>{dumbbell_js}</script>
127237
</head>
128-
<body style="margin:0;">
238+
<body style="margin:0; background:{PAGE_BG};">
129239
<div id="container" style="width: 4800px; height: 2700px;"></div>
130240
<script>
131-
document.addEventListener('DOMContentLoaded', function() {{
132-
Highcharts.chart('container', {chart_options_json});
133-
}});
241+
Highcharts.chart('container', {chart_options_json});
134242
</script>
135243
</body>
136244
</html>"""
137245

138-
# Write temp HTML file
139-
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
246+
with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
140247
f.write(html_content)
141-
temp_path = f.name
142248

143-
# Also save the HTML for interactive viewing
144-
with open("plot.html", "w", encoding="utf-8") as f:
249+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
145250
f.write(html_content)
251+
temp_path = f.name
146252

147-
# Take screenshot with headless Chrome
148253
chrome_options = Options()
149254
chrome_options.add_argument("--headless")
150255
chrome_options.add_argument("--no-sandbox")
@@ -153,10 +258,25 @@
153258
chrome_options.add_argument("--window-size=4800,2700")
154259

155260
driver = webdriver.Chrome(options=chrome_options)
261+
driver.execute_cdp_cmd(
262+
"Emulation.setDeviceMetricsOverride", {"width": 4800, "height": 2700, "deviceScaleFactor": 1, "mobile": False}
263+
)
156264
driver.get(f"file://{temp_path}")
157265
time.sleep(5)
158-
driver.save_screenshot("plot.png")
266+
267+
# Full-page CDP capture so the y-axis title at the bottom is not clipped
268+
# by Chrome's reduced rendering viewport.
269+
result = driver.execute_cdp_cmd(
270+
"Page.captureScreenshot",
271+
{
272+
"captureBeyondViewport": True,
273+
"clip": {"x": 0, "y": 0, "width": 4800, "height": 2700, "scale": 1},
274+
"format": "png",
275+
},
276+
)
277+
with open(f"plot-{THEME}.png", "wb") as f:
278+
f.write(base64.b64decode(result["data"]))
279+
159280
driver.quit()
160281

161-
# Clean up temp file
162282
Path(temp_path).unlink()

0 commit comments

Comments
 (0)