@opendata-ai/openchart-engine 3.0.0 → 6.1.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 +155 -19
- package/dist/index.js +1513 -164
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -3
- package/src/__tests__/axes.test.ts +168 -4
- package/src/__tests__/compile-chart.test.ts +23 -12
- package/src/__tests__/compile-layer.test.ts +386 -0
- package/src/__tests__/dimensions.test.ts +6 -3
- package/src/__tests__/legend.test.ts +6 -3
- package/src/__tests__/scales.test.ts +176 -2
- package/src/annotations/__tests__/compute.test.ts +8 -4
- package/src/charts/bar/__tests__/compute.test.ts +12 -6
- package/src/charts/bar/compute.ts +21 -5
- package/src/charts/column/__tests__/compute.test.ts +14 -7
- package/src/charts/column/compute.ts +21 -6
- package/src/charts/dot/__tests__/compute.test.ts +10 -5
- package/src/charts/dot/compute.ts +10 -4
- package/src/charts/line/__tests__/compute.test.ts +102 -11
- package/src/charts/line/__tests__/curves.test.ts +51 -0
- package/src/charts/line/__tests__/labels.test.ts +2 -1
- package/src/charts/line/__tests__/mark-options.test.ts +175 -0
- package/src/charts/line/area.ts +19 -8
- package/src/charts/line/compute.ts +64 -25
- package/src/charts/line/curves.ts +40 -0
- package/src/charts/pie/__tests__/compute.test.ts +10 -5
- package/src/charts/pie/compute.ts +2 -1
- package/src/charts/rule/index.ts +127 -0
- package/src/charts/scatter/__tests__/compute.test.ts +10 -5
- package/src/charts/scatter/compute.ts +15 -5
- package/src/charts/text/index.ts +92 -0
- package/src/charts/tick/index.ts +84 -0
- package/src/charts/utils.ts +1 -1
- package/src/compile.ts +175 -23
- package/src/compiler/__tests__/compile.test.ts +4 -4
- package/src/compiler/__tests__/normalize.test.ts +4 -4
- package/src/compiler/__tests__/validate.test.ts +25 -26
- package/src/compiler/index.ts +1 -1
- package/src/compiler/normalize.ts +77 -4
- package/src/compiler/types.ts +6 -2
- package/src/compiler/validate.ts +167 -35
- package/src/graphs/__tests__/compile-graph.test.ts +2 -2
- package/src/graphs/compile-graph.ts +2 -2
- package/src/index.ts +17 -1
- package/src/layout/axes.ts +122 -20
- package/src/layout/dimensions.ts +15 -9
- package/src/layout/scales.ts +320 -31
- package/src/legend/compute.ts +9 -6
- package/src/tables/__tests__/compile-table.test.ts +1 -1
- package/src/tooltips/__tests__/compute.test.ts +10 -5
- package/src/tooltips/compute.ts +32 -14
- package/src/transforms/__tests__/bin.test.ts +88 -0
- package/src/transforms/__tests__/calculate.test.ts +146 -0
- package/src/transforms/__tests__/conditional.test.ts +109 -0
- package/src/transforms/__tests__/filter.test.ts +59 -0
- package/src/transforms/__tests__/index.test.ts +93 -0
- package/src/transforms/__tests__/predicates.test.ts +176 -0
- package/src/transforms/__tests__/timeunit.test.ts +129 -0
- package/src/transforms/bin.ts +87 -0
- package/src/transforms/calculate.ts +60 -0
- package/src/transforms/conditional.ts +46 -0
- package/src/transforms/filter.ts +17 -0
- package/src/transforms/index.ts +48 -0
- package/src/transforms/predicates.ts +90 -0
- package/src/transforms/timeunit.ts +88 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { compileLayer } from '../compile';
|
|
4
|
+
import { flattenLayers } from '../compiler/normalize';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const sharedData = [
|
|
11
|
+
{ month: 'Jan', sales: 100, target: 80, region: 'West' },
|
|
12
|
+
{ month: 'Feb', sales: 150, target: 120, region: 'West' },
|
|
13
|
+
{ month: 'Mar', sales: 130, target: 140, region: 'East' },
|
|
14
|
+
{ month: 'Apr', sales: 200, target: 160, region: 'East' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const compileOpts = { width: 600, height: 400 };
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// flattenLayers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe('flattenLayers', () => {
|
|
24
|
+
it('flattens a simple two-layer spec into two ChartSpecs', () => {
|
|
25
|
+
const spec: LayerSpec = {
|
|
26
|
+
layer: [
|
|
27
|
+
{
|
|
28
|
+
mark: 'bar',
|
|
29
|
+
data: sharedData,
|
|
30
|
+
encoding: {
|
|
31
|
+
x: { field: 'sales', type: 'quantitative' },
|
|
32
|
+
y: { field: 'month', type: 'nominal' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
mark: 'line',
|
|
37
|
+
data: sharedData,
|
|
38
|
+
encoding: {
|
|
39
|
+
x: { field: 'month', type: 'nominal' },
|
|
40
|
+
y: { field: 'target', type: 'quantitative' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const leaves = flattenLayers(spec);
|
|
47
|
+
expect(leaves).toHaveLength(2);
|
|
48
|
+
expect(leaves[0].mark).toBe('bar');
|
|
49
|
+
expect(leaves[1].mark).toBe('line');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('inherits shared data from parent when child has none', () => {
|
|
53
|
+
// When children have data: [], they keep it. But if data is undefined, parent is used.
|
|
54
|
+
const specNoChildData: LayerSpec = {
|
|
55
|
+
data: sharedData,
|
|
56
|
+
layer: [
|
|
57
|
+
{
|
|
58
|
+
mark: 'bar',
|
|
59
|
+
encoding: {
|
|
60
|
+
x: { field: 'sales', type: 'quantitative' },
|
|
61
|
+
y: { field: 'month', type: 'nominal' },
|
|
62
|
+
},
|
|
63
|
+
} as ChartSpec,
|
|
64
|
+
{
|
|
65
|
+
mark: 'line',
|
|
66
|
+
encoding: {
|
|
67
|
+
x: { field: 'month', type: 'nominal' },
|
|
68
|
+
y: { field: 'target', type: 'quantitative' },
|
|
69
|
+
},
|
|
70
|
+
} as ChartSpec,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const leaves = flattenLayers(specNoChildData);
|
|
75
|
+
expect(leaves).toHaveLength(2);
|
|
76
|
+
expect(leaves[0].data).toEqual(sharedData);
|
|
77
|
+
expect(leaves[1].data).toEqual(sharedData);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('inherits shared encoding from parent, child channels override', () => {
|
|
81
|
+
const spec: LayerSpec = {
|
|
82
|
+
data: sharedData,
|
|
83
|
+
encoding: {
|
|
84
|
+
x: { field: 'month', type: 'nominal' },
|
|
85
|
+
color: { field: 'region', type: 'nominal' },
|
|
86
|
+
},
|
|
87
|
+
layer: [
|
|
88
|
+
{
|
|
89
|
+
mark: 'bar',
|
|
90
|
+
encoding: {
|
|
91
|
+
y: { field: 'sales', type: 'quantitative' },
|
|
92
|
+
},
|
|
93
|
+
} as ChartSpec,
|
|
94
|
+
{
|
|
95
|
+
mark: 'line',
|
|
96
|
+
encoding: {
|
|
97
|
+
y: { field: 'target', type: 'quantitative' },
|
|
98
|
+
// Override x from parent
|
|
99
|
+
x: { field: 'month', type: 'ordinal' },
|
|
100
|
+
},
|
|
101
|
+
} as ChartSpec,
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const leaves = flattenLayers(spec);
|
|
106
|
+
expect(leaves).toHaveLength(2);
|
|
107
|
+
|
|
108
|
+
// First leaf: inherits x and color from parent, has own y
|
|
109
|
+
expect(leaves[0].encoding.x?.field).toBe('month');
|
|
110
|
+
expect(leaves[0].encoding.x?.type).toBe('nominal');
|
|
111
|
+
expect(leaves[0].encoding.y?.field).toBe('sales');
|
|
112
|
+
expect(leaves[0].encoding.color).toEqual({ field: 'region', type: 'nominal' });
|
|
113
|
+
|
|
114
|
+
// Second leaf: overrides x, inherits color from parent, has own y
|
|
115
|
+
expect(leaves[1].encoding.x?.type).toBe('ordinal');
|
|
116
|
+
expect(leaves[1].encoding.y?.field).toBe('target');
|
|
117
|
+
expect(leaves[1].encoding.color).toEqual({ field: 'region', type: 'nominal' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('chains parent transforms before child transforms', () => {
|
|
121
|
+
const spec: LayerSpec = {
|
|
122
|
+
data: sharedData,
|
|
123
|
+
transform: [{ filter: { field: 'region', equal: 'West' } }],
|
|
124
|
+
layer: [
|
|
125
|
+
{
|
|
126
|
+
mark: 'bar',
|
|
127
|
+
transform: [{ filter: { field: 'sales', gt: 100 } }],
|
|
128
|
+
encoding: {
|
|
129
|
+
x: { field: 'sales', type: 'quantitative' },
|
|
130
|
+
y: { field: 'month', type: 'nominal' },
|
|
131
|
+
},
|
|
132
|
+
} as ChartSpec,
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const leaves = flattenLayers(spec);
|
|
137
|
+
expect(leaves).toHaveLength(1);
|
|
138
|
+
expect(leaves[0].transform).toHaveLength(2);
|
|
139
|
+
// Parent transform first
|
|
140
|
+
expect(leaves[0].transform![0]).toEqual({ filter: { field: 'region', equal: 'West' } });
|
|
141
|
+
// Child transform second
|
|
142
|
+
expect(leaves[0].transform![1]).toEqual({ filter: { field: 'sales', gt: 100 } });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('handles nested layers recursively', () => {
|
|
146
|
+
const spec: LayerSpec = {
|
|
147
|
+
data: sharedData,
|
|
148
|
+
layer: [
|
|
149
|
+
{
|
|
150
|
+
// Nested LayerSpec
|
|
151
|
+
layer: [
|
|
152
|
+
{
|
|
153
|
+
mark: 'bar',
|
|
154
|
+
encoding: {
|
|
155
|
+
x: { field: 'sales', type: 'quantitative' },
|
|
156
|
+
y: { field: 'month', type: 'nominal' },
|
|
157
|
+
},
|
|
158
|
+
} as ChartSpec,
|
|
159
|
+
{
|
|
160
|
+
mark: 'line',
|
|
161
|
+
encoding: {
|
|
162
|
+
x: { field: 'month', type: 'nominal' },
|
|
163
|
+
y: { field: 'target', type: 'quantitative' },
|
|
164
|
+
},
|
|
165
|
+
} as ChartSpec,
|
|
166
|
+
],
|
|
167
|
+
} as LayerSpec,
|
|
168
|
+
{
|
|
169
|
+
mark: 'point',
|
|
170
|
+
encoding: {
|
|
171
|
+
x: { field: 'month', type: 'nominal' },
|
|
172
|
+
y: { field: 'sales', type: 'quantitative' },
|
|
173
|
+
},
|
|
174
|
+
} as ChartSpec,
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const leaves = flattenLayers(spec);
|
|
179
|
+
expect(leaves).toHaveLength(3);
|
|
180
|
+
expect(leaves[0].mark).toBe('bar');
|
|
181
|
+
expect(leaves[1].mark).toBe('line');
|
|
182
|
+
expect(leaves[2].mark).toBe('point');
|
|
183
|
+
// All should inherit data from the top-level parent
|
|
184
|
+
for (const leaf of leaves) {
|
|
185
|
+
expect(leaf.data).toEqual(sharedData);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// compileLayer
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
describe('compileLayer', () => {
|
|
195
|
+
it('compiles a basic two-layer spec into a single ChartLayout', () => {
|
|
196
|
+
const spec: LayerSpec = {
|
|
197
|
+
layer: [
|
|
198
|
+
{
|
|
199
|
+
mark: 'bar' as const,
|
|
200
|
+
data: [
|
|
201
|
+
{ name: 'A', value: 10 },
|
|
202
|
+
{ name: 'B', value: 30 },
|
|
203
|
+
{ name: 'C', value: 20 },
|
|
204
|
+
],
|
|
205
|
+
encoding: {
|
|
206
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
207
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
mark: 'bar' as const,
|
|
212
|
+
data: [
|
|
213
|
+
{ name: 'A', value: 15 },
|
|
214
|
+
{ name: 'B', value: 25 },
|
|
215
|
+
{ name: 'C', value: 18 },
|
|
216
|
+
],
|
|
217
|
+
encoding: {
|
|
218
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
219
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const layout = compileLayer(spec, compileOpts);
|
|
226
|
+
|
|
227
|
+
// Should have a valid chart area
|
|
228
|
+
expect(layout.area.width).toBeGreaterThan(0);
|
|
229
|
+
expect(layout.area.height).toBeGreaterThan(0);
|
|
230
|
+
|
|
231
|
+
// Should have marks from both layers
|
|
232
|
+
expect(layout.marks.length).toBeGreaterThan(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('produces combined marks from all layers', () => {
|
|
236
|
+
const spec: LayerSpec = {
|
|
237
|
+
layer: [
|
|
238
|
+
{
|
|
239
|
+
mark: 'bar' as const,
|
|
240
|
+
data: [
|
|
241
|
+
{ name: 'A', value: 10 },
|
|
242
|
+
{ name: 'B', value: 20 },
|
|
243
|
+
],
|
|
244
|
+
encoding: {
|
|
245
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
246
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
mark: 'bar' as const,
|
|
251
|
+
data: [
|
|
252
|
+
{ name: 'A', value: 5 },
|
|
253
|
+
{ name: 'B', value: 15 },
|
|
254
|
+
],
|
|
255
|
+
encoding: {
|
|
256
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
257
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const layout = compileLayer(spec, compileOpts);
|
|
264
|
+
// Two layers each with 2 data points should produce marks from both
|
|
265
|
+
expect(layout.marks.length).toBeGreaterThanOrEqual(4);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('uses layer-level chrome over leaf chrome', () => {
|
|
269
|
+
const spec: LayerSpec = {
|
|
270
|
+
chrome: {
|
|
271
|
+
title: 'Layer Title',
|
|
272
|
+
subtitle: 'Combined view',
|
|
273
|
+
},
|
|
274
|
+
layer: [
|
|
275
|
+
{
|
|
276
|
+
mark: 'bar' as const,
|
|
277
|
+
data: [{ name: 'A', value: 10 }],
|
|
278
|
+
encoding: {
|
|
279
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
280
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
281
|
+
},
|
|
282
|
+
chrome: {
|
|
283
|
+
title: 'Bar Title',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const layout = compileLayer(spec, compileOpts);
|
|
290
|
+
// The layer-level chrome should be used
|
|
291
|
+
expect(layout.chrome.title?.text).toBe('Layer Title');
|
|
292
|
+
expect(layout.chrome.subtitle?.text).toBe('Combined view');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('unions scale domains across layers for shared scales', () => {
|
|
296
|
+
// Layer 1 has values 0-50, layer 2 has values 0-100.
|
|
297
|
+
// The shared scale should encompass 0-100.
|
|
298
|
+
const spec: LayerSpec = {
|
|
299
|
+
layer: [
|
|
300
|
+
{
|
|
301
|
+
mark: 'bar' as const,
|
|
302
|
+
data: [{ name: 'A', value: 50 }],
|
|
303
|
+
encoding: {
|
|
304
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
305
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
mark: 'bar' as const,
|
|
310
|
+
data: [{ name: 'A', value: 100 }],
|
|
311
|
+
encoding: {
|
|
312
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
313
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const layout = compileLayer(spec, compileOpts);
|
|
320
|
+
// Layout should compile without error and have marks
|
|
321
|
+
expect(layout.marks.length).toBeGreaterThan(0);
|
|
322
|
+
expect(layout.area.width).toBeGreaterThan(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('compiles a single-layer LayerSpec identically to a ChartSpec', () => {
|
|
326
|
+
const chartData = [
|
|
327
|
+
{ name: 'A', value: 10 },
|
|
328
|
+
{ name: 'B', value: 30 },
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const layerSpec: LayerSpec = {
|
|
332
|
+
layer: [
|
|
333
|
+
{
|
|
334
|
+
mark: 'bar' as const,
|
|
335
|
+
data: chartData,
|
|
336
|
+
encoding: {
|
|
337
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
338
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const layout = compileLayer(layerSpec, compileOpts);
|
|
345
|
+
expect(layout.area.width).toBeGreaterThan(0);
|
|
346
|
+
expect(layout.marks.length).toBeGreaterThan(0);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('deduplicates legend entries when layers share a color field', () => {
|
|
350
|
+
const spec: LayerSpec = {
|
|
351
|
+
layer: [
|
|
352
|
+
{
|
|
353
|
+
mark: 'bar' as const,
|
|
354
|
+
data: [
|
|
355
|
+
{ name: 'A', value: 10, cat: 'X' },
|
|
356
|
+
{ name: 'B', value: 20, cat: 'Y' },
|
|
357
|
+
],
|
|
358
|
+
encoding: {
|
|
359
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
360
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
361
|
+
color: { field: 'cat', type: 'nominal' as const },
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
mark: 'bar' as const,
|
|
366
|
+
data: [
|
|
367
|
+
{ name: 'A', value: 5, cat: 'X' },
|
|
368
|
+
{ name: 'B', value: 15, cat: 'Y' },
|
|
369
|
+
],
|
|
370
|
+
encoding: {
|
|
371
|
+
x: { field: 'value', type: 'quantitative' as const },
|
|
372
|
+
y: { field: 'name', type: 'nominal' as const },
|
|
373
|
+
color: { field: 'cat', type: 'nominal' as const },
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const layout = compileLayer(spec, compileOpts);
|
|
380
|
+
// Should have exactly 2 legend entries (X, Y), not 4
|
|
381
|
+
const uniqueLabels = new Set(layout.legend.entries.map((e) => e.label));
|
|
382
|
+
expect(uniqueLabels.size).toBe(2);
|
|
383
|
+
expect(uniqueLabels.has('X')).toBe(true);
|
|
384
|
+
expect(uniqueLabels.has('Y')).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -5,7 +5,8 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
5
5
|
import { computeDimensions } from '../layout/dimensions';
|
|
6
6
|
|
|
7
7
|
const baseSpec: NormalizedChartSpec = {
|
|
8
|
-
|
|
8
|
+
markType: 'line',
|
|
9
|
+
markDef: { type: 'line' },
|
|
9
10
|
data: [
|
|
10
11
|
{ date: '2020-01-01', value: 10 },
|
|
11
12
|
{ date: '2021-01-01', value: 20 },
|
|
@@ -152,7 +153,8 @@ describe('computeDimensions', () => {
|
|
|
152
153
|
it('reserves extra bottom space for rotated x-axis labels', () => {
|
|
153
154
|
const rotatedSpec: NormalizedChartSpec = {
|
|
154
155
|
...baseSpec,
|
|
155
|
-
|
|
156
|
+
markType: 'bar',
|
|
157
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
156
158
|
data: [
|
|
157
159
|
{ category: 'California', value: 10 },
|
|
158
160
|
{ category: 'New York', value: 20 },
|
|
@@ -165,7 +167,8 @@ describe('computeDimensions', () => {
|
|
|
165
167
|
};
|
|
166
168
|
const normalSpec: NormalizedChartSpec = {
|
|
167
169
|
...baseSpec,
|
|
168
|
-
|
|
170
|
+
markType: 'bar',
|
|
171
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
169
172
|
data: rotatedSpec.data,
|
|
170
173
|
encoding: {
|
|
171
174
|
x: { field: 'category', type: 'nominal' },
|
|
@@ -5,7 +5,8 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
5
5
|
import { computeLegend } from '../legend/compute';
|
|
6
6
|
|
|
7
7
|
const specWithColor: NormalizedChartSpec = {
|
|
8
|
-
|
|
8
|
+
markType: 'line',
|
|
9
|
+
markDef: { type: 'line' },
|
|
9
10
|
data: [
|
|
10
11
|
{ date: '2020', value: 10, country: 'US' },
|
|
11
12
|
{ date: '2021', value: 20, country: 'UK' },
|
|
@@ -116,7 +117,8 @@ describe('computeLegend', () => {
|
|
|
116
117
|
|
|
117
118
|
const barSpec: NormalizedChartSpec = {
|
|
118
119
|
...specWithColor,
|
|
119
|
-
|
|
120
|
+
markType: 'bar',
|
|
121
|
+
markDef: { type: 'bar' },
|
|
120
122
|
encoding: {
|
|
121
123
|
x: { field: 'value', type: 'quantitative' },
|
|
122
124
|
y: { field: 'date', type: 'nominal' },
|
|
@@ -128,7 +130,8 @@ describe('computeLegend', () => {
|
|
|
128
130
|
|
|
129
131
|
const scatterSpec: NormalizedChartSpec = {
|
|
130
132
|
...specWithColor,
|
|
131
|
-
|
|
133
|
+
markType: 'point',
|
|
134
|
+
markDef: { type: 'point' },
|
|
132
135
|
encoding: {
|
|
133
136
|
x: { field: 'value', type: 'quantitative' },
|
|
134
137
|
y: { field: 'value', type: 'quantitative' },
|
|
@@ -3,7 +3,8 @@ import type { NormalizedChartSpec } from '../compiler/types';
|
|
|
3
3
|
import { computeScales } from '../layout/scales';
|
|
4
4
|
|
|
5
5
|
const lineSpec: NormalizedChartSpec = {
|
|
6
|
-
|
|
6
|
+
markType: 'line',
|
|
7
|
+
markDef: { type: 'line' },
|
|
7
8
|
data: [
|
|
8
9
|
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
9
10
|
{ date: '2021-01-01', value: 50, country: 'US' },
|
|
@@ -23,7 +24,8 @@ const lineSpec: NormalizedChartSpec = {
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const barSpec: NormalizedChartSpec = {
|
|
26
|
-
|
|
27
|
+
markType: 'bar',
|
|
28
|
+
markDef: { type: 'bar' },
|
|
27
29
|
data: [
|
|
28
30
|
{ category: 'A', count: 10 },
|
|
29
31
|
{ category: 'B', count: 30 },
|
|
@@ -107,3 +109,175 @@ describe('computeScales', () => {
|
|
|
107
109
|
expect(xDomain[1]).toBeGreaterThanOrEqual(30);
|
|
108
110
|
});
|
|
109
111
|
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Expanded scale type tests
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** Helper to make a spec with an explicit scale type on the y axis. */
|
|
118
|
+
function makeQuantSpec(scaleConfig: Record<string, unknown>): NormalizedChartSpec {
|
|
119
|
+
return {
|
|
120
|
+
markType: 'line',
|
|
121
|
+
markDef: { type: 'line' },
|
|
122
|
+
data: [
|
|
123
|
+
{ x: 1, y: 1 },
|
|
124
|
+
{ x: 2, y: 10 },
|
|
125
|
+
{ x: 3, y: 100 },
|
|
126
|
+
{ x: 4, y: 1000 },
|
|
127
|
+
],
|
|
128
|
+
encoding: {
|
|
129
|
+
x: { field: 'x', type: 'quantitative' },
|
|
130
|
+
y: { field: 'y', type: 'quantitative', scale: scaleConfig },
|
|
131
|
+
},
|
|
132
|
+
chrome: {},
|
|
133
|
+
annotations: [],
|
|
134
|
+
responsive: true,
|
|
135
|
+
theme: {},
|
|
136
|
+
darkMode: 'off',
|
|
137
|
+
labels: { density: 'auto', format: '' },
|
|
138
|
+
} as NormalizedChartSpec;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('expanded scale types', () => {
|
|
142
|
+
it('creates a log scale with explicit type override', () => {
|
|
143
|
+
const spec = makeQuantSpec({ type: 'log' });
|
|
144
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
145
|
+
expect(scales.y!.type).toBe('log');
|
|
146
|
+
// Log scale maps multiplicatively - position for 10 should be between 1 and 100
|
|
147
|
+
const pos1 = scales.y!.scale(1) as number;
|
|
148
|
+
const pos10 = scales.y!.scale(10) as number;
|
|
149
|
+
const pos100 = scales.y!.scale(100) as number;
|
|
150
|
+
expect(pos10).toBeLessThan(pos1); // Y is inverted
|
|
151
|
+
expect(pos10).toBeGreaterThan(pos100);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('applies base config to log scale', () => {
|
|
155
|
+
const spec = makeQuantSpec({ type: 'log', base: 2 });
|
|
156
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
157
|
+
expect(scales.y!.type).toBe('log');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('creates a pow scale with exponent', () => {
|
|
161
|
+
const spec = makeQuantSpec({ type: 'pow', exponent: 2 });
|
|
162
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
163
|
+
expect(scales.y!.type).toBe('pow');
|
|
164
|
+
// Pow scale should still map data to pixel range
|
|
165
|
+
const pos = scales.y!.scale(50) as number;
|
|
166
|
+
expect(pos).toBeGreaterThanOrEqual(chartArea.y);
|
|
167
|
+
expect(pos).toBeLessThanOrEqual(chartArea.y + chartArea.height);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('creates a sqrt scale', () => {
|
|
171
|
+
const spec = makeQuantSpec({ type: 'sqrt' });
|
|
172
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
173
|
+
expect(scales.y!.type).toBe('sqrt');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('creates a symlog scale with constant', () => {
|
|
177
|
+
const spec = makeQuantSpec({ type: 'symlog', constant: 2 });
|
|
178
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
179
|
+
expect(scales.y!.type).toBe('symlog');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('creates a utc scale for explicit utc type', () => {
|
|
183
|
+
const spec: NormalizedChartSpec = {
|
|
184
|
+
markType: 'line',
|
|
185
|
+
markDef: { type: 'line' },
|
|
186
|
+
data: [
|
|
187
|
+
{ date: '2020-01-01', value: 10 },
|
|
188
|
+
{ date: '2022-01-01', value: 30 },
|
|
189
|
+
],
|
|
190
|
+
encoding: {
|
|
191
|
+
x: { field: 'date', type: 'temporal', scale: { type: 'utc' } },
|
|
192
|
+
y: { field: 'value', type: 'quantitative' },
|
|
193
|
+
},
|
|
194
|
+
chrome: {},
|
|
195
|
+
annotations: [],
|
|
196
|
+
responsive: true,
|
|
197
|
+
theme: {},
|
|
198
|
+
darkMode: 'off',
|
|
199
|
+
labels: { density: 'auto', format: '' },
|
|
200
|
+
};
|
|
201
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
202
|
+
expect(scales.x!.type).toBe('utc');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('creates a quantile scale', () => {
|
|
206
|
+
const spec = makeQuantSpec({ type: 'quantile' });
|
|
207
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
|
+
expect(scales.y!.type).toBe('quantile');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('creates a quantize scale', () => {
|
|
212
|
+
const spec = makeQuantSpec({ type: 'quantize' });
|
|
213
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
214
|
+
expect(scales.y!.type).toBe('quantize');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('creates a threshold scale', () => {
|
|
218
|
+
const spec = makeQuantSpec({ type: 'threshold', domain: [10, 100] });
|
|
219
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
220
|
+
expect(scales.y!.type).toBe('threshold');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('scale config properties', () => {
|
|
225
|
+
it('applies clamp to linear scale', () => {
|
|
226
|
+
const spec = makeQuantSpec({ type: 'linear', domain: [0, 100], clamp: true });
|
|
227
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
228
|
+
// Values outside domain should be clamped to range edges
|
|
229
|
+
const posAbove = scales.y!.scale(200) as number;
|
|
230
|
+
const posAtMax = scales.y!.scale(100) as number;
|
|
231
|
+
expect(posAbove).toBe(posAtMax);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('applies reverse to linear scale', () => {
|
|
235
|
+
const specNormal = makeQuantSpec({ type: 'linear' });
|
|
236
|
+
const specReversed = makeQuantSpec({ type: 'linear', reverse: true });
|
|
237
|
+
const normal = computeScales(specNormal, chartArea, specNormal.data);
|
|
238
|
+
const reversed = computeScales(specReversed, chartArea, specReversed.data);
|
|
239
|
+
|
|
240
|
+
// In a reversed scale, higher values should map to the opposite end
|
|
241
|
+
const normalRange = normal.y!.scale.range() as number[];
|
|
242
|
+
const reversedRange = reversed.y!.scale.range() as number[];
|
|
243
|
+
expect(normalRange[0]).toBe(reversedRange[1]);
|
|
244
|
+
expect(normalRange[1]).toBe(reversedRange[0]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('applies padding to band scale', () => {
|
|
248
|
+
const specDefault: NormalizedChartSpec = {
|
|
249
|
+
...barSpec,
|
|
250
|
+
encoding: {
|
|
251
|
+
x: { field: 'count', type: 'quantitative' },
|
|
252
|
+
y: { field: 'category', type: 'nominal' },
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const specPadded: NormalizedChartSpec = {
|
|
256
|
+
...barSpec,
|
|
257
|
+
encoding: {
|
|
258
|
+
x: { field: 'count', type: 'quantitative' },
|
|
259
|
+
y: { field: 'category', type: 'nominal', scale: { padding: 0.1 } },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const defaultScales = computeScales(specDefault, chartArea, specDefault.data);
|
|
264
|
+
const paddedScales = computeScales(specPadded, chartArea, specPadded.data);
|
|
265
|
+
|
|
266
|
+
// Smaller padding = wider bands
|
|
267
|
+
const defaultBandwidth = defaultScales.y!.scale.bandwidth!() as number;
|
|
268
|
+
const paddedBandwidth = paddedScales.y!.scale.bandwidth!() as number;
|
|
269
|
+
expect(paddedBandwidth).toBeGreaterThan(defaultBandwidth);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('backward compatible: existing specs still work', () => {
|
|
273
|
+
// lineSpec and barSpec from before should still produce valid scales
|
|
274
|
+
const lineScales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
275
|
+
expect(lineScales.x!.type).toBe('time');
|
|
276
|
+
expect(lineScales.y!.type).toBe('linear');
|
|
277
|
+
expect(lineScales.color!.type).toBe('ordinal');
|
|
278
|
+
|
|
279
|
+
const barScales = computeScales(barSpec, chartArea, barSpec.data);
|
|
280
|
+
expect(barScales.x!.type).toBe('linear');
|
|
281
|
+
expect(barScales.y!.type).toBe('band');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -26,7 +26,8 @@ const compactStrategy: LayoutStrategy = {
|
|
|
26
26
|
|
|
27
27
|
function makeSpec(annotations: Annotation[]): NormalizedChartSpec {
|
|
28
28
|
return {
|
|
29
|
-
|
|
29
|
+
markType: 'line',
|
|
30
|
+
markDef: { type: 'line' },
|
|
30
31
|
data: [
|
|
31
32
|
{ date: '2019-01-01', value: 10 },
|
|
32
33
|
{ date: '2020-01-01', value: 20 },
|
|
@@ -139,7 +140,8 @@ describe('computeAnnotations', () => {
|
|
|
139
140
|
|
|
140
141
|
it('interpolates range position for values between ordinal data points', () => {
|
|
141
142
|
const ordinalSpec: NormalizedChartSpec = {
|
|
142
|
-
|
|
143
|
+
markType: 'line',
|
|
144
|
+
markDef: { type: 'line' },
|
|
143
145
|
data: [
|
|
144
146
|
{ year: '2005', value: 10 },
|
|
145
147
|
{ year: '2007', value: 20 },
|
|
@@ -182,7 +184,8 @@ describe('computeAnnotations', () => {
|
|
|
182
184
|
|
|
183
185
|
it('clamps interpolation for values outside the ordinal domain range', () => {
|
|
184
186
|
const ordinalSpec: NormalizedChartSpec = {
|
|
185
|
-
|
|
187
|
+
markType: 'line',
|
|
188
|
+
markDef: { type: 'line' },
|
|
186
189
|
data: [
|
|
187
190
|
{ year: '2005', value: 10 },
|
|
188
191
|
{ year: '2007', value: 20 },
|
|
@@ -209,7 +212,8 @@ describe('computeAnnotations', () => {
|
|
|
209
212
|
|
|
210
213
|
it('returns null for non-numeric ordinal domain values', () => {
|
|
211
214
|
const catSpec: NormalizedChartSpec = {
|
|
212
|
-
|
|
215
|
+
markType: 'bar',
|
|
216
|
+
markDef: { type: 'bar', orient: 'vertical' },
|
|
213
217
|
data: [
|
|
214
218
|
{ category: 'Jan', value: 10 },
|
|
215
219
|
{ category: 'Feb', value: 20 },
|