@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12297 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +497 -0
  28. package/src/compile.ts +211 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +6 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +270 -33
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. package/src/sankey/compile-sankey.ts +2 -0
  47. package/src/tables/__tests__/heatmap.test.ts +4 -27
  48. 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('preserves legend when labels density is none', () => {
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 (default stacking)', () => {
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 unstacked area chart with labels', () => {
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