{"spec_id":"swarm-basic","library":"echarts","language":"javascript","code":"// anyplot.ai\n// swarm-basic: Basic Swarm Plot\n// Library: echarts 6.1.0 | JavaScript 22.23.1\n// Quality: 89/100 | Created: 2026-07-26\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// CRP (C-reactive protein) biomarker levels across a dose-escalation trial.\nfunction lcg(seed) {\n  let state = seed >>> 0;\n  return function () {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\nconst rng = lcg(42);\nfunction randNormal() {\n  const u1 = Math.max(rng(), 1e-9);\n  const u2 = rng();\n  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n}\n\nconst groups = [\n  { name: \"Placebo\", n: 45, mean: 3.2, sd: 0.9 },\n  { name: \"Low Dose\", n: 45, mean: 2.6, sd: 0.8 },\n  { name: \"Medium Dose\", n: 45, mean: 1.9, sd: 0.7 },\n  { name: \"High Dose\", n: 45, mean: 1.3, sd: 0.6 },\n];\nconst categories = groups.map((g) => g.name);\nconst groupValues = groups.map((g) =>\n  Array.from({ length: g.n }, () => Math.max(0.1, g.mean + randNormal() * g.sd)),\n);\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// Fix both axis extents up front so the coordinate system is fully\n// deterministic before any point is placed.\nconst allValues = groupValues.flat();\nconst yPad = (Math.max(...allValues) - Math.min(...allValues)) * 0.08;\nconst yMin = Math.max(0, Math.floor(Math.min(...allValues) - yPad));\nconst yMax = Math.ceil(Math.max(...allValues) + yPad);\nconst xPad = 0.36; // 12% of the 3-unit category span, keeps swarms off the plot edge\n\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"swarm-basic · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  legend: {\n    data: [{ name: \"Group mean\", icon: \"diamond\" }],\n    right: 60,\n    top: 56,\n    itemWidth: 12,\n    itemHeight: 12,\n    textStyle: { color: t.inkSoft, fontSize: 14 },\n  },\n  grid: { left: 100, right: 60, top: 100, bottom: 80 },\n  tooltip: {\n    trigger: \"item\",\n    formatter: (p) => `${categories[Math.round(p.value[0])] ?? p.seriesName}<br/>CRP: ${p.value[1].toFixed(2)} mg/L`,\n  },\n  xAxis: {\n    type: \"value\",\n    min: -xPad,\n    max: categories.length - 1 + xPad,\n    interval: 1,\n    axisLabel: {\n      color: t.inkSoft,\n      fontSize: 14,\n      formatter: (v) => categories[Math.round(v)] ?? \"\",\n      showMaxLabel: false,\n    },\n    axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },\n    axisTick: { show: false },\n    splitLine: { show: false },\n  },\n  yAxis: {\n    type: \"value\",\n    min: yMin,\n    max: yMax,\n    name: \"CRP (mg/L)\",\n    nameLocation: \"middle\",\n    nameGap: 60,\n    nameTextStyle: { color: t.inkSoft, fontSize: 14 },\n    axisLabel: { color: t.inkSoft, fontSize: 14 },\n    axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },\n    axisTick: { show: false },\n    splitLine: { lineStyle: { color: t.grid } },\n  },\n});\n\n// --- Beeswarm layout ---------------------------------------------------------\n// ECharts has no native swarm/beeswarm series. Rather than bucketing points\n// into fixed-width bins (which produces a blocky, grid-like silhouette), this\n// greedily places each point at the nearest collision-free slot using the\n// chart's own pixel projection (`convertToPixel`/`convertFromPixel`) — the\n// same technique ECharts' own beeswarm/packed-scatter examples use — so the\n// swarm reads as a continuous organic shape at any density.\nconst SYMBOL_SIZE = 14;\nconst GAP = SYMBOL_SIZE * 0.92; // slight overlap reads as a continuous swarm\nfunction beeswarmLayout(ci, values) {\n  const basePx = values.map((v) => chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ci, v]));\n  const order = basePx.map((_, i) => i).sort((a, b) => basePx[a][1] - basePx[b][1]);\n  const placed = [];\n  const finalX = new Array(values.length);\n  order.forEach((i) => {\n    const [cx, cy] = basePx[i];\n    let px = cx;\n    let k = 0;\n    while (k < 200 && placed.some((p) => Math.hypot(p.x - px, p.y - cy) < GAP)) {\n      k += 1;\n      const step = Math.ceil(k / 2);\n      const side = k % 2 === 1 ? 1 : -1;\n      px = cx + side * step * GAP;\n    }\n    placed.push({ x: px, y: cy });\n    finalX[i] = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [px, cy])[0];\n  });\n  return finalX;\n}\n\nconst pointSeries = groups.map((g, ci) => {\n  const values = groupValues[ci];\n  const xs = beeswarmLayout(ci, values);\n  return {\n    name: g.name,\n    type: \"scatter\",\n    data: values.map((v, i) => [xs[i], v]),\n    symbolSize: SYMBOL_SIZE,\n    itemStyle: { color: t.palette[ci], opacity: 0.8 },\n  };\n});\n\nconst meanSeries = {\n  name: \"Group mean\",\n  type: \"scatter\",\n  data: groups.map((g, ci) => [ci, g.mean]),\n  symbol: \"diamond\",\n  symbolSize: 24,\n  itemStyle: { color: t.ink, borderColor: t.pageBg, borderWidth: 2 },\n  z: 3,\n};\n\nchart.setOption({ series: [...pointSeries, meanSeries] });\n"}