@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
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Truth-table tests for the centralized legend / endpoint / end-of-line label
3
+ * suppression helper. Covers all 8 cells of the matrix described in
4
+ * `suppression.ts`, plus the special cases (single-series, stacked area,
5
+ * compact strategy).
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import type { NormalizedChartSpec } from '../../compiler/types';
11
+ import { countColorSeries, resolveSuppression } from '../suppression';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Spec factory
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function makeMultiSeriesLineSpec(
18
+ overrides: Partial<NormalizedChartSpec> = {},
19
+ ): NormalizedChartSpec {
20
+ return {
21
+ markType: 'line',
22
+ markDef: { type: 'line' },
23
+ data: [
24
+ { date: '2020', value: 10, country: 'US' },
25
+ { date: '2021', value: 20, country: 'US' },
26
+ { date: '2020', value: 5, country: 'UK' },
27
+ { date: '2021', value: 15, country: 'UK' },
28
+ ],
29
+ encoding: {
30
+ x: { field: 'date', type: 'temporal' },
31
+ y: { field: 'value', type: 'quantitative' },
32
+ color: { field: 'country', type: 'nominal' },
33
+ },
34
+ chrome: {},
35
+ annotations: [],
36
+ responsive: true,
37
+ theme: {},
38
+ darkMode: 'off',
39
+ labels: { density: 'auto', format: '', prefix: '' },
40
+ hiddenSeries: [],
41
+ seriesStyles: {},
42
+ watermark: true,
43
+ display: 'full',
44
+ userExplicit: {
45
+ chrome: false,
46
+ legend: false,
47
+ endpointLabels: false,
48
+ xAxis: false,
49
+ yAxis: false,
50
+ labels: false,
51
+ animation: false,
52
+ watermark: false,
53
+ crosshair: false,
54
+ },
55
+ ...overrides,
56
+ };
57
+ }
58
+
59
+ const baseCtx = {
60
+ seriesCount: 2,
61
+ labelsHiddenByStrategy: false,
62
+ labelsDensityNone: false,
63
+ };
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Tests
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('countColorSeries', () => {
70
+ it('returns the number of distinct values in the color field', () => {
71
+ const spec = makeMultiSeriesLineSpec();
72
+ expect(countColorSeries(spec)).toBe(2);
73
+ });
74
+
75
+ it('returns 0 when no color encoding is set', () => {
76
+ const spec = makeMultiSeriesLineSpec({
77
+ encoding: {
78
+ x: { field: 'date', type: 'temporal' },
79
+ y: { field: 'value', type: 'quantitative' },
80
+ },
81
+ });
82
+ expect(countColorSeries(spec)).toBe(0);
83
+ });
84
+
85
+ it('returns 0 when color is quantitative', () => {
86
+ const spec = makeMultiSeriesLineSpec({
87
+ encoding: {
88
+ x: { field: 'date', type: 'temporal' },
89
+ y: { field: 'value', type: 'quantitative' },
90
+ color: { field: 'value', type: 'quantitative' },
91
+ },
92
+ });
93
+ expect(countColorSeries(spec)).toBe(0);
94
+ });
95
+ });
96
+
97
+ describe('resolveSuppression - 8-cell truth table', () => {
98
+ // Cell 1: legend unset, endpointLabels unset
99
+ // -> traditional legend hidden, endpoint column shown, end-of-line hidden
100
+ it('cell 1 (unset, unset): hides legend, shows endpoint column', () => {
101
+ const spec = makeMultiSeriesLineSpec();
102
+ const result = resolveSuppression(spec, baseCtx);
103
+ expect(result.showTraditionalLegend).toBe(false);
104
+ expect(result.showEndpointLabels).toBe(true);
105
+ expect(result.showEndOfLineLabels).toBe(false);
106
+ });
107
+
108
+ // Cell 2: legend: { show: true }, endpointLabels unset
109
+ // -> traditional legend shown, endpoint column shown, end-of-line hidden
110
+ it('cell 2 (legend: true, unset): shows both legend and endpoint column', () => {
111
+ const spec = makeMultiSeriesLineSpec({ legend: { show: true } });
112
+ const result = resolveSuppression(spec, baseCtx);
113
+ expect(result.showTraditionalLegend).toBe(true);
114
+ expect(result.showEndpointLabels).toBe(true);
115
+ expect(result.showEndOfLineLabels).toBe(false);
116
+ });
117
+
118
+ // Cell 3: legend unset, endpointLabels: false
119
+ // -> traditional legend shown (auto-revoked), column hidden, end-of-line hidden
120
+ it('cell 3 (unset, endpointLabels: false): auto-revokes legend, hides column', () => {
121
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: false });
122
+ const result = resolveSuppression(spec, baseCtx);
123
+ expect(result.showTraditionalLegend).toBe(true);
124
+ expect(result.showEndpointLabels).toBe(false);
125
+ expect(result.showEndOfLineLabels).toBe(false);
126
+ });
127
+
128
+ // Cell 4: legend: { show: false }, endpointLabels: false
129
+ // -> all legend surfaces off, end-of-line labels are last resort
130
+ it('cell 4 (legend: false, endpointLabels: false): only end-of-line labels show', () => {
131
+ const spec = makeMultiSeriesLineSpec({
132
+ legend: { show: false },
133
+ endpointLabels: false,
134
+ });
135
+ const result = resolveSuppression(spec, baseCtx);
136
+ expect(result.showTraditionalLegend).toBe(false);
137
+ expect(result.showEndpointLabels).toBe(false);
138
+ expect(result.showEndOfLineLabels).toBe(true);
139
+ });
140
+
141
+ // Cell 5: legend: { show: true }, endpointLabels: false
142
+ // -> legend shown, column hidden, end-of-line hidden
143
+ it('cell 5 (legend: true, endpointLabels: false): legend only', () => {
144
+ const spec = makeMultiSeriesLineSpec({
145
+ legend: { show: true },
146
+ endpointLabels: false,
147
+ });
148
+ const result = resolveSuppression(spec, baseCtx);
149
+ expect(result.showTraditionalLegend).toBe(true);
150
+ expect(result.showEndpointLabels).toBe(false);
151
+ expect(result.showEndOfLineLabels).toBe(false);
152
+ });
153
+
154
+ // Cell 6: legend: { show: false }, endpointLabels: true
155
+ // -> legend off, column on, end-of-line hidden
156
+ it('cell 6 (legend: false, endpointLabels: true): column only', () => {
157
+ const spec = makeMultiSeriesLineSpec({
158
+ legend: { show: false },
159
+ endpointLabels: true,
160
+ });
161
+ const result = resolveSuppression(spec, baseCtx);
162
+ expect(result.showTraditionalLegend).toBe(false);
163
+ expect(result.showEndpointLabels).toBe(true);
164
+ expect(result.showEndOfLineLabels).toBe(false);
165
+ });
166
+
167
+ // Cell 7: legend: { show: true }, endpointLabels: true
168
+ // -> both surfaces shown, end-of-line hidden
169
+ it('cell 7 (legend: true, endpointLabels: true): both shown', () => {
170
+ const spec = makeMultiSeriesLineSpec({
171
+ legend: { show: true },
172
+ endpointLabels: true,
173
+ });
174
+ const result = resolveSuppression(spec, baseCtx);
175
+ expect(result.showTraditionalLegend).toBe(true);
176
+ expect(result.showEndpointLabels).toBe(true);
177
+ expect(result.showEndOfLineLabels).toBe(false);
178
+ });
179
+
180
+ // Cell 8 (the implicit eighth cell): unset + endpointLabels: true
181
+ // matches cell 1's column-on behavior, legend auto-suppressed.
182
+ it('cell 8 (unset, endpointLabels: true): legend auto-suppressed, column on', () => {
183
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: true });
184
+ const result = resolveSuppression(spec, baseCtx);
185
+ expect(result.showTraditionalLegend).toBe(false);
186
+ expect(result.showEndpointLabels).toBe(true);
187
+ expect(result.showEndOfLineLabels).toBe(false);
188
+ });
189
+ });
190
+
191
+ describe('resolveSuppression - special cases', () => {
192
+ it('single-series chart: column always off, legend follows its own rules', () => {
193
+ const spec = makeMultiSeriesLineSpec();
194
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 1 });
195
+ expect(result.showEndpointLabels).toBe(false);
196
+ expect(result.showEndOfLineLabels).toBe(false);
197
+ expect(result.showTraditionalLegend).toBe(true);
198
+ });
199
+
200
+ it('non-line/area mark: column always off', () => {
201
+ const spec = makeMultiSeriesLineSpec({ markType: 'bar' });
202
+ const result = resolveSuppression(spec, baseCtx);
203
+ expect(result.showEndpointLabels).toBe(false);
204
+ expect(result.showEndOfLineLabels).toBe(false);
205
+ });
206
+
207
+ it('compact strategy (labelsHiddenByStrategy): column off, end-of-line off', () => {
208
+ const spec = makeMultiSeriesLineSpec();
209
+ const result = resolveSuppression(spec, {
210
+ ...baseCtx,
211
+ labelsHiddenByStrategy: true,
212
+ });
213
+ expect(result.showEndpointLabels).toBe(false);
214
+ expect(result.showEndOfLineLabels).toBe(false);
215
+ });
216
+
217
+ it('labels.density === none: scoped to end-of-line labels only (column + legend follow truth table)', () => {
218
+ // labels.density: 'none' is the legacy switch for end-of-line labels.
219
+ // It must NOT suppress the endpoint column or the traditional legend —
220
+ // those follow the truth table independently. End-of-line labels stay off.
221
+ const spec = makeMultiSeriesLineSpec();
222
+ const result = resolveSuppression(spec, { ...baseCtx, labelsDensityNone: true });
223
+ expect(result.showEndpointLabels).toBe(true);
224
+ expect(result.showEndOfLineLabels).toBe(false);
225
+ // Cell 1 (legend unset, endpointLabels unset): traditional legend hidden.
226
+ expect(result.showTraditionalLegend).toBe(false);
227
+ });
228
+
229
+ it('labels.density === none + legend.show: true: legend stays shown', () => {
230
+ const spec = makeMultiSeriesLineSpec({ legend: { show: true } });
231
+ const result = resolveSuppression(spec, { ...baseCtx, labelsDensityNone: true });
232
+ expect(result.showTraditionalLegend).toBe(true);
233
+ expect(result.showEndpointLabels).toBe(true);
234
+ expect(result.showEndOfLineLabels).toBe(false);
235
+ });
236
+
237
+ it('stacked area: legend shown by default, column off', () => {
238
+ const spec = makeMultiSeriesLineSpec({
239
+ markType: 'area',
240
+ markDef: { type: 'area' },
241
+ encoding: {
242
+ x: { field: 'date', type: 'temporal' },
243
+ y: { field: 'value', type: 'quantitative', stack: 'zero' },
244
+ color: { field: 'country', type: 'nominal' },
245
+ },
246
+ });
247
+ const result = resolveSuppression(spec, baseCtx);
248
+ expect(result.showTraditionalLegend).toBe(true);
249
+ expect(result.showEndpointLabels).toBe(false);
250
+ expect(result.showEndOfLineLabels).toBe(false);
251
+ });
252
+
253
+ it('stacked area with explicit endpointLabels: true: column shown', () => {
254
+ const spec = makeMultiSeriesLineSpec({
255
+ markType: 'area',
256
+ markDef: { type: 'area' },
257
+ encoding: {
258
+ x: { field: 'date', type: 'temporal' },
259
+ y: { field: 'value', type: 'quantitative', stack: 'zero' },
260
+ color: { field: 'country', type: 'nominal' },
261
+ },
262
+ endpointLabels: true,
263
+ });
264
+ const result = resolveSuppression(spec, baseCtx);
265
+ expect(result.showEndpointLabels).toBe(true);
266
+ });
267
+
268
+ it('overlap area (no stack): runs through 8-cell table', () => {
269
+ const spec = makeMultiSeriesLineSpec({
270
+ markType: 'area',
271
+ markDef: { type: 'area' },
272
+ });
273
+ const result = resolveSuppression(spec, baseCtx);
274
+ // Cell 1 behavior: legend hidden, column on
275
+ expect(result.showTraditionalLegend).toBe(false);
276
+ expect(result.showEndpointLabels).toBe(true);
277
+ });
278
+
279
+ it('endpointLabels: { show: false } counts as explicit off', () => {
280
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: { show: false } });
281
+ const result = resolveSuppression(spec, baseCtx);
282
+ expect(result.showEndpointLabels).toBe(false);
283
+ // Auto-revoke legend
284
+ expect(result.showTraditionalLegend).toBe(true);
285
+ });
286
+
287
+ it('endpointLabels: { width: 120 } counts as explicit on', () => {
288
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: { width: 120 } });
289
+ const result = resolveSuppression(spec, baseCtx);
290
+ expect(result.showEndpointLabels).toBe(true);
291
+ // Legend auto-suppressed
292
+ expect(result.showTraditionalLegend).toBe(false);
293
+ });
294
+ });
@@ -23,6 +23,7 @@ import type {
23
23
  import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
24
24
 
25
25
  import type { NormalizedChartSpec } from '../compiler/types';
26
+ import { countColorSeries, resolveSuppression } from './suppression';
26
27
  import { ENTRY_GAP, ENTRY_GAP_COMPACT, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -42,6 +43,25 @@ const TOP_LEGEND_MAX_ROWS = 2;
42
43
  // Helpers
43
44
  // ---------------------------------------------------------------------------
44
45
 
46
+ /**
47
+ * Resolve the constant fields every CategoricalLegendLayout return shares
48
+ * (swatch geometry + chip fill). Pulled out so each early-return branch
49
+ * doesn't re-spell the same five fields.
50
+ */
51
+ function categoricalDefaults(theme: ResolvedTheme): {
52
+ swatchSize: number;
53
+ swatchGap: number;
54
+ entryGap: number;
55
+ swatchChipFill: string;
56
+ } {
57
+ return {
58
+ swatchSize: SWATCH_SIZE,
59
+ swatchGap: SWATCH_GAP,
60
+ entryGap: ENTRY_GAP,
61
+ swatchChipFill: theme.colors.annotationFill,
62
+ };
63
+ }
64
+
45
65
  /** Determine the swatch shape based on mark type. */
46
66
  function swatchShapeForType(markType: string): LegendEntry['shape'] {
47
67
  switch (markType) {
@@ -84,6 +104,10 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
84
104
  : dataValues;
85
105
 
86
106
  const excludeSet = new Set(spec.legend?.exclude ?? []);
107
+ // Hidden series stay in the legend (dimmed) so the user can toggle them
108
+ // back on. `active: false` is the renderer's signal to apply the dimmed
109
+ // visual state.
110
+ const hiddenSet = new Set(spec.hiddenSeries);
87
111
 
88
112
  return uniqueValues
89
113
  .map((value, i) => {
@@ -98,7 +122,7 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
98
122
  label: value,
99
123
  color: palette[colorIndex % palette.length],
100
124
  shape,
101
- active: true,
125
+ active: !hiddenSet.has(value),
102
126
  };
103
127
  })
104
128
  .filter((entry) => !excludeSet.has(entry.label));
@@ -151,6 +175,7 @@ export function computeLegend(
151
175
  // Legend explicitly hidden via show: false, or height strategy says no legend
152
176
  if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
153
177
  return {
178
+ type: 'categorical' as const,
154
179
  position: 'top',
155
180
  entries: [],
156
181
  bounds: { x: 0, y: 0, width: 0, height: 0 },
@@ -161,43 +186,24 @@ export function computeLegend(
161
186
  fill: theme.colors.text,
162
187
  lineHeight: 1.3,
163
188
  },
164
- swatchSize: SWATCH_SIZE,
165
- swatchGap: SWATCH_GAP,
166
- entryGap: ENTRY_GAP,
189
+ ...categoricalDefaults(theme),
167
190
  };
168
191
  }
169
192
 
170
193
  let entries = extractColorEntries(spec, theme);
171
194
 
172
- // Auto-suppress legend when endpoint labels identify series on line/area charts.
173
- // Guards: keep legend at compact breakpoints (labels hidden), for stacked areas
174
- // (endpoint labels overlap), and when user has configured any legend property
175
- // (position, columns, maxRows, etc.) any explicit legend config signals intent
176
- // to show a legend, not just show: true.
177
- const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
178
- const hasLabels = spec.labels.density !== 'none';
179
- const labelsWillRender = strategy.labelMode !== 'none';
180
- const hasColorEncoding = spec.encoding.color != null;
181
- // Legend is "forced" when the user set show: true OR specified any legend config
182
- // other than show: false. Vega-Lite convention: legend is shown by default for
183
- // multi-series charts; auto-suppression only fires when no legend config is present.
184
- const userConfiguredLegend =
185
- spec.legend != null &&
186
- Object.keys(spec.legend).some(
187
- (k) => k !== 'show' || spec.legend![k as keyof typeof spec.legend] !== false,
188
- );
189
- const legendNotForced = !userConfiguredLegend;
190
-
191
- if (isLineOrArea && hasLabels && labelsWillRender && hasColorEncoding && legendNotForced) {
192
- const isArea = spec.markType === 'area';
193
- const quantChannel =
194
- spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x;
195
- const stackValue = quantChannel?.stack;
196
- const isStacked = stackValue !== null && stackValue !== false;
197
-
198
- if (!isArea || !isStacked) {
199
- entries = [];
200
- }
195
+ // Consult the shared suppression truth table so the legend, endpoint column,
196
+ // and end-of-line labels stay in sync. The helper returns
197
+ // `showTraditionalLegend: false` when the endpoint column auto-takes over
198
+ // for ≥2-series line/area charts (and the user hasn't forced the legend on).
199
+ const seriesCount = countColorSeries(spec);
200
+ const suppression = resolveSuppression(spec, {
201
+ seriesCount,
202
+ labelsHiddenByStrategy: strategy.labelMode === 'none',
203
+ labelsDensityNone: spec.labels.density === 'none',
204
+ });
205
+ if (!suppression.showTraditionalLegend) {
206
+ entries = [];
201
207
  }
202
208
 
203
209
  const labelStyle: TextStyle = {
@@ -215,13 +221,12 @@ export function computeLegend(
215
221
  // No entries = empty legend with no space
216
222
  if (entries.length === 0) {
217
223
  return {
224
+ type: 'categorical' as const,
218
225
  position: resolvedPosition,
219
226
  entries: [],
220
227
  bounds: { x: 0, y: 0, width: 0, height: 0 },
221
228
  labelStyle,
222
- swatchSize: SWATCH_SIZE,
223
- swatchGap: SWATCH_GAP,
224
- entryGap: ENTRY_GAP,
229
+ ...categoricalDefaults(theme),
225
230
  };
226
231
  }
227
232
 
@@ -268,6 +273,7 @@ export function computeLegend(
268
273
  const offsetDy = spec.legend?.offset?.dy ?? 0;
269
274
 
270
275
  return {
276
+ type: 'categorical' as const,
271
277
  position: resolvedPosition,
272
278
  entries,
273
279
  bounds: {
@@ -277,8 +283,10 @@ export function computeLegend(
277
283
  height: clampedHeight,
278
284
  },
279
285
  labelStyle,
280
- swatchSize: SWATCH_SIZE,
281
- swatchGap: SWATCH_GAP,
286
+ ...categoricalDefaults(theme),
287
+ // Right-positioned legends pack rows tighter than the default entryGap
288
+ // because each row is its own swatch+label and the gap controls
289
+ // vertical breathing room rather than horizontal spacing.
282
290
  entryGap: 4,
283
291
  };
284
292
  }
@@ -343,6 +351,7 @@ export function computeLegend(
343
351
  const offsetDy = spec.legend?.offset?.dy ?? 0;
344
352
 
345
353
  return {
354
+ type: 'categorical' as const,
346
355
  position: resolvedPosition,
347
356
  entries,
348
357
  bounds: {
@@ -355,8 +364,9 @@ export function computeLegend(
355
364
  height: legendHeight,
356
365
  },
357
366
  labelStyle,
358
- swatchSize: SWATCH_SIZE,
359
- swatchGap: SWATCH_GAP,
367
+ ...categoricalDefaults(theme),
368
+ // Top/bottom legends honor the compact-viewport-aware entry gap so
369
+ // chips stay readable on narrow widths.
360
370
  entryGap: effectiveEntryGap,
361
371
  };
362
372
  }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Single source of truth for legend / endpoint-column / end-of-line label
3
+ * suppression on multi-series line/area charts.
4
+ *
5
+ * Three independent toggles drive the chart's series identification surface:
6
+ *
7
+ * - `legend.show` — the traditional swatch legend (top/bottom/right).
8
+ * - `endpointLabels` — the right-side per-series column with last value.
9
+ * - end-of-line labels — the legacy series-name label glued to the last point.
10
+ *
11
+ * For ≥2-series line/area charts the resolution table is:
12
+ *
13
+ * | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels |
14
+ * |--|--|--|--|--|
15
+ * | unset | unset | hidden (auto-suppressed) | shown | hidden |
16
+ * | true | unset | shown | shown | hidden |
17
+ * | unset | false | shown (auto-revoked) | hidden | hidden |
18
+ * | false | false | hidden | hidden | shown |
19
+ * | true | false | shown | hidden | hidden |
20
+ * | false | true | hidden | shown | hidden |
21
+ * | true | true | shown | shown | hidden |
22
+ *
23
+ * Single-series and non-line/area charts: column always hidden, legend hidden
24
+ * (single-series rule), end-of-line labels never render.
25
+ *
26
+ * Rule of thumb: end-of-line labels are the fallback identifier of last resort.
27
+ * They render only when neither the traditional legend nor the endpoint column
28
+ * is showing. The endpoint column "owns" the right zone and absorbs the
29
+ * suppression role the traditional legend used to have for end-of-line labels.
30
+ */
31
+
32
+ import type { Encoding } from '@opendata-ai/openchart-core';
33
+
34
+ import type { NormalizedChartSpec } from '../compiler/types';
35
+
36
+ /** Result of the suppression resolution. */
37
+ export interface SuppressionResult {
38
+ /** Whether the traditional swatch legend should render. */
39
+ showTraditionalLegend: boolean;
40
+ /** Whether the right-side endpoint column should render. */
41
+ showEndpointLabels: boolean;
42
+ /** Whether the legacy end-of-line labels should render. */
43
+ showEndOfLineLabels: boolean;
44
+ }
45
+
46
+ /** Inputs needed by the resolution helper. */
47
+ export interface SuppressionContext {
48
+ /** Number of distinct series (color-encoding domain values). 0 if no color encoding. */
49
+ seriesCount: number;
50
+ /** True when responsive strategy strips inline labels (compact breakpoint). */
51
+ labelsHiddenByStrategy: boolean;
52
+ /** True when spec.labels.density === 'none'. */
53
+ labelsDensityNone: boolean;
54
+ }
55
+
56
+ /**
57
+ * Did the user write `legend.show: true` or any non-`show` legend config?
58
+ * (Used by the truth table to decide between the "unset" and "true" rows.)
59
+ */
60
+ function legendShownExplicitly(spec: NormalizedChartSpec): boolean {
61
+ if (spec.legend == null) return false;
62
+ if (spec.legend.show === true) return true;
63
+ // Any non-`show` legend property counts as opt-in to render the legend.
64
+ return Object.keys(spec.legend).some((k) => k !== 'show');
65
+ }
66
+
67
+ /** Did the user write `legend.show: false` explicitly? */
68
+ function legendHiddenExplicitly(spec: NormalizedChartSpec): boolean {
69
+ return spec.legend?.show === false;
70
+ }
71
+
72
+ /**
73
+ * Did the user explicitly set endpointLabels: true?
74
+ * Either the bare boolean or `{ show: true }` or any other config keys count.
75
+ */
76
+ function endpointLabelsExplicitlyOn(spec: NormalizedChartSpec): boolean {
77
+ const ep = spec.endpointLabels;
78
+ if (ep === true) return true;
79
+ if (ep === false || ep == null) return false;
80
+ // Object form: explicit when show: true or any non-show key set.
81
+ if (ep.show === true) return true;
82
+ if (ep.show === false) return false;
83
+ return Object.keys(ep).some((k) => k !== 'show');
84
+ }
85
+
86
+ /** Did the user explicitly set endpointLabels: false (or { show: false })? */
87
+ function endpointLabelsExplicitlyOff(spec: NormalizedChartSpec): boolean {
88
+ const ep = spec.endpointLabels;
89
+ if (ep === false) return true;
90
+ if (typeof ep === 'object' && ep != null && ep.show === false) return true;
91
+ return false;
92
+ }
93
+
94
+ /**
95
+ * Resolve the three booleans from spec + context.
96
+ *
97
+ * Pure: no theme, no chart area, no scales. Both `computeLegend` and
98
+ * `computeLineLabels` and `computeEndpointLabels` share this exact logic.
99
+ */
100
+ export function resolveSuppression(
101
+ spec: NormalizedChartSpec,
102
+ ctx: SuppressionContext,
103
+ ): SuppressionResult {
104
+ const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
105
+ const hasColorEncoding = spec.encoding.color != null;
106
+ const isMultiSeries = isLineOrArea && hasColorEncoding && ctx.seriesCount >= 2;
107
+
108
+ // Single-series, non-line/area, or compact strategy: never show endpoint
109
+ // column or end-of-line labels. Legend follows its own rules elsewhere.
110
+ //
111
+ // Note: `labelsDensityNone` is intentionally NOT in this short-circuit.
112
+ // `labels.density: 'none'` is the legacy switch for end-of-line labels and
113
+ // must not affect the endpoint column or the traditional legend (those
114
+ // are independent concerns governed by the truth table below).
115
+ if (!isMultiSeries || ctx.labelsHiddenByStrategy) {
116
+ return {
117
+ // Defer the legend's own show/hide rules to computeLegend; this helper
118
+ // doesn't override the existing legend behavior for non-multi-series.
119
+ showTraditionalLegend: !legendHiddenExplicitly(spec),
120
+ showEndpointLabels: false,
121
+ showEndOfLineLabels: false,
122
+ };
123
+ }
124
+
125
+ // Stacked-area branch: gradient fills overlap, so endpoint labels at the
126
+ // last data point would land on top of each other. Don't auto-enable the
127
+ // column for stacked areas. Users can still force it with `endpointLabels: true`.
128
+ const isArea = spec.markType === 'area';
129
+ const quantChannel = (
130
+ spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x
131
+ ) as Encoding[keyof Encoding] | undefined;
132
+ const stackValue = quantChannel && 'stack' in quantChannel ? quantChannel.stack : undefined;
133
+ const isStacked = isArea
134
+ ? stackValue === true ||
135
+ stackValue === 'zero' ||
136
+ stackValue === 'normalize' ||
137
+ stackValue === 'center'
138
+ : false;
139
+
140
+ // Stacked area: revert to the pre-v6 behavior — show legend, no endpoint column.
141
+ if (isStacked) {
142
+ // Allow explicit user opt-in via endpointLabels: true even on stacked areas.
143
+ const explicitOn = endpointLabelsExplicitlyOn(spec);
144
+ return {
145
+ showTraditionalLegend: !legendHiddenExplicitly(spec),
146
+ showEndpointLabels: explicitOn,
147
+ showEndOfLineLabels: false,
148
+ };
149
+ }
150
+
151
+ // The eight-cell truth table for ≥2-series overlap line/area charts.
152
+ const epExplicitOff = endpointLabelsExplicitlyOff(spec);
153
+ const legShown = legendShownExplicitly(spec);
154
+ const legHidden = legendHiddenExplicitly(spec);
155
+
156
+ // Endpoint column: on by default; only `endpointLabels: false` (or
157
+ // `{ show: false }`) turns it off.
158
+ const showEndpointLabels = !epExplicitOff;
159
+
160
+ // Traditional legend:
161
+ // - explicit hide -> hidden
162
+ // - explicit show -> shown
163
+ // - unset -> hidden when endpoint column is on, shown otherwise
164
+ let showTraditionalLegend: boolean;
165
+ if (legHidden) {
166
+ showTraditionalLegend = false;
167
+ } else if (legShown) {
168
+ showTraditionalLegend = true;
169
+ } else {
170
+ showTraditionalLegend = !showEndpointLabels;
171
+ }
172
+
173
+ // End-of-line labels are the last-resort series identifier: they render
174
+ // only when both the traditional legend and the endpoint column are off.
175
+ // `labels.density: 'none'` is the legacy switch that explicitly disables
176
+ // end-of-line labels regardless of the other toggles.
177
+ const showEndOfLineLabels =
178
+ !showTraditionalLegend && !showEndpointLabels && !ctx.labelsDensityNone;
179
+
180
+ return {
181
+ showTraditionalLegend,
182
+ showEndpointLabels,
183
+ showEndOfLineLabels,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Count distinct series for the color encoding (for use in
189
+ * SuppressionContext.seriesCount). Returns 0 when no color encoding is set
190
+ * or when the encoding is conditional/quantitative.
191
+ */
192
+ export function countColorSeries(spec: NormalizedChartSpec): number {
193
+ const colorEnc = spec.encoding.color;
194
+ if (!colorEnc) return 0;
195
+ if ('condition' in colorEnc) return 0;
196
+ if (colorEnc.type === 'quantitative') return 0;
197
+ const field = colorEnc.field;
198
+ if (!field) return 0;
199
+ const seen = new Set<string>();
200
+ for (const row of spec.data) {
201
+ seen.add(String(row[field]));
202
+ }
203
+ return seen.size;
204
+ }
@@ -590,6 +590,7 @@ function buildSankeyLegend(
590
590
  swatchSize: SWATCH_SIZE,
591
591
  swatchGap: SWATCH_GAP,
592
592
  entryGap: ENTRY_GAP,
593
+ swatchChipFill: theme.colors.annotationFill,
593
594
  };
594
595
  }
595
596
 
@@ -674,6 +675,7 @@ function emptyLayout(
674
675
  swatchSize: SWATCH_SIZE,
675
676
  swatchGap: SWATCH_GAP,
676
677
  entryGap: ENTRY_GAP,
678
+ swatchChipFill: theme.colors.annotationFill,
677
679
  },
678
680
  tooltipDescriptors: new Map(),
679
681
  a11y: {