@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10
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 +124 -14
- package/dist/index.js +16254 -9417
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
- package/src/__tests__/annotation-behavior.test.ts +852 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/facet.test.ts +144 -0
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +69 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +228 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +318 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +121 -21
- package/src/compile/size-scale.ts +141 -0
- package/src/compile/spec-sugar.ts +767 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +415 -110
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/keys.test.ts +3 -9
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
- package/src/compiler/__tests__/validate.test.ts +258 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/keys.ts +5 -4
- package/src/compiler/normalize.ts +151 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +961 -32
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +1128 -0
- package/src/geo/compile-map.ts +1376 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +65 -0
- package/src/geo/types.ts +26 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +20 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +35 -6
- package/src/layout/dimensions.ts +139 -83
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +83 -33
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +123 -16
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "You draw it" (`youDrawIt`) engine tests: spec validation and geometry
|
|
3
|
+
* resolution through the public compile path. Asserts on the resolved
|
|
4
|
+
* ChartLayout.youDrawIt (samples, fromX, yInvert) and on validation errors
|
|
5
|
+
* for the line-only / single-series / from-required rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { compileChart } from '../compile';
|
|
11
|
+
|
|
12
|
+
const OPTS = { width: 600, height: 400 } as const;
|
|
13
|
+
|
|
14
|
+
function lineData() {
|
|
15
|
+
// 2000..2020 in steps of 5, single series, quantitative y.
|
|
16
|
+
return [2000, 2005, 2010, 2015, 2020].map((year, i) => ({
|
|
17
|
+
year: `${year}`,
|
|
18
|
+
value: 100 + i * 10,
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function baseSpec(overrides: Partial<ChartSpec> = {}): ChartSpec {
|
|
23
|
+
return {
|
|
24
|
+
mark: 'line',
|
|
25
|
+
data: lineData(),
|
|
26
|
+
encoding: {
|
|
27
|
+
x: { field: 'year', type: 'temporal' },
|
|
28
|
+
y: { field: 'value', type: 'quantitative' },
|
|
29
|
+
},
|
|
30
|
+
youDrawIt: { from: '2010' },
|
|
31
|
+
...overrides,
|
|
32
|
+
} as ChartSpec;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('youDrawIt resolution', () => {
|
|
36
|
+
it('resolves geometry with samples at/after `from` in data coordinates', () => {
|
|
37
|
+
const layout = compileChart(baseSpec(), OPTS);
|
|
38
|
+
const ydi = layout.youDrawIt;
|
|
39
|
+
expect(ydi).toBeDefined();
|
|
40
|
+
|
|
41
|
+
// fromX sits inside the plot area.
|
|
42
|
+
expect(ydi!.fromX).toBeGreaterThan(ydi!.area.x);
|
|
43
|
+
expect(ydi!.fromX).toBeLessThanOrEqual(ydi!.area.x + ydi!.area.width);
|
|
44
|
+
|
|
45
|
+
// Samples start at 2010 and run to the last x, carrying data x values.
|
|
46
|
+
const xValues = ydi!.samples.map((s) => s.xValue);
|
|
47
|
+
expect(xValues).toEqual(['2010', '2015', '2020']);
|
|
48
|
+
|
|
49
|
+
// Samples ascend by pixel x.
|
|
50
|
+
const pxs = ydi!.samples.map((s) => s.px);
|
|
51
|
+
expect([...pxs].sort((a, b) => a - b)).toEqual(pxs);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('resolves invertible y anchors so guesses map to data coordinates', () => {
|
|
55
|
+
const layout = compileChart(baseSpec(), OPTS);
|
|
56
|
+
const inv = layout.youDrawIt!.yInvert;
|
|
57
|
+
expect(inv).toBeDefined();
|
|
58
|
+
// Top pixel = larger data value, bottom pixel = smaller (y axis flips).
|
|
59
|
+
expect(inv!.topData).toBeGreaterThan(inv!.bottomData);
|
|
60
|
+
expect(inv!.topPixel).toBeLessThan(inv!.bottomPixel);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('applies default prompt and revealLabel', () => {
|
|
64
|
+
const layout = compileChart(baseSpec(), OPTS);
|
|
65
|
+
expect(layout.youDrawIt!.prompt).toBe('Draw your guess');
|
|
66
|
+
expect(layout.youDrawIt!.revealLabel).toBe('Show me');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('honors custom prompt and revealLabel', () => {
|
|
70
|
+
const layout = compileChart(
|
|
71
|
+
baseSpec({ youDrawIt: { from: '2010', prompt: 'Your turn', revealLabel: 'Reveal' } }),
|
|
72
|
+
OPTS,
|
|
73
|
+
);
|
|
74
|
+
expect(layout.youDrawIt!.prompt).toBe('Your turn');
|
|
75
|
+
expect(layout.youDrawIt!.revealLabel).toBe('Reveal');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('resolves a comparison line to pixel points', () => {
|
|
79
|
+
const layout = compileChart(
|
|
80
|
+
baseSpec({
|
|
81
|
+
youDrawIt: {
|
|
82
|
+
from: '2010',
|
|
83
|
+
comparisonLine: [
|
|
84
|
+
{ x: '2010', y: 120 },
|
|
85
|
+
{ x: '2020', y: 150 },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
OPTS,
|
|
90
|
+
);
|
|
91
|
+
const comp = layout.youDrawIt!.comparisonPoints;
|
|
92
|
+
expect(comp).toBeDefined();
|
|
93
|
+
expect(comp!.length).toBe(2);
|
|
94
|
+
expect(comp![0].x).toBeLessThan(comp![1].x);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('omits youDrawIt from the layout when not requested', () => {
|
|
98
|
+
const layout = compileChart(baseSpec({ youDrawIt: undefined }), OPTS);
|
|
99
|
+
expect(layout.youDrawIt).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('youDrawIt validation', () => {
|
|
104
|
+
it('rejects non-line marks', () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
compileChart(
|
|
107
|
+
baseSpec({ mark: 'bar', encoding: { x: { field: 'year' }, y: { field: 'value' } } }),
|
|
108
|
+
OPTS,
|
|
109
|
+
),
|
|
110
|
+
).toThrow(/only supported on line charts/i);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('requires from', () => {
|
|
114
|
+
expect(() => compileChart(baseSpec({ youDrawIt: {} as ChartSpec['youDrawIt'] }), OPTS)).toThrow(
|
|
115
|
+
/from is required/i,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('accepts from: 0 (falsy but valid)', () => {
|
|
120
|
+
const numericData = [0, 1, 2, 3].map((step) => ({ step: `${step}`, value: 10 + step }));
|
|
121
|
+
const layout = compileChart(
|
|
122
|
+
{
|
|
123
|
+
mark: 'line',
|
|
124
|
+
data: numericData,
|
|
125
|
+
encoding: {
|
|
126
|
+
x: { field: 'step', type: 'ordinal' },
|
|
127
|
+
y: { field: 'value', type: 'quantitative' },
|
|
128
|
+
},
|
|
129
|
+
youDrawIt: { from: 0 },
|
|
130
|
+
} as ChartSpec,
|
|
131
|
+
OPTS,
|
|
132
|
+
);
|
|
133
|
+
expect(layout.youDrawIt).toBeDefined();
|
|
134
|
+
expect(layout.youDrawIt!.samples.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects multi-series (categorical color with >1 value)', () => {
|
|
138
|
+
const multi = [
|
|
139
|
+
{ year: '2010', value: 10, region: 'A' },
|
|
140
|
+
{ year: '2011', value: 12, region: 'A' },
|
|
141
|
+
{ year: '2010', value: 20, region: 'B' },
|
|
142
|
+
{ year: '2011', value: 22, region: 'B' },
|
|
143
|
+
];
|
|
144
|
+
expect(() =>
|
|
145
|
+
compileChart(
|
|
146
|
+
{
|
|
147
|
+
mark: 'line',
|
|
148
|
+
data: multi,
|
|
149
|
+
encoding: {
|
|
150
|
+
x: { field: 'year', type: 'temporal' },
|
|
151
|
+
y: { field: 'value', type: 'quantitative' },
|
|
152
|
+
color: { field: 'region', type: 'nominal' },
|
|
153
|
+
},
|
|
154
|
+
youDrawIt: { from: '2010' },
|
|
155
|
+
} as ChartSpec,
|
|
156
|
+
OPTS,
|
|
157
|
+
),
|
|
158
|
+
).toThrow(/single-series/i);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -68,7 +68,11 @@ describe('computeAnnotations', () => {
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
it('text annotation label has a connector to the data point', () => {
|
|
71
|
-
|
|
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
|
+
]);
|
|
72
76
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
73
77
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
74
78
|
|
|
@@ -809,8 +813,16 @@ describe('computeAnnotations', () => {
|
|
|
809
813
|
});
|
|
810
814
|
|
|
811
815
|
describe('text annotation connector', () => {
|
|
812
|
-
it('connector is present by default', () => {
|
|
813
|
-
const spec = makeSpec([
|
|
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
|
+
]);
|
|
814
826
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
815
827
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
816
828
|
|
|
@@ -827,22 +839,31 @@ describe('computeAnnotations', () => {
|
|
|
827
839
|
expect(annotations[0].label!.connector).toBeUndefined();
|
|
828
840
|
});
|
|
829
841
|
|
|
830
|
-
it('connector "to"
|
|
831
|
-
const spec = makeSpec([
|
|
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
|
+
]);
|
|
832
852
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
833
853
|
|
|
834
854
|
const px = scales.x?.scale(new Date('2020-01-01')) as number;
|
|
835
855
|
const py = scales.y?.scale(20) as number;
|
|
836
856
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
837
857
|
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
expect(dist).
|
|
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);
|
|
846
867
|
});
|
|
847
868
|
});
|
|
848
869
|
|
|
@@ -1032,12 +1053,16 @@ describe('computeAnnotations', () => {
|
|
|
1032
1053
|
});
|
|
1033
1054
|
|
|
1034
1055
|
// -----------------------------------------------------------------
|
|
1035
|
-
// Connector origin
|
|
1056
|
+
// Connector origin (ray-box exit with standoff)
|
|
1036
1057
|
// -----------------------------------------------------------------
|
|
1037
1058
|
|
|
1038
1059
|
describe('connector origin auto-selection', () => {
|
|
1039
|
-
|
|
1040
|
-
|
|
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', () => {
|
|
1041
1066
|
const spec = makeSpec([
|
|
1042
1067
|
{
|
|
1043
1068
|
type: 'text',
|
|
@@ -1051,17 +1076,16 @@ describe('computeAnnotations', () => {
|
|
|
1051
1076
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1052
1077
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1053
1078
|
|
|
1054
|
-
const
|
|
1055
|
-
const connector = label
|
|
1056
|
-
const
|
|
1079
|
+
const ann = annotations[0];
|
|
1080
|
+
const connector = ann.label!.connector!;
|
|
1081
|
+
const bounds = ann.bounds!;
|
|
1057
1082
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
expect(connector.from.y).toBeCloseTo(
|
|
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);
|
|
1061
1086
|
});
|
|
1062
1087
|
|
|
1063
|
-
it('connector
|
|
1064
|
-
// Push label far above the data point
|
|
1088
|
+
it('connector exits below the label when the data point is below it', () => {
|
|
1065
1089
|
const spec = makeSpec([
|
|
1066
1090
|
{
|
|
1067
1091
|
type: 'text',
|
|
@@ -1075,19 +1099,15 @@ describe('computeAnnotations', () => {
|
|
|
1075
1099
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1076
1100
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1077
1101
|
|
|
1078
|
-
const
|
|
1079
|
-
const connector = label
|
|
1080
|
-
const
|
|
1081
|
-
const lineHeight = 1.3;
|
|
1082
|
-
const lines = label.text.split('\n');
|
|
1102
|
+
const ann = annotations[0];
|
|
1103
|
+
const connector = ann.label!.connector!;
|
|
1104
|
+
const bounds = ann.bounds!;
|
|
1083
1105
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
expect(connector.from.y).toBeCloseTo(bottomEdgeY, 0);
|
|
1106
|
+
expect(connector.exit).toBe('vertical');
|
|
1107
|
+
expect(connector.from.y).toBeCloseTo(bounds.y + bounds.height + INFLATE + STANDOFF, 0);
|
|
1087
1108
|
});
|
|
1088
1109
|
|
|
1089
|
-
it('connector
|
|
1090
|
-
// Push label far to the right of the data point
|
|
1110
|
+
it('connector exits left of the label when the data point is left of it', () => {
|
|
1091
1111
|
const spec = makeSpec([
|
|
1092
1112
|
{
|
|
1093
1113
|
type: 'text',
|
|
@@ -1101,16 +1121,18 @@ describe('computeAnnotations', () => {
|
|
|
1101
1121
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1102
1122
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1103
1123
|
|
|
1104
|
-
const
|
|
1105
|
-
const connector = label
|
|
1124
|
+
const ann = annotations[0];
|
|
1125
|
+
const connector = ann.label!.connector!;
|
|
1126
|
+
const bounds = ann.bounds!;
|
|
1106
1127
|
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
|
|
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);
|
|
1110
1133
|
});
|
|
1111
1134
|
|
|
1112
|
-
it('connector
|
|
1113
|
-
// Push label far to the left of the data point
|
|
1135
|
+
it('connector exits right of the label when the data point is right of it', () => {
|
|
1114
1136
|
const spec = makeSpec([
|
|
1115
1137
|
{
|
|
1116
1138
|
type: 'text',
|
|
@@ -1124,18 +1146,16 @@ describe('computeAnnotations', () => {
|
|
|
1124
1146
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1125
1147
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1126
1148
|
|
|
1127
|
-
const
|
|
1128
|
-
const connector = label
|
|
1149
|
+
const ann = annotations[0];
|
|
1150
|
+
const connector = ann.label!.connector!;
|
|
1151
|
+
const bounds = ann.bounds!;
|
|
1129
1152
|
|
|
1130
|
-
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
// textWidth = 14 chars * 12 * 0.55 * 1.0 = 92.4
|
|
1134
|
-
expect(connector.from.x).toBeGreaterThan(label.x + 50); // well past the center
|
|
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);
|
|
1135
1156
|
});
|
|
1136
1157
|
|
|
1137
|
-
it('
|
|
1138
|
-
// Multi-line text with label pushed below data point
|
|
1158
|
+
it('the connector origin always sits outside the annotation block', () => {
|
|
1139
1159
|
const spec = makeSpec([
|
|
1140
1160
|
{
|
|
1141
1161
|
type: 'text',
|
|
@@ -1149,17 +1169,15 @@ describe('computeAnnotations', () => {
|
|
|
1149
1169
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1150
1170
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1151
1171
|
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
expect(connector.from.x).toBeCloseTo(label.x, 0);
|
|
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);
|
|
1159
1178
|
});
|
|
1160
1179
|
|
|
1161
|
-
it('connector origin
|
|
1162
|
-
// Same data point, label pushed right vs left
|
|
1180
|
+
it('connector origin flips sides when the label moves to the other side', () => {
|
|
1163
1181
|
const specRight = makeSpec([
|
|
1164
1182
|
{
|
|
1165
1183
|
type: 'text',
|
|
@@ -1183,21 +1201,18 @@ describe('computeAnnotations', () => {
|
|
|
1183
1201
|
const scalesRight = computeScales(specRight, chartArea, specRight.data);
|
|
1184
1202
|
const scalesLeft = computeScales(specLeft, chartArea, specLeft.data);
|
|
1185
1203
|
|
|
1186
|
-
const
|
|
1187
|
-
const
|
|
1204
|
+
const annRight = computeAnnotations(specRight, scalesRight, chartArea, fullStrategy)[0];
|
|
1205
|
+
const annLeft = computeAnnotations(specLeft, scalesLeft, chartArea, fullStrategy)[0];
|
|
1188
1206
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
expect(fromRight.x).toBeCloseTo(labelRight.x, 0);
|
|
1196
|
-
// When label is to the left of data, connector exits from right edge (label.x + textWidth)
|
|
1197
|
-
expect(fromLeft.x).toBeGreaterThan(labelLeft.x + 30); // well past the left edge
|
|
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
|
+
);
|
|
1198
1213
|
});
|
|
1199
1214
|
|
|
1200
|
-
it('connectorOffset is still applied on top of auto-selected origin', () => {
|
|
1215
|
+
it('connectorOffset is still applied on top of the auto-selected origin', () => {
|
|
1201
1216
|
const specWithOffset = makeSpec([
|
|
1202
1217
|
{
|
|
1203
1218
|
type: 'text',
|
|
@@ -1231,8 +1246,9 @@ describe('computeAnnotations', () => {
|
|
|
1231
1246
|
expect(fromWith.y - fromWithout.y).toBeCloseTo(5, 0);
|
|
1232
1247
|
});
|
|
1233
1248
|
|
|
1234
|
-
|
|
1235
|
-
|
|
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', () => {
|
|
1236
1252
|
const spec = makeSpec([
|
|
1237
1253
|
{
|
|
1238
1254
|
type: 'text',
|
|
@@ -1247,13 +1263,13 @@ describe('computeAnnotations', () => {
|
|
|
1247
1263
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1248
1264
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1249
1265
|
|
|
1250
|
-
const
|
|
1251
|
-
const connector = label
|
|
1266
|
+
const ann = annotations[0];
|
|
1267
|
+
const connector = ann.label!.connector!;
|
|
1268
|
+
const bounds = ann.bounds!;
|
|
1252
1269
|
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
expect(connector.from.x).toBeCloseTo(label.x + 54.684, 1);
|
|
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);
|
|
1257
1273
|
});
|
|
1258
1274
|
});
|
|
1259
1275
|
|
|
@@ -1306,28 +1322,120 @@ describe('computeAnnotations', () => {
|
|
|
1306
1322
|
|
|
1307
1323
|
it('recomputes connector origin after nudging', () => {
|
|
1308
1324
|
const spec = makeSpec([
|
|
1309
|
-
{
|
|
1310
|
-
|
|
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
|
+
},
|
|
1311
1339
|
]);
|
|
1312
1340
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1313
1341
|
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
1314
1342
|
|
|
1315
|
-
const
|
|
1343
|
+
const nudged = annotations[1];
|
|
1344
|
+
const nudgedLabel = nudged.label!;
|
|
1316
1345
|
|
|
1317
1346
|
// The nudged annotation should still have a connector
|
|
1318
1347
|
expect(nudgedLabel.connector).toBeDefined();
|
|
1319
1348
|
|
|
1320
|
-
// The
|
|
1321
|
-
const
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
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);
|
|
1324
1381
|
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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,
|
|
1328
1402
|
);
|
|
1329
|
-
|
|
1330
|
-
|
|
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);
|
|
1331
1439
|
});
|
|
1332
1440
|
|
|
1333
1441
|
it('resolves three overlapping annotations without any collision', () => {
|
|
@@ -1428,7 +1536,14 @@ describe('computeAnnotations', () => {
|
|
|
1428
1536
|
it('preserves connector style when nudged away from obstacle', () => {
|
|
1429
1537
|
// connector: true means "straight line" - obstacle avoidance should not change it
|
|
1430
1538
|
const spec = makeSpec([
|
|
1431
|
-
{
|
|
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
|
+
},
|
|
1432
1547
|
]);
|
|
1433
1548
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
1434
1549
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeArrowheadPoints } from '../geometry';
|
|
3
|
+
|
|
4
|
+
describe('computeArrowheadPoints', () => {
|
|
5
|
+
it('produces a downward-pointing arrow for a downward tangent', () => {
|
|
6
|
+
const head = computeArrowheadPoints(100, 200, 0, 1);
|
|
7
|
+
expect(head.tip).toEqual({ x: 100, y: 200 });
|
|
8
|
+
// Base should be above the tip (tangent points down)
|
|
9
|
+
expect(head.baseLeft.y).toBeLessThan(head.tip.y);
|
|
10
|
+
expect(head.baseRight.y).toBeLessThan(head.tip.y);
|
|
11
|
+
// Base corners symmetric around the tangent axis
|
|
12
|
+
expect(head.baseLeft.x).toBeLessThan(head.tip.x);
|
|
13
|
+
expect(head.baseRight.x).toBeGreaterThan(head.tip.x);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('produces a rightward-pointing arrow for a rightward tangent', () => {
|
|
17
|
+
const head = computeArrowheadPoints(200, 100, 1, 0);
|
|
18
|
+
expect(head.tip).toEqual({ x: 200, y: 100 });
|
|
19
|
+
// Base should be to the left of the tip
|
|
20
|
+
expect(head.baseLeft.x).toBeLessThan(head.tip.x);
|
|
21
|
+
expect(head.baseRight.x).toBeLessThan(head.tip.x);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('respects custom length and halfWidth', () => {
|
|
25
|
+
const defaultHead = computeArrowheadPoints(100, 200, 0, 1);
|
|
26
|
+
const bigHead = computeArrowheadPoints(100, 200, 0, 1, 16, 8);
|
|
27
|
+
|
|
28
|
+
// Bigger arrow should have base further from tip
|
|
29
|
+
const defaultBaseDist = Math.abs(defaultHead.baseLeft.y - defaultHead.tip.y);
|
|
30
|
+
const bigBaseDist = Math.abs(bigHead.baseLeft.y - bigHead.tip.y);
|
|
31
|
+
expect(bigBaseDist).toBeGreaterThan(defaultBaseDist);
|
|
32
|
+
|
|
33
|
+
// Bigger arrow should have wider base
|
|
34
|
+
const defaultWidth = Math.abs(defaultHead.baseLeft.x - defaultHead.baseRight.x);
|
|
35
|
+
const bigWidth = Math.abs(bigHead.baseLeft.x - bigHead.baseRight.x);
|
|
36
|
+
expect(bigWidth).toBeGreaterThan(defaultWidth);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('handles diagonal tangent', () => {
|
|
40
|
+
const head = computeArrowheadPoints(100, 100, 1, 1);
|
|
41
|
+
expect(head.tip).toEqual({ x: 100, y: 100 });
|
|
42
|
+
// Base points should be behind the tip along the diagonal
|
|
43
|
+
const baseMidX = (head.baseLeft.x + head.baseRight.x) / 2;
|
|
44
|
+
const baseMidY = (head.baseLeft.y + head.baseRight.y) / 2;
|
|
45
|
+
expect(baseMidX).toBeLessThan(head.tip.x);
|
|
46
|
+
expect(baseMidY).toBeLessThan(head.tip.y);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses defaults of length=7, halfWidth=3.5', () => {
|
|
50
|
+
const head = computeArrowheadPoints(100, 200, 0, 1);
|
|
51
|
+
// For a pure downward tangent, base is 7px above tip
|
|
52
|
+
expect(head.baseLeft.y).toBeCloseTo(193);
|
|
53
|
+
expect(head.baseRight.y).toBeCloseTo(193);
|
|
54
|
+
// Base corners are 3.5px to each side
|
|
55
|
+
expect(head.baseLeft.x).toBeCloseTo(96.5);
|
|
56
|
+
expect(head.baseRight.x).toBeCloseTo(103.5);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles zero-length tangent gracefully', () => {
|
|
60
|
+
const head = computeArrowheadPoints(100, 100, 0, 0);
|
|
61
|
+
expect(head.tip).toEqual({ x: 100, y: 100 });
|
|
62
|
+
// Should not produce NaN
|
|
63
|
+
expect(Number.isFinite(head.baseLeft.x)).toBe(true);
|
|
64
|
+
expect(Number.isFinite(head.baseLeft.y)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|