{"spec_id":"swarm-basic","library":"d3","language":"javascript","code":"// anyplot.ai\n// swarm-basic: Basic Swarm Plot\n// Library: d3 7.9.0 | JavaScript 22.23.1\n// Quality: 92/100 | Created: 2026-07-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\nconst margin = { top: 110, right: 60, bottom: 90, left: 110 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\n\n// --- Data (in-memory, deterministic, fixed-seed LCG) ------------------------\nlet seed = 42;\nfunction lcgUniform() {\n  seed = (seed * 1664525 + 1013904223) % 4294967296;\n  return seed / 4294967296;\n}\nfunction gaussian(mean, sd) {\n  const u1 = Math.max(lcgUniform(), 1e-9);\n  const u2 = lcgUniform();\n  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n  return mean + z * sd;\n}\n\nconst groups = [\n  { category: \"Placebo\", mean: 8.2, sd: 2.1, n: 42 },\n  { category: \"Low Dose\", mean: 6.4, sd: 1.9, n: 45 },\n  { category: \"Medium Dose\", mean: 4.6, sd: 1.7, n: 44 },\n  { category: \"High Dose\", mean: 3.1, sd: 1.4, n: 40 },\n];\n\nconst data = [];\nfor (const grp of groups) {\n  for (let i = 0; i < grp.n; i++) {\n    data.push({ category: grp.category, value: Math.max(0.3, gaussian(grp.mean, grp.sd)) });\n  }\n}\nconst categories = groups.map((grp) => grp.category);\n\n// --- Scales -------------------------------------------------------------\n// Domain hugs the data range (not zero-anchored) — a swarm reads individual\n// values, not magnitude-from-zero, so a tight domain uses the canvas fully.\nconst dataMin = d3.min(data, (d) => d.value);\nconst dataMax = d3.max(data, (d) => d.value);\nconst pad = (dataMax - dataMin) * 0.08;\nconst x = d3.scaleBand().domain(categories).range([0, iw]).padding(0.18);\nconst y = d3\n  .scaleLinear()\n  .domain([Math.max(0, dataMin - pad), dataMax + pad])\n  .nice()\n  .range([ih, 0]);\nconst color = d3.scaleOrdinal().domain(categories).range(t.palette);\n\n// --- Beeswarm layout (force simulation, settled synchronously) --------------\n// `fy` pins each node's vertical position exactly to its value — only x is\n// free, so the simulation resolves overlap purely by spreading horizontally\n// (a `forceY` pull instead of a fixed `fy` lets collide drag points off the\n// value line entirely, even clipping them off-canvas at high density).\nconst radius = 7;\nconst nodes = data.map((d) => ({\n  ...d,\n  targetX: x(d.category) + x.bandwidth() / 2,\n  fy: y(d.value),\n}));\n\nconst simulation = d3\n  .forceSimulation(nodes)\n  .force(\"x\", d3.forceX((d) => d.targetX).strength(0.15))\n  .force(\"collide\", d3.forceCollide(radius + 0.6))\n  .stop();\nfor (let i = 0; i < 300; i++) simulation.tick();\n\n// Keep points within their own band so groups never bleed into neighbors.\nfor (const n of nodes) {\n  const lo = x(n.category) + radius;\n  const hi = x(n.category) + x.bandwidth() - radius;\n  n.x = Math.min(hi, Math.max(lo, n.x));\n}\n\n// --- SVG mount ----------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\nconst g = svg.append(\"g\").attr(\"transform\", `translate(${margin.left},${margin.top})`);\n\n// --- Gridlines (y-axis only, subtle) -----------------------------------\ng.append(\"g\")\n  .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat(\"\"))\n  .call((sel) => sel.select(\".domain\").remove())\n  .selectAll(\"line\")\n  .attr(\"stroke\", t.grid);\n\n// --- Median markers per category (drawn under the points) -------------------\nconst markerHalfWidth = 70;\nfor (const cat of categories) {\n  const values = data.filter((d) => d.category === cat).map((d) => d.value);\n  const median = d3.median(values);\n  const cx = x(cat) + x.bandwidth() / 2;\n  g.append(\"line\")\n    .attr(\"x1\", cx - markerHalfWidth)\n    .attr(\"x2\", cx + markerHalfWidth)\n    .attr(\"y1\", y(median))\n    .attr(\"y2\", y(median))\n    .attr(\"stroke\", t.ink)\n    .attr(\"stroke-width\", 2.5)\n    .attr(\"stroke-opacity\", 0.55)\n    .attr(\"stroke-linecap\", \"round\");\n}\n\n// --- Points -------------------------------------------------------------\ng.selectAll(\"circle\")\n  .data(nodes)\n  .join(\"circle\")\n  .attr(\"cx\", (d) => d.x)\n  .attr(\"cy\", (d) => d.y)\n  .attr(\"r\", radius)\n  .attr(\"fill\", (d) => color(d.category))\n  .attr(\"fill-opacity\", 0.85)\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 1);\n\n// --- Axes -----------------------------------------------------------------\nconst xAxis = g.append(\"g\").attr(\"transform\", `translate(0,${ih})`).call(d3.axisBottom(x).tickSizeOuter(0));\nconst yAxis = g.append(\"g\").call(d3.axisLeft(y).ticks(6));\nfor (const ax of [xAxis, yAxis]) {\n  ax.selectAll(\"text\").attr(\"fill\", t.inkSoft).style(\"font-size\", \"14px\");\n  ax.selectAll(\".tick line\").remove();\n  ax.select(\".domain\").attr(\"stroke\", t.inkSoft);\n}\n\n// --- Axis labels ------------------------------------------------------------\ng.append(\"text\")\n  .attr(\"x\", iw / 2)\n  .attr(\"y\", ih + 64)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"Treatment Group\");\n\ng.append(\"text\")\n  .attr(\"transform\", \"rotate(-90)\")\n  .attr(\"x\", -ih / 2)\n  .attr(\"y\", -78)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .text(\"CRP Biomarker Level (mg/L)\");\n\n// --- Title --------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 56)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"swarm-basic · javascript · d3 · anyplot.ai\");\n"}