{"spec_id":"box-grouped","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// box-grouped: Grouped 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\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\n// --- Data: quarterly performance rating by department and experience level ---\nconst departments = [\"Engineering\", \"Sales\", \"Marketing\", \"Support\"];\nconst levels = [\n  { name: \"Junior\", baseRating: 61, sd: 10 },\n  { name: \"Mid-level\", baseRating: 73, sd: 8 },\n  { name: \"Senior\", baseRating: 83, sd: 7 },\n];\nconst deptShift = { Engineering: 4, Sales: 0, Marketing: -2, Support: -4 };\nconst n = 45;\n\nconst cells = departments.map((dept) =>\n  levels.map((level) => {\n    const mean = level.baseRating + deptShift[dept];\n    const values = Array.from(\n      { length: n },\n      () => Math.round((mean + randNormal() * level.sd) * 10) / 10,\n    );\n    return { dept, level: level.name, values };\n  }),\n);\n\n// Inject a handful of genuine outliers so \"beyond whiskers\" has something to show.\ncells[0][0].values.push(15.2, 12.8);\ncells[2][2].values.push(99.1);\ncells[3][1].values.push(28.4);\n\ncells.forEach((row) =>\n  row.forEach((cell) => {\n    const sorted = [...cell.values].sort((a, b) => a - b);\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    cell.stats = { q1, median, q3, whiskerLo, whiskerHi, outliers };\n  }),\n);\n\nconst allValues = cells.flatMap((row) =>\n  row.flatMap((cell) => [cell.stats.whiskerLo, cell.stats.whiskerHi, ...cell.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 grouped-box renderer ----------------------------------------------\n// Chart.js has no built-in box-plot type; the box/whisker/outlier geometry is\n// drawn by hand with the canvas API inside a plugin hook. The invisible grouped\n// bar chart underneath supplies correct per-subcategory x-positions and widths\n// via its own bar-element metadata — no external chart type or plugin package.\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 groupedBoxPlugin = {\n  id: \"groupedBox\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n\n    levels.forEach((level, levelIdx) => {\n      const meta = chart.getDatasetMeta(levelIdx);\n      const color = t.palette[levelIdx % t.palette.length];\n\n      departments.forEach((dept, deptIdx) => {\n        const el = meta.data[deptIdx];\n        if (!el) return;\n        const cell = cells[deptIdx][levelIdx];\n        const s = cell.stats;\n        const cx = el.x;\n        const halfWidth = el.width * 0.42;\n\n        const yQ1 = chart.scales.y.getPixelForValue(s.q1);\n        const yQ3 = chart.scales.y.getPixelForValue(s.q3);\n        const yMed = chart.scales.y.getPixelForValue(s.median);\n        const yWhiskerLo = chart.scales.y.getPixelForValue(s.whiskerLo);\n        const yWhiskerHi = chart.scales.y.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.5, yWhiskerHi);\n        ctx.lineTo(cx + halfWidth * 0.5, yWhiskerHi);\n        ctx.moveTo(cx, yQ1);\n        ctx.lineTo(cx, yWhiskerLo);\n        ctx.moveTo(cx - halfWidth * 0.5, yWhiskerLo);\n        ctx.lineTo(cx + halfWidth * 0.5, yWhiskerLo);\n        ctx.stroke();\n\n        // Quartile box\n        ctx.fillStyle = hexToRgba(color, 0.45);\n        ctx.fillRect(cx - halfWidth, yQ3, halfWidth * 2, yQ1 - yQ3);\n        ctx.strokeRect(cx - halfWidth, yQ3, halfWidth * 2, yQ1 - yQ3);\n\n        // Median line\n        ctx.strokeStyle = t.ink;\n        ctx.lineWidth = 3;\n        ctx.beginPath();\n        ctx.moveTo(cx - halfWidth, yMed);\n        ctx.lineTo(cx + halfWidth, 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 = chart.scales.y.getPixelForValue(v);\n          ctx.beginPath();\n          ctx.arc(cx, cy, 5.5, 0, 2 * Math.PI);\n          ctx.fill();\n          ctx.stroke();\n        });\n        ctx.restore();\n      });\n    });\n  },\n};\n\n// --- Chart ---------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  data: {\n    labels: departments,\n    datasets: levels.map((level, levelIdx) => ({\n      label: level.name,\n      data: departments.map(() => 0),\n      backgroundColor: \"transparent\",\n      borderWidth: 0,\n      categoryPercentage: 0.8,\n      barPercentage: 0.9,\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-grouped · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      legend: {\n        display: true,\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          boxWidth: 24,\n          boxHeight: 16,\n          // Bar datasets are invisible (the box-plot glyphs are hand-drawn by\n          // groupedBoxPlugin), so build swatches from the same palette colors\n          // the plugin uses instead of the datasets' transparent fill.\n          generateLabels: (chart) =>\n            levels.map((level, i) => ({\n              text: level.name,\n              fillStyle: t.palette[i % t.palette.length],\n              strokeStyle: t.palette[i % t.palette.length],\n              fontColor: t.ink,\n              datasetIndex: i,\n            })),\n        },\n      },\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: \"Department\",\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: \"Quarterly Performance Rating\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [groupedBoxPlugin],\n});\n"}