{"spec_id":"box-notched","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// box-notched: Notched Box Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-18\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG + Box-Muller, no seeded Math.random in browser) ---\nconst lcgState = { s: 20260818 >>> 0 };\nfunction rand() {\n  lcgState.s = (lcgState.s * 1664525 + 1013904223) >>> 0;\n  return lcgState.s / 4294967296;\n}\nfunction randNormal() {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\n// --- Data: symptom improvement score across a clinical trial's dose arms ------\nconst groups = [\n  { name: \"Placebo\", n: 45, mean: 50, sd: 8 },\n  { name: \"Low Dose\", n: 50, mean: 54, sd: 8 },\n  { name: \"High Dose\", n: 42, mean: 68, sd: 9 },\n  { name: \"Combination\", n: 48, mean: 71, sd: 9 },\n];\n\nfunction quantile(sorted, q) {\n  const pos = (sorted.length - 1) * q;\n  const lo = Math.floor(pos);\n  const hi = Math.ceil(pos);\n  if (lo === hi) return sorted[lo];\n  return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);\n}\n\ngroups.forEach((g) => {\n  const values = Array.from(\n    { length: g.n },\n    () => Math.round((g.mean + randNormal() * g.sd) * 10) / 10,\n  );\n  g.values = values;\n});\n// Inject a few genuine outliers so the \"beyond whiskers\" rule has something to show.\ngroups[0].values.push(94.6, 97.2);\ngroups[2].values.push(29.8);\n\ngroups.forEach((g) => {\n  const sorted = [...g.values].sort((a, b) => a - b);\n  const n = sorted.length;\n  const q1 = quantile(sorted, 0.25);\n  const median = quantile(sorted, 0.5);\n  const q3 = quantile(sorted, 0.75);\n  const iqr = q3 - q1;\n  const loBound = q1 - 1.5 * iqr;\n  const hiBound = q3 + 1.5 * iqr;\n  const inRange = sorted.filter((v) => v >= loBound && v <= hiBound);\n  const whiskerLo = inRange.length ? inRange[0] : q1;\n  const whiskerHi = inRange.length ? inRange[inRange.length - 1] : q3;\n  const outliers = sorted.filter((v) => v < whiskerLo || v > whiskerHi);\n  const notchHalf = (1.57 * iqr) / Math.sqrt(n);\n\n  g.stats = {\n    n,\n    q1,\n    median,\n    q3,\n    whiskerLo,\n    whiskerHi,\n    outliers,\n    notchTop: Math.min(median + notchHalf, q3),\n    notchBottom: Math.max(median - notchHalf, q1),\n  };\n});\n\nconst allValues = groups.flatMap((g) => [\n  g.stats.whiskerLo,\n  g.stats.whiskerHi,\n  ...g.stats.outliers,\n]);\nconst dataMin = Math.min(...allValues);\nconst dataMax = Math.max(...allValues);\nconst dataPad = (dataMax - dataMin) * 0.1;\nconst axisMin = Math.floor((dataMin - dataPad) / 5) * 5;\nconst axisMax = Math.ceil((dataMax + dataPad) / 5) * 5;\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Custom notched-box renderer ----------------------------------------------\n// Chart.js has no built-in box-plot type; the box + notch + whisker geometry is\n// drawn by hand with the canvas API inside a plugin hook, using the bar chart's\n// own category/linear scales for coordinate conversion. No external chart type\n// or plugin package is involved — this is core Chart.js's public plugin API.\nfunction hexToRgba(hex, alpha) {\n  const r = parseInt(hex.slice(1, 3), 16);\n  const g = parseInt(hex.slice(3, 5), 16);\n  const b = parseInt(hex.slice(5, 7), 16);\n  return `rgba(${r}, ${g}, ${b}, ${alpha})`;\n}\n\nconst notchedBoxPlugin = {\n  id: \"notchedBox\",\n  afterDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    const xScale = scales.x;\n    const yScale = scales.y;\n    const bandWidth =\n      groups.length > 1\n        ? xScale.getPixelForValue(1) - xScale.getPixelForValue(0)\n        : chart.chartArea.width;\n    const halfWidth = bandWidth * 0.24;\n    const innerHalfWidth = halfWidth * 0.45;\n\n    groups.forEach((g, i) => {\n      const s = g.stats;\n      const color = t.palette[i % t.palette.length];\n      const cx = xScale.getPixelForValue(i);\n      const yQ1 = yScale.getPixelForValue(s.q1);\n      const yQ3 = yScale.getPixelForValue(s.q3);\n      const yMed = yScale.getPixelForValue(s.median);\n      const yNotchTop = yScale.getPixelForValue(s.notchTop);\n      const yNotchBottom = yScale.getPixelForValue(s.notchBottom);\n      const yWhiskerLo = yScale.getPixelForValue(s.whiskerLo);\n      const yWhiskerHi = yScale.getPixelForValue(s.whiskerHi);\n\n      // Whiskers\n      ctx.save();\n      ctx.strokeStyle = color;\n      ctx.lineWidth = 2.5;\n      ctx.beginPath();\n      ctx.moveTo(cx, yQ3);\n      ctx.lineTo(cx, yWhiskerHi);\n      ctx.moveTo(cx - halfWidth * 0.6, yWhiskerHi);\n      ctx.lineTo(cx + halfWidth * 0.6, yWhiskerHi);\n      ctx.moveTo(cx, yQ1);\n      ctx.lineTo(cx, yWhiskerLo);\n      ctx.moveTo(cx - halfWidth * 0.6, yWhiskerLo);\n      ctx.lineTo(cx + halfWidth * 0.6, yWhiskerLo);\n      ctx.stroke();\n\n      // Notched box (hourglass taper toward the median CI)\n      ctx.beginPath();\n      ctx.moveTo(cx - halfWidth, yQ3);\n      ctx.lineTo(cx + halfWidth, yQ3);\n      ctx.lineTo(cx + halfWidth, yNotchTop);\n      ctx.lineTo(cx + innerHalfWidth, yMed);\n      ctx.lineTo(cx + halfWidth, yNotchBottom);\n      ctx.lineTo(cx + halfWidth, yQ1);\n      ctx.lineTo(cx - halfWidth, yQ1);\n      ctx.lineTo(cx - halfWidth, yNotchBottom);\n      ctx.lineTo(cx - innerHalfWidth, yMed);\n      ctx.lineTo(cx - halfWidth, yNotchTop);\n      ctx.closePath();\n      ctx.fillStyle = hexToRgba(color, 0.35);\n      ctx.fill();\n      ctx.stroke();\n\n      // Median line, spanning the notch's pinched width\n      ctx.strokeStyle = t.ink;\n      ctx.lineWidth = 3;\n      ctx.beginPath();\n      ctx.moveTo(cx - innerHalfWidth, yMed);\n      ctx.lineTo(cx + innerHalfWidth, yMed);\n      ctx.stroke();\n      ctx.restore();\n\n      // Outliers\n      ctx.save();\n      ctx.fillStyle = color;\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 1.5;\n      s.outliers.forEach((v) => {\n        const cy = yScale.getPixelForValue(v);\n        ctx.beginPath();\n        ctx.arc(cx, cy, 6, 0, 2 * Math.PI);\n        ctx.fill();\n        ctx.stroke();\n      });\n      ctx.restore();\n    });\n  },\n};\n\n// --- Chart ---------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  data: {\n    labels: groups.map((g) => g.name),\n    datasets: [\n      {\n        label: \"Symptom Improvement Score\",\n        data: groups.map(() => null),\n        backgroundColor: \"transparent\",\n        borderWidth: 0,\n      },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 10, right: 20, bottom: 0, left: 0 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"box-notched · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: { display: false },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        ticks: { color: t.inkSoft, font: { size: 16 } },\n        grid: { display: false },\n        title: {\n          display: true,\n          text: \"Treatment Arm\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n      y: {\n        min: axisMin,\n        max: axisMax,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: {\n          display: true,\n          text: \"Symptom Improvement Score\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [notchedBoxPlugin],\n});\n"}