{"spec_id":"raincloud-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// raincloud-basic: Basic Raincloud Plot\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-26\n\n// --- Reproducible RNG (LCG + Box-Muller) ------------------------------------\nlet seed = 20260826;\nconst rng = () => {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n};\nconst randomNormal = (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\n// --- Data: reaction times (ms) by treatment dose ----------------------------\n// Placebo is a mix of two sub-populations (responders / non-responders) to\n// show why the cloud reveals multimodality that a box plot alone would hide.\nconst groups = [\n  {\n    category: \"Placebo\",\n    values: [\n      ...Array.from({ length: 60 }, () => randomNormal(470, 35)),\n      ...Array.from({ length: 60 }, () => randomNormal(555, 30)),\n    ],\n  },\n  { category: \"Low Dose\", values: Array.from({ length: 110 }, () => randomNormal(450, 58)) },\n  { category: \"Medium Dose\", values: Array.from({ length: 110 }, () => randomNormal(410, 52)) },\n  { category: \"High Dose\", values: Array.from({ length: 100 }, () => randomNormal(378, 46)) },\n];\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 70, bottom: 100, left: 190 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\nconst allValues = groups.flatMap((grp) => grp.values);\nconst x = d3.scaleLinear()\n  .domain([d3.min(allValues) - 40, d3.max(allValues) + 40])\n  .range([0, iw]);\n\nconst y = d3.scaleBand()\n  .domain(groups.map((grp) => grp.category))\n  .range([0, ih])\n  .paddingInner(0.38)\n  .paddingOuter(0.2);\n\nconst color = d3.scaleOrdinal()\n  .domain(groups.map((grp) => grp.category))\n  .range(t.palette);\n\n// --- Kernel density estimation for the \"cloud\" half-violin ------------------\nconst kernelEpanechnikov = (bandwidth) => (v) =>\n  Math.abs((v /= bandwidth)) <= 1 ? (0.75 * (1 - v * v)) / bandwidth : 0;\n\nconst kernelDensityEstimator = (kernel, thresholds) => (sample) =>\n  thresholds.map((v) => [v, d3.mean(sample, (s) => kernel(v - s))]);\n\nconst thresholds = x.ticks(80);\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Vertical gridlines (value axis) -------------------------------------------\ng.append(\"g\")\n  .selectAll(\"line\")\n  .data(x.ticks(8))\n  .join(\"line\")\n  .attr(\"x1\", (d) => x(d))\n  .attr(\"x2\", (d) => x(d))\n  .attr(\"y1\", 0)\n  .attr(\"y2\", ih)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\n// --- Draw each category: cloud (above), box (on), rain (below) ----------------\nconst bandwidth = y.bandwidth();\nconst halfBand = bandwidth / 2;\n\nfor (const grp of groups) {\n  const cat = grp.category;\n  const values = grp.values.slice().sort(d3.ascending);\n  const fill = color(cat);\n  const bandTop = y(cat);\n  const baseline = bandTop + halfBand;\n\n  // Cloud: KDE curve drawn only above the baseline (half-violin)\n  const kdeBandwidth = 1.06 * d3.deviation(values) * Math.pow(values.length, -0.2);\n  const kde = kernelDensityEstimator(kernelEpanechnikov(kdeBandwidth), thresholds);\n  const density = kde(values).filter(\n    (d) => d[0] >= values[0] && d[0] <= values[values.length - 1],\n  );\n  const maxDensity = d3.max(density, (d) => d[1]) || 1;\n  const cloudRise = halfBand * 0.78;\n  const densityScale = d3.scaleLinear().domain([0, maxDensity]).range([0, cloudRise]);\n\n  const area = d3.area()\n    .x((d) => x(d[0]))\n    .y0(baseline)\n    .y1((d) => baseline - densityScale(d[1]))\n    .curve(d3.curveBasis);\n\n  g.append(\"path\")\n    .datum(density)\n    .attr(\"d\", area)\n    .attr(\"fill\", fill)\n    .attr(\"fill-opacity\", 0.55)\n    .attr(\"stroke\", \"none\");\n\n  // Rain: jittered strip points, falling below the baseline\n  const boxHalf = Math.min(16, halfBand * 0.22);\n  const rainTop = baseline + boxHalf + 10;\n  const rainBottom = bandTop + bandwidth - 8;\n  const rainSpan = Math.max(12, rainBottom - rainTop);\n\n  g.append(\"g\")\n    .selectAll(\"circle\")\n    .data(grp.values)\n    .join(\"circle\")\n    .attr(\"cx\", (d) => x(d))\n    .attr(\"cy\", () => rainTop + rng() * rainSpan)\n    .attr(\"r\", 3)\n    .attr(\"fill\", fill)\n    .attr(\"fill-opacity\", 0.45);\n\n  // Box plot: quartiles, whiskers, and median, centered on the baseline\n  const q1 = d3.quantileSorted(values, 0.25);\n  const median = d3.quantileSorted(values, 0.5);\n  const q3 = d3.quantileSorted(values, 0.75);\n  const iqr = q3 - q1;\n  const loWhisker = Math.max(values[0], q1 - 1.5 * iqr);\n  const hiWhisker = Math.min(values[values.length - 1], q3 + 1.5 * iqr);\n\n  g.append(\"line\")\n    .attr(\"x1\", x(loWhisker))\n    .attr(\"x2\", x(q1))\n    .attr(\"y1\", baseline)\n    .attr(\"y2\", baseline)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.5);\n  g.append(\"line\")\n    .attr(\"x1\", x(q3))\n    .attr(\"x2\", x(hiWhisker))\n    .attr(\"y1\", baseline)\n    .attr(\"y2\", baseline)\n    .attr(\"stroke\", t.inkSoft)\n    .attr(\"stroke-width\", 1.5);\n\n  g.append(\"rect\")\n    .attr(\"x\", x(q1))\n    .attr(\"y\", baseline - boxHalf)\n    .attr(\"width\", x(q3) - x(q1))\n    .attr(\"height\", boxHalf * 2)\n    .attr(\"fill\", t.pageBg)\n    .attr(\"stroke\", fill)\n    .attr(\"stroke-width\", 2.5);\n\n  g.append(\"line\")\n    .attr(\"x1\", x(median))\n    .attr(\"x2\", x(median))\n    .attr(\"y1\", baseline - boxHalf)\n    .attr(\"y2\", baseline + boxHalf)\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 2.5);\n}\n\n// --- Axes -----------------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).tickSize(0));\n\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"16px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.grid);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\nyAxis.select(\".domain\").remove();\nyAxis.selectAll(\"text\").style(\"font-size\", \"17px\");\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 64)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"18px\")\n  .text(\"Reaction Time (ms)\");\n\n// --- Title ------------------------------------------------------------------\nsvg.append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 52)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Reaction Time · raincloud-basic · javascript · d3 · anyplot.ai\");\n"}