@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,1672 +0,0 @@
|
|
|
1
|
-
import type { Annotation, LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
2
|
-
import type { ScaleBand, ScalePoint } from 'd3-scale';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
5
|
-
import { computeScales } from '../../layout/scales';
|
|
6
|
-
import { computeAnnotations } from '../compute';
|
|
7
|
-
import { resolvePosition } from '../position';
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Fixtures
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
|
|
14
|
-
|
|
15
|
-
const fullStrategy: LayoutStrategy = {
|
|
16
|
-
labelMode: 'all',
|
|
17
|
-
legendPosition: 'right',
|
|
18
|
-
annotationPosition: 'inline',
|
|
19
|
-
axisLabelDensity: 'full',
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const compactStrategy: LayoutStrategy = {
|
|
23
|
-
labelMode: 'none',
|
|
24
|
-
legendPosition: 'top',
|
|
25
|
-
annotationPosition: 'tooltip-only',
|
|
26
|
-
axisLabelDensity: 'minimal',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function makeSpec(annotations: Annotation[]): NormalizedChartSpec {
|
|
30
|
-
return {
|
|
31
|
-
markType: 'line',
|
|
32
|
-
markDef: { type: 'line' },
|
|
33
|
-
data: [
|
|
34
|
-
{ date: '2019-01-01', value: 10 },
|
|
35
|
-
{ date: '2020-01-01', value: 20 },
|
|
36
|
-
{ date: '2021-01-01', value: 30 },
|
|
37
|
-
{ date: '2022-01-01', value: 40 },
|
|
38
|
-
],
|
|
39
|
-
encoding: {
|
|
40
|
-
x: { field: 'date', type: 'temporal' },
|
|
41
|
-
y: { field: 'value', type: 'quantitative' },
|
|
42
|
-
},
|
|
43
|
-
chrome: {},
|
|
44
|
-
annotations,
|
|
45
|
-
responsive: true,
|
|
46
|
-
theme: {},
|
|
47
|
-
darkMode: 'off',
|
|
48
|
-
labels: { density: 'auto', format: '' },
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ---------------------------------------------------------------------------
|
|
53
|
-
// Tests
|
|
54
|
-
// ---------------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
describe('computeAnnotations', () => {
|
|
57
|
-
describe('text annotations', () => {
|
|
58
|
-
it('resolves text annotation to pixel position', () => {
|
|
59
|
-
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Important point' }]);
|
|
60
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
61
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
62
|
-
|
|
63
|
-
expect(annotations).toHaveLength(1);
|
|
64
|
-
expect(annotations[0].type).toBe('text');
|
|
65
|
-
expect(annotations[0].label).toBeDefined();
|
|
66
|
-
expect(annotations[0].label!.text).toBe('Important point');
|
|
67
|
-
expect(annotations[0].label!.visible).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('text annotation label has a connector to the data point', () => {
|
|
71
|
-
// The default 8px anchor offset produces a sub-MIN_CONNECTOR_LENGTH stub,
|
|
72
|
-
// which is suppressed. Push the label out so a real leader survives.
|
|
73
|
-
const spec = makeSpec([
|
|
74
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note', offset: { dx: 0, dy: -60 } },
|
|
75
|
-
]);
|
|
76
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
77
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
78
|
-
|
|
79
|
-
expect(annotations[0].label!.connector).toBeDefined();
|
|
80
|
-
expect(annotations[0].label!.connector!.from).toBeDefined();
|
|
81
|
-
expect(annotations[0].label!.connector!.to).toBeDefined();
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('returns null for text annotation with invalid data value', () => {
|
|
85
|
-
const spec = makeSpec([{ type: 'text', x: 'not-a-date', y: 999, text: 'Missing' }]);
|
|
86
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
87
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
88
|
-
|
|
89
|
-
// Invalid date should result in null annotation (filtered out)
|
|
90
|
-
expect(annotations).toHaveLength(0);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('drop-line connector', () => {
|
|
94
|
-
it('produces a vertical line through the data point with end-anchored text on the left', () => {
|
|
95
|
-
const spec = makeSpec([
|
|
96
|
-
{
|
|
97
|
-
type: 'text',
|
|
98
|
-
x: '2020-01-01',
|
|
99
|
-
y: 20,
|
|
100
|
-
text: 'Peak',
|
|
101
|
-
connector: 'drop-line',
|
|
102
|
-
anchor: 'left',
|
|
103
|
-
},
|
|
104
|
-
]);
|
|
105
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
106
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
107
|
-
|
|
108
|
-
const ann = annotations[0];
|
|
109
|
-
const c = ann.label?.connector;
|
|
110
|
-
expect(c).toBeDefined();
|
|
111
|
-
expect(c?.style).toBe('drop-line');
|
|
112
|
-
// Vertical line: from.x === to.x and equals the data point's x
|
|
113
|
-
expect(c?.from.x).toBe(c?.to.x);
|
|
114
|
-
const px = resolvePosition('2020-01-01', scales.x);
|
|
115
|
-
expect(c?.from.x).toBe(px);
|
|
116
|
-
// Label sits to the left of the data point with end anchor
|
|
117
|
-
expect(ann.label?.style.textAnchor).toBe('end');
|
|
118
|
-
expect(ann.label?.x).toBeLessThan(px ?? Infinity);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('flips a left-anchored label to the right when there is no room on the left', () => {
|
|
122
|
-
// Place a long label very near the chart-area left edge — left side is too tight
|
|
123
|
-
const spec = makeSpec([
|
|
124
|
-
{
|
|
125
|
-
type: 'text',
|
|
126
|
-
x: '2019-01-01',
|
|
127
|
-
y: 10,
|
|
128
|
-
text: 'A long annotation that needs lots of horizontal room',
|
|
129
|
-
connector: 'drop-line',
|
|
130
|
-
anchor: 'left',
|
|
131
|
-
},
|
|
132
|
-
]);
|
|
133
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
134
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
135
|
-
|
|
136
|
-
const ann = annotations[0];
|
|
137
|
-
const px = resolvePosition('2019-01-01', scales.x);
|
|
138
|
-
expect(ann.label?.style.textAnchor).toBe('start');
|
|
139
|
-
expect(ann.label?.x).toBeGreaterThan(px ?? -Infinity);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('flips a right-anchored label to the left when there is no room on the right', () => {
|
|
143
|
-
const spec = makeSpec([
|
|
144
|
-
{
|
|
145
|
-
type: 'text',
|
|
146
|
-
x: '2022-01-01',
|
|
147
|
-
y: 40,
|
|
148
|
-
text: 'A long annotation that needs lots of horizontal room',
|
|
149
|
-
connector: 'drop-line',
|
|
150
|
-
anchor: 'right',
|
|
151
|
-
},
|
|
152
|
-
]);
|
|
153
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
154
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
155
|
-
|
|
156
|
-
const ann = annotations[0];
|
|
157
|
-
const px = resolvePosition('2022-01-01', scales.x);
|
|
158
|
-
expect(ann.label?.style.textAnchor).toBe('end');
|
|
159
|
-
expect(ann.label?.x).toBeLessThan(px ?? Infinity);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('picks the wider side when neither side fits cleanly', () => {
|
|
163
|
-
// Tiny chart area + long label + data point near right edge means
|
|
164
|
-
// neither side can fit the full label. Left side has more room
|
|
165
|
-
// (x=50 ... px), so the auto-flip should land left.
|
|
166
|
-
const tinyArea: typeof chartArea = { x: 50, y: 20, width: 120, height: 200 };
|
|
167
|
-
const spec = makeSpec([
|
|
168
|
-
{
|
|
169
|
-
type: 'text',
|
|
170
|
-
x: '2022-01-01',
|
|
171
|
-
y: 40,
|
|
172
|
-
text: 'A genuinely long annotation label that exceeds both sides',
|
|
173
|
-
connector: 'drop-line',
|
|
174
|
-
anchor: 'right',
|
|
175
|
-
},
|
|
176
|
-
]);
|
|
177
|
-
const scales = computeScales(spec, tinyArea, spec.data);
|
|
178
|
-
const annotations = computeAnnotations(spec, scales, tinyArea, fullStrategy);
|
|
179
|
-
|
|
180
|
-
// anchor=right but the right side is even narrower than left, so flip
|
|
181
|
-
expect(annotations[0].label?.style.textAnchor).toBe('end');
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('preserves the resolved text-anchor on multi-line drop-line labels', () => {
|
|
185
|
-
const spec = makeSpec([
|
|
186
|
-
{
|
|
187
|
-
type: 'text',
|
|
188
|
-
x: '2020-01-01',
|
|
189
|
-
y: 20,
|
|
190
|
-
text: 'Line one\nLine two',
|
|
191
|
-
connector: 'drop-line',
|
|
192
|
-
anchor: 'left',
|
|
193
|
-
},
|
|
194
|
-
]);
|
|
195
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
196
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
197
|
-
|
|
198
|
-
// Engine output retains end anchor; the renderer relies on this to
|
|
199
|
-
// not override it back to middle.
|
|
200
|
-
expect(annotations[0].label?.style.textAnchor).toBe('end');
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe('range annotations', () => {
|
|
206
|
-
it('resolves x-range annotation to a rect', () => {
|
|
207
|
-
const spec = makeSpec([
|
|
208
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
|
|
209
|
-
]);
|
|
210
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
211
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
212
|
-
|
|
213
|
-
expect(annotations).toHaveLength(1);
|
|
214
|
-
expect(annotations[0].type).toBe('range');
|
|
215
|
-
expect(annotations[0].rect).toBeDefined();
|
|
216
|
-
expect(annotations[0].rect!.width).toBeGreaterThan(0);
|
|
217
|
-
// Height should span the full chart area for x-only range
|
|
218
|
-
expect(annotations[0].rect!.height).toBe(chartArea.height);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('range annotation has a label', () => {
|
|
222
|
-
const spec = makeSpec([
|
|
223
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Recession' },
|
|
224
|
-
]);
|
|
225
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
226
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
227
|
-
|
|
228
|
-
expect(annotations[0].label).toBeDefined();
|
|
229
|
-
expect(annotations[0].label!.text).toBe('Recession');
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it('range label defaults to 11px / weight 500', () => {
|
|
233
|
-
const spec = makeSpec([
|
|
234
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
|
|
235
|
-
]);
|
|
236
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
237
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
238
|
-
|
|
239
|
-
expect(annotations[0].label!.style.fontSize).toBe(11);
|
|
240
|
-
expect(annotations[0].label!.style.fontWeight).toBe(500);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('range label honors fontSize and fontWeight overrides', () => {
|
|
244
|
-
const spec = makeSpec([
|
|
245
|
-
{
|
|
246
|
-
type: 'range',
|
|
247
|
-
x1: '2020-01-01',
|
|
248
|
-
x2: '2021-01-01',
|
|
249
|
-
label: 'Period',
|
|
250
|
-
fontSize: 19,
|
|
251
|
-
fontWeight: 600,
|
|
252
|
-
},
|
|
253
|
-
]);
|
|
254
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
255
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
256
|
-
|
|
257
|
-
expect(annotations[0].label!.style.fontSize).toBe(19);
|
|
258
|
-
expect(annotations[0].label!.style.fontWeight).toBe(600);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('range has fill and opacity', () => {
|
|
262
|
-
const spec = makeSpec([
|
|
263
|
-
{
|
|
264
|
-
type: 'range',
|
|
265
|
-
x1: '2020-01-01',
|
|
266
|
-
x2: '2021-01-01',
|
|
267
|
-
fill: '#ff0000',
|
|
268
|
-
opacity: 0.2,
|
|
269
|
-
},
|
|
270
|
-
]);
|
|
271
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
272
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
273
|
-
|
|
274
|
-
expect(annotations[0].fill).toBe('#ff0000');
|
|
275
|
-
expect(annotations[0].opacity).toBe(0.2);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('uses default fill and opacity when not specified', () => {
|
|
279
|
-
const spec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
|
|
280
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
281
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
282
|
-
|
|
283
|
-
expect(annotations[0].fill).toBeDefined();
|
|
284
|
-
expect(annotations[0].opacity).toBeDefined();
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it('interpolates range position for values between ordinal data points', () => {
|
|
288
|
-
const ordinalSpec: NormalizedChartSpec = {
|
|
289
|
-
markType: 'line',
|
|
290
|
-
markDef: { type: 'line' },
|
|
291
|
-
data: [
|
|
292
|
-
{ year: '2005', value: 10 },
|
|
293
|
-
{ year: '2007', value: 20 },
|
|
294
|
-
{ year: '2009', value: 30 },
|
|
295
|
-
{ year: '2012', value: 40 },
|
|
296
|
-
],
|
|
297
|
-
encoding: {
|
|
298
|
-
x: { field: 'year', type: 'ordinal' },
|
|
299
|
-
y: { field: 'value', type: 'quantitative' },
|
|
300
|
-
},
|
|
301
|
-
chrome: {},
|
|
302
|
-
annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Interpolated' }],
|
|
303
|
-
responsive: true,
|
|
304
|
-
theme: {},
|
|
305
|
-
darkMode: 'off',
|
|
306
|
-
labels: { density: 'auto', format: '' },
|
|
307
|
-
};
|
|
308
|
-
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
309
|
-
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
310
|
-
|
|
311
|
-
expect(annotations).toHaveLength(1);
|
|
312
|
-
expect(annotations[0].rect).toBeDefined();
|
|
313
|
-
expect(annotations[0].rect!.width).toBeGreaterThan(0);
|
|
314
|
-
|
|
315
|
-
// Also verify the interpolated range sits between the known data points
|
|
316
|
-
const rangeWithKnownPoints: NormalizedChartSpec = {
|
|
317
|
-
...ordinalSpec,
|
|
318
|
-
annotations: [{ type: 'range', x1: '2007', x2: '2012', label: 'Known' }],
|
|
319
|
-
};
|
|
320
|
-
const knownAnnotations = computeAnnotations(
|
|
321
|
-
rangeWithKnownPoints,
|
|
322
|
-
scales,
|
|
323
|
-
chartArea,
|
|
324
|
-
fullStrategy,
|
|
325
|
-
);
|
|
326
|
-
// The interpolated range (2008-2010) should be narrower than and inside (2007-2012)
|
|
327
|
-
expect(annotations[0].rect!.width).toBeLessThan(knownAnnotations[0].rect!.width);
|
|
328
|
-
expect(annotations[0].rect!.x).toBeGreaterThan(knownAnnotations[0].rect!.x);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('clamps interpolation for values outside the ordinal domain range', () => {
|
|
332
|
-
const ordinalSpec: NormalizedChartSpec = {
|
|
333
|
-
markType: 'line',
|
|
334
|
-
markDef: { type: 'line' },
|
|
335
|
-
data: [
|
|
336
|
-
{ year: '2005', value: 10 },
|
|
337
|
-
{ year: '2007', value: 20 },
|
|
338
|
-
{ year: '2009', value: 30 },
|
|
339
|
-
],
|
|
340
|
-
encoding: {
|
|
341
|
-
x: { field: 'year', type: 'ordinal' },
|
|
342
|
-
y: { field: 'value', type: 'quantitative' },
|
|
343
|
-
},
|
|
344
|
-
chrome: {},
|
|
345
|
-
annotations: [{ type: 'range', x1: '2003', x2: '2011', label: 'Outside range' }],
|
|
346
|
-
responsive: true,
|
|
347
|
-
theme: {},
|
|
348
|
-
darkMode: 'off',
|
|
349
|
-
labels: { density: 'auto', format: '' },
|
|
350
|
-
};
|
|
351
|
-
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
352
|
-
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
353
|
-
|
|
354
|
-
expect(annotations).toHaveLength(1);
|
|
355
|
-
expect(annotations[0].rect).toBeDefined();
|
|
356
|
-
expect(annotations[0].rect!.width).toBeGreaterThan(0);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it('returns null for non-numeric ordinal domain values', () => {
|
|
360
|
-
const catSpec: NormalizedChartSpec = {
|
|
361
|
-
markType: 'bar',
|
|
362
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
363
|
-
data: [
|
|
364
|
-
{ category: 'Jan', value: 10 },
|
|
365
|
-
{ category: 'Feb', value: 20 },
|
|
366
|
-
{ category: 'Mar', value: 30 },
|
|
367
|
-
],
|
|
368
|
-
encoding: {
|
|
369
|
-
x: { field: 'category', type: 'ordinal' },
|
|
370
|
-
y: { field: 'value', type: 'quantitative' },
|
|
371
|
-
},
|
|
372
|
-
chrome: {},
|
|
373
|
-
annotations: [{ type: 'range', x1: 'Jan-15', x2: 'Feb-15', label: 'Non-numeric' }],
|
|
374
|
-
responsive: true,
|
|
375
|
-
theme: {},
|
|
376
|
-
darkMode: 'off',
|
|
377
|
-
labels: { density: 'auto', format: '' },
|
|
378
|
-
};
|
|
379
|
-
const scales = computeScales(catSpec, chartArea, catSpec.data);
|
|
380
|
-
const annotations = computeAnnotations(catSpec, scales, chartArea, fullStrategy);
|
|
381
|
-
|
|
382
|
-
// Non-numeric domain can't interpolate, annotation is dropped
|
|
383
|
-
expect(annotations).toHaveLength(0);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
it('two adjacent point-scale ranges have a non-zero pixel gap between them', () => {
|
|
387
|
-
// Regression: ranges ending/starting at point-scale centers would share the same
|
|
388
|
-
// pixel boundary, visually merging. resolvePositionEdge now extends each range
|
|
389
|
-
// by half a step so adjacent ranges are truly separated.
|
|
390
|
-
const ordinalSpec: NormalizedChartSpec = {
|
|
391
|
-
markType: 'line',
|
|
392
|
-
markDef: { type: 'line' },
|
|
393
|
-
data: [
|
|
394
|
-
{ year: '2006', value: 10 },
|
|
395
|
-
{ year: '2008', value: 20 },
|
|
396
|
-
{ year: '2010', value: 30 },
|
|
397
|
-
{ year: '2012', value: 40 },
|
|
398
|
-
{ year: '2020', value: 50 },
|
|
399
|
-
{ year: '2022', value: 60 },
|
|
400
|
-
],
|
|
401
|
-
encoding: {
|
|
402
|
-
x: { field: 'year', type: 'ordinal' },
|
|
403
|
-
y: { field: 'value', type: 'quantitative' },
|
|
404
|
-
},
|
|
405
|
-
chrome: {},
|
|
406
|
-
annotations: [
|
|
407
|
-
{ type: 'range', x1: '2008', x2: '2010', label: 'First range' },
|
|
408
|
-
{ type: 'range', x1: '2020', x2: '2022', label: 'Second range' },
|
|
409
|
-
],
|
|
410
|
-
responsive: true,
|
|
411
|
-
theme: {},
|
|
412
|
-
darkMode: 'off',
|
|
413
|
-
labels: { density: 'auto', format: '' },
|
|
414
|
-
};
|
|
415
|
-
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
416
|
-
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
417
|
-
|
|
418
|
-
expect(annotations).toHaveLength(2);
|
|
419
|
-
const rect1 = annotations[0].rect!;
|
|
420
|
-
const rect2 = annotations[1].rect!;
|
|
421
|
-
|
|
422
|
-
// First range must end before second range starts (non-zero gap)
|
|
423
|
-
expect(rect1.x + rect1.width).toBeLessThan(rect2.x);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it('single range on point-scale ordinal x covers at least a full step', () => {
|
|
427
|
-
// A range spanning a single domain value should be at least step-wide,
|
|
428
|
-
// since resolvePositionEdge extends by half a step on each side.
|
|
429
|
-
const domainValues = ['2006', '2008', '2010', '2012'];
|
|
430
|
-
const ordinalSpec: NormalizedChartSpec = {
|
|
431
|
-
markType: 'line',
|
|
432
|
-
markDef: { type: 'line' },
|
|
433
|
-
data: domainValues.map((year, i) => ({ year, value: i * 10 })),
|
|
434
|
-
encoding: {
|
|
435
|
-
x: { field: 'year', type: 'ordinal' },
|
|
436
|
-
y: { field: 'value', type: 'quantitative' },
|
|
437
|
-
},
|
|
438
|
-
chrome: {},
|
|
439
|
-
annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Single step' }],
|
|
440
|
-
responsive: true,
|
|
441
|
-
theme: {},
|
|
442
|
-
darkMode: 'off',
|
|
443
|
-
labels: { density: 'auto', format: '' },
|
|
444
|
-
};
|
|
445
|
-
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
446
|
-
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
447
|
-
|
|
448
|
-
expect(annotations).toHaveLength(1);
|
|
449
|
-
const rect = annotations[0].rect!;
|
|
450
|
-
|
|
451
|
-
// step = chartArea.width / (numPoints - 1) roughly. With 4 points and default 0.5 padding,
|
|
452
|
-
// the step on a point scale = width / (n - 1 + 2*padding) -- but the key property is that
|
|
453
|
-
// the range width should be at least as wide as the distance between two domain centers.
|
|
454
|
-
const xScale = scales.x!.scale as ScalePoint<string>;
|
|
455
|
-
const step = xScale.step();
|
|
456
|
-
expect(rect.width).toBeGreaterThanOrEqual(step);
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
it('band-scale (bar chart) range covers full bands, not band centers', () => {
|
|
460
|
-
// On a bar chart the x scale is a band scale. resolvePositionEdge extends from the
|
|
461
|
-
// center (what resolvePosition returns) to the band edge.
|
|
462
|
-
const barSpec: NormalizedChartSpec = {
|
|
463
|
-
markType: 'bar',
|
|
464
|
-
markDef: { type: 'bar', orient: 'vertical' },
|
|
465
|
-
data: [
|
|
466
|
-
{ year: '2008', value: 10 },
|
|
467
|
-
{ year: '2010', value: 20 },
|
|
468
|
-
{ year: '2012', value: 30 },
|
|
469
|
-
{ year: '2014', value: 40 },
|
|
470
|
-
],
|
|
471
|
-
encoding: {
|
|
472
|
-
x: { field: 'year', type: 'ordinal' },
|
|
473
|
-
y: { field: 'value', type: 'quantitative' },
|
|
474
|
-
},
|
|
475
|
-
chrome: {},
|
|
476
|
-
annotations: [{ type: 'range', x1: '2010', x2: '2012', label: 'Band range' }],
|
|
477
|
-
responsive: true,
|
|
478
|
-
theme: {},
|
|
479
|
-
darkMode: 'off',
|
|
480
|
-
labels: { density: 'auto', format: '' },
|
|
481
|
-
};
|
|
482
|
-
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
483
|
-
const annotations = computeAnnotations(barSpec, scales, chartArea, fullStrategy);
|
|
484
|
-
|
|
485
|
-
expect(annotations).toHaveLength(1);
|
|
486
|
-
const rect = annotations[0].rect!;
|
|
487
|
-
|
|
488
|
-
const bandScale = scales.x!.scale as ScaleBand<string>;
|
|
489
|
-
const bandwidth = bandScale.bandwidth();
|
|
490
|
-
const x1BandStart = bandScale('2010')!;
|
|
491
|
-
const x2BandStart = bandScale('2012')!;
|
|
492
|
-
|
|
493
|
-
// Left edge should be at the start of the 2010 band (not the center)
|
|
494
|
-
expect(rect.x).toBeCloseTo(x1BandStart, 1);
|
|
495
|
-
// Right edge should be at the end of the 2012 band
|
|
496
|
-
expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it('extendToEdges:false anchors a point-scale range at data point centers', () => {
|
|
500
|
-
// With extendToEdges:false the band starts/ends exactly at the first/last
|
|
501
|
-
// data point centers instead of extending half a step to the plot edge,
|
|
502
|
-
// so the range is inset from the axis.
|
|
503
|
-
const domainValues = ['2006', '2008', '2010', '2012'];
|
|
504
|
-
const ordinalSpec: NormalizedChartSpec = {
|
|
505
|
-
markType: 'line',
|
|
506
|
-
markDef: { type: 'line' },
|
|
507
|
-
data: domainValues.map((year, i) => ({ year, value: i * 10 })),
|
|
508
|
-
encoding: {
|
|
509
|
-
x: { field: 'year', type: 'ordinal' },
|
|
510
|
-
y: { field: 'value', type: 'quantitative' },
|
|
511
|
-
},
|
|
512
|
-
chrome: {},
|
|
513
|
-
annotations: [{ type: 'range', x1: '2006', x2: '2012', extendToEdges: false }],
|
|
514
|
-
responsive: true,
|
|
515
|
-
theme: {},
|
|
516
|
-
darkMode: 'off',
|
|
517
|
-
labels: { density: 'auto', format: '' },
|
|
518
|
-
};
|
|
519
|
-
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
520
|
-
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
521
|
-
|
|
522
|
-
expect(annotations).toHaveLength(1);
|
|
523
|
-
const rect = annotations[0].rect!;
|
|
524
|
-
|
|
525
|
-
const xScale = scales.x!.scale as ScalePoint<string>;
|
|
526
|
-
// Left/right edges land at the point centers, not extended past them.
|
|
527
|
-
expect(rect.x).toBeCloseTo(xScale('2006')!, 1);
|
|
528
|
-
expect(rect.x + rect.width).toBeCloseTo(xScale('2012')!, 1);
|
|
529
|
-
// And the band is strictly inside the plot (inset from both edges).
|
|
530
|
-
expect(rect.x).toBeGreaterThan(chartArea.x);
|
|
531
|
-
expect(rect.x + rect.width).toBeLessThan(chartArea.x + chartArea.width);
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
it('linear-scale range is unaffected by edge extension', () => {
|
|
535
|
-
// For linear scales, resolvePositionEdge is identical to resolvePosition.
|
|
536
|
-
// This ensures the fix doesn't introduce any drift on continuous axes.
|
|
537
|
-
const linearSpec = makeSpec([{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' }]);
|
|
538
|
-
const scales = computeScales(linearSpec, chartArea, linearSpec.data);
|
|
539
|
-
const annotations = computeAnnotations(linearSpec, scales, chartArea, fullStrategy);
|
|
540
|
-
|
|
541
|
-
expect(annotations).toHaveLength(1);
|
|
542
|
-
const rect = annotations[0].rect!;
|
|
543
|
-
|
|
544
|
-
// The x positions should exactly match what resolvePosition would return
|
|
545
|
-
const x1Expected = resolvePosition('2020-01-01', scales.x)!;
|
|
546
|
-
const x2Expected = resolvePosition('2021-01-01', scales.x)!;
|
|
547
|
-
|
|
548
|
-
expect(rect.x).toBeCloseTo(Math.min(x1Expected, x2Expected), 1);
|
|
549
|
-
expect(rect.x + rect.width).toBeCloseTo(Math.max(x1Expected, x2Expected), 1);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it('y1/y2 range on ordinal point-scale y-axis has a non-zero pixel gap between two annotations', () => {
|
|
553
|
-
// Horizontal band: y-axis is ordinal (point scale), x-axis is quantitative.
|
|
554
|
-
// Two y-range annotations with a gap should produce two distinct rects.
|
|
555
|
-
const ordinalYSpec: NormalizedChartSpec = {
|
|
556
|
-
markType: 'line',
|
|
557
|
-
markDef: { type: 'line' },
|
|
558
|
-
data: [
|
|
559
|
-
{ year: '2006', value: 10 },
|
|
560
|
-
{ year: '2008', value: 20 },
|
|
561
|
-
{ year: '2010', value: 30 },
|
|
562
|
-
{ year: '2015', value: 40 },
|
|
563
|
-
{ year: '2020', value: 50 },
|
|
564
|
-
{ year: '2022', value: 60 },
|
|
565
|
-
],
|
|
566
|
-
encoding: {
|
|
567
|
-
x: { field: 'value', type: 'quantitative' },
|
|
568
|
-
y: { field: 'year', type: 'ordinal' },
|
|
569
|
-
},
|
|
570
|
-
chrome: {},
|
|
571
|
-
annotations: [
|
|
572
|
-
{ type: 'range', y1: '2006', y2: '2008' },
|
|
573
|
-
{ type: 'range', y1: '2020', y2: '2022' },
|
|
574
|
-
],
|
|
575
|
-
responsive: true,
|
|
576
|
-
theme: {},
|
|
577
|
-
darkMode: 'off',
|
|
578
|
-
labels: { density: 'auto', format: '' },
|
|
579
|
-
};
|
|
580
|
-
const scales = computeScales(ordinalYSpec, chartArea, ordinalYSpec.data);
|
|
581
|
-
const annotations = computeAnnotations(ordinalYSpec, scales, chartArea, fullStrategy);
|
|
582
|
-
|
|
583
|
-
expect(annotations).toHaveLength(2);
|
|
584
|
-
const rects = annotations.map((a) => a.rect!);
|
|
585
|
-
|
|
586
|
-
// In SVG, y increases downward. Sort by y so rect[0] is the top one.
|
|
587
|
-
rects.sort((a, b) => a.y - b.y);
|
|
588
|
-
|
|
589
|
-
// There must be a non-zero pixel gap between the bottom of rect[0] and top of rect[1]
|
|
590
|
-
const bottomOfFirst = rects[0].y + rects[0].height;
|
|
591
|
-
expect(bottomOfFirst).toBeLessThan(rects[1].y);
|
|
592
|
-
});
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
describe('reference line annotations', () => {
|
|
596
|
-
it('resolves horizontal refline at y value', () => {
|
|
597
|
-
const spec = makeSpec([{ type: 'refline', y: 20, label: 'Target' }]);
|
|
598
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
599
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
600
|
-
|
|
601
|
-
expect(annotations).toHaveLength(1);
|
|
602
|
-
expect(annotations[0].type).toBe('refline');
|
|
603
|
-
expect(annotations[0].line).toBeDefined();
|
|
604
|
-
// Horizontal line: start.x = chartArea.x, end.x = chartArea.x + chartArea.width
|
|
605
|
-
expect(annotations[0].line!.start.x).toBe(chartArea.x);
|
|
606
|
-
expect(annotations[0].line!.end.x).toBe(chartArea.x + chartArea.width);
|
|
607
|
-
// Both y values should be the same (horizontal line)
|
|
608
|
-
expect(annotations[0].line!.start.y).toBe(annotations[0].line!.end.y);
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
it('resolves vertical refline at x value', () => {
|
|
612
|
-
const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]);
|
|
613
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
614
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
615
|
-
|
|
616
|
-
expect(annotations).toHaveLength(1);
|
|
617
|
-
expect(annotations[0].line).toBeDefined();
|
|
618
|
-
// Vertical line: start.y = chartArea.y, end.y = chartArea.y + chartArea.height
|
|
619
|
-
expect(annotations[0].line!.start.y).toBe(chartArea.y);
|
|
620
|
-
expect(annotations[0].line!.end.y).toBe(chartArea.y + chartArea.height);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
it('refline has dashed style by default', () => {
|
|
624
|
-
const spec = makeSpec([{ type: 'refline', y: 20 }]);
|
|
625
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
626
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
627
|
-
|
|
628
|
-
expect(annotations[0].strokeDasharray).toBeDefined();
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it('refline label defaults to fontSize 11', () => {
|
|
632
|
-
const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]);
|
|
633
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
634
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
635
|
-
|
|
636
|
-
expect(annotations[0].label!.style.fontSize).toBe(11);
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it('refline label honors fontSize and fontWeight overrides', () => {
|
|
640
|
-
const spec = makeSpec([
|
|
641
|
-
{ type: 'refline', x: '2020-06-01', label: 'Event', fontSize: 24, fontWeight: 600 },
|
|
642
|
-
]);
|
|
643
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
644
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
645
|
-
|
|
646
|
-
expect(annotations[0].label!.style.fontSize).toBe(24);
|
|
647
|
-
expect(annotations[0].label!.style.fontWeight).toBe(600);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
it('solid refline has no dasharray', () => {
|
|
651
|
-
const spec = makeSpec([{ type: 'refline', y: 20, style: 'solid' }]);
|
|
652
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
653
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
654
|
-
|
|
655
|
-
expect(annotations[0].strokeDasharray).toBeUndefined();
|
|
656
|
-
});
|
|
657
|
-
|
|
658
|
-
it('returns nothing for refline with neither x nor y', () => {
|
|
659
|
-
const spec = makeSpec([{ type: 'refline', label: 'Orphan' } as Annotation]);
|
|
660
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
661
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
662
|
-
|
|
663
|
-
expect(annotations).toHaveLength(0);
|
|
664
|
-
});
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
describe('responsive behavior', () => {
|
|
668
|
-
it('returns empty annotations at compact breakpoint', () => {
|
|
669
|
-
const spec = makeSpec([
|
|
670
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Hidden' },
|
|
671
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' },
|
|
672
|
-
{ type: 'refline', y: 20 },
|
|
673
|
-
]);
|
|
674
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
675
|
-
const annotations = computeAnnotations(spec, scales, chartArea, compactStrategy);
|
|
676
|
-
|
|
677
|
-
expect(annotations).toHaveLength(0);
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
it('keeps annotations at compact breakpoint when autoThin is on', () => {
|
|
681
|
-
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Kept' }]);
|
|
682
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
683
|
-
const annotations = computeAnnotations(spec, {
|
|
684
|
-
scales,
|
|
685
|
-
chartArea,
|
|
686
|
-
strategy: compactStrategy,
|
|
687
|
-
isDark: false,
|
|
688
|
-
obstacles: [],
|
|
689
|
-
svg: { width: 320, height: 300 },
|
|
690
|
-
measure: (text, font) => text.length * font.fontSize * 0.6,
|
|
691
|
-
autoThin: true,
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
expect(annotations).toHaveLength(1);
|
|
695
|
-
expect(annotations[0].label!.text).toBe('Kept');
|
|
696
|
-
});
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
describe('multiple annotations', () => {
|
|
700
|
-
it('resolves multiple mixed annotations', () => {
|
|
701
|
-
const spec = makeSpec([
|
|
702
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note' },
|
|
703
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01' },
|
|
704
|
-
{ type: 'refline', y: 25 },
|
|
705
|
-
]);
|
|
706
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
707
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
708
|
-
|
|
709
|
-
expect(annotations).toHaveLength(3);
|
|
710
|
-
expect(annotations.map((a) => a.type)).toEqual(['text', 'range', 'refline']);
|
|
711
|
-
});
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
describe('empty annotations', () => {
|
|
715
|
-
it('returns empty array when spec has no annotations', () => {
|
|
716
|
-
const spec = makeSpec([]);
|
|
717
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
718
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
719
|
-
|
|
720
|
-
expect(annotations).toHaveLength(0);
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
// -----------------------------------------------------------------
|
|
725
|
-
// Fine-grained positioning (offset, anchor, connector, zIndex)
|
|
726
|
-
// -----------------------------------------------------------------
|
|
727
|
-
|
|
728
|
-
describe('text annotation offset', () => {
|
|
729
|
-
it('applies dx/dy offset to text annotation position', () => {
|
|
730
|
-
const spec = makeSpec([
|
|
731
|
-
{
|
|
732
|
-
type: 'text',
|
|
733
|
-
x: '2020-01-01',
|
|
734
|
-
y: 20,
|
|
735
|
-
text: 'Offset label',
|
|
736
|
-
offset: { dx: 20, dy: -30 },
|
|
737
|
-
},
|
|
738
|
-
]);
|
|
739
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
740
|
-
|
|
741
|
-
// Compute with and without offset
|
|
742
|
-
const specNoOffset = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No offset' }]);
|
|
743
|
-
const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
744
|
-
const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
|
|
745
|
-
|
|
746
|
-
expect(withOffset).toHaveLength(1);
|
|
747
|
-
expect(withoutOffset).toHaveLength(1);
|
|
748
|
-
|
|
749
|
-
// The offset annotation should be shifted by the dx/dy amount
|
|
750
|
-
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
751
|
-
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
752
|
-
expect(dx).toBeCloseTo(20);
|
|
753
|
-
expect(dy).toBeCloseTo(-30);
|
|
754
|
-
});
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
describe('text annotation anchor', () => {
|
|
758
|
-
it('anchor "top" places label above the data point', () => {
|
|
759
|
-
const spec = makeSpec([
|
|
760
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Above', anchor: 'top' },
|
|
761
|
-
]);
|
|
762
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
763
|
-
|
|
764
|
-
const py = scales.y?.scale(20);
|
|
765
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
766
|
-
|
|
767
|
-
expect(annotations).toHaveLength(1);
|
|
768
|
-
// Label y should be above the data point y
|
|
769
|
-
expect(annotations[0].label!.y).toBeLessThan(py as number);
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
it('anchor "bottom" places label below the data point', () => {
|
|
773
|
-
const spec = makeSpec([
|
|
774
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Below', anchor: 'bottom' },
|
|
775
|
-
]);
|
|
776
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
777
|
-
|
|
778
|
-
const py = scales.y?.scale(20);
|
|
779
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
780
|
-
|
|
781
|
-
expect(annotations).toHaveLength(1);
|
|
782
|
-
// Label y should be below the data point y
|
|
783
|
-
expect(annotations[0].label!.y).toBeGreaterThan(py as number);
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
it('anchor "left" places label to the left of the data point', () => {
|
|
787
|
-
const spec = makeSpec([
|
|
788
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Left', anchor: 'left' },
|
|
789
|
-
]);
|
|
790
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
791
|
-
|
|
792
|
-
const px = scales.x?.scale(new Date('2020-01-01'));
|
|
793
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
794
|
-
|
|
795
|
-
expect(annotations).toHaveLength(1);
|
|
796
|
-
// Label x should be to the left of the data point x
|
|
797
|
-
expect(annotations[0].label!.x).toBeLessThan(px as number);
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
it('anchor "right" places label to the right of the data point', () => {
|
|
801
|
-
const spec = makeSpec([
|
|
802
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Right', anchor: 'right' },
|
|
803
|
-
]);
|
|
804
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
805
|
-
|
|
806
|
-
const px = scales.x?.scale(new Date('2020-01-01'));
|
|
807
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
808
|
-
|
|
809
|
-
expect(annotations).toHaveLength(1);
|
|
810
|
-
// Label x should be to the right of the data point x
|
|
811
|
-
expect(annotations[0].label!.x).toBeGreaterThan(px as number);
|
|
812
|
-
});
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
describe('text annotation connector', () => {
|
|
816
|
-
it('connector is present by default once the label clears the data point', () => {
|
|
817
|
-
const spec = makeSpec([
|
|
818
|
-
{
|
|
819
|
-
type: 'text',
|
|
820
|
-
x: '2020-01-01',
|
|
821
|
-
y: 20,
|
|
822
|
-
text: 'Has connector',
|
|
823
|
-
offset: { dx: 0, dy: -60 },
|
|
824
|
-
},
|
|
825
|
-
]);
|
|
826
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
827
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
828
|
-
|
|
829
|
-
expect(annotations[0].label!.connector).toBeDefined();
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
it('connector can be disabled', () => {
|
|
833
|
-
const spec = makeSpec([
|
|
834
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'No connector', connector: false },
|
|
835
|
-
]);
|
|
836
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
837
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
838
|
-
|
|
839
|
-
expect(annotations[0].label!.connector).toBeUndefined();
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
it('connector "to" stops short of the endpoint marker', () => {
|
|
843
|
-
const spec = makeSpec([
|
|
844
|
-
{
|
|
845
|
-
type: 'text',
|
|
846
|
-
x: '2020-01-01',
|
|
847
|
-
y: 20,
|
|
848
|
-
text: 'Connector check',
|
|
849
|
-
offset: { dx: 0, dy: -60 },
|
|
850
|
-
},
|
|
851
|
-
]);
|
|
852
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
853
|
-
|
|
854
|
-
const px = scales.x?.scale(new Date('2020-01-01')) as number;
|
|
855
|
-
const py = scales.y?.scale(20) as number;
|
|
856
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
857
|
-
|
|
858
|
-
const resolved = annotations[0];
|
|
859
|
-
const connector = resolved.label!.connector!;
|
|
860
|
-
const dot = resolved.dot!;
|
|
861
|
-
|
|
862
|
-
// The "to" endpoint is pulled back so the line clears the marker's edge
|
|
863
|
-
// instead of piercing it.
|
|
864
|
-
const dist = Math.hypot(px - connector.to.x, py - connector.to.y);
|
|
865
|
-
expect(dist).toBeGreaterThan(dot.radius);
|
|
866
|
-
expect(dist).toBeLessThanOrEqual(dot.radius + dot.strokeWidth / 2 + 3 + 0.001);
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
describe('zIndex', () => {
|
|
871
|
-
it('annotations are sorted by zIndex', () => {
|
|
872
|
-
const spec = makeSpec([
|
|
873
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Low', zIndex: 1 },
|
|
874
|
-
{ type: 'refline', y: 25, zIndex: 10 },
|
|
875
|
-
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', zIndex: 5 },
|
|
876
|
-
]);
|
|
877
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
878
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
879
|
-
|
|
880
|
-
expect(annotations).toHaveLength(3);
|
|
881
|
-
// Should be sorted: zIndex 1, 5, 10
|
|
882
|
-
expect(annotations[0].zIndex).toBe(1);
|
|
883
|
-
expect(annotations[1].zIndex).toBe(5);
|
|
884
|
-
expect(annotations[2].zIndex).toBe(10);
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
it('annotations without zIndex default to 0', () => {
|
|
888
|
-
const spec = makeSpec([
|
|
889
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'After', zIndex: 5 },
|
|
890
|
-
{ type: 'refline', y: 25 }, // no zIndex, defaults to 0
|
|
891
|
-
]);
|
|
892
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
893
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
894
|
-
|
|
895
|
-
expect(annotations).toHaveLength(2);
|
|
896
|
-
// The refline (no zIndex = 0) should come before text (zIndex 5)
|
|
897
|
-
expect(annotations[0].type).toBe('refline');
|
|
898
|
-
expect(annotations[1].type).toBe('text');
|
|
899
|
-
});
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
describe('range annotation labelOffset', () => {
|
|
903
|
-
it('applies labelOffset to range annotation label', () => {
|
|
904
|
-
const spec = makeSpec([
|
|
905
|
-
{
|
|
906
|
-
type: 'range',
|
|
907
|
-
x1: '2020-01-01',
|
|
908
|
-
x2: '2021-01-01',
|
|
909
|
-
label: 'Shifted',
|
|
910
|
-
labelOffset: { dx: 20, dy: 10 },
|
|
911
|
-
},
|
|
912
|
-
]);
|
|
913
|
-
const specNoOffset = makeSpec([
|
|
914
|
-
{
|
|
915
|
-
type: 'range',
|
|
916
|
-
x1: '2020-01-01',
|
|
917
|
-
x2: '2021-01-01',
|
|
918
|
-
label: 'Not shifted',
|
|
919
|
-
},
|
|
920
|
-
]);
|
|
921
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
922
|
-
|
|
923
|
-
const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
924
|
-
const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
|
|
925
|
-
|
|
926
|
-
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
927
|
-
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
928
|
-
expect(dx).toBeCloseTo(20);
|
|
929
|
-
expect(dy).toBeCloseTo(10);
|
|
930
|
-
});
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
describe('refline annotation labelOffset', () => {
|
|
934
|
-
it('applies labelOffset to refline label', () => {
|
|
935
|
-
const spec = makeSpec([
|
|
936
|
-
{
|
|
937
|
-
type: 'refline',
|
|
938
|
-
y: 20,
|
|
939
|
-
label: 'Shifted',
|
|
940
|
-
labelOffset: { dx: 15, dy: -10 },
|
|
941
|
-
},
|
|
942
|
-
]);
|
|
943
|
-
const specNoOffset = makeSpec([
|
|
944
|
-
{
|
|
945
|
-
type: 'refline',
|
|
946
|
-
y: 20,
|
|
947
|
-
label: 'Not shifted',
|
|
948
|
-
},
|
|
949
|
-
]);
|
|
950
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
951
|
-
|
|
952
|
-
const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
953
|
-
const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
|
|
954
|
-
|
|
955
|
-
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
956
|
-
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
957
|
-
expect(dx).toBe(15);
|
|
958
|
-
expect(dy).toBe(-10);
|
|
959
|
-
});
|
|
960
|
-
});
|
|
961
|
-
|
|
962
|
-
// -----------------------------------------------------------------
|
|
963
|
-
// Refline labelAnchor positioning
|
|
964
|
-
// -----------------------------------------------------------------
|
|
965
|
-
|
|
966
|
-
describe('refline labelAnchor positioning', () => {
|
|
967
|
-
it('horizontal refline: "left" places label at start.x with text-anchor start', () => {
|
|
968
|
-
const spec = makeSpec([{ type: 'refline', y: 20, label: 'Left label', labelAnchor: 'left' }]);
|
|
969
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
970
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
971
|
-
|
|
972
|
-
const label = annotations[0].label!;
|
|
973
|
-
// Label x should be near chartArea.x (start of line) + small offset
|
|
974
|
-
expect(label.x).toBeCloseTo(chartArea.x + 4, 0);
|
|
975
|
-
expect(label.style.textAnchor).toBe('start');
|
|
976
|
-
});
|
|
977
|
-
|
|
978
|
-
it('horizontal refline: default places label at end.x with text-anchor end', () => {
|
|
979
|
-
const spec = makeSpec([{ type: 'refline', y: 20, label: 'Default label' }]);
|
|
980
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
981
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
982
|
-
|
|
983
|
-
const label = annotations[0].label!;
|
|
984
|
-
// Label x should be near chartArea.x + chartArea.width (end of line) - small offset
|
|
985
|
-
expect(label.x).toBeCloseTo(chartArea.x + chartArea.width - 4, 0);
|
|
986
|
-
expect(label.style.textAnchor).toBe('end');
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
it('horizontal refline: "bottom" places label below the line', () => {
|
|
990
|
-
const specTop = makeSpec([{ type: 'refline', y: 20, label: 'Top', labelAnchor: 'top' }]);
|
|
991
|
-
const specBottom = makeSpec([
|
|
992
|
-
{ type: 'refline', y: 20, label: 'Bottom', labelAnchor: 'bottom' },
|
|
993
|
-
]);
|
|
994
|
-
const scales = computeScales(specTop, chartArea, specTop.data);
|
|
995
|
-
|
|
996
|
-
const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
|
|
997
|
-
const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
|
|
998
|
-
|
|
999
|
-
// Bottom label should be below top label (larger y value)
|
|
1000
|
-
expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
it('vertical refline: "right" places label with text-anchor end', () => {
|
|
1004
|
-
const spec = makeSpec([
|
|
1005
|
-
{ type: 'refline', x: '2020-06-01', label: 'Right', labelAnchor: 'right' },
|
|
1006
|
-
]);
|
|
1007
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1008
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1009
|
-
|
|
1010
|
-
const label = annotations[0].label!;
|
|
1011
|
-
expect(label.style.textAnchor).toBe('end');
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
|
-
it('vertical refline: "bottom" places label near end.y', () => {
|
|
1015
|
-
const specTop = makeSpec([
|
|
1016
|
-
{ type: 'refline', x: '2020-06-01', label: 'Top', labelAnchor: 'top' },
|
|
1017
|
-
]);
|
|
1018
|
-
const specBottom = makeSpec([
|
|
1019
|
-
{ type: 'refline', x: '2020-06-01', label: 'Bottom', labelAnchor: 'bottom' },
|
|
1020
|
-
]);
|
|
1021
|
-
const scales = computeScales(specTop, chartArea, specTop.data);
|
|
1022
|
-
|
|
1023
|
-
const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
|
|
1024
|
-
const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
|
|
1025
|
-
|
|
1026
|
-
// Bottom label should be further down (near end.y which is chartArea.y + chartArea.height)
|
|
1027
|
-
expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
it('labelOffset still applies on top of anchor positioning', () => {
|
|
1031
|
-
const spec = makeSpec([
|
|
1032
|
-
{
|
|
1033
|
-
type: 'refline',
|
|
1034
|
-
y: 20,
|
|
1035
|
-
label: 'Offset left',
|
|
1036
|
-
labelAnchor: 'left',
|
|
1037
|
-
labelOffset: { dx: 10, dy: -5 },
|
|
1038
|
-
},
|
|
1039
|
-
]);
|
|
1040
|
-
const specNoOffset = makeSpec([
|
|
1041
|
-
{ type: 'refline', y: 20, label: 'No offset', labelAnchor: 'left' },
|
|
1042
|
-
]);
|
|
1043
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1044
|
-
|
|
1045
|
-
const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1046
|
-
const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
|
|
1047
|
-
|
|
1048
|
-
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
1049
|
-
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
1050
|
-
expect(dx).toBe(10);
|
|
1051
|
-
expect(dy).toBe(-5);
|
|
1052
|
-
});
|
|
1053
|
-
});
|
|
1054
|
-
|
|
1055
|
-
// -----------------------------------------------------------------
|
|
1056
|
-
// Connector origin (ray-box exit with standoff)
|
|
1057
|
-
// -----------------------------------------------------------------
|
|
1058
|
-
|
|
1059
|
-
describe('connector origin auto-selection', () => {
|
|
1060
|
-
// The origin is now a ray-box intersection plus a CONNECTOR_STANDOFF gap,
|
|
1061
|
-
// so it sits just OUTSIDE the edge it exits from rather than on it.
|
|
1062
|
-
const STANDOFF = 6;
|
|
1063
|
-
const INFLATE = 2;
|
|
1064
|
-
|
|
1065
|
-
it('connector exits above the label when the data point is above it', () => {
|
|
1066
|
-
const spec = makeSpec([
|
|
1067
|
-
{
|
|
1068
|
-
type: 'text',
|
|
1069
|
-
x: '2020-01-01',
|
|
1070
|
-
y: 20,
|
|
1071
|
-
text: 'Below point',
|
|
1072
|
-
anchor: 'bottom',
|
|
1073
|
-
offset: { dx: 0, dy: 150 },
|
|
1074
|
-
},
|
|
1075
|
-
]);
|
|
1076
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1077
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1078
|
-
|
|
1079
|
-
const ann = annotations[0];
|
|
1080
|
-
const connector = ann.label!.connector!;
|
|
1081
|
-
const bounds = ann.bounds!;
|
|
1082
|
-
|
|
1083
|
-
expect(connector.exit).toBe('vertical');
|
|
1084
|
-
// Origin sits above the (inflated) top edge by the standoff gap.
|
|
1085
|
-
expect(connector.from.y).toBeCloseTo(bounds.y - INFLATE - STANDOFF, 0);
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
it('connector exits below the label when the data point is below it', () => {
|
|
1089
|
-
const spec = makeSpec([
|
|
1090
|
-
{
|
|
1091
|
-
type: 'text',
|
|
1092
|
-
x: '2020-01-01',
|
|
1093
|
-
y: 20,
|
|
1094
|
-
text: 'Above point',
|
|
1095
|
-
anchor: 'top',
|
|
1096
|
-
offset: { dx: 0, dy: -80 },
|
|
1097
|
-
},
|
|
1098
|
-
]);
|
|
1099
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1100
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1101
|
-
|
|
1102
|
-
const ann = annotations[0];
|
|
1103
|
-
const connector = ann.label!.connector!;
|
|
1104
|
-
const bounds = ann.bounds!;
|
|
1105
|
-
|
|
1106
|
-
expect(connector.exit).toBe('vertical');
|
|
1107
|
-
expect(connector.from.y).toBeCloseTo(bounds.y + bounds.height + INFLATE + STANDOFF, 0);
|
|
1108
|
-
});
|
|
1109
|
-
|
|
1110
|
-
it('connector exits left of the label when the data point is left of it', () => {
|
|
1111
|
-
const spec = makeSpec([
|
|
1112
|
-
{
|
|
1113
|
-
type: 'text',
|
|
1114
|
-
x: '2020-01-01',
|
|
1115
|
-
y: 20,
|
|
1116
|
-
text: 'Right of point',
|
|
1117
|
-
anchor: 'right',
|
|
1118
|
-
offset: { dx: 120, dy: 0 },
|
|
1119
|
-
},
|
|
1120
|
-
]);
|
|
1121
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1122
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1123
|
-
|
|
1124
|
-
const ann = annotations[0];
|
|
1125
|
-
const connector = ann.label!.connector!;
|
|
1126
|
-
const bounds = ann.bounds!;
|
|
1127
|
-
|
|
1128
|
-
expect(connector.exit).toBe('horizontal');
|
|
1129
|
-
// Origin sits left of the (inflated) left edge by the standoff gap. Asserting
|
|
1130
|
-
// only `< bounds.x` would pass for an origin anywhere off to the left, which
|
|
1131
|
-
// is what a broken ray-box intersection produces.
|
|
1132
|
-
expect(connector.from.x).toBeCloseTo(bounds.x - INFLATE - STANDOFF, 1);
|
|
1133
|
-
});
|
|
1134
|
-
|
|
1135
|
-
it('connector exits right of the label when the data point is right of it', () => {
|
|
1136
|
-
const spec = makeSpec([
|
|
1137
|
-
{
|
|
1138
|
-
type: 'text',
|
|
1139
|
-
x: '2021-01-01',
|
|
1140
|
-
y: 20,
|
|
1141
|
-
text: 'Left of point',
|
|
1142
|
-
anchor: 'left',
|
|
1143
|
-
offset: { dx: -120, dy: 0 },
|
|
1144
|
-
},
|
|
1145
|
-
]);
|
|
1146
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1147
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1148
|
-
|
|
1149
|
-
const ann = annotations[0];
|
|
1150
|
-
const connector = ann.label!.connector!;
|
|
1151
|
-
const bounds = ann.bounds!;
|
|
1152
|
-
|
|
1153
|
-
expect(connector.exit).toBe('horizontal');
|
|
1154
|
-
// Mirror of the left-exit case: one standoff gap past the inflated right edge.
|
|
1155
|
-
expect(connector.from.x).toBeCloseTo(bounds.x + bounds.width + INFLATE + STANDOFF, 1);
|
|
1156
|
-
});
|
|
1157
|
-
|
|
1158
|
-
it('the connector origin always sits outside the annotation block', () => {
|
|
1159
|
-
const spec = makeSpec([
|
|
1160
|
-
{
|
|
1161
|
-
type: 'text',
|
|
1162
|
-
x: '2020-01-01',
|
|
1163
|
-
y: 20,
|
|
1164
|
-
text: 'First line\nSecond line',
|
|
1165
|
-
anchor: 'bottom',
|
|
1166
|
-
offset: { dx: 0, dy: 80 },
|
|
1167
|
-
},
|
|
1168
|
-
]);
|
|
1169
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1170
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1171
|
-
|
|
1172
|
-
const ann = annotations[0];
|
|
1173
|
-
const { from } = ann.label!.connector!;
|
|
1174
|
-
const b = ann.bounds!;
|
|
1175
|
-
const inside =
|
|
1176
|
-
from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
|
|
1177
|
-
expect(inside).toBe(false);
|
|
1178
|
-
});
|
|
1179
|
-
|
|
1180
|
-
it('connector origin flips sides when the label moves to the other side', () => {
|
|
1181
|
-
const specRight = makeSpec([
|
|
1182
|
-
{
|
|
1183
|
-
type: 'text',
|
|
1184
|
-
x: '2020-01-01',
|
|
1185
|
-
y: 20,
|
|
1186
|
-
text: 'Test label',
|
|
1187
|
-
anchor: 'right',
|
|
1188
|
-
offset: { dx: 120, dy: 0 },
|
|
1189
|
-
},
|
|
1190
|
-
]);
|
|
1191
|
-
const specLeft = makeSpec([
|
|
1192
|
-
{
|
|
1193
|
-
type: 'text',
|
|
1194
|
-
x: '2020-01-01',
|
|
1195
|
-
y: 20,
|
|
1196
|
-
text: 'Test label',
|
|
1197
|
-
anchor: 'left',
|
|
1198
|
-
offset: { dx: -120, dy: 0 },
|
|
1199
|
-
},
|
|
1200
|
-
]);
|
|
1201
|
-
const scalesRight = computeScales(specRight, chartArea, specRight.data);
|
|
1202
|
-
const scalesLeft = computeScales(specLeft, chartArea, specLeft.data);
|
|
1203
|
-
|
|
1204
|
-
const annRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy)[0];
|
|
1205
|
-
const annLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy)[0];
|
|
1206
|
-
|
|
1207
|
-
// Label right of the point: exit off the left edge.
|
|
1208
|
-
expect(annRight.label!.connector!.from.x).toBeLessThan(annRight.bounds!.x);
|
|
1209
|
-
// Label left of the point: exit off the right edge.
|
|
1210
|
-
expect(annLeft.label!.connector!.from.x).toBeGreaterThan(
|
|
1211
|
-
annLeft.bounds!.x + annLeft.bounds!.width,
|
|
1212
|
-
);
|
|
1213
|
-
});
|
|
1214
|
-
|
|
1215
|
-
it('connectorOffset is still applied on top of the auto-selected origin', () => {
|
|
1216
|
-
const specWithOffset = makeSpec([
|
|
1217
|
-
{
|
|
1218
|
-
type: 'text',
|
|
1219
|
-
x: '2020-01-01',
|
|
1220
|
-
y: 20,
|
|
1221
|
-
text: 'Offset test',
|
|
1222
|
-
anchor: 'bottom',
|
|
1223
|
-
offset: { dx: 0, dy: 80 },
|
|
1224
|
-
connectorOffset: { from: { dx: 10, dy: 5 } },
|
|
1225
|
-
},
|
|
1226
|
-
]);
|
|
1227
|
-
const specNoOffset = makeSpec([
|
|
1228
|
-
{
|
|
1229
|
-
type: 'text',
|
|
1230
|
-
x: '2020-01-01',
|
|
1231
|
-
y: 20,
|
|
1232
|
-
text: 'Offset test',
|
|
1233
|
-
anchor: 'bottom',
|
|
1234
|
-
offset: { dx: 0, dy: 80 },
|
|
1235
|
-
},
|
|
1236
|
-
]);
|
|
1237
|
-
const scales = computeScales(specWithOffset, chartArea, specWithOffset.data);
|
|
1238
|
-
|
|
1239
|
-
const withOffset = computeAnnotations(specWithOffset, scales, chartArea, fullStrategy);
|
|
1240
|
-
const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
|
|
1241
|
-
|
|
1242
|
-
const fromWith = withOffset[0].label!.connector!.from;
|
|
1243
|
-
const fromWithout = withoutOffset[0].label!.connector!.from;
|
|
1244
|
-
|
|
1245
|
-
expect(fromWith.x - fromWithout.x).toBeCloseTo(10, 0);
|
|
1246
|
-
expect(fromWith.y - fromWithout.y).toBeCloseTo(5, 0);
|
|
1247
|
-
});
|
|
1248
|
-
|
|
1249
|
-
// Curve connectors used to hardcode a right-edge exit regardless of where the
|
|
1250
|
-
// target was; they now use the same ray-box exit as straight connectors.
|
|
1251
|
-
it('curve connector exits toward the data point, not always from the right edge', () => {
|
|
1252
|
-
const spec = makeSpec([
|
|
1253
|
-
{
|
|
1254
|
-
type: 'text',
|
|
1255
|
-
x: '2020-01-01',
|
|
1256
|
-
y: 20,
|
|
1257
|
-
text: 'Curve test',
|
|
1258
|
-
anchor: 'bottom',
|
|
1259
|
-
offset: { dx: 0, dy: 80 },
|
|
1260
|
-
connector: 'curve',
|
|
1261
|
-
},
|
|
1262
|
-
]);
|
|
1263
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1264
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1265
|
-
|
|
1266
|
-
const ann = annotations[0];
|
|
1267
|
-
const connector = ann.label!.connector!;
|
|
1268
|
-
const bounds = ann.bounds!;
|
|
1269
|
-
|
|
1270
|
-
// Data point is straight above the label, so the curve leaves the top.
|
|
1271
|
-
expect(connector.exit).toBe('vertical');
|
|
1272
|
-
expect(connector.from.y).toBeLessThan(bounds.y);
|
|
1273
|
-
});
|
|
1274
|
-
});
|
|
1275
|
-
|
|
1276
|
-
// -----------------------------------------------------------------
|
|
1277
|
-
// Annotation-to-annotation collision resolution
|
|
1278
|
-
// -----------------------------------------------------------------
|
|
1279
|
-
|
|
1280
|
-
describe('annotation-to-annotation collision', () => {
|
|
1281
|
-
it('nudges second annotation when two overlap at the same data point', () => {
|
|
1282
|
-
const spec = makeSpec([
|
|
1283
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
|
|
1284
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
|
|
1285
|
-
]);
|
|
1286
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1287
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1288
|
-
|
|
1289
|
-
expect(annotations).toHaveLength(2);
|
|
1290
|
-
|
|
1291
|
-
const label1 = annotations[0].label!;
|
|
1292
|
-
const label2 = annotations[1].label!;
|
|
1293
|
-
|
|
1294
|
-
// Both should be visible
|
|
1295
|
-
expect(label1.visible).toBe(true);
|
|
1296
|
-
expect(label2.visible).toBe(true);
|
|
1297
|
-
|
|
1298
|
-
// Labels should not overlap: their positions should differ
|
|
1299
|
-
const samePosition = label1.x === label2.x && label1.y === label2.y;
|
|
1300
|
-
expect(samePosition).toBe(false);
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
it('nudges second annotation when nearby data points produce overlapping labels', () => {
|
|
1304
|
-
const spec = makeSpec([
|
|
1305
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'First annotation' },
|
|
1306
|
-
{ type: 'text', x: '2020-01-01', y: 21, text: 'Second annotation' },
|
|
1307
|
-
]);
|
|
1308
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1309
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1310
|
-
|
|
1311
|
-
expect(annotations).toHaveLength(2);
|
|
1312
|
-
|
|
1313
|
-
const label1 = annotations[0].label!;
|
|
1314
|
-
const label2 = annotations[1].label!;
|
|
1315
|
-
|
|
1316
|
-
// After collision resolution, bounding boxes should not overlap
|
|
1317
|
-
// Check that at least one coordinate differs meaningfully
|
|
1318
|
-
const dy = Math.abs(label1.y - label2.y);
|
|
1319
|
-
const dx = Math.abs(label1.x - label2.x);
|
|
1320
|
-
expect(dx + dy).toBeGreaterThan(5);
|
|
1321
|
-
});
|
|
1322
|
-
|
|
1323
|
-
it('recomputes connector origin after nudging', () => {
|
|
1324
|
-
const spec = makeSpec([
|
|
1325
|
-
{
|
|
1326
|
-
type: 'text',
|
|
1327
|
-
x: '2020-01-01',
|
|
1328
|
-
y: 20,
|
|
1329
|
-
text: 'First note',
|
|
1330
|
-
offset: { dx: 0, dy: -60 },
|
|
1331
|
-
},
|
|
1332
|
-
{
|
|
1333
|
-
type: 'text',
|
|
1334
|
-
x: '2020-01-01',
|
|
1335
|
-
y: 20,
|
|
1336
|
-
text: 'Second note',
|
|
1337
|
-
offset: { dx: 0, dy: -60 },
|
|
1338
|
-
},
|
|
1339
|
-
]);
|
|
1340
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1341
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1342
|
-
|
|
1343
|
-
const nudged = annotations[1];
|
|
1344
|
-
const nudgedLabel = nudged.label!;
|
|
1345
|
-
|
|
1346
|
-
// The nudged annotation should still have a connector
|
|
1347
|
-
expect(nudgedLabel.connector).toBeDefined();
|
|
1348
|
-
|
|
1349
|
-
// The origin must sit just outside the nudged block, not the original one.
|
|
1350
|
-
const b = nudged.bounds!;
|
|
1351
|
-
const { from } = nudgedLabel.connector!;
|
|
1352
|
-
const inside =
|
|
1353
|
-
from.x >= b.x && from.x <= b.x + b.width && from.y >= b.y && from.y <= b.y + b.height;
|
|
1354
|
-
expect(inside).toBe(false);
|
|
1355
|
-
|
|
1356
|
-
const distFromBlock = Math.min(
|
|
1357
|
-
Math.abs(from.y - b.y),
|
|
1358
|
-
Math.abs(from.y - (b.y + b.height)),
|
|
1359
|
-
Math.abs(from.x - b.x),
|
|
1360
|
-
Math.abs(from.x - (b.x + b.width)),
|
|
1361
|
-
);
|
|
1362
|
-
// The origin clears the NUDGED block by the standoff gap and no more. The ray
|
|
1363
|
-
// leaves the box diagonally, so the CONNECTOR_STANDOFF (6) splits across x and
|
|
1364
|
-
// y and the perpendicular clearance lands somewhere in (BOX_INFLATE,
|
|
1365
|
-
// BOX_INFLATE + CONNECTOR_STANDOFF] = (2, 8]. A connector rebuilt against the
|
|
1366
|
-
// pre-nudge bounds strands the origin tens of pixels off the block, which the
|
|
1367
|
-
// old 20px window still admitted.
|
|
1368
|
-
expect(distFromBlock).toBeGreaterThan(2);
|
|
1369
|
-
expect(distFromBlock).toBeLessThanOrEqual(8);
|
|
1370
|
-
});
|
|
1371
|
-
|
|
1372
|
-
// The subtitle carries absolute coordinates, so a nudge that only moved the
|
|
1373
|
-
// label used to leave the subtitle stranded at the original position.
|
|
1374
|
-
it('subtitle moves with a nudged label', () => {
|
|
1375
|
-
const spec = makeSpec([
|
|
1376
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'First note', subtitle: 'Context one' },
|
|
1377
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Second note', subtitle: 'Context two' },
|
|
1378
|
-
]);
|
|
1379
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1380
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1381
|
-
|
|
1382
|
-
const single = computeAnnotations(
|
|
1383
|
-
makeSpec([spec.annotations[1]]),
|
|
1384
|
-
scales,
|
|
1385
|
-
chartArea,
|
|
1386
|
-
fullStrategy,
|
|
1387
|
-
)[0];
|
|
1388
|
-
|
|
1389
|
-
const nudged = annotations[1];
|
|
1390
|
-
const dx = nudged.label!.x - single.label!.x;
|
|
1391
|
-
const dy = nudged.label!.y - single.label!.y;
|
|
1392
|
-
expect(Math.abs(dx) + Math.abs(dy)).toBeGreaterThan(0);
|
|
1393
|
-
|
|
1394
|
-
// The subtitle shifted by the same delta as its label.
|
|
1395
|
-
expect(nudged.subtitle!.x).toBeCloseTo(single.subtitle!.x + dx, 5);
|
|
1396
|
-
expect(nudged.subtitle!.y).toBeCloseTo(single.subtitle!.y + dy, 5);
|
|
1397
|
-
|
|
1398
|
-
// And the label/subtitle gap is unchanged.
|
|
1399
|
-
expect(nudged.subtitle!.y - nudged.label!.y).toBeCloseTo(
|
|
1400
|
-
single.subtitle!.y - single.label!.y,
|
|
1401
|
-
5,
|
|
1402
|
-
);
|
|
1403
|
-
});
|
|
1404
|
-
|
|
1405
|
-
// Auto-placement (compute.ts Pass 2) stamps a new label position from the
|
|
1406
|
-
// placement search; the subtitle must land where the search scored it.
|
|
1407
|
-
it('subtitle follows an auto-placed label', () => {
|
|
1408
|
-
const spec = makeSpec([
|
|
1409
|
-
{
|
|
1410
|
-
type: 'text',
|
|
1411
|
-
x: '2020-01-01',
|
|
1412
|
-
y: 20,
|
|
1413
|
-
text: 'Auto placed',
|
|
1414
|
-
subtitle: 'Its context line',
|
|
1415
|
-
},
|
|
1416
|
-
]);
|
|
1417
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1418
|
-
const annotations = computeAnnotations(spec, {
|
|
1419
|
-
scales,
|
|
1420
|
-
chartArea,
|
|
1421
|
-
strategy: fullStrategy,
|
|
1422
|
-
isDark: false,
|
|
1423
|
-
obstacles: [],
|
|
1424
|
-
svg: { width: 600, height: 400 },
|
|
1425
|
-
measure: (text, font) => text.length * font.fontSize * 0.6,
|
|
1426
|
-
});
|
|
1427
|
-
|
|
1428
|
-
const ann = annotations[0];
|
|
1429
|
-
const label = ann.label!;
|
|
1430
|
-
const sub = ann.subtitle!;
|
|
1431
|
-
const fontSize = label.style.fontSize as number;
|
|
1432
|
-
const lineHeight = label.style.lineHeight as number;
|
|
1433
|
-
|
|
1434
|
-
expect(sub.x).toBeCloseTo(label.x, 5);
|
|
1435
|
-
expect(sub.y).toBeCloseTo(label.y + fontSize * lineHeight * 1 + 2, 5);
|
|
1436
|
-
expect(sub.style.textAnchor).toBe(label.style.textAnchor);
|
|
1437
|
-
// The stamped bounds cover both lines.
|
|
1438
|
-
expect(ann.bounds!.height).toBeGreaterThan(fontSize * lineHeight);
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
it('resolves three overlapping annotations without any collision', () => {
|
|
1442
|
-
const spec = makeSpec([
|
|
1443
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note A' },
|
|
1444
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note B' },
|
|
1445
|
-
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note C' },
|
|
1446
|
-
]);
|
|
1447
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1448
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1449
|
-
|
|
1450
|
-
expect(annotations).toHaveLength(3);
|
|
1451
|
-
|
|
1452
|
-
// All three should be visible
|
|
1453
|
-
for (const ann of annotations) {
|
|
1454
|
-
expect(ann.label!.visible).toBe(true);
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// All three should have distinct positions
|
|
1458
|
-
const positions = annotations.map((a) => `${a.label!.x.toFixed(1)},${a.label!.y.toFixed(1)}`);
|
|
1459
|
-
const unique = new Set(positions);
|
|
1460
|
-
expect(unique.size).toBe(3);
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
it('does not nudge annotations that already have distinct positions', () => {
|
|
1464
|
-
const spec = makeSpec([
|
|
1465
|
-
{
|
|
1466
|
-
type: 'text',
|
|
1467
|
-
x: '2020-01-01',
|
|
1468
|
-
y: 10,
|
|
1469
|
-
text: 'Low point',
|
|
1470
|
-
anchor: 'bottom',
|
|
1471
|
-
offset: { dx: 0, dy: 40 },
|
|
1472
|
-
},
|
|
1473
|
-
{
|
|
1474
|
-
type: 'text',
|
|
1475
|
-
x: '2022-01-01',
|
|
1476
|
-
y: 40,
|
|
1477
|
-
text: 'High point',
|
|
1478
|
-
anchor: 'top',
|
|
1479
|
-
offset: { dx: 0, dy: -40 },
|
|
1480
|
-
},
|
|
1481
|
-
]);
|
|
1482
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1483
|
-
|
|
1484
|
-
// Compute once to get positions
|
|
1485
|
-
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1486
|
-
|
|
1487
|
-
expect(annotations).toHaveLength(2);
|
|
1488
|
-
|
|
1489
|
-
// These annotations are far apart, so positions should match what they'd
|
|
1490
|
-
// be without collision resolution (i.e., not nudged)
|
|
1491
|
-
const specSingle1 = makeSpec([spec.annotations[0]]);
|
|
1492
|
-
const specSingle2 = makeSpec([spec.annotations[1]]);
|
|
1493
|
-
const single1 = computeAnnotations(specSingle1, scales, chartArea, fullStrategy);
|
|
1494
|
-
const single2 = computeAnnotations(specSingle2, scales, chartArea, fullStrategy);
|
|
1495
|
-
|
|
1496
|
-
expect(annotations[0].label!.x).toBeCloseTo(single1[0].label!.x, 1);
|
|
1497
|
-
expect(annotations[0].label!.y).toBeCloseTo(single1[0].label!.y, 1);
|
|
1498
|
-
expect(annotations[1].label!.x).toBeCloseTo(single2[0].label!.x, 1);
|
|
1499
|
-
expect(annotations[1].label!.y).toBeCloseTo(single2[0].label!.y, 1);
|
|
1500
|
-
});
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
// -----------------------------------------------------------------
|
|
1504
|
-
// Obstacle avoidance (label bounds as obstacles)
|
|
1505
|
-
// -----------------------------------------------------------------
|
|
1506
|
-
|
|
1507
|
-
describe('obstacle avoidance', () => {
|
|
1508
|
-
it('nudges annotation away from an obstacle rect at the same position', () => {
|
|
1509
|
-
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Annotation' }]);
|
|
1510
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1511
|
-
|
|
1512
|
-
// Place an obstacle rect exactly where the annotation would land
|
|
1513
|
-
const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1514
|
-
const originalLabel = withoutObstacles[0].label!;
|
|
1515
|
-
|
|
1516
|
-
const obstacle: Rect = {
|
|
1517
|
-
x: originalLabel.x - 5,
|
|
1518
|
-
y: originalLabel.y - 5,
|
|
1519
|
-
width: 80,
|
|
1520
|
-
height: 30,
|
|
1521
|
-
};
|
|
1522
|
-
|
|
1523
|
-
const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
|
|
1524
|
-
obstacle,
|
|
1525
|
-
]);
|
|
1526
|
-
|
|
1527
|
-
expect(withObstacles).toHaveLength(1);
|
|
1528
|
-
const nudgedLabel = withObstacles[0].label!;
|
|
1529
|
-
expect(nudgedLabel.visible).toBe(true);
|
|
1530
|
-
|
|
1531
|
-
// The annotation should have moved away from the obstacle
|
|
1532
|
-
const moved = nudgedLabel.x !== originalLabel.x || nudgedLabel.y !== originalLabel.y;
|
|
1533
|
-
expect(moved).toBe(true);
|
|
1534
|
-
});
|
|
1535
|
-
|
|
1536
|
-
it('preserves connector style when nudged away from obstacle', () => {
|
|
1537
|
-
// connector: true means "straight line" - obstacle avoidance should not change it
|
|
1538
|
-
const spec = makeSpec([
|
|
1539
|
-
{
|
|
1540
|
-
type: 'text',
|
|
1541
|
-
x: '2020-01-01',
|
|
1542
|
-
y: 20,
|
|
1543
|
-
text: 'Explicit connector',
|
|
1544
|
-
connector: true,
|
|
1545
|
-
offset: { dx: 0, dy: -60 },
|
|
1546
|
-
},
|
|
1547
|
-
]);
|
|
1548
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1549
|
-
|
|
1550
|
-
const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1551
|
-
const originalLabel = withoutObstacles[0].label!;
|
|
1552
|
-
expect(originalLabel.connector).toBeDefined();
|
|
1553
|
-
expect(originalLabel.connector!.style).toBe('straight');
|
|
1554
|
-
|
|
1555
|
-
// Place obstacle directly on the annotation to force a nudge
|
|
1556
|
-
const obstacle: Rect = {
|
|
1557
|
-
x: originalLabel.x - 5,
|
|
1558
|
-
y: originalLabel.y - 5,
|
|
1559
|
-
width: 80,
|
|
1560
|
-
height: 30,
|
|
1561
|
-
};
|
|
1562
|
-
|
|
1563
|
-
const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
|
|
1564
|
-
obstacle,
|
|
1565
|
-
]);
|
|
1566
|
-
const nudgedLabel = withObstacles[0].label!;
|
|
1567
|
-
expect(nudgedLabel.connector).toBeDefined();
|
|
1568
|
-
expect(nudgedLabel.connector!.style).toBe('straight');
|
|
1569
|
-
});
|
|
1570
|
-
});
|
|
1571
|
-
|
|
1572
|
-
// -----------------------------------------------------------------
|
|
1573
|
-
// Boundary clamping (SVG edge overflow prevention)
|
|
1574
|
-
// -----------------------------------------------------------------
|
|
1575
|
-
|
|
1576
|
-
describe('boundary clamping', () => {
|
|
1577
|
-
const svgDimensions = { width: 600, height: 400 };
|
|
1578
|
-
|
|
1579
|
-
it('shifts annotation left when it overflows the right SVG edge', () => {
|
|
1580
|
-
// Place annotation at the far right of the chart area so the label
|
|
1581
|
-
// text extends past the SVG boundary.
|
|
1582
|
-
const spec = makeSpec([
|
|
1583
|
-
{
|
|
1584
|
-
type: 'text',
|
|
1585
|
-
x: '2022-01-01',
|
|
1586
|
-
y: 40,
|
|
1587
|
-
text: 'This is a long annotation label',
|
|
1588
|
-
anchor: 'right',
|
|
1589
|
-
offset: { dx: 80, dy: 0 },
|
|
1590
|
-
},
|
|
1591
|
-
]);
|
|
1592
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1593
|
-
const annotations = computeAnnotations(
|
|
1594
|
-
spec,
|
|
1595
|
-
scales,
|
|
1596
|
-
chartArea,
|
|
1597
|
-
fullStrategy,
|
|
1598
|
-
false,
|
|
1599
|
-
[],
|
|
1600
|
-
svgDimensions,
|
|
1601
|
-
);
|
|
1602
|
-
|
|
1603
|
-
expect(annotations).toHaveLength(1);
|
|
1604
|
-
const label = annotations[0].label!;
|
|
1605
|
-
// Use engine-exported bounds instead of re-deriving text width
|
|
1606
|
-
expect(label.bounds).toBeDefined();
|
|
1607
|
-
expect(label.bounds!.x + label.bounds!.width).toBeLessThanOrEqual(svgDimensions.width);
|
|
1608
|
-
});
|
|
1609
|
-
|
|
1610
|
-
it('shifts annotation down when it overflows the top SVG edge', () => {
|
|
1611
|
-
// Place annotation near the top of the chart and push it upward
|
|
1612
|
-
const spec = makeSpec([
|
|
1613
|
-
{
|
|
1614
|
-
type: 'text',
|
|
1615
|
-
x: '2020-01-01',
|
|
1616
|
-
y: 40,
|
|
1617
|
-
text: 'Top overflow',
|
|
1618
|
-
anchor: 'top',
|
|
1619
|
-
offset: { dx: 0, dy: -80 },
|
|
1620
|
-
},
|
|
1621
|
-
]);
|
|
1622
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1623
|
-
const annotations = computeAnnotations(
|
|
1624
|
-
spec,
|
|
1625
|
-
scales,
|
|
1626
|
-
chartArea,
|
|
1627
|
-
fullStrategy,
|
|
1628
|
-
false,
|
|
1629
|
-
[],
|
|
1630
|
-
svgDimensions,
|
|
1631
|
-
);
|
|
1632
|
-
|
|
1633
|
-
expect(annotations).toHaveLength(1);
|
|
1634
|
-
const label = annotations[0].label!;
|
|
1635
|
-
const fontSize = label.style.fontSize ?? 12;
|
|
1636
|
-
// The top of the label bounds (y - fontSize) should be >= 0
|
|
1637
|
-
expect(label.y - fontSize).toBeGreaterThanOrEqual(0);
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
it('does not modify annotation that is well within bounds', () => {
|
|
1641
|
-
// Place annotation in the center of the chart with an explicit offset
|
|
1642
|
-
// so it uses the same explicit placement path with and without SVG dims
|
|
1643
|
-
const spec = makeSpec([
|
|
1644
|
-
{
|
|
1645
|
-
type: 'text',
|
|
1646
|
-
x: '2020-06-01',
|
|
1647
|
-
y: 25,
|
|
1648
|
-
text: 'Centered',
|
|
1649
|
-
offset: { dx: 0, dy: -10 },
|
|
1650
|
-
},
|
|
1651
|
-
]);
|
|
1652
|
-
const scales = computeScales(spec, chartArea, spec.data);
|
|
1653
|
-
|
|
1654
|
-
// Compute with and without SVG dimensions to verify no change
|
|
1655
|
-
const withClamping = computeAnnotations(
|
|
1656
|
-
spec,
|
|
1657
|
-
scales,
|
|
1658
|
-
chartArea,
|
|
1659
|
-
fullStrategy,
|
|
1660
|
-
false,
|
|
1661
|
-
[],
|
|
1662
|
-
svgDimensions,
|
|
1663
|
-
);
|
|
1664
|
-
const withoutClamping = computeAnnotations(spec, scales, chartArea, fullStrategy, false, []);
|
|
1665
|
-
|
|
1666
|
-
expect(withClamping).toHaveLength(1);
|
|
1667
|
-
expect(withoutClamping).toHaveLength(1);
|
|
1668
|
-
expect(withClamping[0].label!.x).toBe(withoutClamping[0].label!.x);
|
|
1669
|
-
expect(withClamping[0].label!.y).toBe(withoutClamping[0].label!.y);
|
|
1670
|
-
});
|
|
1671
|
-
});
|
|
1672
|
-
});
|