Zoomable Sunburst!

Peek 2025-08-07 01-49

from bokeh.application import Application
from bokeh.application.handlers import FunctionHandler
from bokeh.layouts import column, row
from bokeh.models import ColumnDataSource, HoverTool, TapTool, Button, Div
from bokeh.plotting import figure, curdoc
from bokeh.server.server import Server
import numpy as np
import math
from typing import Dict, List, Any
from bokeh.palettes import Category10
# Define discrete colors for parent nodes
PALETTE = Category10[10]  # 10 strong, discrete colors for top-level parents

# Sample hierarchical data - replace with your own
sample_data = {
    "name": "Company Organization",
    "children": [
        {
            "name": "Technology",
            "children": [
                {
                    "name": "Frontend Development",
                    "children": [
                        {
                            "name": "React Ecosystem",
                            "children": [
                                {"name": "React Core", "value": 25},
                                {"name": "Next.js", "value": 20},
                                {"name": "React Native", "value": 15},
                                {"name": "Redux/Zustand", "value": 12}
                            ]
                        },
                        {
                            "name": "Vue Ecosystem", 
                            "children": [
                                {"name": "Vue 3", "value": 18},
                                {"name": "Nuxt.js", "value": 12},
                                {"name": "Vuex/Pinia", "value": 8}
                            ]
                        },
                        {
                            "name": "Other Frameworks",
                            "children": [
                                {"name": "Angular", "value": 15},
                                {"name": "Svelte", "value": 8},
                                {"name": "Vanilla JS", "value": 10}
                            ]
                        }
                    ]
                },
                {
                    "name": "Backend Development", 
                    "children": [
                        {
                            "name": "Python Stack",
                            "children": [
                                {"name": "Django", "value": 22},
                                {"name": "FastAPI", "value": 18},
                                {"name": "Flask", "value": 15},
                                {"name": "Data Science", "value": 20}
                            ]
                        },
                        {
                            "name": "Node.js Stack",
                            "children": [
                                {"name": "Express.js", "value": 15},
                                {"name": "NestJS", "value": 12},
                                {"name": "Koa.js", "value": 8}
                            ]
                        },
                        {
                            "name": "Other Languages",
                            "children": [
                                {"name": "Java Spring", "value": 18},
                                {"name": "C# .NET", "value": 16},
                                {"name": "Go", "value": 12},
                                {"name": "Rust", "value": 8}
                            ]
                        }
                    ]
                },
                {
                    "name": "DevOps & Infrastructure",
                    "children": [
                        {
                            "name": "Cloud Platforms",
                            "children": [
                                {"name": "AWS", "value": 35},
                                {"name": "Google Cloud", "value": 25},
                                {"name": "Azure", "value": 20},
                                {"name": "Digital Ocean", "value": 10}
                            ]
                        },
                        {
                            "name": "Containerization",
                            "children": [
                                {"name": "Docker", "value": 30},
                                {"name": "Kubernetes", "value": 20},
                                {"name": "Podman", "value": 5}
                            ]
                        },
                        {"name": "CI/CD", "value": 25},
                        {"name": "Monitoring", "value": 15}
                    ]
                },
                {
                    "name": "Mobile Development",
                    "children": [
                        {"name": "iOS Native", "value": 25},
                        {"name": "Android Native", "value": 30},
                        {"name": "Flutter", "value": 20},
                        {"name": "React Native", "value": 18},
                        {"name": "Xamarin", "value": 7}
                    ]
                },
                {
                    "name": "Data & AI",
                    "children": [
                        {
                            "name": "Machine Learning",
                            "children": [
                                {"name": "TensorFlow", "value": 20},
                                {"name": "PyTorch", "value": 18},
                                {"name": "Scikit-learn", "value": 15},
                                {"name": "Hugging Face", "value": 12}
                            ]
                        },
                        {
                            "name": "Data Engineering",
                            "children": [
                                {"name": "Apache Spark", "value": 15},
                                {"name": "Airflow", "value": 12},
                                {"name": "Kafka", "value": 10}
                            ]
                        },
                        {"name": "Business Intelligence", "value": 20}
                    ]
                }
            ]
        },
        {
            "name": "Marketing & Sales",
            "children": [
                {
                    "name": "Digital Marketing",
                    "children": [
                        {
                            "name": "Search Marketing",
                            "children": [
                                {"name": "SEO", "value": 25},
                                {"name": "Google Ads", "value": 20},
                                {"name": "Bing Ads", "value": 8}
                            ]
                        },
                        {
                            "name": "Social Media",
                            "children": [
                                {"name": "Facebook/Meta", "value": 18},
                                {"name": "LinkedIn", "value": 15},
                                {"name": "Twitter/X", "value": 10},
                                {"name": "TikTok", "value": 12},
                                {"name": "YouTube", "value": 15}
                            ]
                        },
                        {
                            "name": "Content Marketing",
                            "children": [
                                {"name": "Blog Content", "value": 20},
                                {"name": "Video Content", "value": 25},
                                {"name": "Podcasts", "value": 12},
                                {"name": "Email Marketing", "value": 18}
                            ]
                        }
                    ]
                },
                {
                    "name": "Sales Operations",
                    "children": [
                        {
                            "name": "B2B Sales",
                            "children": [
                                {"name": "Enterprise", "value": 40},
                                {"name": "Mid-Market", "value": 30},
                                {"name": "SMB", "value": 25}
                            ]
                        },
                        {
                            "name": "B2C Sales",
                            "children": [
                                {"name": "E-commerce", "value": 35},
                                {"name": "Retail", "value": 25},
                                {"name": "Direct Sales", "value": 15}
                            ]
                        },
                        {"name": "Customer Success", "value": 30},
                        {"name": "Sales Enablement", "value": 20}
                    ]
                },
                {
                    "name": "Brand & Creative",
                    "children": [
                        {"name": "Brand Strategy", "value": 25},
                        {"name": "Graphic Design", "value": 30},
                        {"name": "UX/UI Design", "value": 35},
                        {"name": "Photography", "value": 15},
                        {"name": "Video Production", "value": 20}
                    ]
                }
            ]
        },
        {
            "name": "Operations",
            "children": [
                {
                    "name": "Human Resources",
                    "children": [
                        {
                            "name": "Talent Acquisition",
                            "children": [
                                {"name": "Technical Recruiting", "value": 25},
                                {"name": "Sales Recruiting", "value": 20},
                                {"name": "Executive Search", "value": 15}
                            ]
                        },
                        {"name": "Employee Development", "value": 30},
                        {"name": "Compensation & Benefits", "value": 25},
                        {"name": "HR Operations", "value": 20}
                    ]
                },
                {
                    "name": "Finance & Accounting",
                    "children": [
                        {"name": "Financial Planning", "value": 30},
                        {"name": "Accounting", "value": 25},
                        {"name": "Tax & Compliance", "value": 20},
                        {"name": "Investor Relations", "value": 15}
                    ]
                },
                {
                    "name": "Legal & Compliance",
                    "children": [
                        {"name": "Corporate Law", "value": 20},
                        {"name": "IP & Patents", "value": 15},
                        {"name": "Privacy & Data", "value": 18},
                        {"name": "Contracts", "value": 22}
                    ]
                },
                {
                    "name": "Facilities & Admin",
                    "children": [
                        {"name": "Office Management", "value": 15},
                        {"name": "IT Support", "value": 20},
                        {"name": "Security", "value": 18},
                        {"name": "Procurement", "value": 12}
                    ]
                }
            ]
        },
        {
            "name": "Product",
            "children": [
                {
                    "name": "Product Management",
                    "children": [
                        {"name": "Product Strategy", "value": 30},
                        {"name": "Product Analytics", "value": 25},
                        {"name": "Roadmap Planning", "value": 20},
                        {"name": "User Research", "value": 25}
                    ]
                },
                {
                    "name": "Design",
                    "children": [
                        {
                            "name": "User Experience",
                            "children": [
                                {"name": "UX Research", "value": 20},
                                {"name": "Information Architecture", "value": 15},
                                {"name": "Interaction Design", "value": 18}
                            ]
                        },
                        {
                            "name": "User Interface",
                            "children": [
                                {"name": "Visual Design", "value": 22},
                                {"name": "Design Systems", "value": 18},
                                {"name": "Prototyping", "value": 15}
                            ]
                        }
                    ]
                },
                {
                    "name": "Quality Assurance",
                    "children": [
                        {"name": "Manual Testing", "value": 20},
                        {"name": "Automation Testing", "value": 25},
                        {"name": "Performance Testing", "value": 15},
                        {"name": "Security Testing", "value": 12}
                    ]
                }
            ]
        }
    ]
}

# Global state variables
original_data = sample_data
current_root = sample_data
zoom_history = [sample_data]
current_wedges = []
plot = None
source = None
center_source = None
title_div = None
width = 700
height = 700
radius = min(width, height) // 3

def get_node_value(node: Dict) -> float:
    """Get the total value of a node"""
    if 'value' in node:
        return node['value']
    elif 'children' in node:
        return sum(get_node_value(child) for child in node['children'])
    return 1
def lighten(hex_color, factor=0.5):
    """Return lighter hex color. factor in [0,1], higher is lighter."""
    hex_color = hex_color.lstrip('#')
    rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
    lighten_rgb = tuple(int((1-factor)*c + factor*255) for c in rgb)
    return '#{:02x}{:02x}{:02x}'.format(*lighten_rgb)

def build_full_sunburst(root_node: Dict) -> List[Dict]:
    """
    Sunburst: top-level = PALETTE, descendants = shade of their parent's color.
    """
    wedges = []
    max_depth = 5

    def process_node(node, depth, start_angle, end_angle, parent_color, color_index):
        # Pick color for this wedge
        if depth == 1:
            # First ring: assign from palette (by order)
            this_color = PALETTE[color_index % len(PALETTE)]
        elif depth > 1:
            # Descendants: shade the parent color lighter each level
            this_color = shade(parent_color, factor=min(0.1*depth, 0.9))
        else:
            this_color = None  # root itself isn't a wedge

        if depth > 0:
            inner_r = 30 + (depth - 1) * 60
            outer_r = 30 + depth * 60
            wedges.append({
                'name': node['name'],
                'value': get_node_value(node),
                'depth': depth,
                'start_angle': start_angle,
                'end_angle': end_angle,
                'inner_radius': inner_r,
                'outer_radius': outer_r,
                'has_children': bool(node.get('children')),
                'node_data': node,
                'parent_name': "",  # optional for legend
                'color': this_color,
                'color_index': color_index,
            })

        # Recurse children
        if node.get('children'):
            children = node['children']
            total_value = sum(get_node_value(child) for child in children)
            cur_angle = start_angle
            for i, child in enumerate(children):
                child_value = get_node_value(child)
                proportion = child_value / total_value if total_value else 0
                span = (end_angle - start_angle) * proportion
                next_angle = cur_angle + span
                # Children of root: color_index = i (to cycle palette)
                if depth == 0:
                    process_node(child, depth+1, cur_angle, next_angle, PALETTE[i % len(PALETTE)], i)
                else:
                    process_node(child, depth+1, cur_angle, next_angle, this_color, color_index)
                cur_angle = next_angle

    process_node(root_node, 0, 0, 2 * math.pi, None, -1)
    return wedges
def build_single_level(root_node: Dict) -> List[Dict]:
    """Build single level view for zoomed state, using legend palette for color"""
    wedges = []

    from bokeh.palettes import Category10
    PALETTE = Category10[10]

    if not root_node.get('children'):
        return wedges

    children = root_node.get('children', [])
    total_value = sum(get_node_value(child) for child in children)

    if total_value == 0:
        return wedges

    current_angle = 0

    for i, child in enumerate(children):
        child_value = get_node_value(child)
        proportion = child_value / total_value
        child_span = proportion * 2 * math.pi
        child_end = current_angle + child_span

        # Single ring that fills most of the space
        inner_r = radius * 0.2
        outer_r = radius * 0.9

        wedge = {
            'name': child['name'],
            'value': child_value,
            'depth': 1,
            'start_angle': current_angle,
            'end_angle': child_end,
            'inner_radius': inner_r,
            'outer_radius': outer_r,
            'has_children': bool(child.get('children')),
            'node_data': child,
            'parent_name': root_node['name'],
            'color_index': i,
            'color': PALETTE[i % len(PALETTE)],  # <<<<<< Add this line!
        }

        wedges.append(wedge)
        current_angle = child_end

    return wedges

def create_plot():
    """Create the Bokeh plot"""
    p = figure(
        width=width, 
        height=height,
        title="",  # We'll use the div for title now
        toolbar_location="above",
        x_range=(-radius*1.3, radius*1.3),
        y_range=(-radius*1.3, radius*1.3),
        tools="pan,wheel_zoom,reset"
    )
    
    # Style the plot
    p.axis.visible = False
    p.grid.visible = False
    p.outline_line_color = None
    p.background_fill_color = "#fafafa"
    
    return p

def zoom_to_node(node_data: Dict):
    """Zoom into a specific node"""
    global current_root, zoom_history
    
    print(f"ZOOM IN: {node_data['name']}")
    zoom_history.append(node_data)
    current_root = node_data
    update_chart()

def zoom_back():
    """Go back to parent level"""
    global current_root, zoom_history
    
    if len(zoom_history) > 1:
        old_name = current_root['name']
        zoom_history.pop()
        current_root = zoom_history[-1]
        print(f"ZOOM OUT: {old_name} -> {current_root['name']}")
        update_chart()
    else:
        print("Already at root level")

def handle_selection(attr, old, new):
    """Handle wedge selection"""
    global current_wedges
    
    if hasattr(handle_selection, '_updating'):
        return
        
    if new and len(new) > 0:
        selected_idx = new[0]
        if selected_idx < len(current_wedges):
            wedge = current_wedges[selected_idx]
            if wedge['has_children']:
                print(f"Zooming into: {wedge['name']}")
                zoom_to_node(wedge['node_data'])
            else:
                print(f"Clicked on leaf node: {wedge['name']} (no children)")
                
            # Clear selection
            handle_selection._updating = True
            source.selected.indices = []
            delattr(handle_selection, '_updating')

def handle_center_click(attr, old, new):
    """Handle center circle click"""
    if new and len(new) > 0:
        print("Clicked center - going back")
        zoom_back()
        center_source.selected.indices = []

def get_parent_colors(root_node):
    if not root_node.get("children"):
        return {}
    return {child["name"]: PALETTE[i % len(PALETTE)] for i, child in enumerate(root_node["children"])}

def prepare_wedge_data(wedges: List[Dict]) -> Dict[str, List]:
    data = {
        'x': [],
        'y': [],
        'start_angle': [],
        'end_angle': [],
        'inner_radius': [],
        'outer_radius': [],
        'name': [],
        'value': [],
        'color': [],
        'alpha': [],
        'line_alpha': [],
        'has_children': [],
        'node_index': []
    }
    for i, wedge in enumerate(wedges):
        data['x'].append(0)
        data['y'].append(0)
        data['start_angle'].append(wedge['start_angle'])
        data['end_angle'].append(wedge['end_angle'])
        data['inner_radius'].append(wedge['inner_radius'])
        data['outer_radius'].append(wedge['outer_radius'])
        data['name'].append(wedge['name'])
        data['value'].append(int(wedge['value']))
        data['has_children'].append(wedge['has_children'])
        data['node_index'].append(i)
        data['color'].append(wedge['color'])
        data['alpha'].append(0.85 if wedge['has_children'] else 0.65)
        data['line_alpha'].append(1.0)
    return data


def shade(hex_color, factor=0.2):
    """
    Lighten color by factor (0=no change, 1=white).
    factor can also be negative for darkening.
    """
    hex_color = hex_color.lstrip('#')
    rgb = [int(hex_color[i:i+2], 16) for i in (0, 2, 4)]
    def clamp(x): return int(max(0, min(x, 255)))
    if factor >= 0:
        rgb = [clamp(c + (255 - c) * factor) for c in rgb]
    else:
        rgb = [clamp(c * (1 + factor)) for c in rgb]
    return '#%02x%02x%02x' % tuple(rgb)

def create_legend_div_for_parents(parent_colors, root_node):
    if not root_node.get("children"):
        return Div(text="<b>No children</b>", width=300)
    items = []
    for c in root_node["children"]:
        color = parent_colors.get(c["name"], "#cccccc")
        name = c["name"]
        value = get_node_value(c)
        items.append(
            f"<span style='display:inline-block; width:1.4em; text-align:center; color:{color}; font-size:1.3em'>■</span> "
            f"<span style='font-weight:600;'>{name}</span> "
            f"<span style='color:#666;'>({value})</span>"
        )
    legend_html = "<div style='padding:8px;'><b>Legend:</b><br>" + "<br>".join(items) + "</div>"
    return Div(text=legend_html, width=300, styles={"overflow-y": "auto"})

def update_chart():
    global current_wedges, source, center_source, title_div, legend_div

    parent_colors = get_parent_colors(current_root)
    is_at_root = len(zoom_history) <= 1
    show_full = is_at_root

    wedges = build_full_sunburst(current_root) if show_full else build_single_level(current_root)
    current_wedges = wedges
    new_data = prepare_wedge_data(wedges)
    source.data = new_data

    legend_div.text = create_legend_div_for_parents(parent_colors, current_root).text


    # --- (rest of your function unchanged) ---
    if len(zoom_history) <= 1:
        center_source.data = dict(x=[0], y=[0], radius=[0])  # Hide
    else:
        center_source.data = dict(x=[0], y=[0], radius=[25])  # Show
        
    breadcrumb = " > ".join([h['name'] for h in zoom_history])
    if len(zoom_history) == 1:
        title_text = f"<h2 style='text-align: center; margin: 10px;'>{current_root['name']}</h2>"
    else:
        title_text = f"<h2 style='text-align: center; margin: 10px;'>{breadcrumb}</h2>"
    title_div.text = title_text

    view_type = "full sunburst" if show_full else "single level"
    print(f"Updated chart with {len(wedges)} wedges for '{current_root['name']}' ({view_type})")
    if wedges:
        clickable = sum(1 for w in wedges if w['has_children'])
        print(f"  - {clickable} segments are clickable (have children)")

def modify_doc(doc):
    """Create the Bokeh application with always-updating legend."""
    global plot, source, center_source, title_div, legend_div
    
    title_div = Div(
        text=f"<h2 style='text-align: center; margin: 10px;'>{sample_data['name']}</h2>",
        width=width,
        height=30
    )
    
    legend_div = Div(text="", width=300, height=300, styles={"overflow-y": "auto"})
    
    plot = create_plot()
    source = ColumnDataSource()
    center_source = ColumnDataSource(dict(x=[0], y=[0], radius=[0]))
    
    wedge_renderer = plot.annular_wedge(
        x='x', y='y',
        inner_radius='inner_radius', outer_radius='outer_radius',
        start_angle='start_angle', end_angle='end_angle',
        color='color', alpha='alpha',
        line_color='white', line_width=3, line_alpha='line_alpha',
        source=source,
        selection_fill_alpha=1.0,
        nonselection_fill_alpha=0.7,
        hover_fill_alpha=0.9,
        hover_line_color='black',
        hover_line_width=4
    )
    
    center_circle = plot.circle(
        x='x', y='y', radius='radius', 
        color='lightblue', alpha=0.8, 
        line_color='darkblue', line_width=4,
        source=center_source,
        hover_color='blue',
        hover_alpha=1.0,
        selection_color='darkblue'
    )
    
    hover = HoverTool(
        tooltips=[
            ("Name", "@name"),
            ("Value", "@value{,0}"),
            ("Clickable", "@has_children")
        ],
        renderers=[wedge_renderer]
    )
    plot.add_tools(hover)
    
    tap = TapTool()
    plot.add_tools(tap)
    
    source.selected.on_change('indices', handle_selection)
    center_source.selected.on_change('indices', handle_center_click)
    
    legend_div = Div(text="", width=300, height=300, styles={"overflow-y": "auto"})

    update_chart()  # This will also update the legend
    
    layout = row(
        column(title_div, row(plot,legend_div)),
        
    )
    doc.add_root(layout)
    doc.title = "Animated Zoomable Sunburst Chart"

# For running as a script
if __name__ == "__main__":
    from bokeh.application import Application
    from bokeh.application.handlers import FunctionHandler
    from bokeh.server.server import Server

    app = Application(FunctionHandler(modify_doc))
    server = Server({'/': app}, num_procs=1, port=5006)
    server.start()
    print("Opening Bokeh application on http://localhost:5006/")
    server.io_loop.add_callback(server.show, "/")
    server.io_loop.start()

python app.py
1 Like