@opendata-ai/openchart-engine 6.28.5 → 7.0.0
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/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12297 -11338
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
- package/src/tables/__tests__/heatmap.test.ts +4 -27
- package/src/tables/heatmap.ts +6 -2
|
@@ -23,7 +23,14 @@ const specWithColor: NormalizedChartSpec = {
|
|
|
23
23
|
responsive: true,
|
|
24
24
|
theme: {},
|
|
25
25
|
darkMode: 'off',
|
|
26
|
+
// density: 'none' historically short-circuited the legend auto-suppression
|
|
27
|
+
// table, but post-fix that switch only governs end-of-line labels. Keep
|
|
28
|
+
// density: 'none' here AND set legend.show: true so the basic legend tests
|
|
29
|
+
// still test what they want — generic legend rendering with an explicit
|
|
30
|
+
// opt-in. Tests that need to exercise auto-suppression rules use
|
|
31
|
+
// `lineWithLabels` (density: 'auto', no explicit legend.show) further down.
|
|
26
32
|
labels: { density: 'none', format: '', prefix: '' },
|
|
33
|
+
legend: { show: true },
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
const specWithoutColor: NormalizedChartSpec = {
|
|
@@ -336,6 +343,9 @@ describe('computeLegend', () => {
|
|
|
336
343
|
const lineWithLabels: NormalizedChartSpec = {
|
|
337
344
|
...specWithColor,
|
|
338
345
|
labels: { density: 'auto', format: '', prefix: '' },
|
|
346
|
+
// Drop the explicit legend opt-in inherited from specWithColor so the
|
|
347
|
+
// auto-suppression truth table actually applies.
|
|
348
|
+
legend: undefined as unknown as NormalizedChartSpec['legend'],
|
|
339
349
|
};
|
|
340
350
|
|
|
341
351
|
it('suppresses legend for multi-series line chart with default labels', () => {
|
|
@@ -365,12 +375,26 @@ describe('computeLegend', () => {
|
|
|
365
375
|
expect(legend.entries).toHaveLength(3);
|
|
366
376
|
});
|
|
367
377
|
|
|
368
|
-
it('
|
|
378
|
+
it('still applies the truth table when labels density is none', () => {
|
|
379
|
+
// labels.density: 'none' is the legacy switch for end-of-line labels
|
|
380
|
+
// only — it must not short-circuit the legend / endpoint-column truth
|
|
381
|
+
// table. So a multi-series line with density: 'none' and no explicit
|
|
382
|
+
// legend.show still gets the legend auto-suppressed (cell 1).
|
|
369
383
|
const spec: NormalizedChartSpec = {
|
|
370
384
|
...lineWithLabels,
|
|
371
385
|
labels: { density: 'none', format: '', prefix: '' },
|
|
372
386
|
};
|
|
373
387
|
const legend = computeLegend(spec, fullStrategy, theme, chartArea);
|
|
388
|
+
expect(legend.entries).toHaveLength(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('preserves legend when density is none AND legend.show is explicitly true', () => {
|
|
392
|
+
const spec: NormalizedChartSpec = {
|
|
393
|
+
...lineWithLabels,
|
|
394
|
+
labels: { density: 'none', format: '', prefix: '' },
|
|
395
|
+
legend: { show: true },
|
|
396
|
+
};
|
|
397
|
+
const legend = computeLegend(spec, fullStrategy, theme, chartArea);
|
|
374
398
|
expect(legend.entries).toHaveLength(3);
|
|
375
399
|
});
|
|
376
400
|
|
|
@@ -379,17 +403,34 @@ describe('computeLegend', () => {
|
|
|
379
403
|
expect(legend.entries).toHaveLength(3);
|
|
380
404
|
});
|
|
381
405
|
|
|
382
|
-
it('preserves legend for stacked area chart (
|
|
406
|
+
it('preserves legend for explicitly stacked area chart (stack: "zero")', () => {
|
|
383
407
|
const areaSpec: NormalizedChartSpec = {
|
|
384
408
|
...lineWithLabels,
|
|
385
409
|
markType: 'area',
|
|
386
410
|
markDef: { type: 'area' },
|
|
411
|
+
encoding: {
|
|
412
|
+
x: { field: 'date', type: 'temporal' },
|
|
413
|
+
y: { field: 'value', type: 'quantitative', stack: 'zero' },
|
|
414
|
+
color: { field: 'country', type: 'nominal' },
|
|
415
|
+
},
|
|
387
416
|
};
|
|
388
417
|
const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
|
|
389
418
|
expect(legend.entries).toHaveLength(3);
|
|
390
419
|
});
|
|
391
420
|
|
|
392
|
-
it('suppresses legend for
|
|
421
|
+
it('suppresses legend for default (overlap) area chart with labels', () => {
|
|
422
|
+
// v6: area defaults to overlap. Endpoint labels identify series, so
|
|
423
|
+
// the legend auto-suppresses just like line charts.
|
|
424
|
+
const areaSpec: NormalizedChartSpec = {
|
|
425
|
+
...lineWithLabels,
|
|
426
|
+
markType: 'area',
|
|
427
|
+
markDef: { type: 'area' },
|
|
428
|
+
};
|
|
429
|
+
const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
|
|
430
|
+
expect(legend.entries).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('suppresses legend for explicit overlap area chart (stack: null)', () => {
|
|
393
434
|
const areaSpec: NormalizedChartSpec = {
|
|
394
435
|
...lineWithLabels,
|
|
395
436
|
markType: 'area',
|
|
@@ -85,6 +85,117 @@ describe('computeAnnotations', () => {
|
|
|
85
85
|
// Invalid date should result in null annotation (filtered out)
|
|
86
86
|
expect(annotations).toHaveLength(0);
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
describe('drop-line connector', () => {
|
|
90
|
+
it('produces a vertical line through the data point with end-anchored text on the left', () => {
|
|
91
|
+
const spec = makeSpec([
|
|
92
|
+
{
|
|
93
|
+
type: 'text',
|
|
94
|
+
x: '2020-01-01',
|
|
95
|
+
y: 20,
|
|
96
|
+
text: 'Peak',
|
|
97
|
+
connector: 'drop-line',
|
|
98
|
+
anchor: 'left',
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
102
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
103
|
+
|
|
104
|
+
const ann = annotations[0];
|
|
105
|
+
const c = ann.label?.connector;
|
|
106
|
+
expect(c).toBeDefined();
|
|
107
|
+
expect(c?.style).toBe('drop-line');
|
|
108
|
+
// Vertical line: from.x === to.x and equals the data point's x
|
|
109
|
+
expect(c?.from.x).toBe(c?.to.x);
|
|
110
|
+
const px = resolvePosition('2020-01-01', scales.x);
|
|
111
|
+
expect(c?.from.x).toBe(px);
|
|
112
|
+
// Label sits to the left of the data point with end anchor
|
|
113
|
+
expect(ann.label?.style.textAnchor).toBe('end');
|
|
114
|
+
expect(ann.label?.x).toBeLessThan(px ?? Infinity);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('flips a left-anchored label to the right when there is no room on the left', () => {
|
|
118
|
+
// Place a long label very near the chart-area left edge — left side is too tight
|
|
119
|
+
const spec = makeSpec([
|
|
120
|
+
{
|
|
121
|
+
type: 'text',
|
|
122
|
+
x: '2019-01-01',
|
|
123
|
+
y: 10,
|
|
124
|
+
text: 'A long annotation that needs lots of horizontal room',
|
|
125
|
+
connector: 'drop-line',
|
|
126
|
+
anchor: 'left',
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
130
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
131
|
+
|
|
132
|
+
const ann = annotations[0];
|
|
133
|
+
const px = resolvePosition('2019-01-01', scales.x);
|
|
134
|
+
expect(ann.label?.style.textAnchor).toBe('start');
|
|
135
|
+
expect(ann.label?.x).toBeGreaterThan(px ?? -Infinity);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('flips a right-anchored label to the left when there is no room on the right', () => {
|
|
139
|
+
const spec = makeSpec([
|
|
140
|
+
{
|
|
141
|
+
type: 'text',
|
|
142
|
+
x: '2022-01-01',
|
|
143
|
+
y: 40,
|
|
144
|
+
text: 'A long annotation that needs lots of horizontal room',
|
|
145
|
+
connector: 'drop-line',
|
|
146
|
+
anchor: 'right',
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
150
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
151
|
+
|
|
152
|
+
const ann = annotations[0];
|
|
153
|
+
const px = resolvePosition('2022-01-01', scales.x);
|
|
154
|
+
expect(ann.label?.style.textAnchor).toBe('end');
|
|
155
|
+
expect(ann.label?.x).toBeLessThan(px ?? Infinity);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('picks the wider side when neither side fits cleanly', () => {
|
|
159
|
+
// Tiny chart area + long label + data point near right edge means
|
|
160
|
+
// neither side can fit the full label. Left side has more room
|
|
161
|
+
// (x=50 ... px), so the auto-flip should land left.
|
|
162
|
+
const tinyArea: typeof chartArea = { x: 50, y: 20, width: 120, height: 200 };
|
|
163
|
+
const spec = makeSpec([
|
|
164
|
+
{
|
|
165
|
+
type: 'text',
|
|
166
|
+
x: '2022-01-01',
|
|
167
|
+
y: 40,
|
|
168
|
+
text: 'A genuinely long annotation label that exceeds both sides',
|
|
169
|
+
connector: 'drop-line',
|
|
170
|
+
anchor: 'right',
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
const scales = computeScales(spec, tinyArea, spec.data);
|
|
174
|
+
const annotations = computeAnnotations(spec, scales, tinyArea, fullStrategy);
|
|
175
|
+
|
|
176
|
+
// anchor=right but the right side is even narrower than left, so flip
|
|
177
|
+
expect(annotations[0].label?.style.textAnchor).toBe('end');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('preserves the resolved text-anchor on multi-line drop-line labels', () => {
|
|
181
|
+
const spec = makeSpec([
|
|
182
|
+
{
|
|
183
|
+
type: 'text',
|
|
184
|
+
x: '2020-01-01',
|
|
185
|
+
y: 20,
|
|
186
|
+
text: 'Line one\nLine two',
|
|
187
|
+
connector: 'drop-line',
|
|
188
|
+
anchor: 'left',
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
192
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
193
|
+
|
|
194
|
+
// Engine output retains end anchor; the renderer relies on this to
|
|
195
|
+
// not override it back to middle.
|
|
196
|
+
expect(annotations[0].label?.style.textAnchor).toBe('end');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
88
199
|
});
|
|
89
200
|
|
|
90
201
|
describe('range annotations', () => {
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the text-annotation resolver, focused on the `dot` and
|
|
3
|
+
* `subtitle` fields added in the multi-series area redesign.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Annotation, LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
9
|
+
import { computeScales } from '../../layout/scales';
|
|
10
|
+
import { computeAnnotations } from '../compute';
|
|
11
|
+
import {
|
|
12
|
+
DARK_DOT_FILL,
|
|
13
|
+
DARK_MUTED_TEXT_FILL,
|
|
14
|
+
DARK_TEXT_FILL,
|
|
15
|
+
DEFAULT_ANNOTATION_FONT_SIZE,
|
|
16
|
+
DEFAULT_DOT_RADIUS,
|
|
17
|
+
DEFAULT_DOT_STROKE_WIDTH,
|
|
18
|
+
DEFAULT_LINE_HEIGHT,
|
|
19
|
+
LIGHT_DOT_FILL,
|
|
20
|
+
LIGHT_MUTED_TEXT_FILL,
|
|
21
|
+
LIGHT_TEXT_FILL,
|
|
22
|
+
SUBTITLE_FONT_SIZE_RATIO,
|
|
23
|
+
SUBTITLE_GAP,
|
|
24
|
+
} from '../constants';
|
|
25
|
+
|
|
26
|
+
const chartArea: Rect = { x: 50, y: 20, width: 500, height: 300 };
|
|
27
|
+
|
|
28
|
+
const fullStrategy: LayoutStrategy = {
|
|
29
|
+
labelMode: 'all',
|
|
30
|
+
legendPosition: 'right',
|
|
31
|
+
annotationPosition: 'inline',
|
|
32
|
+
axisLabelDensity: 'full',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function makeSpec(annotations: Annotation[]): NormalizedChartSpec {
|
|
36
|
+
return {
|
|
37
|
+
markType: 'line',
|
|
38
|
+
markDef: { type: 'line' },
|
|
39
|
+
data: [
|
|
40
|
+
{ date: '2019-01-01', value: 10 },
|
|
41
|
+
{ date: '2020-01-01', value: 20 },
|
|
42
|
+
{ date: '2021-01-01', value: 30 },
|
|
43
|
+
{ date: '2022-01-01', value: 40 },
|
|
44
|
+
],
|
|
45
|
+
encoding: {
|
|
46
|
+
x: { field: 'date', type: 'temporal' },
|
|
47
|
+
y: { field: 'value', type: 'quantitative' },
|
|
48
|
+
},
|
|
49
|
+
chrome: {},
|
|
50
|
+
annotations,
|
|
51
|
+
responsive: true,
|
|
52
|
+
theme: {},
|
|
53
|
+
darkMode: 'off',
|
|
54
|
+
labels: { density: 'auto', format: '' },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('text annotation: dot', () => {
|
|
59
|
+
it('does not populate dot when not specified', () => {
|
|
60
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No dot' }]);
|
|
61
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
62
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
63
|
+
|
|
64
|
+
expect(annotations[0].dot).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('populates dot with default styling when dot: true (light mode)', () => {
|
|
68
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'With dot', dot: true }]);
|
|
69
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
70
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, false);
|
|
71
|
+
|
|
72
|
+
const dot = annotations[0].dot;
|
|
73
|
+
expect(dot).toBeDefined();
|
|
74
|
+
expect(dot!.radius).toBe(DEFAULT_DOT_RADIUS);
|
|
75
|
+
expect(dot!.strokeWidth).toBe(DEFAULT_DOT_STROKE_WIDTH);
|
|
76
|
+
expect(dot!.fill).toBe(LIGHT_DOT_FILL);
|
|
77
|
+
expect(dot!.stroke).toBe(LIGHT_TEXT_FILL);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('populates dot with dark-mode defaults when isDark is true', () => {
|
|
81
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Dark', dot: true }]);
|
|
82
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
83
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, true);
|
|
84
|
+
|
|
85
|
+
const dot = annotations[0].dot;
|
|
86
|
+
expect(dot).toBeDefined();
|
|
87
|
+
expect(dot!.fill).toBe(DARK_DOT_FILL);
|
|
88
|
+
expect(dot!.stroke).toBe(DARK_TEXT_FILL);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('respects user-supplied dot style overrides', () => {
|
|
92
|
+
const spec = makeSpec([
|
|
93
|
+
{
|
|
94
|
+
type: 'text',
|
|
95
|
+
x: '2020-01-01',
|
|
96
|
+
y: 20,
|
|
97
|
+
text: 'Custom dot',
|
|
98
|
+
dot: { radius: 8, fill: '#ff00ff', stroke: '#00ff00', strokeWidth: 4 },
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
102
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
103
|
+
|
|
104
|
+
const dot = annotations[0].dot;
|
|
105
|
+
expect(dot).toBeDefined();
|
|
106
|
+
expect(dot!.radius).toBe(8);
|
|
107
|
+
expect(dot!.fill).toBe('#ff00ff');
|
|
108
|
+
expect(dot!.stroke).toBe('#00ff00');
|
|
109
|
+
expect(dot!.strokeWidth).toBe(4);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('partial dot overrides merge with defaults', () => {
|
|
113
|
+
const spec = makeSpec([
|
|
114
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Partial', dot: { radius: 3 } },
|
|
115
|
+
]);
|
|
116
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
117
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
118
|
+
|
|
119
|
+
const dot = annotations[0].dot;
|
|
120
|
+
expect(dot).toBeDefined();
|
|
121
|
+
expect(dot!.radius).toBe(3);
|
|
122
|
+
// Other fields fall back to defaults.
|
|
123
|
+
expect(dot!.fill).toBe(LIGHT_DOT_FILL);
|
|
124
|
+
expect(dot!.stroke).toBe(LIGHT_TEXT_FILL);
|
|
125
|
+
expect(dot!.strokeWidth).toBe(DEFAULT_DOT_STROKE_WIDTH);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('dot coordinates match the connector "to" endpoint exactly', () => {
|
|
129
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Co-render', dot: true }]);
|
|
130
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
131
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
132
|
+
|
|
133
|
+
const resolved = annotations[0];
|
|
134
|
+
expect(resolved.dot).toBeDefined();
|
|
135
|
+
expect(resolved.label?.connector).toBeDefined();
|
|
136
|
+
expect(resolved.dot!.x).toBe(resolved.label!.connector!.to.x);
|
|
137
|
+
expect(resolved.dot!.y).toBe(resolved.label!.connector!.to.y);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('dot coordinates apply user connectorOffset.to', () => {
|
|
141
|
+
const withoutOffset = computeAnnotations(
|
|
142
|
+
makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'A', dot: true }]),
|
|
143
|
+
computeScales(makeSpec([]), chartArea, []),
|
|
144
|
+
chartArea,
|
|
145
|
+
fullStrategy,
|
|
146
|
+
);
|
|
147
|
+
const baseDot = withoutOffset[0].dot!;
|
|
148
|
+
|
|
149
|
+
const withOffsetSpec = makeSpec([
|
|
150
|
+
{
|
|
151
|
+
type: 'text',
|
|
152
|
+
x: '2020-01-01',
|
|
153
|
+
y: 20,
|
|
154
|
+
text: 'A',
|
|
155
|
+
dot: true,
|
|
156
|
+
connectorOffset: { to: { dx: 10, dy: -5 } },
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
const scales2 = computeScales(withOffsetSpec, chartArea, withOffsetSpec.data);
|
|
160
|
+
const withOffset = computeAnnotations(withOffsetSpec, scales2, chartArea, fullStrategy);
|
|
161
|
+
const offsetDot = withOffset[0].dot!;
|
|
162
|
+
|
|
163
|
+
// The user's connector offset shifts the data-side endpoint, so the dot
|
|
164
|
+
// should track it. Exact equality with connector.to must hold.
|
|
165
|
+
expect(offsetDot.x).toBe(withOffset[0].label!.connector!.to.x);
|
|
166
|
+
expect(offsetDot.y).toBe(withOffset[0].label!.connector!.to.y);
|
|
167
|
+
// And it should differ from the un-offset case (sanity check).
|
|
168
|
+
expect(offsetDot.x).not.toBe(baseDot.x);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('text annotation: subtitle', () => {
|
|
173
|
+
it('does not populate subtitle when not specified', () => {
|
|
174
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'No subtitle' }]);
|
|
175
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
176
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
177
|
+
|
|
178
|
+
expect(annotations[0].subtitle).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('populates subtitle with muted styling and smaller font (light mode)', () => {
|
|
182
|
+
const spec = makeSpec([
|
|
183
|
+
{
|
|
184
|
+
type: 'text',
|
|
185
|
+
x: '2020-01-01',
|
|
186
|
+
y: 20,
|
|
187
|
+
text: 'Primary',
|
|
188
|
+
subtitle: 'Methodology note',
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
192
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, false);
|
|
193
|
+
|
|
194
|
+
const sub = annotations[0].subtitle;
|
|
195
|
+
expect(sub).toBeDefined();
|
|
196
|
+
expect(sub!.text).toBe('Methodology note');
|
|
197
|
+
expect(sub!.style.fill).toBe(LIGHT_MUTED_TEXT_FILL);
|
|
198
|
+
expect(sub!.style.fontSize).toBe(
|
|
199
|
+
Math.round(DEFAULT_ANNOTATION_FONT_SIZE * SUBTITLE_FONT_SIZE_RATIO),
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('populates subtitle with dark-mode muted color when isDark', () => {
|
|
204
|
+
const spec = makeSpec([
|
|
205
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Primary', subtitle: 'Note' },
|
|
206
|
+
]);
|
|
207
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy, true);
|
|
209
|
+
|
|
210
|
+
expect(annotations[0].subtitle!.style.fill).toBe(DARK_MUTED_TEXT_FILL);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('positions subtitle directly below single-line primary text', () => {
|
|
214
|
+
const spec = makeSpec([
|
|
215
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'One line', subtitle: 'sub' },
|
|
216
|
+
]);
|
|
217
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
218
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
219
|
+
|
|
220
|
+
const label = annotations[0].label!;
|
|
221
|
+
const sub = annotations[0].subtitle!;
|
|
222
|
+
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
223
|
+
const expectedY = label.y + fontSize * DEFAULT_LINE_HEIGHT * 1 + SUBTITLE_GAP;
|
|
224
|
+
expect(sub.y).toBe(expectedY);
|
|
225
|
+
expect(sub.x).toBe(label.x);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('positions subtitle below all primary lines when text contains newlines', () => {
|
|
229
|
+
const spec = makeSpec([
|
|
230
|
+
{
|
|
231
|
+
type: 'text',
|
|
232
|
+
x: '2020-01-01',
|
|
233
|
+
y: 20,
|
|
234
|
+
text: 'Line one\nLine two\nLine three',
|
|
235
|
+
subtitle: 'after multi-line',
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
239
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
240
|
+
|
|
241
|
+
const label = annotations[0].label!;
|
|
242
|
+
const sub = annotations[0].subtitle!;
|
|
243
|
+
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
244
|
+
const expectedY = label.y + fontSize * DEFAULT_LINE_HEIGHT * 3 + SUBTITLE_GAP;
|
|
245
|
+
expect(sub.y).toBe(expectedY);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('subtitle font size scales with custom primary fontSize', () => {
|
|
249
|
+
const spec = makeSpec([
|
|
250
|
+
{
|
|
251
|
+
type: 'text',
|
|
252
|
+
x: '2020-01-01',
|
|
253
|
+
y: 20,
|
|
254
|
+
text: 'Big',
|
|
255
|
+
fontSize: 20,
|
|
256
|
+
subtitle: 'small',
|
|
257
|
+
},
|
|
258
|
+
]);
|
|
259
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
260
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
261
|
+
|
|
262
|
+
expect(annotations[0].subtitle!.style.fontSize).toBe(Math.round(20 * SUBTITLE_FONT_SIZE_RATIO));
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('text annotation: dot + subtitle co-resolution', () => {
|
|
267
|
+
it('both fields resolve simultaneously without interfering', () => {
|
|
268
|
+
const spec = makeSpec([
|
|
269
|
+
{
|
|
270
|
+
type: 'text',
|
|
271
|
+
x: '2020-01-01',
|
|
272
|
+
y: 20,
|
|
273
|
+
text: 'Big moment',
|
|
274
|
+
subtitle: 'Adjusted for inflation',
|
|
275
|
+
dot: true,
|
|
276
|
+
},
|
|
277
|
+
]);
|
|
278
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
279
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
280
|
+
|
|
281
|
+
const resolved = annotations[0];
|
|
282
|
+
expect(resolved.dot).toBeDefined();
|
|
283
|
+
expect(resolved.subtitle).toBeDefined();
|
|
284
|
+
// Connector + dot still co-render at the same point.
|
|
285
|
+
expect(resolved.dot!.x).toBe(resolved.label!.connector!.to.x);
|
|
286
|
+
expect(resolved.dot!.y).toBe(resolved.label!.connector!.to.y);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -22,6 +22,26 @@ export const DARK_TEXT_FILL = '#d1d5db';
|
|
|
22
22
|
export const LIGHT_REFLINE_STROKE = '#888888';
|
|
23
23
|
export const DARK_REFLINE_STROKE = '#9ca3af';
|
|
24
24
|
|
|
25
|
+
// Muted text fill for annotation subtitles (~60% perceived contrast vs primary)
|
|
26
|
+
export const LIGHT_MUTED_TEXT_FILL = '#6b7280';
|
|
27
|
+
export const DARK_MUTED_TEXT_FILL = '#9ca3af';
|
|
28
|
+
|
|
29
|
+
// Background fills used as the default "open ring" dot interior
|
|
30
|
+
export const LIGHT_DOT_FILL = '#ffffff';
|
|
31
|
+
export const DARK_DOT_FILL = '#0a0a0a';
|
|
32
|
+
|
|
33
|
+
/** Default annotation dot radius in pixels. */
|
|
34
|
+
export const DEFAULT_DOT_RADIUS = 5;
|
|
35
|
+
|
|
36
|
+
/** Default annotation dot stroke width in pixels. */
|
|
37
|
+
export const DEFAULT_DOT_STROKE_WIDTH = 2;
|
|
38
|
+
|
|
39
|
+
/** Vertical gap (px) between the primary annotation text and its subtitle. */
|
|
40
|
+
export const SUBTITLE_GAP = 2;
|
|
41
|
+
|
|
42
|
+
/** Subtitle font size multiplier (relative to primary annotation font size). */
|
|
43
|
+
export const SUBTITLE_FONT_SIZE_RATIO = 0.85;
|
|
44
|
+
|
|
25
45
|
/** Default label offset when using anchor directions. */
|
|
26
46
|
export const ANCHOR_OFFSET = 8;
|
|
27
47
|
|