{"spec_id":"box-notched","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nbox-notched: Notched Box Plot\nLibrary: letsplot 4.11.0 | Python 3.13.15\nQuality: 93/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nGRID_COLOR = \"rgba(26,26,23,0.15)\" if THEME == \"light\" else \"rgba(240,239,232,0.15)\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\n# Data - department salaries with different distributions for statistical comparison\nnp.random.seed(42)\n\ndepartments = [\"Engineering\", \"Marketing\", \"Sales\", \"Finance\", \"Operations\"]\ndata = []\n\n# Engineering: higher salaries, moderate spread\neng_salaries = np.random.normal(95000, 12000, 80)\ndata.extend([{\"Department\": \"Engineering\", \"Salary\": s} for s in eng_salaries])\n\n# Marketing: medium salaries, wider spread with some outliers\nmkt_salaries = np.concatenate(\n    [\n        np.random.normal(72000, 15000, 70),\n        np.array([120000, 125000, 35000]),  # outliers\n    ]\n)\ndata.extend([{\"Department\": \"Marketing\", \"Salary\": s} for s in mkt_salaries])\n\n# Sales: variable salaries with commission-based outliers\nsales_salaries = np.concatenate(\n    [\n        np.random.normal(68000, 10000, 65),\n        np.array([130000, 140000, 145000, 30000, 28000]),  # high and low outliers\n    ]\n)\ndata.extend([{\"Department\": \"Sales\", \"Salary\": s} for s in sales_salaries])\n\n# Finance: similar to engineering but slightly lower (overlapping notches expected)\nfin_salaries = np.random.normal(90000, 11000, 75)\ndata.extend([{\"Department\": \"Finance\", \"Salary\": s} for s in fin_salaries])\n\n# Operations: lower salaries, tight distribution\nops_salaries = np.random.normal(58000, 8000, 85)\ndata.extend([{\"Department\": \"Operations\", \"Salary\": s} for s in ops_salaries])\n\ndf = pd.DataFrame(data)\n\n# lets-plot's geom_boxplot(notch=...) keyword is not implemented by the\n# rendering backend (silently ignored), so the notch geometry is built by\n# hand from the five-number summary: a 10-vertex \"bowtie\" polygon per\n# category whose waist pinches to the median at +/-1.57*IQR/sqrt(n).\ndept_x = {dept: i + 1 for i, dept in enumerate(departments)}\ndf[\"x\"] = df[\"Department\"].map(dept_x)\n\nBOX_HALF_WIDTH = 0.32\nNOTCH_INDENT_FRAC = 0.5  # fraction of half-width the waist pinches to (R default)\n\npoly_rows = []\nwhisker_rows = []\noutlier_rows = []\n\nfor dept in departments:\n    vals = df.loc[df[\"Department\"] == dept, \"Salary\"].to_numpy()\n    n = len(vals)\n    q1, median, q3 = np.percentile(vals, [25, 50, 75])\n    iqr = q3 - q1\n    lo_fence, hi_fence = q1 - 1.5 * iqr, q3 + 1.5 * iqr\n    inside = vals[(vals >= lo_fence) & (vals <= hi_fence)]\n    whisker_lo, whisker_hi = inside.min(), inside.max()\n    outliers = vals[(vals < lo_fence) | (vals > hi_fence)]\n\n    notch_half = 1.57 * iqr / np.sqrt(n)\n    notch_lo = max(median - notch_half, q1)\n    notch_hi = min(median + notch_half, q3)\n\n    x0 = dept_x[dept]\n    hw = BOX_HALF_WIDTH\n    indent = hw * NOTCH_INDENT_FRAC\n\n    vertices = [\n        (x0 - hw, q3),\n        (x0 + hw, q3),\n        (x0 + hw, notch_hi),\n        (x0 + indent, median),\n        (x0 + hw, notch_lo),\n        (x0 + hw, q1),\n        (x0 - hw, q1),\n        (x0 - hw, notch_lo),\n        (x0 - indent, median),\n        (x0 - hw, notch_hi),\n    ]\n    poly_rows.extend(\n        {\n            \"Department\": dept,\n            \"x\": px,\n            \"y\": py,\n            \"Q1\": round(q1),\n            \"Median\": round(median),\n            \"Q3\": round(q3),\n            \"NotchCI\": f\"{round(notch_lo)} - {round(notch_hi)}\",\n        }\n        for px, py in vertices\n    )\n\n    cap = hw * 0.4\n    whisker_rows.append({\"Department\": dept, \"x\": x0, \"xend\": x0, \"y\": q3, \"yend\": whisker_hi})\n    whisker_rows.append({\"Department\": dept, \"x\": x0, \"xend\": x0, \"y\": q1, \"yend\": whisker_lo})\n    whisker_rows.append({\"Department\": dept, \"x\": x0 - cap, \"xend\": x0 + cap, \"y\": whisker_hi, \"yend\": whisker_hi})\n    whisker_rows.append({\"Department\": dept, \"x\": x0 - cap, \"xend\": x0 + cap, \"y\": whisker_lo, \"yend\": whisker_lo})\n\n    outlier_rows.extend({\"Department\": dept, \"x\": x0, \"y\": o} for o in outliers)\n\npoly_df = pd.DataFrame(poly_rows)\nwhisker_df = pd.DataFrame(whisker_rows)\noutlier_df = pd.DataFrame(outlier_rows)\n\n# Jittered raw points behind the boxes surface the underlying distribution\n# shape that the five-number summary hides\ndf[\"x_jitter\"] = df[\"x\"] + np.random.uniform(-0.16, 0.16, len(df))\n\nplot = (\n    ggplot()\n    + geom_point(\n        aes(x=\"x_jitter\", y=\"Salary\", color=\"Department\"), data=df, size=1.6, alpha=0.15, shape=16, show_legend=False\n    )\n    + geom_segment(\n        aes(x=\"x\", xend=\"xend\", y=\"y\", yend=\"yend\", color=\"Department\"), data=whisker_df, size=1.0, show_legend=False\n    )\n    + geom_polygon(\n        aes(x=\"x\", y=\"y\", group=\"Department\", fill=\"Department\"),\n        data=poly_df,\n        color=INK,\n        size=0.6,\n        alpha=0.85,\n        show_legend=False,\n        tooltips=layer_tooltips()\n        .line(\"@Department\")\n        .line(\"Q1/Median/Q3|@Q1 / @Median / @Q3\")\n        .line(\"Notch CI|@NotchCI\"),\n    )\n    + geom_point(\n        aes(x=\"x\", y=\"y\", color=\"Department\"),\n        data=outlier_df,\n        size=2.5,\n        alpha=0.8,\n        show_legend=False,\n        tooltips=layer_tooltips().line(\"@Department\").line(\"Outlier salary|@y\"),\n    )\n    + scale_fill_manual(values=IMPRINT)\n    + scale_color_manual(values=IMPRINT)\n    + scale_x_continuous(breaks=list(dept_x.values()), labels=list(dept_x.keys()))\n    + labs(title=\"box-notched · python · letsplot · anyplot.ai\", x=\"Department\", y=\"Annual Salary (USD)\")\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_grid_major_y=element_line(color=GRID_COLOR, size=0.3, linetype=\"solid\"),\n        panel_grid_major_x=element_blank(),\n        panel_grid_minor=element_blank(),\n        axis_title=element_text(size=12, color=INK),\n        axis_text_x=element_text(size=10, color=INK_SOFT),\n        axis_text_y=element_text(size=10, color=INK_SOFT),\n        axis_line=element_line(color=INK_SOFT, size=0.4),\n        plot_title=element_text(size=16, color=INK, hjust=0.5),\n        legend_position=\"none\",\n    )\n    + ggsize(800, 450)\n)\n\n# Save as PNG (scale 4x for 3200x1800)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\n\n# Save as HTML for interactivity\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}