Skip to content

Commit ab70787

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

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
""" pyplots.ai
2+
chernoff-basic: Chernoff Faces for Multivariate Data
3+
Library: bokeh 3.8.1 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
from bokeh.io import export_png
9+
from bokeh.models import ColumnDataSource, HoverTool, Label
10+
from bokeh.plotting import figure
11+
12+
13+
# Generate synthetic company performance data (4 metrics for 12 companies)
14+
# Metrics: Revenue Growth, Profit Margin, Customer Satisfaction, Market Share
15+
np.random.seed(42)
16+
17+
# Three company sectors with different profiles
18+
sectors = ["Tech", "Retail", "Energy"]
19+
sector_idx = np.array([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2])
20+
21+
# Tech companies: high growth, high margin
22+
tech_data = np.column_stack(
23+
[
24+
np.random.uniform(0.6, 1.0, 4), # Revenue Growth
25+
np.random.uniform(0.5, 0.9, 4), # Profit Margin
26+
np.random.uniform(0.6, 0.95, 4), # Customer Satisfaction
27+
np.random.uniform(0.3, 0.7, 4), # Market Share
28+
]
29+
)
30+
31+
# Retail companies: moderate growth, moderate margin
32+
retail_data = np.column_stack(
33+
[
34+
np.random.uniform(0.2, 0.5, 4), # Revenue Growth
35+
np.random.uniform(0.15, 0.4, 4), # Profit Margin
36+
np.random.uniform(0.5, 0.8, 4), # Customer Satisfaction
37+
np.random.uniform(0.4, 0.8, 4), # Market Share
38+
]
39+
)
40+
41+
# Energy companies: low growth, variable margin
42+
energy_data = np.column_stack(
43+
[
44+
np.random.uniform(0.05, 0.3, 4), # Revenue Growth
45+
np.random.uniform(0.3, 0.6, 4), # Profit Margin
46+
np.random.uniform(0.3, 0.6, 4), # Customer Satisfaction
47+
np.random.uniform(0.5, 0.9, 4), # Market Share
48+
]
49+
)
50+
51+
data = np.vstack([tech_data, retail_data, energy_data])
52+
53+
# Normalize each feature to 0-1
54+
data_norm = (data - data.min(axis=0)) / (data.max(axis=0) - data.min(axis=0) + 1e-10)
55+
56+
# Colors for sectors
57+
colors = ["#306998", "#FFD43B", "#8B4513"]
58+
59+
# Store face center data for hover tooltips using ColumnDataSource
60+
face_centers_x = []
61+
face_centers_y = []
62+
face_labels = []
63+
face_revenue = []
64+
face_margin = []
65+
face_satisfaction = []
66+
face_market_share = []
67+
68+
# Create figure with 4x3 grid for 12 faces
69+
p = figure(
70+
width=4800,
71+
height=2700,
72+
title="chernoff-basic · bokeh · pyplots.ai",
73+
x_range=(-0.1, 4.1),
74+
y_range=(-0.2, 3.2),
75+
tools="",
76+
)
77+
78+
# Style
79+
p.title.text_font_size = "32pt"
80+
p.title.align = "center"
81+
p.xaxis.visible = False
82+
p.yaxis.visible = False
83+
p.xgrid.visible = False
84+
p.ygrid.visible = False
85+
p.outline_line_color = None
86+
p.background_fill_color = "#FAFAFA"
87+
88+
# Draw faces in a 4x3 grid (inline, no helper function)
89+
face_size = 0.4
90+
for i, (features, sec_idx) in enumerate(zip(data_norm, sector_idx, strict=True)):
91+
col = i % 4
92+
row = 2 - i // 4 # Start from top row
93+
cx = col + 0.5
94+
cy = row + 0.5
95+
color = colors[sec_idx]
96+
label_text = f"{sectors[sec_idx]} #{i % 4 + 1}"
97+
98+
# Store data for hover tooltip
99+
face_centers_x.append(cx)
100+
face_centers_y.append(cy)
101+
face_labels.append(label_text)
102+
face_revenue.append(f"{data[i, 0] * 100:.1f}%")
103+
face_margin.append(f"{data[i, 1] * 100:.1f}%")
104+
face_satisfaction.append(f"{data[i, 2] * 100:.1f}%")
105+
face_market_share.append(f"{data[i, 3] * 100:.1f}%")
106+
107+
# Features mapping:
108+
# - revenue_growth (features[0]) -> face width
109+
# - profit_margin (features[1]) -> face height
110+
# - customer_satisfaction (features[2]) -> eye size
111+
# - market_share (features[3]) -> mouth curvature
112+
face_width = (0.3 + features[0] * 0.3) * face_size
113+
face_height = (0.35 + features[1] * 0.25) * face_size
114+
eye_size = (0.03 + features[2] * 0.05) * face_size
115+
mouth_curve = features[3]
116+
117+
# Draw face outline (ellipse approximation using patches)
118+
theta = np.linspace(0, 2 * np.pi, 50)
119+
face_x = cx + face_width * np.cos(theta)
120+
face_y = cy + face_height * np.sin(theta)
121+
p.patch(face_x, face_y, fill_color=color, fill_alpha=0.3, line_color=color, line_width=3)
122+
123+
# Draw eyes
124+
eye_spacing = face_width * 0.5
125+
eye_y = cy + face_height * 0.25
126+
eye_theta = np.linspace(0, 2 * np.pi, 30)
127+
128+
# Left eye
129+
left_eye_x = cx - eye_spacing
130+
left_ex = left_eye_x + eye_size * np.cos(eye_theta)
131+
left_ey = eye_y + eye_size * np.sin(eye_theta)
132+
p.patch(left_ex, left_ey, fill_color="white", line_color="#333333", line_width=2)
133+
134+
# Left pupil
135+
pupil_size = eye_size * 0.5
136+
left_px = left_eye_x + pupil_size * np.cos(eye_theta) * 0.6
137+
left_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6
138+
p.patch(left_px, left_py, fill_color="#333333", line_color="#333333")
139+
140+
# Right eye
141+
right_eye_x = cx + eye_spacing
142+
right_ex = right_eye_x + eye_size * np.cos(eye_theta)
143+
right_ey = eye_y + eye_size * np.sin(eye_theta)
144+
p.patch(right_ex, right_ey, fill_color="white", line_color="#333333", line_width=2)
145+
146+
# Right pupil
147+
right_px = right_eye_x + pupil_size * np.cos(eye_theta) * 0.6
148+
right_py = eye_y + pupil_size * np.sin(eye_theta) * 0.6
149+
p.patch(right_px, right_py, fill_color="#333333", line_color="#333333")
150+
151+
# Draw eyebrows
152+
brow_y = eye_y + eye_size * 1.8
153+
brow_width = eye_size * 1.2
154+
eyebrow_slant = (features[0] - 0.5) * 0.02 * face_size
155+
156+
p.line(
157+
[left_eye_x - brow_width, left_eye_x + brow_width],
158+
[brow_y + eyebrow_slant, brow_y - eyebrow_slant],
159+
line_color="#333333",
160+
line_width=3,
161+
)
162+
p.line(
163+
[right_eye_x - brow_width, right_eye_x + brow_width],
164+
[brow_y - eyebrow_slant, brow_y + eyebrow_slant],
165+
line_color="#333333",
166+
line_width=3,
167+
)
168+
169+
# Draw nose
170+
nose_length = (0.02 + features[1] * 0.03) * face_size
171+
nose_y_top = cy + face_height * 0.1
172+
nose_y_bottom = cy - face_height * 0.1
173+
p.line([cx, cx], [nose_y_top, nose_y_bottom], line_color="#333333", line_width=2)
174+
p.line(
175+
[cx - nose_length * 0.5, cx, cx + nose_length * 0.5],
176+
[nose_y_bottom, nose_y_bottom - nose_length * 0.3, nose_y_bottom],
177+
line_color="#333333",
178+
line_width=2,
179+
)
180+
181+
# Draw mouth (curved based on market_share)
182+
mouth_y = cy - face_height * 0.4
183+
mouth_width = face_width * 0.5
184+
mouth_x = np.linspace(cx - mouth_width, cx + mouth_width, 20)
185+
curve_amount = (mouth_curve - 0.5) * 0.08 * face_size
186+
mouth_y_curve = mouth_y + curve_amount * (1 - ((mouth_x - cx) / mouth_width) ** 2) * 4
187+
p.line(mouth_x, mouth_y_curve, line_color="#333333", line_width=3)
188+
189+
# Add label below face
190+
label_obj = Label(
191+
x=cx,
192+
y=cy - face_height - 0.1,
193+
text=label_text,
194+
text_align="center",
195+
text_font_size="20pt",
196+
text_color="#333333",
197+
)
198+
p.add_layout(label_obj)
199+
200+
# Create ColumnDataSource for hover tooltips (Bokeh-specific feature)
201+
hover_source = ColumnDataSource(
202+
data={
203+
"x": face_centers_x,
204+
"y": face_centers_y,
205+
"label": face_labels,
206+
"revenue": face_revenue,
207+
"margin": face_margin,
208+
"satisfaction": face_satisfaction,
209+
"market_share": face_market_share,
210+
}
211+
)
212+
213+
# Add invisible scatter for hover interaction
214+
hover_renderer = p.scatter("x", "y", source=hover_source, size=80, fill_alpha=0, line_alpha=0)
215+
216+
# Add HoverTool for interactivity (distinctive Bokeh feature)
217+
hover_tool = HoverTool(
218+
renderers=[hover_renderer],
219+
tooltips=[
220+
("Company", "@label"),
221+
("Revenue Growth", "@revenue"),
222+
("Profit Margin", "@margin"),
223+
("Satisfaction", "@satisfaction"),
224+
("Market Share", "@market_share"),
225+
],
226+
)
227+
p.add_tools(hover_tool)
228+
229+
# Add legend manually using patches and labels (positioned below grid)
230+
legend_y_base = 2.95
231+
legend_x_positions = [0.5, 1.5, 2.5]
232+
for i, (name, color) in enumerate(zip(sectors, colors, strict=True)):
233+
lx_center = legend_x_positions[i]
234+
theta = np.linspace(0, 2 * np.pi, 30)
235+
lx = lx_center + 0.06 * np.cos(theta)
236+
ly = legend_y_base + 0.06 * np.sin(theta)
237+
p.patch(lx, ly, fill_color=color, fill_alpha=0.3, line_color=color, line_width=2)
238+
legend_label = Label(
239+
x=lx_center + 0.12, y=legend_y_base - 0.02, text=name, text_font_size="20pt", text_color="#333333"
240+
)
241+
p.add_layout(legend_label)
242+
243+
# Add subtitle with feature mapping explanation (increased font size)
244+
subtitle = Label(
245+
x=2.0,
246+
y=-0.02,
247+
text="Face width=Revenue Growth, Face height=Profit Margin, Eye size=Satisfaction, Mouth=Market Share",
248+
text_align="center",
249+
text_font_size="22pt",
250+
text_color="#666666",
251+
)
252+
p.add_layout(subtitle)
253+
254+
# Save
255+
export_png(p, filename="plot.png")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
library: bokeh
2+
specification_id: chernoff-basic
3+
created: '2025-12-31T11:00:13Z'
4+
updated: '2025-12-31T14:50:05Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20617520899
7+
issue: 3003
8+
python_version: 3.13.11
9+
library_version: 3.8.1
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/bokeh/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/bokeh/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/chernoff-basic/bokeh/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Clear visual differentiation between sectors through color and facial expression
17+
patterns
18+
- Excellent business context with realistic company performance metrics
19+
- Proper normalization of data as spec requires
20+
- 'Good use of Bokeh-specific features: ColumnDataSource, HoverTool for interactivity'
21+
- Feature mapping subtitle explains what each facial feature represents
22+
- Well-organized 4x3 grid layout makes comparison easy
23+
weaknesses:
24+
- Legend positioned too far from the faces (at very top of plot)
25+
- Faces could be slightly larger to better utilize the canvas space

0 commit comments

Comments
 (0)