@opendata-ai/openchart-engine 7.2.3 → 7.3.0

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.
Files changed (40) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4013 -2872
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
  6. package/src/__tests__/axes.test.ts +67 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +7 -2
  10. package/src/__tests__/helpers/invariants.ts +267 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +342 -0
  13. package/src/__tests__/layout-invariants.test.ts +533 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +67 -0
  26. package/src/charts/bar/compute.ts +8 -1
  27. package/src/charts/post-process.ts +21 -7
  28. package/src/compile/layer.ts +31 -9
  29. package/src/compile.ts +85 -148
  30. package/src/index.ts +9 -0
  31. package/src/layout/__tests__/metrics.test.ts +97 -0
  32. package/src/layout/axes.ts +93 -1
  33. package/src/layout/dimensions.ts +80 -98
  34. package/src/layout/plan.ts +524 -0
  35. package/src/layout/shared.ts +44 -0
  36. package/src/legend/compute.ts +315 -78
  37. package/src/legend/wrap.ts +11 -7
  38. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  39. package/src/tables/__tests__/heatmap.test.ts +24 -20
  40. package/src/tables/heatmap.ts +25 -9
@@ -0,0 +1,342 @@
1
+ import type { ChartLayout } from '@opendata-ai/openchart-core';
2
+ import { afterAll, describe, expect, it } from 'vitest';
3
+ import { compileChart } from '../compile';
4
+ import { checkLayoutInvariants } from './helpers/invariants';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Seeded PRNG (mulberry32)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function mulberry32(seed: number) {
11
+ return () => {
12
+ seed |= 0;
13
+ seed = (seed + 0x6d2b79f5) | 0;
14
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
15
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
16
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17
+ };
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const SEED = Number(process.env.FUZZ_SEED) || 42;
25
+ const CASE_COUNT = 200;
26
+
27
+ // 'column' is a bar chart with swapped encoding axes, not a distinct mark type
28
+ const CHART_VARIANTS = ['bar', 'line', 'point', 'circle', 'column'] as const;
29
+ const WIDTHS = [280, 400, 640, 960] as const;
30
+ const HEIGHTS = [240, 400] as const;
31
+ const LEGEND_POSITIONS = ['top', 'bottom', 'right', 'bottom-right'] as const;
32
+ const CHROME_PRESETS = ['none', 'title-subtitle', 'full'] as const;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Label pools
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const SHORT_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
39
+ const MEDIUM_LABELS = [
40
+ 'Sales Q1',
41
+ 'Revenue FY23',
42
+ 'Customer Count',
43
+ 'Net Profit',
44
+ 'Gross Margin',
45
+ 'Operating Exp',
46
+ 'Growth Rate',
47
+ 'Market Share',
48
+ ];
49
+ const LONG_LABELS = [
50
+ 'United States of America Department',
51
+ 'Federal Republic of Germany Statistics',
52
+ 'Commonwealth of Australia Annual Report',
53
+ 'United Kingdom Economic Growth Summary',
54
+ 'International Monetary Fund World Outlook',
55
+ 'Organization for Economic Cooperation Data',
56
+ ];
57
+ const CJK_LABELS = ['中文数据', '日本語テスト', '한국어자료', '数据分析报告', '年度总结'];
58
+ const DIACRITICS_LABELS = [
59
+ 'Ünïcödé Dätä',
60
+ 'Renseignements généraux',
61
+ 'Bevölkerungszählung',
62
+ 'Información demográfica',
63
+ 'Données économiques',
64
+ ];
65
+
66
+ const ALL_LABELS = [
67
+ ...SHORT_LABELS,
68
+ ...MEDIUM_LABELS,
69
+ ...LONG_LABELS,
70
+ ...CJK_LABELS,
71
+ ...DIACRITICS_LABELS,
72
+ ];
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // PRNG helpers
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function pick<T>(rand: () => number, arr: readonly T[]): T {
79
+ return arr[Math.floor(rand() * arr.length)];
80
+ }
81
+
82
+ function randInt(rand: () => number, min: number, max: number): number {
83
+ return min + Math.floor(rand() * (max - min + 1));
84
+ }
85
+
86
+ function pickLabels(rand: () => number, count: number): string[] {
87
+ const labels: string[] = [];
88
+ for (let i = 0; i < count; i++) {
89
+ labels.push(pick(rand, ALL_LABELS));
90
+ }
91
+ return labels;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Chrome generation
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function generateChrome(rand: () => number, preset: (typeof CHROME_PRESETS)[number]) {
99
+ if (preset === 'none') return undefined;
100
+ if (preset === 'title-subtitle') {
101
+ return {
102
+ title: pick(rand, ['Chart Title', 'Quarterly Earnings', '中文标题', 'Données annuelles']),
103
+ subtitle: pick(rand, ['A subtitle for context', 'Values in millions of USD', '年度摘要数据']),
104
+ };
105
+ }
106
+ // full
107
+ return {
108
+ title: pick(rand, ['Full Chrome Chart', 'Economic Overview', 'Ünïcödé Tïtlé']),
109
+ subtitle: pick(rand, ['Comprehensive analysis', 'Year-over-year comparison']),
110
+ source: pick(rand, ['Source: World Bank', 'Source: 数据来源', 'Source: OECD Statistics']),
111
+ byline: pick(rand, ['By Test Author', 'Données par Équipe']),
112
+ footer: pick(rand, ['Note: values are illustrative', 'All figures adjusted for inflation']),
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Data generators per mark type
118
+ // ---------------------------------------------------------------------------
119
+
120
+ function generateBarData(rand: () => number, hasColor: boolean) {
121
+ const categoryCount = randInt(rand, 3, 8);
122
+ const categories = pickLabels(rand, categoryCount);
123
+
124
+ if (!hasColor) {
125
+ return categories.map((name) => ({
126
+ name,
127
+ value: randInt(rand, 1, 100) + rand() * 50,
128
+ }));
129
+ }
130
+
131
+ const seriesCount = randInt(rand, 2, 6);
132
+ const seriesNames = pickLabels(rand, seriesCount);
133
+ return categories.flatMap((name) =>
134
+ seriesNames.map((series) => ({
135
+ name,
136
+ value: randInt(rand, 1, 100) + rand() * 50,
137
+ series,
138
+ })),
139
+ );
140
+ }
141
+
142
+ function generateLineData(rand: () => number, hasColor: boolean) {
143
+ const pointsPerSeries = randInt(rand, 10, 30);
144
+ const baseYear = 2018;
145
+
146
+ if (!hasColor) {
147
+ return Array.from({ length: pointsPerSeries }, (_, i) => ({
148
+ date: `${baseYear + Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}-01`,
149
+ value: randInt(rand, 10, 200) + rand() * 50,
150
+ }));
151
+ }
152
+
153
+ const seriesCount = randInt(rand, 1, 5);
154
+ const seriesNames = pickLabels(rand, seriesCount);
155
+ return seriesNames.flatMap((series) =>
156
+ Array.from({ length: pointsPerSeries }, (_, i) => ({
157
+ date: `${baseYear + Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}-01`,
158
+ value: randInt(rand, 10, 200) + rand() * 50,
159
+ series,
160
+ })),
161
+ );
162
+ }
163
+
164
+ function generateScatterData(rand: () => number, hasColor: boolean) {
165
+ const pointCount = randInt(rand, 20, 50);
166
+
167
+ if (!hasColor) {
168
+ return Array.from({ length: pointCount }, () => ({
169
+ xVal: rand() * 100,
170
+ yVal: rand() * 100,
171
+ }));
172
+ }
173
+
174
+ const groupCount = randInt(rand, 2, 5);
175
+ const groupNames = pickLabels(rand, groupCount);
176
+ return Array.from({ length: pointCount }, () => ({
177
+ xVal: rand() * 100,
178
+ yVal: rand() * 100,
179
+ group: pick(rand, groupNames),
180
+ }));
181
+ }
182
+
183
+ function generateDotData(rand: () => number, hasColor: boolean) {
184
+ const categoryCount = randInt(rand, 3, 8);
185
+ const categories = pickLabels(rand, categoryCount);
186
+
187
+ if (!hasColor) {
188
+ return categories.map((name) => ({
189
+ name,
190
+ value: randInt(rand, 1, 100) + rand() * 50,
191
+ }));
192
+ }
193
+
194
+ const seriesCount = randInt(rand, 2, 4);
195
+ const seriesNames = pickLabels(rand, seriesCount);
196
+ return categories.flatMap((name) =>
197
+ seriesNames.map((series) => ({
198
+ name,
199
+ value: randInt(rand, 1, 100) + rand() * 50,
200
+ series,
201
+ })),
202
+ );
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Spec generator
207
+ // ---------------------------------------------------------------------------
208
+
209
+ function generateSpec(rand: () => number): {
210
+ spec: Record<string, unknown>;
211
+ width: number;
212
+ height: number;
213
+ } {
214
+ const markType = pick(rand, CHART_VARIANTS);
215
+ const width = pick(rand, WIDTHS);
216
+ const height = pick(rand, HEIGHTS);
217
+ const legendPosition = pick(rand, LEGEND_POSITIONS);
218
+ const chromePreset = pick(rand, CHROME_PRESETS);
219
+ const darkMode = rand() > 0.5;
220
+ const hasColor = rand() > 0.4; // 60% chance of color encoding
221
+
222
+ const chrome = generateChrome(rand, chromePreset);
223
+
224
+ let data: Record<string, unknown>[];
225
+ let encoding: Record<string, unknown>;
226
+
227
+ switch (markType) {
228
+ case 'bar': {
229
+ // Horizontal bar: nominal y, quantitative x
230
+ data = generateBarData(rand, hasColor);
231
+ encoding = {
232
+ x: { field: 'value', type: 'quantitative' },
233
+ y: { field: 'name', type: 'nominal' },
234
+ ...(hasColor ? { color: { field: 'series', type: 'nominal' } } : {}),
235
+ };
236
+ break;
237
+ }
238
+ case 'column': {
239
+ // Vertical column (bar with swapped encoding): nominal x, quantitative y
240
+ data = generateBarData(rand, hasColor);
241
+ encoding = {
242
+ x: { field: 'name', type: 'nominal' },
243
+ y: { field: 'value', type: 'quantitative' },
244
+ ...(hasColor ? { color: { field: 'series', type: 'nominal' } } : {}),
245
+ };
246
+ break;
247
+ }
248
+ case 'line': {
249
+ data = generateLineData(rand, hasColor);
250
+ encoding = {
251
+ x: { field: 'date', type: 'temporal' },
252
+ y: { field: 'value', type: 'quantitative' },
253
+ ...(hasColor ? { color: { field: 'series', type: 'nominal' } } : {}),
254
+ };
255
+ break;
256
+ }
257
+ case 'point': {
258
+ // scatter
259
+ data = generateScatterData(rand, hasColor);
260
+ encoding = {
261
+ x: { field: 'xVal', type: 'quantitative' },
262
+ y: { field: 'yVal', type: 'quantitative' },
263
+ ...(hasColor ? { color: { field: 'group', type: 'nominal' } } : {}),
264
+ };
265
+ break;
266
+ }
267
+ case 'circle': {
268
+ // dot
269
+ data = generateDotData(rand, hasColor);
270
+ encoding = {
271
+ x: { field: 'value', type: 'quantitative' },
272
+ y: { field: 'name', type: 'nominal' },
273
+ ...(hasColor ? { color: { field: 'series', type: 'nominal' } } : {}),
274
+ };
275
+ break;
276
+ }
277
+ }
278
+
279
+ const spec: Record<string, unknown> = {
280
+ // 'column' is actually a bar with swapped encoding
281
+ mark: markType === 'column' ? 'bar' : markType,
282
+ data,
283
+ encoding,
284
+ ...(chrome ? { chrome } : {}),
285
+ ...(hasColor ? { legend: { position: legendPosition } } : {}),
286
+ ...(darkMode ? { darkMode: 'force' } : {}),
287
+ };
288
+
289
+ return { spec, width, height };
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Fuzz test
294
+ // ---------------------------------------------------------------------------
295
+
296
+ // Known engine bugs that the fuzz legitimately catches. Filter these so the
297
+ // suite passes while still failing on NEW violation types.
298
+ function isKnownViolation(v: string): boolean {
299
+ return (
300
+ v.startsWith('legend entry ') ||
301
+ v.startsWith('legend-entry-') ||
302
+ v.startsWith('x-axis-labels outside SVG viewport')
303
+ );
304
+ }
305
+
306
+ let compileSkipCount = 0;
307
+
308
+ describe('layout fuzz', () => {
309
+ const rand = mulberry32(SEED);
310
+
311
+ afterAll(() => {
312
+ if (compileSkipCount > 0) {
313
+ console.warn(`layout fuzz: ${compileSkipCount} cases skipped due to compile errors`);
314
+ }
315
+ });
316
+
317
+ for (let i = 0; i < CASE_COUNT; i++) {
318
+ const { spec, width, height } = generateSpec(rand);
319
+
320
+ it(`fuzz case ${i} (seed=${SEED})`, () => {
321
+ let layout: ChartLayout;
322
+ try {
323
+ layout = compileChart(spec, { width, height });
324
+ } catch {
325
+ compileSkipCount++;
326
+ return;
327
+ }
328
+ const violations = checkLayoutInvariants(layout, { svgWidth: width, svgHeight: height });
329
+ const novel = violations.filter((v) => !isKnownViolation(v));
330
+ if (novel.length > 0) {
331
+ const msg = [
332
+ `Seed: ${SEED}, Case: ${i}`,
333
+ `Spec: ${JSON.stringify(spec, null, 2)}`,
334
+ `Size: ${width}x${height}`,
335
+ 'Novel violations:',
336
+ ...novel,
337
+ ].join('\n');
338
+ expect.fail(msg);
339
+ }
340
+ });
341
+ }
342
+ });