@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +123 -14
- package/dist/index.js +16166 -10027
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +310 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +119 -13
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { PointMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { compileChart } from '../../../compile';
|
|
4
|
+
import { compile } from '../../../compiler';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Fixtures
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Deterministic pseudo-random sequence (mulberry32) for repeatable fixtures. */
|
|
11
|
+
function seededRandom(seed: number): () => number {
|
|
12
|
+
let a = seed;
|
|
13
|
+
return () => {
|
|
14
|
+
a |= 0;
|
|
15
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
16
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
17
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
18
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const REGIONS = ['Northeast', 'Midwest', 'South', 'West'];
|
|
23
|
+
|
|
24
|
+
/** Deterministic observation rows: income value, region lane, population size. */
|
|
25
|
+
function makeRows(count: number, seed = 1): Array<Record<string, unknown>> {
|
|
26
|
+
const rand = seededRandom(seed);
|
|
27
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
28
|
+
income: Math.round(((rand() + rand()) / 2) * 120 + 20),
|
|
29
|
+
region: REGIONS[i % REGIONS.length],
|
|
30
|
+
pop: Math.round(rand() * 900 + 10),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function singleLaneSpec(count = 300) {
|
|
35
|
+
return {
|
|
36
|
+
mark: 'beeswarm' as const,
|
|
37
|
+
data: makeRows(count),
|
|
38
|
+
encoding: {
|
|
39
|
+
x: { field: 'income', type: 'quantitative' as const },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function groupedSpec(count = 200) {
|
|
45
|
+
return {
|
|
46
|
+
mark: 'beeswarm' as const,
|
|
47
|
+
data: makeRows(count),
|
|
48
|
+
encoding: {
|
|
49
|
+
x: { field: 'income', type: 'quantitative' as const },
|
|
50
|
+
y: { field: 'region', type: 'nominal' as const },
|
|
51
|
+
color: { field: 'region', type: 'nominal' as const },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OPTIONS = { width: 800, height: 500 };
|
|
57
|
+
|
|
58
|
+
function pointMarks(layout: ReturnType<typeof compileChart>): PointMark[] {
|
|
59
|
+
return layout.marks.filter((m): m is PointMark => m.type === 'point');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Assert every pair of circles keeps at least (r1 + r2) separation. */
|
|
63
|
+
function expectNoOverlap(marks: PointMark[]): void {
|
|
64
|
+
const epsilon = 1e-6;
|
|
65
|
+
for (let i = 0; i < marks.length; i++) {
|
|
66
|
+
for (let j = i + 1; j < marks.length; j++) {
|
|
67
|
+
const dx = marks[i].cx - marks[j].cx;
|
|
68
|
+
const dy = marks[i].cy - marks[j].cy;
|
|
69
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
70
|
+
expect(dist).toBeGreaterThanOrEqual(marks[i].r + marks[j].r - epsilon);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Tests
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe('beeswarm compile', () => {
|
|
80
|
+
it('renders a 300-point single-lane swarm with zero overlapping circles', () => {
|
|
81
|
+
const layout = compileChart(singleLaneSpec(300), OPTIONS);
|
|
82
|
+
const marks = pointMarks(layout);
|
|
83
|
+
expect(marks).toHaveLength(300);
|
|
84
|
+
expectNoOverlap(marks);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('compiles the same spec twice to identical positions (determinism)', () => {
|
|
88
|
+
const spec = singleLaneSpec(300);
|
|
89
|
+
const first = pointMarks(compileChart(spec, OPTIONS)).map((m) => [m.cx, m.cy, m.r]);
|
|
90
|
+
const second = pointMarks(compileChart(spec, OPTIONS)).map((m) => [m.cx, m.cy, m.r]);
|
|
91
|
+
expect(second).toEqual(first);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('drops rows whose value is null or the field is missing', () => {
|
|
95
|
+
// The validator rejects a quantitative field that carries non-numeric or
|
|
96
|
+
// non-finite values (strings, NaN, Infinity), but tolerates null/undefined
|
|
97
|
+
// (`val != null` skips them). Those null/missing rows must then be dropped
|
|
98
|
+
// by the compute pass rather than producing NaN-positioned dots.
|
|
99
|
+
const spec = {
|
|
100
|
+
mark: 'beeswarm' as const,
|
|
101
|
+
data: [
|
|
102
|
+
{ income: 40 },
|
|
103
|
+
{ income: null },
|
|
104
|
+
{ notIncome: 5 }, // field missing entirely -> undefined
|
|
105
|
+
{ income: 90 },
|
|
106
|
+
],
|
|
107
|
+
encoding: {
|
|
108
|
+
x: { field: 'income', type: 'quantitative' as const },
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const marks = pointMarks(compileChart(spec, OPTIONS));
|
|
112
|
+
// Only the two rows with finite values (40 and 90) survive.
|
|
113
|
+
expect(marks).toHaveLength(2);
|
|
114
|
+
for (const mark of marks) {
|
|
115
|
+
expect(Number.isFinite(mark.cx)).toBe(true);
|
|
116
|
+
expect(Number.isFinite(mark.cy)).toBe(true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('renders only the value axis for a single-lane swarm', () => {
|
|
121
|
+
const layout = compileChart(singleLaneSpec(60), OPTIONS);
|
|
122
|
+
expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
|
|
123
|
+
expect(layout.axes.y).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('centers a single-lane horizontal swarm on the middle of the chart area', () => {
|
|
127
|
+
const layout = compileChart(singleLaneSpec(1), OPTIONS);
|
|
128
|
+
const [mark] = pointMarks(layout);
|
|
129
|
+
expect(mark.cy).toBeCloseTo(layout.area.y + layout.area.height / 2, 6);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('renders 4 grouped lanes as non-overlapping swarms with lane labels', () => {
|
|
133
|
+
const layout = compileChart(groupedSpec(200), OPTIONS);
|
|
134
|
+
const marks = pointMarks(layout);
|
|
135
|
+
expect(marks).toHaveLength(200);
|
|
136
|
+
expectNoOverlap(marks);
|
|
137
|
+
|
|
138
|
+
// One swarm per lane: 4 distinct lane groups in the data
|
|
139
|
+
const lanes = new Set(marks.map((m) => String(m.data.region)));
|
|
140
|
+
expect(lanes).toEqual(new Set(REGIONS));
|
|
141
|
+
|
|
142
|
+
// Lane labels render on the band axis
|
|
143
|
+
const tickLabels = (layout.axes.y?.ticks ?? []).map((t) => t.label);
|
|
144
|
+
for (const region of REGIONS) {
|
|
145
|
+
expect(tickLabels).toContain(region);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Each lane's dots cluster around its own band center: lanes must not
|
|
149
|
+
// share a mean cross-axis position.
|
|
150
|
+
const meansByLane = REGIONS.map((region) => {
|
|
151
|
+
const laneMarks = marks.filter((m) => m.data.region === region);
|
|
152
|
+
return laneMarks.reduce((sum, m) => sum + m.cy, 0) / laneMarks.length;
|
|
153
|
+
});
|
|
154
|
+
const uniqueMeans = new Set(meansByLane.map((m) => Math.round(m / 10)));
|
|
155
|
+
expect(uniqueMeans.size).toBe(REGIONS.length);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('renders a vertical swarm when y is quantitative', () => {
|
|
159
|
+
const data = [
|
|
160
|
+
{ income: 20, region: 'West' },
|
|
161
|
+
{ income: 140, region: 'West' },
|
|
162
|
+
];
|
|
163
|
+
const layout = compileChart(
|
|
164
|
+
{
|
|
165
|
+
mark: 'beeswarm' as const,
|
|
166
|
+
data,
|
|
167
|
+
encoding: {
|
|
168
|
+
y: { field: 'income', type: 'quantitative' as const },
|
|
169
|
+
x: { field: 'region', type: 'nominal' as const },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
OPTIONS,
|
|
173
|
+
);
|
|
174
|
+
const marks = pointMarks(layout);
|
|
175
|
+
expect(marks).toHaveLength(2);
|
|
176
|
+
// SVG y grows downward: the larger value sits higher (smaller cy)
|
|
177
|
+
const low = marks.find((m) => m.data.income === 20)!;
|
|
178
|
+
const high = marks.find((m) => m.data.income === 140)!;
|
|
179
|
+
expect(high.cy).toBeLessThan(low.cy);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('sizes dots from the size encoding and keeps them collision-free', () => {
|
|
183
|
+
const spec = {
|
|
184
|
+
...groupedSpec(120),
|
|
185
|
+
encoding: {
|
|
186
|
+
...groupedSpec(120).encoding,
|
|
187
|
+
size: { field: 'pop', type: 'quantitative' as const },
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const layout = compileChart(spec, OPTIONS);
|
|
191
|
+
const marks = pointMarks(layout);
|
|
192
|
+
const radii = new Set(marks.map((m) => m.r));
|
|
193
|
+
expect(radii.size).toBeGreaterThan(1);
|
|
194
|
+
for (const mark of marks) {
|
|
195
|
+
expect(mark.r).toBeGreaterThanOrEqual(2);
|
|
196
|
+
expect(mark.r).toBeLessThanOrEqual(10);
|
|
197
|
+
}
|
|
198
|
+
expectNoOverlap(marks);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('stamps stable unique keys on every dot', () => {
|
|
202
|
+
const layout = compileChart(singleLaneSpec(100), OPTIONS);
|
|
203
|
+
const keys = pointMarks(layout).map((m) => m.key);
|
|
204
|
+
expect(keys.every((k) => typeof k === 'string' && k.length > 0)).toBe(true);
|
|
205
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('produces tooltips and a11y coverage for every dot', () => {
|
|
209
|
+
const layout = compileChart(groupedSpec(40), OPTIONS);
|
|
210
|
+
const marks = pointMarks(layout);
|
|
211
|
+
expect(layout.tooltipDescriptors.size).toBe(marks.length);
|
|
212
|
+
expect(layout.a11y.keyboardNavigable).toBe(true);
|
|
213
|
+
expect(layout.a11y.altText).toContain('Beeswarm chart');
|
|
214
|
+
expect(layout.a11y.altText).toContain('distribution of income by region');
|
|
215
|
+
for (const mark of marks) {
|
|
216
|
+
expect(mark.aria.label).toContain('income=');
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('beeswarm validation', () => {
|
|
222
|
+
it('rejects a spec with two quantitative axes', () => {
|
|
223
|
+
expect(() =>
|
|
224
|
+
compileChart(
|
|
225
|
+
{
|
|
226
|
+
mark: 'beeswarm',
|
|
227
|
+
data: [{ a: 1, b: 2 }],
|
|
228
|
+
encoding: {
|
|
229
|
+
x: { field: 'a', type: 'quantitative' },
|
|
230
|
+
y: { field: 'b', type: 'quantitative' },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
OPTIONS,
|
|
234
|
+
),
|
|
235
|
+
).toThrow(/only one quantitative axis/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('rejects a spec with no positional channel', () => {
|
|
239
|
+
expect(() =>
|
|
240
|
+
compileChart({ mark: 'beeswarm', data: [{ a: 1 }], encoding: {} }, OPTIONS),
|
|
241
|
+
).toThrow(/quantitative encoding\.x or encoding\.y/);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('rejects a spec where no declared axis is quantitative', () => {
|
|
245
|
+
expect(() =>
|
|
246
|
+
compileChart(
|
|
247
|
+
{
|
|
248
|
+
mark: 'beeswarm',
|
|
249
|
+
data: [{ a: 'x', b: 'y' }],
|
|
250
|
+
encoding: {
|
|
251
|
+
x: { field: 'a', type: 'nominal' },
|
|
252
|
+
y: { field: 'b', type: 'nominal' },
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
OPTIONS,
|
|
256
|
+
),
|
|
257
|
+
).toThrow(/requires one quantitative axis/);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('beeswarm overplotting guard', () => {
|
|
262
|
+
it('warns past the ~2000-point budget and suggests alternatives', () => {
|
|
263
|
+
const { warnings } = compile(singleLaneSpec(2001));
|
|
264
|
+
const warning = warnings.find((w) => w.includes('beeswarm'));
|
|
265
|
+
expect(warning).toBeDefined();
|
|
266
|
+
expect(warning).toContain('2001 data points');
|
|
267
|
+
expect(warning).toContain("mark: 'tick'");
|
|
268
|
+
expect(warning).toContain('histogram');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('stays quiet at or under the budget', () => {
|
|
272
|
+
const { warnings } = compile(singleLaneSpec(2000));
|
|
273
|
+
expect(warnings.filter((w) => w.includes('beeswarm'))).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('never escalates the budget to an error: the chart still compiles', () => {
|
|
277
|
+
const layout = compileChart(singleLaneSpec(2001), OPTIONS);
|
|
278
|
+
expect(pointMarks(layout)).toHaveLength(2001);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_DODGE_PADDING, dodgeOffsets } from '../dodge';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Fixtures
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** Deterministic pseudo-random sequence (mulberry32) for repeatable fixtures. */
|
|
9
|
+
function seededRandom(seed: number): () => number {
|
|
10
|
+
let a = seed;
|
|
11
|
+
return () => {
|
|
12
|
+
a |= 0;
|
|
13
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
14
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
15
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Clustered value-axis positions in [0, 500] that force many collisions. */
|
|
21
|
+
function makePositions(count: number, seed = 42): number[] {
|
|
22
|
+
const rand = seededRandom(seed);
|
|
23
|
+
return Array.from({ length: count }, () => {
|
|
24
|
+
// Sum of two draws clusters values around the middle (triangular-ish)
|
|
25
|
+
return ((rand() + rand()) / 2) * 500;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Assert every pair of circles keeps at least (r1 + r2) separation. */
|
|
30
|
+
function expectNoOverlap(
|
|
31
|
+
positions: readonly number[],
|
|
32
|
+
offsets: readonly number[],
|
|
33
|
+
radii: readonly number[],
|
|
34
|
+
): void {
|
|
35
|
+
const epsilon = 1e-6;
|
|
36
|
+
for (let i = 0; i < positions.length; i++) {
|
|
37
|
+
for (let j = i + 1; j < positions.length; j++) {
|
|
38
|
+
const dx = positions[i] - positions[j];
|
|
39
|
+
const dy = offsets[i] - offsets[j];
|
|
40
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
41
|
+
expect(dist).toBeGreaterThanOrEqual(radii[i] + radii[j] - epsilon);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Tests
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
describe('dodgeOffsets', () => {
|
|
51
|
+
it('leaves a single dot at offset 0', () => {
|
|
52
|
+
expect(dodgeOffsets([100], [4])).toEqual([0]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('leaves non-colliding dots at offset 0', () => {
|
|
56
|
+
const offsets = dodgeOffsets([0, 100, 200], [4, 4, 4]);
|
|
57
|
+
expect(offsets).toEqual([0, 0, 0]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('produces zero overlapping circles at 300 clustered points', () => {
|
|
61
|
+
const positions = makePositions(300);
|
|
62
|
+
const radii = positions.map(() => 4);
|
|
63
|
+
const offsets = dodgeOffsets(positions, radii);
|
|
64
|
+
expectNoOverlap(positions, offsets, radii);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('respects per-dot radii (sized dots stay collision-free)', () => {
|
|
68
|
+
const positions = makePositions(150, 7);
|
|
69
|
+
const rand = seededRandom(99);
|
|
70
|
+
const radii = positions.map(() => 2 + rand() * 8);
|
|
71
|
+
const offsets = dodgeOffsets(positions, radii);
|
|
72
|
+
expectNoOverlap(positions, offsets, radii);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('is deterministic: same input yields identical output', () => {
|
|
76
|
+
const positions = makePositions(200, 3);
|
|
77
|
+
const radii = positions.map(() => 4);
|
|
78
|
+
const first = dodgeOffsets(positions, radii);
|
|
79
|
+
const second = dodgeOffsets(positions, radii);
|
|
80
|
+
expect(second).toEqual(first);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps the first placed dot on the lane center and breaks symmetric ties to the negative side', () => {
|
|
84
|
+
// Two dots at the same position: the first (by index) stays at 0, the
|
|
85
|
+
// second must move; both interval edges have equal magnitude, so the
|
|
86
|
+
// documented tie-break picks the negative side.
|
|
87
|
+
const offsets = dodgeOffsets([100, 100], [4, 4]);
|
|
88
|
+
expect(offsets[0]).toBe(0);
|
|
89
|
+
expect(offsets[1]).toBeLessThan(0);
|
|
90
|
+
expect(Math.abs(offsets[1])).toBeCloseTo(8 + DEFAULT_DODGE_PADDING, 6);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('places dots at the minimal offset avoiding collisions', () => {
|
|
94
|
+
// Three dots at one position: center, one side, other side; the third
|
|
95
|
+
// dot must not leapfrog to a farther slot.
|
|
96
|
+
const offsets = dodgeOffsets([50, 50, 50], [4, 4, 4]);
|
|
97
|
+
expect(offsets[0]).toBe(0);
|
|
98
|
+
const sep = 8 + DEFAULT_DODGE_PADDING;
|
|
99
|
+
expect(Math.abs(offsets[1])).toBeCloseTo(sep, 6);
|
|
100
|
+
expect(Math.abs(offsets[2])).toBeCloseTo(sep, 6);
|
|
101
|
+
expect(Math.sign(offsets[1])).not.toBe(Math.sign(offsets[2]));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('maps offsets back to input order, not sweep order', () => {
|
|
105
|
+
// Descending input positions: the sweep runs ascending, but each offset
|
|
106
|
+
// must land on its own input index.
|
|
107
|
+
const positions = [300, 100, 200];
|
|
108
|
+
const offsets = dodgeOffsets(positions, [4, 4, 4]);
|
|
109
|
+
expect(offsets).toEqual([0, 0, 0]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('dodges only within a lane', () => {
|
|
113
|
+
// Same position, different lanes: no collision, both stay centered.
|
|
114
|
+
const separate = dodgeOffsets([100, 100], [4, 4], ['a', 'b']);
|
|
115
|
+
expect(separate).toEqual([0, 0]);
|
|
116
|
+
|
|
117
|
+
// Same lane collides as usual.
|
|
118
|
+
const together = dodgeOffsets([100, 100], [4, 4], ['a', 'a']);
|
|
119
|
+
expect(together[1]).not.toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('keeps lanes independent and collision-free in mixed input', () => {
|
|
123
|
+
const rand = seededRandom(11);
|
|
124
|
+
const lanesPool = ['a', 'b', 'c', 'd'];
|
|
125
|
+
const positions = makePositions(200, 5);
|
|
126
|
+
const radii = positions.map(() => 4);
|
|
127
|
+
const lanes = positions.map(() => lanesPool[Math.floor(rand() * lanesPool.length)]);
|
|
128
|
+
const offsets = dodgeOffsets(positions, radii, lanes);
|
|
129
|
+
|
|
130
|
+
for (const lane of lanesPool) {
|
|
131
|
+
const idx = positions.map((_, i) => i).filter((i) => lanes[i] === lane);
|
|
132
|
+
expectNoOverlap(
|
|
133
|
+
idx.map((i) => positions[i]),
|
|
134
|
+
idx.map((i) => offsets[i]),
|
|
135
|
+
idx.map((i) => radii[i]),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('honors a custom padding', () => {
|
|
141
|
+
const offsets = dodgeOffsets([100, 100], [4, 4], undefined, 5);
|
|
142
|
+
expect(Math.abs(offsets[1])).toBeCloseTo(13, 6);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns an empty array for empty input', () => {
|
|
146
|
+
expect(dodgeOffsets([], [])).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beeswarm chart mark computation.
|
|
3
|
+
*
|
|
4
|
+
* One quantitative value axis positions every observation; the deterministic
|
|
5
|
+
* dodge layout (see ./dodge) spreads colliding dots along the cross axis so
|
|
6
|
+
* each dot stays visible. An optional nominal channel on the other axis
|
|
7
|
+
* groups the dots into lanes (one swarm per category via a band scale).
|
|
8
|
+
* The cross axis has no scale: offsets are pure pixel-space.
|
|
9
|
+
*
|
|
10
|
+
* Orientation follows the bar convention: quantitative x = horizontal swarm,
|
|
11
|
+
* quantitative y = vertical swarm.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
Encoding,
|
|
16
|
+
GradientDef,
|
|
17
|
+
LayoutStrategy,
|
|
18
|
+
MarkAria,
|
|
19
|
+
PointMark,
|
|
20
|
+
Rect,
|
|
21
|
+
} from '@opendata-ai/openchart-core';
|
|
22
|
+
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
23
|
+
import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
|
|
24
|
+
import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
|
|
25
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
26
|
+
import type { ResolvedScales } from '../../layout/scales';
|
|
27
|
+
import { getColor, getSequentialColor } from '../utils';
|
|
28
|
+
import { dodgeOffsets } from './dodge';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Default dot radius (px). Smaller than the scatter default (5) because
|
|
36
|
+
* beeswarms carry hundreds of dots and the dodge layout trades radius for
|
|
37
|
+
* swarm height: at r=4 a 300-dot swarm stays inside a typical chart area.
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_DOT_RADIUS = 4;
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Public API
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute beeswarm marks from a normalized chart spec.
|
|
47
|
+
*
|
|
48
|
+
* The quantitative channel is the value axis (x = horizontal swarm,
|
|
49
|
+
* y = vertical). The opposite channel, when present, must be nominal/ordinal
|
|
50
|
+
* and lanes the dots via its band scale; otherwise all dots share one lane
|
|
51
|
+
* centered on the cross axis. Radii come from the optional `size` encoding
|
|
52
|
+
* (sqrt scale, area-proportional) or the default dot radius; the dodge
|
|
53
|
+
* layout consumes per-dot radii so sized swarms stay collision-free.
|
|
54
|
+
*/
|
|
55
|
+
export function computeBeeswarmMarks(
|
|
56
|
+
spec: NormalizedChartSpec,
|
|
57
|
+
scales: ResolvedScales,
|
|
58
|
+
chartArea: Rect,
|
|
59
|
+
_strategy: LayoutStrategy,
|
|
60
|
+
): PointMark[] {
|
|
61
|
+
const encoding = spec.encoding as Encoding;
|
|
62
|
+
|
|
63
|
+
// Orientation: prefer a quantitative x (horizontal swarm), else quantitative y.
|
|
64
|
+
const isHorizontal = encoding.x?.type === 'quantitative';
|
|
65
|
+
const valueChannel = isHorizontal ? encoding.x : encoding.y;
|
|
66
|
+
const laneChannel = isHorizontal ? encoding.y : encoding.x;
|
|
67
|
+
const valueScale = isHorizontal ? scales.x : scales.y;
|
|
68
|
+
const laneScale = isHorizontal ? scales.y : scales.x;
|
|
69
|
+
|
|
70
|
+
if (!valueChannel || valueChannel.type !== 'quantitative' || !valueScale) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const positionScale = valueScale.scale as ScaleLinear<number, number>;
|
|
75
|
+
|
|
76
|
+
// Lane centers: band scale when the nominal channel is present, else the
|
|
77
|
+
// middle of the cross axis. The cross axis itself has no scale: dodge
|
|
78
|
+
// offsets are pixel-space around each lane center.
|
|
79
|
+
const bandScale =
|
|
80
|
+
laneScale && laneScale.type === 'band' ? (laneScale.scale as ScaleBand<string>) : undefined;
|
|
81
|
+
const defaultLaneCenter = isHorizontal
|
|
82
|
+
? chartArea.y + chartArea.height / 2
|
|
83
|
+
: chartArea.x + chartArea.width / 2;
|
|
84
|
+
|
|
85
|
+
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
86
|
+
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
87
|
+
const colorField = colorEnc?.field;
|
|
88
|
+
const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
89
|
+
const sizeField = sizeEnc?.field;
|
|
90
|
+
|
|
91
|
+
// Sized dots: sqrt (area-proportional), same override surface as scatter.
|
|
92
|
+
// Shared builder so the marks and any size legend resolve one scale.
|
|
93
|
+
const resolvedSize = buildSizeScale(sizeEnc, spec.data, SIZE_SCALE_DEFAULTS.beeswarm);
|
|
94
|
+
const sizeScale = resolvedSize?.scale;
|
|
95
|
+
|
|
96
|
+
// Resolve every renderable dot first: dodge needs the full position/radius
|
|
97
|
+
// arrays before any mark can be placed.
|
|
98
|
+
interface ResolvedDot {
|
|
99
|
+
row: Record<string, unknown>;
|
|
100
|
+
value: number;
|
|
101
|
+
position: number;
|
|
102
|
+
radius: number;
|
|
103
|
+
lane: string;
|
|
104
|
+
laneCenter: number;
|
|
105
|
+
}
|
|
106
|
+
const dots: ResolvedDot[] = [];
|
|
107
|
+
|
|
108
|
+
for (const row of spec.data) {
|
|
109
|
+
const raw = row[valueChannel.field];
|
|
110
|
+
// Drop null/undefined explicitly: the validator tolerates them (it only
|
|
111
|
+
// rejects non-numeric or non-finite present values), but Number(null) is 0,
|
|
112
|
+
// which would silently place a phantom dot at value 0 instead of skipping.
|
|
113
|
+
if (raw == null) continue;
|
|
114
|
+
const value = Number(raw);
|
|
115
|
+
if (!Number.isFinite(value)) continue;
|
|
116
|
+
|
|
117
|
+
const position = positionScale(value);
|
|
118
|
+
if (!Number.isFinite(position)) continue;
|
|
119
|
+
|
|
120
|
+
let lane = '__default__';
|
|
121
|
+
let laneCenter = defaultLaneCenter;
|
|
122
|
+
if (bandScale && laneChannel) {
|
|
123
|
+
lane = String(row[laneChannel.field] ?? '');
|
|
124
|
+
const bandStart = bandScale(lane);
|
|
125
|
+
if (bandStart === undefined) continue;
|
|
126
|
+
laneCenter = bandStart + bandScale.bandwidth() / 2;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let radius = DEFAULT_DOT_RADIUS;
|
|
130
|
+
if (sizeScale && sizeField) {
|
|
131
|
+
const sizeVal = Number(row[sizeField]);
|
|
132
|
+
if (Number.isFinite(sizeVal)) {
|
|
133
|
+
radius = sizeScale(sizeVal);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
dots.push({ row: row as Record<string, unknown>, value, position, radius, lane, laneCenter });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const offsets = dodgeOffsets(
|
|
141
|
+
dots.map((d) => d.position),
|
|
142
|
+
dots.map((d) => d.radius),
|
|
143
|
+
dots.map((d) => d.lane),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
|
|
147
|
+
const keyField = keyEnc?.field;
|
|
148
|
+
const marks: PointMark[] = [];
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < dots.length; i++) {
|
|
151
|
+
const dot = dots[i];
|
|
152
|
+
const cross = dot.laneCenter + offsets[i];
|
|
153
|
+
|
|
154
|
+
let color: string | GradientDef;
|
|
155
|
+
if (isSequentialColor && colorField) {
|
|
156
|
+
const colorVal = Number(dot.row[colorField]);
|
|
157
|
+
color = Number.isFinite(colorVal)
|
|
158
|
+
? getSequentialColor(scales, colorVal)
|
|
159
|
+
: getColor(scales, '__default__');
|
|
160
|
+
} else if (colorField) {
|
|
161
|
+
color = getColor(scales, String(dot.row[colorField] ?? '__default__'));
|
|
162
|
+
} else {
|
|
163
|
+
color = getColor(scales, '__default__');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const labelParts = [`${valueChannel.field}=${dot.row[valueChannel.field]}`];
|
|
167
|
+
if (laneChannel && dot.lane !== '__default__') {
|
|
168
|
+
labelParts.push(`${laneChannel.field}=${dot.lane}`);
|
|
169
|
+
}
|
|
170
|
+
if (sizeField && dot.row[sizeField] != null) {
|
|
171
|
+
labelParts.push(`${sizeField}=${dot.row[sizeField]}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const aria: MarkAria = {
|
|
175
|
+
label: `Data point: ${labelParts.join(', ')}`,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
marks.push({
|
|
179
|
+
type: 'point',
|
|
180
|
+
cx: isHorizontal ? dot.position : cross,
|
|
181
|
+
cy: isHorizontal ? cross : dot.position,
|
|
182
|
+
r: dot.radius,
|
|
183
|
+
fill: color,
|
|
184
|
+
stroke: '#ffffff',
|
|
185
|
+
strokeWidth: 1,
|
|
186
|
+
data: dot.row,
|
|
187
|
+
aria,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Stamp keys: encoding.key field when set, else lane|value composite
|
|
192
|
+
const rawKeys = marks.map((m, i) => {
|
|
193
|
+
if (keyField) return serializeKeyValue(m.data[keyField]);
|
|
194
|
+
const valueKey = serializeKeyValue(m.data[valueChannel.field]);
|
|
195
|
+
return dots[i].lane !== '__default__' ? `${dots[i].lane}|${valueKey}` : valueKey;
|
|
196
|
+
});
|
|
197
|
+
const keys = dedupeKeys(rawKeys);
|
|
198
|
+
for (let i = 0; i < marks.length; i++) {
|
|
199
|
+
marks[i].key = keys[i];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return marks;
|
|
203
|
+
}
|