Skip to content

Commit 4b1994e

Browse files
feat(chartjs): implement swarm-basic (#9944)
## Implementation: `swarm-basic` - javascript/chartjs Implements the **javascript/chartjs** version of `swarm-basic`. **File:** `plots/swarm-basic/implementations/javascript/chartjs.js` **Parent Issue:** #974 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30192487529)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent a84f422 commit 4b1994e

2 files changed

Lines changed: 437 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// anyplot.ai
2+
// swarm-basic: Basic Swarm Plot
3+
// Library: chartjs 4.4.7 | JavaScript 22.23.1
4+
// Quality: 92/100 | Created: 2026-07-26
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data (in-memory, deterministic) ----------------------------------------
9+
// Reaction times (ms) across a psychology experiment: 4 conditions, 40 obs each.
10+
function makeRng(seed) {
11+
let state = seed >>> 0;
12+
return function () {
13+
state = (1664525 * state + 1013904223) >>> 0;
14+
return state / 4294967296;
15+
};
16+
}
17+
function randNormal(rng, mean, sd) {
18+
const u1 = Math.max(rng(), 1e-9);
19+
const u2 = rng();
20+
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
21+
return mean + z * sd;
22+
}
23+
24+
const rng = makeRng(42);
25+
const categories = ["Control", "Caffeine", "Sleep-Deprived", "Exercise"];
26+
const conditionStats = [
27+
{ mean: 340, sd: 35 },
28+
{ mean: 285, sd: 28 },
29+
{ mean: 415, sd: 55 },
30+
{ mean: 305, sd: 38 },
31+
];
32+
const observationsPerGroup = 40;
33+
34+
const valuesByCategory = conditionStats.map(({ mean, sd }) =>
35+
Array.from({ length: observationsPerGroup }, () =>
36+
Math.max(150, Math.round(randNormal(rng, mean, sd) * 10) / 10),
37+
),
38+
);
39+
40+
// --- Swarm layout: bin by value, spread symmetrically within each bin -------
41+
// binCount adapts to each group's own spread (finer bins for wider-spread groups)
42+
// so a group with more within-bin crowding still resolves into distinct points
43+
// instead of a dense clump, without touching marker size (kept uniform per spec).
44+
function computeSwarmOffsets(values, targetPointsPerBin, step) {
45+
const min = Math.min(...values);
46+
const max = Math.max(...values);
47+
const binCount = Math.max(8, Math.round(values.length / targetPointsPerBin));
48+
const binSize = (max - min) / binCount || 1;
49+
const binCounts = new Array(binCount).fill(0);
50+
const sortedIdx = values.map((_, i) => i).sort((a, b) => values[a] - values[b]);
51+
const offsets = new Array(values.length).fill(0);
52+
for (const i of sortedIdx) {
53+
const binIdx = Math.min(binCount - 1, Math.floor((values[i] - min) / binSize));
54+
const count = binCounts[binIdx];
55+
const side = count % 2 === 0 ? 1 : -1;
56+
const rank = Math.ceil(count / 2);
57+
offsets[i] = side * rank * step;
58+
binCounts[binIdx] = count + 1;
59+
}
60+
return offsets;
61+
}
62+
63+
const HIGHLIGHT_CATEGORY_IDX = categories.indexOf("Sleep-Deprived");
64+
65+
const swarmDatasets = categories.map((category, catIdx) => {
66+
const values = valuesByCategory[catIdx];
67+
const offsets = computeSwarmOffsets(values, 2.5, 0.05);
68+
return {
69+
type: "scatter",
70+
label: category,
71+
data: values.map((v, i) => ({ x: catIdx + offsets[i], y: v })),
72+
backgroundColor: t.palette[catIdx % t.palette.length],
73+
borderColor: t.pageBg,
74+
borderWidth: 1.5,
75+
pointRadius: 5,
76+
pointHoverRadius: 5,
77+
order: 2,
78+
};
79+
});
80+
81+
function median(values) {
82+
const sorted = [...values].sort((a, b) => a - b);
83+
const mid = Math.floor(sorted.length / 2);
84+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
85+
}
86+
87+
const medianPoints = [];
88+
categories.forEach((_, catIdx) => {
89+
const med = median(valuesByCategory[catIdx]);
90+
medianPoints.push({ x: catIdx - 0.32, y: med });
91+
medianPoints.push({ x: catIdx + 0.32, y: med });
92+
medianPoints.push({ x: catIdx, y: null });
93+
});
94+
95+
// Median line is a single dataset, but its per-segment color/width is driven by
96+
// Chart.js's `segment` styling API so the standout Sleep-Deprived condition
97+
// (markedly slower and more variable reaction times) reads as the visual focal
98+
// point, without varying the swarm point size the spec asks to keep consistent.
99+
const medianDataset = {
100+
type: "line",
101+
label: "Median",
102+
data: medianPoints,
103+
borderColor: t.ink,
104+
borderWidth: 3,
105+
pointRadius: 0,
106+
spanGaps: false,
107+
order: 1,
108+
segment: {
109+
borderColor: (ctx) =>
110+
Math.floor(ctx.p0DataIndex / 3) === HIGHLIGHT_CATEGORY_IDX ? t.amber : t.ink,
111+
borderWidth: (ctx) => (Math.floor(ctx.p0DataIndex / 3) === HIGHLIGHT_CATEGORY_IDX ? 5 : 3),
112+
},
113+
};
114+
115+
// Custom plugin (native Chart.js plugin-core API, not a community plugin): draws
116+
// a subtle backdrop band behind the standout condition so it reads as the focal
117+
// point at a glance, before any dataset is drawn.
118+
const swarmHighlightPlugin = {
119+
id: "swarmHighlight",
120+
beforeDatasetsDraw(chart) {
121+
const { ctx, chartArea, scales } = chart;
122+
if (!chartArea) return;
123+
const left = scales.x.getPixelForValue(HIGHLIGHT_CATEGORY_IDX - 0.46);
124+
const right = scales.x.getPixelForValue(HIGHLIGHT_CATEGORY_IDX + 0.46);
125+
ctx.save();
126+
ctx.fillStyle = `${t.amber}1f`;
127+
ctx.fillRect(left, chartArea.top, right - left, chartArea.bottom - chartArea.top);
128+
ctx.restore();
129+
},
130+
};
131+
132+
// --- Mount -------------------------------------------------------------------
133+
const canvas = document.createElement("canvas");
134+
document.getElementById("container").appendChild(canvas);
135+
136+
// --- Chart ---------------------------------------------------------------
137+
new Chart(canvas, {
138+
type: "scatter",
139+
data: { datasets: [...swarmDatasets, medianDataset] },
140+
plugins: [swarmHighlightPlugin],
141+
options: {
142+
responsive: true,
143+
maintainAspectRatio: false,
144+
animation: false,
145+
plugins: {
146+
title: {
147+
display: true,
148+
text: "swarm-basic · javascript · chartjs · anyplot.ai",
149+
color: t.ink,
150+
font: { size: 22 },
151+
},
152+
legend: {
153+
position: "top",
154+
labels: {
155+
color: t.ink,
156+
font: { size: 16 },
157+
filter: (item) => item.text !== "Median",
158+
},
159+
},
160+
},
161+
scales: {
162+
x: {
163+
type: "linear",
164+
min: -0.6,
165+
max: categories.length - 1 + 0.6,
166+
afterBuildTicks: (axis) => {
167+
axis.ticks = categories.map((_, i) => ({ value: i }));
168+
},
169+
ticks: {
170+
color: t.inkSoft,
171+
font: { size: 14 },
172+
callback: (value) => categories[value] ?? "",
173+
},
174+
grid: { display: false },
175+
title: { display: true, text: "Experimental Condition", color: t.ink, font: { size: 16 } },
176+
},
177+
y: {
178+
ticks: { color: t.inkSoft, font: { size: 14 } },
179+
grid: { color: t.grid },
180+
title: { display: true, text: "Reaction Time (ms)", color: t.ink, font: { size: 16 } },
181+
},
182+
},
183+
},
184+
});

0 commit comments

Comments
 (0)