{"spec_id":"raincloud-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// raincloud-basic: Basic Raincloud Plot\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG) + Box-Muller normal sampling ------------------\nlet seed = 42;\nfunction lcg() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randNormal(mean, sd) {\n  const u1 = Math.max(lcg(), 1e-9);\n  const u2 = lcg();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * sd;\n}\nfunction quantile(sorted, q) {\n  const pos = (sorted.length - 1) * q;\n  const base = Math.floor(pos);\n  const rest = pos - base;\n  return sorted[base + 1] !== undefined\n    ? sorted[base] + rest * (sorted[base + 1] - sorted[base])\n    : sorted[base];\n}\nfunction stdDev(values) {\n  const mean = values.reduce((a, b) => a + b, 0) / values.length;\n  const variance =\n    values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;\n  return Math.sqrt(variance);\n}\nfunction kde(values, evalPoints, bandwidth) {\n  const norm = 1 / (values.length * bandwidth * Math.sqrt(2 * Math.PI));\n  return evalPoints.map((x) => {\n    let sum = 0;\n    for (const v of values) {\n      const z = (x - v) / bandwidth;\n      sum += Math.exp(-0.5 * z * z);\n    }\n    return sum * norm;\n  });\n}\n\n// --- Data: reaction time (ms) in a driving-simulator task by distraction ---\nconst conditionSpecs = [\n  { label: \"No Distraction\", mean: 380, sd: 40, n: 130 },\n  { label: \"Phone Call\", mean: 460, sd: 55, n: 120 },\n  { label: \"Texting\", mean: 590, sd: 90, n: 110 },\n  { label: \"Passenger Talk\", mean: 415, sd: 45, n: 125 },\n];\n\nconst categories = conditionSpecs.map((spec) => {\n  const values = Array.from({ length: spec.n }, () =>\n    Math.max(180, randNormal(spec.mean, spec.sd))\n  );\n  const sorted = [...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 lowerFence = q1 - 1.5 * iqr;\n  const upperFence = q3 + 1.5 * iqr;\n  const inRange = sorted.filter((v) => v >= lowerFence && v <= upperFence);\n  const bandwidth = 1.06 * stdDev(values) * Math.pow(values.length, -0.2);\n  const evalMin = sorted[0] - 2 * bandwidth;\n  const evalMax = sorted[sorted.length - 1] + 2 * bandwidth;\n  const evalPoints = Array.from(\n    { length: 60 },\n    (_, i) => evalMin + (i / 59) * (evalMax - evalMin)\n  );\n  const densities = kde(values, evalPoints, bandwidth);\n  const maxDensity = Math.max(...densities);\n\n  return {\n    label: spec.label,\n    values,\n    q1,\n    median,\n    q3,\n    whiskerMin: inRange[0],\n    whiskerMax: inRange[inRange.length - 1],\n    evalPoints,\n    densities,\n    maxDensity,\n  };\n});\n\nconst allValues = categories.flatMap((c) => c.values);\nconst dataMin = Math.min(...allValues);\nconst dataMax = Math.max(...allValues);\nconst pad = (dataMax - dataMin) * 0.1;\nconst xMin = Math.floor((dataMin - pad) / 20) * 20;\nconst xMax = Math.ceil((dataMax + pad) / 20) * 20;\n\n// Standout category (most notable right-shift) — the highest median gets a\n// bold, ink-colored y-axis label so the story stands out without touching\n// the Imprint data colors.\nconst standoutIndex = categories.reduce(\n  (best, cat, idx) => (cat.median > categories[best].median ? idx : best),\n  0\n);\n\n// --- Custom plugin: half-violin cloud, jittered rain, whiskers, median -----\nconst raincloudExtras = {\n  id: \"raincloudExtras\",\n  afterDatasetsDraw(chart) {\n    const { ctx } = chart;\n    const meta = chart.getDatasetMeta(0);\n    const xScale = chart.scales.x;\n    ctx.save();\n\n    const rowHeight =\n      categories.length > 1\n        ? Math.abs(meta.data[1].y - meta.data[0].y)\n        : chart.chartArea.height * 0.7;\n    const cloudMaxHeight = rowHeight * 0.36;\n    const rainBandHeight = rowHeight * 0.24;\n\n    categories.forEach((cat, i) => {\n      const bar = meta.data[i];\n      if (!bar) return;\n      const centerY = bar.y;\n      const color = t.palette[i % t.palette.length];\n\n      // Cloud — half-violin (KDE curve) rising ABOVE the baseline\n      ctx.beginPath();\n      cat.evalPoints.forEach((value, j) => {\n        const x = xScale.getPixelForValue(value);\n        const y =\n          centerY - (cat.densities[j] / cat.maxDensity) * cloudMaxHeight;\n        if (j === 0) ctx.moveTo(x, centerY);\n        ctx.lineTo(x, y);\n      });\n      ctx.lineTo(\n        xScale.getPixelForValue(cat.evalPoints[cat.evalPoints.length - 1]),\n        centerY\n      );\n      ctx.closePath();\n      ctx.fillStyle = color;\n      ctx.globalAlpha = 0.35;\n      ctx.fill();\n      ctx.globalAlpha = 1;\n      ctx.lineWidth = 1.5;\n      ctx.strokeStyle = color;\n      ctx.stroke();\n\n      // Rain — jittered points falling BELOW the baseline\n      const rainStart = bar.height / 2 + 8;\n      ctx.fillStyle = color;\n      ctx.globalAlpha = 0.6;\n      cat.values.forEach((value) => {\n        const x = xScale.getPixelForValue(value);\n        const y = centerY + rainStart + lcg() * rainBandHeight;\n        ctx.beginPath();\n        ctx.arc(x, y, 4, 0, Math.PI * 2);\n        ctx.fill();\n      });\n      ctx.globalAlpha = 1;\n\n      // Box — whiskers, caps, median line (drawn on the baseline)\n      const capHalf = bar.height * 0.3;\n      const xWhiskerMin = xScale.getPixelForValue(cat.whiskerMin);\n      const xWhiskerMax = xScale.getPixelForValue(cat.whiskerMax);\n      const xQ1 = xScale.getPixelForValue(cat.q1);\n      const xQ3 = xScale.getPixelForValue(cat.q3);\n      const xMedian = xScale.getPixelForValue(cat.median);\n\n      ctx.strokeStyle = t.ink;\n      ctx.lineWidth = 2;\n      ctx.beginPath();\n      ctx.moveTo(xWhiskerMin, centerY);\n      ctx.lineTo(xQ1, centerY);\n      ctx.moveTo(xQ3, centerY);\n      ctx.lineTo(xWhiskerMax, centerY);\n      ctx.moveTo(xWhiskerMin, centerY - capHalf);\n      ctx.lineTo(xWhiskerMin, centerY + capHalf);\n      ctx.moveTo(xWhiskerMax, centerY - capHalf);\n      ctx.lineTo(xWhiskerMax, centerY + capHalf);\n      ctx.stroke();\n\n      ctx.strokeStyle = t.pageBg;\n      ctx.lineWidth = 3;\n      ctx.beginPath();\n      ctx.moveTo(xMedian, centerY - bar.height / 2);\n      ctx.lineTo(xMedian, centerY + bar.height / 2);\n      ctx.stroke();\n    });\n\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart: horizontal floating bars for Q1-Q3, custom plugin for the rest -\nnew Chart(canvas, {\n  type: \"bar\",\n  data: {\n    labels: categories.map((c) => c.label),\n    datasets: [\n      {\n        label: \"Interquartile range\",\n        data: categories.map((c) => [c.q1, c.q3]),\n        backgroundColor: categories.map((_, i) => t.palette[i % t.palette.length]),\n        borderColor: t.ink,\n        borderWidth: 2,\n        borderRadius: 3,\n        barPercentage: 0.32,\n        categoryPercentage: 0.85,\n      },\n    ],\n  },\n  plugins: [raincloudExtras],\n  options: {\n    indexAxis: \"y\",\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: {\n        display: true,\n        text: \"raincloud-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 26 },\n      },\n      legend: { display: false },\n    },\n    scales: {\n      x: {\n        min: xMin,\n        max: xMax,\n        title: {\n          display: true,\n          text: \"Reaction Time (ms)\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n      },\n      y: {\n        title: {\n          display: true,\n          text: \"Distraction Condition\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n        ticks: {\n          color: (ctx) => (ctx.index === standoutIndex ? t.ink : t.inkSoft),\n          font: (ctx) => ({\n            size: 14,\n            weight: ctx.index === standoutIndex ? \"bold\" : \"normal\",\n          }),\n        },\n        grid: { display: false },\n      },\n    },\n  },\n});\n"}