{"spec_id":"violin-grouped-swarm","library":"d3","language":"javascript","code":"// anyplot.ai\n// violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 50, bottom: 90, left: 100 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic LCG) ------------------------------------\nfunction makeLcg(seed) {\n  let state = seed % 2147483647;\n  if (state <= 0) state += 2147483646;\n  return () => {\n    state = (state * 16807) % 2147483647;\n    return (state - 1) / 2147483646;\n  };\n}\nconst rand = makeLcg(42);\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\nconst categories = [\"Simple\", \"Moderate\", \"Complex\"];\nconst groups = [\"Novice\", \"Expert\"];\nconst meanByCategory = { Simple: 320, Moderate: 480, Complex: 650 };\nconst groupOffsetMs = { Novice: 95, Expert: 0 };\nconst groupSpreadMs = { Novice: 75, Expert: 45 };\nconst spreadFactor = { Simple: 1, Moderate: 1.1, Complex: 1.3 };\nconst samplesPerCombo = 35;\n\nconst data = [];\nfor (const category of categories) {\n  for (const group of groups) {\n    const mean = meanByCategory[category] + groupOffsetMs[group];\n    const spread = groupSpreadMs[group] * spreadFactor[category];\n    for (let i = 0; i < samplesPerCombo; i++) {\n      const value = Math.max(80, mean + randNormal() * spread);\n      data.push({ category, group, value });\n    }\n  }\n}\n\n// --- Scales -------------------------------------------------------------\nconst x0 = d3.scaleBand().domain(categories).range([0, iw]).paddingInner(0.35).paddingOuter(0.15);\nconst x1 = d3.scaleBand().domain(groups).range([0, x0.bandwidth()]).padding(0.18);\nconst y = d3.scaleLinear().domain([0, d3.max(data, (d) => d.value)]).nice().range([ih, 0]);\nconst color = d3.scaleOrdinal().domain(groups).range(t.palette);\n\n// --- Kernel density estimation (per category-group violin) ------------------\nfunction kernelEpanechnikov(bandwidth) {\n  return (v) => {\n    v /= bandwidth;\n    return Math.abs(v) <= 1 ? (0.75 * (1 - v * v)) / bandwidth : 0;\n  };\n}\nfunction kernelDensityEstimator(kernel, gridPoints) {\n  return (sample) => gridPoints.map((gp) => [gp, d3.mean(sample, (v) => kernel(gp - v))]);\n}\n\nconst yMax = y.domain()[1];\n\nconst subBandwidth = x1.bandwidth();\nconst halfWidth = subBandwidth * 0.46;\n\n// Fixed bandwidth (shared across violins so widths stay comparable); each\n// violin's density grid is still clipped to its own data range (+3\n// bandwidths) rather than the shared axis domain — otherwise the\n// Epanechnikov kernel's near-zero tail gets stroked as a thin needle\n// spanning most of the axis.\nconst bandwidth = yMax * 0.07;\nconst violins = categories.flatMap((category) =>\n  groups.map((group) => {\n    const sample = data.filter((d) => d.category === category && d.group === group).map((d) => d.value);\n    const [sampleMin, sampleMax] = d3.extent(sample);\n    const lo = Math.max(0, sampleMin - bandwidth * 3);\n    const hi = Math.min(yMax, sampleMax + bandwidth * 3);\n    const gridPoints = d3.range(60).map((i) => lo + (i / 59) * (hi - lo));\n    const density = kernelDensityEstimator(kernelEpanechnikov(bandwidth), gridPoints)(sample);\n    const maxDensity = d3.max(density, (d) => d[1]);\n    return { category, group, density, maxDensity, sample };\n  }),\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 + axis --------------------------------------------------------\nconst yAxisG = g.append(\"g\").call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickPadding(12));\nyAxisG.selectAll(\"line\").attr(\"stroke\", t.grid);\nyAxisG.select(\".domain\").remove();\nyAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n\n// --- X axis -----------------------------------------------------------------\nconst xAxisG = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x0).tickSize(0).tickPadding(14));\nxAxisG.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"15px\");\nxAxisG.select(\".domain\").attr(\"stroke\", t.inkSoft);\n\n// --- Violins + swarm points, one <g> per category-group combination --------\nconst swarmRadius = 3.8;\nfor (const v of violins) {\n  const violinG = g\n    .append(\"g\")\n    .attr(\"transform\", `translate(${x0(v.category) + x1(v.group)},0)`);\n\n  const xNum = d3.scaleLinear().domain([0, v.maxDensity]).range([0, halfWidth]);\n  const area = d3\n    .area()\n    .curve(d3.curveBasis)\n    .y((d) => y(d[0]))\n    .x0((d) => subBandwidth / 2 - xNum(d[1]))\n    .x1((d) => subBandwidth / 2 + xNum(d[1]));\n\n  violinG\n    .append(\"path\")\n    .datum(v.density)\n    .attr(\"d\", area)\n    .attr(\"fill\", color(v.group))\n    .attr(\"fill-opacity\", 0.5)\n    .attr(\"stroke\", color(v.group))\n    .attr(\"stroke-width\", 1.5);\n\n  // Beeswarm layout: sort by pixel-y, place each point at the smallest\n  // horizontal offset that clears every already-placed point within 2*radius.\n  const points = v.sample.map((value) => ({ value, py: y(value), px: 0 }));\n  points.sort((a, b) => a.py - b.py);\n  const placed = [];\n  for (const p of points) {\n    const neighbors = placed.filter((q) => Math.abs(q.py - p.py) < swarmRadius * 2);\n    if (neighbors.length > 0) {\n      const candidates = [0];\n      for (const q of neighbors) {\n        const dy = p.py - q.py;\n        const dx = Math.sqrt(Math.max(0, (swarmRadius * 2) ** 2 - dy * dy));\n        candidates.push(q.px + dx, q.px - dx);\n      }\n      candidates.sort((a, b) => Math.abs(a) - Math.abs(b));\n      p.px = candidates.find((c) =>\n        neighbors.every((q) => Math.hypot(c - q.px, p.py - q.py) >= swarmRadius * 2 - 0.01),\n      ) ?? candidates[candidates.length - 1];\n    }\n    p.px = Math.max(-halfWidth, Math.min(halfWidth, p.px));\n    placed.push(p);\n  }\n\n  violinG\n    .selectAll(\"circle\")\n    .data(placed)\n    .join(\"circle\")\n    .attr(\"cx\", (d) => subBandwidth / 2 + d.px)\n    .attr(\"cy\", (d) => d.py)\n    .attr(\"r\", swarmRadius)\n    .attr(\"fill\", color(v.group))\n    .attr(\"fill-opacity\", 0.85)\n    .attr(\"stroke\", t.pageBg)\n    .attr(\"stroke-width\", 0.6);\n}\n\n// --- Axis labels --------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -margin.left + 28)\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Response 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\", \"16px\")\n  .text(\"Task Complexity\");\n\n// --- Legend (group hue) --------------------------------------------------\nconst legendW = 260;\nconst legendH = 40;\nconst legendSwatchStart = 104;\nconst legend = svg.append(\"g\").attr(\"transform\", `translate(${width - margin.right - legendW},46)`);\n\nlegend\n  .append(\"rect\")\n  .attr(\"width\", legendW)\n  .attr(\"height\", legendH)\n  .attr(\"rx\", 8)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid)\n  .attr(\"stroke-width\", 1);\n\nlegend\n  .append(\"text\")\n  .attr(\"x\", 14)\n  .attr(\"y\", legendH / 2 + 5)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"12px\")\n  .style(\"font-weight\", \"600\")\n  .style(\"letter-spacing\", \"0.04em\")\n  .text(\"EXPERTISE\");\n\ngroups.forEach((group, i) => {\n  const row = legend.append(\"g\").attr(\"transform\", `translate(${legendSwatchStart + i * 78},${legendH / 2 - 8})`);\n  row\n    .append(\"rect\")\n    .attr(\"width\", 16)\n    .attr(\"height\", 16)\n    .attr(\"rx\", 4)\n    .attr(\"fill\", color(group))\n    .attr(\"fill-opacity\", 0.55)\n    .attr(\"stroke\", color(group))\n    .attr(\"stroke-width\", 1.5);\n  row\n    .append(\"text\")\n    .attr(\"x\", 22)\n    .attr(\"y\", 13)\n    .attr(\"fill\", t.inkSoft)\n    .style(\"font-size\", \"14px\")\n    .text(group);\n});\n\n// --- Title ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 44)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"violin-grouped-swarm · javascript · d3 · anyplot.ai\");\n"}