@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,1579 +0,0 @@
|
|
|
1
|
-
import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
2
|
-
import { maxRotatedLabelWidth, resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
|
-
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import type { NormalizedChartSpec } from '../compiler/types';
|
|
6
|
-
import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
|
|
7
|
-
import { buildContinuousTicks } from '../layout/axes/ticks';
|
|
8
|
-
import type { ResolvedScale } from '../layout/scales';
|
|
9
|
-
import { computeScales } from '../layout/scales';
|
|
10
|
-
|
|
11
|
-
const lineSpec: NormalizedChartSpec = {
|
|
12
|
-
markType: 'line',
|
|
13
|
-
markDef: { type: 'line' },
|
|
14
|
-
data: [
|
|
15
|
-
{ date: '2020-01-01', value: 100 },
|
|
16
|
-
{ date: '2021-01-01', value: 500 },
|
|
17
|
-
{ date: '2022-01-01', value: 300 },
|
|
18
|
-
],
|
|
19
|
-
encoding: {
|
|
20
|
-
x: { field: 'date', type: 'temporal' },
|
|
21
|
-
y: { field: 'value', type: 'quantitative' },
|
|
22
|
-
},
|
|
23
|
-
chrome: {},
|
|
24
|
-
annotations: [],
|
|
25
|
-
responsive: true,
|
|
26
|
-
theme: {},
|
|
27
|
-
darkMode: 'off',
|
|
28
|
-
labels: { density: 'auto', format: '' },
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const chartArea = { x: 50, y: 50, width: 500, height: 300 };
|
|
32
|
-
const theme = resolveTheme();
|
|
33
|
-
|
|
34
|
-
const fullStrategy: LayoutStrategy = {
|
|
35
|
-
labelMode: 'all',
|
|
36
|
-
legendPosition: 'right',
|
|
37
|
-
annotationPosition: 'inline',
|
|
38
|
-
axisLabelDensity: 'full',
|
|
39
|
-
chromeMode: 'full',
|
|
40
|
-
legendMaxHeight: -1,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const minimalStrategy: LayoutStrategy = {
|
|
44
|
-
labelMode: 'none',
|
|
45
|
-
legendPosition: 'top',
|
|
46
|
-
annotationPosition: 'tooltip-only',
|
|
47
|
-
axisLabelDensity: 'minimal',
|
|
48
|
-
chromeMode: 'full',
|
|
49
|
-
legendMaxHeight: -1,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
describe('computeAxes', () => {
|
|
53
|
-
it('produces x and y axes for a line chart', () => {
|
|
54
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
55
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
56
|
-
|
|
57
|
-
expect(axes.x).toBeDefined();
|
|
58
|
-
expect(axes.y).toBeDefined();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('generates ticks for both axes', () => {
|
|
62
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
63
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
64
|
-
|
|
65
|
-
expect(axes.x!.ticks.length).toBeGreaterThan(0);
|
|
66
|
-
expect(axes.y!.ticks.length).toBeGreaterThan(0);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('tick positions are within chart area', () => {
|
|
70
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
71
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
72
|
-
|
|
73
|
-
for (const tick of axes.x!.ticks) {
|
|
74
|
-
expect(tick.position).toBeGreaterThanOrEqual(chartArea.x - 1);
|
|
75
|
-
expect(tick.position).toBeLessThanOrEqual(chartArea.x + chartArea.width + 1);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
for (const tick of axes.y!.ticks) {
|
|
79
|
-
expect(tick.position).toBeGreaterThanOrEqual(chartArea.y - 1);
|
|
80
|
-
expect(tick.position).toBeLessThanOrEqual(chartArea.y + chartArea.height + 1);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('tick labels are formatted strings', () => {
|
|
85
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
86
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
87
|
-
|
|
88
|
-
for (const tick of axes.y!.ticks) {
|
|
89
|
-
expect(typeof tick.label).toBe('string');
|
|
90
|
-
expect(tick.label.length).toBeGreaterThan(0);
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('produces fewer ticks with minimal density', () => {
|
|
95
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
96
|
-
const axesFull = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
97
|
-
const axesMinimal = computeAxes(scales, chartArea, minimalStrategy, theme);
|
|
98
|
-
|
|
99
|
-
// Minimal should have fewer or equal ticks
|
|
100
|
-
expect(axesMinimal.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('y-axis has gridlines by default', () => {
|
|
104
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
105
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
106
|
-
|
|
107
|
-
expect(axes.y!.gridlines.length).toBeGreaterThan(0);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('axes have correct start/end positions', () => {
|
|
111
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
112
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
113
|
-
|
|
114
|
-
// X axis sits at the bottom of the chart area
|
|
115
|
-
expect(axes.x!.start.y).toBe(chartArea.y + chartArea.height);
|
|
116
|
-
expect(axes.x!.end.y).toBe(chartArea.y + chartArea.height);
|
|
117
|
-
|
|
118
|
-
// Y axis sits at the left of the chart area
|
|
119
|
-
expect(axes.y!.start.x).toBe(chartArea.x);
|
|
120
|
-
expect(axes.y!.end.x).toBe(chartArea.x);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
// -------------------------------------------------------------------------
|
|
124
|
-
// Height-aware y-axis tick reduction
|
|
125
|
-
// -------------------------------------------------------------------------
|
|
126
|
-
|
|
127
|
-
it('reduces y-axis ticks for very short chart areas (< 80px)', () => {
|
|
128
|
-
const shortArea = { x: 50, y: 50, width: 500, height: 80 };
|
|
129
|
-
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
130
|
-
const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
|
|
131
|
-
|
|
132
|
-
// Very short chart area -- tick count clamped to at most 4
|
|
133
|
-
expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(4);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('reduces y-axis ticks for medium-short chart areas (80-100px)', () => {
|
|
137
|
-
const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
|
|
138
|
-
const tallArea = { x: 50, y: 50, width: 500, height: 400 };
|
|
139
|
-
|
|
140
|
-
const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
|
|
141
|
-
const scalesTall = computeScales(lineSpec, tallArea, lineSpec.data);
|
|
142
|
-
|
|
143
|
-
const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
|
|
144
|
-
const axesTall = computeAxes(scalesTall, tallArea, fullStrategy, theme);
|
|
145
|
-
|
|
146
|
-
// Medium height should have fewer ticks than a tall chart with same 'full' density
|
|
147
|
-
expect(axesMedium.y!.ticks.length).toBeLessThanOrEqual(axesTall.y!.ticks.length);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('does not increase y-axis ticks beyond base density for short charts', () => {
|
|
151
|
-
const shortArea = { x: 50, y: 50, width: 500, height: 80 };
|
|
152
|
-
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
153
|
-
|
|
154
|
-
// Strategy already says minimal - short height shouldn't change anything
|
|
155
|
-
const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
|
|
156
|
-
expect(axes.y!.ticks.length).toBeLessThanOrEqual(4);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// -------------------------------------------------------------------------
|
|
160
|
-
// Width-aware x-axis tick reduction
|
|
161
|
-
// -------------------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
it('reduces x-axis ticks for very narrow chart areas (< 150px)', () => {
|
|
164
|
-
const narrowArea = { x: 50, y: 50, width: 100, height: 300 };
|
|
165
|
-
const scales = computeScales(lineSpec, narrowArea, lineSpec.data);
|
|
166
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
167
|
-
|
|
168
|
-
// Width < 150 forces minimal density for x-axis
|
|
169
|
-
expect(axes.x!.ticks.length).toBeLessThanOrEqual(4);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
|
|
173
|
-
const mediumArea = { x: 50, y: 50, width: 250, height: 300 };
|
|
174
|
-
const wideArea = { x: 50, y: 50, width: 600, height: 300 };
|
|
175
|
-
|
|
176
|
-
const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
|
|
177
|
-
const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
|
|
178
|
-
|
|
179
|
-
const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
|
|
180
|
-
const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
|
|
181
|
-
|
|
182
|
-
expect(axesMedium.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// -------------------------------------------------------------------------
|
|
186
|
-
// Both axes constrained simultaneously (thumbnail scenario)
|
|
187
|
-
// -------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
it('reduces ticks on both axes for thumbnail-sized charts', () => {
|
|
190
|
-
const thumbnailArea = { x: 10, y: 10, width: 120, height: 80 };
|
|
191
|
-
const fullArea = { x: 50, y: 50, width: 600, height: 400 };
|
|
192
|
-
|
|
193
|
-
const scalesThumb = computeScales(lineSpec, thumbnailArea, lineSpec.data);
|
|
194
|
-
const scalesFull = computeScales(lineSpec, fullArea, lineSpec.data);
|
|
195
|
-
|
|
196
|
-
const axesThumb = computeAxes(scalesThumb, thumbnailArea, fullStrategy, theme);
|
|
197
|
-
const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
|
|
198
|
-
|
|
199
|
-
// Both axes should have minimal ticks in a thumbnail
|
|
200
|
-
expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(4);
|
|
201
|
-
expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(4);
|
|
202
|
-
|
|
203
|
-
// And fewer than full-size
|
|
204
|
-
expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
|
|
205
|
-
expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// -------------------------------------------------------------------------
|
|
209
|
-
// labelAngle propagation
|
|
210
|
-
// -------------------------------------------------------------------------
|
|
211
|
-
|
|
212
|
-
it('propagates labelAngle from encoding to x-axis layout', () => {
|
|
213
|
-
const specWithAngle: NormalizedChartSpec = {
|
|
214
|
-
...lineSpec,
|
|
215
|
-
markType: 'bar',
|
|
216
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
217
|
-
data: [
|
|
218
|
-
{ cat: 'California', val: 10 },
|
|
219
|
-
{ cat: 'New York', val: 20 },
|
|
220
|
-
],
|
|
221
|
-
encoding: {
|
|
222
|
-
x: { field: 'cat', type: 'nominal', axis: { labelAngle: -90 } },
|
|
223
|
-
y: { field: 'val', type: 'quantitative' },
|
|
224
|
-
},
|
|
225
|
-
};
|
|
226
|
-
const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
|
|
227
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
228
|
-
|
|
229
|
-
expect(axes.x!.tickAngle).toBe(-90);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('leaves tickAngle undefined when not specified', () => {
|
|
233
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
234
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
235
|
-
|
|
236
|
-
expect(axes.x!.tickAngle).toBeUndefined();
|
|
237
|
-
expect(axes.y!.tickAngle).toBeUndefined();
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('propagates labelAngle to y-axis layout', () => {
|
|
241
|
-
const specWithAngle: NormalizedChartSpec = {
|
|
242
|
-
...lineSpec,
|
|
243
|
-
encoding: {
|
|
244
|
-
x: { field: 'date', type: 'temporal' },
|
|
245
|
-
y: { field: 'value', type: 'quantitative', axis: { labelAngle: -45 } },
|
|
246
|
-
},
|
|
247
|
-
};
|
|
248
|
-
const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
|
|
249
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
250
|
-
|
|
251
|
-
expect(axes.y!.tickAngle).toBe(-45);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// ---------------------------------------------------------------------------
|
|
256
|
-
// effectiveDensity unit tests
|
|
257
|
-
// ---------------------------------------------------------------------------
|
|
258
|
-
|
|
259
|
-
describe('effectiveDensity', () => {
|
|
260
|
-
const MINIMAL_THRESHOLD = 120;
|
|
261
|
-
const REDUCED_THRESHOLD = 200;
|
|
262
|
-
|
|
263
|
-
it('returns base density when axis length exceeds all thresholds', () => {
|
|
264
|
-
expect(effectiveDensity('full', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
|
|
265
|
-
expect(effectiveDensity('reduced', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
266
|
-
expect(effectiveDensity('minimal', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('forces minimal density below the minimal threshold', () => {
|
|
270
|
-
expect(effectiveDensity('full', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
271
|
-
expect(effectiveDensity('reduced', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
272
|
-
expect(effectiveDensity('minimal', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it('caps at reduced density between thresholds', () => {
|
|
276
|
-
// 'full' base should step down to 'reduced'
|
|
277
|
-
expect(effectiveDensity('full', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('does not increase density beyond base when between thresholds', () => {
|
|
281
|
-
// 'minimal' base should stay 'minimal' even between thresholds
|
|
282
|
-
expect(effectiveDensity('minimal', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
283
|
-
// 'reduced' base should stay 'reduced'
|
|
284
|
-
expect(effectiveDensity('reduced', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('handles exact threshold boundaries', () => {
|
|
288
|
-
// At exactly the minimal threshold, we're NOT below it
|
|
289
|
-
expect(effectiveDensity('full', 120, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
290
|
-
// At exactly the reduced threshold, we're NOT below it
|
|
291
|
-
expect(effectiveDensity('full', 200, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
it('handles zero and negative lengths', () => {
|
|
295
|
-
expect(effectiveDensity('full', 0, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
296
|
-
expect(effectiveDensity('full', -10, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('works with custom thresholds (x-axis uses different values)', () => {
|
|
300
|
-
const X_MINIMAL = 150;
|
|
301
|
-
const X_REDUCED = 300;
|
|
302
|
-
|
|
303
|
-
expect(effectiveDensity('full', 100, X_MINIMAL, X_REDUCED)).toBe('minimal');
|
|
304
|
-
expect(effectiveDensity('full', 200, X_MINIMAL, X_REDUCED)).toBe('reduced');
|
|
305
|
-
expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
// ---------------------------------------------------------------------------
|
|
310
|
-
// ticksOverlap unit tests
|
|
311
|
-
// ---------------------------------------------------------------------------
|
|
312
|
-
|
|
313
|
-
describe('ticksOverlap', () => {
|
|
314
|
-
const fontSize = 12;
|
|
315
|
-
const fontWeight = 400;
|
|
316
|
-
|
|
317
|
-
it('returns false for empty or single tick', () => {
|
|
318
|
-
expect(ticksOverlap([], fontSize, fontWeight)).toBe(false);
|
|
319
|
-
expect(ticksOverlap([{ value: 0, position: 100, label: 'A' }], fontSize, fontWeight)).toBe(
|
|
320
|
-
false,
|
|
321
|
-
);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('returns false when ticks are well-spaced', () => {
|
|
325
|
-
const ticks: AxisTick[] = [
|
|
326
|
-
{ value: 0, position: 0, label: 'A' },
|
|
327
|
-
{ value: 1, position: 200, label: 'B' },
|
|
328
|
-
{ value: 2, position: 400, label: 'C' },
|
|
329
|
-
];
|
|
330
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(false);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('returns true when ticks are too close together', () => {
|
|
334
|
-
const ticks: AxisTick[] = [
|
|
335
|
-
{ value: 0, position: 0, label: 'January 2025' },
|
|
336
|
-
{ value: 1, position: 30, label: 'February 2025' },
|
|
337
|
-
{ value: 2, position: 60, label: 'March 2025' },
|
|
338
|
-
];
|
|
339
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(true);
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it('uses measureText when provided', () => {
|
|
343
|
-
const ticks: AxisTick[] = [
|
|
344
|
-
{ value: 0, position: 0, label: 'A' },
|
|
345
|
-
{ value: 1, position: 100, label: 'B' },
|
|
346
|
-
];
|
|
347
|
-
|
|
348
|
-
// With a measureText that reports very wide labels, they should overlap
|
|
349
|
-
const wideMeasure = () => ({ width: 200, height: 12 });
|
|
350
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, wideMeasure)).toBe(true);
|
|
351
|
-
|
|
352
|
-
// With a measureText that reports very narrow labels, they should not overlap
|
|
353
|
-
const narrowMeasure = () => ({ width: 1, height: 12 });
|
|
354
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, narrowMeasure)).toBe(false);
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
// ---------------------------------------------------------------------------
|
|
359
|
-
// thinTicksUntilFit unit tests
|
|
360
|
-
// ---------------------------------------------------------------------------
|
|
361
|
-
|
|
362
|
-
describe('thinTicksUntilFit', () => {
|
|
363
|
-
const fontSize = 12;
|
|
364
|
-
const fontWeight = 400;
|
|
365
|
-
|
|
366
|
-
it('returns original array when no overlap', () => {
|
|
367
|
-
const ticks: AxisTick[] = [
|
|
368
|
-
{ value: 0, position: 0, label: 'A' },
|
|
369
|
-
{ value: 1, position: 200, label: 'B' },
|
|
370
|
-
{ value: 2, position: 400, label: 'C' },
|
|
371
|
-
];
|
|
372
|
-
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
373
|
-
expect(result).toBe(ticks); // Same reference, not a copy
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('thins overlapping ticks while keeping first and last', () => {
|
|
377
|
-
// Ticks at 10px intervals with long labels that will overlap
|
|
378
|
-
const ticks: AxisTick[] = Array.from({ length: 8 }, (_, i) => ({
|
|
379
|
-
value: i,
|
|
380
|
-
position: i * 10,
|
|
381
|
-
label: 'Long Label Text',
|
|
382
|
-
}));
|
|
383
|
-
|
|
384
|
-
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
385
|
-
|
|
386
|
-
// Should have fewer ticks than the original
|
|
387
|
-
expect(result.length).toBeLessThan(ticks.length);
|
|
388
|
-
// Should always keep first and last
|
|
389
|
-
expect(result[0]).toBe(ticks[0]);
|
|
390
|
-
expect(result[result.length - 1]).toBe(ticks[ticks.length - 1]);
|
|
391
|
-
// Should have at least MIN_TICK_COUNT (2)
|
|
392
|
-
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('returns at least 2 ticks even when labels are very wide', () => {
|
|
396
|
-
const ticks: AxisTick[] = [
|
|
397
|
-
{ value: 0, position: 0, label: 'Very Long Label That Is Wide' },
|
|
398
|
-
{ value: 1, position: 5, label: 'Another Very Long Label' },
|
|
399
|
-
{ value: 2, position: 10, label: 'Yet Another Long Label Here' },
|
|
400
|
-
];
|
|
401
|
-
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
402
|
-
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
403
|
-
});
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
// Text-aware tick density integration tests
|
|
408
|
-
// ---------------------------------------------------------------------------
|
|
409
|
-
|
|
410
|
-
describe('text-aware tick density', () => {
|
|
411
|
-
it('produces fewer x-axis ticks in narrow charts', () => {
|
|
412
|
-
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
413
|
-
const wideArea = { x: 50, y: 50, width: 800, height: 300 };
|
|
414
|
-
|
|
415
|
-
const scalesNarrow = computeScales(lineSpec, narrowArea, lineSpec.data);
|
|
416
|
-
const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
|
|
417
|
-
|
|
418
|
-
const axesNarrow = computeAxes(scalesNarrow, narrowArea, fullStrategy, theme);
|
|
419
|
-
const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
|
|
420
|
-
|
|
421
|
-
expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
it('still thins x-axis ticks when tickCount is set but D3 overshoots', () => {
|
|
425
|
-
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
426
|
-
const specWithTickCount: NormalizedChartSpec = {
|
|
427
|
-
...lineSpec,
|
|
428
|
-
encoding: {
|
|
429
|
-
x: { field: 'date', type: 'temporal', axis: { tickCount: 8 } },
|
|
430
|
-
y: { field: 'value', type: 'quantitative' },
|
|
431
|
-
},
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
|
|
435
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
436
|
-
|
|
437
|
-
// tickCount is advisory for D3 - if it overshoots, thinning still applies
|
|
438
|
-
// to prevent overlap. The result should still have ticks, just not more
|
|
439
|
-
// than the narrow area can display without overlap.
|
|
440
|
-
expect(axes.x!.ticks.length).toBeGreaterThan(0);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
it('does not thin x-axis ticks when explicit values are set', () => {
|
|
444
|
-
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
445
|
-
const specWithValues: NormalizedChartSpec = {
|
|
446
|
-
...lineSpec,
|
|
447
|
-
encoding: {
|
|
448
|
-
x: {
|
|
449
|
-
field: 'date',
|
|
450
|
-
type: 'temporal',
|
|
451
|
-
axis: { values: ['2020-01-01', '2021-01-01', '2022-01-01'] },
|
|
452
|
-
},
|
|
453
|
-
y: { field: 'value', type: 'quantitative' },
|
|
454
|
-
},
|
|
455
|
-
};
|
|
456
|
-
|
|
457
|
-
const scales = computeScales(specWithValues, narrowArea, specWithValues.data);
|
|
458
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
459
|
-
|
|
460
|
-
// Explicit values should be preserved exactly as specified
|
|
461
|
-
expect(axes.x!.ticks.length).toBe(3);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('band scale shows all categories regardless of width', () => {
|
|
465
|
-
const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
|
|
466
|
-
const barSpec: NormalizedChartSpec = {
|
|
467
|
-
...lineSpec,
|
|
468
|
-
markType: 'bar',
|
|
469
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
470
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
471
|
-
encoding: {
|
|
472
|
-
x: { field: 'cat', type: 'nominal' },
|
|
473
|
-
y: { field: 'val', type: 'quantitative' },
|
|
474
|
-
},
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
478
|
-
const scales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
479
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
480
|
-
|
|
481
|
-
// Band scales show all categories (auto-rotation handles overlap instead)
|
|
482
|
-
expect(axes.x!.ticks.length).toBe(categories.length);
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('keeps all band labels when one label is much wider (no decimation)', () => {
|
|
486
|
-
// Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
|
|
487
|
-
// than the others. Label width cannot cause rotated-label collisions
|
|
488
|
-
// (parallel ribbons — see ./layout/axes/rotation), so every label is kept.
|
|
489
|
-
// The old span-based thinning dropped "2023" and "2025" here.
|
|
490
|
-
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
491
|
-
const barSpec: NormalizedChartSpec = {
|
|
492
|
-
...lineSpec,
|
|
493
|
-
markType: 'bar',
|
|
494
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
495
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
496
|
-
encoding: {
|
|
497
|
-
x: { field: 'cat', type: 'nominal' },
|
|
498
|
-
y: { field: 'val', type: 'quantitative' },
|
|
499
|
-
},
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
// measureText reports the wide label as genuinely wide and the year labels
|
|
503
|
-
// as narrow, matching real glyph metrics.
|
|
504
|
-
const measure = (text: string) => ({
|
|
505
|
-
width: text.length * 8,
|
|
506
|
-
height: 12,
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// Narrow area (280px) forces -45° rotation. Every label survives — the
|
|
510
|
-
// ~47px step separates the diagonal ribbons by far more than a line height.
|
|
511
|
-
const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
|
|
512
|
-
const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
513
|
-
const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
|
|
514
|
-
expect(narrowAxes.x!.tickAngle).toBe(-45);
|
|
515
|
-
expect(narrowAxes.x!.ticks.map((t) => t.label)).toEqual(categories);
|
|
516
|
-
|
|
517
|
-
// Wider area (500px) has room for every label — none are thinned.
|
|
518
|
-
const wideArea = { x: 50, y: 50, width: 500, height: 300 };
|
|
519
|
-
const wideScales = computeScales(barSpec, wideArea, barSpec.data);
|
|
520
|
-
const wideAxes = computeAxes(wideScales, wideArea, fullStrategy, theme, measure);
|
|
521
|
-
expect(wideAxes.x!.ticks.length).toBe(categories.length);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
it('keeps a short neighbor beside a wide final label at phone width', () => {
|
|
525
|
-
// Regression (mobile): "2025" beside "2026 (to wk 17)" at ~300px. Two
|
|
526
|
-
// earlier span-model revisions dropped "2025" here (once via minGap
|
|
527
|
-
// comfort padding, once via a genuine 1-D span overlap at larger fonts)
|
|
528
|
-
// even though the rendered -45° diagonals never touch.
|
|
529
|
-
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
530
|
-
const barSpec: NormalizedChartSpec = {
|
|
531
|
-
...lineSpec,
|
|
532
|
-
markType: 'bar',
|
|
533
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
534
|
-
data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2000, 1600][i] })),
|
|
535
|
-
encoding: {
|
|
536
|
-
x: { field: 'cat', type: 'nominal' },
|
|
537
|
-
y: { field: 'val', type: 'quantitative' },
|
|
538
|
-
},
|
|
539
|
-
};
|
|
540
|
-
|
|
541
|
-
// Uses default (real) text measurement. At 300px the band centers sit ~56px
|
|
542
|
-
// apart; the year labels project to ~17px at -45° and the wide label to
|
|
543
|
-
// ~54px, leaving "2025" and "2026 (to wk 17)" ~2px apart — a near miss.
|
|
544
|
-
const area = { x: 60, y: 50, width: 300, height: 300 };
|
|
545
|
-
const scales = computeScales(barSpec, area, barSpec.data);
|
|
546
|
-
const axes = computeAxes(scales, area, fullStrategy, theme);
|
|
547
|
-
expect(axes.x!.tickAngle).toBe(-45);
|
|
548
|
-
const kept = axes.x!.ticks.map((t) => t.label);
|
|
549
|
-
expect(kept).toContain('2025');
|
|
550
|
-
expect(kept).toContain('2026 (to wk 17)');
|
|
551
|
-
expect(kept.length).toBe(categories.length);
|
|
552
|
-
});
|
|
553
|
-
|
|
554
|
-
it('keeps all labels with a larger themed tick font at phone width (deployed blog regression)', () => {
|
|
555
|
-
// Regression: the deployed blog theme sets axisTick: 14 (default 11). At
|
|
556
|
-
// ~61px steps the larger font made the 1-D span overlap genuine, so 7.9.2
|
|
557
|
-
// still dropped "2025" in production while the default theme passed every
|
|
558
|
-
// test. Ribbon separation at -45° is 61·sin45 ≈ 43px vs the ~21px one line
|
|
559
|
-
// at 14px needs — the labels never touch, so all five must survive.
|
|
560
|
-
const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
|
|
561
|
-
const barSpec: NormalizedChartSpec = {
|
|
562
|
-
...lineSpec,
|
|
563
|
-
markType: 'bar',
|
|
564
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
565
|
-
data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2026, 1602][i] })),
|
|
566
|
-
encoding: {
|
|
567
|
-
x: { field: 'cat', type: 'nominal' },
|
|
568
|
-
y: { field: 'val', type: 'quantitative' },
|
|
569
|
-
},
|
|
570
|
-
};
|
|
571
|
-
const blogTheme = resolveTheme({ fonts: { sizes: { axisTick: 14 } } });
|
|
572
|
-
|
|
573
|
-
const area = { x: 60, y: 50, width: 330, height: 300 };
|
|
574
|
-
const scales = computeScales(barSpec, area, barSpec.data);
|
|
575
|
-
const axes = computeAxes(scales, area, fullStrategy, blogTheme);
|
|
576
|
-
expect(axes.x!.tickAngle).toBe(-45);
|
|
577
|
-
expect(axes.x!.ticks.map((t) => t.label)).toEqual(categories);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it('keeps every label when one is very long — rotated labels are parallel ribbons', () => {
|
|
581
|
-
// A very long final category does NOT force neighbors to drop. Rotated
|
|
582
|
-
// labels all lean at the same angle from their own anchors, so adjacent
|
|
583
|
-
// labels are parallel ribbons separated by step·sin(angle) — label LENGTH
|
|
584
|
-
// is irrelevant to collision. The old 1-D horizontal-span model reported
|
|
585
|
-
// phantom overlaps here and dropped "B", "C", and "D".
|
|
586
|
-
const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
|
|
587
|
-
const barSpec: NormalizedChartSpec = {
|
|
588
|
-
...lineSpec,
|
|
589
|
-
markType: 'bar',
|
|
590
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
591
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
592
|
-
encoding: {
|
|
593
|
-
x: { field: 'cat', type: 'nominal' },
|
|
594
|
-
y: { field: 'val', type: 'quantitative' },
|
|
595
|
-
},
|
|
596
|
-
};
|
|
597
|
-
const measure = (text: string) => ({ width: text.length * 8, height: 12 });
|
|
598
|
-
const narrowArea = { x: 50, y: 50, width: 260, height: 300 };
|
|
599
|
-
const scales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
600
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
|
|
601
|
-
expect(axes.x!.tickAngle).toBe(-45);
|
|
602
|
-
|
|
603
|
-
// Every category keeps a tick — the diagonals never touch, so no label is
|
|
604
|
-
// DROPPED. (The long one is ellipsized to fit the capped rotated-label
|
|
605
|
-
// band, which is a separate concern from decimation; its full text is
|
|
606
|
-
// preserved on `fullLabel`.)
|
|
607
|
-
expect(axes.x!.ticks).toHaveLength(categories.length);
|
|
608
|
-
const kept = axes.x!.ticks.map((t) => t.fullLabel ?? t.label);
|
|
609
|
-
expect(kept).toEqual(categories);
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
it('truncates rotated labels that would overflow the capped reservation', () => {
|
|
613
|
-
// Regression: the layout clamps the reserved rotated-label band to
|
|
614
|
-
// X_AXIS_ROTATED_EXTENT_CAP (120px) but nothing shortened the drawn string,
|
|
615
|
-
// so a long label rendered straight past the axis and through the source
|
|
616
|
-
// line. Long labels must now be ellipsized to fit that band, with the full
|
|
617
|
-
// text retained on `fullLabel` for tooltips and the a11y table.
|
|
618
|
-
const categories = ['Short', 'An Extremely Long Category Label That Cannot Possibly Fit'];
|
|
619
|
-
const barSpec: NormalizedChartSpec = {
|
|
620
|
-
...lineSpec,
|
|
621
|
-
markType: 'bar',
|
|
622
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
623
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
624
|
-
encoding: {
|
|
625
|
-
x: { field: 'cat', type: 'nominal' },
|
|
626
|
-
y: { field: 'val', type: 'quantitative' },
|
|
627
|
-
},
|
|
628
|
-
};
|
|
629
|
-
const measure = (text: string) => ({ width: text.length * 8, height: 12 });
|
|
630
|
-
const area = { x: 50, y: 50, width: 260, height: 300 };
|
|
631
|
-
const scales = computeScales(barSpec, area, barSpec.data);
|
|
632
|
-
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
633
|
-
|
|
634
|
-
const ticks = axes.x!.ticks;
|
|
635
|
-
const angle = axes.x!.tickAngle!;
|
|
636
|
-
expect(Math.abs(angle)).toBeGreaterThan(10);
|
|
637
|
-
|
|
638
|
-
// The short label is untouched and carries no fullLabel.
|
|
639
|
-
const short = ticks.find((t) => t.value === 'Short')!;
|
|
640
|
-
expect(short.label).toBe('Short');
|
|
641
|
-
expect(short.fullLabel).toBeUndefined();
|
|
642
|
-
|
|
643
|
-
// The long one is ellipsized, but its full text survives on fullLabel.
|
|
644
|
-
const long = ticks.find((t) => t.value === categories[1])!;
|
|
645
|
-
expect(long.label).not.toBe(categories[1]);
|
|
646
|
-
expect(long.label.endsWith('…')).toBe(true);
|
|
647
|
-
expect(long.fullLabel).toBe(categories[1]);
|
|
648
|
-
|
|
649
|
-
// And the drawn label now fits the budget the reservation is computed from,
|
|
650
|
-
// so it cannot overflow the band.
|
|
651
|
-
const budget = maxRotatedLabelWidth(angle, theme.fonts.sizes.axisTick);
|
|
652
|
-
expect(measure(long.label).width).toBeLessThanOrEqual(budget);
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
|
|
656
|
-
// 40 categories with tickCount: 8 — categoricalTicks retains every 5th
|
|
657
|
-
// category, so the drawn ticks sit 5 band steps apart. The angle and
|
|
658
|
-
// stride must use that real spacing: at ~45px apart the 8 labels fit at
|
|
659
|
-
// -45° with no thinning. Keying on the raw ~9px band step would wrongly
|
|
660
|
-
// escalate to -90° and stride away half the requested labels.
|
|
661
|
-
const categories = Array.from({ length: 40 }, (_, i) => `Cat ${i + 1}`);
|
|
662
|
-
const barSpec: NormalizedChartSpec = {
|
|
663
|
-
...lineSpec,
|
|
664
|
-
markType: 'bar',
|
|
665
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
666
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
667
|
-
encoding: {
|
|
668
|
-
x: { field: 'cat', type: 'nominal', axis: { tickCount: 8 } },
|
|
669
|
-
y: { field: 'val', type: 'quantitative' },
|
|
670
|
-
},
|
|
671
|
-
};
|
|
672
|
-
const measure = (text: string) => ({ width: text.length * 6, height: 12 });
|
|
673
|
-
const area = { x: 50, y: 50, width: 360, height: 300 };
|
|
674
|
-
const scales = computeScales(barSpec, area, barSpec.data);
|
|
675
|
-
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
676
|
-
|
|
677
|
-
expect(axes.x!.tickAngle).toBe(-45);
|
|
678
|
-
expect(axes.x!.ticks.length).toBe(8);
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
it('applies the last-anchored stride when even vertical labels cannot fit', () => {
|
|
682
|
-
// 40 short categories on a 360px chart: ~9px steps fit nothing at any
|
|
683
|
-
// angle (a vertical label ribbon needs ~17px), so the safety net keeps
|
|
684
|
-
// every 2nd label counting back from the LAST category.
|
|
685
|
-
const categories = Array.from({ length: 40 }, (_, i) => `C${i + 1}`);
|
|
686
|
-
const barSpec: NormalizedChartSpec = {
|
|
687
|
-
...lineSpec,
|
|
688
|
-
markType: 'bar',
|
|
689
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
690
|
-
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
691
|
-
encoding: {
|
|
692
|
-
x: { field: 'cat', type: 'nominal' },
|
|
693
|
-
y: { field: 'val', type: 'quantitative' },
|
|
694
|
-
},
|
|
695
|
-
};
|
|
696
|
-
const measure = (text: string) => ({ width: text.length * 6, height: 12 });
|
|
697
|
-
const area = { x: 50, y: 50, width: 360, height: 300 };
|
|
698
|
-
const scales = computeScales(barSpec, area, barSpec.data);
|
|
699
|
-
const axes = computeAxes(scales, area, fullStrategy, theme, measure);
|
|
700
|
-
|
|
701
|
-
expect(axes.x!.tickAngle).toBe(-90);
|
|
702
|
-
const kept = axes.x!.ticks.map((t) => t.label);
|
|
703
|
-
expect(kept.length).toBe(20);
|
|
704
|
-
// The stride anchors at the final category — the most recent one is
|
|
705
|
-
// always labeled.
|
|
706
|
-
expect(kept[kept.length - 1]).toBe('C40');
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
it('y-axis gridlines match ticks so every gridline has a label', () => {
|
|
710
|
-
// Force thinning by using a measureText that reports wide labels
|
|
711
|
-
const wideMeasure = () => ({ width: 200, height: 12 });
|
|
712
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
713
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
714
|
-
|
|
715
|
-
// Y-axis gridlines should always match ticks 1:1 (every gridline gets a label)
|
|
716
|
-
expect(axes.y!.gridlines.length).toBe(axes.y!.ticks.length);
|
|
717
|
-
for (let i = 0; i < axes.y!.ticks.length; i++) {
|
|
718
|
-
expect(axes.y!.gridlines[i].position).toBe(axes.y!.ticks[i].position);
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
it('x-axis gridlines survive tick thinning when grid is enabled', () => {
|
|
723
|
-
const specWithGrid: NormalizedChartSpec = {
|
|
724
|
-
...lineSpec,
|
|
725
|
-
encoding: {
|
|
726
|
-
x: { field: 'date', type: 'temporal', axis: { grid: true } },
|
|
727
|
-
y: { field: 'value', type: 'quantitative' },
|
|
728
|
-
},
|
|
729
|
-
};
|
|
730
|
-
|
|
731
|
-
const wideMeasure = () => ({ width: 200, height: 12 });
|
|
732
|
-
const scales = computeScales(specWithGrid, chartArea, specWithGrid.data);
|
|
733
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
734
|
-
|
|
735
|
-
expect(axes.x!.gridlines.length).toBeGreaterThanOrEqual(axes.x!.ticks.length);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
it('passes measureText to auto-rotation detection', () => {
|
|
739
|
-
const barSpec: NormalizedChartSpec = {
|
|
740
|
-
...lineSpec,
|
|
741
|
-
markType: 'bar',
|
|
742
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
743
|
-
data: [
|
|
744
|
-
{ cat: 'A', val: 10 },
|
|
745
|
-
{ cat: 'B', val: 20 },
|
|
746
|
-
],
|
|
747
|
-
encoding: {
|
|
748
|
-
x: { field: 'cat', type: 'nominal' },
|
|
749
|
-
y: { field: 'val', type: 'quantitative' },
|
|
750
|
-
},
|
|
751
|
-
};
|
|
752
|
-
|
|
753
|
-
// measureText that reports very wide labels should trigger rotation
|
|
754
|
-
const wideMeasure = () => ({ width: 1000, height: 12 });
|
|
755
|
-
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
756
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
757
|
-
|
|
758
|
-
expect(axes.x!.tickAngle).toBe(-45);
|
|
759
|
-
});
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
// ---------------------------------------------------------------------------
|
|
763
|
-
// Axis config expansion tests
|
|
764
|
-
// ---------------------------------------------------------------------------
|
|
765
|
-
|
|
766
|
-
describe('axis config properties', () => {
|
|
767
|
-
it('uses title instead of deprecated label', () => {
|
|
768
|
-
const spec: NormalizedChartSpec = {
|
|
769
|
-
...lineSpec,
|
|
770
|
-
encoding: {
|
|
771
|
-
x: { field: 'date', type: 'temporal', axis: { title: 'Year' } },
|
|
772
|
-
y: { field: 'value', type: 'quantitative', axis: { title: 'Amount ($)' } },
|
|
773
|
-
},
|
|
774
|
-
};
|
|
775
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
776
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
777
|
-
|
|
778
|
-
expect(axes.x!.label).toBe('Year');
|
|
779
|
-
expect(axes.y!.label).toBe('Amount ($)');
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
it('propagates labelAngle to layout tickAngle', () => {
|
|
783
|
-
const spec: NormalizedChartSpec = {
|
|
784
|
-
...lineSpec,
|
|
785
|
-
encoding: {
|
|
786
|
-
x: {
|
|
787
|
-
field: 'date',
|
|
788
|
-
type: 'temporal',
|
|
789
|
-
axis: { labelAngle: -30 },
|
|
790
|
-
},
|
|
791
|
-
y: { field: 'value', type: 'quantitative' },
|
|
792
|
-
},
|
|
793
|
-
};
|
|
794
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
795
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
796
|
-
|
|
797
|
-
expect(axes.x!.tickAngle).toBe(-30);
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it('passes orient config to axis layout', () => {
|
|
801
|
-
const spec: NormalizedChartSpec = {
|
|
802
|
-
...lineSpec,
|
|
803
|
-
encoding: {
|
|
804
|
-
x: { field: 'date', type: 'temporal', axis: { orient: 'top' } },
|
|
805
|
-
y: { field: 'value', type: 'quantitative', axis: { orient: 'right' } },
|
|
806
|
-
},
|
|
807
|
-
};
|
|
808
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
809
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
810
|
-
|
|
811
|
-
expect(axes.x!.orient).toBe('top');
|
|
812
|
-
expect(axes.y!.orient).toBe('right');
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
it('passes domain and ticks visibility to axis layout', () => {
|
|
816
|
-
const spec: NormalizedChartSpec = {
|
|
817
|
-
...lineSpec,
|
|
818
|
-
encoding: {
|
|
819
|
-
x: { field: 'date', type: 'temporal', axis: { domain: false, ticks: false } },
|
|
820
|
-
y: { field: 'value', type: 'quantitative' },
|
|
821
|
-
},
|
|
822
|
-
};
|
|
823
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
824
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
825
|
-
|
|
826
|
-
expect(axes.x!.domainLine).toBe(false);
|
|
827
|
-
expect(axes.x!.tickMarks).toBe(false);
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
it('passes offset and padding configs to axis layout', () => {
|
|
831
|
-
const spec: NormalizedChartSpec = {
|
|
832
|
-
...lineSpec,
|
|
833
|
-
encoding: {
|
|
834
|
-
x: {
|
|
835
|
-
field: 'date',
|
|
836
|
-
type: 'temporal',
|
|
837
|
-
axis: { offset: 10, titlePadding: 8, labelPadding: 4 },
|
|
838
|
-
},
|
|
839
|
-
y: { field: 'value', type: 'quantitative' },
|
|
840
|
-
},
|
|
841
|
-
};
|
|
842
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
843
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
844
|
-
|
|
845
|
-
expect(axes.x!.offset).toBe(10);
|
|
846
|
-
expect(axes.x!.titlePadding).toBe(8);
|
|
847
|
-
expect(axes.x!.labelPadding).toBe(4);
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
it('passes labelOverlap and labelFlush to axis layout', () => {
|
|
851
|
-
const spec: NormalizedChartSpec = {
|
|
852
|
-
...lineSpec,
|
|
853
|
-
encoding: {
|
|
854
|
-
x: {
|
|
855
|
-
field: 'date',
|
|
856
|
-
type: 'temporal',
|
|
857
|
-
axis: { labelOverlap: 'parity', labelFlush: true },
|
|
858
|
-
},
|
|
859
|
-
y: { field: 'value', type: 'quantitative' },
|
|
860
|
-
},
|
|
861
|
-
};
|
|
862
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
863
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
864
|
-
|
|
865
|
-
expect(axes.x!.labelOverlap).toBe('parity');
|
|
866
|
-
expect(axes.x!.labelFlush).toBe(true);
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
it('uses explicit tick values when provided', () => {
|
|
870
|
-
const spec: NormalizedChartSpec = {
|
|
871
|
-
...lineSpec,
|
|
872
|
-
encoding: {
|
|
873
|
-
x: { field: 'date', type: 'temporal' },
|
|
874
|
-
y: {
|
|
875
|
-
field: 'value',
|
|
876
|
-
type: 'quantitative',
|
|
877
|
-
axis: { values: [0, 250, 500] },
|
|
878
|
-
},
|
|
879
|
-
},
|
|
880
|
-
};
|
|
881
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
882
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
883
|
-
|
|
884
|
-
// Should produce exactly 3 ticks matching our explicit values
|
|
885
|
-
expect(axes.y!.ticks.length).toBe(3);
|
|
886
|
-
expect(axes.y!.ticks[0].value).toBe(0);
|
|
887
|
-
expect(axes.y!.ticks[1].value).toBe(250);
|
|
888
|
-
expect(axes.y!.ticks[2].value).toBe(500);
|
|
889
|
-
});
|
|
890
|
-
|
|
891
|
-
it('defaults are undefined when axis config properties are not set', () => {
|
|
892
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
893
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
894
|
-
|
|
895
|
-
// All new properties should be undefined when not set (except domainLine,
|
|
896
|
-
// which defaults to false for non-grounded marks like line)
|
|
897
|
-
expect(axes.x!.orient).toBeUndefined();
|
|
898
|
-
expect(axes.x!.domainLine).toBe(false);
|
|
899
|
-
expect(axes.x!.tickMarks).toBeUndefined();
|
|
900
|
-
expect(axes.x!.offset).toBeUndefined();
|
|
901
|
-
expect(axes.x!.titlePadding).toBeUndefined();
|
|
902
|
-
expect(axes.x!.labelPadding).toBeUndefined();
|
|
903
|
-
expect(axes.x!.labelOverlap).toBeUndefined();
|
|
904
|
-
expect(axes.x!.labelFlush).toBeUndefined();
|
|
905
|
-
});
|
|
906
|
-
});
|
|
907
|
-
|
|
908
|
-
// ---------------------------------------------------------------------------
|
|
909
|
-
// Y-axis produces ~5 ticks at standard sizes
|
|
910
|
-
// ---------------------------------------------------------------------------
|
|
911
|
-
|
|
912
|
-
describe('y-axis tick density', () => {
|
|
913
|
-
it('produces 5+ y-axis ticks at standard chart size with full density', () => {
|
|
914
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
915
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
916
|
-
|
|
917
|
-
// A 500x300 chart with domain [100, 500] should show ~5+ ticks, not just 2
|
|
918
|
-
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
it('y-axis thinning uses vertical overlap, not horizontal text width', () => {
|
|
922
|
-
// Even with a measureText that reports very wide labels, y-axis should
|
|
923
|
-
// not thin aggressively because overlap is checked vertically
|
|
924
|
-
const wideMeasure = () => ({ width: 500, height: 12 });
|
|
925
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
926
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
927
|
-
|
|
928
|
-
// Wide label text shouldn't cause y-axis thinning (only height matters)
|
|
929
|
-
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
it('does not collapse to min/max when nice() domain creates a close last tick', () => {
|
|
933
|
-
// Regression: domain [0, 340] nice()'s to [0, 350]. The old thinning path
|
|
934
|
-
// kept both 340 and the endpoint 350, causing cascaded thinning down to
|
|
935
|
-
// [0, 340] (2 ticks). Fix re-requests at lower counts from D3 instead.
|
|
936
|
-
const barSpec: NormalizedChartSpec = {
|
|
937
|
-
markType: 'bar',
|
|
938
|
-
markDef: { type: 'bar' },
|
|
939
|
-
data: [
|
|
940
|
-
{ year: '2019', v: 110 },
|
|
941
|
-
{ year: '2023', v: 340 },
|
|
942
|
-
],
|
|
943
|
-
encoding: {
|
|
944
|
-
x: { field: 'year', type: 'nominal' },
|
|
945
|
-
y: { field: 'v', type: 'quantitative' },
|
|
946
|
-
},
|
|
947
|
-
chrome: {},
|
|
948
|
-
annotations: [],
|
|
949
|
-
responsive: true,
|
|
950
|
-
theme: {},
|
|
951
|
-
darkMode: 'off',
|
|
952
|
-
labels: { density: 'auto', format: '' },
|
|
953
|
-
};
|
|
954
|
-
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
955
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
956
|
-
|
|
957
|
-
// The specific bug produced exactly [0, 340] — only the min and max with
|
|
958
|
-
// no interior gridlines. Any output with interior values is a pass, even
|
|
959
|
-
// if the count varies with D3's step choice across platforms.
|
|
960
|
-
const values = axes.y!.ticks.map((t) => t.value);
|
|
961
|
-
expect(values).not.toEqual([0, 340]);
|
|
962
|
-
expect(values).not.toEqual([0, 350]);
|
|
963
|
-
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(4);
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
it('adapts y-axis tick count down for short charts', () => {
|
|
967
|
-
const shortArea = { x: 50, y: 50, width: 500, height: 140 };
|
|
968
|
-
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
969
|
-
const axes = computeAxes(scales, shortArea, fullStrategy, theme);
|
|
970
|
-
|
|
971
|
-
// Short charts should still show multiple ticks but fewer than tall ones
|
|
972
|
-
expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
973
|
-
expect(axes.y!.ticks.length).toBeLessThan(10);
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
it('steps continuous x-axis down when D3 overshoots on narrow temporal scales', () => {
|
|
977
|
-
// D3 time scales jump between calendar units — a request for 6 ticks on
|
|
978
|
-
// a 3-year range can return 4 (yearly) or 14 (quarterly). On a narrow
|
|
979
|
-
// chart we want the sparser choice, not the dense one.
|
|
980
|
-
const narrowTimeSpec: NormalizedChartSpec = {
|
|
981
|
-
...lineSpec,
|
|
982
|
-
data: [
|
|
983
|
-
{ date: '2022-01-01', value: 10 },
|
|
984
|
-
{ date: '2022-12-01', value: 40 },
|
|
985
|
-
],
|
|
986
|
-
};
|
|
987
|
-
const narrowArea = { x: 50, y: 50, width: 300, height: 300 };
|
|
988
|
-
const scales = computeScales(narrowTimeSpec, narrowArea, narrowTimeSpec.data);
|
|
989
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
990
|
-
|
|
991
|
-
// A 300px-wide time axis should show at most ~6 labels, not the 10-14
|
|
992
|
-
// that D3 produces when its nice() step hops into monthly territory.
|
|
993
|
-
expect(axes.x!.ticks.length).toBeLessThanOrEqual(6);
|
|
994
|
-
expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
995
|
-
});
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
// ---------------------------------------------------------------------------
|
|
999
|
-
// Vertical orientation overlap detection
|
|
1000
|
-
// ---------------------------------------------------------------------------
|
|
1001
|
-
|
|
1002
|
-
describe('ticksOverlap with vertical orientation', () => {
|
|
1003
|
-
const fontSize = 12;
|
|
1004
|
-
const fontWeight = 400;
|
|
1005
|
-
|
|
1006
|
-
it('returns false when vertical ticks have sufficient spacing', () => {
|
|
1007
|
-
// Labels at 30px intervals with 12px font (14.4px with lineHeight)
|
|
1008
|
-
const ticks: AxisTick[] = [
|
|
1009
|
-
{ value: 0, position: 0, label: '100' },
|
|
1010
|
-
{ value: 1, position: 30, label: '200' },
|
|
1011
|
-
{ value: 2, position: 60, label: '300' },
|
|
1012
|
-
{ value: 3, position: 90, label: '400' },
|
|
1013
|
-
{ value: 4, position: 120, label: '500' },
|
|
1014
|
-
];
|
|
1015
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
|
|
1016
|
-
});
|
|
1017
|
-
|
|
1018
|
-
it('returns true when vertical ticks are too close', () => {
|
|
1019
|
-
// Labels at 10px intervals with 12px font - should overlap vertically
|
|
1020
|
-
const ticks: AxisTick[] = [
|
|
1021
|
-
{ value: 0, position: 0, label: '100' },
|
|
1022
|
-
{ value: 1, position: 10, label: '200' },
|
|
1023
|
-
{ value: 2, position: 20, label: '300' },
|
|
1024
|
-
];
|
|
1025
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(true);
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
it('ignores label text width for vertical orientation', () => {
|
|
1029
|
-
// Very wide labels but well-spaced vertically - should NOT overlap
|
|
1030
|
-
const ticks: AxisTick[] = [
|
|
1031
|
-
{ value: 0, position: 0, label: 'Very Long Label Text Here' },
|
|
1032
|
-
{ value: 1, position: 40, label: 'Another Very Long Label' },
|
|
1033
|
-
{ value: 2, position: 80, label: 'Yet Another Long Label' },
|
|
1034
|
-
];
|
|
1035
|
-
// Horizontal would detect overlap, vertical should not
|
|
1036
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'horizontal')).toBe(true);
|
|
1037
|
-
expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
|
|
1038
|
-
});
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
// ---------------------------------------------------------------------------
|
|
1042
|
-
// Horizontal bar chart: y-axis category label regression
|
|
1043
|
-
// Mobile/compact viewports must show all category labels on horizontal bar
|
|
1044
|
-
// charts, regardless of axisLabelDensity. Thinning is only valid on x-axis
|
|
1045
|
-
// band scales where many category names can overlap horizontally.
|
|
1046
|
-
// ---------------------------------------------------------------------------
|
|
1047
|
-
|
|
1048
|
-
describe('horizontal bar y-axis label thinning regression', () => {
|
|
1049
|
-
const countries = [
|
|
1050
|
-
'USA',
|
|
1051
|
-
'Germany',
|
|
1052
|
-
'France',
|
|
1053
|
-
'Japan',
|
|
1054
|
-
'UK',
|
|
1055
|
-
'Canada',
|
|
1056
|
-
'Australia',
|
|
1057
|
-
'Netherlands',
|
|
1058
|
-
'Sweden',
|
|
1059
|
-
'Switzerland',
|
|
1060
|
-
];
|
|
1061
|
-
|
|
1062
|
-
const hBarSpec: NormalizedChartSpec = {
|
|
1063
|
-
markType: 'bar',
|
|
1064
|
-
markDef: { type: 'bar', orient: 'horizontal' },
|
|
1065
|
-
data: countries.map((country, i) => ({ country, value: (i + 1) * 100 })),
|
|
1066
|
-
encoding: {
|
|
1067
|
-
x: { field: 'value', type: 'quantitative' },
|
|
1068
|
-
y: { field: 'country', type: 'nominal' },
|
|
1069
|
-
},
|
|
1070
|
-
chrome: {},
|
|
1071
|
-
annotations: [],
|
|
1072
|
-
responsive: true,
|
|
1073
|
-
theme: {},
|
|
1074
|
-
darkMode: 'off',
|
|
1075
|
-
labels: { density: 'auto', format: '' },
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
it('shows all category labels on y-axis at minimal density (mobile regression)', () => {
|
|
1079
|
-
const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
|
|
1080
|
-
const axes = computeAxes(scales, chartArea, minimalStrategy, theme);
|
|
1081
|
-
|
|
1082
|
-
// Every bar must have a label -- thinning to 3 on mobile was the bug
|
|
1083
|
-
expect(axes.y!.ticks.length).toBe(countries.length);
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
it('shows all category labels on y-axis at reduced density', () => {
|
|
1087
|
-
const reducedStrategy: LayoutStrategy = {
|
|
1088
|
-
...minimalStrategy,
|
|
1089
|
-
axisLabelDensity: 'reduced',
|
|
1090
|
-
};
|
|
1091
|
-
const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
|
|
1092
|
-
const axes = computeAxes(scales, chartArea, reducedStrategy, theme);
|
|
1093
|
-
|
|
1094
|
-
expect(axes.y!.ticks.length).toBe(countries.length);
|
|
1095
|
-
});
|
|
1096
|
-
|
|
1097
|
-
it('rotates to vertical instead of thinning when diagonals would touch (column chart)', () => {
|
|
1098
|
-
const vBarSpec: NormalizedChartSpec = {
|
|
1099
|
-
...hBarSpec,
|
|
1100
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
1101
|
-
encoding: {
|
|
1102
|
-
x: { field: 'country', type: 'nominal' },
|
|
1103
|
-
y: { field: 'value', type: 'quantitative' },
|
|
1104
|
-
},
|
|
1105
|
-
};
|
|
1106
|
-
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
1107
|
-
const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
|
|
1108
|
-
const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
|
|
1109
|
-
|
|
1110
|
-
// 10 categories at ~19px steps: -45° ribbons would touch (19·sin45 ≈ 14px
|
|
1111
|
-
// < one line height), so the ladder escalates to vertical, where the 19px
|
|
1112
|
-
// step separation fits — every bar keeps its label.
|
|
1113
|
-
expect(axes.x!.tickAngle).toBe(-90);
|
|
1114
|
-
expect(axes.x!.ticks.length).toBe(countries.length);
|
|
1115
|
-
});
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
// ---------------------------------------------------------------------------
|
|
1119
|
-
// Log scale tick filtering — buildContinuousTicks
|
|
1120
|
-
// D3 log scales ignore the count hint and return ticks at every sub-power
|
|
1121
|
-
// position. The engine must filter these down to powers of the base only.
|
|
1122
|
-
// ---------------------------------------------------------------------------
|
|
1123
|
-
|
|
1124
|
-
/**
|
|
1125
|
-
* Build a ResolvedScale backed by a D3 log scale, matching what buildLogScale produces.
|
|
1126
|
-
* Using a single `base` parameter keeps the D3 scale and channel config in sync,
|
|
1127
|
-
* mirroring how they're always derived from the same spec field.
|
|
1128
|
-
*/
|
|
1129
|
-
function makeLogScale(domain: [number, number], base = 10): ResolvedScale {
|
|
1130
|
-
const scale = scaleLog().domain(domain).range([400, 0]);
|
|
1131
|
-
scale.base(base);
|
|
1132
|
-
return {
|
|
1133
|
-
scale,
|
|
1134
|
-
type: 'log',
|
|
1135
|
-
channel: {
|
|
1136
|
-
field: 'value',
|
|
1137
|
-
type: 'quantitative',
|
|
1138
|
-
scale: base !== 10 ? { base } : undefined,
|
|
1139
|
-
},
|
|
1140
|
-
} as ResolvedScale;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
describe('buildContinuousTicks — log scale power filtering', () => {
|
|
1144
|
-
it('returns only power-of-10 ticks for [5, 25000] at tickCount 5', () => {
|
|
1145
|
-
const resolved = makeLogScale([5, 25000]);
|
|
1146
|
-
const ticks = buildContinuousTicks(resolved, 5);
|
|
1147
|
-
const values = ticks.map((t) => t.value as number);
|
|
1148
|
-
// Should be exactly the powers of 10 in domain: 10, 100, 1000, 10000
|
|
1149
|
-
expect(values).toEqual([10, 100, 1000, 10000]);
|
|
1150
|
-
});
|
|
1151
|
-
|
|
1152
|
-
it('returns only power-of-10 ticks for [1, 1000000] at tickCount 5', () => {
|
|
1153
|
-
const resolved = makeLogScale([1, 1_000_000]);
|
|
1154
|
-
const ticks = buildContinuousTicks(resolved, 5);
|
|
1155
|
-
const values = ticks.map((t) => t.value as number);
|
|
1156
|
-
// Assert invariants rather than exact output: every tick is a power of 10,
|
|
1157
|
-
// and we get at least 5 (the domain spans 6 decades).
|
|
1158
|
-
for (const v of values) {
|
|
1159
|
-
const exp = Math.log10(v);
|
|
1160
|
-
expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-9);
|
|
1161
|
-
}
|
|
1162
|
-
expect(values.length).toBeGreaterThanOrEqual(5);
|
|
1163
|
-
});
|
|
1164
|
-
|
|
1165
|
-
it('returns only powers-of-2 for [1, 64] base-2 at tickCount 5', () => {
|
|
1166
|
-
const resolved = makeLogScale([1, 64], 2);
|
|
1167
|
-
const ticks = buildContinuousTicks(resolved, 5);
|
|
1168
|
-
const values = ticks.map((t) => t.value as number);
|
|
1169
|
-
expect(values).toEqual([1, 2, 4, 8, 16, 32, 64]);
|
|
1170
|
-
});
|
|
1171
|
-
|
|
1172
|
-
it('handles fractional powers for [0.001, 100] at tickCount 5', () => {
|
|
1173
|
-
const resolved = makeLogScale([0.001, 100]);
|
|
1174
|
-
const ticks = buildContinuousTicks(resolved, 5);
|
|
1175
|
-
const values = ticks.map((t) => t.value as number);
|
|
1176
|
-
// Tolerance check prevents floating-point false negatives on 0.001, 0.01, 0.1
|
|
1177
|
-
expect(values).toEqual([0.001, 0.01, 0.1, 1, 10, 100]);
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
it('still produces ticks at tickCount 3 (regression guard)', () => {
|
|
1181
|
-
const resolved = makeLogScale([1, 1000]);
|
|
1182
|
-
const ticks = buildContinuousTicks(resolved, 3);
|
|
1183
|
-
expect(ticks.length).toBeGreaterThanOrEqual(2);
|
|
1184
|
-
const values = ticks.map((t) => t.value as number);
|
|
1185
|
-
// Every value must be a power of 10
|
|
1186
|
-
for (const v of values) {
|
|
1187
|
-
const exp = Math.log10(v);
|
|
1188
|
-
expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-6);
|
|
1189
|
-
}
|
|
1190
|
-
});
|
|
1191
|
-
|
|
1192
|
-
it('does not over-filter linear scales with the same domain', () => {
|
|
1193
|
-
const scale = scaleLinear().domain([5, 25000]).range([400, 0]);
|
|
1194
|
-
const resolved: ResolvedScale = {
|
|
1195
|
-
scale,
|
|
1196
|
-
type: 'linear',
|
|
1197
|
-
channel: { field: 'value', type: 'quantitative' },
|
|
1198
|
-
} as ResolvedScale;
|
|
1199
|
-
const ticks = buildContinuousTicks(resolved, 5);
|
|
1200
|
-
// Linear scale should return normal D3 ticks — not just power-of-10 values
|
|
1201
|
-
expect(ticks.length).toBeGreaterThanOrEqual(4);
|
|
1202
|
-
// At least one tick should NOT be a power of 10 (e.g. 5000, 10000, 15000, 20000, 25000)
|
|
1203
|
-
const values = ticks.map((t) => t.value as number);
|
|
1204
|
-
const nonPowerOf10 = values.filter((v) => {
|
|
1205
|
-
if (v <= 0) return true;
|
|
1206
|
-
const exp = Math.log10(v);
|
|
1207
|
-
return Math.abs(exp - Math.round(exp)) >= 0.01;
|
|
1208
|
-
});
|
|
1209
|
-
expect(nonPowerOf10.length).toBeGreaterThan(0);
|
|
1210
|
-
});
|
|
1211
|
-
});
|
|
1212
|
-
|
|
1213
|
-
describe('axis tickPosition', () => {
|
|
1214
|
-
it('defaults y-axis to inline for line charts', () => {
|
|
1215
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1216
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1217
|
-
data: lineSpec.data,
|
|
1218
|
-
encoding: lineSpec.encoding,
|
|
1219
|
-
markType: 'line',
|
|
1220
|
-
});
|
|
1221
|
-
|
|
1222
|
-
expect(axes.y!.tickPosition).toBe('inline');
|
|
1223
|
-
expect(axes.y!.domainLine).toBe(false);
|
|
1224
|
-
expect(axes.y!.tickMarks).toBe(false);
|
|
1225
|
-
expect(axes.x!.tickPosition).toBe('gutter');
|
|
1226
|
-
});
|
|
1227
|
-
|
|
1228
|
-
it('defaults y-axis to inline for area charts', () => {
|
|
1229
|
-
const areaSpec: NormalizedChartSpec = {
|
|
1230
|
-
...lineSpec,
|
|
1231
|
-
markType: 'area',
|
|
1232
|
-
markDef: { type: 'area' },
|
|
1233
|
-
};
|
|
1234
|
-
const scales = computeScales(areaSpec, chartArea, areaSpec.data);
|
|
1235
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1236
|
-
data: areaSpec.data,
|
|
1237
|
-
encoding: areaSpec.encoding,
|
|
1238
|
-
markType: 'area',
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
expect(axes.y!.tickPosition).toBe('inline');
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
it('defaults y-axis to gutter when markType is unknown', () => {
|
|
1245
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1246
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1247
|
-
|
|
1248
|
-
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
it('respects explicit user override on y-axis', () => {
|
|
1252
|
-
const overrideSpec: NormalizedChartSpec = {
|
|
1253
|
-
...lineSpec,
|
|
1254
|
-
encoding: {
|
|
1255
|
-
...lineSpec.encoding,
|
|
1256
|
-
y: { ...lineSpec.encoding.y!, axis: { tickPosition: 'gutter' } },
|
|
1257
|
-
},
|
|
1258
|
-
};
|
|
1259
|
-
const scales = computeScales(overrideSpec, chartArea, overrideSpec.data);
|
|
1260
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1261
|
-
data: overrideSpec.data,
|
|
1262
|
-
encoding: overrideSpec.encoding,
|
|
1263
|
-
markType: 'line',
|
|
1264
|
-
});
|
|
1265
|
-
|
|
1266
|
-
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1267
|
-
});
|
|
1268
|
-
|
|
1269
|
-
it('right-side y-axis stays gutter even on line charts', () => {
|
|
1270
|
-
const dualSpec: NormalizedChartSpec = {
|
|
1271
|
-
...lineSpec,
|
|
1272
|
-
encoding: {
|
|
1273
|
-
...lineSpec.encoding,
|
|
1274
|
-
y: { ...lineSpec.encoding.y!, axis: { orient: 'right' } },
|
|
1275
|
-
},
|
|
1276
|
-
};
|
|
1277
|
-
const scales = computeScales(dualSpec, chartArea, dualSpec.data);
|
|
1278
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1279
|
-
data: dualSpec.data,
|
|
1280
|
-
encoding: dualSpec.encoding,
|
|
1281
|
-
markType: 'line',
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1285
|
-
});
|
|
1286
|
-
});
|
|
1287
|
-
|
|
1288
|
-
describe('axis extent and titlePosition', () => {
|
|
1289
|
-
it('emits x-axis extent for a basic line chart', () => {
|
|
1290
|
-
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1291
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1292
|
-
|
|
1293
|
-
expect(axes.x!.extent).toBeGreaterThan(0);
|
|
1294
|
-
expect(axes.x!.extent).toBeLessThan(200);
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
it('extent is larger when axis has a title', () => {
|
|
1298
|
-
const specWithTitle: NormalizedChartSpec = {
|
|
1299
|
-
...lineSpec,
|
|
1300
|
-
encoding: {
|
|
1301
|
-
...lineSpec.encoding,
|
|
1302
|
-
x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
|
|
1303
|
-
},
|
|
1304
|
-
};
|
|
1305
|
-
const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
|
|
1306
|
-
const axesWithTitle = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1307
|
-
|
|
1308
|
-
const scalesNoTitle = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1309
|
-
const axesNoTitle = computeAxes(scalesNoTitle, chartArea, fullStrategy, theme);
|
|
1310
|
-
|
|
1311
|
-
expect(axesWithTitle.x!.extent).toBeGreaterThan(axesNoTitle.x!.extent!);
|
|
1312
|
-
});
|
|
1313
|
-
|
|
1314
|
-
it('emits titlePosition for x-axis with title', () => {
|
|
1315
|
-
const specWithTitle: NormalizedChartSpec = {
|
|
1316
|
-
...lineSpec,
|
|
1317
|
-
encoding: {
|
|
1318
|
-
...lineSpec.encoding,
|
|
1319
|
-
x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
|
|
1320
|
-
},
|
|
1321
|
-
};
|
|
1322
|
-
const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
|
|
1323
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1324
|
-
|
|
1325
|
-
expect(axes.x!.titlePosition).toBeDefined();
|
|
1326
|
-
expect(axes.x!.titlePosition!.x).toBeCloseTo(chartArea.x + chartArea.width / 2, 0);
|
|
1327
|
-
expect(axes.x!.titlePosition!.y).toBeGreaterThan(chartArea.y + chartArea.height);
|
|
1328
|
-
});
|
|
1329
|
-
|
|
1330
|
-
it('emits titlePosition for y-axis with title', () => {
|
|
1331
|
-
const specWithYTitle: NormalizedChartSpec = {
|
|
1332
|
-
...lineSpec,
|
|
1333
|
-
encoding: {
|
|
1334
|
-
...lineSpec.encoding,
|
|
1335
|
-
y: { ...lineSpec.encoding.y, axis: { title: 'Value' } },
|
|
1336
|
-
},
|
|
1337
|
-
};
|
|
1338
|
-
const scales = computeScales(specWithYTitle, chartArea, specWithYTitle.data);
|
|
1339
|
-
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1340
|
-
data: specWithYTitle.data,
|
|
1341
|
-
encoding: specWithYTitle.encoding as Encoding,
|
|
1342
|
-
skipX: false,
|
|
1343
|
-
skipY: false,
|
|
1344
|
-
markType: 'line',
|
|
1345
|
-
totalWidth: 600,
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
expect(axes.y!.titlePosition).toBeDefined();
|
|
1349
|
-
expect(axes.y!.titlePosition!.x).toBeLessThan(chartArea.x);
|
|
1350
|
-
expect(axes.y!.titlePosition!.angle).toBe(-90);
|
|
1351
|
-
});
|
|
1352
|
-
});
|
|
1353
|
-
|
|
1354
|
-
describe('quantitative axis minimum tick floor', () => {
|
|
1355
|
-
// A tight domain like [73, 88] makes D3's `scale.ticks(2)` collapse to a
|
|
1356
|
-
// single nice value ([80]). Before the floor, this rendered as a lone tick
|
|
1357
|
-
// (a real iPhone blog chart showed only "80"). The floor must hold even at
|
|
1358
|
-
// very narrow widths where the target count drops to 2.
|
|
1359
|
-
const tightDomainSpec: NormalizedChartSpec = {
|
|
1360
|
-
markType: 'bar',
|
|
1361
|
-
markDef: { type: 'bar', orient: 'horizontal' },
|
|
1362
|
-
data: [
|
|
1363
|
-
{ district: 'A', score: 79 },
|
|
1364
|
-
{ district: 'B', score: 73 },
|
|
1365
|
-
{ district: 'C', score: 88 },
|
|
1366
|
-
],
|
|
1367
|
-
encoding: {
|
|
1368
|
-
y: { field: 'district', type: 'nominal' },
|
|
1369
|
-
x: {
|
|
1370
|
-
field: 'score',
|
|
1371
|
-
type: 'quantitative',
|
|
1372
|
-
axis: { format: '.0f', grid: true },
|
|
1373
|
-
scale: { domain: [73, 88], nice: false },
|
|
1374
|
-
},
|
|
1375
|
-
},
|
|
1376
|
-
chrome: {},
|
|
1377
|
-
annotations: [],
|
|
1378
|
-
responsive: true,
|
|
1379
|
-
theme: {},
|
|
1380
|
-
darkMode: 'off',
|
|
1381
|
-
labels: { density: 'all', format: '.0f' },
|
|
1382
|
-
};
|
|
1383
|
-
|
|
1384
|
-
it('renders >= 2 ticks at a very narrow (320px-ish) chart width', () => {
|
|
1385
|
-
// Narrow chart area forces the minimal density target count down to 2,
|
|
1386
|
-
// where D3 collapses [73, 88] to the single tick "80" without the floor.
|
|
1387
|
-
const narrowArea = { x: 90, y: 50, width: 90, height: 180 };
|
|
1388
|
-
const scales = computeScales(tightDomainSpec, narrowArea, tightDomainSpec.data);
|
|
1389
|
-
const axes = computeAxes(scales, narrowArea, minimalStrategy, theme, undefined, {
|
|
1390
|
-
data: tightDomainSpec.data,
|
|
1391
|
-
encoding: tightDomainSpec.encoding as Encoding,
|
|
1392
|
-
markType: 'bar',
|
|
1393
|
-
});
|
|
1394
|
-
|
|
1395
|
-
expect(axes.x).toBeDefined();
|
|
1396
|
-
expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
1397
|
-
});
|
|
1398
|
-
|
|
1399
|
-
it('holds the 2-tick floor across a sweep of narrow widths', () => {
|
|
1400
|
-
for (const width of [60, 90, 120, 220, 320]) {
|
|
1401
|
-
const area = { x: 90, y: 50, width, height: 180 };
|
|
1402
|
-
const scales = computeScales(tightDomainSpec, area, tightDomainSpec.data);
|
|
1403
|
-
const axes = computeAxes(scales, area, minimalStrategy, theme, undefined, {
|
|
1404
|
-
data: tightDomainSpec.data,
|
|
1405
|
-
encoding: tightDomainSpec.encoding as Encoding,
|
|
1406
|
-
markType: 'bar',
|
|
1407
|
-
});
|
|
1408
|
-
expect(
|
|
1409
|
-
axes.x!.ticks.length,
|
|
1410
|
-
`expected >= 2 ticks at width ${width}, got ${axes.x!.ticks.length}`,
|
|
1411
|
-
).toBeGreaterThanOrEqual(2);
|
|
1412
|
-
}
|
|
1413
|
-
});
|
|
1414
|
-
});
|
|
1415
|
-
|
|
1416
|
-
// ---------------------------------------------------------------------------
|
|
1417
|
-
// Compact temporal tick formats: pinned pre-change behavior
|
|
1418
|
-
// ---------------------------------------------------------------------------
|
|
1419
|
-
// These two tests pin the exact tick labels produced BEFORE the compact
|
|
1420
|
-
// temporal format rung lands in fitContinuousTicks. They must stay green
|
|
1421
|
-
// through that change:
|
|
1422
|
-
// - wide desktop axes have room for full-format labels, so compact never fires
|
|
1423
|
-
// - temporal y ticks are never compact (planner/renderer gutter agreement;
|
|
1424
|
-
// see plans/compact-temporal-ticks.md)
|
|
1425
|
-
|
|
1426
|
-
describe('compact temporal ticks: pinned pre-change output', () => {
|
|
1427
|
-
const monthlyData = Array.from({ length: 12 }, (_, i) => ({
|
|
1428
|
-
date: `2025-${String(i + 1).padStart(2, '0')}-01`,
|
|
1429
|
-
value: 100 + i * 10,
|
|
1430
|
-
}));
|
|
1431
|
-
|
|
1432
|
-
const monthlySpec: NormalizedChartSpec = {
|
|
1433
|
-
...lineSpec,
|
|
1434
|
-
data: monthlyData,
|
|
1435
|
-
};
|
|
1436
|
-
|
|
1437
|
-
it('desktop unchanged: monthly temporal x at ~900px keeps full-format labels', () => {
|
|
1438
|
-
const wideArea = { x: 50, y: 50, width: 900, height: 300 };
|
|
1439
|
-
const scales = computeScales(monthlySpec, wideArea, monthlySpec.data);
|
|
1440
|
-
const axes = computeAxes(scales, wideArea, fullStrategy, theme);
|
|
1441
|
-
|
|
1442
|
-
expect(axes.x!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
|
|
1443
|
-
});
|
|
1444
|
-
|
|
1445
|
-
it('temporal y-axis unaffected: labels at ~360px stay full-format', () => {
|
|
1446
|
-
const ySpec: NormalizedChartSpec = {
|
|
1447
|
-
...monthlySpec,
|
|
1448
|
-
markType: 'point',
|
|
1449
|
-
markDef: { type: 'point' },
|
|
1450
|
-
encoding: {
|
|
1451
|
-
x: { field: 'value', type: 'quantitative' },
|
|
1452
|
-
y: { field: 'date', type: 'temporal' },
|
|
1453
|
-
},
|
|
1454
|
-
};
|
|
1455
|
-
const narrowArea = { x: 40, y: 40, width: 360, height: 300 };
|
|
1456
|
-
const scales = computeScales(ySpec, narrowArea, ySpec.data);
|
|
1457
|
-
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
1458
|
-
|
|
1459
|
-
expect(axes.y!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
|
|
1460
|
-
});
|
|
1461
|
-
|
|
1462
|
-
it('temporal y-axis stays full-format even at starved heights (fallback path)', () => {
|
|
1463
|
-
// Regression guard for the fallback path: compact is x-axis-only (the
|
|
1464
|
-
// planner/renderer y pair must format identically), so even a height so
|
|
1465
|
-
// short that fitContinuousTicks falls through to thinning must keep the
|
|
1466
|
-
// year on every label — never a bare compact month like 'Oct'.
|
|
1467
|
-
const ySpec: NormalizedChartSpec = {
|
|
1468
|
-
...monthlySpec,
|
|
1469
|
-
markType: 'point',
|
|
1470
|
-
markDef: { type: 'point' },
|
|
1471
|
-
encoding: {
|
|
1472
|
-
x: { field: 'value', type: 'quantitative' },
|
|
1473
|
-
y: { field: 'date', type: 'temporal' },
|
|
1474
|
-
},
|
|
1475
|
-
};
|
|
1476
|
-
const starvedArea = { x: 40, y: 40, width: 360, height: 55 };
|
|
1477
|
-
const scales = computeScales(ySpec, starvedArea, ySpec.data);
|
|
1478
|
-
const axes = computeAxes(scales, starvedArea, fullStrategy, theme);
|
|
1479
|
-
|
|
1480
|
-
const labels = axes.y!.ticks.map((t) => t.label);
|
|
1481
|
-
expect(labels.length).toBeGreaterThanOrEqual(2);
|
|
1482
|
-
for (const label of labels) {
|
|
1483
|
-
expect(label).toMatch(/\d{4}/);
|
|
1484
|
-
}
|
|
1485
|
-
});
|
|
1486
|
-
});
|
|
1487
|
-
|
|
1488
|
-
// ---------------------------------------------------------------------------
|
|
1489
|
-
// Compact temporal tick formats: the fitting rung
|
|
1490
|
-
// ---------------------------------------------------------------------------
|
|
1491
|
-
// When full-format labels overlap on a temporal x-axis, fitContinuousTicks
|
|
1492
|
-
// tries the compact format table at the SAME count before dropping ticks.
|
|
1493
|
-
// Pre-change counts below were measured by running these exact inputs against
|
|
1494
|
-
// the unmodified engine (git stash) — see plans/compact-temporal-ticks.md.
|
|
1495
|
-
|
|
1496
|
-
describe('compact temporal ticks: fitting rung', () => {
|
|
1497
|
-
const monthlySpecOver = (
|
|
1498
|
-
months: number,
|
|
1499
|
-
axis?: Record<string, unknown>,
|
|
1500
|
-
): NormalizedChartSpec => ({
|
|
1501
|
-
...lineSpec,
|
|
1502
|
-
data: Array.from({ length: months }, (_, i) => ({
|
|
1503
|
-
date: `2025-${String(i + 1).padStart(2, '0')}-01`,
|
|
1504
|
-
value: 100 + i * 10,
|
|
1505
|
-
})),
|
|
1506
|
-
encoding: {
|
|
1507
|
-
x: { field: 'date', type: 'temporal', ...(axis ? { axis } : {}) },
|
|
1508
|
-
y: { field: 'value', type: 'quantitative' },
|
|
1509
|
-
},
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
|
-
const xLabelsAt = (spec: NormalizedChartSpec, width: number): string[] => {
|
|
1513
|
-
const area = { x: 40, y: 40, width, height: 300 };
|
|
1514
|
-
const scales = computeScales(spec, area, spec.data);
|
|
1515
|
-
const axes = computeAxes(scales, area, fullStrategy, theme);
|
|
1516
|
-
return axes.x!.ticks.map((t) => t.label);
|
|
1517
|
-
};
|
|
1518
|
-
|
|
1519
|
-
const COMPACT_MONTH = /^[A-Z][a-z]{2}$/; // 'Feb'
|
|
1520
|
-
const FULL_MONTH = /^[A-Z][a-z]{2} \d{4}$/; // 'Feb 2025'
|
|
1521
|
-
const YEAR_ONLY = /^\d{4}$/; // year-boundary tick, same in both tables
|
|
1522
|
-
|
|
1523
|
-
it('keeps more ticks than pre-change when full labels overlap (Jan-Dec 2025 at 160px)', () => {
|
|
1524
|
-
const labels = xLabelsAt(monthlySpecOver(12), 160);
|
|
1525
|
-
|
|
1526
|
-
// Pre-change output at this width: ['2025', 'Oct 2025'] — the overlap
|
|
1527
|
-
// forced a count step-down to 2. Compact keeps all 4 quarterly ticks.
|
|
1528
|
-
expect(labels).toEqual(['2025', 'Apr', 'Jul', 'Oct']);
|
|
1529
|
-
expect(labels.length).toBeGreaterThan(2);
|
|
1530
|
-
|
|
1531
|
-
// No label carries a year except year-boundary ticks.
|
|
1532
|
-
for (const label of labels) {
|
|
1533
|
-
if (/\d{4}/.test(label)) expect(label).toMatch(YEAR_ONLY);
|
|
1534
|
-
}
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
it('renders bare compact months at monthly tick granularity (Jan-Apr 2025 at 160px)', () => {
|
|
1538
|
-
// End the domain mid-April: a domain ending exactly on Apr 1 includes the
|
|
1539
|
-
// Apr tick only when the runner's timezone is UTC (local time scales place
|
|
1540
|
-
// month boundaries at local midnight, so negative offsets push Apr 1 past
|
|
1541
|
-
// the domain end).
|
|
1542
|
-
const base = monthlySpecOver(4);
|
|
1543
|
-
const spec = { ...base, data: [...base.data, { date: '2025-04-15', value: 145 }] };
|
|
1544
|
-
const labels = xLabelsAt(spec, 160);
|
|
1545
|
-
|
|
1546
|
-
// Pre-change output at this width: ['2025', 'Mar 2025'] (2 ticks).
|
|
1547
|
-
expect(labels).toEqual(['2025', 'Feb', 'Mar', 'Apr']);
|
|
1548
|
-
expect(labels).toContain('Feb');
|
|
1549
|
-
});
|
|
1550
|
-
|
|
1551
|
-
it('refit stays internally consistent when D3 overshoots the requested count', () => {
|
|
1552
|
-
// Jan-May 2025: D3 returns 5 monthly ticks for a request of 3 (> the 1.5x
|
|
1553
|
-
// overshoot tolerance), forcing the refit loop. Whatever count/format the
|
|
1554
|
-
// loop lands on, the returned set must be ONE format family — never a mix
|
|
1555
|
-
// of 'Feb' and 'Mar 2025'.
|
|
1556
|
-
const spec = monthlySpecOver(5);
|
|
1557
|
-
for (const width of [100, 140, 180, 240]) {
|
|
1558
|
-
const labels = xLabelsAt(spec, width);
|
|
1559
|
-
const monthLabels = labels.filter((l) => !YEAR_ONLY.test(l));
|
|
1560
|
-
expect(monthLabels.length, `width ${width}`).toBeGreaterThan(0);
|
|
1561
|
-
const allCompact = monthLabels.every((l) => COMPACT_MONTH.test(l));
|
|
1562
|
-
const allFull = monthLabels.every((l) => FULL_MONTH.test(l));
|
|
1563
|
-
expect(
|
|
1564
|
-
allCompact || allFull,
|
|
1565
|
-
`mixed format families at width ${width}: ${JSON.stringify(labels)}`,
|
|
1566
|
-
).toBe(true);
|
|
1567
|
-
}
|
|
1568
|
-
});
|
|
1569
|
-
|
|
1570
|
-
it('explicit axis.format wins over compact at narrow width', () => {
|
|
1571
|
-
const labels = xLabelsAt(monthlySpecOver(12, { format: '%Y-%m' }), 160);
|
|
1572
|
-
|
|
1573
|
-
expect(labels.length).toBeGreaterThan(0);
|
|
1574
|
-
for (const label of labels) {
|
|
1575
|
-
expect(label).toMatch(/^\d{4}-\d{2}$/);
|
|
1576
|
-
}
|
|
1577
|
-
expect(labels).toEqual(['2025-01', '2025-10']);
|
|
1578
|
-
});
|
|
1579
|
-
});
|