Skip to content

Commit 8db136d

Browse files
feat(highcharts): implement violin-box (#2688)
## Implementation: `violin-box` - highcharts Implements the **highcharts** version of `violin-box`. **File:** `plots/violin-box/implementations/highcharts.py` --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20595341717)* --------- 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 9ef6e6b commit 8db136d

2 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
""" pyplots.ai
2+
violin-box: Violin Plot with Embedded Box Plot
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 92/100 | Created: 2025-12-30
5+
"""
6+
7+
import tempfile
8+
import time
9+
import urllib.request
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from highcharts_core.chart import Chart
14+
from highcharts_core.options import HighchartsOptions
15+
from highcharts_core.options.series.polygon import PolygonSeries
16+
from highcharts_core.options.series.scatter import ScatterSeries
17+
from scipy.stats import gaussian_kde
18+
from selenium import webdriver
19+
from selenium.webdriver.chrome.options import Options
20+
21+
22+
# Data - generate sample distributions for 4 groups with different characteristics
23+
np.random.seed(42)
24+
categories = ["Control", "Treatment A", "Treatment B", "Treatment C"]
25+
colors = ["#306998", "#FFD43B", "#9467BD", "#17BECF"]
26+
27+
# Generate data with different distributions to showcase violin+box features
28+
raw_data = {
29+
"Control": np.random.normal(50, 10, 150), # Normal distribution
30+
"Treatment A": np.concatenate([np.random.normal(45, 6, 80), np.random.normal(70, 6, 70)]), # Bimodal
31+
"Treatment B": np.random.normal(65, 8, 150), # Higher mean, tighter spread
32+
"Treatment C": np.concatenate([np.random.exponential(10, 120) + 35, [95, 98, 100]]), # Skewed with outliers
33+
}
34+
35+
# Calculate KDE and box plot statistics for each category
36+
violin_width = 0.35 # Half-width of violin in category units
37+
violin_box_data = []
38+
39+
for i, cat in enumerate(categories):
40+
data = raw_data[cat]
41+
42+
# Compute KDE using scipy
43+
y_min, y_max = data.min() - 5, data.max() + 5
44+
y_grid = np.linspace(y_min, y_max, 100)
45+
kde_func = gaussian_kde(data)
46+
density = kde_func(y_grid)
47+
48+
# Normalize density to fit within violin width
49+
density_norm = density / density.max() * violin_width
50+
51+
# Box plot statistics
52+
q1 = float(np.percentile(data, 25))
53+
median = float(np.percentile(data, 50))
54+
q3 = float(np.percentile(data, 75))
55+
iqr = q3 - q1
56+
whisker_low = max(float(data.min()), q1 - 1.5 * iqr)
57+
whisker_high = min(float(data.max()), q3 + 1.5 * iqr)
58+
59+
# Identify outliers
60+
outliers = data[(data < whisker_low) | (data > whisker_high)]
61+
62+
violin_box_data.append(
63+
{
64+
"category": cat,
65+
"index": i,
66+
"y_grid": y_grid,
67+
"density": density_norm,
68+
"q1": q1,
69+
"median": median,
70+
"q3": q3,
71+
"whisker_low": whisker_low,
72+
"whisker_high": whisker_high,
73+
"outliers": [float(o) for o in outliers],
74+
"color": colors[i],
75+
}
76+
)
77+
78+
# Create chart
79+
chart = Chart(container="container")
80+
chart.options = HighchartsOptions()
81+
82+
# Chart configuration
83+
chart.options.chart = {
84+
"type": "scatter",
85+
"width": 4800,
86+
"height": 2700,
87+
"backgroundColor": "#ffffff",
88+
"marginBottom": 200,
89+
"marginLeft": 280,
90+
"marginRight": 120,
91+
}
92+
93+
# Title
94+
chart.options.title = {
95+
"text": "violin-box \u00b7 highcharts \u00b7 pyplots.ai",
96+
"style": {"fontSize": "84px", "fontWeight": "bold"},
97+
}
98+
99+
# X-axis (categories)
100+
chart.options.x_axis = {
101+
"title": {"text": "Group", "style": {"fontSize": "56px"}},
102+
"labels": {"style": {"fontSize": "44px"}, "format": "{value}"},
103+
"min": -0.5,
104+
"max": 3.5,
105+
"tickPositions": [0, 1, 2, 3],
106+
"categories": categories,
107+
"lineWidth": 2,
108+
}
109+
110+
# Y-axis (values)
111+
chart.options.y_axis = {
112+
"title": {"text": "Response Value (units)", "style": {"fontSize": "56px"}},
113+
"labels": {"style": {"fontSize": "44px"}},
114+
"gridLineWidth": 1,
115+
"gridLineColor": "rgba(0, 0, 0, 0.15)",
116+
}
117+
118+
# Legend
119+
chart.options.legend = {"enabled": True, "itemStyle": {"fontSize": "44px"}}
120+
121+
# Plot options
122+
chart.options.plot_options = {
123+
"polygon": {"lineWidth": 3, "fillOpacity": 0.5, "enableMouseTracking": True},
124+
"scatter": {"marker": {"radius": 16, "symbol": "circle"}, "zIndex": 10},
125+
}
126+
127+
# Add violin shapes as polygon series
128+
for v in violin_box_data:
129+
# Create polygon points for the violin shape
130+
polygon_points = []
131+
132+
# Right side (positive x offset from center)
133+
for y_val, dens in zip(v["y_grid"], v["density"], strict=True):
134+
polygon_points.append([float(v["index"] + dens), float(y_val)])
135+
136+
# Left side (negative x offset from center) - reversed to close the polygon
137+
for j in range(len(v["y_grid"]) - 1, -1, -1):
138+
y_val = v["y_grid"][j]
139+
dens = v["density"][j]
140+
polygon_points.append([float(v["index"] - dens), float(y_val)])
141+
142+
series = PolygonSeries()
143+
series.data = polygon_points
144+
series.name = v["category"]
145+
series.color = v["color"]
146+
series.fill_color = v["color"]
147+
series.fill_opacity = 0.5
148+
chart.add_series(series)
149+
150+
# Add embedded box plots inside the violins
151+
box_width = 0.08 # Width of the internal box
152+
whisker_width = 0.04 # Width of the whisker caps
153+
154+
for v in violin_box_data:
155+
idx = v["index"]
156+
157+
# Draw the IQR box (Q1 to Q3)
158+
box_points = [
159+
[float(idx - box_width), float(v["q1"])],
160+
[float(idx + box_width), float(v["q1"])],
161+
[float(idx + box_width), float(v["q3"])],
162+
[float(idx - box_width), float(v["q3"])],
163+
]
164+
165+
box_series = PolygonSeries()
166+
box_series.data = box_points
167+
box_series.name = f"{v['category']} Box"
168+
box_series.show_in_legend = False
169+
box_series.color = "#1a1a1a"
170+
box_series.fill_color = "#ffffff"
171+
box_series.fill_opacity = 0.9
172+
box_series.line_width = 4
173+
chart.add_series(box_series)
174+
175+
# Draw whisker lines (vertical stem from whisker_low to Q1 and Q3 to whisker_high)
176+
# Lower whisker line
177+
lower_whisker_line = PolygonSeries()
178+
lower_whisker_line.data = [
179+
[float(idx - 0.01), float(v["whisker_low"])],
180+
[float(idx + 0.01), float(v["whisker_low"])],
181+
[float(idx + 0.01), float(v["q1"])],
182+
[float(idx - 0.01), float(v["q1"])],
183+
]
184+
lower_whisker_line.name = f"{v['category']} Lower Whisker"
185+
lower_whisker_line.show_in_legend = False
186+
lower_whisker_line.color = "#1a1a1a"
187+
lower_whisker_line.fill_color = "#1a1a1a"
188+
lower_whisker_line.fill_opacity = 1.0
189+
chart.add_series(lower_whisker_line)
190+
191+
# Upper whisker line
192+
upper_whisker_line = PolygonSeries()
193+
upper_whisker_line.data = [
194+
[float(idx - 0.01), float(v["q3"])],
195+
[float(idx + 0.01), float(v["q3"])],
196+
[float(idx + 0.01), float(v["whisker_high"])],
197+
[float(idx - 0.01), float(v["whisker_high"])],
198+
]
199+
upper_whisker_line.name = f"{v['category']} Upper Whisker"
200+
upper_whisker_line.show_in_legend = False
201+
upper_whisker_line.color = "#1a1a1a"
202+
upper_whisker_line.fill_color = "#1a1a1a"
203+
upper_whisker_line.fill_opacity = 1.0
204+
chart.add_series(upper_whisker_line)
205+
206+
# Lower whisker cap (horizontal line)
207+
lower_cap = PolygonSeries()
208+
lower_cap.data = [
209+
[float(idx - whisker_width), float(v["whisker_low"] - 0.3)],
210+
[float(idx + whisker_width), float(v["whisker_low"] - 0.3)],
211+
[float(idx + whisker_width), float(v["whisker_low"] + 0.3)],
212+
[float(idx - whisker_width), float(v["whisker_low"] + 0.3)],
213+
]
214+
lower_cap.name = f"{v['category']} Lower Cap"
215+
lower_cap.show_in_legend = False
216+
lower_cap.color = "#1a1a1a"
217+
lower_cap.fill_color = "#1a1a1a"
218+
lower_cap.fill_opacity = 1.0
219+
chart.add_series(lower_cap)
220+
221+
# Upper whisker cap (horizontal line)
222+
upper_cap = PolygonSeries()
223+
upper_cap.data = [
224+
[float(idx - whisker_width), float(v["whisker_high"] - 0.3)],
225+
[float(idx + whisker_width), float(v["whisker_high"] - 0.3)],
226+
[float(idx + whisker_width), float(v["whisker_high"] + 0.3)],
227+
[float(idx - whisker_width), float(v["whisker_high"] + 0.3)],
228+
]
229+
upper_cap.name = f"{v['category']} Upper Cap"
230+
upper_cap.show_in_legend = False
231+
upper_cap.color = "#1a1a1a"
232+
upper_cap.fill_color = "#1a1a1a"
233+
upper_cap.fill_opacity = 1.0
234+
chart.add_series(upper_cap)
235+
236+
# Add median markers as scatter points (white fill with dark border for visibility)
237+
med_series = ScatterSeries()
238+
med_series.data = [[float(v["index"]), float(v["median"])] for v in violin_box_data]
239+
med_series.name = "Median"
240+
med_series.color = "#ffffff"
241+
med_series.marker = {"fillColor": "#ffffff", "lineColor": "#1a1a1a", "lineWidth": 5, "radius": 18, "symbol": "circle"}
242+
med_series.z_index = 20
243+
chart.add_series(med_series)
244+
245+
# Add outliers as scatter points
246+
outlier_points = []
247+
for v in violin_box_data:
248+
for outlier_val in v["outliers"]:
249+
outlier_points.append([float(v["index"]), outlier_val])
250+
251+
if outlier_points:
252+
outlier_series = ScatterSeries()
253+
outlier_series.data = outlier_points
254+
outlier_series.name = "Outliers"
255+
outlier_series.color = "#E74C3C"
256+
outlier_series.marker = {
257+
"fillColor": "#E74C3C",
258+
"lineColor": "#C0392B",
259+
"lineWidth": 3,
260+
"radius": 14,
261+
"symbol": "diamond",
262+
}
263+
outlier_series.z_index = 15
264+
chart.add_series(outlier_series)
265+
266+
# Download Highcharts JS files
267+
highcharts_url = "https://code.highcharts.com/highcharts.js"
268+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
269+
highcharts_js = response.read().decode("utf-8")
270+
271+
# Polygon requires highcharts-more.js
272+
highcharts_more_url = "https://code.highcharts.com/highcharts-more.js"
273+
with urllib.request.urlopen(highcharts_more_url, timeout=30) as response:
274+
highcharts_more_js = response.read().decode("utf-8")
275+
276+
# Generate HTML with inline scripts
277+
html_str = chart.to_js_literal()
278+
html_content = f"""<!DOCTYPE html>
279+
<html>
280+
<head>
281+
<meta charset="utf-8">
282+
<script>{highcharts_js}</script>
283+
<script>{highcharts_more_js}</script>
284+
</head>
285+
<body style="margin:0;">
286+
<div id="container" style="width: 4800px; height: 2700px;"></div>
287+
<script>{html_str}</script>
288+
</body>
289+
</html>"""
290+
291+
# Write temp HTML file
292+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
293+
f.write(html_content)
294+
temp_path = f.name
295+
296+
# Save HTML for interactive viewing
297+
with open("plot.html", "w", encoding="utf-8") as f:
298+
standalone_html = f"""<!DOCTYPE html>
299+
<html>
300+
<head>
301+
<meta charset="utf-8">
302+
<script src="https://code.highcharts.com/highcharts.js"></script>
303+
<script src="https://code.highcharts.com/highcharts-more.js"></script>
304+
</head>
305+
<body style="margin:0;">
306+
<div id="container" style="width: 100%; height: 100vh;"></div>
307+
<script>{html_str}</script>
308+
</body>
309+
</html>"""
310+
f.write(standalone_html)
311+
312+
# Take screenshot with Selenium
313+
chrome_options = Options()
314+
chrome_options.add_argument("--headless")
315+
chrome_options.add_argument("--no-sandbox")
316+
chrome_options.add_argument("--disable-dev-shm-usage")
317+
chrome_options.add_argument("--disable-gpu")
318+
chrome_options.add_argument("--window-size=5000,3000")
319+
320+
driver = webdriver.Chrome(options=chrome_options)
321+
driver.get(f"file://{temp_path}")
322+
time.sleep(5) # Wait for chart to render
323+
324+
container = driver.find_element("id", "container")
325+
container.screenshot("plot.png")
326+
driver.quit()
327+
328+
Path(temp_path).unlink() # Clean up temp file
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
library: highcharts
2+
specification_id: violin-box
3+
created: '2025-12-30T11:29:43Z'
4+
updated: '2025-12-30T11:37:05Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20595341717
7+
issue: 0
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/violin-box/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/violin-box/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/violin-box/highcharts/plot.html
13+
quality_score: 92
14+
review:
15+
strengths:
16+
- Excellent implementation of a complex visualization using polygon primitives
17+
- Creative approach building violin shapes from KDE using scipy and PolygonSeries
18+
- 'Diverse data showcasing all features: bimodal distribution, outliers, different
19+
spreads'
20+
- Colorblind-safe palette
21+
- 'All spec requirements met: violin KDE, embedded box plot, median markers, whiskers,
22+
outliers'
23+
weaknesses:
24+
- No grid lines visible, which would improve value readability
25+
- Some unused whitespace at top of plot area

0 commit comments

Comments
 (0)