Grouped Violin Plot with Swarm Overlay — Apache ECharts

A grouped violin plot with individual data points overlaid as swarm points, showing distributions across two categorical dimensions simultaneously. Multiple violin plots are arranged by category on the x-axis and grouped by a secondary variable using color/hue, with swarm points revealing the underlying raw data. This visualization combines distribution shape visualization with complete data transparency, ideal for comparing how distributions differ across multiple factors.

Grouped Violin Plot with Swarm Overlay rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay
// Library: echarts 6.1.0 | JavaScript 22.23.2
// Quality: 93/100 | Created: 2026-09-02

const t = window.ANYPLOT_TOKENS;

// --- Deterministic PRNG (LCG + Box-Muller) so data is reproducible ---------
let seed = 42;
function nextUniform() {
  seed = (seed * 1664525 + 1013904223) % 4294967296;
  return seed / 4294967296;
}
function randNormal(mean, std) {
  const u1 = Math.max(nextUniform(), 1e-9);
  const u2 = nextUniform();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + z * std;
}

// --- Data: response times (seconds) across task types and expertise levels
const categories = ["Data Entry", "Debugging", "Code Review"];
const groups = ["Junior", "Senior"];
const N_PER_COMBO = 40;
const nCat = categories.length;
const nGrp = groups.length;

// baseline [mean, std] per task type, scaled by an expertise-level factor
const categoryBaseline = {
  "Data Entry": [8, 2.2],
  Debugging: [24, 7.5],
  "Code Review": [15, 4.5],
};
const groupFactor = { Junior: 1.4, Senior: 0.8 };

const combos = categories.map(() => groups.map(() => []));
categories.forEach((cat, ci) => {
  const [baseMean, baseStd] = categoryBaseline[cat];
  groups.forEach((grp, gi) => {
    const factor = groupFactor[grp];
    for (let i = 0; i < N_PER_COMBO; i++) {
      combos[ci][gi].push(Math.max(1, randNormal(baseMean * factor, baseStd * factor)));
    }
  });
});

// --- Gaussian KDE (Silverman bandwidth), density self-normalized to [0, 1] --
function gaussianKde(values, gridSize) {
  const n = values.length;
  const mean = values.reduce((a, b) => a + b, 0) / n;
  const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / (n - 1);
  const std = Math.sqrt(variance);
  const bandwidth = 1.06 * std * Math.pow(n, -0.2);
  const dataMin = Math.min(...values);
  const dataMax = Math.max(...values);
  const pad = bandwidth * 2;
  const gridMin = Math.max(0, dataMin - pad);
  const gridMax = dataMax + pad;
  const grid = [];
  const density = [];
  for (let i = 0; i < gridSize; i++) {
    const y = gridMin + ((gridMax - gridMin) * i) / (gridSize - 1);
    let sum = 0;
    for (let j = 0; j < n; j++) {
      const u = (y - values[j]) / bandwidth;
      sum += Math.exp(-0.5 * u * u);
    }
    grid.push(y);
    density.push(sum / (n * bandwidth * Math.sqrt(2 * Math.PI)));
  }
  const maxDensity = Math.max(...density);
  return { grid, density: density.map((d) => d / maxDensity) };
}

// --- Slot geometry: categories at integer x, groups dodge within a slot ----
const CATEGORY_SPAN = 0.8; // total width per category reserved for its groups
const GROUP_WIDTH = CATEGORY_SPAN / nGrp;
const GROUP_GAP = 0.06; // gap between adjacent group violins in the same category
const VIOLIN_HALF_WIDTH = (GROUP_WIDTH - GROUP_GAP) / 2;
const SWARM_HALF_WIDTH = VIOLIN_HALF_WIDTH * 0.75;

function slotX(catIdx, grpIdx) {
  const start = catIdx - CATEGORY_SPAN / 2;
  return start + GROUP_WIDTH * (grpIdx + 0.5);
}

// violinsByGroup[gi] holds one violin polygon per category, in category order
const violinsByGroup = groups.map(() => []);
// swarmByGroup[gi] holds [x, y] pairs for every observation
const swarmByGroup = groups.map(() => []);

categories.forEach((cat, ci) => {
  groups.forEach((grp, gi) => {
    const values = combos[ci][gi];
    const centerX = slotX(ci, gi);

    // Violin: mirrored KDE profile, closed polygon
    const { grid, density } = gaussianKde(values, 60);
    const left = grid.map((y, i) => [centerX - density[i] * VIOLIN_HALF_WIDTH, y]);
    const right = grid.map((y, i) => [centerX + density[i] * VIOLIN_HALF_WIDTH, y]).reverse();
    violinsByGroup[gi].push({ points: left.concat(right) });

    // Swarm: bin the values, dodge symmetrically within each bin
    const sorted = values.slice().sort((a, b) => a - b);
    const dataMin = sorted[0];
    const dataMax = sorted[sorted.length - 1];
    const nBins = 18;
    const binWidth = (dataMax - dataMin) / nBins || 1;
    const bins = Array.from({ length: nBins }, () => []);
    sorted.forEach((v) => {
      let idx = Math.floor((v - dataMin) / binWidth);
      if (idx >= nBins) idx = nBins - 1;
      if (idx < 0) idx = 0;
      bins[idx].push(v);
    });
    const maxBinCount = Math.max(...bins.map((b) => b.length));
    const halfMax = Math.max(1, Math.ceil(maxBinCount / 2));
    const pointSpacing = Math.min(0.03, SWARM_HALF_WIDTH / halfMax);
    bins.forEach((bin) => {
      bin.forEach((v, k) => {
        const rank = Math.ceil(k / 2);
        const sign = k % 2 === 0 ? 1 : -1;
        swarmByGroup[gi].push([centerX + sign * rank * pointSpacing, v]);
      });
    });
  });
});

// Explicit y-axis bounds from the full violin extent — the auto-scaled "nice"
// range would clip the KDE tails, which reach well past the raw data min/max.
const allViolinYs = violinsByGroup.flatMap((vs) => vs.flatMap((v) => v.points.map((p) => p[1])));
const yAxisMax = Math.ceil(Math.max(...allViolinYs) / 5) * 5;

// Highlight the widest Junior/Senior split (Debugging) with a median-to-median
// markLine, giving the viewer a focal point beyond "read the chart yourself".
function median(values) {
  const sorted = values.slice().sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}
const gapCatIdx = categories.indexOf("Debugging");
const gapJuniorMedian = median(combos[gapCatIdx][0]);
const gapSeniorMedian = median(combos[gapCatIdx][1]);

// --- Chart --------------------------------------------------------------
const chart = echarts.init(document.getElementById("container"));

function renderViolin(params, api) {
  const violin = violinsByGroup[params.seriesIndex][params.dataIndex];
  const points = violin.points.map((p) => api.coord(p));
  return {
    type: "polygon",
    shape: { points },
    style: api.style({ opacity: 0.5, lineWidth: 1.5 }),
  };
}

const violinSeries = groups.map((grp, gi) => ({
  name: grp,
  type: "custom",
  renderItem: renderViolin,
  itemStyle: { color: t.palette[gi] },
  data: violinsByGroup[gi].map(() => 0),
  z: 2,
  silent: true,
}));

const swarmSeries = groups.map((grp, gi) => ({
  name: grp,
  type: "scatter",
  data: swarmByGroup[gi],
  symbolSize: 9,
  itemStyle: { color: t.palette[gi], borderColor: t.pageBg, borderWidth: 1 },
  z: 3,
  ...(gi === groups.length - 1
    ? {
        markLine: {
          symbol: "none",
          silent: true,
          z: 4,
          lineStyle: { color: t.ink, type: "dashed", width: 1.5 },
          label: {
            formatter: `Δ ${Math.abs(gapSeniorMedian - gapJuniorMedian).toFixed(1)}s`,
            color: t.ink,
            fontSize: 13,
            position: "middle",
          },
          data: [
            [
              { coord: [slotX(gapCatIdx, 0), gapJuniorMedian] },
              { coord: [slotX(gapCatIdx, 1), gapSeniorMedian] },
            ],
          ],
        },
      }
    : {}),
}));

chart.setOption({
  animation: false,
  backgroundColor: "transparent",
  color: t.palette,
  title: {
    text: "violin-grouped-swarm · javascript · echarts · anyplot.ai",
    left: "center",
    textStyle: { color: t.ink, fontSize: 22 },
  },
  legend: {
    data: groups,
    top: 56,
    textStyle: { color: t.ink, fontSize: 16 },
  },
  tooltip: {
    trigger: "item",
    formatter: (p) => {
      if (p.seriesType !== "scatter") return "";
      const catIdx = Math.round(p.value[0]);
      const cat = categories[Math.max(0, Math.min(nCat - 1, catIdx))];
      return `${cat}<br/>${p.seriesName}: ${p.value[1].toFixed(1)}s`;
    },
  },
  grid: { left: 110, right: 60, top: 130, bottom: 100 },
  xAxis: {
    type: "value",
    min: -0.5,
    max: nCat - 0.5,
    minInterval: 1,
    maxInterval: 1,
    name: "Task Type",
    nameLocation: "middle",
    nameGap: 45,
    nameTextStyle: { color: t.ink, fontSize: 16 },
    axisLabel: {
      color: t.inkSoft,
      fontSize: 14,
      formatter: (value) => {
        const idx = Math.round(value);
        return Math.abs(value - idx) < 1e-6 && idx >= 0 && idx < nCat ? categories[idx] : "";
      },
    },
    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },
    axisTick: { show: false },
    splitLine: { show: false },
  },
  yAxis: {
    type: "value",
    min: 0,
    max: yAxisMax,
    name: "Response Time (seconds)",
    nameLocation: "middle",
    nameGap: 65,
    nameTextStyle: { color: t.ink, fontSize: 16 },
    axisLabel: { color: t.inkSoft, fontSize: 14 },
    axisLine: { onZero: false, lineStyle: { color: t.inkSoft } },
    splitLine: { lineStyle: { color: t.grid } },
  },
  series: [...violinSeries, ...swarmSeries],
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/violin-grouped-swarm/echarts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "violin-grouped-swarm",
  "language": "javascript",
  "library": "echarts",
  "page": "https://anyplot.ai/violin-grouped-swarm/javascript/echarts",
  "hub": "https://anyplot.ai/violin-grouped-swarm",
  "code_json": "https://api.anyplot.ai/specs/violin-grouped-swarm/echarts/code",
  "spec_json": "https://api.anyplot.ai/specs/violin-grouped-swarm",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/echarts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/echarts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/echarts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/echarts/plot-dark.html",
  "quality_score": 93.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Grouped Violin Plot with Swarm Overlay on anyplot.ai.

Other implementations