{"spec_id":"violin-swarm","library":"d3","language":"javascript","code":"// anyplot.ai\n// violin-swarm: Violin Plot with Overlaid Swarm Points\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 70, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data: reaction times (ms) across 4 stimulus conditions, individual ----\n// trials overlaid on the smoothed distribution. Deterministic LCG + Box-Muller\n// stand in for a seeded RNG (the browser has none).\nlet seed = 42;\nfunction rand() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randNormal(mean, std) {\n  const u1 = Math.max(rand(), 1e-9);\n  const u2 = rand();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\nconst conditions = [\n  { category: \"Visual\", mean: 320, std: 45, n: 70 },\n  { category: \"Auditory\", mean: 280, std: 35, n: 70 },\n  { category: \"Tactile\", mean: 360, std: 55, n: 55 },\n  { category: \"Multimodal\", mean: 250, std: 30, n: 65 },\n];\n\nconst data = conditions.map((c) => ({\n  category: c.category,\n  values: Array.from({ length: c.n }, () => Math.max(120, randNormal(c.mean, c.std))),\n}));\n\nconst allValues = data.flatMap((d) => d.values);\n\n// --- Scales ------------------------------------------------------------------\nconst x = d3\n  .scaleBand()\n  .domain(data.map((d) => d.category))\n  .range([0, iw])\n  .padding(0.38);\n\nconst y = d3\n  .scaleLinear()\n  .domain([d3.min(allValues) - 30, d3.max(allValues) + 30])\n  .nice()\n  .range([ih, 0]);\n\n// --- Kernel density estimation (Epanechnikov, Silverman bandwidth) ---------\nfunction kernelEpanechnikov(bandwidth) {\n  return (v) => (Math.abs((v /= bandwidth)) <= 1 ? (0.75 * (1 - v * v)) / bandwidth : 0);\n}\nfunction kde(kernel, sample, grid) {\n  return grid.map((x0) => [x0, d3.mean(sample, (v) => kernel(x0 - v))]);\n}\n\nconst gridPoints = 80;\nconst [yMin, yMax] = y.domain();\n\n// Each violin is sampled over its own data extent (± one bandwidth, the\n// Epanechnikov kernel's support) rather than the shared axis range — sampling\n// past that support only adds an exact-zero-density tail, which collapses the\n// area shape into a spurious spike reaching the axis limits.\nconst densities = data.map((d) => {\n  const std = d3.deviation(d.values);\n  const bandwidth = 1.06 * std * Math.pow(d.values.length, -0.2);\n  const kernel = kernelEpanechnikov(bandwidth);\n  const lo = Math.max(yMin, d3.min(d.values) - bandwidth);\n  const hi = Math.min(yMax, d3.max(d.values) + bandwidth);\n  const localGrid = d3.range(gridPoints).map((i) => lo + (i / (gridPoints - 1)) * (hi - lo));\n  return { category: d.category, points: kde(kernel, d.values, localGrid) };\n});\n\n// --- Violin half-width scale, fit per category to its own lane -------------\nconst maxHalfWidth = (x.bandwidth() / 2) * 0.92;\nconst widthScales = densities.map((d) => {\n  const maxDensity = d3.max(d.points, (p) => p[1]);\n  return d3.scaleLinear().domain([0, maxDensity]).range([0, maxHalfWidth]);\n});\n\n// Interpolated half-width at an arbitrary value, used to keep swarm points\n// inside the violin's smoothed outline rather than a fixed rectangular lane.\nfunction halfWidthAt(points, widthScale, value) {\n  const bis = d3.bisector((p) => p[0]).left;\n  const idx = Math.max(1, Math.min(points.length - 1, bis(points, value)));\n  const p0 = points[idx - 1];\n  const p1 = points[idx];\n  const frac = p1[0] === p0[0] ? 0 : (value - p0[0]) / (p1[0] - p0[0]);\n  const density = p0[1] + frac * (p1[1] - p0[1]);\n  return widthScale(density);\n}\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// --- Y grid (subtle, value axis only) ---------------------------------------\ng.append(\"g\")\n  .call(d3.axisLeft(y).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Violins -------------------------------------------------------------\ndensities.forEach((d, i) => {\n  const centerX = x(d.category) + x.bandwidth() / 2;\n  const widthScale = widthScales[i];\n  const color = t.palette[i % t.palette.length];\n  const area = d3\n    .area()\n    .curve(d3.curveBasis)\n    .y((p) => y(p[0]))\n    .x0((p) => centerX - widthScale(p[1]))\n    .x1((p) => centerX + widthScale(p[1]));\n\n  g.append(\"path\")\n    .datum(d.points)\n    .attr(\"fill\", color)\n    .attr(\"fill-opacity\", 0.38)\n    .attr(\"stroke\", color)\n    .attr(\"stroke-width\", 1.5)\n    .attr(\"d\", area);\n});\n\n// --- Swarm points: force-settled beeswarm, clamped to the violin outline ---\n// Radius kept small so points stay distinguishable even in the densest bands\n// (Tactile, Auditory); fill uses the ink tone (not the violin's own hue) so\n// individual observations read as a contrasting layer on top of the density\n// shape, per the spec's \"consider a contrasting color\" guidance.\nconst radius = 3.6;\ndata.forEach((d, i) => {\n  const centerX = x(d.category) + x.bandwidth() / 2;\n  const points = densities[i].points;\n  const widthScale = widthScales[i];\n  const nodes = d.values.map((v) => ({ value: v, x: centerX, y: y(v) }));\n\n  const sim = d3\n    .forceSimulation(nodes)\n    .force(\n      \"y\",\n      d3.forceY((n) => y(n.value)).strength(1)\n    )\n    .force(\"x\", d3.forceX(centerX).strength(0.03))\n    .force(\"collide\", d3.forceCollide(radius + 0.9))\n    .stop();\n  for (let k = 0; k < 260; k++) sim.tick();\n\n  g.selectAll(null)\n    .data(nodes)\n    .join(\"circle\")\n    .attr(\"cx\", (n) => {\n      const maxOffset = Math.max(halfWidthAt(points, widthScale, n.value) - radius * 0.6, 1);\n      return centerX + Math.max(-maxOffset, Math.min(maxOffset, n.x - centerX));\n    })\n    .attr(\"cy\", (n) => n.y)\n    .attr(\"r\", radius)\n    .attr(\"fill\", t.inkSoft)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 1);\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));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\n  ax.selectAll(\"line\").attr(\"stroke\", t.inkSoft);\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\ng.append(\"text\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -margin.left + 34)\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"Reaction Time (ms)\");\n\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + margin.bottom - 24)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"17px\")\n  .text(\"Stimulus Condition\");\n\n// --- Title -------------------------------------------------------------------\nsvg\n  .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(\"violin-swarm · javascript · d3 · anyplot.ai\");\n"}