Skip to content

Commit 3017f58

Browse files
feat(echarts): implement swarm-basic (#9945)
## Implementation: `swarm-basic` - javascript/echarts Implements the **javascript/echarts** version of `swarm-basic`. **File:** `plots/swarm-basic/implementations/javascript/echarts.js` **Parent Issue:** #974 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/30192609034)* --------- 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 4b1994e commit 3017f58

2 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// anyplot.ai
2+
// swarm-basic: Basic Swarm Plot
3+
// Library: echarts 6.1.0 | JavaScript 22.23.1
4+
// Quality: 89/100 | Created: 2026-07-26
5+
6+
const t = window.ANYPLOT_TOKENS;
7+
8+
// --- Data (in-memory, deterministic) ----------------------------------------
9+
// CRP (C-reactive protein) biomarker levels across a dose-escalation trial.
10+
function lcg(seed) {
11+
let state = seed >>> 0;
12+
return function () {
13+
state = (state * 1664525 + 1013904223) >>> 0;
14+
return state / 4294967296;
15+
};
16+
}
17+
const rng = lcg(42);
18+
function randNormal() {
19+
const u1 = Math.max(rng(), 1e-9);
20+
const u2 = rng();
21+
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
22+
}
23+
24+
const groups = [
25+
{ name: "Placebo", n: 45, mean: 3.2, sd: 0.9 },
26+
{ name: "Low Dose", n: 45, mean: 2.6, sd: 0.8 },
27+
{ name: "Medium Dose", n: 45, mean: 1.9, sd: 0.7 },
28+
{ name: "High Dose", n: 45, mean: 1.3, sd: 0.6 },
29+
];
30+
const categories = groups.map((g) => g.name);
31+
const groupValues = groups.map((g) =>
32+
Array.from({ length: g.n }, () => Math.max(0.1, g.mean + randNormal() * g.sd)),
33+
);
34+
35+
// --- Init ---------------------------------------------------------------
36+
const chart = echarts.init(document.getElementById("container"));
37+
38+
// Fix both axis extents up front so the coordinate system is fully
39+
// deterministic before any point is placed.
40+
const allValues = groupValues.flat();
41+
const yPad = (Math.max(...allValues) - Math.min(...allValues)) * 0.08;
42+
const yMin = Math.max(0, Math.floor(Math.min(...allValues) - yPad));
43+
const yMax = Math.ceil(Math.max(...allValues) + yPad);
44+
const xPad = 0.36; // 12% of the 3-unit category span, keeps swarms off the plot edge
45+
46+
chart.setOption({
47+
animation: false,
48+
backgroundColor: "transparent",
49+
title: {
50+
text: "swarm-basic · javascript · echarts · anyplot.ai",
51+
left: "center",
52+
textStyle: { color: t.ink, fontSize: 22 },
53+
},
54+
legend: {
55+
data: [{ name: "Group mean", icon: "diamond" }],
56+
right: 60,
57+
top: 56,
58+
itemWidth: 12,
59+
itemHeight: 12,
60+
textStyle: { color: t.inkSoft, fontSize: 14 },
61+
},
62+
grid: { left: 100, right: 60, top: 100, bottom: 80 },
63+
tooltip: {
64+
trigger: "item",
65+
formatter: (p) => `${categories[Math.round(p.value[0])] ?? p.seriesName}<br/>CRP: ${p.value[1].toFixed(2)} mg/L`,
66+
},
67+
xAxis: {
68+
type: "value",
69+
min: -xPad,
70+
max: categories.length - 1 + xPad,
71+
interval: 1,
72+
axisLabel: {
73+
color: t.inkSoft,
74+
fontSize: 14,
75+
formatter: (v) => categories[Math.round(v)] ?? "",
76+
showMaxLabel: false,
77+
},
78+
axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },
79+
axisTick: { show: false },
80+
splitLine: { show: false },
81+
},
82+
yAxis: {
83+
type: "value",
84+
min: yMin,
85+
max: yMax,
86+
name: "CRP (mg/L)",
87+
nameLocation: "middle",
88+
nameGap: 60,
89+
nameTextStyle: { color: t.inkSoft, fontSize: 14 },
90+
axisLabel: { color: t.inkSoft, fontSize: 14 },
91+
axisLine: { lineStyle: { color: t.inkSoft }, onZero: false },
92+
axisTick: { show: false },
93+
splitLine: { lineStyle: { color: t.grid } },
94+
},
95+
});
96+
97+
// --- Beeswarm layout ---------------------------------------------------------
98+
// ECharts has no native swarm/beeswarm series. Rather than bucketing points
99+
// into fixed-width bins (which produces a blocky, grid-like silhouette), this
100+
// greedily places each point at the nearest collision-free slot using the
101+
// chart's own pixel projection (`convertToPixel`/`convertFromPixel`) — the
102+
// same technique ECharts' own beeswarm/packed-scatter examples use — so the
103+
// swarm reads as a continuous organic shape at any density.
104+
const SYMBOL_SIZE = 14;
105+
const GAP = SYMBOL_SIZE * 0.92; // slight overlap reads as a continuous swarm
106+
function beeswarmLayout(ci, values) {
107+
const basePx = values.map((v) => chart.convertToPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [ci, v]));
108+
const order = basePx.map((_, i) => i).sort((a, b) => basePx[a][1] - basePx[b][1]);
109+
const placed = [];
110+
const finalX = new Array(values.length);
111+
order.forEach((i) => {
112+
const [cx, cy] = basePx[i];
113+
let px = cx;
114+
let k = 0;
115+
while (k < 200 && placed.some((p) => Math.hypot(p.x - px, p.y - cy) < GAP)) {
116+
k += 1;
117+
const step = Math.ceil(k / 2);
118+
const side = k % 2 === 1 ? 1 : -1;
119+
px = cx + side * step * GAP;
120+
}
121+
placed.push({ x: px, y: cy });
122+
finalX[i] = chart.convertFromPixel({ xAxisIndex: 0, yAxisIndex: 0 }, [px, cy])[0];
123+
});
124+
return finalX;
125+
}
126+
127+
const pointSeries = groups.map((g, ci) => {
128+
const values = groupValues[ci];
129+
const xs = beeswarmLayout(ci, values);
130+
return {
131+
name: g.name,
132+
type: "scatter",
133+
data: values.map((v, i) => [xs[i], v]),
134+
symbolSize: SYMBOL_SIZE,
135+
itemStyle: { color: t.palette[ci], opacity: 0.8 },
136+
};
137+
});
138+
139+
const meanSeries = {
140+
name: "Group mean",
141+
type: "scatter",
142+
data: groups.map((g, ci) => [ci, g.mean]),
143+
symbol: "diamond",
144+
symbolSize: 24,
145+
itemStyle: { color: t.ink, borderColor: t.pageBg, borderWidth: 2 },
146+
z: 3,
147+
};
148+
149+
chart.setOption({ series: [...pointSeries, meanSeries] });
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
library: echarts
2+
language: javascript
3+
specification_id: swarm-basic
4+
created: '2026-07-26T07:25:48Z'
5+
updated: '2026-07-26T07:51:49Z'
6+
generated_by: claude-sonnet
7+
workflow_run: 30192609034
8+
issue: 974
9+
language_version: 22.23.1
10+
library_version: 6.1.0
11+
preview_url_light: https://storage.googleapis.com/anyplot-images/plots/swarm-basic/javascript/echarts/plot-light.png
12+
preview_url_dark: https://storage.googleapis.com/anyplot-images/plots/swarm-basic/javascript/echarts/plot-dark.png
13+
preview_html_light: https://storage.googleapis.com/anyplot-images/plots/swarm-basic/javascript/echarts/plot-light.html
14+
preview_html_dark: https://storage.googleapis.com/anyplot-images/plots/swarm-basic/javascript/echarts/plot-dark.html
15+
quality_score: 89
16+
review:
17+
strengths:
18+
- 'All three Attempt 1 weaknesses fixed: legend entry now labels the mean-diamond
19+
markers, y-axis tick marks hidden to match x-axis chrome, and beeswarm layout
20+
rewritten to a greedy nearest-slot placement in pixel space for an organic swarm
21+
silhouette'
22+
- Correct Imprint palette assignment across all 4 categories in canonical order
23+
(Placebo=#009E73, Low Dose=#C475FD, Medium Dose=#4467A3, High Dose=#BD8233)
24+
- Theme-adaptive chrome verified correct in both renders — no dark-on-dark or light-on-light
25+
legibility failures
26+
- Deterministic seeded LCG data generation, fully reproducible
27+
- Realistic, neutral CRP dose-escalation clinical trial dataset with a clear downward
28+
dose-response trend, varied spread per group, and visible outliers
29+
weaknesses:
30+
- Library Mastery is still fundamentally a generic scatter series; the custom layout
31+
uses ECharts' coordinate-conversion API but doesn't showcase a truly ECharts-distinctive
32+
series type (e.g. custom renderItem)
33+
- Data storytelling is solid but static — no annotation or callout emphasizes the
34+
dose-response trend beyond the visual pattern itself
35+
image_description: |-
36+
Light render (plot-light.png):
37+
Background: Warm off-white matching #FAF8F1, not pure white.
38+
Chrome: Title "swarm-basic · javascript · echarts · anyplot.ai" centered, bold dark charcoal text, fully legible. "Group mean" legend with diamond icon top-right. Y-axis "CRP (mg/L)" label with units. X-axis category labels (Placebo, Low Dose, Medium Dose, High Dose) directly beneath each swarm. Subtle horizontal-only gridlines; both axis tick marks hidden. All readable against the light background.
39+
Data: Four swarms in Imprint canonical order — green #009E73 (Placebo), lavender #C475FD (Low Dose), blue #4467A3 (Medium Dose), ochre #BD8233 (High Dose) — each with a dark diamond (light halo border) marking the group mean. Clear downward dose-response trend visible.
40+
Legibility verdict: PASS
41+
42+
Dark render (plot-dark.png):
43+
Background: Warm near-black matching #1A1A17, not pure black.
44+
Chrome: Title, legend text, y-axis title, and all tick labels switch to light off-white/gray and remain fully readable — no dark-on-dark failures anywhere. Gridlines remain subtle but visible.
45+
Data: Colors are pixel-identical to the light render — only chrome flipped. Mean-marker diamonds swap to a light fill with dark border to stay visible against the darker page, an appropriate theme-adaptive treatment.
46+
Legibility verdict: PASS
47+
criteria_checklist:
48+
visual_quality:
49+
score: 28
50+
max: 30
51+
items:
52+
- id: VQ-01
53+
name: Text Legibility
54+
score: 7
55+
max: 8
56+
passed: true
57+
comment: All text readable in both themes, correctly sized
58+
- id: VQ-02
59+
name: No Overlap
60+
score: 6
61+
max: 6
62+
passed: true
63+
comment: No text or marker collisions
64+
- id: VQ-03
65+
name: Element Visibility
66+
score: 5
67+
max: 6
68+
passed: true
69+
comment: Marker size/alpha appropriate for ~45 points per group
70+
- id: VQ-04
71+
name: Color Accessibility
72+
score: 2
73+
max: 2
74+
passed: true
75+
comment: CVD-safe Imprint palette, adequate contrast
76+
- id: VQ-05
77+
name: Layout & Canvas
78+
score: 4
79+
max: 4
80+
passed: true
81+
comment: Good proportions, nothing cut off
82+
- id: VQ-06
83+
name: Axis Labels & Title
84+
score: 2
85+
max: 2
86+
passed: true
87+
comment: Descriptive with units (CRP mg/L)
88+
- id: VQ-07
89+
name: Palette Compliance
90+
score: 2
91+
max: 2
92+
passed: true
93+
comment: Canonical Imprint order, correct theme backgrounds both renders
94+
design_excellence:
95+
score: 15
96+
max: 20
97+
items:
98+
- id: DE-01
99+
name: Aesthetic Sophistication
100+
score: 6
101+
max: 8
102+
passed: true
103+
comment: Custom pixel-space beeswarm layout + themed mean markers
104+
- id: DE-02
105+
name: Visual Refinement
106+
score: 5
107+
max: 6
108+
passed: true
109+
comment: Subtle grid, generous whitespace, both axis ticks hidden (fixed from
110+
Attempt 1)
111+
- id: DE-03
112+
name: Data Storytelling
113+
score: 4
114+
max: 6
115+
passed: true
116+
comment: Dose-response trend + labeled mean markers create readable hierarchy
117+
spec_compliance:
118+
score: 15
119+
max: 15
120+
items:
121+
- id: SC-01
122+
name: Plot Type
123+
score: 5
124+
max: 5
125+
passed: true
126+
comment: Correct swarm/beeswarm plot
127+
- id: SC-02
128+
name: Required Features
129+
score: 4
130+
max: 4
131+
passed: true
132+
comment: All spec features present
133+
- id: SC-03
134+
name: Data Mapping
135+
score: 3
136+
max: 3
137+
passed: true
138+
comment: X/Y correct, axes show all data
139+
- id: SC-04
140+
name: Title & Legend
141+
score: 3
142+
max: 3
143+
passed: true
144+
comment: Exact title format; legend now identifies mean markers (fixed from
145+
Attempt 1)
146+
data_quality:
147+
score: 15
148+
max: 15
149+
items:
150+
- id: DQ-01
151+
name: Feature Coverage
152+
score: 6
153+
max: 6
154+
passed: true
155+
comment: Shows distribution shape, outliers, mean markers
156+
- id: DQ-02
157+
name: Realistic Context
158+
score: 5
159+
max: 5
160+
passed: true
161+
comment: Realistic, neutral CRP clinical trial data
162+
- id: DQ-03
163+
name: Appropriate Scale
164+
score: 4
165+
max: 4
166+
passed: true
167+
comment: Sensible CRP mg/L values
168+
code_quality:
169+
score: 10
170+
max: 10
171+
items:
172+
- id: CQ-01
173+
name: KISS Structure
174+
score: 3
175+
max: 3
176+
passed: true
177+
comment: Helper functions justified by lack of native ECharts swarm series
178+
- id: CQ-02
179+
name: Reproducibility
180+
score: 2
181+
max: 2
182+
passed: true
183+
comment: Seeded LCG RNG, deterministic
184+
- id: CQ-03
185+
name: Clean Imports
186+
score: 2
187+
max: 2
188+
passed: true
189+
comment: No unused imports
190+
- id: CQ-04
191+
name: Code Elegance
192+
score: 2
193+
max: 2
194+
passed: true
195+
comment: Appropriate complexity, no fake functionality
196+
- id: CQ-05
197+
name: Output & API
198+
score: 1
199+
max: 1
200+
passed: true
201+
comment: Follows mount-node contract correctly
202+
library_mastery:
203+
score: 6
204+
max: 10
205+
items:
206+
- id: LM-01
207+
name: Idiomatic Usage
208+
score: 4
209+
max: 5
210+
passed: true
211+
comment: Correct token usage, idiomatic setOption pattern
212+
- id: LM-02
213+
name: Distinctive Features
214+
score: 2
215+
max: 5
216+
passed: false
217+
comment: Uses convertToPixel/convertFromPixel coordinate API but still a plain
218+
scatter series, not a distinctive ECharts technique like custom renderItem
219+
verdict: APPROVED
220+
impl_tags:
221+
dependencies: []
222+
techniques:
223+
- custom-legend
224+
patterns:
225+
- data-generation
226+
- iteration-over-groups
227+
dataprep: []
228+
styling:
229+
- alpha-blending

0 commit comments

Comments
 (0)