Skip to content

Commit f5f9a66

Browse files
feat(highcharts): implement chernoff-basic (#3153)
## Implementation: `chernoff-basic` - highcharts Implements the **highcharts** version of `chernoff-basic`. **File:** `plots/chernoff-basic/implementations/highcharts.py` **Parent Issue:** #3003 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20627520345)* --------- 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 5f4f7fa commit f5f9a66

2 files changed

Lines changed: 290 additions & 0 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: highcharts unknown | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import base64
8+
import tempfile
9+
import time
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from selenium import webdriver
14+
from selenium.webdriver.chrome.options import Options
15+
from sklearn.datasets import load_iris
16+
17+
18+
# Data - Using Iris dataset (4 variables per flower)
19+
np.random.seed(42)
20+
iris = load_iris()
21+
X = iris.data
22+
y = iris.target
23+
species_names = ["Setosa", "Versicolor", "Virginica"]
24+
25+
# Take 3 samples from each species for 9 total faces
26+
sample_indices = []
27+
for species_id in [0, 1, 2]:
28+
species_indices = np.where(y == species_id)[0]
29+
sample_indices.extend(species_indices[:3])
30+
31+
X_sample = X[sample_indices]
32+
y_sample = y[sample_indices]
33+
34+
# Normalize data to 0-1 range for each feature
35+
X_min = X_sample.min(axis=0)
36+
X_max = X_sample.max(axis=0)
37+
X_norm = (X_sample - X_min) / (X_max - X_min + 1e-8)
38+
39+
# Colors for species - colorblind-safe
40+
species_colors = ["#306998", "#FFD43B", "#9467BD"]
41+
42+
# SVG dimensions
43+
svg_width = 4800
44+
svg_height = 2700
45+
46+
# Build custom Chernoff faces HTML/SVG
47+
# Feature mappings:
48+
# - Variable 0 (sepal length): face width
49+
# - Variable 1 (sepal width): eye size
50+
# - Variable 2 (petal length): mouth curvature
51+
# - Variable 3 (petal width): eyebrow slant
52+
53+
54+
def create_face_svg(values, color, label, x_pos, y_pos, size=450):
55+
"""Create SVG for a single Chernoff face."""
56+
# Extract normalized values (0-1)
57+
face_width = 0.7 + values[0] * 0.3 # 0.7 to 1.0 multiplier
58+
eye_size = 0.7 + values[1] * 0.5 # 0.7 to 1.2 multiplier
59+
mouth_curve = values[2] * 2 - 1 # -1 to 1 (sad to happy)
60+
eyebrow_slant = (values[3] - 0.5) * 30 # -15 to +15 degrees
61+
62+
cx = x_pos + size // 2
63+
cy = y_pos + size // 2
64+
face_rx = int(size * 0.4 * face_width)
65+
face_ry = int(size * 0.45)
66+
67+
# Eye positions and sizes
68+
eye_cx_left = cx - int(size * 0.15)
69+
eye_cx_right = cx + int(size * 0.15)
70+
eye_cy = cy - int(size * 0.08)
71+
eye_r = int(18 * eye_size)
72+
pupil_r = int(9 * eye_size)
73+
74+
# Mouth (cubic bezier curve)
75+
mouth_y = cy + int(size * 0.2)
76+
mouth_width = int(size * 0.25)
77+
mouth_curve_offset = int(mouth_curve * size * 0.12)
78+
79+
# Eyebrows
80+
brow_y = eye_cy - int(size * 0.12)
81+
brow_len = int(size * 0.12)
82+
83+
svg = f"""
84+
<!-- Face {label} -->
85+
<ellipse cx="{cx}" cy="{cy}" rx="{face_rx}" ry="{face_ry}"
86+
fill="{color}" stroke="#333333" stroke-width="4"/>
87+
88+
<!-- Left eye -->
89+
<circle cx="{eye_cx_left}" cy="{eye_cy}" r="{eye_r}" fill="white" stroke="#333333" stroke-width="3"/>
90+
<circle cx="{eye_cx_left}" cy="{eye_cy}" r="{pupil_r}" fill="#333333"/>
91+
92+
<!-- Right eye -->
93+
<circle cx="{eye_cx_right}" cy="{eye_cy}" r="{eye_r}" fill="white" stroke="#333333" stroke-width="3"/>
94+
<circle cx="{eye_cx_right}" cy="{eye_cy}" r="{pupil_r}" fill="#333333"/>
95+
96+
<!-- Left eyebrow -->
97+
<line x1="{eye_cx_left - brow_len}" y1="{brow_y + int(eyebrow_slant)}"
98+
x2="{eye_cx_left + brow_len}" y2="{brow_y - int(eyebrow_slant)}"
99+
stroke="#333333" stroke-width="5" stroke-linecap="round"/>
100+
101+
<!-- Right eyebrow -->
102+
<line x1="{eye_cx_right - brow_len}" y1="{brow_y - int(eyebrow_slant)}"
103+
x2="{eye_cx_right + brow_len}" y2="{brow_y + int(eyebrow_slant)}"
104+
stroke="#333333" stroke-width="5" stroke-linecap="round"/>
105+
106+
<!-- Nose -->
107+
<line x1="{cx}" y1="{cy - int(size * 0.02)}" x2="{cx}" y2="{cy + int(size * 0.1)}"
108+
stroke="#333333" stroke-width="4" stroke-linecap="round"/>
109+
110+
<!-- Mouth -->
111+
<path d="M {cx - mouth_width} {mouth_y} Q {cx} {mouth_y + mouth_curve_offset} {cx + mouth_width} {mouth_y}"
112+
fill="none" stroke="#333333" stroke-width="5" stroke-linecap="round"/>
113+
114+
<!-- Label -->
115+
<text x="{cx}" y="{y_pos + size + 50}" text-anchor="middle"
116+
font-size="36" font-family="Arial, sans-serif" font-weight="bold">{label}</text>
117+
"""
118+
return svg
119+
120+
121+
# Create the complete HTML with embedded SVG
122+
faces_svg = ""
123+
face_size = 580
124+
cols = 3
125+
rows = 3
126+
127+
# Calculate grid to center faces properly across the canvas
128+
# Leave space for legends on right (about 600px) and title at top (about 220px)
129+
grid_left = 100
130+
grid_right = 3350
131+
grid_top = 250
132+
grid_bottom = svg_height - 100
133+
134+
grid_width = grid_right - grid_left
135+
grid_height = grid_bottom - grid_top
136+
137+
# Calculate cell size for even distribution
138+
cell_width = grid_width // cols
139+
cell_height = grid_height // rows
140+
141+
for idx in range(9):
142+
row = idx // cols
143+
col = idx % cols
144+
145+
# Center face within its cell
146+
cell_x = grid_left + col * cell_width
147+
cell_y = grid_top + row * cell_height
148+
x_pos = cell_x + (cell_width - face_size) // 2
149+
y_pos = cell_y + (cell_height - face_size - 60) // 2 # -60 for label space
150+
151+
species_idx = y_sample[idx]
152+
color = species_colors[species_idx]
153+
label = f"{species_names[species_idx]} #{(idx % 3) + 1}"
154+
155+
faces_svg += create_face_svg(X_norm[idx], color, label, x_pos, y_pos, face_size)
156+
157+
# Create legend - positioned in right column, vertically centered
158+
legend_x = 3550
159+
legend_y = 450
160+
legend_svg = f"""
161+
<rect x="{legend_x}" y="{legend_y}" width="550" height="380" fill="#f8f8f8" stroke="#333333" stroke-width="3" rx="15"/>
162+
<text x="{legend_x + 35}" y="{legend_y + 60}" font-size="44" font-family="Arial, sans-serif" font-weight="bold">Species Legend</text>
163+
"""
164+
165+
for i, (species, color) in enumerate(zip(species_names, species_colors, strict=True)):
166+
ly_item = legend_y + 130 + i * 80
167+
legend_svg += f"""
168+
<circle cx="{legend_x + 60}" cy="{ly_item}" r="30" fill="{color}" stroke="#333333" stroke-width="3"/>
169+
<text x="{legend_x + 110}" y="{ly_item + 14}" font-size="38" font-family="Arial, sans-serif">{species}</text>
170+
"""
171+
172+
# Feature mapping legend
173+
feature_legend_y = legend_y + 450
174+
feature_legend_svg = f"""
175+
<rect x="{legend_x}" y="{feature_legend_y}" width="550" height="480" fill="#f8f8f8" stroke="#333333" stroke-width="3" rx="15"/>
176+
<text x="{legend_x + 35}" y="{feature_legend_y + 60}" font-size="40" font-family="Arial, sans-serif" font-weight="bold">Feature Mapping</text>
177+
<text x="{legend_x + 35}" y="{feature_legend_y + 130}" font-size="30" font-family="Arial, sans-serif">Face Width → Sepal Length</text>
178+
<text x="{legend_x + 35}" y="{feature_legend_y + 190}" font-size="30" font-family="Arial, sans-serif">Eye Size → Sepal Width</text>
179+
<text x="{legend_x + 35}" y="{feature_legend_y + 250}" font-size="30" font-family="Arial, sans-serif">Mouth Curve → Petal Length</text>
180+
<text x="{legend_x + 35}" y="{feature_legend_y + 310}" font-size="30" font-family="Arial, sans-serif">Eyebrow Slant → Petal Width</text>
181+
<line x1="{legend_x + 35}" y1="{feature_legend_y + 355}" x2="{legend_x + 515}" y2="{feature_legend_y + 355}" stroke="#cccccc" stroke-width="2"/>
182+
<text x="{legend_x + 35}" y="{feature_legend_y + 410}" font-size="26" font-family="Arial, sans-serif" fill="#666666">All values normalized to 0-1 range</text>
183+
"""
184+
185+
# Complete HTML
186+
html_content = f"""<!DOCTYPE html>
187+
<html>
188+
<head>
189+
<meta charset="utf-8">
190+
<style>
191+
body {{ margin: 0; padding: 0; background: #ffffff; }}
192+
</style>
193+
</head>
194+
<body>
195+
<svg width="{svg_width}" height="{svg_height}" xmlns="http://www.w3.org/2000/svg">
196+
<!-- Background -->
197+
<rect width="100%" height="100%" fill="#ffffff"/>
198+
199+
<!-- Title -->
200+
<text x="{svg_width // 2}" y="100" text-anchor="middle"
201+
font-size="64" font-family="Arial, sans-serif" font-weight="bold">
202+
chernoff-basic · highcharts · pyplots.ai
203+
</text>
204+
205+
<!-- Subtitle -->
206+
<text x="{svg_width // 2}" y="175" text-anchor="middle"
207+
font-size="40" font-family="Arial, sans-serif" fill="#666666">
208+
Iris Dataset: 4 Variables Mapped to Facial Features (9 Samples, 3 Per Species)
209+
</text>
210+
211+
<!-- Faces Grid -->
212+
{faces_svg}
213+
214+
<!-- Species Legend -->
215+
{legend_svg}
216+
217+
<!-- Feature Mapping Legend -->
218+
{feature_legend_svg}
219+
</svg>
220+
</body>
221+
</html>"""
222+
223+
# Save HTML version
224+
with open("plot.html", "w", encoding="utf-8") as f:
225+
f.write(html_content)
226+
227+
# Export to PNG via Selenium
228+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
229+
f.write(html_content)
230+
temp_path = f.name
231+
232+
chrome_options = Options()
233+
chrome_options.add_argument("--headless=new")
234+
chrome_options.add_argument("--no-sandbox")
235+
chrome_options.add_argument("--disable-dev-shm-usage")
236+
chrome_options.add_argument("--disable-gpu")
237+
chrome_options.add_argument("--window-size=4800,2700")
238+
chrome_options.add_argument("--force-device-scale-factor=1")
239+
240+
driver = webdriver.Chrome(options=chrome_options)
241+
driver.get(f"file://{temp_path}")
242+
time.sleep(3)
243+
244+
# Use CDP to capture full page at exact dimensions
245+
driver.execute_cdp_cmd(
246+
"Emulation.setDeviceMetricsOverride", {"width": 4800, "height": 2700, "deviceScaleFactor": 1, "mobile": False}
247+
)
248+
time.sleep(1)
249+
250+
# Take screenshot with clip to exact dimensions
251+
result = driver.execute_cdp_cmd(
252+
"Page.captureScreenshot",
253+
{
254+
"format": "png",
255+
"captureBeyondViewport": True,
256+
"clip": {"x": 0, "y": 0, "width": 4800, "height": 2700, "scale": 1},
257+
},
258+
)
259+
260+
with open("plot.png", "wb") as f:
261+
f.write(base64.b64decode(result["data"]))
262+
263+
driver.quit()
264+
265+
Path(temp_path).unlink()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
library: highcharts
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T21:38:01Z'
4+
updated: '2025-12-31T21:40:26Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20627520345
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: unknown
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/highcharts/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/highcharts/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/highcharts/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent visual design with clear, distinguishable faces for each species
17+
- Comprehensive legends explaining both species colors and feature mappings
18+
- Colorblind-safe color palette (blue, yellow, purple)
19+
- Proper data normalization as specified
20+
- Uses real Iris dataset for meaningful visualization
21+
- Good grid layout with balanced spacing and canvas utilization
22+
weaknesses:
23+
- Uses helper function create_face_svg() instead of flat KISS structure
24+
- Not technically using Highcharts library - implements pure SVG instead (understandable
25+
since Highcharts lacks native Chernoff face support)

0 commit comments

Comments
 (0)