Skip to content

Commit a84f422

Browse files
feat(muix): implement swarm-basic (#9947)
## Implementation: `swarm-basic` - javascript/muix Implements the **javascript/muix** version of `swarm-basic`. **File:** `plots/swarm-basic/implementations/javascript/muix.tsx` **Parent Issue:** #974 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30192673222)* --------- 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 c8e78f8 commit a84f422

2 files changed

Lines changed: 457 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// anyplot.ai
2+
// swarm-basic: Basic Swarm Plot
3+
// Library: muix 7.29.1 | JavaScript 22.23.1
4+
// Quality: 90/100 | Created: 2026-07-26
5+
//# anyplot-orientation: landscape
6+
// anyplot.ai
7+
// swarm-basic: Basic Swarm Plot
8+
// Library: MUI X Charts | React | Node 22
9+
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
10+
// Quality: pending | Created: 2026-07-26
11+
12+
import { ChartContainer } from "@mui/x-charts/ChartContainer";
13+
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
14+
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
15+
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
16+
import { ScatterPlot } from "@mui/x-charts/ScatterChart";
17+
import { useXScale, useYScale } from "@mui/x-charts/hooks";
18+
19+
const t = window.ANYPLOT_TOKENS;
20+
21+
// --- Deterministic PRNG (LCG) + Box-Muller for approx-normal samples --------
22+
let seed = 42;
23+
function nextUniform() {
24+
seed = (seed * 1664525 + 1013904223) % 4294967296;
25+
return seed / 4294967296;
26+
}
27+
function nextNormal(mean, stdDev) {
28+
const u1 = Math.max(nextUniform(), 1e-9);
29+
const u2 = nextUniform();
30+
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
31+
return mean + z * stdDev;
32+
}
33+
34+
// --- Data: quarterly performance review scores by department ---------------
35+
const DEPARTMENTS = ["Engineering", "Sales", "Marketing", "Support"];
36+
const MEANS = [78, 70, 74, 83];
37+
const STD_DEVS = [8, 12, 9, 6];
38+
const POINTS_PER_DEPT = 40;
39+
40+
const rawPoints = DEPARTMENTS.flatMap((department, categoryIndex) =>
41+
Array.from({ length: POINTS_PER_DEPT }, () => ({
42+
department,
43+
categoryIndex,
44+
score: Math.min(99, Math.max(45, nextNormal(MEANS[categoryIndex], STD_DEVS[categoryIndex]))),
45+
})),
46+
);
47+
48+
const median = (values) => {
49+
const sorted = [...values].sort((a, b) => a - b);
50+
const mid = Math.floor(sorted.length / 2);
51+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
52+
};
53+
54+
const medianByDept = DEPARTMENTS.map((_, i) =>
55+
median(rawPoints.filter((p) => p.categoryIndex === i).map((p) => p.score)),
56+
);
57+
58+
// --- Layout geometry — must stay in sync with the ChartContainer margin ----
59+
const MARGIN = { left: 110, right: 60, top: 80, bottom: 100 };
60+
const SCORE_MIN = 40;
61+
const SCORE_MAX = 100;
62+
const X_MIN = -0.6;
63+
const X_MAX = DEPARTMENTS.length - 1 + 0.6;
64+
const MARKER_SIZE = 6; // circle radius, px
65+
const MARKER_DIAMETER_PX = MARKER_SIZE * 2 + 1;
66+
67+
// --- Beeswarm packing: spread points horizontally within each department so
68+
// none overlap. Collisions are resolved in on-screen pixels (rather than data
69+
// units) so the spread looks even regardless of the score axis' range. Each
70+
// point keeps its true score on the y-axis; only the x-offset is adjusted.
71+
function layoutSwarm(plotWidthPx, plotHeightPx) {
72+
const pxPerScore = plotHeightPx / (SCORE_MAX - SCORE_MIN);
73+
const pxPerX = plotWidthPx / (X_MAX - X_MIN);
74+
75+
return DEPARTMENTS.flatMap((department, categoryIndex) => {
76+
const points = rawPoints
77+
.filter((p) => p.categoryIndex === categoryIndex)
78+
.sort((a, b) => a.score - b.score);
79+
80+
const placed = [];
81+
points.forEach((point) => {
82+
const nearby = placed.filter(
83+
(p) => Math.abs((point.score - p.score) * pxPerScore) < MARKER_DIAMETER_PX,
84+
);
85+
let offsetPx = 0;
86+
if (nearby.length > 0) {
87+
const step = MARKER_DIAMETER_PX * 0.92;
88+
let k = 0;
89+
let resolved = false;
90+
while (!resolved && k < 200) {
91+
const candidate = k === 0 ? 0 : (k % 2 === 1 ? Math.ceil(k / 2) : -Math.ceil(k / 2)) * step;
92+
if (
93+
nearby.every(
94+
(p) => Math.hypot(candidate - p.offsetPx, (point.score - p.score) * pxPerScore) >= MARKER_DIAMETER_PX * 0.95,
95+
)
96+
) {
97+
offsetPx = candidate;
98+
resolved = true;
99+
}
100+
k += 1;
101+
}
102+
}
103+
placed.push({ ...point, offsetPx });
104+
});
105+
106+
return placed.map((p) => ({
107+
id: `${department}-${p.score.toFixed(3)}-${p.offsetPx.toFixed(2)}`,
108+
x: categoryIndex + p.offsetPx / pxPerX,
109+
y: p.score,
110+
}));
111+
});
112+
}
113+
114+
// Short reference ticks at each department's median score. Rendered inside
115+
// ChartContainer so it can read the live D3 scales.
116+
function MedianTicks() {
117+
const xScale = useXScale();
118+
const yScale = useYScale();
119+
const halfWidth = 0.34;
120+
121+
return (
122+
<g>
123+
{DEPARTMENTS.map((department, i) => {
124+
const x1 = xScale(i - halfWidth) ?? 0;
125+
const x2 = xScale(i + halfWidth) ?? 0;
126+
const y = yScale(medianByDept[i]) ?? 0;
127+
return (
128+
<line
129+
key={department}
130+
x1={x1}
131+
y1={y}
132+
x2={x2}
133+
y2={y}
134+
stroke={t.ink}
135+
strokeWidth={2.5}
136+
strokeOpacity={0.75}
137+
/>
138+
);
139+
})}
140+
</g>
141+
);
142+
}
143+
144+
const TITLE = "swarm-basic · javascript · muix · anyplot.ai";
145+
146+
export default function Chart() {
147+
const W = window.ANYPLOT_SIZE.width;
148+
const H = window.ANYPLOT_SIZE.height;
149+
const plotWidthPx = W - MARGIN.left - MARGIN.right;
150+
const plotHeightPx = H - MARGIN.top - MARGIN.bottom;
151+
const scoreData = layoutSwarm(plotWidthPx, plotHeightPx);
152+
153+
return (
154+
<ChartContainer
155+
width={W}
156+
height={H}
157+
skipAnimation
158+
series={[
159+
{
160+
type: "scatter",
161+
id: "scores",
162+
label: "Performance Score",
163+
data: scoreData,
164+
color: t.palette[0],
165+
markerSize: MARKER_SIZE,
166+
},
167+
]}
168+
xAxis={[
169+
{
170+
id: "xAxis",
171+
min: X_MIN,
172+
max: X_MAX,
173+
tickMinStep: 1,
174+
valueFormatter: (v) => DEPARTMENTS[Math.round(v)] ?? "",
175+
tickLabelStyle: { fontSize: 15, fill: t.inkSoft },
176+
label: "Department",
177+
labelStyle: { fontSize: 16, fill: t.ink },
178+
},
179+
]}
180+
yAxis={[
181+
{
182+
id: "yAxis",
183+
min: SCORE_MIN,
184+
max: SCORE_MAX,
185+
label: "Performance Score",
186+
tickLabelStyle: { fontSize: 15, fill: t.inkSoft },
187+
labelStyle: { fontSize: 16, fill: t.ink },
188+
},
189+
]}
190+
margin={MARGIN}
191+
>
192+
<ChartsGrid horizontal />
193+
<ScatterPlot />
194+
<MedianTicks />
195+
<ChartsXAxis axisId="xAxis" />
196+
<ChartsYAxis axisId="yAxis" />
197+
<text x={W / 2} y={42} textAnchor="middle" fontSize={22} fontFamily="sans-serif" fontWeight="500" fill={t.ink}>
198+
{TITLE}
199+
</text>
200+
</ChartContainer>
201+
);
202+
}

0 commit comments

Comments
 (0)