{"spec_id":"violin-grouped-swarm","library":"echarts","language":"javascript","code":"// anyplot.ai\n// violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Deterministic PRNG (LCG + Box-Muller) so data is reproducible ---------\nlet seed = 42;\nfunction nextUniform() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction randNormal(mean, std) {\n  const u1 = Math.max(nextUniform(), 1e-9);\n  const u2 = nextUniform();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * std;\n}\n\n// --- Data: response times (seconds) across task types and expertise levels\nconst categories = [\"Data Entry\", \"Debugging\", \"Code Review\"];\nconst groups = [\"Junior\", \"Senior\"];\nconst N_PER_COMBO = 40;\nconst nCat = categories.length;\nconst nGrp = groups.length;\n\n// baseline [mean, std] per task type, scaled by an expertise-level factor\nconst categoryBaseline = {\n  \"Data Entry\": [8, 2.2],\n  Debugging: [24, 7.5],\n  \"Code Review\": [15, 4.5],\n};\nconst groupFactor = { Junior: 1.4, Senior: 0.8 };\n\nconst combos = categories.map(() => groups.map(() => []));\ncategories.forEach((cat, ci) => {\n  const [baseMean, baseStd] = categoryBaseline[cat];\n  groups.forEach((grp, gi) => {\n    const factor = groupFactor[grp];\n    for (let i = 0; i < N_PER_COMBO; i++) {\n      combos[ci][gi].push(Math.max(1, randNormal(baseMean * factor, baseStd * factor)));\n    }\n  });\n});\n\n// --- Gaussian KDE (Silverman bandwidth), density self-normalized to [0, 1] --\nfunction gaussianKde(values, gridSize) {\n  const n = values.length;\n  const mean = values.reduce((a, b) => a + b, 0) / n;\n  const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1);\n  const std = Math.sqrt(variance);\n  const bandwidth = 1.06 * std * Math.pow(n, -0.2);\n  const dataMin = Math.min(...values);\n  const dataMax = Math.max(...values);\n  const pad = bandwidth * 2;\n  const gridMin = Math.max(0, dataMin - pad);\n  const gridMax = dataMax + pad;\n  const grid = [];\n  const density = [];\n  for (let i = 0; i < gridSize; i++) {\n    const y = gridMin + ((gridMax - gridMin) * i) / (gridSize - 1);\n    let sum = 0;\n    for (let j = 0; j < n; j++) {\n      const u = (y - values[j]) / bandwidth;\n      sum += Math.exp(-0.5 * u * u);\n    }\n    grid.push(y);\n    density.push(sum / (n * bandwidth * Math.sqrt(2 * Math.PI)));\n  }\n  const maxDensity = Math.max(...density);\n  return { grid, density: density.map((d) => d / maxDensity) };\n}\n\n// --- Slot geometry: categories at integer x, groups dodge within a slot ----\nconst CATEGORY_SPAN = 0.8; // total width per category reserved for its groups\nconst GROUP_WIDTH = CATEGORY_SPAN / nGrp;\nconst GROUP_GAP = 0.06; // gap between adjacent group violins in the same category\nconst VIOLIN_HALF_WIDTH = (GROUP_WIDTH - GROUP_GAP) / 2;\nconst SWARM_HALF_WIDTH = VIOLIN_HALF_WIDTH * 0.75;\n\nfunction slotX(catIdx, grpIdx) {\n  const start = catIdx - CATEGORY_SPAN / 2;\n  return start + GROUP_WIDTH * (grpIdx + 0.5);\n}\n\n// violinsByGroup[gi] holds one violin polygon per category, in category order\nconst violinsByGroup = groups.map(() => []);\n// swarmByGroup[gi] holds [x, y] pairs for every observation\nconst swarmByGroup = groups.map(() => []);\n\ncategories.forEach((cat, ci) => {\n  groups.forEach((grp, gi) => {\n    const values = combos[ci][gi];\n    const centerX = slotX(ci, gi);\n\n    // Violin: mirrored KDE profile, closed polygon\n    const { grid, density } = gaussianKde(values, 60);\n    const left = grid.map((y, i) => [centerX - density[i] * VIOLIN_HALF_WIDTH, y]);\n    const right = grid.map((y, i) => [centerX + density[i] * VIOLIN_HALF_WIDTH, y]).reverse();\n    violinsByGroup[gi].push({ points: left.concat(right) });\n\n    // Swarm: bin the values, dodge symmetrically within each bin\n    const sorted = values.slice().sort((a, b) => a - b);\n    const dataMin = sorted[0];\n    const dataMax = sorted[sorted.length - 1];\n    const nBins = 18;\n    const binWidth = (dataMax - dataMin) / nBins || 1;\n    const bins = Array.from({ length: nBins }, () => []);\n    sorted.forEach((v) => {\n      let idx = Math.floor((v - dataMin) / binWidth);\n      if (idx >= nBins) idx = nBins - 1;\n      if (idx < 0) idx = 0;\n      bins[idx].push(v);\n    });\n    const maxBinCount = Math.max(...bins.map((b) => b.length));\n    const halfMax = Math.max(1, Math.ceil(maxBinCount / 2));\n    const pointSpacing = Math.min(0.03, SWARM_HALF_WIDTH / halfMax);\n    bins.forEach((bin) => {\n      bin.forEach((v, k) => {\n        const rank = Math.ceil(k / 2);\n        const sign = k % 2 === 0 ? 1 : -1;\n        swarmByGroup[gi].push([centerX + sign * rank * pointSpacing, v]);\n      });\n    });\n  });\n});\n\n// Explicit y-axis bounds from the full violin extent — the auto-scaled \"nice\"\n// range would clip the KDE tails, which reach well past the raw data min/max.\nconst allViolinYs = violinsByGroup.flatMap((vs) => vs.flatMap((v) => v.points.map((p) => p[1])));\nconst yAxisMax = Math.ceil(Math.max(...allViolinYs) / 5) * 5;\n\n// Highlight the widest Junior/Senior split (Debugging) with a median-to-median\n// markLine, giving the viewer a focal point beyond \"read the chart yourself\".\nfunction median(values) {\n  const sorted = values.slice().sort((a, b) => a - b);\n  const mid = Math.floor(sorted.length / 2);\n  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];\n}\nconst gapCatIdx = categories.indexOf(\"Debugging\");\nconst gapJuniorMedian = median(combos[gapCatIdx][0]);\nconst gapSeniorMedian = median(combos[gapCatIdx][1]);\n\n// --- Chart --------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\nfunction renderViolin(params, api) {\n  const violin = violinsByGroup[params.seriesIndex][params.dataIndex];\n  const points = violin.points.map((p) => api.coord(p));\n  return {\n    type: \"polygon\",\n    shape: { points },\n    style: api.style({ opacity: 0.5, lineWidth: 1.5 }),\n  };\n}\n\nconst violinSeries = groups.map((grp, gi) => ({\n  name: grp,\n  type: \"custom\",\n  renderItem: renderViolin,\n  itemStyle: { color: t.palette[gi] },\n  data: violinsByGroup[gi].map(() => 0),\n  z: 2,\n  silent: true,\n}));\n\nconst swarmSeries = groups.map((grp, gi) => ({\n  name: grp,\n  type: \"scatter\",\n  data: swarmByGroup[gi],\n  symbolSize: 9,\n  itemStyle: { color: t.palette[gi], borderColor: t.pageBg, borderWidth: 1 },\n  z: 3,\n  ...(gi === groups.length - 1\n    ? {\n        markLine: {\n          symbol: \"none\",\n          silent: true,\n          z: 4,\n          lineStyle: { color: t.ink, type: \"dashed\", width: 1.5 },\n          label: {\n            formatter: `Δ ${Math.abs(gapSeniorMedian - gapJuniorMedian).toFixed(1)}s`,\n            color: t.ink,\n            fontSize: 13,\n            position: \"middle\",\n          },\n          data: [\n            [\n              { coord: [slotX(gapCatIdx, 0), gapJuniorMedian] },\n              { coord: [slotX(gapCatIdx, 1), gapSeniorMedian] },\n            ],\n          ],\n        },\n      }\n    : {}),\n}));\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  color: t.palette,\n  title: {\n    text: \"violin-grouped-swarm · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  legend: {\n    data: groups,\n    top: 56,\n    textStyle: { color: t.ink, fontSize: 16 },\n  },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (p) => {\n      if (p.seriesType !== \"scatter\") return \"\";\n      const catIdx = Math.round(p.value[0]);\n      const cat = categories[Math.max(0, Math.min(nCat - 1, catIdx))];\n      return `${cat}<br/>${p.seriesName}: ${p.value[1].toFixed(1)}s`;\n    },\n  },\n  grid: { left: 110, right: 60, top: 130, bottom: 100 },\n  xAxis: {\n    type: \"value\",\n    min: -0.5,\n    max: nCat - 0.5,\n    minInterval: 1,\n    maxInterval: 1,\n    name: \"Task Type\",\n    nameLocation: \"middle\",\n    nameGap: 45,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 14,\n      formatter: (value) => {\n        const idx = Math.round(value);\n        return Math.abs(value - idx) < 1e-6 && idx >= 0 && idx < nCat ? categories[idx] : \"\";\n      },\n    },\n    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: \"value\",\n    min: 0,\n    max: yAxisMax,\n    name: \"Response Time (seconds)\",\n    nameLocation: \"middle\",\n    nameGap: 65,\n    nameTextStyle: { color: t.ink, fontSize: 16 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n  series: [...violinSeries, ...swarmSeries],\n});\n"}