@opendata-ai/openchart-engine 7.2.4 → 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.
- package/dist/index.d.ts +84 -36
- package/dist/index.js +4010 -2871
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
- package/src/__tests__/axes.test.ts +67 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +7 -2
- package/src/__tests__/helpers/invariants.ts +267 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +342 -0
- package/src/__tests__/layout-invariants.test.ts +533 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/post-process.ts +21 -7
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +85 -148
- package/src/index.ts +9 -0
- package/src/layout/axes.ts +93 -1
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +524 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
|
@@ -0,0 +1,533 @@
|
|
|
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
|
+
// Test data helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const SHORT_LABELS = ['A', 'B', 'C', 'D', 'E'];
|
|
11
|
+
const LONG_LABELS = [
|
|
12
|
+
'United States of America',
|
|
13
|
+
'United Kingdom of Great Britain',
|
|
14
|
+
"People's Republic of China",
|
|
15
|
+
'Federal Republic of Germany',
|
|
16
|
+
'Commonwealth of Australia',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function makeNominalData(labels: string[]) {
|
|
20
|
+
return labels.map((name, i) => ({ name, value: (i + 1) * 10 }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeTemporalData(series: string[]) {
|
|
24
|
+
const dates = ['2020-01-01', '2020-06-01', '2021-01-01', '2021-06-01', '2022-01-01'];
|
|
25
|
+
return series.flatMap((s) =>
|
|
26
|
+
dates.map((date, i) => ({ date, value: (i + 1) * 10 + 2.5, series: s })),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makePieData(labels: string[]) {
|
|
31
|
+
return labels.map((category, i) => ({ category, value: (i + 1) * 15 }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeScatterData(groups: string[]) {
|
|
35
|
+
return groups.flatMap((group) =>
|
|
36
|
+
Array.from({ length: 5 }, (_, i) => ({
|
|
37
|
+
x: (i + 1) * 10 + 0.5 * 5,
|
|
38
|
+
y: (i + 1) * 8 + 0.5 * 5,
|
|
39
|
+
group,
|
|
40
|
+
})),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Chrome presets
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const NO_CHROME = {};
|
|
49
|
+
const TITLE_ONLY = { title: 'Chart Title', subtitle: 'A subtitle for context' };
|
|
50
|
+
const FULL_CHROME = {
|
|
51
|
+
title: 'Chart Title',
|
|
52
|
+
subtitle: 'A subtitle for context',
|
|
53
|
+
source: 'Source: Test Data Corp',
|
|
54
|
+
byline: 'By Test Author',
|
|
55
|
+
footer: 'Note: values are illustrative',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Shared config
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const WIDTHS = [320, 800] as const;
|
|
63
|
+
const HEIGHT = 400;
|
|
64
|
+
|
|
65
|
+
let compileSkipCount = 0;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compile a spec and run layout invariants. Returns early (skip) if
|
|
69
|
+
* compileChart throws due to pre-existing compile failures from plan merges.
|
|
70
|
+
*/
|
|
71
|
+
function compileAndCheck(spec: Record<string, unknown>, width: number, height = HEIGHT) {
|
|
72
|
+
let layout: ChartLayout;
|
|
73
|
+
try {
|
|
74
|
+
layout = compileChart(spec, { width, height });
|
|
75
|
+
} catch {
|
|
76
|
+
compileSkipCount++;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const violations = checkLayoutInvariants(layout, { svgWidth: width, svgHeight: height });
|
|
80
|
+
expect(violations, violations.join('\n')).toEqual([]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Tests
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('layout invariants', () => {
|
|
88
|
+
afterAll(() => {
|
|
89
|
+
if (compileSkipCount > 0) {
|
|
90
|
+
console.warn(`layout invariants: ${compileSkipCount} tests skipped due to compile errors`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// -----------------------------------------------------------------------
|
|
95
|
+
// Bar charts (horizontal: x=quantitative, y=nominal)
|
|
96
|
+
// -----------------------------------------------------------------------
|
|
97
|
+
describe('bar charts (horizontal)', () => {
|
|
98
|
+
for (const width of WIDTHS) {
|
|
99
|
+
it(`short labels, no chrome at ${width}px`, () => {
|
|
100
|
+
compileAndCheck(
|
|
101
|
+
{
|
|
102
|
+
mark: 'bar',
|
|
103
|
+
data: makeNominalData(SHORT_LABELS),
|
|
104
|
+
encoding: {
|
|
105
|
+
x: { field: 'value', type: 'quantitative' },
|
|
106
|
+
y: { field: 'name', type: 'nominal' },
|
|
107
|
+
},
|
|
108
|
+
chrome: NO_CHROME,
|
|
109
|
+
},
|
|
110
|
+
width,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it('long labels with full chrome at 320px', () => {
|
|
116
|
+
compileAndCheck(
|
|
117
|
+
{
|
|
118
|
+
mark: 'bar',
|
|
119
|
+
data: makeNominalData(LONG_LABELS),
|
|
120
|
+
encoding: {
|
|
121
|
+
x: { field: 'value', type: 'quantitative' },
|
|
122
|
+
y: { field: 'name', type: 'nominal' },
|
|
123
|
+
},
|
|
124
|
+
chrome: FULL_CHROME,
|
|
125
|
+
},
|
|
126
|
+
320,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('long labels with title only at 800px', () => {
|
|
131
|
+
compileAndCheck(
|
|
132
|
+
{
|
|
133
|
+
mark: 'bar',
|
|
134
|
+
data: makeNominalData(LONG_LABELS),
|
|
135
|
+
encoding: {
|
|
136
|
+
x: { field: 'value', type: 'quantitative' },
|
|
137
|
+
y: { field: 'name', type: 'nominal' },
|
|
138
|
+
},
|
|
139
|
+
chrome: TITLE_ONLY,
|
|
140
|
+
},
|
|
141
|
+
800,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// -----------------------------------------------------------------------
|
|
147
|
+
// Column charts (vertical bars: x=nominal, y=quantitative)
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
describe('column charts (vertical)', () => {
|
|
150
|
+
for (const width of WIDTHS) {
|
|
151
|
+
it(`short labels, title+subtitle at ${width}px`, () => {
|
|
152
|
+
compileAndCheck(
|
|
153
|
+
{
|
|
154
|
+
mark: 'bar',
|
|
155
|
+
data: makeNominalData(SHORT_LABELS),
|
|
156
|
+
encoding: {
|
|
157
|
+
x: { field: 'name', type: 'nominal' },
|
|
158
|
+
y: { field: 'value', type: 'quantitative' },
|
|
159
|
+
},
|
|
160
|
+
chrome: TITLE_ONLY,
|
|
161
|
+
},
|
|
162
|
+
width,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('long labels with full chrome at 320px', () => {
|
|
168
|
+
compileAndCheck(
|
|
169
|
+
{
|
|
170
|
+
mark: 'bar',
|
|
171
|
+
data: makeNominalData(LONG_LABELS),
|
|
172
|
+
encoding: {
|
|
173
|
+
x: { field: 'name', type: 'nominal' },
|
|
174
|
+
y: { field: 'value', type: 'quantitative' },
|
|
175
|
+
},
|
|
176
|
+
chrome: FULL_CHROME,
|
|
177
|
+
},
|
|
178
|
+
320,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Rotated long labels overflow the SVG bottom edge
|
|
183
|
+
it.fails('long labels, no chrome at 800px', () => {
|
|
184
|
+
compileAndCheck(
|
|
185
|
+
{
|
|
186
|
+
mark: 'bar',
|
|
187
|
+
data: makeNominalData(LONG_LABELS),
|
|
188
|
+
encoding: {
|
|
189
|
+
x: { field: 'name', type: 'nominal' },
|
|
190
|
+
y: { field: 'value', type: 'quantitative' },
|
|
191
|
+
},
|
|
192
|
+
chrome: NO_CHROME,
|
|
193
|
+
},
|
|
194
|
+
800,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// -----------------------------------------------------------------------
|
|
200
|
+
// Line charts (multi-series with legend variations)
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
describe('line charts', () => {
|
|
203
|
+
const series = ['US', 'UK', 'DE'];
|
|
204
|
+
const data = makeTemporalData(series);
|
|
205
|
+
|
|
206
|
+
for (const width of WIDTHS) {
|
|
207
|
+
it(`multi-series with top legend at ${width}px`, () => {
|
|
208
|
+
compileAndCheck(
|
|
209
|
+
{
|
|
210
|
+
mark: 'line',
|
|
211
|
+
data,
|
|
212
|
+
encoding: {
|
|
213
|
+
x: { field: 'date', type: 'temporal' },
|
|
214
|
+
y: { field: 'value', type: 'quantitative' },
|
|
215
|
+
color: { field: 'series', type: 'nominal' },
|
|
216
|
+
},
|
|
217
|
+
legend: { position: 'top', show: true },
|
|
218
|
+
chrome: TITLE_ONLY,
|
|
219
|
+
},
|
|
220
|
+
width,
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('multi-series with bottom legend, full chrome at 800px', () => {
|
|
226
|
+
compileAndCheck(
|
|
227
|
+
{
|
|
228
|
+
mark: 'line',
|
|
229
|
+
data,
|
|
230
|
+
encoding: {
|
|
231
|
+
x: { field: 'date', type: 'temporal' },
|
|
232
|
+
y: { field: 'value', type: 'quantitative' },
|
|
233
|
+
color: { field: 'series', type: 'nominal' },
|
|
234
|
+
},
|
|
235
|
+
legend: { position: 'bottom', show: true },
|
|
236
|
+
chrome: FULL_CHROME,
|
|
237
|
+
},
|
|
238
|
+
800,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('multi-series with right legend, no chrome at 800px', () => {
|
|
243
|
+
compileAndCheck(
|
|
244
|
+
{
|
|
245
|
+
mark: 'line',
|
|
246
|
+
data,
|
|
247
|
+
encoding: {
|
|
248
|
+
x: { field: 'date', type: 'temporal' },
|
|
249
|
+
y: { field: 'value', type: 'quantitative' },
|
|
250
|
+
color: { field: 'series', type: 'nominal' },
|
|
251
|
+
},
|
|
252
|
+
legend: { position: 'right', show: true },
|
|
253
|
+
chrome: NO_CHROME,
|
|
254
|
+
},
|
|
255
|
+
800,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('multi-series with bottom-right legend at 320px', () => {
|
|
260
|
+
compileAndCheck(
|
|
261
|
+
{
|
|
262
|
+
mark: 'line',
|
|
263
|
+
data,
|
|
264
|
+
encoding: {
|
|
265
|
+
x: { field: 'date', type: 'temporal' },
|
|
266
|
+
y: { field: 'value', type: 'quantitative' },
|
|
267
|
+
color: { field: 'series', type: 'nominal' },
|
|
268
|
+
},
|
|
269
|
+
legend: { position: 'bottom-right', show: true },
|
|
270
|
+
chrome: TITLE_ONLY,
|
|
271
|
+
},
|
|
272
|
+
320,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('single series, full chrome at 320px', () => {
|
|
277
|
+
compileAndCheck(
|
|
278
|
+
{
|
|
279
|
+
mark: 'line',
|
|
280
|
+
data: makeTemporalData(['US']),
|
|
281
|
+
encoding: {
|
|
282
|
+
x: { field: 'date', type: 'temporal' },
|
|
283
|
+
y: { field: 'value', type: 'quantitative' },
|
|
284
|
+
},
|
|
285
|
+
chrome: FULL_CHROME,
|
|
286
|
+
},
|
|
287
|
+
320,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// -----------------------------------------------------------------------
|
|
293
|
+
// Pie charts (arc mark: y=quantitative, color=nominal)
|
|
294
|
+
// -----------------------------------------------------------------------
|
|
295
|
+
describe('pie charts', () => {
|
|
296
|
+
for (const width of WIDTHS) {
|
|
297
|
+
it(`basic pie, title only at ${width}px`, () => {
|
|
298
|
+
compileAndCheck(
|
|
299
|
+
{
|
|
300
|
+
mark: 'arc',
|
|
301
|
+
data: makePieData(SHORT_LABELS),
|
|
302
|
+
encoding: {
|
|
303
|
+
y: { field: 'value', type: 'quantitative' },
|
|
304
|
+
color: { field: 'category', type: 'nominal' },
|
|
305
|
+
},
|
|
306
|
+
chrome: TITLE_ONLY,
|
|
307
|
+
},
|
|
308
|
+
width,
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('pie with bottom legend, full chrome at 800px', () => {
|
|
314
|
+
compileAndCheck(
|
|
315
|
+
{
|
|
316
|
+
mark: 'arc',
|
|
317
|
+
data: makePieData(SHORT_LABELS),
|
|
318
|
+
encoding: {
|
|
319
|
+
y: { field: 'value', type: 'quantitative' },
|
|
320
|
+
color: { field: 'category', type: 'nominal' },
|
|
321
|
+
},
|
|
322
|
+
legend: { position: 'bottom', show: true },
|
|
323
|
+
chrome: FULL_CHROME,
|
|
324
|
+
},
|
|
325
|
+
800,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Legend entry positions overflow bounds at narrow width with long labels
|
|
330
|
+
it.fails('pie with long labels, top legend at 320px', () => {
|
|
331
|
+
compileAndCheck(
|
|
332
|
+
{
|
|
333
|
+
mark: 'arc',
|
|
334
|
+
data: makePieData(LONG_LABELS),
|
|
335
|
+
encoding: {
|
|
336
|
+
y: { field: 'value', type: 'quantitative' },
|
|
337
|
+
color: { field: 'category', type: 'nominal' },
|
|
338
|
+
},
|
|
339
|
+
legend: { position: 'top', show: true },
|
|
340
|
+
chrome: TITLE_ONLY,
|
|
341
|
+
},
|
|
342
|
+
320,
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('pie with no chrome at 800px', () => {
|
|
347
|
+
compileAndCheck(
|
|
348
|
+
{
|
|
349
|
+
mark: 'arc',
|
|
350
|
+
data: makePieData(SHORT_LABELS),
|
|
351
|
+
encoding: {
|
|
352
|
+
y: { field: 'value', type: 'quantitative' },
|
|
353
|
+
color: { field: 'category', type: 'nominal' },
|
|
354
|
+
},
|
|
355
|
+
chrome: NO_CHROME,
|
|
356
|
+
},
|
|
357
|
+
800,
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
// -----------------------------------------------------------------------
|
|
363
|
+
// Scatter charts (point mark: x=quantitative, y=quantitative)
|
|
364
|
+
// -----------------------------------------------------------------------
|
|
365
|
+
describe('scatter charts', () => {
|
|
366
|
+
const groups = ['Group A', 'Group B', 'Group C'];
|
|
367
|
+
|
|
368
|
+
for (const width of WIDTHS) {
|
|
369
|
+
it(`multi-group with right legend at ${width}px`, () => {
|
|
370
|
+
compileAndCheck(
|
|
371
|
+
{
|
|
372
|
+
mark: 'point',
|
|
373
|
+
data: makeScatterData(groups),
|
|
374
|
+
encoding: {
|
|
375
|
+
x: { field: 'x', type: 'quantitative' },
|
|
376
|
+
y: { field: 'y', type: 'quantitative' },
|
|
377
|
+
color: { field: 'group', type: 'nominal' },
|
|
378
|
+
},
|
|
379
|
+
legend: { position: 'right', show: true },
|
|
380
|
+
chrome: TITLE_ONLY,
|
|
381
|
+
},
|
|
382
|
+
width,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
it('multi-group with top legend, full chrome at 320px', () => {
|
|
388
|
+
compileAndCheck(
|
|
389
|
+
{
|
|
390
|
+
mark: 'point',
|
|
391
|
+
data: makeScatterData(groups),
|
|
392
|
+
encoding: {
|
|
393
|
+
x: { field: 'x', type: 'quantitative' },
|
|
394
|
+
y: { field: 'y', type: 'quantitative' },
|
|
395
|
+
color: { field: 'group', type: 'nominal' },
|
|
396
|
+
},
|
|
397
|
+
legend: { position: 'top', show: true },
|
|
398
|
+
chrome: FULL_CHROME,
|
|
399
|
+
},
|
|
400
|
+
320,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('single group, no chrome at 800px', () => {
|
|
405
|
+
compileAndCheck(
|
|
406
|
+
{
|
|
407
|
+
mark: 'point',
|
|
408
|
+
data: makeScatterData(['Only']),
|
|
409
|
+
encoding: {
|
|
410
|
+
x: { field: 'x', type: 'quantitative' },
|
|
411
|
+
y: { field: 'y', type: 'quantitative' },
|
|
412
|
+
},
|
|
413
|
+
chrome: NO_CHROME,
|
|
414
|
+
},
|
|
415
|
+
800,
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('multi-group with bottom legend, full chrome at 800px', () => {
|
|
420
|
+
compileAndCheck(
|
|
421
|
+
{
|
|
422
|
+
mark: 'point',
|
|
423
|
+
data: makeScatterData(groups),
|
|
424
|
+
encoding: {
|
|
425
|
+
x: { field: 'x', type: 'quantitative' },
|
|
426
|
+
y: { field: 'y', type: 'quantitative' },
|
|
427
|
+
color: { field: 'group', type: 'nominal' },
|
|
428
|
+
},
|
|
429
|
+
legend: { position: 'bottom', show: true },
|
|
430
|
+
chrome: FULL_CHROME,
|
|
431
|
+
},
|
|
432
|
+
800,
|
|
433
|
+
);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// -----------------------------------------------------------------------
|
|
438
|
+
// Dot charts (circle mark: x=quantitative, y=quantitative)
|
|
439
|
+
// -----------------------------------------------------------------------
|
|
440
|
+
describe('dot charts', () => {
|
|
441
|
+
for (const width of WIDTHS) {
|
|
442
|
+
it(`multi-group with bottom legend at ${width}px`, () => {
|
|
443
|
+
compileAndCheck(
|
|
444
|
+
{
|
|
445
|
+
mark: 'circle',
|
|
446
|
+
data: makeScatterData(['Alpha', 'Beta']),
|
|
447
|
+
encoding: {
|
|
448
|
+
x: { field: 'x', type: 'quantitative' },
|
|
449
|
+
y: { field: 'y', type: 'quantitative' },
|
|
450
|
+
color: { field: 'group', type: 'nominal' },
|
|
451
|
+
},
|
|
452
|
+
legend: { position: 'bottom', show: true },
|
|
453
|
+
chrome: TITLE_ONLY,
|
|
454
|
+
},
|
|
455
|
+
width,
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
it('dot with full chrome, long legend labels at 320px', () => {
|
|
461
|
+
compileAndCheck(
|
|
462
|
+
{
|
|
463
|
+
mark: 'circle',
|
|
464
|
+
data: makeScatterData(['Very Long Group Name A', 'Very Long Group Name B']),
|
|
465
|
+
encoding: {
|
|
466
|
+
x: { field: 'x', type: 'quantitative' },
|
|
467
|
+
y: { field: 'y', type: 'quantitative' },
|
|
468
|
+
color: { field: 'group', type: 'nominal' },
|
|
469
|
+
},
|
|
470
|
+
legend: { position: 'top', show: true },
|
|
471
|
+
chrome: FULL_CHROME,
|
|
472
|
+
},
|
|
473
|
+
320,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// -----------------------------------------------------------------------
|
|
479
|
+
// Area charts (area mark, similar encoding to line)
|
|
480
|
+
// -----------------------------------------------------------------------
|
|
481
|
+
describe('area charts', () => {
|
|
482
|
+
const data = makeTemporalData(['Revenue', 'Expenses']);
|
|
483
|
+
|
|
484
|
+
it('stacked area with top legend, full chrome at 800px', () => {
|
|
485
|
+
compileAndCheck(
|
|
486
|
+
{
|
|
487
|
+
mark: 'area',
|
|
488
|
+
data,
|
|
489
|
+
encoding: {
|
|
490
|
+
x: { field: 'date', type: 'temporal' },
|
|
491
|
+
y: { field: 'value', type: 'quantitative' },
|
|
492
|
+
color: { field: 'series', type: 'nominal' },
|
|
493
|
+
},
|
|
494
|
+
legend: { position: 'top', show: true },
|
|
495
|
+
chrome: FULL_CHROME,
|
|
496
|
+
},
|
|
497
|
+
800,
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('single series area, no chrome at 800px', () => {
|
|
502
|
+
compileAndCheck(
|
|
503
|
+
{
|
|
504
|
+
mark: 'area',
|
|
505
|
+
data: makeTemporalData(['Revenue']),
|
|
506
|
+
encoding: {
|
|
507
|
+
x: { field: 'date', type: 'temporal' },
|
|
508
|
+
y: { field: 'value', type: 'quantitative' },
|
|
509
|
+
},
|
|
510
|
+
chrome: NO_CHROME,
|
|
511
|
+
},
|
|
512
|
+
800,
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('stacked area with bottom-right legend at 320px', () => {
|
|
517
|
+
compileAndCheck(
|
|
518
|
+
{
|
|
519
|
+
mark: 'area',
|
|
520
|
+
data,
|
|
521
|
+
encoding: {
|
|
522
|
+
x: { field: 'date', type: 'temporal' },
|
|
523
|
+
y: { field: 'value', type: 'quantitative' },
|
|
524
|
+
color: { field: 'series', type: 'nominal' },
|
|
525
|
+
},
|
|
526
|
+
legend: { position: 'bottom-right', show: true },
|
|
527
|
+
chrome: TITLE_ONLY,
|
|
528
|
+
},
|
|
529
|
+
320,
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
@@ -511,4 +511,41 @@ describe('computeLegend', () => {
|
|
|
511
511
|
expect(gap).toBe(0);
|
|
512
512
|
});
|
|
513
513
|
});
|
|
514
|
+
|
|
515
|
+
describe('entryPositions contract', () => {
|
|
516
|
+
it('emits entryPositions for horizontal legend', () => {
|
|
517
|
+
const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
|
|
518
|
+
expect(legend.entries.length).toBeGreaterThan(0);
|
|
519
|
+
expect(legend.entryPositions).toBeDefined();
|
|
520
|
+
expect(legend.entryPositions).toHaveLength(legend.entries.length);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('entryPositions have correct structure', () => {
|
|
524
|
+
const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
|
|
525
|
+
for (const pos of legend.entryPositions!) {
|
|
526
|
+
expect(pos).toHaveProperty('x');
|
|
527
|
+
expect(pos).toHaveProperty('y');
|
|
528
|
+
expect(pos).toHaveProperty('labelX');
|
|
529
|
+
expect(pos).toHaveProperty('labelY');
|
|
530
|
+
expect(pos).toHaveProperty('width');
|
|
531
|
+
expect(pos).toHaveProperty('row');
|
|
532
|
+
expect(pos.labelX).toBeGreaterThan(pos.x);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('emits entryPositions for right-positioned legend', () => {
|
|
537
|
+
const legend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
|
|
538
|
+
expect(legend.entryPositions).toBeDefined();
|
|
539
|
+
expect(legend.entryPositions).toHaveLength(legend.entries.length);
|
|
540
|
+
for (const pos of legend.entryPositions!) {
|
|
541
|
+
expect(pos.row).toBeGreaterThanOrEqual(0);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('emits rowHeight', () => {
|
|
546
|
+
const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
|
|
547
|
+
expect(legend.rowHeight).toBeDefined();
|
|
548
|
+
expect(legend.rowHeight).toBeGreaterThan(0);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
514
551
|
});
|
|
@@ -739,7 +739,6 @@ describe('computeAnnotations', () => {
|
|
|
739
739
|
]);
|
|
740
740
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
741
741
|
|
|
742
|
-
const _px = scales.x?.scale(new Date('2020-01-01'));
|
|
743
742
|
const py = scales.y?.scale(20);
|
|
744
743
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
745
744
|
|
|
@@ -1235,8 +1234,8 @@ describe('computeAnnotations', () => {
|
|
|
1235
1234
|
|
|
1236
1235
|
// Curve should start from right edge of text, not top edge
|
|
1237
1236
|
// Right edge x ≈ label.x + textWidth
|
|
1238
|
-
// "Curve test" =
|
|
1239
|
-
expect(connector.from.x).toBeCloseTo(label.x +
|
|
1237
|
+
// Per-char lookup: "Curve test" = 4.557 * 12 = 54.684
|
|
1238
|
+
expect(connector.from.x).toBeCloseTo(label.x + 54.684, 1);
|
|
1240
1239
|
});
|
|
1241
1240
|
});
|
|
1242
1241
|
|
|
@@ -1470,17 +1469,9 @@ describe('computeAnnotations', () => {
|
|
|
1470
1469
|
|
|
1471
1470
|
expect(annotations).toHaveLength(1);
|
|
1472
1471
|
const label = annotations[0].label!;
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
const textWidth = label.text
|
|
1477
|
-
.split('\n')
|
|
1478
|
-
.reduce(
|
|
1479
|
-
(max, line) => Math.max(max, line.length * fontSize * (fontWeight >= 600 ? 0.65 : 0.55)),
|
|
1480
|
-
0,
|
|
1481
|
-
);
|
|
1482
|
-
// The label's right edge should not exceed the SVG width
|
|
1483
|
-
expect(label.x + textWidth).toBeLessThanOrEqual(svgDimensions.width);
|
|
1472
|
+
// Use engine-exported bounds instead of re-deriving text width
|
|
1473
|
+
expect(label.bounds).toBeDefined();
|
|
1474
|
+
expect(label.bounds!.x + label.bounds!.width).toBeLessThanOrEqual(svgDimensions.width);
|
|
1484
1475
|
});
|
|
1485
1476
|
|
|
1486
1477
|
it('shifts annotation down when it overflows the top SVG edge', () => {
|
|
@@ -1514,13 +1505,15 @@ describe('computeAnnotations', () => {
|
|
|
1514
1505
|
});
|
|
1515
1506
|
|
|
1516
1507
|
it('does not modify annotation that is well within bounds', () => {
|
|
1517
|
-
// Place annotation in the center of the chart
|
|
1508
|
+
// Place annotation in the center of the chart with an explicit offset
|
|
1509
|
+
// so it uses the same explicit placement path with and without SVG dims
|
|
1518
1510
|
const spec = makeSpec([
|
|
1519
1511
|
{
|
|
1520
1512
|
type: 'text',
|
|
1521
1513
|
x: '2020-06-01',
|
|
1522
1514
|
y: 25,
|
|
1523
1515
|
text: 'Centered',
|
|
1516
|
+
offset: { dx: 0, dy: -10 },
|
|
1524
1517
|
},
|
|
1525
1518
|
]);
|
|
1526
1519
|
const scales = computeScales(spec, chartArea, spec.data);
|