@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.11
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.js +2 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/__test-fixtures__/specs.ts +0 -182
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
- package/src/__tests__/annotation-behavior.test.ts +0 -852
- package/src/__tests__/axes.test.ts +0 -1579
- package/src/__tests__/compile-animation.test.ts +0 -161
- package/src/__tests__/compile-chart.test.ts +0 -1303
- package/src/__tests__/compile-footnotes.test.ts +0 -277
- package/src/__tests__/compile-layer.test.ts +0 -969
- package/src/__tests__/compile-snapshot.test.ts +0 -156
- package/src/__tests__/compound-labels.test.ts +0 -147
- package/src/__tests__/dimensions.test.ts +0 -918
- package/src/__tests__/encoding-sugar.test.ts +0 -390
- package/src/__tests__/facet.test.ts +0 -359
- package/src/__tests__/helpers/invariants.ts +0 -299
- package/src/__tests__/known-bugs.test.ts +0 -566
- package/src/__tests__/layout-fuzz.test.ts +0 -358
- package/src/__tests__/layout-invariants.test.ts +0 -535
- package/src/__tests__/legend.test.ts +0 -721
- package/src/__tests__/mark-keys.test.ts +0 -294
- package/src/__tests__/ranking-recipes.test.ts +0 -260
- package/src/__tests__/rotation.test.ts +0 -83
- package/src/__tests__/scales.test.ts +0 -323
- package/src/__tests__/series-search.test.ts +0 -92
- package/src/__tests__/spec-sugar.test.ts +0 -801
- package/src/__tests__/you-draw-it.test.ts +0 -160
- package/src/annotations/__tests__/compute.test.ts +0 -1672
- package/src/annotations/__tests__/geometry.test.ts +0 -66
- package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
- package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
- package/src/annotations/__tests__/placement.test.ts +0 -316
- package/src/annotations/__tests__/resolve-text.test.ts +0 -788
- package/src/annotations/__tests__/rich-text.test.ts +0 -91
- package/src/annotations/__tests__/thinning.test.ts +0 -285
- package/src/annotations/collisions.ts +0 -326
- package/src/annotations/compute.ts +0 -333
- package/src/annotations/constants.ts +0 -162
- package/src/annotations/geometry.ts +0 -376
- package/src/annotations/move.ts +0 -135
- package/src/annotations/placement.ts +0 -636
- package/src/annotations/position.ts +0 -131
- package/src/annotations/resolve-range.ts +0 -117
- package/src/annotations/resolve-refline.ts +0 -157
- package/src/annotations/resolve-text.ts +0 -545
- package/src/annotations/rich-text.ts +0 -65
- package/src/annotations/thinning.ts +0 -190
- package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
- package/src/barlist/compile-barlist.ts +0 -391
- package/src/barlist/types.ts +0 -28
- package/src/charts/__tests__/post-process.test.ts +0 -237
- package/src/charts/__tests__/registry.test.ts +0 -116
- package/src/charts/__tests__/utils.test.ts +0 -195
- package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
- package/src/charts/_shared/density-filter.ts +0 -26
- package/src/charts/_shared/format-label-value.ts +0 -16
- package/src/charts/bar/__tests__/compute.test.ts +0 -934
- package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
- package/src/charts/bar/__tests__/labels.test.ts +0 -292
- package/src/charts/bar/compute.ts +0 -539
- package/src/charts/bar/index.ts +0 -60
- package/src/charts/bar/labels.ts +0 -311
- package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
- package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
- package/src/charts/beeswarm/compute.ts +0 -203
- package/src/charts/beeswarm/dodge.ts +0 -155
- package/src/charts/beeswarm/index.ts +0 -32
- package/src/charts/builtin.ts +0 -75
- package/src/charts/calendar/__tests__/compute.test.ts +0 -417
- package/src/charts/calendar/compute.ts +0 -319
- package/src/charts/calendar/index.ts +0 -32
- package/src/charts/column/__tests__/compute.test.ts +0 -540
- package/src/charts/column/__tests__/labels.test.ts +0 -179
- package/src/charts/column/compute.ts +0 -493
- package/src/charts/column/index.ts +0 -53
- package/src/charts/column/labels.ts +0 -229
- package/src/charts/dot/__tests__/compute.test.ts +0 -380
- package/src/charts/dot/__tests__/labels.test.ts +0 -98
- package/src/charts/dot/compute.ts +0 -285
- package/src/charts/dot/index.ts +0 -62
- package/src/charts/dot/labels.ts +0 -121
- package/src/charts/line/__tests__/compute.test.ts +0 -1194
- package/src/charts/line/__tests__/curves.test.ts +0 -51
- package/src/charts/line/__tests__/labels.test.ts +0 -94
- package/src/charts/line/__tests__/mark-options.test.ts +0 -175
- package/src/charts/line/area.ts +0 -484
- package/src/charts/line/compute.ts +0 -356
- package/src/charts/line/curves.ts +0 -85
- package/src/charts/line/index.ts +0 -176
- package/src/charts/line/labels.ts +0 -177
- package/src/charts/parliament/__tests__/compute.test.ts +0 -411
- package/src/charts/parliament/compute.ts +0 -313
- package/src/charts/parliament/index.ts +0 -32
- package/src/charts/pie/__tests__/compute.test.ts +0 -384
- package/src/charts/pie/__tests__/labels.test.ts +0 -132
- package/src/charts/pie/compute.ts +0 -324
- package/src/charts/pie/index.ts +0 -49
- package/src/charts/pie/labels.ts +0 -143
- package/src/charts/post-process.ts +0 -229
- package/src/charts/range/__tests__/compute.test.ts +0 -486
- package/src/charts/range/compute.ts +0 -273
- package/src/charts/range/index.ts +0 -82
- package/src/charts/range/labels.ts +0 -169
- package/src/charts/rect/__tests__/compute.test.ts +0 -120
- package/src/charts/rect/index.ts +0 -127
- package/src/charts/registry.ts +0 -72
- package/src/charts/rule/index.ts +0 -139
- package/src/charts/scatter/__tests__/compute.test.ts +0 -488
- package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
- package/src/charts/scatter/compute.ts +0 -192
- package/src/charts/scatter/index.ts +0 -46
- package/src/charts/scatter/trendline.ts +0 -100
- package/src/charts/text/__tests__/compute.test.ts +0 -307
- package/src/charts/text/index.ts +0 -141
- package/src/charts/tick/__tests__/compute.test.ts +0 -75
- package/src/charts/tick/index.ts +0 -99
- package/src/charts/utils.ts +0 -170
- package/src/charts/waffle/__tests__/compute.test.ts +0 -305
- package/src/charts/waffle/compute.ts +0 -197
- package/src/charts/waffle/index.ts +0 -31
- package/src/compile/__tests__/color-scale-range.test.ts +0 -158
- package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
- package/src/compile/__tests__/data-clip.test.ts +0 -127
- package/src/compile/__tests__/fill-patterns.test.ts +0 -154
- package/src/compile/__tests__/size-scale.test.ts +0 -108
- package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
- package/src/compile/color-scale-range.ts +0 -101
- package/src/compile/data-clip.ts +0 -48
- package/src/compile/fill-patterns.ts +0 -74
- package/src/compile/layer.ts +0 -630
- package/src/compile/size-scale.ts +0 -141
- package/src/compile/spec-sugar.ts +0 -767
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1548
- package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
- package/src/compiler/__tests__/animation.test.ts +0 -177
- package/src/compiler/__tests__/compile.test.ts +0 -87
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
- package/src/compiler/__tests__/keys.test.ts +0 -83
- package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
- package/src/compiler/__tests__/normalize.test.ts +0 -399
- package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
- package/src/compiler/__tests__/validate.test.ts +0 -731
- package/src/compiler/__tests__/vl-parity.test.ts +0 -68
- package/src/compiler/a11y-warnings.ts +0 -110
- package/src/compiler/animation.ts +0 -142
- package/src/compiler/index.ts +0 -47
- package/src/compiler/keys.ts +0 -55
- package/src/compiler/normalize.ts +0 -734
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -2093
- package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
- package/src/endpoint-labels/compute.ts +0 -621
- package/src/endpoint-labels/constants.ts +0 -54
- package/src/endpoint-labels/format.ts +0 -60
- package/src/endpoint-labels/predict.ts +0 -139
- package/src/format/__tests__/field-format.test.ts +0 -164
- package/src/format/field-format.ts +0 -27
- package/src/geo/__tests__/compile-map.test.ts +0 -1128
- package/src/geo/compile-map.ts +0 -1376
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -65
- package/src/geo/types.ts +0 -26
- package/src/graphs/__tests__/community.test.ts +0 -228
- package/src/graphs/__tests__/compile-graph.test.ts +0 -396
- package/src/graphs/__tests__/encoding.test.ts +0 -415
- package/src/graphs/community.ts +0 -92
- package/src/graphs/compile-graph.ts +0 -329
- package/src/graphs/encoding.ts +0 -356
- package/src/graphs/types.ts +0 -106
- package/src/index.ts +0 -177
- package/src/layout/__tests__/metrics.test.ts +0 -97
- package/src/layout/__tests__/scales-highlight.test.ts +0 -192
- package/src/layout/axes/rotation.ts +0 -152
- package/src/layout/axes/thinning.ts +0 -98
- package/src/layout/axes/ticks.ts +0 -409
- package/src/layout/axes.ts +0 -757
- package/src/layout/dimensions.ts +0 -912
- package/src/layout/facet.ts +0 -91
- package/src/layout/gridlines.ts +0 -84
- package/src/layout/metrics.ts +0 -132
- package/src/layout/plan.ts +0 -631
- package/src/layout/scales.ts +0 -1053
- package/src/layout/shared.ts +0 -44
- package/src/legend/__tests__/continuous.test.ts +0 -318
- package/src/legend/__tests__/redundancy.test.ts +0 -108
- package/src/legend/__tests__/size.test.ts +0 -348
- package/src/legend/__tests__/suppression.test.ts +0 -382
- package/src/legend/compute.ts +0 -748
- package/src/legend/continuous.ts +0 -327
- package/src/legend/size.ts +0 -407
- package/src/legend/suppression.ts +0 -226
- package/src/legend/wrap.ts +0 -117
- package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
- package/src/sankey/__tests__/layout.test.ts +0 -165
- package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
- package/src/sankey/__tests__/node-sort.test.ts +0 -45
- package/src/sankey/compile-sankey.ts +0 -706
- package/src/sankey/layout.ts +0 -184
- package/src/sankey/types.ts +0 -41
- package/src/tables/__tests__/bar-column.test.ts +0 -147
- package/src/tables/__tests__/category-colors.test.ts +0 -230
- package/src/tables/__tests__/compile-table.test.ts +0 -236
- package/src/tables/__tests__/format-cells.test.ts +0 -143
- package/src/tables/__tests__/heatmap.test.ts +0 -148
- package/src/tables/__tests__/pagination.test.ts +0 -78
- package/src/tables/__tests__/search.test.ts +0 -94
- package/src/tables/__tests__/sort.test.ts +0 -107
- package/src/tables/__tests__/sparkline.test.ts +0 -122
- package/src/tables/bar-column.ts +0 -94
- package/src/tables/category-colors.ts +0 -78
- package/src/tables/compile-table.ts +0 -427
- package/src/tables/format-cells.ts +0 -115
- package/src/tables/heatmap.ts +0 -141
- package/src/tables/pagination.ts +0 -46
- package/src/tables/search.ts +0 -66
- package/src/tables/sort.ts +0 -69
- package/src/tables/sparkline.ts +0 -113
- package/src/tables/utils.ts +0 -16
- package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
- package/src/tilemap/compile-tilemap.ts +0 -755
- package/src/tilemap/layout.ts +0 -172
- package/src/tilemap/types.ts +0 -33
- package/src/tooltips/__tests__/compute.test.ts +0 -582
- package/src/tooltips/compute.ts +0 -700
- package/src/transforms/__tests__/aggregate.test.ts +0 -159
- package/src/transforms/__tests__/bin.test.ts +0 -88
- package/src/transforms/__tests__/calculate.test.ts +0 -146
- package/src/transforms/__tests__/conditional.test.ts +0 -127
- package/src/transforms/__tests__/filter-relative.test.ts +0 -202
- package/src/transforms/__tests__/filter.test.ts +0 -59
- package/src/transforms/__tests__/fold.test.ts +0 -79
- package/src/transforms/__tests__/index.test.ts +0 -93
- package/src/transforms/__tests__/predicates.test.ts +0 -193
- package/src/transforms/__tests__/timeunit.test.ts +0 -129
- package/src/transforms/__tests__/window.test.ts +0 -286
- package/src/transforms/aggregate.ts +0 -131
- package/src/transforms/bin.ts +0 -87
- package/src/transforms/calculate.ts +0 -60
- package/src/transforms/conditional.ts +0 -46
- package/src/transforms/filter.ts +0 -122
- package/src/transforms/fold.ts +0 -49
- package/src/transforms/index.ts +0 -60
- package/src/transforms/predicates.ts +0 -123
- package/src/transforms/timeunit.ts +0 -88
- package/src/transforms/window.ts +0 -185
|
@@ -1,1194 +0,0 @@
|
|
|
1
|
-
import type { LayoutStrategy, LineMark, PointMark, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
4
|
-
import { computeScales } from '../../../layout/scales';
|
|
5
|
-
import { computeAreaMarks } from '../area';
|
|
6
|
-
import { computeLineMarks } from '../compute';
|
|
7
|
-
import { computeLineLabels } from '../labels';
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Shared fixtures
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
|
|
14
|
-
|
|
15
|
-
const fullStrategy: LayoutStrategy = {
|
|
16
|
-
labelMode: 'all',
|
|
17
|
-
legendPosition: 'right',
|
|
18
|
-
annotationPosition: 'inline',
|
|
19
|
-
axisLabelDensity: 'full',
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const compactStrategy: LayoutStrategy = {
|
|
23
|
-
labelMode: 'none',
|
|
24
|
-
legendPosition: 'top',
|
|
25
|
-
annotationPosition: 'tooltip-only',
|
|
26
|
-
axisLabelDensity: 'minimal',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function makeSingleSeriesSpec(): NormalizedChartSpec {
|
|
30
|
-
return {
|
|
31
|
-
markType: 'line',
|
|
32
|
-
markDef: { type: 'line', point: true },
|
|
33
|
-
data: [
|
|
34
|
-
{ date: '2020-01-01', value: 10 },
|
|
35
|
-
{ date: '2021-01-01', value: 40 },
|
|
36
|
-
{ date: '2022-01-01', value: 30 },
|
|
37
|
-
],
|
|
38
|
-
encoding: {
|
|
39
|
-
x: { field: 'date', type: 'temporal' },
|
|
40
|
-
y: { field: 'value', type: 'quantitative' },
|
|
41
|
-
},
|
|
42
|
-
chrome: {},
|
|
43
|
-
annotations: [],
|
|
44
|
-
responsive: true,
|
|
45
|
-
theme: {},
|
|
46
|
-
darkMode: 'off',
|
|
47
|
-
labels: { density: 'auto', format: '' },
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function makeMultiSeriesSpec(): NormalizedChartSpec {
|
|
52
|
-
return {
|
|
53
|
-
markType: 'line',
|
|
54
|
-
markDef: { type: 'line', point: true },
|
|
55
|
-
data: [
|
|
56
|
-
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
57
|
-
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
58
|
-
{ date: '2022-01-01', value: 30, country: 'US' },
|
|
59
|
-
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
60
|
-
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
61
|
-
{ date: '2022-01-01', value: 45, country: 'UK' },
|
|
62
|
-
],
|
|
63
|
-
encoding: {
|
|
64
|
-
x: { field: 'date', type: 'temporal' },
|
|
65
|
-
y: { field: 'value', type: 'quantitative' },
|
|
66
|
-
color: { field: 'country', type: 'nominal' },
|
|
67
|
-
},
|
|
68
|
-
chrome: {},
|
|
69
|
-
annotations: [],
|
|
70
|
-
responsive: true,
|
|
71
|
-
theme: {},
|
|
72
|
-
darkMode: 'off',
|
|
73
|
-
labels: { density: 'auto', format: '' },
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function makeMissingDataSpec(): NormalizedChartSpec {
|
|
78
|
-
return {
|
|
79
|
-
markType: 'line',
|
|
80
|
-
markDef: { type: 'line', point: true },
|
|
81
|
-
data: [
|
|
82
|
-
{ date: '2020-01-01', value: 10 },
|
|
83
|
-
{ date: '2021-01-01', value: null },
|
|
84
|
-
{ date: '2022-01-01', value: 30 },
|
|
85
|
-
],
|
|
86
|
-
encoding: {
|
|
87
|
-
x: { field: 'date', type: 'temporal' },
|
|
88
|
-
y: { field: 'value', type: 'quantitative' },
|
|
89
|
-
},
|
|
90
|
-
chrome: {},
|
|
91
|
-
annotations: [],
|
|
92
|
-
responsive: true,
|
|
93
|
-
theme: {},
|
|
94
|
-
darkMode: 'off',
|
|
95
|
-
labels: { density: 'auto', format: '' },
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
// Line mark computation tests
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
describe('computeLineMarks', () => {
|
|
104
|
-
describe('single series', () => {
|
|
105
|
-
it('produces one LineMark and PointMarks for each data point', () => {
|
|
106
|
-
const spec = makeSingleSeriesSpec();
|
|
107
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
108
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
109
|
-
|
|
110
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
111
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
112
|
-
|
|
113
|
-
expect(lineMarks).toHaveLength(1);
|
|
114
|
-
expect(pointMarks).toHaveLength(3);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('line mark has correct number of points', () => {
|
|
118
|
-
const spec = makeSingleSeriesSpec();
|
|
119
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
120
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
121
|
-
|
|
122
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
123
|
-
expect(lineMark.points).toHaveLength(3);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('line mark points are within chart area bounds', () => {
|
|
127
|
-
const spec = makeSingleSeriesSpec();
|
|
128
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
129
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
130
|
-
|
|
131
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
132
|
-
for (const point of lineMark.points) {
|
|
133
|
-
expect(point.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
134
|
-
expect(point.x).toBeLessThanOrEqual(chartArea.x + chartArea.width);
|
|
135
|
-
// Y axis is inverted (higher values = lower y)
|
|
136
|
-
expect(point.y).toBeGreaterThanOrEqual(chartArea.y);
|
|
137
|
-
expect(point.y).toBeLessThanOrEqual(chartArea.y + chartArea.height);
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('single series has no seriesKey', () => {
|
|
142
|
-
const spec = makeSingleSeriesSpec();
|
|
143
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
144
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
145
|
-
|
|
146
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
147
|
-
expect(lineMark.seriesKey).toBeUndefined();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('visible point marks have filled opacity when point: true', () => {
|
|
151
|
-
const spec = makeSingleSeriesSpec();
|
|
152
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
153
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
154
|
-
|
|
155
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
156
|
-
for (const pm of pointMarks) {
|
|
157
|
-
expect(pm.fillOpacity).toBe(1);
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe('multi-series', () => {
|
|
163
|
-
it('produces separate LineMarks for each series', () => {
|
|
164
|
-
const spec = makeMultiSeriesSpec();
|
|
165
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
166
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
167
|
-
|
|
168
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
169
|
-
expect(lineMarks).toHaveLength(2);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('each series has a distinct seriesKey', () => {
|
|
173
|
-
const spec = makeMultiSeriesSpec();
|
|
174
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
175
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
176
|
-
|
|
177
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
178
|
-
const keys = lineMarks.map((m) => m.seriesKey);
|
|
179
|
-
expect(keys).toContain('US');
|
|
180
|
-
expect(keys).toContain('UK');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('each series has different stroke colors', () => {
|
|
184
|
-
const spec = makeMultiSeriesSpec();
|
|
185
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
186
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
187
|
-
|
|
188
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
189
|
-
expect(lineMarks[0].stroke).not.toBe(lineMarks[1].stroke);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('produces point marks for all data points across all series', () => {
|
|
193
|
-
const spec = makeMultiSeriesSpec();
|
|
194
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
195
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
196
|
-
|
|
197
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
198
|
-
// 3 points per series * 2 series = 6
|
|
199
|
-
expect(pointMarks).toHaveLength(6);
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe('missing data', () => {
|
|
204
|
-
it('breaks line at null values - fewer points in the line', () => {
|
|
205
|
-
const spec = makeMissingDataSpec();
|
|
206
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
207
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
208
|
-
|
|
209
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
210
|
-
// null value is excluded, so only 2 valid points remain
|
|
211
|
-
expect(lineMark.points).toHaveLength(2);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('produces point marks only for valid data points', () => {
|
|
215
|
-
const spec = makeMissingDataSpec();
|
|
216
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
217
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
218
|
-
|
|
219
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
220
|
-
expect(pointMarks).toHaveLength(2);
|
|
221
|
-
});
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
describe('x-axis sorting', () => {
|
|
225
|
-
it('sorts unsorted temporal data so points increase left-to-right', () => {
|
|
226
|
-
const spec: NormalizedChartSpec = {
|
|
227
|
-
...makeSingleSeriesSpec(),
|
|
228
|
-
data: [
|
|
229
|
-
{ date: '2022-01-01', value: 30 },
|
|
230
|
-
{ date: '2020-01-01', value: 10 },
|
|
231
|
-
{ date: '2021-01-01', value: 40 },
|
|
232
|
-
],
|
|
233
|
-
};
|
|
234
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
235
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
236
|
-
|
|
237
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
238
|
-
// Points should have monotonically increasing x pixel values
|
|
239
|
-
for (let i = 1; i < lineMark.points.length; i++) {
|
|
240
|
-
expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
it('sorts reverse-ordered dates correctly', () => {
|
|
245
|
-
const spec: NormalizedChartSpec = {
|
|
246
|
-
...makeSingleSeriesSpec(),
|
|
247
|
-
data: [
|
|
248
|
-
{ date: '2025-01-01', value: 50 },
|
|
249
|
-
{ date: '2024-01-01', value: 40 },
|
|
250
|
-
{ date: '2023-01-01', value: 30 },
|
|
251
|
-
{ date: '2022-01-01', value: 20 },
|
|
252
|
-
{ date: '2021-01-01', value: 10 },
|
|
253
|
-
],
|
|
254
|
-
};
|
|
255
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
256
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
257
|
-
|
|
258
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
259
|
-
expect(lineMark.points).toHaveLength(5);
|
|
260
|
-
for (let i = 1; i < lineMark.points.length; i++) {
|
|
261
|
-
expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it('sorts unsorted numeric x-axis data', () => {
|
|
266
|
-
const spec: NormalizedChartSpec = {
|
|
267
|
-
...makeSingleSeriesSpec(),
|
|
268
|
-
data: [
|
|
269
|
-
{ date: 2022, value: 30 },
|
|
270
|
-
{ date: 2020, value: 10 },
|
|
271
|
-
{ date: 2021, value: 40 },
|
|
272
|
-
],
|
|
273
|
-
encoding: {
|
|
274
|
-
x: { field: 'date', type: 'quantitative' },
|
|
275
|
-
y: { field: 'value', type: 'quantitative' },
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
279
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
280
|
-
|
|
281
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
282
|
-
for (let i = 1; i < lineMark.points.length; i++) {
|
|
283
|
-
expect(lineMark.points[i].x).toBeGreaterThan(lineMark.points[i - 1].x);
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('sorts each series independently in multi-series', () => {
|
|
288
|
-
const spec: NormalizedChartSpec = {
|
|
289
|
-
...makeMultiSeriesSpec(),
|
|
290
|
-
data: [
|
|
291
|
-
{ date: '2022-01-01', value: 30, country: 'US' },
|
|
292
|
-
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
293
|
-
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
294
|
-
{ date: '2022-01-01', value: 45, country: 'UK' },
|
|
295
|
-
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
296
|
-
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
297
|
-
],
|
|
298
|
-
};
|
|
299
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
300
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
301
|
-
|
|
302
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
303
|
-
for (const lm of lineMarks) {
|
|
304
|
-
for (let i = 1; i < lm.points.length; i++) {
|
|
305
|
-
expect(lm.points[i].x).toBeGreaterThan(lm.points[i - 1].x);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it('attaches data rows in sorted order on marks', () => {
|
|
311
|
-
const spec: NormalizedChartSpec = {
|
|
312
|
-
...makeSingleSeriesSpec(),
|
|
313
|
-
data: [
|
|
314
|
-
{ date: '2022-01-01', value: 30 },
|
|
315
|
-
{ date: '2020-01-01', value: 10 },
|
|
316
|
-
{ date: '2021-01-01', value: 40 },
|
|
317
|
-
],
|
|
318
|
-
};
|
|
319
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
320
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
321
|
-
|
|
322
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
323
|
-
// The data array on the mark should be chronologically ordered
|
|
324
|
-
const dates = lineMark.data!.map((r) => r.date);
|
|
325
|
-
expect(dates).toEqual(['2020-01-01', '2021-01-01', '2022-01-01']);
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('sorts data before handling null y-value line breaks', () => {
|
|
329
|
-
// Unsorted data with a null in the middle chronologically.
|
|
330
|
-
// After sorting: 2020 (10), 2021 (null), 2022 (30) -> line breaks at 2021
|
|
331
|
-
const spec: NormalizedChartSpec = {
|
|
332
|
-
...makeSingleSeriesSpec(),
|
|
333
|
-
data: [
|
|
334
|
-
{ date: '2022-01-01', value: 30 },
|
|
335
|
-
{ date: '2021-01-01', value: null },
|
|
336
|
-
{ date: '2020-01-01', value: 10 },
|
|
337
|
-
],
|
|
338
|
-
};
|
|
339
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
340
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
341
|
-
|
|
342
|
-
const lineMark = marks.find((m): m is LineMark => m.type === 'line')!;
|
|
343
|
-
// Null is excluded, so only 2 valid points
|
|
344
|
-
expect(lineMark.points).toHaveLength(2);
|
|
345
|
-
// The two valid points should still be left-to-right
|
|
346
|
-
expect(lineMark.points[1].x).toBeGreaterThan(lineMark.points[0].x);
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it('produces identical output for already-sorted data', () => {
|
|
350
|
-
// Verify sorting doesn't break pre-sorted input (regression check)
|
|
351
|
-
const sorted = makeSingleSeriesSpec(); // already chronological
|
|
352
|
-
const shuffled: NormalizedChartSpec = {
|
|
353
|
-
...sorted,
|
|
354
|
-
data: [
|
|
355
|
-
{ date: '2021-01-01', value: 40 },
|
|
356
|
-
{ date: '2020-01-01', value: 10 },
|
|
357
|
-
{ date: '2022-01-01', value: 30 },
|
|
358
|
-
],
|
|
359
|
-
};
|
|
360
|
-
|
|
361
|
-
const sortedScales = computeScales(sorted, chartArea, sorted.data);
|
|
362
|
-
const sortedMarks = computeLineMarks(sorted, sortedScales, chartArea, fullStrategy);
|
|
363
|
-
|
|
364
|
-
const shuffledScales = computeScales(shuffled, chartArea, shuffled.data);
|
|
365
|
-
const shuffledMarks = computeLineMarks(shuffled, shuffledScales, chartArea, fullStrategy);
|
|
366
|
-
|
|
367
|
-
const sortedLine = sortedMarks.find((m): m is LineMark => m.type === 'line')!;
|
|
368
|
-
const shuffledLine = shuffledMarks.find((m): m is LineMark => m.type === 'line')!;
|
|
369
|
-
|
|
370
|
-
// Both should produce the same pixel positions
|
|
371
|
-
expect(sortedLine.points).toEqual(shuffledLine.points);
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it('sorts within-year dates by month in multi-series', () => {
|
|
375
|
-
const spec: NormalizedChartSpec = {
|
|
376
|
-
...makeMultiSeriesSpec(),
|
|
377
|
-
data: [
|
|
378
|
-
{ date: '2020-12-01', value: 30, country: 'US' },
|
|
379
|
-
{ date: '2020-03-01', value: 10, country: 'US' },
|
|
380
|
-
{ date: '2020-07-01', value: 20, country: 'US' },
|
|
381
|
-
{ date: '2020-12-01', value: 45, country: 'UK' },
|
|
382
|
-
{ date: '2020-03-01', value: 15, country: 'UK' },
|
|
383
|
-
{ date: '2020-07-01', value: 25, country: 'UK' },
|
|
384
|
-
],
|
|
385
|
-
};
|
|
386
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
387
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
388
|
-
|
|
389
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
390
|
-
expect(lineMarks).toHaveLength(2);
|
|
391
|
-
|
|
392
|
-
for (const lm of lineMarks) {
|
|
393
|
-
// Data rows should be Mar -> Jul -> Dec
|
|
394
|
-
const dates = lm.data!.map((r) => r.date);
|
|
395
|
-
expect(dates).toEqual(['2020-03-01', '2020-07-01', '2020-12-01']);
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
describe('edge cases', () => {
|
|
401
|
-
it('returns empty array when no x encoding', () => {
|
|
402
|
-
const spec: NormalizedChartSpec = {
|
|
403
|
-
markType: 'line',
|
|
404
|
-
markDef: { type: 'line', point: true },
|
|
405
|
-
data: [{ value: 10 }],
|
|
406
|
-
encoding: {
|
|
407
|
-
y: { field: 'value', type: 'quantitative' },
|
|
408
|
-
},
|
|
409
|
-
chrome: {},
|
|
410
|
-
annotations: [],
|
|
411
|
-
responsive: true,
|
|
412
|
-
theme: {},
|
|
413
|
-
darkMode: 'off',
|
|
414
|
-
labels: { density: 'auto', format: '' },
|
|
415
|
-
};
|
|
416
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
417
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
418
|
-
expect(marks).toHaveLength(0);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it('returns empty array for empty data', () => {
|
|
422
|
-
const spec: NormalizedChartSpec = {
|
|
423
|
-
markType: 'line',
|
|
424
|
-
markDef: { type: 'line', point: true },
|
|
425
|
-
data: [],
|
|
426
|
-
encoding: {
|
|
427
|
-
x: { field: 'date', type: 'temporal' },
|
|
428
|
-
y: { field: 'value', type: 'quantitative' },
|
|
429
|
-
},
|
|
430
|
-
chrome: {},
|
|
431
|
-
annotations: [],
|
|
432
|
-
responsive: true,
|
|
433
|
-
theme: {},
|
|
434
|
-
darkMode: 'off',
|
|
435
|
-
labels: { density: 'auto', format: '' },
|
|
436
|
-
};
|
|
437
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
438
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
439
|
-
expect(marks).toHaveLength(0);
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
// ---------------------------------------------------------------------------
|
|
445
|
-
// Area mark computation tests
|
|
446
|
-
// ---------------------------------------------------------------------------
|
|
447
|
-
|
|
448
|
-
describe('computeAreaMarks', () => {
|
|
449
|
-
it('produces an AreaMark with a non-empty path for single series', () => {
|
|
450
|
-
const spec = makeSingleSeriesSpec();
|
|
451
|
-
// Change type to 'area' for the renderer (though compute doesn't check type)
|
|
452
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
453
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
454
|
-
|
|
455
|
-
expect(marks).toHaveLength(1);
|
|
456
|
-
expect(marks[0].type).toBe('area');
|
|
457
|
-
expect(marks[0].path).toBeTruthy();
|
|
458
|
-
expect(marks[0].path.length).toBeGreaterThan(0);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it('area mark has top and bottom boundary points', () => {
|
|
462
|
-
const spec = makeSingleSeriesSpec();
|
|
463
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
464
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
465
|
-
|
|
466
|
-
const area = marks[0];
|
|
467
|
-
expect(area.topPoints).toHaveLength(3);
|
|
468
|
-
expect(area.bottomPoints).toHaveLength(3);
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it('area fill has appropriate opacity', () => {
|
|
472
|
-
const spec = makeSingleSeriesSpec();
|
|
473
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
474
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
475
|
-
|
|
476
|
-
expect(marks[0].fillOpacity).toBeGreaterThan(0);
|
|
477
|
-
expect(marks[0].fillOpacity).toBeLessThanOrEqual(1);
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
it('area with y2 encoding uses y2 field as bottom boundary instead of baseline', () => {
|
|
481
|
-
const spec: NormalizedChartSpec = {
|
|
482
|
-
...makeSingleSeriesSpec(),
|
|
483
|
-
data: [
|
|
484
|
-
{ date: '2020-01-01', value: 80, value_low: 60 },
|
|
485
|
-
{ date: '2021-01-01', value: 90, value_low: 70 },
|
|
486
|
-
{ date: '2022-01-01', value: 85, value_low: 65 },
|
|
487
|
-
],
|
|
488
|
-
encoding: {
|
|
489
|
-
x: { field: 'date', type: 'temporal' },
|
|
490
|
-
y: { field: 'value', type: 'quantitative' },
|
|
491
|
-
y2: { field: 'value_low', type: 'quantitative' },
|
|
492
|
-
},
|
|
493
|
-
};
|
|
494
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
495
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
496
|
-
|
|
497
|
-
expect(marks).toHaveLength(1);
|
|
498
|
-
// Bottom points should NOT all be at the same baseline y coordinate
|
|
499
|
-
const bottomYValues = marks[0].bottomPoints.map((p) => p.y);
|
|
500
|
-
const allSame = bottomYValues.every((y) => y === bottomYValues[0]);
|
|
501
|
-
expect(allSame).toBe(false);
|
|
502
|
-
// Each bottom point should be between the top point and the chart bottom
|
|
503
|
-
for (let i = 0; i < marks[0].topPoints.length; i++) {
|
|
504
|
-
expect(marks[0].bottomPoints[i].y).toBeGreaterThan(marks[0].topPoints[i].y); // SVG coords: larger y = lower on screen
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('stacked areas: produces multiple AreaMarks for multi-series with stack: "zero"', () => {
|
|
509
|
-
const spec = makeMultiSeriesSpec();
|
|
510
|
-
spec.encoding.y!.stack = 'zero';
|
|
511
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
512
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
513
|
-
|
|
514
|
-
expect(marks.length).toBeGreaterThanOrEqual(2);
|
|
515
|
-
const seriesKeys = marks.map((m) => m.seriesKey).filter(Boolean);
|
|
516
|
-
expect(seriesKeys).toContain('US');
|
|
517
|
-
expect(seriesKeys).toContain('UK');
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
it('stacked (default): produces multiple AreaMarks for multi-series without an explicit stack', () => {
|
|
521
|
-
// v8 VL-aligned default: multi-series with color stacks unless `stack: null | false`.
|
|
522
|
-
const spec = makeMultiSeriesSpec();
|
|
523
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
524
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
525
|
-
|
|
526
|
-
expect(marks).toHaveLength(2);
|
|
527
|
-
const seriesKeys = marks.map((m) => m.seriesKey).filter(Boolean);
|
|
528
|
-
expect(seriesKeys).toContain('US');
|
|
529
|
-
expect(seriesKeys).toContain('UK');
|
|
530
|
-
// Stacked layers should have different baselines (one stacks on top of the other)
|
|
531
|
-
const firstBottom = marks[0].bottomPoints[0]?.y;
|
|
532
|
-
const secondBottom = marks[1].bottomPoints[0]?.y;
|
|
533
|
-
expect(firstBottom).not.toBe(secondBottom);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it('overlap (stack: null): every series shares the same baseline (no stacking offset)', () => {
|
|
537
|
-
const spec = makeMultiSeriesSpec();
|
|
538
|
-
spec.encoding.y!.stack = null;
|
|
539
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
540
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
541
|
-
|
|
542
|
-
// All bottom points across all series should be at the same baseline.
|
|
543
|
-
const baselines = new Set<number>();
|
|
544
|
-
for (const mark of marks) {
|
|
545
|
-
for (const p of mark.bottomPoints) {
|
|
546
|
-
baselines.add(p.y);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
expect(baselines.size).toBe(1);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it('overlap (stack: null): each series uses a translucent gradient fill', () => {
|
|
553
|
-
const spec = makeMultiSeriesSpec();
|
|
554
|
-
spec.encoding.y!.stack = null;
|
|
555
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
556
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
557
|
-
|
|
558
|
-
expect(marks.length).toBeGreaterThan(0);
|
|
559
|
-
for (const mark of marks) {
|
|
560
|
-
expect(typeof mark.fill).toBe('object');
|
|
561
|
-
const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
|
|
562
|
-
expect(fill.gradient).toBe('linear');
|
|
563
|
-
// Overlap stops are calibrated lower than solo stops so layered bands stay legible.
|
|
564
|
-
expect(fill.stops[0].opacity).toBe(0.22);
|
|
565
|
-
expect(fill.stops[fill.stops.length - 1].opacity).toBe(0);
|
|
566
|
-
}
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
it('stack: false opts into overlap rendering, same as stack: null', () => {
|
|
570
|
-
const spec = makeMultiSeriesSpec();
|
|
571
|
-
spec.encoding.y!.stack = false;
|
|
572
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
573
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
574
|
-
|
|
575
|
-
expect(marks).toHaveLength(2);
|
|
576
|
-
const baselines = new Set(marks.flatMap((m) => m.bottomPoints.map((p) => p.y)));
|
|
577
|
-
expect(baselines.size).toBe(1);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('stack: true opts into stacked rendering', () => {
|
|
581
|
-
const spec = makeMultiSeriesSpec();
|
|
582
|
-
spec.encoding.y!.stack = true;
|
|
583
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
584
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
585
|
-
|
|
586
|
-
expect(marks).toHaveLength(2);
|
|
587
|
-
// Stacked layers should have different baselines (one stacks on top of the other)
|
|
588
|
-
const firstBottom = marks[0].bottomPoints[0]?.y;
|
|
589
|
-
const secondBottom = marks[1].bottomPoints[0]?.y;
|
|
590
|
-
expect(firstBottom).not.toBe(secondBottom);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
it('solo (single series) uses the richer solo gradient', () => {
|
|
594
|
-
const spec = makeSingleSeriesSpec();
|
|
595
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
596
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
597
|
-
|
|
598
|
-
expect(marks).toHaveLength(1);
|
|
599
|
-
const fill = marks[0].fill as { gradient: string; stops: { opacity?: number }[] };
|
|
600
|
-
expect(fill.gradient).toBe('linear');
|
|
601
|
-
// Solo stops are heavier than overlap stops since there's no overlap.
|
|
602
|
-
expect(fill.stops[0].opacity).toBe(0.42);
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
it('stacked areas use a top-to-bottom gradient fill (not flat opacity)', () => {
|
|
606
|
-
const spec = makeMultiSeriesSpec();
|
|
607
|
-
spec.encoding.y!.stack = 'zero';
|
|
608
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
609
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
610
|
-
|
|
611
|
-
expect(marks.length).toBeGreaterThan(0);
|
|
612
|
-
for (const mark of marks) {
|
|
613
|
-
const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
|
|
614
|
-
expect(fill.gradient).toBe('linear');
|
|
615
|
-
expect(fill.stops).toHaveLength(2);
|
|
616
|
-
expect(fill.stops[0].opacity).toBe(0.65);
|
|
617
|
-
// Light mode: bottom fades to 0 so the colored wash at the base is avoided
|
|
618
|
-
expect(fill.stops[1].opacity).toBe(0);
|
|
619
|
-
// fillOpacity should be 1 so gradient stop-opacity controls the fade
|
|
620
|
-
expect(mark.fillOpacity).toBe(1);
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
it('stacked areas use higher bottom opacity in dark mode', () => {
|
|
625
|
-
const spec = makeMultiSeriesSpec();
|
|
626
|
-
spec.encoding.y!.stack = 'zero';
|
|
627
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
628
|
-
const marks = computeAreaMarks(spec, scales, chartArea, true /* darkMode */);
|
|
629
|
-
|
|
630
|
-
expect(marks.length).toBeGreaterThan(0);
|
|
631
|
-
for (const mark of marks) {
|
|
632
|
-
const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
|
|
633
|
-
expect(fill.gradient).toBe('linear');
|
|
634
|
-
expect(fill.stops).toHaveLength(2);
|
|
635
|
-
expect(fill.stops[0].opacity).toBe(0.65);
|
|
636
|
-
// Dark mode: bottom stop is 0.35 so bands remain visible on dark surfaces
|
|
637
|
-
expect(fill.stops[1].opacity).toBe(0.35);
|
|
638
|
-
expect(mark.fillOpacity).toBe(1);
|
|
639
|
-
}
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it('stacked: markDef.fill string still overrides per-layer gradient', () => {
|
|
643
|
-
const spec = makeMultiSeriesSpec();
|
|
644
|
-
spec.encoding.y!.stack = 'zero';
|
|
645
|
-
spec.markDef = { type: 'area', fill: '#ff00ff' };
|
|
646
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
647
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
648
|
-
|
|
649
|
-
for (const mark of marks) {
|
|
650
|
-
expect(mark.fill).toBe('#ff00ff');
|
|
651
|
-
// Falls back to the historical 0.7 fillOpacity when user supplies flat color
|
|
652
|
-
expect(mark.fillOpacity).toBe(0.7);
|
|
653
|
-
}
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
describe('x-axis sorting', () => {
|
|
657
|
-
it('sorts unsorted temporal data for single area', () => {
|
|
658
|
-
const spec: NormalizedChartSpec = {
|
|
659
|
-
...makeSingleSeriesSpec(),
|
|
660
|
-
data: [
|
|
661
|
-
{ date: '2022-01-01', value: 30 },
|
|
662
|
-
{ date: '2020-01-01', value: 10 },
|
|
663
|
-
{ date: '2021-01-01', value: 40 },
|
|
664
|
-
],
|
|
665
|
-
};
|
|
666
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
667
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
668
|
-
|
|
669
|
-
expect(marks).toHaveLength(1);
|
|
670
|
-
for (let i = 1; i < marks[0].topPoints.length; i++) {
|
|
671
|
-
expect(marks[0].topPoints[i].x).toBeGreaterThan(marks[0].topPoints[i - 1].x);
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
it('sorts unsorted temporal data for stacked area', () => {
|
|
676
|
-
const base = makeMultiSeriesSpec();
|
|
677
|
-
base.encoding.y!.stack = 'zero';
|
|
678
|
-
const spec: NormalizedChartSpec = {
|
|
679
|
-
...base,
|
|
680
|
-
data: [
|
|
681
|
-
{ date: '2022-01-01', value: 30, country: 'US' },
|
|
682
|
-
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
683
|
-
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
684
|
-
{ date: '2022-01-01', value: 45, country: 'UK' },
|
|
685
|
-
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
686
|
-
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
687
|
-
],
|
|
688
|
-
};
|
|
689
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
690
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
691
|
-
|
|
692
|
-
for (const mark of marks) {
|
|
693
|
-
for (let i = 1; i < mark.topPoints.length; i++) {
|
|
694
|
-
expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
it('sorts unsorted temporal data for overlap (stack: null)', () => {
|
|
700
|
-
const base = makeMultiSeriesSpec();
|
|
701
|
-
base.encoding.y!.stack = null;
|
|
702
|
-
const spec: NormalizedChartSpec = {
|
|
703
|
-
...base,
|
|
704
|
-
data: [
|
|
705
|
-
{ date: '2022-01-01', value: 30, country: 'US' },
|
|
706
|
-
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
707
|
-
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
708
|
-
{ date: '2022-01-01', value: 45, country: 'UK' },
|
|
709
|
-
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
710
|
-
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
711
|
-
],
|
|
712
|
-
};
|
|
713
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
714
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
715
|
-
|
|
716
|
-
expect(marks).toHaveLength(2);
|
|
717
|
-
for (const mark of marks) {
|
|
718
|
-
for (let i = 1; i < mark.topPoints.length; i++) {
|
|
719
|
-
expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
it('attaches sorted data rows on single area marks', () => {
|
|
725
|
-
const spec: NormalizedChartSpec = {
|
|
726
|
-
...makeSingleSeriesSpec(),
|
|
727
|
-
data: [
|
|
728
|
-
{ date: '2022-01-01', value: 30 },
|
|
729
|
-
{ date: '2020-01-01', value: 10 },
|
|
730
|
-
{ date: '2021-01-01', value: 40 },
|
|
731
|
-
],
|
|
732
|
-
};
|
|
733
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
734
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
735
|
-
|
|
736
|
-
const dates = marks[0].data!.map((r) => r.date);
|
|
737
|
-
expect(dates).toEqual(['2020-01-01', '2021-01-01', '2022-01-01']);
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
it('sorts stacked area with 3+ series and shuffled dates', () => {
|
|
741
|
-
const spec: NormalizedChartSpec = {
|
|
742
|
-
markType: 'line',
|
|
743
|
-
markDef: { type: 'line', point: true },
|
|
744
|
-
data: [
|
|
745
|
-
{ date: '2022-01-01', value: 30, region: 'A' },
|
|
746
|
-
{ date: '2020-01-01', value: 10, region: 'A' },
|
|
747
|
-
{ date: '2021-01-01', value: 20, region: 'A' },
|
|
748
|
-
{ date: '2021-01-01', value: 25, region: 'B' },
|
|
749
|
-
{ date: '2022-01-01', value: 35, region: 'B' },
|
|
750
|
-
{ date: '2020-01-01', value: 15, region: 'B' },
|
|
751
|
-
{ date: '2022-01-01', value: 40, region: 'C' },
|
|
752
|
-
{ date: '2020-01-01', value: 5, region: 'C' },
|
|
753
|
-
{ date: '2021-01-01', value: 30, region: 'C' },
|
|
754
|
-
],
|
|
755
|
-
encoding: {
|
|
756
|
-
x: { field: 'date', type: 'temporal' },
|
|
757
|
-
y: { field: 'value', type: 'quantitative', stack: 'zero' },
|
|
758
|
-
color: { field: 'region', type: 'nominal' },
|
|
759
|
-
},
|
|
760
|
-
chrome: {},
|
|
761
|
-
annotations: [],
|
|
762
|
-
responsive: true,
|
|
763
|
-
theme: {},
|
|
764
|
-
darkMode: 'off',
|
|
765
|
-
labels: { density: 'auto', format: '' },
|
|
766
|
-
};
|
|
767
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
768
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
769
|
-
|
|
770
|
-
expect(marks).toHaveLength(3);
|
|
771
|
-
for (const mark of marks) {
|
|
772
|
-
for (let i = 1; i < mark.topPoints.length; i++) {
|
|
773
|
-
expect(mark.topPoints[i].x).toBeGreaterThan(mark.topPoints[i - 1].x);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
it('produces identical output for pre-sorted and shuffled single area data', () => {
|
|
779
|
-
const preSorted = makeSingleSeriesSpec();
|
|
780
|
-
const shuffled: NormalizedChartSpec = {
|
|
781
|
-
...preSorted,
|
|
782
|
-
data: [
|
|
783
|
-
{ date: '2021-01-01', value: 40 },
|
|
784
|
-
{ date: '2020-01-01', value: 10 },
|
|
785
|
-
{ date: '2022-01-01', value: 30 },
|
|
786
|
-
],
|
|
787
|
-
};
|
|
788
|
-
|
|
789
|
-
const preSortedScales = computeScales(preSorted, chartArea, preSorted.data);
|
|
790
|
-
const preSortedMarks = computeAreaMarks(preSorted, preSortedScales, chartArea);
|
|
791
|
-
|
|
792
|
-
const shuffledScales = computeScales(shuffled, chartArea, shuffled.data);
|
|
793
|
-
const shuffledMarks = computeAreaMarks(shuffled, shuffledScales, chartArea);
|
|
794
|
-
|
|
795
|
-
expect(preSortedMarks[0].topPoints).toEqual(shuffledMarks[0].topPoints);
|
|
796
|
-
expect(preSortedMarks[0].bottomPoints).toEqual(shuffledMarks[0].bottomPoints);
|
|
797
|
-
});
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it('stacked areas: each layer has different baselines', () => {
|
|
801
|
-
const spec = makeMultiSeriesSpec();
|
|
802
|
-
spec.encoding.y!.stack = 'zero';
|
|
803
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
804
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
805
|
-
|
|
806
|
-
// First layer should start at y=0 baseline, second layer starts higher
|
|
807
|
-
if (marks.length >= 2) {
|
|
808
|
-
const firstBottom = marks[0].bottomPoints[0]?.y;
|
|
809
|
-
const secondBottom = marks[1].bottomPoints[0]?.y;
|
|
810
|
-
// They should be different (stacked offset)
|
|
811
|
-
expect(firstBottom).not.toBe(secondBottom);
|
|
812
|
-
}
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
it('stacked areas: y-domain covers the stacked sum, not individual max', () => {
|
|
816
|
-
// Three series each with value 100 at the same x point. The stacked sum
|
|
817
|
-
// is 300, so the y-scale domain must go up to at least 300. Without the
|
|
818
|
-
// stacked domain fix, the domain only reaches 100 and the top layers clip.
|
|
819
|
-
const spec: NormalizedChartSpec = {
|
|
820
|
-
markType: 'area',
|
|
821
|
-
markDef: { type: 'area' },
|
|
822
|
-
data: [
|
|
823
|
-
{ date: '2020-01-01', value: 100, group: 'A' },
|
|
824
|
-
{ date: '2021-01-01', value: 100, group: 'A' },
|
|
825
|
-
{ date: '2020-01-01', value: 100, group: 'B' },
|
|
826
|
-
{ date: '2021-01-01', value: 100, group: 'B' },
|
|
827
|
-
{ date: '2020-01-01', value: 100, group: 'C' },
|
|
828
|
-
{ date: '2021-01-01', value: 100, group: 'C' },
|
|
829
|
-
],
|
|
830
|
-
encoding: {
|
|
831
|
-
x: { field: 'date', type: 'temporal' },
|
|
832
|
-
y: { field: 'value', type: 'quantitative', stack: 'zero' },
|
|
833
|
-
color: { field: 'group', type: 'nominal' },
|
|
834
|
-
},
|
|
835
|
-
chrome: {},
|
|
836
|
-
annotations: [],
|
|
837
|
-
responsive: true,
|
|
838
|
-
theme: {},
|
|
839
|
-
darkMode: 'off',
|
|
840
|
-
labels: { density: 'auto', format: '' },
|
|
841
|
-
};
|
|
842
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
843
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
844
|
-
|
|
845
|
-
expect(marks).toHaveLength(3);
|
|
846
|
-
|
|
847
|
-
// The topmost layer's top points should be within the chart area, not
|
|
848
|
-
// clipped beyond it. With a proper stacked domain the y-scale covers
|
|
849
|
-
// 0..300 (niced), so all pixel positions stay within bounds.
|
|
850
|
-
const lastLayer = marks[marks.length - 1];
|
|
851
|
-
for (const pt of lastLayer.topPoints) {
|
|
852
|
-
expect(pt.y).toBeGreaterThanOrEqual(chartArea.y);
|
|
853
|
-
expect(pt.y).toBeLessThanOrEqual(chartArea.y + chartArea.height);
|
|
854
|
-
}
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
it('returns empty marks for unparseable temporal data', () => {
|
|
858
|
-
// Quarterly strings like '2022-Q1' are not valid Date strings. The engine
|
|
859
|
-
// should handle this gracefully (empty marks) rather than crashing or
|
|
860
|
-
// producing NaN-filled paths.
|
|
861
|
-
const spec: NormalizedChartSpec = {
|
|
862
|
-
markType: 'area',
|
|
863
|
-
markDef: { type: 'area' },
|
|
864
|
-
data: [
|
|
865
|
-
{ quarter: '2022-Q1', revenue: 45, segment: 'Services' },
|
|
866
|
-
{ quarter: '2022-Q2', revenue: 52, segment: 'Services' },
|
|
867
|
-
{ quarter: '2022-Q1', revenue: 120, segment: 'Products' },
|
|
868
|
-
{ quarter: '2022-Q2', revenue: 135, segment: 'Products' },
|
|
869
|
-
],
|
|
870
|
-
encoding: {
|
|
871
|
-
x: { field: 'quarter', type: 'temporal' },
|
|
872
|
-
y: { field: 'revenue', type: 'quantitative' },
|
|
873
|
-
color: { field: 'segment', type: 'nominal' },
|
|
874
|
-
},
|
|
875
|
-
chrome: {},
|
|
876
|
-
annotations: [],
|
|
877
|
-
responsive: true,
|
|
878
|
-
theme: {},
|
|
879
|
-
darkMode: 'off',
|
|
880
|
-
labels: { density: 'auto', format: '' },
|
|
881
|
-
};
|
|
882
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
883
|
-
const marks = computeAreaMarks(spec, scales, chartArea);
|
|
884
|
-
|
|
885
|
-
// Should produce empty marks since dates can't be parsed, not crash
|
|
886
|
-
expect(marks).toHaveLength(0);
|
|
887
|
-
});
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
// ---------------------------------------------------------------------------
|
|
891
|
-
// Label computation tests
|
|
892
|
-
// ---------------------------------------------------------------------------
|
|
893
|
-
|
|
894
|
-
describe('computeLineLabels', () => {
|
|
895
|
-
it('produces labels for multi-series line marks', () => {
|
|
896
|
-
const spec = makeMultiSeriesSpec();
|
|
897
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
898
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
899
|
-
|
|
900
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
901
|
-
const labelMap = computeLineLabels(lineMarks, fullStrategy);
|
|
902
|
-
|
|
903
|
-
expect(labelMap.size).toBe(2);
|
|
904
|
-
expect(labelMap.has('US')).toBe(true);
|
|
905
|
-
expect(labelMap.has('UK')).toBe(true);
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
it('labels are positioned at end of line', () => {
|
|
909
|
-
const spec = makeMultiSeriesSpec();
|
|
910
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
911
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
912
|
-
|
|
913
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
914
|
-
const labelMap = computeLineLabels(lineMarks, fullStrategy);
|
|
915
|
-
|
|
916
|
-
for (const lineMark of lineMarks) {
|
|
917
|
-
if (!lineMark.seriesKey) continue;
|
|
918
|
-
const lastPoint = lineMark.points[lineMark.points.length - 1];
|
|
919
|
-
const label = labelMap.get(lineMark.seriesKey);
|
|
920
|
-
expect(label).toBeDefined();
|
|
921
|
-
// Label x should be to the right of the last point
|
|
922
|
-
expect(label!.x).toBeGreaterThan(lastPoint.x);
|
|
923
|
-
}
|
|
924
|
-
});
|
|
925
|
-
|
|
926
|
-
it('labels are visible at full width', () => {
|
|
927
|
-
const spec = makeMultiSeriesSpec();
|
|
928
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
929
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
930
|
-
|
|
931
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
932
|
-
const labelMap = computeLineLabels(lineMarks, fullStrategy);
|
|
933
|
-
|
|
934
|
-
// At least some labels should be visible
|
|
935
|
-
const labels = Array.from(labelMap.values());
|
|
936
|
-
expect(labels.some((l) => l.visible)).toBe(true);
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
it('labels collapse at compact breakpoint', () => {
|
|
940
|
-
const spec = makeMultiSeriesSpec();
|
|
941
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
942
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, compactStrategy);
|
|
943
|
-
|
|
944
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
945
|
-
const labelMap = computeLineLabels(lineMarks, compactStrategy);
|
|
946
|
-
|
|
947
|
-
// At compact, no labels should be produced
|
|
948
|
-
expect(labelMap.size).toBe(0);
|
|
949
|
-
});
|
|
950
|
-
|
|
951
|
-
it('collision detection resolves overlapping labels', () => {
|
|
952
|
-
// Create a spec where series end at the same y position
|
|
953
|
-
const spec: NormalizedChartSpec = {
|
|
954
|
-
markType: 'line',
|
|
955
|
-
markDef: { type: 'line', point: true },
|
|
956
|
-
data: [
|
|
957
|
-
{ date: '2020-01-01', value: 10, country: 'A' },
|
|
958
|
-
{ date: '2021-01-01', value: 30, country: 'A' },
|
|
959
|
-
{ date: '2020-01-01', value: 10, country: 'B' },
|
|
960
|
-
{ date: '2021-01-01', value: 30, country: 'B' },
|
|
961
|
-
{ date: '2020-01-01', value: 10, country: 'C' },
|
|
962
|
-
{ date: '2021-01-01', value: 30, country: 'C' },
|
|
963
|
-
],
|
|
964
|
-
encoding: {
|
|
965
|
-
x: { field: 'date', type: 'temporal' },
|
|
966
|
-
y: { field: 'value', type: 'quantitative' },
|
|
967
|
-
color: { field: 'country', type: 'nominal' },
|
|
968
|
-
},
|
|
969
|
-
chrome: {},
|
|
970
|
-
annotations: [],
|
|
971
|
-
responsive: true,
|
|
972
|
-
theme: {},
|
|
973
|
-
darkMode: 'off',
|
|
974
|
-
labels: { density: 'auto', format: '' },
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
978
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
979
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
980
|
-
const labelMap = computeLineLabels(lineMarks, fullStrategy);
|
|
981
|
-
|
|
982
|
-
expect(labelMap.size).toBe(3);
|
|
983
|
-
|
|
984
|
-
// At least one label should be offset or demoted due to collision
|
|
985
|
-
const labels = Array.from(labelMap.values());
|
|
986
|
-
const positions = labels.map((l) => `${l.x},${l.y}`);
|
|
987
|
-
const uniquePositions = new Set(positions);
|
|
988
|
-
// If collision worked, positions should differ even though anchor points are the same
|
|
989
|
-
expect(uniquePositions.size).toBeGreaterThanOrEqual(2);
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
it('skips single series with no seriesKey', () => {
|
|
993
|
-
const spec = makeSingleSeriesSpec();
|
|
994
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
995
|
-
const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
996
|
-
|
|
997
|
-
const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line');
|
|
998
|
-
const labelMap = computeLineLabels(lineMarks, fullStrategy);
|
|
999
|
-
|
|
1000
|
-
// Single series has no seriesKey, so no label
|
|
1001
|
-
expect(labelMap.size).toBe(0);
|
|
1002
|
-
});
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
// ---------------------------------------------------------------------------
|
|
1006
|
-
// seriesStyles tests
|
|
1007
|
-
// ---------------------------------------------------------------------------
|
|
1008
|
-
|
|
1009
|
-
describe('seriesStyles', () => {
|
|
1010
|
-
it('applies dashed line style to a specific series', () => {
|
|
1011
|
-
const spec = makeMultiSeriesSpec();
|
|
1012
|
-
spec.seriesStyles = { UK: { lineStyle: 'dashed' } };
|
|
1013
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1014
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1015
|
-
|
|
1016
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
1017
|
-
const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
|
|
1018
|
-
const usLine = lineMarks.find((m) => m.seriesKey === 'US');
|
|
1019
|
-
|
|
1020
|
-
expect(ukLine?.strokeDasharray).toBe('6 4');
|
|
1021
|
-
expect(usLine?.strokeDasharray).toBeUndefined();
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
it('applies dotted line style', () => {
|
|
1025
|
-
const spec = makeMultiSeriesSpec();
|
|
1026
|
-
spec.seriesStyles = { US: { lineStyle: 'dotted' } };
|
|
1027
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1028
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1029
|
-
|
|
1030
|
-
const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
|
|
1031
|
-
expect(usLine?.strokeDasharray).toBe('2 3');
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
it('hides point markers when showPoints is false', () => {
|
|
1035
|
-
const spec = makeMultiSeriesSpec();
|
|
1036
|
-
spec.seriesStyles = { UK: { showPoints: false } };
|
|
1037
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1038
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1039
|
-
|
|
1040
|
-
const ukPoints = marks.filter(
|
|
1041
|
-
(m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
|
|
1042
|
-
);
|
|
1043
|
-
const usPoints = marks.filter(
|
|
1044
|
-
(m): m is PointMark => m.type === 'point' && m.data.country === 'US',
|
|
1045
|
-
);
|
|
1046
|
-
|
|
1047
|
-
// UK points should have r=0 (hidden)
|
|
1048
|
-
expect(ukPoints.every((p) => p.r === 0)).toBe(true);
|
|
1049
|
-
// US points should still have default radius
|
|
1050
|
-
expect(usPoints.every((p) => p.r > 0)).toBe(true);
|
|
1051
|
-
});
|
|
1052
|
-
|
|
1053
|
-
it('overrides strokeWidth for a series', () => {
|
|
1054
|
-
const spec = makeMultiSeriesSpec();
|
|
1055
|
-
spec.seriesStyles = { UK: { strokeWidth: 1.5 } };
|
|
1056
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1057
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1058
|
-
|
|
1059
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
1060
|
-
const ukLine = lineMarks.find((m) => m.seriesKey === 'UK');
|
|
1061
|
-
const usLine = lineMarks.find((m) => m.seriesKey === 'US');
|
|
1062
|
-
|
|
1063
|
-
expect(ukLine?.strokeWidth).toBe(1.5);
|
|
1064
|
-
expect(usLine?.strokeWidth).toBe(1.5); // default
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it('sets opacity on a series', () => {
|
|
1068
|
-
const spec = makeMultiSeriesSpec();
|
|
1069
|
-
spec.seriesStyles = { UK: { opacity: 0.5 } };
|
|
1070
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1071
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1072
|
-
|
|
1073
|
-
const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
|
|
1074
|
-
const usLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'US');
|
|
1075
|
-
|
|
1076
|
-
expect(ukLine?.opacity).toBe(0.5);
|
|
1077
|
-
expect(usLine?.opacity).toBeUndefined();
|
|
1078
|
-
});
|
|
1079
|
-
|
|
1080
|
-
it('combines multiple style overrides on the same series', () => {
|
|
1081
|
-
const spec = makeMultiSeriesSpec();
|
|
1082
|
-
spec.seriesStyles = {
|
|
1083
|
-
UK: { lineStyle: 'dashed', showPoints: false, strokeWidth: 1, opacity: 0.6 },
|
|
1084
|
-
};
|
|
1085
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1086
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1087
|
-
|
|
1088
|
-
const ukLine = marks.find((m): m is LineMark => m.type === 'line' && m.seriesKey === 'UK');
|
|
1089
|
-
expect(ukLine?.strokeDasharray).toBe('6 4');
|
|
1090
|
-
expect(ukLine?.strokeWidth).toBe(1);
|
|
1091
|
-
expect(ukLine?.opacity).toBe(0.6);
|
|
1092
|
-
|
|
1093
|
-
const ukPoints = marks.filter(
|
|
1094
|
-
(m): m is PointMark => m.type === 'point' && m.data.country === 'UK',
|
|
1095
|
-
);
|
|
1096
|
-
expect(ukPoints.every((p) => p.r === 0)).toBe(true);
|
|
1097
|
-
});
|
|
1098
|
-
|
|
1099
|
-
it('does not apply styles when seriesStyles is empty', () => {
|
|
1100
|
-
const spec = makeMultiSeriesSpec();
|
|
1101
|
-
spec.seriesStyles = {};
|
|
1102
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1103
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1104
|
-
|
|
1105
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
1106
|
-
for (const line of lineMarks) {
|
|
1107
|
-
expect(line.strokeDasharray).toBeUndefined();
|
|
1108
|
-
expect(line.opacity).toBeUndefined();
|
|
1109
|
-
expect(line.strokeWidth).toBe(1.5);
|
|
1110
|
-
}
|
|
1111
|
-
});
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
// ---------------------------------------------------------------------------
|
|
1115
|
-
// Sequential (quantitative) color
|
|
1116
|
-
// ---------------------------------------------------------------------------
|
|
1117
|
-
|
|
1118
|
-
describe('sequential color encoding', () => {
|
|
1119
|
-
function makeSequentialColorSpec(): NormalizedChartSpec {
|
|
1120
|
-
return {
|
|
1121
|
-
markType: 'line',
|
|
1122
|
-
markDef: { type: 'line' },
|
|
1123
|
-
data: [
|
|
1124
|
-
{ date: '2020-01-01', value: 10 },
|
|
1125
|
-
{ date: '2021-01-01', value: 40 },
|
|
1126
|
-
{ date: '2022-01-01', value: 30 },
|
|
1127
|
-
],
|
|
1128
|
-
encoding: {
|
|
1129
|
-
x: { field: 'date', type: 'temporal' },
|
|
1130
|
-
y: { field: 'value', type: 'quantitative' },
|
|
1131
|
-
color: { field: 'value', type: 'quantitative' },
|
|
1132
|
-
},
|
|
1133
|
-
chrome: {},
|
|
1134
|
-
annotations: [],
|
|
1135
|
-
responsive: true,
|
|
1136
|
-
theme: {},
|
|
1137
|
-
darkMode: 'off',
|
|
1138
|
-
labels: { density: 'auto', format: '' },
|
|
1139
|
-
};
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
it('produces a single line mark (no grouping) with sequential color', () => {
|
|
1143
|
-
const spec = makeSequentialColorSpec();
|
|
1144
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1145
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1146
|
-
|
|
1147
|
-
const lineMarks = marks.filter((m): m is LineMark => m.type === 'line');
|
|
1148
|
-
expect(lineMarks).toHaveLength(1);
|
|
1149
|
-
// Should not group into multiple series
|
|
1150
|
-
expect(lineMarks[0].seriesKey).toBeUndefined();
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
it('auto-shows point marks for sequential color', () => {
|
|
1154
|
-
const spec = makeSequentialColorSpec();
|
|
1155
|
-
// markDef.point is NOT set, but points should appear anyway for sequential color
|
|
1156
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1157
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1158
|
-
|
|
1159
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
1160
|
-
expect(pointMarks).toHaveLength(3);
|
|
1161
|
-
// Points should be visible (r > 0)
|
|
1162
|
-
expect(pointMarks.every((p) => p.r > 0)).toBe(true);
|
|
1163
|
-
});
|
|
1164
|
-
|
|
1165
|
-
it('assigns different colors to points based on data value', () => {
|
|
1166
|
-
const spec = makeSequentialColorSpec();
|
|
1167
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1168
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1169
|
-
|
|
1170
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
1171
|
-
// The three points have values 10, 40, 30 so should get distinct colors
|
|
1172
|
-
const colors = pointMarks.map((p) => p.fill);
|
|
1173
|
-
// Min (10) and max (40) should definitely differ
|
|
1174
|
-
expect(colors[0]).not.toBe(colors[1]);
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
it('handles NaN values gracefully in sequential color', () => {
|
|
1178
|
-
const spec = makeSequentialColorSpec();
|
|
1179
|
-
spec.data = [
|
|
1180
|
-
{ date: '2020-01-01', value: 10 },
|
|
1181
|
-
{ date: '2021-01-01', value: 'not a number' },
|
|
1182
|
-
{ date: '2022-01-01', value: 30 },
|
|
1183
|
-
];
|
|
1184
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1185
|
-
// Should not throw
|
|
1186
|
-
const marks = computeLineMarks(spec, scales, chartArea, fullStrategy);
|
|
1187
|
-
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
1188
|
-
// All points should have a valid fill color (string)
|
|
1189
|
-
for (const p of pointMarks) {
|
|
1190
|
-
expect(typeof p.fill).toBe('string');
|
|
1191
|
-
expect(p.fill.length).toBeGreaterThan(0);
|
|
1192
|
-
}
|
|
1193
|
-
});
|
|
1194
|
-
});
|