Skip to content

Commit 40ff874

Browse files
feat(highcharts): implement violin-swarm (#3544)
## Implementation: `violin-swarm` - highcharts Implements the **highcharts** version of `violin-swarm`. **File:** `plots/violin-swarm/implementations/highcharts.py` **Parent Issue:** #3526 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20858847017)* --------- 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 e4ad010 commit 40ff874

2 files changed

Lines changed: 513 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
""" pyplots.ai
2+
violin-swarm: Violin Plot with Overlaid Swarm Points
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 91/100 | Created: 2026-01-09
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 - Reaction times (ms) across 4 experimental conditions
23+
np.random.seed(42)
24+
categories = ["Control", "Condition A", "Condition B", "Condition C"]
25+
# Violin fills with transparency, darker point colors for contrast
26+
colors_violin = [
27+
"rgba(48, 105, 152, 0.4)",
28+
"rgba(255, 212, 59, 0.4)",
29+
"rgba(148, 103, 189, 0.4)",
30+
"rgba(23, 190, 207, 0.4)",
31+
]
32+
colors_points = ["#1a3d5c", "#c9a200", "#5c3d7a", "#0d7a85"] # Darker variants for points
33+
n_obs = 50
34+
35+
# Generate distinct distributions for each condition
36+
raw_data = {
37+
"Control": np.random.normal(350, 45, n_obs), # Normal distribution
38+
"Condition A": np.random.normal(280, 35, n_obs), # Faster responses, lower variance
39+
"Condition B": np.concatenate(
40+
[np.random.normal(320, 25, n_obs // 2), np.random.normal(420, 30, n_obs // 2)]
41+
), # Bimodal
42+
"Condition C": np.random.exponential(50, n_obs) + 270, # Right-skewed
43+
}
44+
45+
# Calculate KDE for violin shapes
46+
violin_width = 0.35
47+
violin_data = []
48+
49+
for i, cat in enumerate(categories):
50+
data = raw_data[cat]
51+
52+
# Compute KDE
53+
y_min, y_max = data.min() - 20, data.max() + 20
54+
y_grid = np.linspace(y_min, y_max, 100)
55+
kde_func = gaussian_kde(data)
56+
density = kde_func(y_grid)
57+
58+
# Normalize density to fit within violin width
59+
density_norm = density / density.max() * violin_width
60+
61+
violin_data.append(
62+
{
63+
"category": cat,
64+
"index": i,
65+
"y_grid": y_grid,
66+
"density": density_norm,
67+
"raw_data": data,
68+
"color_violin": colors_violin[i],
69+
"color_points": colors_points[i],
70+
}
71+
)
72+
73+
74+
# Swarm layout function - position points to avoid overlap within violin bounds
75+
def swarm_positions(data, index, density_norm, y_grid):
76+
"""Calculate x positions for swarm points within violin bounds."""
77+
sorted_indices = np.argsort(data)
78+
sorted_data = data[sorted_indices]
79+
80+
# For each point, find position within violin width
81+
x_positions = np.zeros(len(data))
82+
83+
for j, y_val in enumerate(sorted_data):
84+
# Find width of violin at this y value
85+
y_idx = np.argmin(np.abs(y_grid - y_val))
86+
max_width = density_norm[y_idx] * 0.9 # Stay slightly inside violin
87+
88+
# Find available x position that doesn't overlap with nearby points
89+
placed = False
90+
for attempt_x in np.linspace(0, max_width, 20):
91+
conflict = False
92+
for k in range(j):
93+
if abs(sorted_data[k] - y_val) < 10: # Within 10ms vertically
94+
if abs(x_positions[k] - attempt_x) < 0.04: # Too close horizontally
95+
conflict = True
96+
break
97+
if not conflict:
98+
x_positions[j] = attempt_x if j % 2 == 0 else -attempt_x
99+
placed = True
100+
break
101+
102+
if not placed:
103+
# Random jitter within bounds as fallback
104+
x_positions[j] = np.random.uniform(-max_width, max_width)
105+
106+
# Reorder to original order
107+
result = np.zeros(len(data))
108+
result[sorted_indices] = x_positions
109+
return index + result
110+
111+
112+
# Create chart
113+
chart = Chart(container="container")
114+
chart.options = HighchartsOptions()
115+
116+
# Chart configuration
117+
chart.options.chart = {
118+
"type": "scatter",
119+
"width": 4800,
120+
"height": 2700,
121+
"backgroundColor": "#ffffff",
122+
"marginBottom": 200,
123+
"marginLeft": 280,
124+
"marginRight": 150,
125+
}
126+
127+
# Title
128+
chart.options.title = {
129+
"text": "violin-swarm · highcharts · pyplots.ai",
130+
"style": {"fontSize": "72px", "fontWeight": "bold"},
131+
}
132+
133+
# Subtitle
134+
chart.options.subtitle = {
135+
"text": "Reaction Times Across Experimental Conditions",
136+
"style": {"fontSize": "42px", "color": "#666666"},
137+
}
138+
139+
# X-axis (categories)
140+
chart.options.x_axis = {
141+
"title": {"text": "Experimental Condition", "style": {"fontSize": "48px"}},
142+
"labels": {"style": {"fontSize": "38px"}},
143+
"min": -0.6,
144+
"max": 3.6,
145+
"tickPositions": [0, 1, 2, 3],
146+
"categories": categories,
147+
"lineWidth": 2,
148+
"lineColor": "#333333",
149+
}
150+
151+
# Y-axis (values)
152+
chart.options.y_axis = {
153+
"title": {"text": "Reaction Time (ms)", "style": {"fontSize": "48px"}},
154+
"labels": {"style": {"fontSize": "38px"}},
155+
"gridLineWidth": 1,
156+
"gridLineColor": "rgba(0, 0, 0, 0.12)",
157+
}
158+
159+
# Legend
160+
chart.options.legend = {
161+
"enabled": True,
162+
"itemStyle": {"fontSize": "38px"},
163+
"symbolHeight": 24,
164+
"symbolWidth": 24,
165+
"layout": "horizontal",
166+
"align": "center",
167+
"verticalAlign": "bottom",
168+
"y": 30,
169+
}
170+
171+
# Plot options
172+
chart.options.plot_options = {
173+
"polygon": {"lineWidth": 2, "fillOpacity": 0.35, "enableMouseTracking": False},
174+
"scatter": {"marker": {"radius": 10, "symbol": "circle", "lineWidth": 2}, "zIndex": 10},
175+
}
176+
177+
# Add violin shapes as polygon series (background)
178+
for v in violin_data:
179+
polygon_points = []
180+
181+
# Right side
182+
for y_val, dens in zip(v["y_grid"], v["density"], strict=True):
183+
polygon_points.append([float(v["index"] + dens), float(y_val)])
184+
185+
# Left side (reversed)
186+
for j in range(len(v["y_grid"]) - 1, -1, -1):
187+
y_val = v["y_grid"][j]
188+
dens = v["density"][j]
189+
polygon_points.append([float(v["index"] - dens), float(y_val)])
190+
191+
series = PolygonSeries()
192+
series.data = polygon_points
193+
series.name = f"{v['category']} (distribution)"
194+
series.color = v["color_points"] # Border color
195+
series.fill_color = v["color_violin"] # Semi-transparent fill
196+
series.fill_opacity = 0.35
197+
series.show_in_legend = False
198+
series.z_index = 1
199+
chart.add_series(series)
200+
201+
# Add swarm points for each category (foreground)
202+
for v in violin_data:
203+
# Calculate swarm positions
204+
x_positions = swarm_positions(v["raw_data"], v["index"], v["density"], v["y_grid"])
205+
206+
# Create scatter series for swarm points
207+
scatter_series = ScatterSeries()
208+
scatter_series.data = [[float(x), float(y)] for x, y in zip(x_positions, v["raw_data"], strict=True)]
209+
scatter_series.name = v["category"]
210+
scatter_series.color = v["color_points"]
211+
scatter_series.marker = {
212+
"fillColor": v["color_points"],
213+
"lineColor": "#ffffff",
214+
"lineWidth": 2,
215+
"radius": 12, # Larger markers for visibility
216+
"symbol": "circle",
217+
}
218+
scatter_series.z_index = 10
219+
chart.add_series(scatter_series)
220+
221+
# Download Highcharts JS files
222+
highcharts_url = "https://code.highcharts.com/highcharts.js"
223+
with urllib.request.urlopen(highcharts_url, timeout=30) as response:
224+
highcharts_js = response.read().decode("utf-8")
225+
226+
# Polygon requires highcharts-more.js
227+
highcharts_more_url = "https://code.highcharts.com/highcharts-more.js"
228+
with urllib.request.urlopen(highcharts_more_url, timeout=30) as response:
229+
highcharts_more_js = response.read().decode("utf-8")
230+
231+
# Generate HTML with inline scripts
232+
html_str = chart.to_js_literal()
233+
html_content = f"""<!DOCTYPE html>
234+
<html>
235+
<head>
236+
<meta charset="utf-8">
237+
<script>{highcharts_js}</script>
238+
<script>{highcharts_more_js}</script>
239+
</head>
240+
<body style="margin:0;">
241+
<div id="container" style="width: 4800px; height: 2700px;"></div>
242+
<script>{html_str}</script>
243+
</body>
244+
</html>"""
245+
246+
# Write temp HTML file
247+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
248+
f.write(html_content)
249+
temp_path = f.name
250+
251+
# Save HTML for interactive viewing
252+
with open("plot.html", "w", encoding="utf-8") as f:
253+
standalone_html = f"""<!DOCTYPE html>
254+
<html>
255+
<head>
256+
<meta charset="utf-8">
257+
<script src="https://code.highcharts.com/highcharts.js"></script>
258+
<script src="https://code.highcharts.com/highcharts-more.js"></script>
259+
</head>
260+
<body style="margin:0;">
261+
<div id="container" style="width: 100%; height: 100vh;"></div>
262+
<script>{html_str}</script>
263+
</body>
264+
</html>"""
265+
f.write(standalone_html)
266+
267+
# Take screenshot with Selenium
268+
chrome_options = Options()
269+
chrome_options.add_argument("--headless")
270+
chrome_options.add_argument("--no-sandbox")
271+
chrome_options.add_argument("--disable-dev-shm-usage")
272+
chrome_options.add_argument("--disable-gpu")
273+
chrome_options.add_argument("--window-size=5000,3000")
274+
275+
driver = webdriver.Chrome(options=chrome_options)
276+
driver.get(f"file://{temp_path}")
277+
time.sleep(5)
278+
279+
container = driver.find_element("id", "container")
280+
container.screenshot("plot.png")
281+
driver.quit()
282+
283+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)