|
| 1 | +// anyplot.ai |
| 2 | +// swarm-basic: Basic Swarm Plot |
| 3 | +// Library: d3 7.9.0 | JavaScript 22.23.1 |
| 4 | +// Quality: 92/100 | Created: 2026-07-26 |
| 5 | + |
| 6 | +const t = window.ANYPLOT_TOKENS; |
| 7 | +const { width, height } = window.ANYPLOT_SIZE; |
| 8 | +const margin = { top: 110, right: 60, bottom: 90, left: 110 }; |
| 9 | +const iw = width - margin.left - margin.right; |
| 10 | +const ih = height - margin.top - margin.bottom; |
| 11 | + |
| 12 | +// --- Data (in-memory, deterministic, fixed-seed LCG) ------------------------ |
| 13 | +let seed = 42; |
| 14 | +function lcgUniform() { |
| 15 | + seed = (seed * 1664525 + 1013904223) % 4294967296; |
| 16 | + return seed / 4294967296; |
| 17 | +} |
| 18 | +function gaussian(mean, sd) { |
| 19 | + const u1 = Math.max(lcgUniform(), 1e-9); |
| 20 | + const u2 = lcgUniform(); |
| 21 | + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); |
| 22 | + return mean + z * sd; |
| 23 | +} |
| 24 | + |
| 25 | +const groups = [ |
| 26 | + { category: "Placebo", mean: 8.2, sd: 2.1, n: 42 }, |
| 27 | + { category: "Low Dose", mean: 6.4, sd: 1.9, n: 45 }, |
| 28 | + { category: "Medium Dose", mean: 4.6, sd: 1.7, n: 44 }, |
| 29 | + { category: "High Dose", mean: 3.1, sd: 1.4, n: 40 }, |
| 30 | +]; |
| 31 | + |
| 32 | +const data = []; |
| 33 | +for (const grp of groups) { |
| 34 | + for (let i = 0; i < grp.n; i++) { |
| 35 | + data.push({ category: grp.category, value: Math.max(0.3, gaussian(grp.mean, grp.sd)) }); |
| 36 | + } |
| 37 | +} |
| 38 | +const categories = groups.map((grp) => grp.category); |
| 39 | + |
| 40 | +// --- Scales ------------------------------------------------------------- |
| 41 | +// Domain hugs the data range (not zero-anchored) — a swarm reads individual |
| 42 | +// values, not magnitude-from-zero, so a tight domain uses the canvas fully. |
| 43 | +const dataMin = d3.min(data, (d) => d.value); |
| 44 | +const dataMax = d3.max(data, (d) => d.value); |
| 45 | +const pad = (dataMax - dataMin) * 0.08; |
| 46 | +const x = d3.scaleBand().domain(categories).range([0, iw]).padding(0.18); |
| 47 | +const y = d3 |
| 48 | + .scaleLinear() |
| 49 | + .domain([Math.max(0, dataMin - pad), dataMax + pad]) |
| 50 | + .nice() |
| 51 | + .range([ih, 0]); |
| 52 | +const color = d3.scaleOrdinal().domain(categories).range(t.palette); |
| 53 | + |
| 54 | +// --- Beeswarm layout (force simulation, settled synchronously) -------------- |
| 55 | +// `fy` pins each node's vertical position exactly to its value — only x is |
| 56 | +// free, so the simulation resolves overlap purely by spreading horizontally |
| 57 | +// (a `forceY` pull instead of a fixed `fy` lets collide drag points off the |
| 58 | +// value line entirely, even clipping them off-canvas at high density). |
| 59 | +const radius = 7; |
| 60 | +const nodes = data.map((d) => ({ |
| 61 | + ...d, |
| 62 | + targetX: x(d.category) + x.bandwidth() / 2, |
| 63 | + fy: y(d.value), |
| 64 | +})); |
| 65 | + |
| 66 | +const simulation = d3 |
| 67 | + .forceSimulation(nodes) |
| 68 | + .force("x", d3.forceX((d) => d.targetX).strength(0.15)) |
| 69 | + .force("collide", d3.forceCollide(radius + 0.6)) |
| 70 | + .stop(); |
| 71 | +for (let i = 0; i < 300; i++) simulation.tick(); |
| 72 | + |
| 73 | +// Keep points within their own band so groups never bleed into neighbors. |
| 74 | +for (const n of nodes) { |
| 75 | + const lo = x(n.category) + radius; |
| 76 | + const hi = x(n.category) + x.bandwidth() - radius; |
| 77 | + n.x = Math.min(hi, Math.max(lo, n.x)); |
| 78 | +} |
| 79 | + |
| 80 | +// --- SVG mount ---------------------------------------------------------- |
| 81 | +const svg = d3.select("#container").append("svg").attr("width", width).attr("height", height); |
| 82 | +const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); |
| 83 | + |
| 84 | +// --- Gridlines (y-axis only, subtle) ----------------------------------- |
| 85 | +g.append("g") |
| 86 | + .call(d3.axisLeft(y).ticks(6).tickSize(-iw).tickFormat("")) |
| 87 | + .call((sel) => sel.select(".domain").remove()) |
| 88 | + .selectAll("line") |
| 89 | + .attr("stroke", t.grid); |
| 90 | + |
| 91 | +// --- Median markers per category (drawn under the points) ------------------- |
| 92 | +const markerHalfWidth = 70; |
| 93 | +for (const cat of categories) { |
| 94 | + const values = data.filter((d) => d.category === cat).map((d) => d.value); |
| 95 | + const median = d3.median(values); |
| 96 | + const cx = x(cat) + x.bandwidth() / 2; |
| 97 | + g.append("line") |
| 98 | + .attr("x1", cx - markerHalfWidth) |
| 99 | + .attr("x2", cx + markerHalfWidth) |
| 100 | + .attr("y1", y(median)) |
| 101 | + .attr("y2", y(median)) |
| 102 | + .attr("stroke", t.ink) |
| 103 | + .attr("stroke-width", 2.5) |
| 104 | + .attr("stroke-opacity", 0.55) |
| 105 | + .attr("stroke-linecap", "round"); |
| 106 | +} |
| 107 | + |
| 108 | +// --- Points ------------------------------------------------------------- |
| 109 | +g.selectAll("circle") |
| 110 | + .data(nodes) |
| 111 | + .join("circle") |
| 112 | + .attr("cx", (d) => d.x) |
| 113 | + .attr("cy", (d) => d.y) |
| 114 | + .attr("r", radius) |
| 115 | + .attr("fill", (d) => color(d.category)) |
| 116 | + .attr("fill-opacity", 0.85) |
| 117 | + .attr("stroke", t.pageBg) |
| 118 | + .attr("stroke-width", 1); |
| 119 | + |
| 120 | +// --- Axes ----------------------------------------------------------------- |
| 121 | +const xAxis = g.append("g").attr("transform", `translate(0,${ih})`).call(d3.axisBottom(x).tickSizeOuter(0)); |
| 122 | +const yAxis = g.append("g").call(d3.axisLeft(y).ticks(6)); |
| 123 | +for (const ax of [xAxis, yAxis]) { |
| 124 | + ax.selectAll("text").attr("fill", t.inkSoft).style("font-size", "14px"); |
| 125 | + ax.selectAll(".tick line").remove(); |
| 126 | + ax.select(".domain").attr("stroke", t.inkSoft); |
| 127 | +} |
| 128 | + |
| 129 | +// --- Axis labels ------------------------------------------------------------ |
| 130 | +g.append("text") |
| 131 | + .attr("x", iw / 2) |
| 132 | + .attr("y", ih + 64) |
| 133 | + .attr("text-anchor", "middle") |
| 134 | + .attr("fill", t.ink) |
| 135 | + .style("font-size", "16px") |
| 136 | + .text("Treatment Group"); |
| 137 | + |
| 138 | +g.append("text") |
| 139 | + .attr("transform", "rotate(-90)") |
| 140 | + .attr("x", -ih / 2) |
| 141 | + .attr("y", -78) |
| 142 | + .attr("text-anchor", "middle") |
| 143 | + .attr("fill", t.ink) |
| 144 | + .style("font-size", "16px") |
| 145 | + .text("CRP Biomarker Level (mg/L)"); |
| 146 | + |
| 147 | +// --- Title -------------------------------------------------------------- |
| 148 | +svg |
| 149 | + .append("text") |
| 150 | + .attr("x", width / 2) |
| 151 | + .attr("y", 56) |
| 152 | + .attr("text-anchor", "middle") |
| 153 | + .attr("fill", t.ink) |
| 154 | + .style("font-size", "22px") |
| 155 | + .style("font-weight", "600") |
| 156 | + .text("swarm-basic · javascript · d3 · anyplot.ai"); |
0 commit comments