|
| 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=".") |
0 commit comments