{"spec_id":"violin-split","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nviolin-split: Split Violin Plot\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-05-08\n\"\"\"\n\nimport sys\nfrom pathlib import Path\n\n\n# Remove script directory from sys.path to avoid shadowing bokeh package\nscript_dir = str(Path(__file__).parent)\nif script_dir in sys.path:\n    sys.path.remove(script_dir)\n# Also remove if it's sys.path[0]\nif sys.path and sys.path[0] == script_dir:\n    sys.path.pop(0)\n\nimport os\nimport time\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import HoverTool, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Change to script directory for output files\nos.chdir(Path(__file__).parent)\n\n\n# Theme configuration\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 colors for split groups\nCOLOR_PLACEBO = \"#009E73\"  # First series - brand green\nCOLOR_TREATMENT = \"#C475FD\"  # Second series - vermillion\n\n# Data - Clinical trial recovery scores (placebo vs active treatment)\nnp.random.seed(42)\n\nconditions = [\"Mild\", \"Moderate\", \"Severe\"]\nsplit_groups = [\"Placebo\", \"Active Treatment\"]\nn_per_group = 120\n\n# Generate realistic recovery improvement data (0-100 scale)\ndata = []\nfor cond in conditions:\n    # Base recovery varies by condition severity\n    base = {\"Mild\": 65, \"Moderate\": 45, \"Severe\": 30}[cond]\n    improvement = {\"Mild\": 12, \"Moderate\": 25, \"Severe\": 35}[cond]\n    spread = {\"Mild\": 8, \"Moderate\": 12, \"Severe\": 15}[cond]\n\n    # Placebo group - lower improvement\n    placebo = np.clip(np.random.normal(base, spread, n_per_group), 0, 100)\n    for v in placebo:\n        data.append({\"category\": cond, \"value\": v, \"split_group\": \"Placebo\"})\n\n    # Active treatment - higher improvement\n    treatment = np.clip(np.random.normal(base + improvement, spread * 0.85, n_per_group), 0, 100)\n    for v in treatment:\n        data.append({\"category\": cond, \"value\": v, \"split_group\": \"Active Treatment\"})\n\n# Organize data by category and split group\nvalues_by_cat_group = {}\nfor cat in conditions:\n    values_by_cat_group[cat] = {}\n    for sg in split_groups:\n        values_by_cat_group[cat][sg] = [d[\"value\"] for d in data if d[\"category\"] == cat and d[\"split_group\"] == sg]\n\n\ndef gaussian_kde_numpy(values, n_points=100, y_min=0.0, y_max=100.0):\n    \"\"\"Compute kernel density estimate using Gaussian kernels with bounded domain.\"\"\"\n    values = np.array(values)\n    n = len(values)\n\n    # Scott's rule for bandwidth\n    std = np.std(values, ddof=1)\n    bandwidth = 1.06 * std * n ** (-1 / 5)\n\n    # Grid for evaluation - bounded to data scale\n    y_grid = np.linspace(y_min, y_max, n_points)\n\n    # Compute KDE at each grid point\n    density = np.zeros(n_points)\n    for i, y in enumerate(y_grid):\n        kernel_values = np.exp(-0.5 * ((y - values) / bandwidth) ** 2)\n        density[i] = np.sum(kernel_values) / (n * bandwidth * np.sqrt(2 * np.pi))\n\n    return y_grid, density\n\n\n# Create figure\np = figure(\n    width=4800,\n    height=2700,\n    title=\"Clinical Recovery Scores · violin-split · bokeh · anyplot.ai\",\n    x_axis_label=\"Condition Severity\",\n    y_axis_label=\"Recovery Score (0-100)\",\n    x_range=conditions,\n    y_range=(-5, 105),\n    tools=\"pan,wheel_zoom,box_zoom,reset\",\n)\n\n# Width of each violin half\nviolin_width = 0.35\n\n# Store patches for legend\nplacebo_patch = None\ntreatment_patch = None\n\n# Store statistics for hover tooltips\nstats_data = {\"x\": [], \"y\": [], \"group\": [], \"median\": [], \"q1\": [], \"q3\": [], \"n\": []}\n\n# Draw split violins for each category\nfor i, cat in enumerate(conditions):\n    cat_x = i\n\n    for sg in split_groups:\n        values = values_by_cat_group[cat][sg]\n        y_grid, density = gaussian_kde_numpy(values, y_min=0.0, y_max=100.0)\n\n        # Normalize density to fit within violin width\n        max_density = max(density)\n        if max_density > 0:\n            density_norm = density / max_density * violin_width\n        else:\n            density_norm = density\n\n        # Build polygon coordinates\n        if sg == \"Placebo\":\n            # Left half - density goes negative (left)\n            xs = np.concatenate([cat_x - density_norm, [cat_x], [cat_x]])\n            ys = np.concatenate([y_grid, [y_grid[-1]], [y_grid[0]]])\n            color = COLOR_PLACEBO\n        else:\n            # Right half - density goes positive (right)\n            xs = np.concatenate([[cat_x], cat_x + density_norm, [cat_x]])\n            ys = np.concatenate([[y_grid[0]], y_grid, [y_grid[-1]]])\n            color = COLOR_TREATMENT\n\n        # Draw violin patch\n        patch = p.patch(xs.tolist(), ys.tolist(), fill_color=color, fill_alpha=0.65, line_color=color, line_width=2)\n\n        # Store first patches for legend\n        if sg == \"Placebo\" and placebo_patch is None:\n            placebo_patch = patch\n        elif sg == \"Active Treatment\" and treatment_patch is None:\n            treatment_patch = patch\n\n        # Add quartile markers\n        q1, median, q3 = np.percentile(values, [25, 50, 75])\n\n        # Horizontal offset for quartile lines\n        if sg == \"Placebo\":\n            line_start = cat_x - violin_width * 0.6\n            line_end = cat_x\n        else:\n            line_start = cat_x\n            line_end = cat_x + violin_width * 0.6\n\n        # Draw quartile lines with theme-adaptive colors\n        p.segment(x0=[line_start], y0=[median], x1=[line_end], y1=[median], line_color=INK, line_width=4)\n        p.segment(\n            x0=[line_start], y0=[q1], x1=[line_end], y1=[q1], line_color=INK_SOFT, line_width=2, line_dash=\"dashed\"\n        )\n        p.segment(\n            x0=[line_start], y0=[q3], x1=[line_end], y1=[q3], line_color=INK_SOFT, line_width=2, line_dash=\"dashed\"\n        )\n\n        # Store hover data for this violin\n        hover_x = cat_x - violin_width / 2 if sg == \"Placebo\" else cat_x + violin_width / 2\n        stats_data[\"x\"].append(hover_x)\n        stats_data[\"y\"].append(median)\n        stats_data[\"group\"].append(f\"{cat} - {sg}\")\n        stats_data[\"median\"].append(f\"{median:.1f}\")\n        stats_data[\"q1\"].append(f\"{q1:.1f}\")\n        stats_data[\"q3\"].append(f\"{q3:.1f}\")\n        stats_data[\"n\"].append(str(len(values)))\n\n# Add invisible scatter points for hover tooltips\nhover_circles = p.scatter(x=stats_data[\"x\"], y=stats_data[\"y\"], size=30, fill_alpha=0, line_alpha=0)\n\n# Configure HoverTool with statistics\nhover = HoverTool(\n    renderers=[hover_circles],\n    tooltips=[(\"Group\", \"@group\"), (\"Median\", \"@median\"), (\"Q1\", \"@q1\"), (\"Q3\", \"@q3\"), (\"N\", \"@n\")],\n)\nhover_circles.data_source.data.update(stats_data)\np.add_tools(hover)\n\n# Add legend with theme-adaptive styling\nlegend = Legend(\n    items=[\n        LegendItem(label=\"Placebo\", renderers=[placebo_patch]),\n        LegendItem(label=\"Active Treatment\", renderers=[treatment_patch]),\n    ],\n    location=\"top_right\",\n)\nlegend.label_text_font_size = \"22pt\"\nlegend.glyph_width = 80\nlegend.glyph_height = 50\nlegend.spacing = 20\nlegend.padding = 30\nlegend.background_fill_alpha = 0.9\nlegend.background_fill_color = ELEVATED_BG\nlegend.border_line_color = INK_SOFT\nlegend.border_line_width = 2\nlegend.label_text_color = INK\np.add_layout(legend)\n\n# Style - large text for 4800x2700 canvas\np.title.text_font_size = \"28pt\"\np.title.align = \"center\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"22pt\"\np.yaxis.axis_label_text_font_size = \"22pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"18pt\"\np.yaxis.major_label_text_font_size = \"18pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\n# Grid styling\np.xgrid.grid_line_color = None\np.ygrid.grid_line_alpha = 0.15\np.ygrid.grid_line_color = INK\n\n# Axis styling\np.xaxis.axis_line_color = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.outline_line_color = INK_SOFT\np.outline_line_width = 1\n\n# 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 - Selenium 4 / Selenium Manager\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"}