{"spec_id":"violin-grouped-swarm","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 89/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG + Box-Muller) ----------------------------------\nfunction makeLcg(seed) {\n  let state = seed >>> 0;\n  return () => {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rand = makeLcg(42);\nfunction gaussian() {\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\n// --- Data: task completion time (minutes) by task type and developer role --\nconst categories = [\"Debugging\", \"Feature Dev\", \"Code Review\"];\nconst groups = [\"Junior\", \"Senior\"];\nconst meanStdByCell = {\n  Debugging: { Junior: [42, 12], Senior: [25, 8] },\n  \"Feature Dev\": { Junior: [65, 18], Senior: [40, 12] },\n  \"Code Review\": { Junior: [20, 6], Senior: [12, 4] },\n};\nconst nPerCell = 35;\n\n// --- Layout: category centers on x, groups dodged around each center ------\nconst categoryUnit = 5;\nconst groupSpacing = 1.7;\nconst violinHalfWidth = 0.75;\nconst categoryCenters = categories.map((_, i) => i * categoryUnit);\nconst groupOffsets = groups.map((_, gi) => (gi - (groups.length - 1) / 2) * groupSpacing);\nconst xMin = categoryCenters[0] - categoryUnit / 2;\nconst xMax = categoryCenters[categoryCenters.length - 1] + categoryUnit / 2;\n\n// Approximate beeswarm: bin each cell's values, spread points within a bin\n// symmetrically around the violin center so overlapping points fan out.\nfunction computeSwarmOffsets(values, halfWidth) {\n  const n = values.length;\n  const order = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);\n  const cellMin = values[order[0]];\n  const cellMax = values[order[n - 1]];\n  const range = Math.max(cellMax - cellMin, 1e-6);\n  const nBins = Math.max(6, Math.min(16, Math.round(n / 3)));\n  const binWidth = range / nBins;\n  const bins = Array.from({ length: nBins }, () => []);\n  order.forEach((origIdx) => {\n    let b = Math.floor((values[origIdx] - cellMin) / binWidth);\n    if (b >= nBins) b = nBins - 1;\n    if (b < 0) b = 0;\n    bins[b].push(origIdx);\n  });\n  const offsets = new Array(n).fill(0);\n  bins.forEach((binIndices) => {\n    const count = binIndices.length;\n    if (count <= 1) return;\n    const step = Math.min(halfWidth * 0.32, (halfWidth * 1.6) / count);\n    binIndices.forEach((origIdx, k) => {\n      const centered = k - (count - 1) / 2;\n      const clamped = Math.max(-halfWidth * 0.92, Math.min(halfWidth * 0.92, centered * step));\n      offsets[origIdx] = clamped;\n    });\n  });\n  return offsets;\n}\n\n// --- Build one violin (KDE curve) + swarm points per category-group cell --\nconst cells = [];\nconst swarmPointsByGroup = groups.map(() => []);\n\ncategories.forEach((cat, ci) => {\n  groups.forEach((grp, gi) => {\n    const [mean, std] = meanStdByCell[cat][grp];\n    const values = [];\n    for (let k = 0; k < nPerCell; k += 1) {\n      values.push(Math.max(1, mean + std * gaussian()));\n    }\n\n    const centerX = categoryCenters[ci] + groupOffsets[gi];\n    const offsets = computeSwarmOffsets(values, violinHalfWidth);\n    values.forEach((v, i) => swarmPointsByGroup[gi].push({ x: centerX + offsets[i], y: v }));\n\n    const n = values.length;\n    const cellMean = values.reduce((a, b) => a + b, 0) / n;\n    const cellStd = Math.sqrt(values.reduce((a, b) => a + (b - cellMean) ** 2, 0) / n);\n    const bandwidth = Math.max(1.06 * cellStd * n ** -0.2, 0.6);\n    const cellMin = Math.min(...values);\n    const cellMax = Math.max(...values);\n    const lo = cellMin - bandwidth * 2;\n    const hi = cellMax + bandwidth * 2;\n    const samples = 50;\n    const curve = [];\n    for (let s = 0; s <= samples; s += 1) {\n      const yv = lo + ((hi - lo) * s) / samples;\n      let density = 0;\n      for (const v of values) {\n        const u = (yv - v) / bandwidth;\n        density += Math.exp(-0.5 * u * u);\n      }\n      density /= n * bandwidth * Math.sqrt(2 * Math.PI);\n      curve.push({ y: yv, density });\n    }\n    const maxDensity = Math.max(...curve.map((c) => c.density));\n    curve.forEach((c) => {\n      c.width = (c.density / maxDensity) * violinHalfWidth;\n    });\n    curve[0].width = 0;\n    curve[curve.length - 1].width = 0;\n\n    cells.push({ color: t.palette[gi], centerX, curve, cellLo: lo, cellHi: hi });\n  });\n});\n\nconst yMin = Math.max(0, Math.min(...cells.map((c) => c.cellLo)) - 2);\nconst yMax = Math.ceil((Math.max(...cells.map((c) => c.cellHi)) * 1.05) / 10) * 10;\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\n// --- Violin layer: drawn behind the swarm points via a native plugin hook --\nconst violinPlugin = {\n  id: \"violinLayer\",\n  beforeDatasetsDraw(chart) {\n    const { ctx, scales } = chart;\n    ctx.save();\n    cells.forEach((cell) => {\n      ctx.beginPath();\n      cell.curve.forEach((pt, i) => {\n        const px = scales.x.getPixelForValue(cell.centerX - pt.width);\n        const py = scales.y.getPixelForValue(pt.y);\n        if (i === 0) ctx.moveTo(px, py);\n        else ctx.lineTo(px, py);\n      });\n      for (let i = cell.curve.length - 1; i >= 0; i -= 1) {\n        const pt = cell.curve[i];\n        const px = scales.x.getPixelForValue(cell.centerX + pt.width);\n        const py = scales.y.getPixelForValue(pt.y);\n        ctx.lineTo(px, py);\n      }\n      ctx.closePath();\n      ctx.fillStyle = hexToRgba(cell.color, 0.32);\n      ctx.fill();\n      ctx.lineWidth = 2.5;\n      ctx.strokeStyle = hexToRgba(cell.color, 0.95);\n      ctx.stroke();\n    });\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Title (scale fontsize down if the descriptive prefix pushes length up) -\n// Gentle sqrt falloff (not linear) so a long mandated title still reads as\n// prominent, floored well above illegibility.\nconst title = \"Task Completion Time by Role · violin-grouped-swarm · javascript · chartjs · anyplot.ai\";\nconst baseTitleSize = 22;\nconst titleFontSize =\n  title.length > 67 ? Math.max(20, Math.round(baseTitleSize * Math.sqrt(67 / title.length))) : baseTitleSize;\n\n// --- Insight callout: subtitle surfaces the clear cross-category pattern ---\nconst insight = \"Senior engineers finish every task type faster and more consistently than juniors\";\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: groups.map((g, gi) => ({\n      label: g,\n      data: swarmPointsByGroup[gi],\n      backgroundColor: t.palette[gi],\n      borderColor: t.pageBg,\n      borderWidth: 1,\n      radius: 3.6,\n      hoverRadius: 3.6,\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    plugins: {\n      title: { display: true, text: title, color: t.ink, font: { size: titleFontSize, weight: \"500\" } },\n      subtitle: {\n        display: true,\n        text: insight,\n        color: t.inkSoft,\n        font: { size: 14, style: \"italic\" },\n        padding: { bottom: 12 },\n      },\n      legend: {\n        position: \"top\",\n        align: \"end\",\n        labels: { color: t.ink, font: { size: 16 }, usePointStyle: true, pointStyle: \"circle\" },\n      },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: xMin,\n        max: xMax,\n        afterBuildTicks: (scale) => {\n          scale.ticks = categoryCenters.map((v) => ({ value: v }));\n        },\n        ticks: {\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => {\n            const idx = categoryCenters.findIndex((c) => Math.abs(c - value) < 0.01);\n            return idx >= 0 ? categories[idx] : \"\";\n          },\n        },\n        grid: { display: false },\n        title: { display: true, text: \"Task Type\", color: t.ink, font: { size: 16 } },\n      },\n      y: {\n        type: \"linear\",\n        min: yMin,\n        max: yMax,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Completion Time (minutes)\", color: t.ink, font: { size: 16 } },\n      },\n    },\n  },\n  plugins: [violinPlugin],\n});\n"}