{"spec_id":"box-grouped","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbox-grouped: Grouped Box Plot\nLibrary: bokeh 3.9.2 | Python 3.13.15\nQuality: 91/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import Arrow, ColumnDataSource, FixedTicker, HoverTool, Label, Legend, LegendItem, NormalHead, Range1d\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Theme-adaptive Chrome\")\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# Imprint palette — first categorical series is always #009E73\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\n\n# Data - Employee performance scores across departments by experience level\nnp.random.seed(42)\n\ncategories = [\"Sales\", \"Engineering\", \"Marketing\", \"Support\"]\nsubcategories = [\"Junior\", \"Senior\", \"Lead\"]\n\n# Generate performance data with different distributions per group\ndata = {}\nfor cat in categories:\n    data[cat] = {}\n    for i, sub in enumerate(subcategories):\n        # Different base means for departments\n        base = {\"Sales\": 70, \"Engineering\": 75, \"Marketing\": 68, \"Support\": 72}[cat]\n        # Experience adds to mean\n        exp_bonus = i * 8\n        # Generate realistic performance scores (50-100 range)\n        n_points = 50\n        scores = np.random.normal(base + exp_bonus, 10, n_points)\n        scores = np.clip(scores, 40, 100)\n        # Add some outliers for visual interest\n        if cat == \"Engineering\" and sub == \"Lead\":\n            scores = np.append(scores, [38, 100, 100])  # Add outliers\n        if cat == \"Sales\" and sub == \"Junior\":\n            scores = np.append(scores, [35, 105])  # Add outliers\n        data[cat][sub] = scores\n\n\n# Calculate box plot statistics\ndef calc_boxplot_stats(values):\n    q1 = np.percentile(values, 25)\n    q2 = np.percentile(values, 50)  # median\n    q3 = np.percentile(values, 75)\n    iqr = q3 - q1\n    upper_whisker = min(max(values), q3 + 1.5 * iqr)\n    lower_whisker = max(min(values), q1 - 1.5 * iqr)\n    outliers = values[(values < lower_whisker) | (values > upper_whisker)]\n    return {\"q1\": q1, \"q2\": q2, \"q3\": q3, \"lower\": lower_whisker, \"upper\": upper_whisker, \"outliers\": outliers}\n\n\n# Create figure — width/height are the TOTAL canvas (see prompts/library/bokeh.md \"Canvas — hard rule\")\np = figure(\n    width=3200,\n    height=1800,\n    # A plain FactorRange's implicit padding isn't wide enough to fit boxes\n    # offset from the first/last category (they clip against the frame edge)\n    # — use an explicit numeric range with room for the widest box offset.\n    x_range=Range1d(start=0.5, end=len(categories) + 0.5),\n    y_range=(30, 110),\n    title=\"box-grouped · python · bokeh · anyplot.ai\",\n    x_axis_label=\"Department\",\n    y_axis_label=\"Performance Score\",\n    tools=\"\",\n    toolbar_location=None,  # bokeh's default toolbar shrinks the saved PNG below the target height\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# Styling\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\np.title.align = \"center\"\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\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.xgrid.grid_line_color = INK\np.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.15\np.ygrid.grid_line_alpha = 0.15\np.xgrid.grid_line_dash = \"dashed\"\np.ygrid.grid_line_dash = \"dashed\"\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None  # despine-equivalent: no boxed plot border\n\n# Box dimensions\nbox_width = 0.22\noffsets = [-0.28, 0, 0.28]  # Position offsets for subcategories\n\n# Store renderers for legend and hover tooltips\nlegend_items = []\nbox_renderers = []\n\n# Track medians and the highest visible point per category — used below to\n# build a data-driven \"Junior -> Lead\" trend annotation (DE-03).\nmedians = {sub: {} for sub in subcategories}\nmax_y_per_cat = dict.fromkeys(categories, -np.inf)\n\n# Draw grouped box plots\nfor sub_idx, sub in enumerate(subcategories):\n    color = IMPRINT_PALETTE[sub_idx]\n    offset = offsets[sub_idx]\n\n    # Collect data for this subcategory across all categories\n    boxes_lower = []\n    boxes_upper = []\n    boxes_q1 = []\n    boxes_q2 = []\n    boxes_q3 = []\n    x_positions = []\n    all_outliers_x = []\n    all_outliers_y = []\n\n    for cat_idx, cat in enumerate(categories):\n        stats = calc_boxplot_stats(data[cat][sub])\n        # bokeh's FactorRange places factors at synthetic coordinates 1, 2, 3, ...\n        # (1-indexed), not 0-indexed — offset from (cat_idx + 1), not cat_idx.\n        x_pos = (cat_idx + 1) + offset\n        x_positions.append(x_pos)\n\n        boxes_lower.append(stats[\"lower\"])\n        boxes_upper.append(stats[\"upper\"])\n        boxes_q1.append(stats[\"q1\"])\n        boxes_q2.append(stats[\"q2\"])\n        boxes_q3.append(stats[\"q3\"])\n\n        medians[sub][cat] = stats[\"q2\"]\n        cat_top = max(stats[\"upper\"], *stats[\"outliers\"]) if len(stats[\"outliers\"]) else stats[\"upper\"]\n        max_y_per_cat[cat] = max(max_y_per_cat[cat], cat_top)\n\n        # Collect outliers\n        for outlier in stats[\"outliers\"]:\n            all_outliers_x.append(x_pos)\n            all_outliers_y.append(outlier)\n\n    # Draw whisker stems (vertical lines from lower to upper)\n    for i, _cat in enumerate(categories):\n        x_pos = x_positions[i]\n        # Lower whisker\n        p.segment(\n            x0=[x_pos],\n            y0=[boxes_lower[i]],\n            x1=[x_pos],\n            y1=[boxes_q1[i]],\n            line_color=INK_SOFT,\n            line_width=3,\n            line_cap=\"round\",\n        )\n        # Upper whisker\n        p.segment(\n            x0=[x_pos],\n            y0=[boxes_q3[i]],\n            x1=[x_pos],\n            y1=[boxes_upper[i]],\n            line_color=INK_SOFT,\n            line_width=3,\n            line_cap=\"round\",\n        )\n        # Whisker caps\n        cap_width = box_width * 0.6\n        p.segment(\n            x0=[x_pos - cap_width / 2],\n            y0=[boxes_lower[i]],\n            x1=[x_pos + cap_width / 2],\n            y1=[boxes_lower[i]],\n            line_color=INK_SOFT,\n            line_width=3,\n            line_cap=\"round\",\n        )\n        p.segment(\n            x0=[x_pos - cap_width / 2],\n            y0=[boxes_upper[i]],\n            x1=[x_pos + cap_width / 2],\n            y1=[boxes_upper[i]],\n            line_color=INK_SOFT,\n            line_width=3,\n            line_cap=\"round\",\n        )\n\n    # Draw boxes (q1 to q3) — category/subcategory/median are only used by the\n    # HoverTool tooltip on the HTML artifact (LM-02); they don't affect the PNG.\n    box_source = ColumnDataSource(\n        data={\n            \"x\": x_positions,\n            \"bottom\": boxes_q1,\n            \"top\": boxes_q3,\n            \"category\": categories,\n            \"subcategory\": [sub] * len(categories),\n            \"median\": boxes_q2,\n        }\n    )\n\n    box_renderer = p.vbar(\n        x=\"x\",\n        width=box_width,\n        bottom=\"bottom\",\n        top=\"top\",\n        source=box_source,\n        fill_color=color,\n        fill_alpha=0.85,\n        line_color=INK_SOFT,\n        line_width=2,\n    )\n    box_renderers.append(box_renderer)\n\n    # Draw median lines\n    for i in range(len(categories)):\n        p.segment(\n            x0=[x_positions[i] - box_width / 2],\n            y0=[boxes_q2[i]],\n            x1=[x_positions[i] + box_width / 2],\n            y1=[boxes_q2[i]],\n            line_color=INK,\n            line_width=4,\n            line_cap=\"round\",\n        )\n\n    # Draw outliers\n    if all_outliers_x:\n        p.scatter(\n            x=all_outliers_x,\n            y=all_outliers_y,\n            size=18,\n            color=color,\n            alpha=0.9,\n            line_color=INK_SOFT,\n            line_width=2,\n            marker=\"circle\",\n        )\n\n    # Store for legend\n    legend_items.append(LegendItem(label=sub, renderers=[box_renderer]))\n\n# Add legend\nlegend = Legend(\n    items=legend_items,\n    location=\"top_right\",\n    label_text_font_size=\"30pt\",\n    label_text_color=INK_SOFT,\n    glyph_width=40,\n    glyph_height=40,\n    spacing=15,\n    padding=20,\n    background_fill_color=ELEVATED_BG,\n    background_fill_alpha=0.9,\n    border_line_color=INK_SOFT,\n    border_line_width=2,\n)\np.add_layout(legend, \"right\")\n\n# Bokeh-distinctive interactivity (LM-02): hover tooltips on the HTML\n# artifact — no effect on the static PNG, which is captured without a\n# mouse position.\nhover = HoverTool(\n    renderers=box_renderers,\n    tooltips=[\n        (\"Department\", \"@category\"),\n        (\"Level\", \"@subcategory\"),\n        (\"Median\", \"@median{0.0}\"),\n        (\"Q1 – Q3\", \"@bottom{0.0} – @top{0.0}\"),\n    ],\n)\np.add_tools(hover)\n\n# Ticks live at the same 1, 2, 3, ... synthetic positions used for x_pos above\np.xaxis.ticker = FixedTicker(ticks=list(range(1, len(categories) + 1)))\np.xaxis.major_label_overrides = {i + 1: cat for i, cat in enumerate(categories)}\n\n# Data storytelling (DE-03): call out the department with the largest\n# Junior -> Lead score gap with a connecting arrow + label, derived from the\n# medians actually computed above (not hardcoded).\nhighlight_cat = max(categories, key=lambda cat: medians[\"Lead\"][cat] - medians[\"Junior\"][cat])\nhighlight_gap = medians[\"Lead\"][highlight_cat] - medians[\"Junior\"][highlight_cat]\nhighlight_idx = categories.index(highlight_cat) + 1  # 1-indexed synthetic x position\nx_junior = highlight_idx + offsets[0]\nx_lead = highlight_idx + offsets[-1]\nannotation_y = min(104, max_y_per_cat[highlight_cat] + 6)\n\np.add_layout(\n    Arrow(\n        x_start=x_junior,\n        y_start=annotation_y,\n        x_end=x_lead,\n        y_end=annotation_y,\n        start=NormalHead(size=8, fill_color=INK_SOFT, line_color=INK_SOFT),\n        end=NormalHead(size=8, fill_color=INK_SOFT, line_color=INK_SOFT),\n        line_color=INK_SOFT,\n        line_width=2,\n    )\n)\np.add_layout(\n    Label(\n        x=(x_junior + x_lead) / 2,\n        y=annotation_y + 3,\n        text=f\"Junior → Lead: +{highlight_gap:.0f} pts\",\n        text_font_size=\"22pt\",\n        text_font_style=\"italic\",\n        text_color=INK_SOFT,\n        text_align=\"center\",\n        text_baseline=\"bottom\",\n    )\n)\n\n# Save the interactive HTML (also a required catalog artifact)\noutput_file(f\"plot-{THEME}.html\", title=\"box-grouped · python · bokeh · anyplot.ai\")\nsave(p)\n\n# Screenshot it with headless Chrome — bokeh.export_png() is unreliable on this\n# box (chromedriver snap shim), so render + screenshot the saved HTML instead,\n# matching the pattern in prompts/library/bokeh.md.\nW, H = 3200, 1800\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()}\")\n# Headless Chrome's --window-size sets the OUTER window; pin the viewport exactly via CDP.\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)  # let bokeh's JS render the canvas\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}