{"spec_id":"parallel-categories-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nparallel-categories-basic: Basic Parallel Categories Plot\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 96/100 | Updated: 2026-05-13\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, Label\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Okabe-Ito palette - first series always #009E73\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\"]\n\n# Data - Product purchase journey: Channel -> Category -> Outcome\nnp.random.seed(42)\n\nchannels = [\"Online\", \"Store\", \"Mobile\"]\ncategories = [\"Electronics\", \"Clothing\", \"Home\"]\noutcomes = [\"Purchased\", \"Returned\", \"Exchanged\"]\n\n# Generate data with realistic patterns\ndata = []\nfor _ in range(500):\n    channel = np.random.choice(channels, p=[0.45, 0.35, 0.20])\n    if channel == \"Online\":\n        category = np.random.choice(categories, p=[0.5, 0.3, 0.2])\n    elif channel == \"Store\":\n        category = np.random.choice(categories, p=[0.2, 0.5, 0.3])\n    else:\n        category = np.random.choice(categories, p=[0.6, 0.25, 0.15])\n    if category == \"Electronics\":\n        outcome = np.random.choice(outcomes, p=[0.7, 0.2, 0.1])\n    elif category == \"Clothing\":\n        outcome = np.random.choice(outcomes, p=[0.6, 0.25, 0.15])\n    else:\n        outcome = np.random.choice(outcomes, p=[0.85, 0.1, 0.05])\n    data.append({\"Channel\": channel, \"Category\": category, \"Outcome\": outcome})\n\ndf = pd.DataFrame(data)\n\n# Aggregate data to get counts for each path\npath_counts = df.groupby([\"Channel\", \"Category\", \"Outcome\"]).size().reset_index(name=\"count\")\n\n# Define dimensions and their unique values\ndimensions = [\"Channel\", \"Category\", \"Outcome\"]\ndim_values = {\"Channel\": channels, \"Category\": categories, \"Outcome\": outcomes}\n\n# Calculate x positions for each dimension\nx_positions = {dim: i * 1.5 for i, dim in enumerate(dimensions)}\n\n# Total count for normalization\ntotal_count = len(df)\n\n# Build category positions for each dimension\ndim_cat_positions = {}\nfor dim in dimensions:\n    counts = df[dim].value_counts()\n    positions = {}\n    y_current = 0\n    for cat in dim_values[dim]:\n        count = counts.get(cat, 0)\n        height = count / total_count\n        positions[cat] = {\"y_start\": y_current, \"height\": height, \"y_end\": y_current + height}\n        y_current += height\n    dim_cat_positions[dim] = positions\n\n# Create ribbons connecting categories between adjacent dimensions\nribbon_patches_x = []\nribbon_patches_y = []\nribbon_colors = []\n\n# Color by first dimension (Channel) - using Okabe-Ito palette\nchannel_colors = {\n    \"Online\": IMPRINT[0],  # #009E73\n    \"Store\": IMPRINT[1],  # #C475FD\n    \"Mobile\": IMPRINT[2],  # #4467A3\n}\n\n# Track running position within each category box\nrunning_positions = {dim: dict.fromkeys(dim_values[dim], 0) for dim in dimensions}\n\n# Process each unique path\nfor _, row in path_counts.iterrows():\n    count = row[\"count\"]\n    ribbon_height = count / total_count\n\n    # Get color based on first dimension\n    color = channel_colors[row[\"Channel\"]]\n\n    # Create ribbons between each pair of adjacent dimensions\n    for i in range(len(dimensions) - 1):\n        dim1 = dimensions[i]\n        dim2 = dimensions[i + 1]\n        cat1 = row[dim1]\n        cat2 = row[dim2]\n\n        # Get x positions\n        x1 = x_positions[dim1]\n        x2 = x_positions[dim2]\n\n        # Get y positions\n        y1_base = dim_cat_positions[dim1][cat1][\"y_start\"]\n        y1_start = y1_base + running_positions[dim1][cat1]\n        y1_end = y1_start + ribbon_height\n\n        y2_base = dim_cat_positions[dim2][cat2][\"y_start\"]\n        y2_start = y2_base + running_positions[dim2][cat2]\n        y2_end = y2_start + ribbon_height\n\n        # Create smooth ribbon using bezier-like path\n        x_mid = (x1 + x2) / 2\n        num_curve_points = 20\n        t = np.linspace(0, 1, num_curve_points)\n\n        # Top edge: bezier from (x1, y1_end) to (x2, y2_end)\n        top_x = x1 * (1 - t) ** 3 + 3 * x_mid * t * (1 - t) ** 2 + 3 * x_mid * t**2 * (1 - t) + x2 * t**3\n        top_y = y1_end * (1 - t) ** 3 + 3 * y1_end * t * (1 - t) ** 2 + 3 * y2_end * t**2 * (1 - t) + y2_end * t**3\n\n        # Bottom edge: bezier from (x2, y2_start) to (x1, y1_start) (reversed)\n        bottom_x = x2 * (1 - t) ** 3 + 3 * x_mid * t * (1 - t) ** 2 + 3 * x_mid * t**2 * (1 - t) + x1 * t**3\n        bottom_y = (\n            y2_start * (1 - t) ** 3 + 3 * y2_start * t * (1 - t) ** 2 + 3 * y1_start * t**2 * (1 - t) + y1_start * t**3\n        )\n\n        # Combine to form closed polygon\n        patch_x = np.concatenate([top_x, bottom_x])\n        patch_y = np.concatenate([top_y, bottom_y])\n\n        ribbon_patches_x.append(patch_x.tolist())\n        ribbon_patches_y.append(patch_y.tolist())\n        ribbon_colors.append(color)\n\n        # Update running positions after processing\n        if i == len(dimensions) - 2:\n            for j in range(len(dimensions)):\n                dim = dimensions[j]\n                cat = row[dim]\n                running_positions[dim][cat] += ribbon_height\n\n# Reset running positions for proper tracking\nrunning_positions = {dim: dict.fromkeys(dim_values[dim], 0) for dim in dimensions}\n\n# Process each path again to correctly update positions\nfor _, row in path_counts.iterrows():\n    count = row[\"count\"]\n    ribbon_height = count / total_count\n    for dim in dimensions:\n        cat = row[dim]\n        running_positions[dim][cat] += ribbon_height\n\n# Create figure\np = figure(\n    width=4800,\n    height=2700,\n    title=\"parallel-categories-basic · bokeh · anyplot.ai\",\n    x_range=(-0.7, 4.0),\n    y_range=(-0.05, 1.15),\n    tools=\"\",\n    toolbar_location=None,\n)\n\n# Draw ribbons\nfor i in range(len(ribbon_patches_x)):\n    source = ColumnDataSource(data={\"x\": [ribbon_patches_x[i]], \"y\": [ribbon_patches_y[i]]})\n    p.patches(\n        xs=\"x\",\n        ys=\"y\",\n        source=source,\n        fill_color=ribbon_colors[i],\n        fill_alpha=0.7,\n        line_color=ribbon_colors[i],\n        line_alpha=0.9,\n        line_width=1,\n    )\n\n# Draw category boxes (rectangles for each category in each dimension)\nbox_width = 0.12\nfor dim in dimensions:\n    x = x_positions[dim]\n    for cat in dim_values[dim]:\n        pos = dim_cat_positions[dim][cat]\n        source = ColumnDataSource(\n            data={\n                \"x\": [[x - box_width / 2, x + box_width / 2, x + box_width / 2, x - box_width / 2]],\n                \"y\": [[pos[\"y_start\"], pos[\"y_start\"], pos[\"y_end\"], pos[\"y_end\"]]],\n            }\n        )\n        p.patches(xs=\"x\", ys=\"y\", source=source, fill_color=INK_SOFT, fill_alpha=0.3, line_color=INK_SOFT, line_width=2)\n\n        # Add category label\n        y_mid = (pos[\"y_start\"] + pos[\"y_end\"]) / 2\n        if dim == dimensions[-1]:\n            label_x = x + box_width / 2 + 0.05\n            align = \"left\"\n        else:\n            label_x = x - box_width / 2 - 0.05\n            align = \"right\"\n        label = Label(\n            x=label_x,\n            y=y_mid,\n            text=cat,\n            text_font_size=\"28pt\",\n            text_color=INK,\n            text_align=align,\n            text_baseline=\"middle\",\n        )\n        p.add_layout(label)\n\n# Add dimension labels at the top\nfor dim in dimensions:\n    x = x_positions[dim]\n    label = Label(\n        x=x,\n        y=1.08,\n        text=dim,\n        text_font_size=\"36pt\",\n        text_color=INK,\n        text_font_style=\"bold\",\n        text_align=\"center\",\n        text_baseline=\"bottom\",\n    )\n    p.add_layout(label)\n\n# Add legend - centered bottom for better balance\nlegend_items = [(\"Online\", IMPRINT[0]), (\"Store\", IMPRINT[1]), (\"Mobile\", IMPRINT[2])]\nlegend_x_start = 0.8\nlegend_y = -0.02\nfor i, (name, color) in enumerate(legend_items):\n    lx = legend_x_start + i * 0.5\n    ly = legend_y\n    # Legend box\n    source = ColumnDataSource(\n        data={\"x\": [[lx - 0.05, lx + 0.05, lx + 0.05, lx - 0.05]], \"y\": [[ly - 0.03, ly - 0.03, ly + 0.03, ly + 0.03]]}\n    )\n    p.patches(xs=\"x\", ys=\"y\", source=source, fill_color=color, fill_alpha=0.85, line_color=INK_SOFT, line_width=2)\n    # Legend label\n    label = Label(\n        x=lx + 0.1,\n        y=ly,\n        text=name,\n        text_font_size=\"24pt\",\n        text_color=INK_SOFT,\n        text_align=\"left\",\n        text_baseline=\"middle\",\n    )\n    p.add_layout(label)\n\n# Style the figure\np.title.text_font_size = \"48pt\"\np.title.text_color = INK\np.title.align = \"center\"\n\n# Hide axes and grid\np.xaxis.visible = False\np.yaxis.visible = False\np.xgrid.visible = False\np.ygrid.visible = False\np.outline_line_color = None\n\n# Theme-adaptive background\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\n\n# Save as HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Screenshot with headless Chrome using Selenium\nW, H = 4800, 2700\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}