@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20
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 +140 -2
- package/dist/index.js +1403 -210
- 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 -2339
- package/src/__tests__/annotation-behavior.test.ts +0 -721
- 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 -215
- 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 -370
- 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 -539
- 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 -626
- package/src/compile/size-scale.ts +0 -139
- package/src/compile/spec-sugar.ts +0 -758
- package/src/compile/watermark-obstacle.ts +0 -54
- package/src/compile/you-draw-it.ts +0 -123
- package/src/compile.ts +0 -1450
- 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 -86
- package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
- package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
- package/src/compiler/__tests__/validate.test.ts +0 -629
- 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 -733
- package/src/compiler/sparkline-defaults.ts +0 -138
- package/src/compiler/types.ts +0 -241
- package/src/compiler/validate.ts +0 -1810
- 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 -621
- package/src/geo/compile-map.ts +0 -856
- package/src/geo/join.ts +0 -115
- package/src/geo/projections.ts +0 -45
- package/src/geo/types.ts +0 -24
- 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 -747
- 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,934 +0,0 @@
|
|
|
1
|
-
import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { buildD3Formatter } from '@opendata-ai/openchart-core';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import type { NormalizedChartSpec } from '../../../compiler/types';
|
|
5
|
-
import { computeScales } from '../../../layout/scales';
|
|
6
|
-
import { computeBarMarks } from '../compute';
|
|
7
|
-
import { computeBarLabels } from '../labels';
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Shared fixtures
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const chartArea: Rect = { x: 80, y: 20, width: 500, height: 300 };
|
|
14
|
-
|
|
15
|
-
// Default container width for tests that don't exercise the narrow-container
|
|
16
|
-
// grouped-bar reclaim gate. Wide enough (>= NARROW_VIEWPORT_MAX) that the gate
|
|
17
|
-
// stays off, matching the prior default geometry.
|
|
18
|
-
const CONTAINER_WIDTH = 800;
|
|
19
|
-
|
|
20
|
-
const fullStrategy: LayoutStrategy = {
|
|
21
|
-
labelMode: 'all',
|
|
22
|
-
legendPosition: 'right',
|
|
23
|
-
annotationPosition: 'inline',
|
|
24
|
-
axisLabelDensity: 'full',
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
function makeSimpleBarSpec(): NormalizedChartSpec {
|
|
28
|
-
return {
|
|
29
|
-
markType: 'bar',
|
|
30
|
-
markDef: { type: 'bar' },
|
|
31
|
-
data: [
|
|
32
|
-
{ category: 'Apple', value: 50 },
|
|
33
|
-
{ category: 'Banana', value: 30 },
|
|
34
|
-
{ category: 'Cherry', value: 70 },
|
|
35
|
-
],
|
|
36
|
-
encoding: {
|
|
37
|
-
x: { field: 'value', type: 'quantitative' },
|
|
38
|
-
y: { field: 'category', type: 'nominal' },
|
|
39
|
-
},
|
|
40
|
-
chrome: {},
|
|
41
|
-
annotations: [],
|
|
42
|
-
responsive: true,
|
|
43
|
-
theme: {},
|
|
44
|
-
darkMode: 'off',
|
|
45
|
-
labels: { density: 'auto', format: '' },
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function makeGroupedBarSpec(): NormalizedChartSpec {
|
|
50
|
-
return {
|
|
51
|
-
markType: 'bar',
|
|
52
|
-
markDef: { type: 'bar' },
|
|
53
|
-
data: [
|
|
54
|
-
{ category: 'Q1', value: 50, region: 'East' },
|
|
55
|
-
{ category: 'Q1', value: 40, region: 'West' },
|
|
56
|
-
{ category: 'Q2', value: 60, region: 'East' },
|
|
57
|
-
{ category: 'Q2', value: 55, region: 'West' },
|
|
58
|
-
{ category: 'Q3', value: 45, region: 'East' },
|
|
59
|
-
{ category: 'Q3', value: 70, region: 'West' },
|
|
60
|
-
],
|
|
61
|
-
encoding: {
|
|
62
|
-
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
63
|
-
// stack: null explicitly so this fixture stays grouped.
|
|
64
|
-
x: { field: 'value', type: 'quantitative', stack: null },
|
|
65
|
-
y: { field: 'category', type: 'nominal' },
|
|
66
|
-
color: { field: 'region', 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 makeNegativeBarSpec(): NormalizedChartSpec {
|
|
78
|
-
return {
|
|
79
|
-
markType: 'bar',
|
|
80
|
-
markDef: { type: 'bar' },
|
|
81
|
-
data: [
|
|
82
|
-
{ category: 'Growth', value: 15 },
|
|
83
|
-
{ category: 'Decline', value: -10 },
|
|
84
|
-
{ category: 'Stable', value: 2 },
|
|
85
|
-
],
|
|
86
|
-
encoding: {
|
|
87
|
-
x: { field: 'value', type: 'quantitative' },
|
|
88
|
-
y: { field: 'category', type: 'nominal' },
|
|
89
|
-
},
|
|
90
|
-
chrome: {},
|
|
91
|
-
annotations: [],
|
|
92
|
-
responsive: true,
|
|
93
|
-
theme: {},
|
|
94
|
-
darkMode: 'off',
|
|
95
|
-
labels: { density: 'auto', format: '' },
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ---------------------------------------------------------------------------
|
|
100
|
-
// computeBarMarks tests
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
|
|
103
|
-
describe('computeBarMarks', () => {
|
|
104
|
-
describe('simple bars', () => {
|
|
105
|
-
it('produces one RectMark per data row', () => {
|
|
106
|
-
const spec = makeSimpleBarSpec();
|
|
107
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
108
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
109
|
-
|
|
110
|
-
expect(marks).toHaveLength(3);
|
|
111
|
-
expect(marks.every((m) => m.type === 'rect')).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('bars have positive width and height', () => {
|
|
115
|
-
const spec = makeSimpleBarSpec();
|
|
116
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
117
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
118
|
-
|
|
119
|
-
for (const mark of marks) {
|
|
120
|
-
expect(mark.width).toBeGreaterThan(0);
|
|
121
|
-
expect(mark.height).toBeGreaterThan(0);
|
|
122
|
-
}
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('wider bars correspond to larger values', () => {
|
|
126
|
-
const spec = makeSimpleBarSpec();
|
|
127
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
128
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
129
|
-
|
|
130
|
-
// Cherry (70) should be wider than Banana (30)
|
|
131
|
-
const cherry = marks.find((m) => m.aria.label.includes('Cherry'))!;
|
|
132
|
-
const banana = marks.find((m) => m.aria.label.includes('Banana'))!;
|
|
133
|
-
expect(cherry.width).toBeGreaterThan(banana.width);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('bars have corner radius applied', () => {
|
|
137
|
-
const spec = makeSimpleBarSpec();
|
|
138
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
139
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
140
|
-
|
|
141
|
-
expect(marks[0].cornerRadius).toBe(2);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('each bar has an aria label with category and value', () => {
|
|
145
|
-
const spec = makeSimpleBarSpec();
|
|
146
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
147
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
148
|
-
|
|
149
|
-
expect(marks[0].aria.label).toContain('Apple');
|
|
150
|
-
expect(marks[0].aria.label).toContain('50');
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe('stacked bars (stack: zero)', () => {
|
|
155
|
-
function makeStackedBarSpec(): NormalizedChartSpec {
|
|
156
|
-
const spec = makeGroupedBarSpec();
|
|
157
|
-
(spec.encoding.x as { stack?: string }).stack = 'zero';
|
|
158
|
-
return spec;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
it('produces marks for all data rows', () => {
|
|
162
|
-
const spec = makeStackedBarSpec();
|
|
163
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
164
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
165
|
-
|
|
166
|
-
// 3 categories * 2 groups = 6
|
|
167
|
-
expect(marks).toHaveLength(6);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('segments within a category have different colors', () => {
|
|
171
|
-
const spec = makeStackedBarSpec();
|
|
172
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
173
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
174
|
-
|
|
175
|
-
// Find Q1 bars
|
|
176
|
-
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
177
|
-
expect(q1Marks).toHaveLength(2);
|
|
178
|
-
expect(q1Marks[0].fill).not.toBe(q1Marks[1].fill);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('stacked segments share the same y position within a category', () => {
|
|
182
|
-
const spec = makeStackedBarSpec();
|
|
183
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
184
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
185
|
-
|
|
186
|
-
const q1East = marks.find(
|
|
187
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
188
|
-
)!;
|
|
189
|
-
const q1West = marks.find(
|
|
190
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
191
|
-
)!;
|
|
192
|
-
|
|
193
|
-
// Stacked bars share the same y position (full band height)
|
|
194
|
-
expect(q1East.y).toBe(q1West.y);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it('stacked segments are placed end-to-end horizontally', () => {
|
|
198
|
-
const spec = makeStackedBarSpec();
|
|
199
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
200
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
201
|
-
|
|
202
|
-
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
203
|
-
// Second segment should start where first ends
|
|
204
|
-
const first = q1Marks[0];
|
|
205
|
-
const second = q1Marks[1];
|
|
206
|
-
expect(second.x).toBeCloseTo(first.x + first.width, 0);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('stacked bars have zero corner radius', () => {
|
|
210
|
-
const spec = makeStackedBarSpec();
|
|
211
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
212
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
213
|
-
|
|
214
|
-
for (const mark of marks) {
|
|
215
|
-
expect(mark.cornerRadius).toBe(0);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
describe('stack: undefined (omitted) stacks by default (v8 VL-aligned default)', () => {
|
|
221
|
-
function makeDefaultStackBarSpec(): NormalizedChartSpec {
|
|
222
|
-
return {
|
|
223
|
-
markType: 'bar',
|
|
224
|
-
markDef: { type: 'bar' },
|
|
225
|
-
data: [
|
|
226
|
-
{ category: 'Q1', value: 50, region: 'East' },
|
|
227
|
-
{ category: 'Q1', value: 40, region: 'West' },
|
|
228
|
-
{ category: 'Q2', value: 60, region: 'East' },
|
|
229
|
-
{ category: 'Q2', value: 55, region: 'West' },
|
|
230
|
-
],
|
|
231
|
-
encoding: {
|
|
232
|
-
x: { field: 'value', type: 'quantitative' },
|
|
233
|
-
y: { field: 'category', type: 'nominal' },
|
|
234
|
-
color: { field: 'region', type: 'nominal' },
|
|
235
|
-
},
|
|
236
|
-
chrome: {},
|
|
237
|
-
annotations: [],
|
|
238
|
-
responsive: true,
|
|
239
|
-
theme: {},
|
|
240
|
-
darkMode: 'off',
|
|
241
|
-
labels: { density: 'auto', format: '' },
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
it('stacks segments end-to-end without an explicit stack property', () => {
|
|
246
|
-
const spec = makeDefaultStackBarSpec();
|
|
247
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
249
|
-
|
|
250
|
-
const q1Marks = marks.filter((m) => m.aria.label.includes('Q1'));
|
|
251
|
-
expect(q1Marks).toHaveLength(2);
|
|
252
|
-
expect(q1Marks[0].y).toBe(q1Marks[1].y);
|
|
253
|
-
const [first, second] = q1Marks;
|
|
254
|
-
expect(second.x).toBeCloseTo(first.x + first.width, 0);
|
|
255
|
-
expect(marks.every((m) => m.stackGroup !== undefined)).toBe(true);
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe('grouped bars (stack: null)', () => {
|
|
260
|
-
it('produces marks for all data rows', () => {
|
|
261
|
-
const spec = makeGroupedBarSpec();
|
|
262
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
263
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
264
|
-
|
|
265
|
-
expect(marks).toHaveLength(6);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
it('grouped bars within a category have different y positions', () => {
|
|
269
|
-
const spec = makeGroupedBarSpec();
|
|
270
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
271
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
272
|
-
|
|
273
|
-
const q1East = marks.find(
|
|
274
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
275
|
-
)!;
|
|
276
|
-
const q1West = marks.find(
|
|
277
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
278
|
-
)!;
|
|
279
|
-
|
|
280
|
-
expect(q1East.y).not.toBe(q1West.y);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('grouped bars all start from baseline (not cumulative)', () => {
|
|
284
|
-
const spec = makeGroupedBarSpec();
|
|
285
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
286
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
287
|
-
|
|
288
|
-
const q1East = marks.find(
|
|
289
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
290
|
-
)!;
|
|
291
|
-
const q1West = marks.find(
|
|
292
|
-
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
293
|
-
)!;
|
|
294
|
-
|
|
295
|
-
// Both bars start at the same x position (baseline)
|
|
296
|
-
expect(q1East.x).toBe(q1West.x);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('grouped bars have cornerRadius 2', () => {
|
|
300
|
-
const spec = makeGroupedBarSpec();
|
|
301
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
302
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
303
|
-
|
|
304
|
-
for (const mark of marks) {
|
|
305
|
-
expect(mark.cornerRadius).toBe(2);
|
|
306
|
-
}
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it('grouped bars do not set stackGroup', () => {
|
|
310
|
-
const spec = makeGroupedBarSpec();
|
|
311
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
312
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
313
|
-
|
|
314
|
-
for (const mark of marks) {
|
|
315
|
-
expect(mark.stackGroup).toBeUndefined();
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it('sub-band heights are smaller than full bandwidth (stacked bars use full height)', () => {
|
|
320
|
-
const groupedSpec = makeGroupedBarSpec();
|
|
321
|
-
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
322
|
-
const groupedMarks = computeBarMarks(
|
|
323
|
-
groupedSpec,
|
|
324
|
-
groupedScales,
|
|
325
|
-
chartArea,
|
|
326
|
-
fullStrategy,
|
|
327
|
-
CONTAINER_WIDTH,
|
|
328
|
-
);
|
|
329
|
-
|
|
330
|
-
// With 2 groups, each sub-bar should be less than the full bandwidth
|
|
331
|
-
const stackedSpec = makeGroupedBarSpec();
|
|
332
|
-
(stackedSpec.encoding.x as { stack?: string }).stack = 'zero';
|
|
333
|
-
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
334
|
-
const stackedMarks = computeBarMarks(
|
|
335
|
-
stackedSpec,
|
|
336
|
-
stackedScales,
|
|
337
|
-
chartArea,
|
|
338
|
-
fullStrategy,
|
|
339
|
-
CONTAINER_WIDTH,
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
expect(groupedMarks[0].height).toBeLessThan(stackedMarks[0].height);
|
|
343
|
-
expect(groupedMarks[0].height).toBeGreaterThan(0);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
it('scale domain covers max individual value, not stacked sum', () => {
|
|
347
|
-
const spec = makeGroupedBarSpec();
|
|
348
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
349
|
-
|
|
350
|
-
// Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
|
|
351
|
-
const xScale = scales.x!.scale;
|
|
352
|
-
const domain = xScale.domain() as number[];
|
|
353
|
-
// Domain should not extend to the stacked sum (115)
|
|
354
|
-
expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// Issue 1: at tight bandwidths on narrow plots, grouped sub-bars fall below
|
|
358
|
-
// the readable floor and read as stripes. The engine reclaims reserved
|
|
359
|
-
// inter-category whitespace to keep bars >= MIN_GROUPED_BAR_THICKNESS (8px).
|
|
360
|
-
it('reclaims band whitespace so narrow-plot sub-bars meet the readable floor', () => {
|
|
361
|
-
// 11 categories x 3 series in a short, phone-width plot -> sub-bars would
|
|
362
|
-
// be ~7px without the reclaim.
|
|
363
|
-
const data = [] as { district: string; source: string; dollars: number }[];
|
|
364
|
-
for (let i = 0; i < 11; i++) {
|
|
365
|
-
for (const source of ['State', 'Local', 'Federal']) {
|
|
366
|
-
data.push({ district: `D${i}`, source, dollars: 100 + i });
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
const spec: NormalizedChartSpec = {
|
|
370
|
-
markType: 'bar',
|
|
371
|
-
markDef: { type: 'bar' },
|
|
372
|
-
data,
|
|
373
|
-
encoding: {
|
|
374
|
-
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
375
|
-
// stack: null explicitly since this test exercises the
|
|
376
|
-
// grouped-bar-only readable-floor reclaim.
|
|
377
|
-
x: { field: 'dollars', type: 'quantitative', stack: null },
|
|
378
|
-
y: { field: 'district', type: 'nominal' },
|
|
379
|
-
color: { field: 'source', type: 'nominal' },
|
|
380
|
-
},
|
|
381
|
-
chrome: {},
|
|
382
|
-
annotations: [],
|
|
383
|
-
responsive: true,
|
|
384
|
-
theme: {},
|
|
385
|
-
darkMode: 'off',
|
|
386
|
-
labels: { density: 'auto', format: '' },
|
|
387
|
-
};
|
|
388
|
-
|
|
389
|
-
// Narrow container (< NARROW_VIEWPORT_MAX): reclaim active. Same plot
|
|
390
|
-
// geometry both times; only the container width toggles the gate, proving
|
|
391
|
-
// the gate keys off the container, not the derived plot width.
|
|
392
|
-
const plotArea: Rect = { x: 120, y: 20, width: 240, height: 380 };
|
|
393
|
-
const scales = computeScales(spec, plotArea, spec.data);
|
|
394
|
-
const narrowMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 400);
|
|
395
|
-
for (const mark of narrowMarks) {
|
|
396
|
-
expect(mark.height).toBeGreaterThanOrEqual(8);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Wide container (>= NARROW_VIEWPORT_MAX): reclaim gated off, so the same
|
|
400
|
-
// tight-height plot is left untouched (bars may be thin, matching the
|
|
401
|
-
// pre-fix desktop geometry). Bars must not be *taller* than the narrow
|
|
402
|
-
// case, proving the reclaim only applies for narrow containers. A wide
|
|
403
|
-
// container with a sub-500px plot (e.g. a large left gutter) is exactly
|
|
404
|
-
// the desktop-embed case that must stay pixel-identical.
|
|
405
|
-
const wideMarks = computeBarMarks(spec, scales, plotArea, fullStrategy, 700);
|
|
406
|
-
const wideMax = Math.max(...wideMarks.map((m) => m.height));
|
|
407
|
-
const narrowMin = Math.min(...narrowMarks.map((m) => m.height));
|
|
408
|
-
expect(wideMax).toBeLessThan(narrowMin);
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
describe('colored (non-stacked) bars', () => {
|
|
413
|
-
it('renders colored bars when each category has one row with color encoding', () => {
|
|
414
|
-
const spec: NormalizedChartSpec = {
|
|
415
|
-
markType: 'bar',
|
|
416
|
-
markDef: { type: 'bar' },
|
|
417
|
-
data: [
|
|
418
|
-
{ category: 'Apple', value: 50, type: 'Fruit' },
|
|
419
|
-
{ category: 'Banana', value: 30, type: 'Tropical' },
|
|
420
|
-
{ category: 'Cherry', value: 70, type: 'Berry' },
|
|
421
|
-
],
|
|
422
|
-
encoding: {
|
|
423
|
-
x: { field: 'value', type: 'quantitative' },
|
|
424
|
-
y: { field: 'category', type: 'nominal' },
|
|
425
|
-
color: { field: 'type', type: 'nominal' },
|
|
426
|
-
},
|
|
427
|
-
chrome: {},
|
|
428
|
-
annotations: [],
|
|
429
|
-
responsive: true,
|
|
430
|
-
theme: {},
|
|
431
|
-
darkMode: 'off',
|
|
432
|
-
labels: { density: 'auto', format: '' },
|
|
433
|
-
};
|
|
434
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
435
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
436
|
-
|
|
437
|
-
expect(marks).toHaveLength(3);
|
|
438
|
-
// Each bar should have different colors
|
|
439
|
-
const colors = new Set(marks.map((m) => m.fill));
|
|
440
|
-
expect(colors.size).toBe(3);
|
|
441
|
-
// Non-stacked bars should have corner radius
|
|
442
|
-
expect(marks[0].cornerRadius).toBe(2);
|
|
443
|
-
// Bars should not be stacked (no stackGroup)
|
|
444
|
-
expect(marks[0].stackGroup).toBeUndefined();
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
it('handles negative values in colored bars', () => {
|
|
448
|
-
const spec: NormalizedChartSpec = {
|
|
449
|
-
markType: 'bar',
|
|
450
|
-
markDef: { type: 'bar' },
|
|
451
|
-
data: [
|
|
452
|
-
{ category: 'Growth', value: 15, status: 'positive' },
|
|
453
|
-
{ category: 'Decline', value: -10, status: 'negative' },
|
|
454
|
-
],
|
|
455
|
-
encoding: {
|
|
456
|
-
x: { field: 'value', type: 'quantitative' },
|
|
457
|
-
y: { field: 'category', type: 'nominal' },
|
|
458
|
-
color: { field: 'status', type: 'nominal' },
|
|
459
|
-
},
|
|
460
|
-
chrome: {},
|
|
461
|
-
annotations: [],
|
|
462
|
-
responsive: true,
|
|
463
|
-
theme: {},
|
|
464
|
-
darkMode: 'off',
|
|
465
|
-
labels: { density: 'auto', format: '' },
|
|
466
|
-
};
|
|
467
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
468
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
469
|
-
|
|
470
|
-
expect(marks).toHaveLength(2);
|
|
471
|
-
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
472
|
-
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
473
|
-
// Negative bar starts to the left of positive bar
|
|
474
|
-
expect(decline.x).toBeLessThan(growth.x);
|
|
475
|
-
expect(decline.width).toBeGreaterThan(0);
|
|
476
|
-
});
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
describe('negative values', () => {
|
|
480
|
-
it('negative bars extend leftward from baseline', () => {
|
|
481
|
-
const spec = makeNegativeBarSpec();
|
|
482
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
483
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
484
|
-
|
|
485
|
-
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
486
|
-
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
487
|
-
|
|
488
|
-
// Negative bar should start to the left of where positive bar starts
|
|
489
|
-
expect(decline.x).toBeLessThan(growth.x);
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
it('negative bars still have positive width', () => {
|
|
493
|
-
const spec = makeNegativeBarSpec();
|
|
494
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
495
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
496
|
-
|
|
497
|
-
for (const mark of marks) {
|
|
498
|
-
expect(mark.width).toBeGreaterThan(0);
|
|
499
|
-
}
|
|
500
|
-
});
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
describe('stacked vs grouped (wage data reproduction)', () => {
|
|
504
|
-
// 2 years × 2 firm-size categories — the canonical grouped-bar use case
|
|
505
|
-
const wageData = [
|
|
506
|
-
{ size: '<5 employees', year: '2018', pay: 48200 },
|
|
507
|
-
{ size: '<5 employees', year: '2022', pay: 56400 },
|
|
508
|
-
{ size: '5,000+ employees', year: '2018', pay: 62300 },
|
|
509
|
-
{ size: '5,000+ employees', year: '2022', pay: 74800 },
|
|
510
|
-
];
|
|
511
|
-
|
|
512
|
-
function makeWageSpec(stackZero = false): NormalizedChartSpec {
|
|
513
|
-
return {
|
|
514
|
-
markType: 'bar',
|
|
515
|
-
markDef: { type: 'bar' },
|
|
516
|
-
data: wageData,
|
|
517
|
-
encoding: {
|
|
518
|
-
// Grouped is opt-out since v8 (stacked is now the default): stamp
|
|
519
|
-
// stack: null explicitly for the grouped case.
|
|
520
|
-
x: {
|
|
521
|
-
field: 'pay',
|
|
522
|
-
type: 'quantitative',
|
|
523
|
-
stack: stackZero ? 'zero' : null,
|
|
524
|
-
},
|
|
525
|
-
y: { field: 'size', type: 'nominal' },
|
|
526
|
-
color: { field: 'year', type: 'nominal' },
|
|
527
|
-
},
|
|
528
|
-
chrome: {},
|
|
529
|
-
annotations: [],
|
|
530
|
-
responsive: true,
|
|
531
|
-
theme: {},
|
|
532
|
-
darkMode: 'off',
|
|
533
|
-
labels: { density: 'auto', format: '' },
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
it('grouped (stack: null): bars sit at different y positions within each category', () => {
|
|
538
|
-
const spec = makeWageSpec(false);
|
|
539
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
540
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
541
|
-
|
|
542
|
-
// 2 firm sizes × 2 years = 4 bars
|
|
543
|
-
expect(marks).toHaveLength(4);
|
|
544
|
-
|
|
545
|
-
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
546
|
-
expect(smallFirmMarks).toHaveLength(2);
|
|
547
|
-
expect(smallFirmMarks[0].y).not.toBe(smallFirmMarks[1].y);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it('grouped (stack: null): scale domain covers max individual value, not stacked sum', () => {
|
|
551
|
-
const spec = makeWageSpec(false);
|
|
552
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
553
|
-
|
|
554
|
-
// Max individual pay is 74800. Stacked sum for 5000+ employees = 62300 + 74800 = 137100.
|
|
555
|
-
// Grouped bars should NOT extend domain to 137100.
|
|
556
|
-
const xScale = scales.x!.scale;
|
|
557
|
-
const domain = xScale.domain() as number[];
|
|
558
|
-
expect(domain[1]).toBeLessThan(137100);
|
|
559
|
-
expect(domain[1]).toBeGreaterThanOrEqual(74800);
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
it('stacked with stack:zero: segments are contiguous end-to-end within each category', () => {
|
|
563
|
-
const spec = makeWageSpec(true);
|
|
564
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
565
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
566
|
-
|
|
567
|
-
expect(marks).toHaveLength(4);
|
|
568
|
-
|
|
569
|
-
// For stacked bars, the second segment starts exactly where the first ends.
|
|
570
|
-
const smallFirmMarks = marks
|
|
571
|
-
.filter((m) => m.aria.label.includes('<5'))
|
|
572
|
-
.sort((a, b) => a.x - b.x);
|
|
573
|
-
expect(smallFirmMarks).toHaveLength(2);
|
|
574
|
-
expect(smallFirmMarks[1].x).toBeCloseTo(smallFirmMarks[0].x + smallFirmMarks[0].width, 1);
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
it('stacked with stack:zero: segments share the same y position (stacked on same row)', () => {
|
|
578
|
-
const spec = makeWageSpec(true);
|
|
579
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
580
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
581
|
-
|
|
582
|
-
const smallFirmMarks = marks.filter((m) => m.aria.label.includes('<5'));
|
|
583
|
-
expect(smallFirmMarks).toHaveLength(2);
|
|
584
|
-
expect(smallFirmMarks[0].y).toBe(smallFirmMarks[1].y);
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
it('grouped vs stacked: grouped bars each start from baseline', () => {
|
|
588
|
-
const stackedSpec = makeWageSpec(true);
|
|
589
|
-
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
590
|
-
const stackedMarks = computeBarMarks(
|
|
591
|
-
stackedSpec,
|
|
592
|
-
stackedScales,
|
|
593
|
-
chartArea,
|
|
594
|
-
fullStrategy,
|
|
595
|
-
CONTAINER_WIDTH,
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
const groupedSpec = makeWageSpec(false);
|
|
599
|
-
const groupedScales = computeScales(groupedSpec, chartArea, groupedSpec.data);
|
|
600
|
-
const groupedMarks = computeBarMarks(
|
|
601
|
-
groupedSpec,
|
|
602
|
-
groupedScales,
|
|
603
|
-
chartArea,
|
|
604
|
-
fullStrategy,
|
|
605
|
-
CONTAINER_WIDTH,
|
|
606
|
-
);
|
|
607
|
-
|
|
608
|
-
expect(groupedMarks).toHaveLength(4);
|
|
609
|
-
|
|
610
|
-
// Grouped bars each start from the baseline (same x for both years within a firm size)
|
|
611
|
-
const smallFirmGrouped = groupedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
612
|
-
expect(smallFirmGrouped[0].x).toBe(smallFirmGrouped[1].x);
|
|
613
|
-
|
|
614
|
-
// Stacked bars for the same category have different x positions (end-to-end)
|
|
615
|
-
const smallFirmStacked = stackedMarks.filter((m) => m.aria.label.includes('<5'));
|
|
616
|
-
expect(smallFirmStacked[0].x).not.toBe(smallFirmStacked[1].x);
|
|
617
|
-
});
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
describe('x-domain excludes zero', () => {
|
|
621
|
-
it('simple bars anchor at the domain minimum, not xScale(0)', () => {
|
|
622
|
-
const spec = makeSimpleBarSpec();
|
|
623
|
-
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
624
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
625
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
626
|
-
|
|
627
|
-
const xScale = scales.x!.scale as (v: number) => number;
|
|
628
|
-
const left = xScale(65);
|
|
629
|
-
|
|
630
|
-
// Every bar starts at the plot-area left edge (xScale of the domain min),
|
|
631
|
-
// not at the far-left extrapolation of xScale(0).
|
|
632
|
-
for (const mark of marks) {
|
|
633
|
-
expect(mark.x).toBeCloseTo(left, 5);
|
|
634
|
-
// Bars never bleed left of the plot area into the y-axis label gutter.
|
|
635
|
-
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
636
|
-
}
|
|
637
|
-
// Guard against the old behavior: xScale(0) lands well left of the plot area.
|
|
638
|
-
expect(xScale(0)).toBeLessThan(chartArea.x);
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
it('grouped bars anchor at the domain minimum, not xScale(0)', () => {
|
|
642
|
-
const spec = makeGroupedBarSpec();
|
|
643
|
-
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
644
|
-
// Lift values into the [65, 95] window so widths stay positive.
|
|
645
|
-
spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
|
|
646
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
647
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
648
|
-
|
|
649
|
-
const xScale = scales.x!.scale as (v: number) => number;
|
|
650
|
-
const left = xScale(65);
|
|
651
|
-
|
|
652
|
-
for (const mark of marks) {
|
|
653
|
-
expect(mark.x).toBeCloseTo(left, 5);
|
|
654
|
-
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
655
|
-
}
|
|
656
|
-
});
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
describe('x-domain includes zero (no regression)', () => {
|
|
660
|
-
it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
|
|
661
|
-
const spec = makeSimpleBarSpec();
|
|
662
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
663
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
664
|
-
|
|
665
|
-
const xScale = scales.x!.scale as (v: number) => number;
|
|
666
|
-
for (const mark of marks) {
|
|
667
|
-
expect(mark.x).toBeCloseTo(xScale(0), 5);
|
|
668
|
-
}
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
|
|
672
|
-
const spec = makeNegativeBarSpec();
|
|
673
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
674
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
675
|
-
|
|
676
|
-
const xScale = scales.x!.scale as (v: number) => number;
|
|
677
|
-
const zero = xScale(0);
|
|
678
|
-
|
|
679
|
-
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
680
|
-
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
681
|
-
// Positive bar starts at zero; negative bar ends at zero.
|
|
682
|
-
expect(growth.x).toBeCloseTo(zero, 5);
|
|
683
|
-
expect(decline.x + decline.width).toBeCloseTo(zero, 5);
|
|
684
|
-
});
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
describe('edge cases', () => {
|
|
688
|
-
it('returns empty array when no x encoding', () => {
|
|
689
|
-
const spec: NormalizedChartSpec = {
|
|
690
|
-
markType: 'bar',
|
|
691
|
-
markDef: { type: 'bar' },
|
|
692
|
-
data: [{ category: 'A', value: 10 }],
|
|
693
|
-
encoding: {
|
|
694
|
-
y: { field: 'category', type: 'nominal' },
|
|
695
|
-
},
|
|
696
|
-
chrome: {},
|
|
697
|
-
annotations: [],
|
|
698
|
-
responsive: true,
|
|
699
|
-
theme: {},
|
|
700
|
-
darkMode: 'off',
|
|
701
|
-
labels: { density: 'auto', format: '' },
|
|
702
|
-
};
|
|
703
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
704
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
705
|
-
expect(marks).toHaveLength(0);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it('returns empty array for empty data', () => {
|
|
709
|
-
const spec: NormalizedChartSpec = {
|
|
710
|
-
markType: 'bar',
|
|
711
|
-
markDef: { type: 'bar' },
|
|
712
|
-
data: [],
|
|
713
|
-
encoding: {
|
|
714
|
-
x: { field: 'value', type: 'quantitative' },
|
|
715
|
-
y: { field: 'category', type: 'nominal' },
|
|
716
|
-
},
|
|
717
|
-
chrome: {},
|
|
718
|
-
annotations: [],
|
|
719
|
-
responsive: true,
|
|
720
|
-
theme: {},
|
|
721
|
-
darkMode: 'off',
|
|
722
|
-
labels: { density: 'auto', format: '' },
|
|
723
|
-
};
|
|
724
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
725
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
726
|
-
expect(marks).toHaveLength(0);
|
|
727
|
-
});
|
|
728
|
-
});
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
// ---------------------------------------------------------------------------
|
|
732
|
-
// computeBarLabels tests
|
|
733
|
-
// ---------------------------------------------------------------------------
|
|
734
|
-
|
|
735
|
-
describe('computeBarLabels', () => {
|
|
736
|
-
it('produces one label per bar mark', () => {
|
|
737
|
-
const spec = makeSimpleBarSpec();
|
|
738
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
739
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
740
|
-
const labels = computeBarLabels(marks, chartArea);
|
|
741
|
-
|
|
742
|
-
expect(labels).toHaveLength(marks.length);
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
it('labels contain the value text', () => {
|
|
746
|
-
const spec = makeSimpleBarSpec();
|
|
747
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
748
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
749
|
-
const labels = computeBarLabels(marks, chartArea);
|
|
750
|
-
|
|
751
|
-
const texts = labels.map((l) => l.text);
|
|
752
|
-
expect(texts).toContain('50');
|
|
753
|
-
expect(texts).toContain('30');
|
|
754
|
-
expect(texts).toContain('70');
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
it('applies d3 label format string', () => {
|
|
758
|
-
const spec = makeSimpleBarSpec();
|
|
759
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
760
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
761
|
-
const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('$,.0f'));
|
|
762
|
-
|
|
763
|
-
const texts = labels.map((l) => l.text);
|
|
764
|
-
expect(texts).toContain('$50');
|
|
765
|
-
expect(texts).toContain('$30');
|
|
766
|
-
expect(texts).toContain('$70');
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
it('applies format with literal alpha suffix (e.g. "T")', () => {
|
|
770
|
-
const spec: NormalizedChartSpec = {
|
|
771
|
-
markType: 'bar',
|
|
772
|
-
markDef: { type: 'bar' },
|
|
773
|
-
data: [
|
|
774
|
-
{ company: 'Apple', cap: 3.75 },
|
|
775
|
-
{ company: 'Meta', cap: 1.63 },
|
|
776
|
-
],
|
|
777
|
-
encoding: {
|
|
778
|
-
x: { field: 'cap', type: 'quantitative' },
|
|
779
|
-
y: { field: 'company', type: 'nominal' },
|
|
780
|
-
},
|
|
781
|
-
chrome: {},
|
|
782
|
-
annotations: [],
|
|
783
|
-
responsive: true,
|
|
784
|
-
theme: {},
|
|
785
|
-
darkMode: 'off',
|
|
786
|
-
labels: { density: 'all', format: '$,.2~fT' },
|
|
787
|
-
};
|
|
788
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
789
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
790
|
-
const labels = computeBarLabels(marks, chartArea, 'all', buildD3Formatter('$,.2~fT'));
|
|
791
|
-
|
|
792
|
-
const texts = labels.map((l) => l.text);
|
|
793
|
-
expect(texts).toContain('$3.75T');
|
|
794
|
-
expect(texts).toContain('$1.63T');
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
it('applies format with non-alpha suffix (e.g. "%")', () => {
|
|
798
|
-
const spec = makeSimpleBarSpec();
|
|
799
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
800
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
801
|
-
const labels = computeBarLabels(marks, chartArea, 'auto', buildD3Formatter('.0f%'));
|
|
802
|
-
|
|
803
|
-
const texts = labels.map((l) => l.text);
|
|
804
|
-
expect(texts).toContain('50%');
|
|
805
|
-
expect(texts).toContain('30%');
|
|
806
|
-
expect(texts).toContain('70%');
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
it('applies fixed label color to outside labels', () => {
|
|
810
|
-
// Narrow chart area forces bars < 40px, putting labels outside
|
|
811
|
-
const smallArea: Rect = { x: 80, y: 20, width: 30, height: 300 };
|
|
812
|
-
const spec: NormalizedChartSpec = {
|
|
813
|
-
markType: 'bar',
|
|
814
|
-
markDef: { type: 'bar', size: 6, cornerRadius: 'pill' },
|
|
815
|
-
data: [
|
|
816
|
-
{ category: 'A', value: 1 },
|
|
817
|
-
{ category: 'B', value: 2 },
|
|
818
|
-
],
|
|
819
|
-
encoding: {
|
|
820
|
-
x: { field: 'value', type: 'quantitative' },
|
|
821
|
-
y: { field: 'category', type: 'nominal' },
|
|
822
|
-
},
|
|
823
|
-
chrome: {},
|
|
824
|
-
annotations: [],
|
|
825
|
-
responsive: true,
|
|
826
|
-
theme: {},
|
|
827
|
-
darkMode: 'off',
|
|
828
|
-
labels: { density: 'auto', format: '', prefix: '' },
|
|
829
|
-
};
|
|
830
|
-
const scales = computeScales(spec, smallArea, spec.data);
|
|
831
|
-
const marks = computeBarMarks(spec, scales, smallArea, fullStrategy, CONTAINER_WIDTH);
|
|
832
|
-
const labels = computeBarLabels(
|
|
833
|
-
marks,
|
|
834
|
-
smallArea,
|
|
835
|
-
'all',
|
|
836
|
-
undefined,
|
|
837
|
-
undefined,
|
|
838
|
-
undefined,
|
|
839
|
-
'#a1a1aa',
|
|
840
|
-
);
|
|
841
|
-
|
|
842
|
-
// Outside labels should use the fixed color; inside labels use contrast-adjusted colors
|
|
843
|
-
const outsideLabels = labels.filter((l) => l.style.textAnchor === 'start');
|
|
844
|
-
expect(outsideLabels.length).toBeGreaterThan(0);
|
|
845
|
-
for (const label of outsideLabels) {
|
|
846
|
-
expect(label.style.fill).toBe('#a1a1aa');
|
|
847
|
-
}
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
describe('markDef overrides', () => {
|
|
852
|
-
it('markDef.size reduces bar height and centers within band', () => {
|
|
853
|
-
const spec = makeSimpleBarSpec();
|
|
854
|
-
spec.markDef = { type: 'bar', size: 6 };
|
|
855
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
856
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
857
|
-
|
|
858
|
-
for (const mark of marks) {
|
|
859
|
-
expect(mark.height).toBe(6);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// Bars should be centered: offset = (bandwidth - 6) / 2
|
|
863
|
-
const specDefault = makeSimpleBarSpec();
|
|
864
|
-
const scalesDefault = computeScales(specDefault, chartArea, specDefault.data);
|
|
865
|
-
const marksDefault = computeBarMarks(
|
|
866
|
-
specDefault,
|
|
867
|
-
scalesDefault,
|
|
868
|
-
chartArea,
|
|
869
|
-
fullStrategy,
|
|
870
|
-
CONTAINER_WIDTH,
|
|
871
|
-
);
|
|
872
|
-
for (let i = 0; i < marks.length; i++) {
|
|
873
|
-
expect(marks[i].y).toBeGreaterThan(marksDefault[i].y);
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
it('markDef.size is capped at bandwidth', () => {
|
|
878
|
-
const spec = makeSimpleBarSpec();
|
|
879
|
-
spec.markDef = { type: 'bar', size: 9999 };
|
|
880
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
881
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
882
|
-
|
|
883
|
-
const bandwidth = marks[0].height;
|
|
884
|
-
// Should be capped at bandwidth, not 9999
|
|
885
|
-
expect(bandwidth).toBeLessThan(9999);
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
it('markDef.cornerRadius "pill" resolves to half the bar height', () => {
|
|
889
|
-
const spec = makeSimpleBarSpec();
|
|
890
|
-
spec.markDef = { type: 'bar', size: 6, cornerRadius: 'pill' };
|
|
891
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
892
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
893
|
-
|
|
894
|
-
for (const mark of marks) {
|
|
895
|
-
expect(mark.cornerRadius).toBe(3);
|
|
896
|
-
}
|
|
897
|
-
});
|
|
898
|
-
|
|
899
|
-
it('markDef.cornerRadius as number overrides default', () => {
|
|
900
|
-
const spec = makeSimpleBarSpec();
|
|
901
|
-
spec.markDef = { type: 'bar', cornerRadius: 8 };
|
|
902
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
903
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
904
|
-
|
|
905
|
-
for (const mark of marks) {
|
|
906
|
-
expect(mark.cornerRadius).toBe(8);
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
it('markDef.size is skipped for stacked bars', () => {
|
|
911
|
-
const spec = makeGroupedBarSpec();
|
|
912
|
-
spec.markDef = { type: 'bar', size: 6 };
|
|
913
|
-
// Enable stacking (default for grouped)
|
|
914
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
915
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
916
|
-
|
|
917
|
-
const stackedMarks = marks.filter((m) => m.stackGroup !== undefined);
|
|
918
|
-
for (const mark of stackedMarks) {
|
|
919
|
-
expect(mark.height).not.toBe(6);
|
|
920
|
-
}
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
it('combined size + pill uses adjusted size for radius', () => {
|
|
924
|
-
const spec = makeSimpleBarSpec();
|
|
925
|
-
spec.markDef = { type: 'bar', size: 10, cornerRadius: 'pill' };
|
|
926
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
927
|
-
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy, CONTAINER_WIDTH);
|
|
928
|
-
|
|
929
|
-
for (const mark of marks) {
|
|
930
|
-
expect(mark.height).toBe(10);
|
|
931
|
-
expect(mark.cornerRadius).toBe(5);
|
|
932
|
-
}
|
|
933
|
-
});
|
|
934
|
-
});
|