Basic Swarm Plot — Apache ECharts

A swarm plot (beeswarm plot) displays individual data points for categorical comparisons, with points spread horizontally to avoid overlap. This reveals the full distribution shape and density while preserving exact values - combining the benefits of strip plots (individual points) and violin plots (density visualization). Ideal when you need to see every observation rather than just summary statistics.

Basic Swarm Plot rendered with Apache ECharts

JavaScript source (Apache ECharts)

// anyplot.ai
// swarm-basic: Basic Swarm Plot
// Library: echarts 6.1.0 | JavaScript 22.23.1
// Quality: 89/100 | Created: 2026-07-26

const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// CRP (C-reactive protein) biomarker levels across a dose-escalation trial.
function lcg(seed) {
  let state = seed >>> 0;
  return function () {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rng = lcg(42);
function randNormal() {
  const u1 = Math.max(rng(), 1e-9);
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

const groups = [
  { name: "Placebo", n: 45, mean: 3.2, sd: 0.9 },
  { name: "Low Dose", n: 45, mean: 2.6, sd: 0.8 },
  { name: "Medium Dose", n: 45, mean: 1.9, sd: 0.7 },
  { name: "High Dose", n: 45, mean: 1.3, sd: 0.6 },
];
const categories = groups.map((g) => g.name);
const groupValues = groups.map((g) =>
  Array.from({ length: g.n }, () => Math.max(0.1, g.mean + randNormal() * g.sd)),
);

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

// Fix both axis extents up front so the coordinate system is fully
// deterministic before any point is placed.
const allValues = groupValues.flat();
const yPad = (Math.max(...allValues) - Math.min(...allValues)) * 0.08;
const yMin = Math.max(0, Math.floor(Math.min(...allValues) - yPad));
const yMax = Math.ceil(Math.max(...allValues) + yPad);
const xPad = 0.36; // 12% of the 3-unit category span, keeps swarms off the plot edge

chart.setOption({
  animation: false,
  backgroundColor: "transparent",
  title: {
    text: "swarm-basic · javascript · echarts · anyplot.ai",
    left: "center",
    textStyle: { color: t.ink, fontSize: 22 },
  },
  legend: {
    data: [{ name: "Group mean", icon: "diamond" }],
    right: 60,
    top: 56,
    itemWidth: 12,
    itemHeight: 12,
    textStyle: { color: t.inkSoft, fontSize: 14 },
  },
  grid: { left: 100, right: 60, top: 100, bottom: 80 },
  tooltip: {
    trigger: "item",
    formatter: (p) => `${categories[Math.round(p.value[0])] ?? p.seriesName}<br/>CRP: ${p.value[1].toFixed(2)} mg/L`,
  },
  xAxis: {
    type: "value",
    min: -xPad,
    max: categories.length - 1 + xPad,
    interval: 1,
    axisLabel: {
      color: t.inkSoft,
      fontSize: 14,
      formatter: (v) => categories[Math.round(v)] ?? "",
      showMaxLabel: false,
    },
    axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },
    axisTick: { show: false },
    splitLine: { show: false },
  },
  yAxis: {
    type: "value",
    min: yMin,
    max: yMax,
    name: "CRP (mg/L)",
    nameLocation: "middle",
    nameGap: 60,
    nameTextStyle: { color: t.inkSoft, fontSize: 14 },
    axisLabel: { color: t.inkSoft, fontSize: 14 },
    axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },
    axisTick: { show: false },
    splitLine: { lineStyle: { color: t.grid } },
  },
});

// --- Beeswarm layout ---------------------------------------------------------
// ECharts has no native swarm/beeswarm series. Rather than bucketing points
// into fixed-width bins (which produces a blocky, grid-like silhouette), this
// greedily places each point at the nearest collision-free slot using the
// chart's own pixel projection (`convertToPixel`/`convertFromPixel`) — the
// same technique ECharts' own beeswarm/packed-scatter examples use — so the
// swarm reads as a continuous organic shape at any density.
const SYMBOL_SIZE = 14;
const GAP = SYMBOL_SIZE * 0.92; // slight overlap reads as a continuous swarm
function beeswarmLayout(ci, values) {
  const basePx = values.map((v) => chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ci, v]));
  const order = basePx.map((_, i) => i).sort((a, b) => basePx[a][1] - basePx[b][1]);
  const placed = [];
  const finalX = new Array(values.length);
  order.forEach((i) => {
    const [cx, cy] = basePx[i];
    let px = cx;
    let k = 0;
    while (k < 200 && placed.some((p) => Math.hypot(p.x - px, p.y - cy) < GAP)) {
      k += 1;
      const step = Math.ceil(k / 2);
      const side = k % 2 === 1 ? 1 : -1;
      px = cx + side * step * GAP;
    }
    placed.push({ x: px, y: cy });
    finalX[i] = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [px, cy])[0];
  });
  return finalX;
}

const pointSeries = groups.map((g, ci) => {
  const values = groupValues[ci];
  const xs = beeswarmLayout(ci, values);
  return {
    name: g.name,
    type: "scatter",
    data: values.map((v, i) => [xs[i], v]),
    symbolSize: SYMBOL_SIZE,
    itemStyle: { color: t.palette[ci], opacity: 0.8 },
  };
});

const meanSeries = {
  name: "Group mean",
  type: "scatter",
  data: groups.map((g, ci) => [ci, g.mean]),
  symbol: "diamond",
  symbolSize: 24,
  itemStyle: { color: t.ink, borderColor: t.pageBg, borderWidth: 2 },
  z: 3,
};

chart.setOption({ series: [...pointSeries, meanSeries] });

Part of Basic Swarm Plot on anyplot.ai.

Other implementations