Grouped Violin Plot with Swarm Overlay — MUI X Charts

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 MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// violin-grouped-swarm: Grouped Violin Plot with Swarm Overlay
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-09-02
import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { useXScale, useYScale, useDrawingArea } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;

// --- Deterministic PRNG (LCG) + Box-Muller for approx-normal samples --------
function makeLcg(seed) {
  let state = seed >>> 0;
  return function next() {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rand = makeLcg(42);
function gaussianSample() {
  const u1 = Math.max(rand(), 1e-9);
  const u2 = rand();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
function clamp(v, lo, hi) {
  return Math.min(hi, Math.max(lo, v));
}

// --- Data: support-ticket resolution times (s) by task type & agent level --
const CATEGORIES = ["Bug Triage", "Data Entry", "Code Review"];
const GROUPS = ["Junior", "Senior"];
const N_PER_CELL = 40;

// Junior agents run slower and more variably than Senior agents on most task
// types — except Code Review, where the gap nearly closes (both levels lean
// on the same review checklist), a shape only the swarm's raw points make
// obvious against the violins.
const PARAMS = {
  "Bug Triage": { Junior: { mean: 95, std: 24 }, Senior: { mean: 52, std: 12 } },
  "Data Entry": { Junior: { mean: 60, std: 16 }, Senior: { mean: 38, std: 9 } },
  "Code Review": { Junior: { mean: 130, std: 22 }, Senior: { mean: 118, std: 20 } },
};

const cells = CATEGORIES.flatMap((category) =>
  GROUPS.map((group) => {
    const { mean, std } = PARAMS[category][group];
    const values = Array.from({ length: N_PER_CELL }, () => clamp(mean + std * gaussianSample(), 10, 220));
    return { category, group, values };
  }),
);
function getCell(category, group) {
  return cells.find((c) => c.category === category && c.group === group);
}

const allValues = cells.flatMap((c) => c.values);
const dataMin = Math.min(...allValues);
const dataMax = Math.max(...allValues);
const yPad = (dataMax - dataMin) * 0.1;
const Y_MIN = Math.max(0, dataMin - yPad);
const Y_MAX = dataMax + yPad;

function median(values) {
  const sorted = [...values].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];
}
function stdOf(values) {
  const m = values.reduce((a, b) => a + b, 0) / values.length;
  const variance = values.reduce((a, b) => a + (b - m) ** 2, 0) / (values.length - 1);
  return Math.sqrt(variance);
}

// --- Gaussian KDE per cell, Silverman bandwidth, normalized to its own peak
// so each violin shows shape, not sample count. Each cell gets its OWN grid,
// local to its data range ± 3 bandwidths (clipped to the shared axis domain)
// rather than the full shared Y_MIN..Y_MAX span — otherwise a cell whose
// values cluster in a narrow band (e.g. Data Entry) draws a near-invisible
// KDE tail that still strokes a hairline the full height of the axis.
const GRID_N = 120;
function kde(values) {
  const n = values.length;
  const bandwidth = 0.9 * stdOf(values) * Math.pow(n, -0.2);
  const localMin = Math.max(Y_MIN, Math.min(...values) - 3 * bandwidth);
  const localMax = Math.min(Y_MAX, Math.max(...values) + 3 * bandwidth);
  const grid = Array.from({ length: GRID_N }, (_, k) => localMin + (k * (localMax - localMin)) / (GRID_N - 1));
  const raw = grid.map((gy) => values.reduce((sum, v) => sum + Math.exp(-0.5 * ((gy - v) / bandwidth) ** 2), 0));
  const peak = Math.max(...raw);
  return { grid, density: raw.map((v) => v / peak) };
}
cells.forEach((cell) => {
  const { grid, density } = kde(cell.values);
  cell.grid = grid;
  cell.density = density;
  cell.median = median(cell.values);
});

// --- Beeswarm packing in pixel space: points are sorted by value and pushed
// sideways from the violin's centerline only when they'd overlap a neighbor
// already placed, capped at maxHalfWidth so the swarm stays inside its violin.
function layoutSwarmForCell(values, yScale, markerRadius, maxHalfWidth) {
  const diameter = markerRadius * 2 + 1;
  const step = diameter * 0.92;
  const maxK = Math.max(1, Math.floor(maxHalfWidth / step));
  const sorted = values.map((v) => ({ value: v, y: yScale(v) })).sort((a, b) => a.y - b.y);
  const placed = [];
  sorted.forEach((point) => {
    const nearby = placed.filter((p) => Math.abs(point.y - p.y) < diameter);
    let offsetPx = 0;
    if (nearby.length > 0) {
      let resolved = false;
      let k = 0;
      while (!resolved && k <= maxK) {
        const candidate = k === 0 ? 0 : (k % 2 === 1 ? Math.ceil(k / 2) : -Math.ceil(k / 2)) * step;
        if (nearby.every((p) => Math.hypot(candidate - p.offsetPx, point.y - p.y) >= diameter * 0.95)) {
          offsetPx = candidate;
          resolved = true;
        }
        k += 1;
      }
      if (!resolved) offsetPx = (k % 2 === 1 ? 1 : -1) * maxHalfWidth;
    }
    placed.push({ ...point, offsetPx });
  });
  return placed;
}

// A small labeled bracket that calls out the Code Review group's narrowing
// Junior/Senior gap — the standout finding in the data — rendered in the
// padding zone above Y_MAX (below `top`, above where any KDE curve or swarm
// point can reach) so it never collides with the marks it's annotating.
function GapAnnotation({ x1, x2, y, label }) {
  return (
    <g>
      <line x1={x1} x2={x2} y1={y} y2={y} stroke={t.inkSoft} strokeWidth={1} />
      <line x1={x1} x2={x1} y1={y} y2={y + 5} stroke={t.inkSoft} strokeWidth={1} />
      <line x1={x2} x2={x2} y1={y} y2={y + 5} stroke={t.inkSoft} strokeWidth={1} />
      <text x={(x1 + x2) / 2} y={y - 6} textAnchor="middle" fontSize={11} fontStyle="italic" fill={t.inkSoft}>
        {label}
      </text>
    </g>
  );
}

// --- Grouped violins (mirrored KDE, dodged by group within each category's
// band) with swarm points overlaid, matching each violin's hue. A custom SVG
// layer positioned via the chart's own band/linear scale hooks — the
// community package (7.29.1) has no violin/swarm component, so this is the
// documented "composition" technique for chart types MUI X doesn't ship.
function GroupedViolinSwarm() {
  const xScale = useXScale();
  const yScale = useYScale();
  const { top } = useDrawingArea();
  const bandwidth = xScale.bandwidth();
  const groupWidth = bandwidth * 0.82;
  const slotWidth = groupWidth / GROUPS.length;
  const violinHalfWidth = slotWidth * 0.42;
  const swarmMaxHalfWidth = slotWidth * 0.4;
  const markerRadius = 4;

  return (
    <g>
      {CATEGORIES.map((category) => {
        const bandStart = xScale(category) ?? 0;
        const groupStart = bandStart + (bandwidth - groupWidth) / 2;

        const groupData = GROUPS.map((group, groupIndex) => ({
          group,
          groupIndex,
          cell: getCell(category, group),
          color: t.palette[groupIndex % t.palette.length],
          cx: groupStart + slotWidth * (groupIndex + 0.5),
        }));

        return (
          <g key={category}>
            {groupData.map(({ group, cell, color, cx }) => {
              const leftSide = cell.grid.map((gy, k) => `${cx - cell.density[k] * violinHalfWidth},${yScale(gy)}`);
              const rightSide = cell.grid.map((gy, k) => `${cx + cell.density[k] * violinHalfWidth},${yScale(gy)}`).reverse();
              const violinPath = `M${leftSide.join(" L")} L${rightSide.join(" L")} Z`;

              const swarmPoints = layoutSwarmForCell(cell.values, yScale, markerRadius, swarmMaxHalfWidth);

              return (
                <g key={`${category}-${group}`}>
                  <path d={violinPath} fill={color} fillOpacity={0.45} stroke={color} strokeWidth={1.75} strokeLinejoin="round" />
                  <line
                    x1={cx - violinHalfWidth * 0.8}
                    x2={cx + violinHalfWidth * 0.8}
                    y1={yScale(cell.median)}
                    y2={yScale(cell.median)}
                    stroke={t.ink}
                    strokeWidth={2.1}
                    strokeOpacity={0.85}
                  />
                  {swarmPoints.map((p, i) => (
                    <circle
                      key={i}
                      cx={cx + p.offsetPx}
                      cy={p.y}
                      r={markerRadius}
                      fill={color}
                      fillOpacity={0.85}
                      stroke={t.pageBg}
                      strokeWidth={0.75}
                    />
                  ))}
                </g>
              );
            })}
            {category === "Code Review" && (
              <GapAnnotation
                x1={groupData[0].cx}
                x2={groupData[1].cx}
                y={top + 20}
                label="Junior/Senior gap narrows"
              />
            )}
          </g>
        );
      })}
    </g>
  );
}

// Custom y-axis title, positioned well clear of the (up to 3-digit) tick
// labels — MUI X's built-in yAxis `label` sits at a fixed offset from the
// axis line that doesn't grow with tick-label width, which crowds a 3-digit
// value axis. Rendering it ourselves via useDrawingArea sidesteps that.
function YAxisLabel({ text }) {
  const { top, height } = useDrawingArea();
  const cy = top + height / 2;
  return (
    <text x={26} y={cy} textAnchor="middle" transform={`rotate(-90, 26, ${cy})`} fontSize={16} fill={t.ink}>
      {text}
    </text>
  );
}

const TITLE = "violin-grouped-swarm · javascript · muix · anyplot.ai";
const TITLE_HEIGHT = 56;
const LEGEND_HEIGHT = 34;
const GAP = 20;

export default function Chart() {
  const W = window.ANYPLOT_SIZE.width;
  const H = window.ANYPLOT_SIZE.height;
  const chartHeight = H - TITLE_HEIGHT - LEGEND_HEIGHT - GAP;

  return (
    <div style={{ width: W, height: H, display: "flex", flexDirection: "column" }}>
      <div style={{ height: TITLE_HEIGHT, display: "flex", alignItems: "center", paddingLeft: 8 }}>
        <span style={{ fontSize: 36, fontWeight: 500, color: t.ink }}>{TITLE}</span>
      </div>
      <div style={{ height: LEGEND_HEIGHT, display: "flex", alignItems: "center", gap: 24, paddingLeft: 8 }}>
        {GROUPS.map((group, i) => (
          <div key={group} style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <span style={{ width: 14, height: 14, borderRadius: 7, background: t.palette[i], display: "inline-block" }} />
            <span style={{ fontSize: 14, color: t.inkSoft }}>{group}</span>
          </div>
        ))}
      </div>
      <div style={{ height: GAP }} />
      <ChartContainer
        width={W}
        height={chartHeight}
        series={[]}
        skipAnimation
        margin={{ top: 34, right: 50, bottom: 84, left: 110 }}
        xAxis={[
          {
            id: "category",
            scaleType: "band",
            data: CATEGORIES,
            categoryGapRatio: 0.35,
            disableTicks: true,
            label: "Task Type",
            labelStyle: { fontSize: 16 },
            tickLabelStyle: { fontSize: 14 },
          },
        ]}
        yAxis={[
          {
            id: "value",
            min: Y_MIN,
            max: Y_MAX,
            disableTicks: true,
            tickLabelStyle: { fontSize: 14 },
          },
        ]}
      >
        <ChartsGrid
          horizontal
          sx={{
            "& .MuiChartsGrid-line": {
              stroke: t.grid,
              opacity: 0.2,
            },
          }}
        />
        <GroupedViolinSwarm />
        <ChartsXAxis axisId="category" />
        <ChartsYAxis axisId="value" />
        <YAxisLabel text="Resolution Time (s)" />
      </ChartContainer>
    </div>
  );
}

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/violin-grouped-swarm/muix/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": "muix",
  "page": "https://anyplot.ai/violin-grouped-swarm/javascript/muix",
  "hub": "https://anyplot.ai/violin-grouped-swarm",
  "code_json": "https://api.anyplot.ai/specs/violin-grouped-swarm/muix/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/muix/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/muix/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/muix/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/violin-grouped-swarm/javascript/muix/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations