3D Pie Plot

Hello :grinning_face: ,

A high-level function to plot a 3D pie plot.

from bokeh.plotting import figure, show, output_file
from bokeh.models import Label, ColumnDataSource, GlobalInlineStyleSheet
from bokeh.layouts import row
import numpy as np

def get_dark_stylesheet():
    """Create a new dark theme stylesheet instance."""
    return GlobalInlineStyleSheet(css=""" 
        html, body, .bk, .bk-root {
            background-color: #343838; 
            margin: 0; 
            padding: 0; 
            height: 100%; 
            color: white; 
            font-family: 'Consolas', 'Courier New', monospace; 
        } 
        .bk { color: white; } 
        .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, 
        .bk-label, .bk-title, .bk-legend, .bk-axis-label { 
            color: white !important; 
        } 
        .bk-input::placeholder { color: #aaaaaa !important; } 
    """)

def get_light_stylesheet():
    """Create a new light theme stylesheet instance."""
    return GlobalInlineStyleSheet(css=""" 
        html, body, .bk, .bk-root {
            background-color: #FDFBD4; 
            margin: 0; 
            padding: 0; 
            height: 100%; 
            color: black; 
            font-family: 'Consolas', 'Courier New', monospace; 
        } 
        .bk { color: black; } 
        .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, 
        .bk-label, .bk-title, .bk-legend, .bk-axis-label { 
            color: black !important; 
        } 
        .bk-input::placeholder { color: #555555 !important; } 
    """)

def darken_color(hex_color, factor=0.7):
    """Darken a hex color by a factor."""
    hex_color = hex_color.lstrip('#')
    r, g, b = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
    r, g, b = int(r * factor), int(g * factor), int(b * factor)
    return f'#{r:02x}{g:02x}{b:02x}'

def plot_3d_pie(values, colors, labels, title='3D Pie Chart',
                width=800, height=700, radius=1.5, depth=0.3,
                tilt=25, rotation=0, dark_bg=True, explode=None):
    """
    Create a PROPERLY WORKING 3D pie chart with correct perspective.
    """
    bg_color = '#343838' if dark_bg else '#FDFBD4'
    text_color = 'white' if dark_bg else 'black'
    
    # Normalize values
    total = sum(values)
    percentages = [v / total for v in values]
    
    if explode is None:
        explode = [0] * len(values)
    
    
    p = figure(
        width=width,
        height=height,
        title=title,
        toolbar_location=None,
        background_fill_color=bg_color,
        border_fill_color=bg_color,
        match_aspect=True,
    )
    
    # Styling
    p.title.text_color = text_color
    p.title.text_font_size = '16pt'
    p.xaxis.visible = False
    p.yaxis.visible = False
    p.xgrid.visible = False
    p.ygrid.visible = False
    p.outline_line_color = None
    
    # 3D transformation parameters
    tilt_rad = np.radians(tilt)
    
    # Calculate cumulative angles
    angles = [p * 360 for p in percentages]
    start_angles = [0]
    for angle in angles[:-1]:
        start_angles.append(start_angles[-1] + angle)
    
    # Determine drawing order: back to front based on mid-angle
    slice_order = []
    for i in range(len(values)):
        mid_angle = start_angles[i] + angles[i]/2 + rotation
        # Use negative sin for proper sorting (back to front)
        slice_order.append((-np.sin(np.radians(mid_angle)), i))
    
    slice_order.sort()
    
    for _, i in slice_order:
        start_deg = start_angles[i] + rotation
        end_deg = start_deg + angles[i]
        mid_deg = start_deg + angles[i]/2
        
        # Explode offset
        explode_offset = explode[i] * radius * 0.15
        explode_x = explode_offset * np.cos(np.radians(mid_deg))
        explode_y = explode_offset * np.sin(np.radians(mid_deg)) * np.cos(tilt_rad)
        
        # Generate points for the slice
        n_points = max(30, int(angles[i] / 360 * 60))
        theta = np.linspace(np.radians(start_deg), np.radians(end_deg), n_points)
        
        # Top surface coordinates
        top_x = radius * np.cos(theta) + explode_x
        top_y = radius * np.sin(theta) * np.cos(tilt_rad) + explode_y
        
        # Bottom surface coordinates
        bottom_x = top_x.copy()
        bottom_y = top_y - depth
        
        edge_color = darken_color(colors[i], 0.6)
        
        # Draw the OUTER CURVED EDGE (visible from front)
        for j in range(len(theta) - 1):
            angle_mid = (theta[j] + theta[j+1]) / 2
            # Front-facing check: sin(angle) should be NEGATIVE (towards viewer)
            if np.sin(angle_mid) < 0:
                edge_x = [top_x[j], top_x[j+1], bottom_x[j+1], bottom_x[j], top_x[j]]
                edge_y = [top_y[j], top_y[j+1], bottom_y[j+1], bottom_y[j], top_y[j]]
                p.patch(edge_x, edge_y, color=edge_color, alpha=1.0, 
                       line_color='#000000', line_width=0.8)
        
        # Add vertical hatching on outer edge
        hatch_density = max(8, int(angles[i] / 360 * 50))
        hatch_indices = np.linspace(0, len(theta)-1, hatch_density, dtype=int)
        
        for idx in hatch_indices:
            if idx < len(theta) and np.sin(theta[idx]) < 0:
                hatch_x = [top_x[idx], bottom_x[idx]]
                hatch_y = [top_y[idx], bottom_y[idx]]
                p.line(hatch_x, hatch_y, color='#000000', 
                      alpha=0.3, line_width=1.0)
        
        # Top surface
        top_wedge_x = np.concatenate([[explode_x], top_x, [explode_x]])
        top_wedge_y = np.concatenate([[explode_y], top_y, [explode_y]])
        
        source = ColumnDataSource(data=dict(
            x=top_wedge_x,
            y=top_wedge_y,
            label=[labels[i]] * len(top_wedge_x),
            value=[values[i]] * len(top_wedge_x),
            percentage=[f'{percentages[i]*100:.1f}%'] * len(top_wedge_x)
        ))
        
        p.patch('x', 'y', source=source, color=colors[i], alpha=1.0,
               line_color='#000000', line_width=1.2,
               hover_alpha=0.8)
        
        # Add percentage label on top surface
        label_radius = radius * 0.65
        label_x = label_radius * np.cos(np.radians(mid_deg)) + explode_x
        label_y = label_radius * np.sin(np.radians(mid_deg)) * np.cos(tilt_rad) + explode_y
        
        percentage_text = f'{percentages[i]*100:.1f}%'
        label_obj = Label(
            x=label_x, y=label_y,
            text=percentage_text,
            text_color='white',
            text_font_size='14pt',
            text_align='center',
            text_baseline='middle',
            text_font_style='normal'
        )
        p.add_layout(label_obj)
    
    # Set ranges
    margin = radius * 1.5
    p.x_range.start = -margin
    p.x_range.end = margin
    p.y_range.start = -margin - depth
    p.y_range.end = margin
    
    return p

def create_legend(labels, colors, dark_bg=True):
    """Create a separate legend figure."""
    text_color = 'white' if dark_bg else 'black'
    bg_color = '#343838' if dark_bg else '#FDFBD4'
    
    # Calculate required height
    item_height = 40
    total_height = len(labels) * item_height + 60
    
    legend_fig = figure(
        width=250,
        height=total_height,
        toolbar_location=None,
        background_fill_color=bg_color,
        border_fill_color=bg_color,
        outline_line_color=None,
        x_range=(0, 1),
        y_range=(0, len(labels) * item_height + 20)
    )
    
    legend_fig.xaxis.visible = False
    legend_fig.yaxis.visible = False
    legend_fig.xgrid.visible = False
    legend_fig.ygrid.visible = False
    
    for i, (label, color) in enumerate(zip(labels, colors)):
        y_pos = (len(labels) - i) * item_height - 10
        
        # Draw color circle
        legend_fig.circle(x=[0.1], y=[y_pos], size=18, color=color, 
                         alpha=1.0, line_color='#000000', line_width=2)
        
        # Draw text label
        label_obj = Label(
            x=0.18, y=y_pos - 7,
            text=label,
            text_color=text_color,
            text_font_size='12pt',
            text_baseline='middle'
        )
        legend_fig.add_layout(label_obj)
    
    return legend_fig


# ============================================================================
# EXAMPLES - ALL PIES TOGETHER
# ============================================================================

if __name__ == "__main__":
    from bokeh.io import reset_output
    
    # Example 1: Energy Sources
    print("Example 1: Energy Sources Distribution")
    reset_output()
    
    sources = ['Solar', 'Wind', 'Hydro', 'Nuclear', 'Gas', 'Coal']
    energy = [22, 24, 18, 16, 12, 8]
    colors1 = ['#f39c12', '#3498db', '#1abc9c', '#9b59b6', '#e67e22', '#34495e']
    
    pie1 = plot_3d_pie(
        values=energy,
        colors=colors1,
        labels=sources,
        title='Energy Sources Distribution',
        width=900,
        height=700,
        radius=1.8,
        depth=0.45,
        tilt=35,
        rotation=120
    )
    legend1 = create_legend(sources, colors1)
    output_file("example1_energy.html")
    show(row(pie1, legend1, stylesheets=[get_dark_stylesheet()]))
    
    
    # Example 2: Market Share
    print("\nExample 2: Market Share")
    reset_output()
    
    companies = ['Company A', 'Company B', 'Company C', 'Company D', 'Company E']
    market_share = [35, 25, 20, 12, 8]
    colors2 = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6']
    
    pie2 = plot_3d_pie(
        values=market_share,
        colors=colors2,
        labels=companies,
        title='Market Share Distribution',
        width=900,
        height=700,
        radius=1.8,
        depth=0.5,
        tilt=32,
        rotation=0,
        dark_bg=False
    )
    legend2 = create_legend(companies, colors2, dark_bg=False)
    output_file("example2_market.html")
    show(row(pie2, legend2, stylesheets=[get_light_stylesheet()]))
    
    
    # Example 3: Budget Allocation
    print("\nExample 3: Budget Allocation")
    reset_output()
    
    categories = ['R&D', 'Marketing', 'Operations', 'Sales', 'Admin']
    budget = [30, 25, 20, 15, 10]
    colors3 = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#95a5a6']
    
    pie3 = plot_3d_pie(
        values=budget,
        colors=colors3,
        labels=categories,
        title='Budget Allocation',
        width=900,
        height=700,
        radius=1.8,
        depth=0.42,
        tilt=30,
        rotation=45
    )
    legend3 = create_legend(categories, colors3)
    output_file("example3_budget.html")
    show(row(pie3, legend3, stylesheets=[get_dark_stylesheet()]))
    
    
    # Example 4: Light Theme Version
    print("\nExample 4: Product Categories (Light Theme)")
    reset_output()
    
    products = ['Electronics', 'Clothing', 'Food', 'Home', 'Books', 'Sports']
    product_sales = [28, 22, 18, 15, 10, 7]
    colors4 = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c']
    
    pie4 = plot_3d_pie(
        values=product_sales,
        colors=colors4,
        labels=products,
        title='Product Category Distribution',
        width=900,
        height=700,
        radius=1.8,
        depth=0.48,
        tilt=33,
        rotation=60,
        dark_bg=False
    )
    legend4 = create_legend(products, colors4, dark_bg=False)
    output_file("example4_products_light.html")
    show(row(pie4, legend4, stylesheets=[get_light_stylesheet()]))
    
    
    # Example 5: Sales by Region
    print("\nExample 5: Sales by Region")
    reset_output()
    
    regions = ['North America', 'Europe', 'Asia Pacific', 'Latin America']
    sales = [40, 30, 22, 8]
    colors5 = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12']
    
    pie5 = plot_3d_pie(
        values=sales,
        colors=colors5,
        labels=regions,
        title='Sales by Region',
        width=900,
        height=700,
        radius=1.8,
        depth=0.4,
        tilt=35,
        rotation=30
    )
    legend5 = create_legend(regions, colors5)
    output_file("example5_sales.html")
    show(row(pie5, legend5, stylesheets=[get_dark_stylesheet()]))
    

1 Like

I’m not personally a huge fan of either pie charts or “3d extruded” versions of perfectly good 2-d charts, but it’s always interesting to see Bokeh used in new and innovative ways :smiley:

1 Like

I completely agree, but yeah it’s fun to experiment with what Bokeh can do.