{"spec_id":"violin-split","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// violin-split: Split Violin Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-09\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG) + samplers ------------------------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return function next() {\n    state = (Math.imul(1664525, state) + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\n\nfunction randNormal(rng, mean, std) {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\nfunction clamp(value, min, max) {\n  return Math.min(max, Math.max(min, value));\n}\n\n// --- Data: exam scores by subject, control vs. new-teaching-method cohort --\nconst rng = makeLcg(2026);\nconst sampleSize = 130;\n\nfunction sampleGroup(mean, std) {\n  return Array.from({ length: sampleSize }, () => clamp(randNormal(rng, mean, std), 20, 100));\n}\n\nconst subjects = [\n  { name: \"Reading\", control: [68, 10], treatment: [74, 9] },\n  { name: \"Writing\", control: [65, 12], treatment: [71, 8] },\n  { name: \"Math\", control: [60, 14], treatment: [60, 14] },\n  { name: \"Science\", control: [70, 9], treatment: [82, 7] },\n  { name: \"History\", control: [72, 11], treatment: [75, 13] },\n];\n\n// --- Stats helpers -----------------------------------------------------------\nfunction mean(values) {\n  return values.reduce((sum, v) => sum + v, 0) / values.length;\n}\n\nfunction std(values, m) {\n  const variance = values.reduce((sum, v) => sum + (v - m) ** 2, 0) / (values.length - 1);\n  return Math.sqrt(variance);\n}\n\nfunction silvermanBandwidth(values) {\n  return 1.06 * std(values, mean(values)) * values.length ** (-1 / 5);\n}\n\nfunction gaussianKde(values, evalPoints, bandwidth) {\n  const norm = 1 / (values.length * bandwidth * Math.sqrt(2 * Math.PI));\n  return evalPoints.map((point) => {\n    let sum = 0;\n    for (const v of values) {\n      const u = (point - v) / bandwidth;\n      sum += Math.exp(-0.5 * u * u);\n    }\n    return sum * norm;\n  });\n}\n\nfunction quantile(sortedValues, q) {\n  const idx = q * (sortedValues.length - 1);\n  const lower = Math.floor(idx);\n  const upper = Math.ceil(idx);\n  if (lower === upper) return sortedValues[lower];\n  return sortedValues[lower] + (sortedValues[upper] - sortedValues[lower]) * (idx - lower);\n}\n\n// --- Build a mirrored KDE half for each cohort, sharing one y-grid per subject\n// so the two halves meet exactly at the category's center line -------------\nconst gridSize = 110;\nconst maxHalfWidth = 0.42;\nconst innerOffset = 0.09; // quartile/median marker distance from the center line\n\nconst violins = subjects.map((subject, i) => {\n  const catX = i + 1;\n  const control = sampleGroup(...subject.control);\n  const treatment = sampleGroup(...subject.treatment);\n  const sortedControl = [...control].sort((a, b) => a - b);\n  const sortedTreatment = [...treatment].sort((a, b) => a - b);\n\n  const combinedMin = Math.min(sortedControl[0], sortedTreatment[0]);\n  const combinedMax = Math.max(sortedControl[sortedControl.length - 1], sortedTreatment[sortedTreatment.length - 1]);\n  const pad = (combinedMax - combinedMin) * 0.12;\n  const yMin = combinedMin - pad;\n  const yMax = combinedMax + pad;\n  const step = (yMax - yMin) / (gridSize - 1);\n  const evalPoints = Array.from({ length: gridSize }, (_, j) => yMin + j * step);\n\n  const densityControl = gaussianKde(control, evalPoints, silvermanBandwidth(sortedControl));\n  const densityTreatment = gaussianKde(treatment, evalPoints, silvermanBandwidth(sortedTreatment));\n  const scaleControl = maxHalfWidth / Math.max(...densityControl);\n  const scaleTreatment = maxHalfWidth / Math.max(...densityTreatment);\n\n  const statsFor = (sorted) => ({\n    q1: quantile(sorted, 0.25),\n    median: quantile(sorted, 0.5),\n    q3: quantile(sorted, 0.75),\n  });\n\n  return {\n    catX,\n    evalPoints,\n    densityControl,\n    scaleControl,\n    densityTreatment,\n    scaleTreatment,\n    stats: { control: statsFor(sortedControl), treatment: statsFor(sortedTreatment) },\n    raw: [...control, ...treatment],\n  };\n});\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\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 controlColor = t.palette[0];\nconst treatmentColor = t.palette[1];\n\n// Round to clean tick bounds based on the raw (clamped) data range so a\n// single skewed subject's KDE padding can't dictate the shared axis extent.\nconst rawValues = violins.flatMap((v) => v.raw);\nconst rawMin = Math.min(...rawValues);\nconst rawMax = Math.max(...rawValues);\nconst axisPad = (rawMax - rawMin) * 0.08;\nconst yAxisMin = Math.floor((rawMin - axisPad) / 5) * 5;\nconst yAxisMax = Math.ceil((rawMax + axisPad) / 5) * 5;\n\n// --- Split violins + quartile/median markers: hand-drawn on the canvas ----\n// with the canvas API in a plugin hook, using the chart's own linear x/y\n// scales for pixel mapping. Chart.js has no built-in violin type, and its\n// line-dataset \"fill\" option only fills between two curves that share the\n// same index axis — it cannot fill a horizontally-varying silhouette like a\n// violin half, so the shape is drawn directly instead of faked through fill.\nfunction drawHalf(ctx, centerXpx, points, color) {\n  ctx.save();\n  ctx.beginPath();\n  ctx.moveTo(centerXpx, points[0].yPx);\n  points.forEach((p) => ctx.lineTo(p.xPx, p.yPx));\n  ctx.lineTo(centerXpx, points[points.length - 1].yPx);\n  ctx.closePath();\n  ctx.fillStyle = hexToRgba(color, 0.55);\n  ctx.fill();\n  ctx.restore();\n\n  ctx.save();\n  ctx.beginPath();\n  points.forEach((p, j) => (j === 0 ? ctx.moveTo(p.xPx, p.yPx) : ctx.lineTo(p.xPx, p.yPx)));\n  ctx.strokeStyle = color;\n  ctx.lineWidth = 2.5;\n  ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawMarker(ctx, cxPx, stats, scaleY, color) {\n  const yQ1 = scaleY.getPixelForValue(stats.q1);\n  const yQ3 = scaleY.getPixelForValue(stats.q3);\n  const yMed = scaleY.getPixelForValue(stats.median);\n\n  ctx.save();\n  ctx.strokeStyle = t.ink;\n  ctx.lineWidth = 3;\n  ctx.beginPath();\n  ctx.moveTo(cxPx, yQ3);\n  ctx.lineTo(cxPx, yQ1);\n  ctx.stroke();\n\n  ctx.beginPath();\n  ctx.arc(cxPx, yMed, 6, 0, 2 * Math.PI);\n  ctx.fillStyle = t.pageBg;\n  ctx.fill();\n  ctx.strokeStyle = color;\n  ctx.lineWidth = 2.5;\n  ctx.stroke();\n  ctx.restore();\n}\n\nconst splitViolinPlugin = {\n  id: \"splitViolin\",\n  afterDraw(chart) {\n    const { ctx, scales, chartArea } = chart;\n\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n    ctx.clip();\n\n    violins.forEach((violin) => {\n      const centerXpx = scales.x.getPixelForValue(violin.catX);\n      const leftPoints = violin.evalPoints.map((y, j) => ({\n        xPx: scales.x.getPixelForValue(violin.catX - violin.densityControl[j] * violin.scaleControl),\n        yPx: scales.y.getPixelForValue(y),\n      }));\n      const rightPoints = violin.evalPoints.map((y, j) => ({\n        xPx: scales.x.getPixelForValue(violin.catX + violin.densityTreatment[j] * violin.scaleTreatment),\n        yPx: scales.y.getPixelForValue(y),\n      }));\n      drawHalf(ctx, centerXpx, leftPoints, controlColor);\n      drawHalf(ctx, centerXpx, rightPoints, treatmentColor);\n    });\n\n    violins.forEach((violin) => {\n      const leftXpx = scales.x.getPixelForValue(violin.catX - innerOffset);\n      const rightXpx = scales.x.getPixelForValue(violin.catX + innerOffset);\n      drawMarker(ctx, leftXpx, violin.stats.control, scales.y, controlColor);\n      drawMarker(ctx, rightXpx, violin.stats.treatment, scales.y, treatmentColor);\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: { datasets: [] },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"violin-split · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22 },\n      },\n      subtitle: {\n        display: true,\n        text: \"Left = control · right = new method · width = density (KDE) · tick = IQR & median\",\n        color: t.inkSoft,\n        font: { size: 14, style: \"italic\" },\n        padding: { bottom: 12 },\n      },\n      legend: {\n        labels: {\n          color: t.ink,\n          font: { size: 16 },\n          generateLabels: () => [\n            { text: \"Control\", fillStyle: controlColor, strokeStyle: controlColor, pointStyle: \"rect\" },\n            { text: \"New method\", fillStyle: treatmentColor, strokeStyle: treatmentColor, pointStyle: \"rect\" },\n          ],\n        },\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0.5,\n        max: subjects.length + 0.5,\n        afterBuildTicks: (axis) => {\n          axis.ticks = subjects.map((_, i) => ({ value: i + 1 }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => subjects[Math.round(value) - 1]?.name ?? \"\",\n        },\n        grid: { display: false },\n        title: { display: true, text: \"Subject\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        min: yAxisMin,\n        max: yAxisMax,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Exam score (points, 0-100)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n  plugins: [splitViolinPlugin],\n});\n"}