|
| 1 | +""" pyplots.ai |
| 2 | +bar-3d: 3D Bar Chart |
| 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, save |
| 9 | +from bokeh.models import ColorBar, Label, LinearColorMapper, Range1d |
| 10 | +from bokeh.palettes import Viridis256 |
| 11 | +from bokeh.plotting import figure |
| 12 | +from bokeh.resources import CDN |
| 13 | + |
| 14 | + |
| 15 | +# Data - Quarterly sales by product category (5 products x 4 quarters) |
| 16 | +np.random.seed(42) |
| 17 | + |
| 18 | +products = ["Product A", "Product B", "Product C", "Product D", "Product E"] |
| 19 | +quarters = ["Q1", "Q2", "Q3", "Q4"] |
| 20 | + |
| 21 | +n_products = len(products) |
| 22 | +n_quarters = len(quarters) |
| 23 | + |
| 24 | +# Sales data (thousands of units) - varied to show different bar heights |
| 25 | +sales = np.array( |
| 26 | + [ |
| 27 | + [85, 92, 78, 95], # Product A |
| 28 | + [65, 70, 88, 82], # Product B |
| 29 | + [120, 115, 130, 125], # Product C (higher performer) |
| 30 | + [45, 55, 50, 60], # Product D (lower performer) |
| 31 | + [90, 85, 95, 100], # Product E |
| 32 | + ] |
| 33 | +) |
| 34 | + |
| 35 | +# 3D to 2D isometric projection parameters |
| 36 | +elev_rad = np.radians(25) |
| 37 | +azim_rad = np.radians(45) |
| 38 | + |
| 39 | +# Bar dimensions in 3D space |
| 40 | +bar_width = 0.6 |
| 41 | +bar_depth = 0.6 |
| 42 | +spacing_x = 1.2 # Space between products |
| 43 | +spacing_y = 1.2 # Space between quarters |
| 44 | + |
| 45 | +# Normalize sales for height scaling (0-5 range for good visual proportion) |
| 46 | +sales_normalized = sales / sales.max() * 5 |
| 47 | + |
| 48 | +# Generate all bar faces using inline projection |
| 49 | +all_faces = [] |
| 50 | +z_min, z_max = 0, sales.max() |
| 51 | + |
| 52 | +for i in range(n_products): |
| 53 | + for j in range(n_quarters): |
| 54 | + x_center = i * spacing_x |
| 55 | + y_center = j * spacing_y |
| 56 | + height = sales_normalized[i, j] |
| 57 | + original_value = sales[i, j] |
| 58 | + |
| 59 | + hw = bar_width / 2 |
| 60 | + hd = bar_depth / 2 |
| 61 | + |
| 62 | + # Top face corners (3D) |
| 63 | + top_corners_3d = [ |
| 64 | + (x_center - hw, y_center - hd, height), |
| 65 | + (x_center + hw, y_center - hd, height), |
| 66 | + (x_center + hw, y_center + hd, height), |
| 67 | + (x_center - hw, y_center + hd, height), |
| 68 | + ] |
| 69 | + # Project top face to 2D isometric |
| 70 | + top_projected = [] |
| 71 | + for x, y, z in top_corners_3d: |
| 72 | + x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad) |
| 73 | + y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad) |
| 74 | + x_proj = x_rot |
| 75 | + z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad) |
| 76 | + depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad) |
| 77 | + top_projected.append((x_proj, z_proj, depth)) |
| 78 | + top_xs = [p[0] for p in top_projected] |
| 79 | + top_ys = [p[1] for p in top_projected] |
| 80 | + avg_depth_top = np.mean([p[2] for p in top_projected]) |
| 81 | + all_faces.append((avg_depth_top, "top", top_xs, top_ys, original_value, height)) |
| 82 | + |
| 83 | + # Front face corners (3D) |
| 84 | + front_corners_3d = [ |
| 85 | + (x_center + hw, y_center - hd, 0), |
| 86 | + (x_center + hw, y_center + hd, 0), |
| 87 | + (x_center + hw, y_center + hd, height), |
| 88 | + (x_center + hw, y_center - hd, height), |
| 89 | + ] |
| 90 | + # Project front face to 2D isometric |
| 91 | + front_projected = [] |
| 92 | + for x, y, z in front_corners_3d: |
| 93 | + x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad) |
| 94 | + y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad) |
| 95 | + x_proj = x_rot |
| 96 | + z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad) |
| 97 | + depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad) |
| 98 | + front_projected.append((x_proj, z_proj, depth)) |
| 99 | + front_xs = [p[0] for p in front_projected] |
| 100 | + front_ys = [p[1] for p in front_projected] |
| 101 | + avg_depth_front = np.mean([p[2] for p in front_projected]) |
| 102 | + all_faces.append((avg_depth_front, "front", front_xs, front_ys, original_value, height)) |
| 103 | + |
| 104 | + # Right side face corners (3D) |
| 105 | + right_corners_3d = [ |
| 106 | + (x_center - hw, y_center + hd, 0), |
| 107 | + (x_center + hw, y_center + hd, 0), |
| 108 | + (x_center + hw, y_center + hd, height), |
| 109 | + (x_center - hw, y_center + hd, height), |
| 110 | + ] |
| 111 | + # Project right face to 2D isometric |
| 112 | + right_projected = [] |
| 113 | + for x, y, z in right_corners_3d: |
| 114 | + x_rot = x * np.cos(azim_rad) - y * np.sin(azim_rad) |
| 115 | + y_rot = x * np.sin(azim_rad) + y * np.cos(azim_rad) |
| 116 | + x_proj = x_rot |
| 117 | + z_proj = y_rot * np.sin(elev_rad) + z * np.cos(elev_rad) |
| 118 | + depth = y_rot * np.cos(elev_rad) - z * np.sin(elev_rad) |
| 119 | + right_projected.append((x_proj, z_proj, depth)) |
| 120 | + right_xs = [p[0] for p in right_projected] |
| 121 | + right_ys = [p[1] for p in right_projected] |
| 122 | + avg_depth_right = np.mean([p[2] for p in right_projected]) |
| 123 | + all_faces.append((avg_depth_right, "right", right_xs, right_ys, original_value, height)) |
| 124 | + |
| 125 | +# Sort by depth (back to front - painter's algorithm) |
| 126 | +all_faces.sort(key=lambda f: f[0], reverse=True) |
| 127 | + |
| 128 | +# Color mapping using Viridis colormap based on original sales values |
| 129 | +color_mapper = LinearColorMapper(palette=Viridis256, low=z_min, high=z_max) |
| 130 | + |
| 131 | +# Create Bokeh figure |
| 132 | +p = figure( |
| 133 | + width=4800, |
| 134 | + height=2700, |
| 135 | + title="bar-3d · bokeh · pyplots.ai", |
| 136 | + toolbar_location="right", |
| 137 | + tools="pan,wheel_zoom,box_zoom,reset,save", |
| 138 | +) |
| 139 | + |
| 140 | +# Draw bar faces with shading and semi-transparency to reveal hidden bars |
| 141 | +for _depth, face_type, xs, ys, value, _h in all_faces: |
| 142 | + # Get base color from Viridis colormap |
| 143 | + idx = int((value - z_min) / (z_max - z_min) * 255) |
| 144 | + idx = max(0, min(255, idx)) |
| 145 | + base_color = Viridis256[idx] |
| 146 | + |
| 147 | + # Convert hex to RGB for shading |
| 148 | + r = int(base_color[1:3], 16) |
| 149 | + g = int(base_color[3:5], 16) |
| 150 | + b = int(base_color[5:7], 16) |
| 151 | + |
| 152 | + # Apply shading: top is brightest, front slightly darker, right side darkest |
| 153 | + if face_type == "top": |
| 154 | + factor = 1.0 |
| 155 | + elif face_type == "front": |
| 156 | + factor = 0.85 |
| 157 | + else: # right |
| 158 | + factor = 0.7 |
| 159 | + |
| 160 | + r = int(min(255, r * factor)) |
| 161 | + g = int(min(255, g * factor)) |
| 162 | + b = int(min(255, b * factor)) |
| 163 | + |
| 164 | + color = f"#{r:02x}{g:02x}{b:02x}" |
| 165 | + |
| 166 | + # Semi-transparent bars (alpha=0.75) to reveal hidden bars behind taller ones |
| 167 | + p.patch(x=xs, y=ys, fill_color=color, line_color="#306998", line_width=1.5, line_alpha=0.6, alpha=0.75) |
| 168 | + |
| 169 | +# Calculate plot range from all face coordinates |
| 170 | +all_xs = [x for f in all_faces for x in f[2]] |
| 171 | +all_ys = [y for f in all_faces for y in f[3]] |
| 172 | + |
| 173 | +x_min, x_max = min(all_xs), max(all_xs) |
| 174 | +y_min, y_max = min(all_ys), max(all_ys) |
| 175 | + |
| 176 | +x_pad = (x_max - x_min) * 0.18 |
| 177 | +y_pad = (y_max - y_min) * 0.15 |
| 178 | + |
| 179 | +p.x_range = Range1d(x_min - x_pad * 1.5, x_max + x_pad * 1.5) |
| 180 | +p.y_range = Range1d(y_min - y_pad * 1.5, y_max + y_pad) |
| 181 | + |
| 182 | +# Hide default axes for cleaner 3D projection look |
| 183 | +p.xaxis.visible = False |
| 184 | +p.yaxis.visible = False |
| 185 | + |
| 186 | +# Add 3D axis lines from origin - inline projection |
| 187 | +origin_3d = (-0.5, -0.5, 0) |
| 188 | +origin_x_rot = origin_3d[0] * np.cos(azim_rad) - origin_3d[1] * np.sin(azim_rad) |
| 189 | +origin_y_rot = origin_3d[0] * np.sin(azim_rad) + origin_3d[1] * np.cos(azim_rad) |
| 190 | +origin_x = origin_x_rot |
| 191 | +origin_y = origin_y_rot * np.sin(elev_rad) + origin_3d[2] * np.cos(elev_rad) |
| 192 | + |
| 193 | +axis_color = "#444444" |
| 194 | +axis_width = 4 |
| 195 | + |
| 196 | +# X-axis (products direction) - inline projection |
| 197 | +x_axis_end_3d = ((n_products - 1) * spacing_x + 0.8, -0.5, 0) |
| 198 | +x_axis_x_rot = x_axis_end_3d[0] * np.cos(azim_rad) - x_axis_end_3d[1] * np.sin(azim_rad) |
| 199 | +x_axis_y_rot = x_axis_end_3d[0] * np.sin(azim_rad) + x_axis_end_3d[1] * np.cos(azim_rad) |
| 200 | +x_axis_end_x = x_axis_x_rot |
| 201 | +x_axis_end_y = x_axis_y_rot * np.sin(elev_rad) + x_axis_end_3d[2] * np.cos(elev_rad) |
| 202 | +p.line(x=[origin_x, x_axis_end_x], y=[origin_y, x_axis_end_y], line_color=axis_color, line_width=axis_width) |
| 203 | + |
| 204 | +# Y-axis (quarters direction) - inline projection |
| 205 | +y_axis_end_3d = (-0.5, (n_quarters - 1) * spacing_y + 0.8, 0) |
| 206 | +y_axis_x_rot = y_axis_end_3d[0] * np.cos(azim_rad) - y_axis_end_3d[1] * np.sin(azim_rad) |
| 207 | +y_axis_y_rot = y_axis_end_3d[0] * np.sin(azim_rad) + y_axis_end_3d[1] * np.cos(azim_rad) |
| 208 | +y_axis_end_x = y_axis_x_rot |
| 209 | +y_axis_end_y = y_axis_y_rot * np.sin(elev_rad) + y_axis_end_3d[2] * np.cos(elev_rad) |
| 210 | +p.line(x=[origin_x, y_axis_end_x], y=[origin_y, y_axis_end_y], line_color=axis_color, line_width=axis_width) |
| 211 | + |
| 212 | +# Z-axis (height direction) - inline projection |
| 213 | +z_axis_end_3d = (-0.5, -0.5, 5.8) |
| 214 | +z_axis_x_rot = z_axis_end_3d[0] * np.cos(azim_rad) - z_axis_end_3d[1] * np.sin(azim_rad) |
| 215 | +z_axis_y_rot = z_axis_end_3d[0] * np.sin(azim_rad) + z_axis_end_3d[1] * np.cos(azim_rad) |
| 216 | +z_axis_end_x = z_axis_x_rot |
| 217 | +z_axis_end_y = z_axis_y_rot * np.sin(elev_rad) + z_axis_end_3d[2] * np.cos(elev_rad) |
| 218 | +p.line(x=[origin_x, z_axis_end_x], y=[origin_y, z_axis_end_y], line_color=axis_color, line_width=axis_width) |
| 219 | + |
| 220 | +# Add product labels along X-axis |
| 221 | +for i, product in enumerate(products): |
| 222 | + label_pos_3d = (i * spacing_x, -1.0, 0) |
| 223 | + lx_rot = label_pos_3d[0] * np.cos(azim_rad) - label_pos_3d[1] * np.sin(azim_rad) |
| 224 | + ly_rot = label_pos_3d[0] * np.sin(azim_rad) + label_pos_3d[1] * np.cos(azim_rad) |
| 225 | + label_x = lx_rot |
| 226 | + label_y = ly_rot * np.sin(elev_rad) + label_pos_3d[2] * np.cos(elev_rad) |
| 227 | + label = Label( |
| 228 | + x=label_x, y=label_y - 0.3, text=product, text_font_size="26pt", text_color="#333333", text_align="center" |
| 229 | + ) |
| 230 | + p.add_layout(label) |
| 231 | + |
| 232 | +# Add quarter labels along Y-axis |
| 233 | +for j, quarter in enumerate(quarters): |
| 234 | + label_pos_3d = (-1.0, j * spacing_y, 0) |
| 235 | + lx_rot = label_pos_3d[0] * np.cos(azim_rad) - label_pos_3d[1] * np.sin(azim_rad) |
| 236 | + ly_rot = label_pos_3d[0] * np.sin(azim_rad) + label_pos_3d[1] * np.cos(azim_rad) |
| 237 | + label_x = lx_rot |
| 238 | + label_y = ly_rot * np.sin(elev_rad) + label_pos_3d[2] * np.cos(elev_rad) |
| 239 | + label = Label( |
| 240 | + x=label_x - 0.4, y=label_y, text=quarter, text_font_size="26pt", text_color="#333333", text_align="right" |
| 241 | + ) |
| 242 | + p.add_layout(label) |
| 243 | + |
| 244 | +# Add axis titles |
| 245 | +products_label = Label( |
| 246 | + x=x_axis_end_x + 0.3, |
| 247 | + y=x_axis_end_y - 0.4, |
| 248 | + text="Products", |
| 249 | + text_font_size="32pt", |
| 250 | + text_color="#333333", |
| 251 | + text_font_style="bold", |
| 252 | +) |
| 253 | +p.add_layout(products_label) |
| 254 | + |
| 255 | +quarters_label = Label( |
| 256 | + x=y_axis_end_x - 1.0, |
| 257 | + y=y_axis_end_y - 0.3, |
| 258 | + text="Quarters", |
| 259 | + text_font_size="32pt", |
| 260 | + text_color="#333333", |
| 261 | + text_font_style="bold", |
| 262 | +) |
| 263 | +p.add_layout(quarters_label) |
| 264 | + |
| 265 | +# Add color bar for sales value scale |
| 266 | +color_bar = ColorBar( |
| 267 | + color_mapper=color_mapper, |
| 268 | + width=60, |
| 269 | + location=(0, 0), |
| 270 | + title="Sales (thousands)", |
| 271 | + title_text_font_size="28pt", |
| 272 | + major_label_text_font_size="22pt", |
| 273 | + title_standoff=20, |
| 274 | + margin=40, |
| 275 | + padding=20, |
| 276 | +) |
| 277 | +p.add_layout(color_bar, "right") |
| 278 | + |
| 279 | +# Title styling for large canvas |
| 280 | +p.title.text_font_size = "44pt" |
| 281 | +p.title.text_font_style = "bold" |
| 282 | + |
| 283 | +# Grid styling - subtle |
| 284 | +p.xgrid.grid_line_color = "#dddddd" |
| 285 | +p.ygrid.grid_line_color = "#dddddd" |
| 286 | +p.xgrid.grid_line_alpha = 0.2 |
| 287 | +p.ygrid.grid_line_alpha = 0.2 |
| 288 | +p.xgrid.grid_line_dash = [6, 4] |
| 289 | +p.ygrid.grid_line_dash = [6, 4] |
| 290 | + |
| 291 | +# Background styling |
| 292 | +p.background_fill_color = "#f9f9f9" |
| 293 | +p.border_fill_color = "white" |
| 294 | +p.outline_line_color = None |
| 295 | +p.min_border_right = 220 |
| 296 | + |
| 297 | +# Save PNG |
| 298 | +export_png(p, filename="plot.png") |
| 299 | + |
| 300 | +# Save HTML for interactive version |
| 301 | +save(p, filename="plot.html", resources=CDN, title="bar-3d · bokeh · pyplots.ai") |
0 commit comments