Skip to content

Commit e4d5391

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

2 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: pygal 3.1.0 | Python 3.13.11
4+
Quality: 90/100 | Created: 2025-12-31
5+
"""
6+
7+
import xml.etree.ElementTree as ET
8+
9+
import cairosvg
10+
import numpy as np
11+
import pygal
12+
from pygal.style import Style
13+
14+
15+
# Set seed for reproducibility
16+
np.random.seed(42)
17+
18+
# Data - Car performance metrics (5 cars, 7 attributes each)
19+
# Attributes: Engine Power, Fuel Efficiency, Safety Rating, Comfort, Reliability, Price Value, Handling
20+
car_names = ["Sedan A", "SUV B", "Sports C", "Compact D", "Luxury E"]
21+
22+
# Normalized data (0-1 scale) for each car's attributes
23+
# Each row: [face_width, face_height, eye_size, eye_spacing, mouth_curve, nose_length, eyebrow_slant]
24+
car_data = np.array(
25+
[
26+
[0.6, 0.5, 0.7, 0.5, 0.8, 0.4, 0.5], # Sedan A - balanced, happy
27+
[0.8, 0.7, 0.5, 0.6, 0.4, 0.7, 0.3], # SUV B - large, serious
28+
[0.4, 0.6, 0.9, 0.4, 0.9, 0.3, 0.7], # Sports C - narrow, excited
29+
[0.5, 0.4, 0.6, 0.5, 0.6, 0.5, 0.5], # Compact D - small, neutral
30+
[0.7, 0.8, 0.8, 0.7, 0.7, 0.6, 0.6], # Luxury E - large, pleasant
31+
]
32+
)
33+
34+
# Group colors for cars (colorblind-safe palette)
35+
face_colors = ["#306998", "#FFD43B", "#4ECDC4", "#FF7043", "#9C88FF"]
36+
37+
# SVG namespace
38+
SVG_NS = "http://www.w3.org/2000/svg"
39+
ET.register_namespace("", SVG_NS)
40+
41+
# Custom style for pygal
42+
custom_style = Style(
43+
background="white",
44+
plot_background="white",
45+
foreground="#333333",
46+
foreground_strong="#333333",
47+
foreground_subtle="#666666",
48+
colors=tuple(face_colors),
49+
title_font_size=72,
50+
label_font_size=36,
51+
major_label_font_size=32,
52+
legend_font_size=36,
53+
value_font_size=28,
54+
)
55+
56+
# Create a base pygal XY chart to leverage its SVG rendering infrastructure
57+
chart = pygal.XY(
58+
width=4800,
59+
height=2700,
60+
style=custom_style,
61+
show_legend=False,
62+
show_x_guides=False,
63+
show_y_guides=False,
64+
show_x_labels=False,
65+
show_y_labels=False,
66+
show_dots=False,
67+
margin=50,
68+
no_data_text="",
69+
)
70+
71+
# Add dummy data to create valid chart structure (hidden)
72+
chart.add("", [])
73+
74+
# Render to SVG string
75+
svg_string = chart.render().decode("utf-8")
76+
77+
# Parse SVG and add custom Chernoff faces
78+
svg_tree = ET.fromstring(svg_string)
79+
80+
# Create faces group
81+
faces_group = ET.SubElement(svg_tree, f"{{{SVG_NS}}}g")
82+
faces_group.set("id", "chernoff-faces")
83+
84+
# Calculate face positions in a grid (5 faces in a row)
85+
cols = 5
86+
face_size = 520
87+
margin_x = 500
88+
spacing_x = (4800 - 2 * margin_x) / (cols - 1) if cols > 1 else 0
89+
base_cy = 950 # Center faces vertically for better canvas utilization
90+
91+
# Draw each Chernoff face inline (KISS structure - no functions)
92+
for i, (name, data, color) in enumerate(zip(car_names, car_data, face_colors, strict=True)):
93+
col = i % cols
94+
cx = margin_x + col * spacing_x
95+
cy = base_cy
96+
97+
# Calculate facial feature parameters from data
98+
scale = face_size / 200
99+
face_width_factor = 0.6 + data[0] * 0.4
100+
face_height_factor = 0.6 + data[1] * 0.4
101+
eye_size = (12 + data[2] * 28) * scale
102+
eye_spacing = (30 + data[3] * 40) * scale
103+
mouth_curve = (-50 + data[4] * 100) * scale
104+
nose_length = (20 + data[5] * 35) * scale
105+
eyebrow_slant = -20 + data[6] * 40
106+
107+
face_width = face_size * face_width_factor * 0.45
108+
face_height = face_size * face_height_factor * 0.55
109+
110+
# Create group for this face
111+
face_group = ET.SubElement(faces_group, f"{{{SVG_NS}}}g")
112+
face_group.set("id", f"face-{name.replace(' ', '-')}")
113+
114+
# Face outline (ellipse)
115+
face_ellipse = ET.SubElement(face_group, f"{{{SVG_NS}}}ellipse")
116+
face_ellipse.set("cx", str(cx))
117+
face_ellipse.set("cy", str(cy))
118+
face_ellipse.set("rx", str(face_width))
119+
face_ellipse.set("ry", str(face_height))
120+
face_ellipse.set("fill", color)
121+
face_ellipse.set("fill-opacity", "0.3")
122+
face_ellipse.set("stroke", color)
123+
face_ellipse.set("stroke-width", str(max(4, 3 * scale)))
124+
125+
# Left eye
126+
left_eye_x = cx - eye_spacing * 0.5
127+
left_eye_y = cy - face_height * 0.2
128+
left_eye = ET.SubElement(face_group, f"{{{SVG_NS}}}circle")
129+
left_eye.set("cx", str(left_eye_x))
130+
left_eye.set("cy", str(left_eye_y))
131+
left_eye.set("r", str(eye_size))
132+
left_eye.set("fill", "white")
133+
left_eye.set("stroke", "#333")
134+
left_eye.set("stroke-width", str(max(3, 2.5 * scale)))
135+
136+
# Left pupil
137+
left_pupil = ET.SubElement(face_group, f"{{{SVG_NS}}}circle")
138+
left_pupil.set("cx", str(left_eye_x))
139+
left_pupil.set("cy", str(left_eye_y))
140+
left_pupil.set("r", str(eye_size * 0.4))
141+
left_pupil.set("fill", "#333")
142+
143+
# Right eye
144+
right_eye_x = cx + eye_spacing * 0.5
145+
right_eye_y = cy - face_height * 0.2
146+
right_eye = ET.SubElement(face_group, f"{{{SVG_NS}}}circle")
147+
right_eye.set("cx", str(right_eye_x))
148+
right_eye.set("cy", str(right_eye_y))
149+
right_eye.set("r", str(eye_size))
150+
right_eye.set("fill", "white")
151+
right_eye.set("stroke", "#333")
152+
right_eye.set("stroke-width", str(max(3, 2.5 * scale)))
153+
154+
# Right pupil
155+
right_pupil = ET.SubElement(face_group, f"{{{SVG_NS}}}circle")
156+
right_pupil.set("cx", str(right_eye_x))
157+
right_pupil.set("cy", str(right_eye_y))
158+
right_pupil.set("r", str(eye_size * 0.4))
159+
right_pupil.set("fill", "#333")
160+
161+
# Eyebrows
162+
brow_length = eye_size * 2.2
163+
brow_y_offset = eye_size + 20 * scale
164+
slant_offset = np.tan(np.radians(eyebrow_slant)) * brow_length * 0.5
165+
166+
# Left eyebrow
167+
left_brow = ET.SubElement(face_group, f"{{{SVG_NS}}}line")
168+
left_brow.set("x1", str(left_eye_x - brow_length * 0.5))
169+
left_brow.set("y1", str(left_eye_y - brow_y_offset + slant_offset))
170+
left_brow.set("x2", str(left_eye_x + brow_length * 0.5))
171+
left_brow.set("y2", str(left_eye_y - brow_y_offset - slant_offset))
172+
left_brow.set("stroke", "#333")
173+
left_brow.set("stroke-width", str(max(5, 4 * scale)))
174+
left_brow.set("stroke-linecap", "round")
175+
176+
# Right eyebrow (mirrored slant)
177+
right_brow = ET.SubElement(face_group, f"{{{SVG_NS}}}line")
178+
right_brow.set("x1", str(right_eye_x - brow_length * 0.5))
179+
right_brow.set("y1", str(right_eye_y - brow_y_offset - slant_offset))
180+
right_brow.set("x2", str(right_eye_x + brow_length * 0.5))
181+
right_brow.set("y2", str(right_eye_y - brow_y_offset + slant_offset))
182+
right_brow.set("stroke", "#333")
183+
right_brow.set("stroke-width", str(max(5, 4 * scale)))
184+
right_brow.set("stroke-linecap", "round")
185+
186+
# Nose (vertical line)
187+
nose = ET.SubElement(face_group, f"{{{SVG_NS}}}line")
188+
nose.set("x1", str(cx))
189+
nose.set("y1", str(cy - nose_length * 0.3))
190+
nose.set("x2", str(cx))
191+
nose.set("y2", str(cy + nose_length * 0.5))
192+
nose.set("stroke", "#333")
193+
nose.set("stroke-width", str(max(4, 3.5 * scale)))
194+
nose.set("stroke-linecap", "round")
195+
196+
# Mouth (quadratic bezier curve)
197+
mouth_y = cy + face_height * 0.45
198+
mouth_width = face_width * 0.55
199+
mouth_path = f"M {cx - mouth_width} {mouth_y} Q {cx} {mouth_y + mouth_curve} {cx + mouth_width} {mouth_y}"
200+
mouth = ET.SubElement(face_group, f"{{{SVG_NS}}}path")
201+
mouth.set("d", mouth_path)
202+
mouth.set("fill", "none")
203+
mouth.set("stroke", "#333")
204+
mouth.set("stroke-width", str(max(5, 4 * scale)))
205+
mouth.set("stroke-linecap", "round")
206+
207+
# Label below face
208+
label_elem = ET.SubElement(face_group, f"{{{SVG_NS}}}text")
209+
label_elem.set("x", str(cx))
210+
label_elem.set("y", str(cy + face_height + 65))
211+
label_elem.set("text-anchor", "middle")
212+
label_elem.set("font-family", "sans-serif")
213+
label_elem.set("font-size", str(max(36, 28 * scale)))
214+
label_elem.set("font-weight", "bold")
215+
label_elem.set("fill", "#333")
216+
label_elem.text = name
217+
218+
# Add title
219+
title_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text")
220+
title_elem.set("x", "2400")
221+
title_elem.set("y", "100")
222+
title_elem.set("text-anchor", "middle")
223+
title_elem.set("font-family", "sans-serif")
224+
title_elem.set("font-size", "72")
225+
title_elem.set("font-weight", "bold")
226+
title_elem.set("fill", "#333")
227+
title_elem.text = "Car Performance Comparison · chernoff-basic · pygal · pyplots.ai"
228+
229+
# Add legend for attributes - positioned closer to faces
230+
legend_y = 1680
231+
legend_x_start = 400
232+
233+
legend_title = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text")
234+
legend_title.set("x", str(legend_x_start))
235+
legend_title.set("y", str(legend_y))
236+
legend_title.set("font-family", "sans-serif")
237+
legend_title.set("font-size", "44")
238+
legend_title.set("font-weight", "bold")
239+
legend_title.set("fill", "#333")
240+
legend_title.text = "Feature Mappings:"
241+
242+
feature_mappings = [
243+
"Face Width = Engine Power",
244+
"Face Height = Fuel Efficiency",
245+
"Eye Size = Safety Rating",
246+
"Eye Spacing = Comfort",
247+
"Mouth Curve = Reliability",
248+
"Nose Length = Price Value",
249+
"Eyebrow Slant = Handling",
250+
]
251+
252+
# Add feature mappings as legend items (two rows)
253+
for i, mapping in enumerate(feature_mappings):
254+
row = i // 4
255+
col = i % 4
256+
text_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text")
257+
text_elem.set("x", str(legend_x_start + col * 1150))
258+
text_elem.set("y", str(legend_y + 80 + row * 70))
259+
text_elem.set("font-family", "sans-serif")
260+
text_elem.set("font-size", "36")
261+
text_elem.set("fill", "#555")
262+
text_elem.text = mapping
263+
264+
# Add color legend for car identification
265+
color_legend_y = legend_y + 230
266+
color_legend_x = 400
267+
268+
color_title = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text")
269+
color_title.set("x", str(color_legend_x))
270+
color_title.set("y", str(color_legend_y))
271+
color_title.set("font-family", "sans-serif")
272+
color_title.set("font-size", "44")
273+
color_title.set("font-weight", "bold")
274+
color_title.set("fill", "#333")
275+
color_title.text = "Cars:"
276+
277+
for i, (name, color) in enumerate(zip(car_names, face_colors, strict=True)):
278+
# Color swatch
279+
swatch = ET.SubElement(svg_tree, f"{{{SVG_NS}}}rect")
280+
swatch.set("x", str(color_legend_x + 150 + i * 850))
281+
swatch.set("y", str(color_legend_y - 30))
282+
swatch.set("width", "40")
283+
swatch.set("height", "40")
284+
swatch.set("fill", color)
285+
swatch.set("rx", "5")
286+
287+
# Car name
288+
name_elem = ET.SubElement(svg_tree, f"{{{SVG_NS}}}text")
289+
name_elem.set("x", str(color_legend_x + 200 + i * 850))
290+
name_elem.set("y", str(color_legend_y))
291+
name_elem.set("font-family", "sans-serif")
292+
name_elem.set("font-size", "36")
293+
name_elem.set("fill", "#555")
294+
name_elem.text = name
295+
296+
# Convert back to string
297+
final_svg = ET.tostring(svg_tree, encoding="unicode")
298+
299+
# Add XML declaration
300+
final_svg = '<?xml version="1.0" encoding="UTF-8"?>\n' + final_svg
301+
302+
# Save as SVG file
303+
with open("plot.svg", "w") as f:
304+
f.write(final_svg)
305+
306+
# Use cairosvg to convert to PNG
307+
cairosvg.svg2png(bytestring=final_svg.encode(), write_to="plot.png", output_width=4800, output_height=2700)
308+
309+
# Save HTML for interactive version
310+
with open("plot.html", "w") as f:
311+
f.write(
312+
"""<!DOCTYPE html>
313+
<html>
314+
<head>
315+
<title>chernoff-basic · pygal · pyplots.ai</title>
316+
<style>
317+
body { margin: 0; padding: 20px; background: #f5f5f5; font-family: sans-serif; }
318+
.container { max-width: 100%; margin: 0 auto; }
319+
h1 { text-align: center; color: #333; }
320+
object { width: 100%; height: auto; }
321+
</style>
322+
</head>
323+
<body>
324+
<div class="container">
325+
<object type="image/svg+xml" data="plot.svg">
326+
Chernoff faces visualization not supported
327+
</object>
328+
</div>
329+
</body>
330+
</html>"""
331+
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
library: pygal
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T11:03:10Z'
4+
updated: '2025-12-31T11:25:36Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617550285
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: 3.1.0
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/pygal/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/pygal/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/pygal/plot.html
13+
quality_score: 90
14+
review:
15+
strengths:
16+
- Excellent visual representation of Chernoff faces with clear differentiation between
17+
car types
18+
- Well-implemented feature mappings with 7 distinct facial attributes
19+
- Good colorblind-safe palette with distinct colors for each car
20+
- Comprehensive legend explaining feature mappings
21+
- Correct title format following spec requirements
22+
- Clean horizontal layout with proper spacing
23+
- Both PNG and HTML output for flexibility
24+
weaknesses:
25+
- Color legend at bottom is redundant since car names are already labeled below
26+
each face
27+
- Some unused vertical space at the bottom of the canvas could be better utilized
28+
- Faces could be slightly larger to better fill the available canvas space

0 commit comments

Comments
 (0)