Skip to content

Commit b6fb986

Browse files
feat(letsplot): implement bar-3d (#2901)
## Implementation: `bar-3d` - letsplot Implements the **letsplot** version of `bar-3d`. **File:** `plots/bar-3d/implementations/letsplot.py` **Parent Issue:** #2857 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20608481317)* --------- 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 bc6097f commit b6fb986

2 files changed

Lines changed: 330 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
""" pyplots.ai
2+
bar-3d: 3D Bar Chart
3+
Library: letsplot 4.8.2 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from lets_plot import (
10+
LetsPlot,
11+
aes,
12+
element_blank,
13+
element_text,
14+
geom_polygon,
15+
geom_segment,
16+
geom_text,
17+
ggplot,
18+
ggsize,
19+
labs,
20+
layer_tooltips,
21+
scale_fill_viridis,
22+
theme,
23+
theme_minimal,
24+
)
25+
from lets_plot.export import ggsave
26+
27+
28+
LetsPlot.setup_html()
29+
30+
# Data - Quarterly sales performance across product categories
31+
np.random.seed(42)
32+
33+
products = ["Electronics", "Clothing", "Home", "Sports", "Books"]
34+
quarters = ["Q1", "Q2", "Q3", "Q4"]
35+
36+
# Revenue data in millions
37+
revenue = np.array(
38+
[
39+
[45, 52, 48, 65], # Electronics
40+
[32, 38, 42, 55], # Clothing
41+
[28, 30, 35, 40], # Home
42+
[20, 35, 45, 30], # Sports
43+
[15, 18, 22, 28], # Books
44+
]
45+
)
46+
47+
# 3D to 2D isometric projection settings
48+
elev = np.radians(25) # elevation angle
49+
azim = np.radians(-50) # azimuth angle
50+
cos_azim, sin_azim = np.cos(azim), np.sin(azim)
51+
cos_elev, sin_elev = np.cos(elev), np.sin(elev)
52+
53+
# Bar dimensions - good spacing for clarity
54+
bar_width = 0.5
55+
bar_depth = 0.5
56+
spacing_x = 1.4
57+
spacing_y = 1.6
58+
59+
# Create bar polygons (each bar is a 3D box with visible faces)
60+
bar_polygons = []
61+
bar_id = 0
62+
63+
for i, product in enumerate(products):
64+
for j, quarter in enumerate(quarters):
65+
x_base = j * spacing_x
66+
y_base = i * spacing_y
67+
height = revenue[i, j]
68+
69+
# Define the 8 corners of the bar
70+
corners_3d = {
71+
"front_bottom_left": (x_base, y_base, 0),
72+
"front_bottom_right": (x_base + bar_width, y_base, 0),
73+
"front_top_left": (x_base, y_base, height),
74+
"front_top_right": (x_base + bar_width, y_base, height),
75+
"back_bottom_left": (x_base, y_base + bar_depth, 0),
76+
"back_bottom_right": (x_base + bar_width, y_base + bar_depth, 0),
77+
"back_top_left": (x_base, y_base + bar_depth, height),
78+
"back_top_right": (x_base + bar_width, y_base + bar_depth, height),
79+
}
80+
81+
# Project all corners to 2D (inline projection math)
82+
corners_2d = {}
83+
for name, (cx, cy, cz) in corners_3d.items():
84+
x_rot = cx * cos_azim - cy * sin_azim
85+
y_rot = cx * sin_azim + cy * cos_azim
86+
px = x_rot
87+
py = y_rot * sin_elev + cz * cos_elev
88+
corners_2d[name] = (px, py)
89+
90+
# Calculate bar center depth for ordering (inline projection)
91+
center_x = x_base + bar_width / 2
92+
center_y = y_base + bar_depth / 2
93+
center_z = height / 2
94+
cx_rot = center_x * cos_azim - center_y * sin_azim
95+
cy_rot = center_x * sin_azim + center_y * cos_azim
96+
bar_depth_value = cy_rot * cos_elev - center_z * sin_elev
97+
98+
# Define the 3 visible faces (front, top, right side)
99+
front_face = [
100+
corners_2d["front_bottom_left"],
101+
corners_2d["front_bottom_right"],
102+
corners_2d["front_top_right"],
103+
corners_2d["front_top_left"],
104+
]
105+
106+
top_face = [
107+
corners_2d["front_top_left"],
108+
corners_2d["front_top_right"],
109+
corners_2d["back_top_right"],
110+
corners_2d["back_top_left"],
111+
]
112+
113+
right_face = [
114+
corners_2d["front_bottom_right"],
115+
corners_2d["back_bottom_right"],
116+
corners_2d["back_top_right"],
117+
corners_2d["front_top_right"],
118+
]
119+
120+
# Add each face as a polygon
121+
for face_name, face_coords in [("front", front_face), ("top", top_face), ("right", right_face)]:
122+
for k, (fx, fy) in enumerate(face_coords):
123+
bar_polygons.append(
124+
{
125+
"x": fx,
126+
"y": fy,
127+
"bar_id": bar_id,
128+
"face": face_name,
129+
"face_id": f"{bar_id}_{face_name}",
130+
"height": height,
131+
"depth": bar_depth_value,
132+
"product": product,
133+
"quarter": quarter,
134+
"order": k,
135+
}
136+
)
137+
bar_id += 1
138+
139+
df_bars = pd.DataFrame(bar_polygons)
140+
141+
# Sort by depth (back to front) for proper rendering order
142+
df_bars = df_bars.sort_values(["depth", "face_id", "order"])
143+
144+
# Create floor grid for depth perception
145+
grid_lines = []
146+
x_min, x_max = -0.3, (len(quarters) - 1) * spacing_x + bar_width + 0.3
147+
y_min, y_max = -0.3, (len(products) - 1) * spacing_y + bar_depth + 0.3
148+
z_floor = -3
149+
150+
# Grid lines parallel to X axis
151+
for i in range(len(products) + 1):
152+
y_line = i * spacing_y - 0.3 + bar_depth / 2
153+
# Inline projection for start point
154+
x_rot_s = x_min * cos_azim - y_line * sin_azim
155+
y_rot_s = x_min * sin_azim + y_line * cos_azim
156+
start_x = x_rot_s
157+
start_y = y_rot_s * sin_elev + z_floor * cos_elev
158+
# Inline projection for end point
159+
x_rot_e = x_max * cos_azim - y_line * sin_azim
160+
y_rot_e = x_max * sin_azim + y_line * cos_azim
161+
end_x = x_rot_e
162+
end_y = y_rot_e * sin_elev + z_floor * cos_elev
163+
grid_lines.append({"x": start_x, "y": start_y, "xend": end_x, "yend": end_y})
164+
165+
# Grid lines parallel to Y axis
166+
for j in range(len(quarters) + 1):
167+
x_line = j * spacing_x - 0.3 + bar_width / 2
168+
# Inline projection for start point
169+
x_rot_s = x_line * cos_azim - y_min * sin_azim
170+
y_rot_s = x_line * sin_azim + y_min * cos_azim
171+
start_x = x_rot_s
172+
start_y = y_rot_s * sin_elev + z_floor * cos_elev
173+
# Inline projection for end point
174+
x_rot_e = x_line * cos_azim - y_max * sin_azim
175+
y_rot_e = x_line * sin_azim + y_max * cos_azim
176+
end_x = x_rot_e
177+
end_y = y_rot_e * sin_elev + z_floor * cos_elev
178+
grid_lines.append({"x": start_x, "y": start_y, "xend": end_x, "yend": end_y})
179+
180+
df_grid = pd.DataFrame(grid_lines)
181+
182+
# Create axis lines from corner (inline projection)
183+
origin_x, origin_y, origin_z = x_min - 1.5, y_min - 1.5, z_floor
184+
x_rot_o = origin_x * cos_azim - origin_y * sin_azim
185+
y_rot_o = origin_x * sin_azim + origin_y * cos_azim
186+
axis_origin_x = x_rot_o
187+
axis_origin_y = y_rot_o * sin_elev + origin_z * cos_elev
188+
189+
axis_len_x = (x_max - x_min) * 0.4
190+
axis_len_y = (y_max - y_min) * 0.4
191+
axis_len_z = revenue.max() * 0.4
192+
193+
# X axis end (inline projection)
194+
x_end_3d = origin_x + axis_len_x
195+
x_rot_xe = x_end_3d * cos_azim - origin_y * sin_azim
196+
y_rot_xe = x_end_3d * sin_azim + origin_y * cos_azim
197+
x_axis_end_x = x_rot_xe
198+
x_axis_end_y = y_rot_xe * sin_elev + origin_z * cos_elev
199+
200+
# Y axis end (inline projection)
201+
y_end_3d = origin_y + axis_len_y
202+
x_rot_ye = origin_x * cos_azim - y_end_3d * sin_azim
203+
y_rot_ye = origin_x * sin_azim + y_end_3d * cos_azim
204+
y_axis_end_x = x_rot_ye
205+
y_axis_end_y = y_rot_ye * sin_elev + origin_z * cos_elev
206+
207+
# Z axis end (inline projection)
208+
z_end_3d = origin_z + axis_len_z
209+
z_axis_end_x = x_rot_o
210+
z_axis_end_y = y_rot_o * sin_elev + z_end_3d * cos_elev
211+
212+
axes_df = pd.DataFrame(
213+
{
214+
"x": [axis_origin_x, axis_origin_x, axis_origin_x],
215+
"y": [axis_origin_y, axis_origin_y, axis_origin_y],
216+
"xend": [x_axis_end_x, y_axis_end_x, z_axis_end_x],
217+
"yend": [x_axis_end_y, y_axis_end_y, z_axis_end_y],
218+
}
219+
)
220+
221+
# Revenue label on Z axis (positioned at top of z-axis, slightly right to avoid cutoff)
222+
z_label_df = pd.DataFrame({"x": [z_axis_end_x + 0.3], "y": [z_axis_end_y + 1.0], "label": ["Revenue ($M)"]})
223+
224+
# Product labels positioned at front-left, well away from the bars
225+
product_labels = []
226+
for i, product in enumerate(products):
227+
y_pos = i * spacing_y + bar_depth / 2
228+
# Position labels at front-left corner, outside the grid area
229+
label_x_3d = -2.5
230+
label_y_3d = -3.5
231+
x_rot_p = label_x_3d * cos_azim - label_y_3d * sin_azim
232+
y_rot_p = label_x_3d * sin_azim + label_y_3d * cos_azim
233+
# Stack vertically with consistent spacing
234+
px = x_rot_p
235+
py = y_rot_p * sin_elev + z_floor * cos_elev + i * 2.5
236+
product_labels.append({"x": px, "y": py, "label": product})
237+
df_product_labels = pd.DataFrame(product_labels)
238+
239+
# Quarter labels positioned further forward to avoid overlap with bars
240+
quarter_labels = []
241+
for j, quarter in enumerate(quarters):
242+
x_pos = j * spacing_x + bar_width / 2
243+
# Inline projection - move labels much further forward (-4.0 instead of -2.0)
244+
label_y_3d = -4.0
245+
x_rot_q = x_pos * cos_azim - label_y_3d * sin_azim
246+
y_rot_q = x_pos * sin_azim + label_y_3d * cos_azim
247+
px = x_rot_q
248+
py = y_rot_q * sin_elev + z_floor * cos_elev
249+
quarter_labels.append({"x": px, "y": py, "label": quarter})
250+
df_quarter_labels = pd.DataFrame(quarter_labels)
251+
252+
# Create the plot
253+
plot = (
254+
ggplot()
255+
# Floor grid
256+
+ geom_segment(
257+
data=df_grid, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#CCCCCC", size=0.5, alpha=0.5
258+
)
259+
# Axis lines
260+
+ geom_segment(
261+
data=axes_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#666666", size=1.5, alpha=0.8
262+
)
263+
# Bar faces (rendered back to front due to sorting)
264+
+ geom_polygon(
265+
data=df_bars,
266+
mapping=aes(x="x", y="y", group="face_id", fill="height"),
267+
color="#333333",
268+
size=0.6,
269+
alpha=0.85,
270+
tooltips=layer_tooltips().line("@product").line("@quarter").line("Revenue|$@{height}M"),
271+
)
272+
# Product labels (positioned to left of bars, left-aligned to avoid cutoff)
273+
+ geom_text(
274+
data=df_product_labels,
275+
mapping=aes(x="x", y="y", label="label"),
276+
color="#1a1a1a",
277+
size=11,
278+
hjust=0,
279+
fontface="bold",
280+
)
281+
# Quarter labels (further forward)
282+
+ geom_text(
283+
data=df_quarter_labels, mapping=aes(x="x", y="y", label="label"), color="#1a1a1a", size=11, fontface="bold"
284+
)
285+
# Revenue axis label
286+
+ geom_text(data=z_label_df, mapping=aes(x="x", y="y", label="label"), color="#1a1a1a", size=12, fontface="bold")
287+
+ scale_fill_viridis(name="Revenue ($M)", option="viridis")
288+
+ labs(x="", y="", title="bar-3d \u00b7 letsplot \u00b7 pyplots.ai")
289+
+ theme_minimal()
290+
+ theme(
291+
axis_title=element_blank(),
292+
axis_text=element_blank(),
293+
axis_ticks=element_blank(),
294+
panel_grid=element_blank(),
295+
plot_title=element_text(size=32, face="bold"),
296+
legend_text=element_text(size=16),
297+
legend_title=element_text(size=18),
298+
)
299+
+ ggsize(1600, 900)
300+
)
301+
302+
# Save PNG (scale=3 gives 4800x2700)
303+
ggsave(plot, "plot.png", path=".", scale=3)
304+
305+
# Save HTML for interactivity (interactive rotation available via HTML export)
306+
ggsave(plot, "plot.html", path=".")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
library: letsplot
2+
specification_id: bar-3d
3+
created: '2025-12-31T00:00:17Z'
4+
updated: '2025-12-31T00:21:37Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20608481317
7+
issue: 2857
8+
python_version: 3.13.11
9+
library_version: 4.8.2
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/bar-3d/letsplot/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/bar-3d/letsplot/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/bar-3d/letsplot/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent 3D isometric projection with clear depth perception using geom_polygon
17+
for bar faces
18+
- Good use of viridis color scale reinforcing z-values for colorblind accessibility
19+
- Clean separation of product categories and quarters with readable labels
20+
- Interactive tooltips provide rich data exploration when viewing HTML export
21+
- Well-structured code with proper depth sorting for correct face rendering
22+
weaknesses:
23+
- Product labels on left side appear stacked vertically rather than aligned with
24+
their corresponding bar rows in the 3D space

0 commit comments

Comments
 (0)