Skip to content

Commit f14299e

Browse files
seanhancaclaude
andcommitted
core+site: Tier-2 mark: "beeswarm" — packed strip plot
@glyph/core - New mark in MarkSchema: "beeswarm". Categorical x (band scale), quantitative y. Compiler runs per-band 1D non-overlap packing so dots are placed near their encoded y position but nudged horizontally within the band so they don't overlap. Visually halfway between a strip plot and a violin — keeps every individual data point visible while distribution shape emerges naturally from density. - Deterministic placement: rows processed in source order; the offset-search algorithm is fully deterministic (no random tiebreaks), so byte-identical SVG on every run. - Added "beeswarm" to the Phase-1 allowedMarks gate alongside the other distribution / data marks. - 4 new tests in src/compiler/beeswarm-mark.test.ts: one circle per row, 2r minimum spacing between any pair, byte-identical output across runs, SVG renders with the expected circle count. site/play - New "Tenure beeswarm — by team" chip: 4 teams × 12 employees each, packed by tenure months. Shows the canonical beeswarm use-case (compare distributions across categories without binning or losing individual points). Tests now 720/720 passing. Biome clean. Playground bundle rebuilt. Tier-2 status after this PR: - arc ✓ shipped (#106) - slopegraph ✓ shipped via playground example (#106) - step-line ✓ shipped (#103 core + #106 example) - beeswarm ✓ shipped (this PR) - sankey ← still deferred (next-PR scope: ~400-500 LOC for longest-path layering + crossing-minimization + curved bezier links; no existing layout to lean on) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 832a978 commit f14299e

8 files changed

Lines changed: 368 additions & 1 deletion

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Tier-2 — `mark: "beeswarm"` packed strip plot.
2+
import { describe, expect, it } from "vitest";
3+
import { renderSvg } from "../render/svg.js";
4+
import type { GlyphSpec } from "../spec/types.js";
5+
import { compileSpec } from "./compile.js";
6+
7+
const schema = [
8+
{ name: "team", type: "VARCHAR", nullable: false },
9+
{ name: "tenure", type: "DOUBLE", nullable: false },
10+
];
11+
12+
// 3 teams × 8 employees each. tenure is months at the company.
13+
const rows: ReadonlyArray<ReadonlyArray<unknown>> = [
14+
// Platform team
15+
["Platform", 6],
16+
["Platform", 12],
17+
["Platform", 18],
18+
["Platform", 24],
19+
["Platform", 30],
20+
["Platform", 36],
21+
["Platform", 48],
22+
["Platform", 60],
23+
// Product team
24+
["Product", 3],
25+
["Product", 9],
26+
["Product", 15],
27+
["Product", 21],
28+
["Product", 27],
29+
["Product", 33],
30+
["Product", 42],
31+
["Product", 54],
32+
// Design team
33+
["Design", 8],
34+
["Design", 14],
35+
["Design", 20],
36+
["Design", 26],
37+
["Design", 32],
38+
["Design", 38],
39+
["Design", 50],
40+
["Design", 62],
41+
];
42+
43+
describe('Tier-2 — `mark: "beeswarm"`', () => {
44+
it("emits one circle SceneMark per row", () => {
45+
const spec: GlyphSpec = {
46+
layers: [{ mark: "beeswarm", encoding: { x: "team", y: "tenure" } }],
47+
};
48+
const scene = compileSpec({ spec, rows, schema });
49+
const circles = scene.marks.filter((m) => m.type === "circle");
50+
expect(circles.length).toBe(24);
51+
});
52+
53+
it("dots within a band are non-overlapping (2r minimum spacing)", () => {
54+
const spec: GlyphSpec = {
55+
layers: [{ mark: "beeswarm", encoding: { x: "team", y: "tenure" } }],
56+
};
57+
const scene = compileSpec({ spec, rows, schema });
58+
const circles = scene.marks.filter(
59+
(m): m is Extract<typeof m, { type: "circle" }> => m.type === "circle",
60+
);
61+
// For every pair of circles, distance ≥ 2r.
62+
for (let i = 0; i < circles.length; i++) {
63+
for (let j = i + 1; j < circles.length; j++) {
64+
const a = circles[i]!;
65+
const b = circles[j]!;
66+
const d = Math.hypot(a.cx - b.cx, a.cy - b.cy);
67+
// Allow a 0.5px slack for round-pixel rounding.
68+
expect(d).toBeGreaterThanOrEqual(2 * a.r - 0.5);
69+
}
70+
}
71+
});
72+
73+
it("is deterministic — same rows produce identical SVG", () => {
74+
const spec: GlyphSpec = {
75+
layers: [{ mark: "beeswarm", encoding: { x: "team", y: "tenure" } }],
76+
};
77+
const a = renderSvg(compileSpec({ spec, rows, schema }));
78+
const b = renderSvg(compileSpec({ spec, rows, schema }));
79+
expect(a).toBe(b);
80+
});
81+
82+
it("renders to a non-empty SVG with the expected number of circles", () => {
83+
const spec: GlyphSpec = {
84+
layers: [{ mark: "beeswarm", encoding: { x: "team", y: "tenure" } }],
85+
};
86+
const scene = compileSpec({ spec, rows, schema });
87+
const svg = renderSvg(scene);
88+
expect(svg.startsWith("<svg")).toBe(true);
89+
expect((svg.match(/<circle /g) ?? []).length).toBe(24);
90+
});
91+
});

packages/core/src/compiler/compile.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,9 @@ export function compileSpec(input: CompileInput): Scene {
10971097
// control points (no row data; configuration lives in
10981098
// `layer.bezier`). Cartesian path with linear x/y.
10991099
"bezier",
1100+
// Tier-2 — packed strip plot. Categorical x, quantitative y;
1101+
// per x-group the compiler runs 1D non-overlap packing.
1102+
"beeswarm",
11001103
];
11011104
if (!allowedMarks.includes(l.mark)) {
11021105
throw new Error(`Phase 1 supports marks ${allowedMarks.join("|")}; layer ${i} has ${l.mark}`);
@@ -3112,6 +3115,100 @@ function buildRules(
31123115
}
31133116
}
31143117

3118+
/**
3119+
* buildBeeswarm — packed strip plot (Tier-2).
3120+
*
3121+
* For each x-band (categorical), collects all the rows whose x value
3122+
* matches, then runs a 1D non-overlap pack: dots are placed at their
3123+
* encoded y, but nudged horizontally within the band so adjacent dots
3124+
* don't overlap. Halfway visually between a strip plot and a violin
3125+
* — keeps every individual point visible while letting density shape
3126+
* emerge.
3127+
*
3128+
* Determinism: rows are processed in source order; the placement
3129+
* algorithm is a deterministic offset search (no random tiebreaks).
3130+
* Same rows → same offsets → byte-identical SVG.
3131+
*/
3132+
function buildBeeswarm(
3133+
out: SceneMark[],
3134+
rows: ReadonlyArray<ReadonlyArray<unknown>>,
3135+
schema: ReadonlyArray<CompileFieldInfo>,
3136+
encoding: Encoding,
3137+
xField: string,
3138+
yField: string,
3139+
xScale: ReturnType<typeof bandScale>,
3140+
yScale: ReturnType<typeof linearScale>,
3141+
theme: Theme,
3142+
): void {
3143+
const colorField = fieldOf(encoding.color);
3144+
const colorDomain = colorField ? distinctOrdered(rows, schema, colorField) : [];
3145+
const yIdx = schema.findIndex((c) => c.name === yField);
3146+
const radius = 4;
3147+
// Group rows by x-band.
3148+
type Point = { y: number; row: ReadonlyArray<unknown>; rowIdx: number };
3149+
const bands = new Map<string, Point[]>();
3150+
for (let i = 0; i < rows.length; i++) {
3151+
const r = rows[i];
3152+
if (!r) continue;
3153+
const xv = valueAt(r, schema, xField);
3154+
if (xv == null) continue;
3155+
const yv = yIdx >= 0 ? Number(r[yIdx]) : Number.NaN;
3156+
if (!Number.isFinite(yv)) continue;
3157+
const key = String(xv);
3158+
let list = bands.get(key);
3159+
if (!list) {
3160+
list = [];
3161+
bands.set(key, list);
3162+
}
3163+
list.push({ y: yScale.apply(yv), row: r, rowIdx: i });
3164+
}
3165+
// Per-band layout: sort by y, then place each point at the closest
3166+
// x-offset that doesn't collide with already-placed neighbors.
3167+
// Search candidates: bandCenter, ±radius·2, ±radius·4, … This is
3168+
// d3-beeswarm's "force-x clamped to a band" pattern, simplified.
3169+
const bandHalfWidth = xScale.bandwidth / 2 - radius;
3170+
for (const [bandKey, ptsRaw] of bands) {
3171+
const bandCenter = xScale.apply(bandKey) + xScale.bandwidth / 2;
3172+
const pts = [...ptsRaw].sort((a, b) => a.y - b.y);
3173+
const placed: Array<{ x: number; y: number; row: ReadonlyArray<unknown>; rowIdx: number }> = [];
3174+
for (const p of pts) {
3175+
let chosenX = bandCenter;
3176+
// Generate up to N offset candidates outward from bandCenter.
3177+
const candidates = [0];
3178+
for (let i = 1; i < 50; i++) {
3179+
candidates.push(i * radius);
3180+
candidates.push(-i * radius);
3181+
}
3182+
let found = false;
3183+
for (const dx of candidates) {
3184+
if (Math.abs(dx) > bandHalfWidth) continue;
3185+
const x = bandCenter + dx;
3186+
// Collision check: distance to any already-placed point in
3187+
// this band must be ≥ 2r.
3188+
const collides = placed.some((q) => Math.hypot(x - q.x, p.y - q.y) < radius * 2);
3189+
if (!collides) {
3190+
chosenX = x;
3191+
found = true;
3192+
break;
3193+
}
3194+
}
3195+
// Fallback: if every candidate inside the band collides, allow
3196+
// overlap at the closest legal x (caps overflowing slightly).
3197+
if (!found) chosenX = bandCenter;
3198+
placed.push({ x: chosenX, y: p.y, row: p.row, rowIdx: p.rowIdx });
3199+
}
3200+
for (const q of placed) {
3201+
out.push({
3202+
type: "circle",
3203+
cx: roundPx(q.x),
3204+
cy: q.y,
3205+
r: radius,
3206+
fill: colorForRow(encoding, schema, q.row, colorDomain, theme, rows),
3207+
});
3208+
}
3209+
}
3210+
}
3211+
31153212
/**
31163213
* buildBoxplot — distribution mark (PR50).
31173214
*
@@ -3987,6 +4084,24 @@ registerMark({
39874084
);
39884085
},
39894086
});
4087+
registerMark({
4088+
type: "beeswarm",
4089+
compile(args) {
4090+
if (!args.yScale) return;
4091+
if (args.xScale.type !== "band") return;
4092+
buildBeeswarm(
4093+
args.out,
4094+
args.rows,
4095+
args.schema,
4096+
args.layer.encoding,
4097+
args.xField,
4098+
args.yField,
4099+
args.xScale,
4100+
args.yScale,
4101+
args.theme,
4102+
);
4103+
},
4104+
});
39904105
registerMark({
39914106
type: "text",
39924107
compile(args) {

packages/core/src/spec/schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,12 @@ export const MarkSchema = z.enum([
550550
// the compiler computes Q1 / median / Q3 / whiskers (Tukey, 1.5 × IQR)
551551
// and outliers beyond the whisker bounds.
552552
"boxplot",
553+
// Tier-2 — beeswarm packing. Categorical x, quantitative y; per
554+
// x-group the compiler runs a 1D non-overlap pack that nudges
555+
// dots horizontally within the band so they don't overlap.
556+
// Visually halfway between a strip plot and a violin — keeps every
557+
// individual data point visible while showing distribution shape.
558+
"beeswarm",
553559
// PR50 — direct label annotation. Renders a text mark at each row's
554560
// (x, y) with the value of encoding.text. Composes with other marks
555561
// via multi-layer specs (e.g. bars + text labels).
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
team,tenure_months
2+
Platform,6
3+
Platform,14
4+
Platform,18
5+
Platform,22
6+
Platform,28
7+
Platform,33
8+
Platform,37
9+
Platform,42
10+
Platform,48
11+
Platform,55
12+
Platform,62
13+
Platform,71
14+
Product,4
15+
Product,9
16+
Product,13
17+
Product,17
18+
Product,21
19+
Product,24
20+
Product,28
21+
Product,32
22+
Product,36
23+
Product,40
24+
Product,46
25+
Product,52
26+
Design,8
27+
Design,12
28+
Design,16
29+
Design,20
30+
Design,24
31+
Design,29
32+
Design,34
33+
Design,41
34+
Design,50
35+
Design,58
36+
Design,65
37+
Design,74
38+
Sales,3
39+
Sales,8
40+
Sales,12
41+
Sales,16
42+
Sales,20
43+
Sales,24
44+
Sales,29
45+
Sales,33
46+
Sales,40
47+
Sales,48
48+
Sales,57
49+
Sales,66
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"version": "glyph/0.1",
3+
"title": "Employee tenure by team — beeswarm (months at company)",
4+
"width": 640,
5+
"height": 420,
6+
"layers": [
7+
{
8+
"mark": "beeswarm",
9+
"encoding": {
10+
"x": { "field": "team", "type": "ordinal" },
11+
"y": { "field": "tenure_months", "type": "quantitative", "scale": { "domain": [0, 80] } }
12+
}
13+
}
14+
]
15+
}

site/play/examples/index.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,14 @@
231231
"csv": "examples/business/step-line.csv",
232232
"spec": "examples/business/step-line.spec.json"
233233
},
234+
{
235+
"id": "tenure-beeswarm",
236+
"category": "Business charts",
237+
"name": "Tenure beeswarm — by team",
238+
"description": "12 employees × 4 teams. Each dot is one person; the `beeswarm` mark packs them horizontally inside each band so density shows without losing individual points.",
239+
"csv": "examples/business/tenure-beeswarm.csv",
240+
"spec": "examples/business/tenure-beeswarm.spec.json"
241+
},
234242

235243
{
236244
"id": "ridgeline",

0 commit comments

Comments
 (0)