@opendata-ai/openchart-engine 7.6.1 → 7.7.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 (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1031 -187
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +371 -117
  42. package/src/tilemap/types.ts +1 -0
@@ -0,0 +1,192 @@
1
+ import type { ChartSpec, LineMark } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compileChart } from '../../compile';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Test data
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const MUTED_COLOR = '#bfc3c8';
10
+
11
+ const data = [
12
+ { date: '2020', country: 'Germany', value: 100 },
13
+ { date: '2020', country: 'France', value: 90 },
14
+ { date: '2020', country: 'Italy', value: 80 },
15
+ { date: '2020', country: 'Spain', value: 70 },
16
+ { date: '2020', country: 'Poland', value: 60 },
17
+ { date: '2021', country: 'Germany', value: 110 },
18
+ { date: '2021', country: 'France', value: 95 },
19
+ { date: '2021', country: 'Italy', value: 85 },
20
+ { date: '2021', country: 'Spain', value: 75 },
21
+ { date: '2021', country: 'Poland', value: 65 },
22
+ { date: '2022', country: 'Germany', value: 120 },
23
+ { date: '2022', country: 'France', value: 100 },
24
+ { date: '2022', country: 'Italy', value: 90 },
25
+ { date: '2022', country: 'Spain', value: 80 },
26
+ { date: '2022', country: 'Poland', value: 70 },
27
+ ];
28
+
29
+ const compileOptions = { width: 600, height: 400 };
30
+
31
+ function makeSpec(highlight?: string | string[]): ChartSpec {
32
+ const colorChannel: Record<string, unknown> = {
33
+ field: 'country',
34
+ type: 'nominal',
35
+ };
36
+ if (highlight !== undefined) {
37
+ colorChannel.highlight = highlight;
38
+ }
39
+ return {
40
+ mark: 'line',
41
+ data,
42
+ encoding: {
43
+ x: { field: 'date', type: 'temporal' },
44
+ y: { field: 'value', type: 'quantitative' },
45
+ color: colorChannel,
46
+ },
47
+ } as ChartSpec;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Color partitioning
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe('highlight — color partitioning', () => {
55
+ it('assigns palette color to highlighted series and muted color to others', () => {
56
+ const layout = compileChart(makeSpec(['Germany']), compileOptions);
57
+ const marks = layout.marks as LineMark[];
58
+
59
+ const germany = marks.find((m) => m.seriesKey === 'Germany');
60
+ const france = marks.find((m) => m.seriesKey === 'France');
61
+ const italy = marks.find((m) => m.seriesKey === 'Italy');
62
+
63
+ expect(germany).toBeDefined();
64
+ expect(france).toBeDefined();
65
+ expect(italy).toBeDefined();
66
+
67
+ // Germany should have a real palette color (not the muted gray)
68
+ expect(germany!.stroke).not.toBe(MUTED_COLOR);
69
+
70
+ // Non-highlighted series should get the muted color
71
+ expect(france!.stroke).toBe(MUTED_COLOR);
72
+ expect(italy!.stroke).toBe(MUTED_COLOR);
73
+ });
74
+
75
+ it('assigns palette colors to multiple highlighted series', () => {
76
+ const layout = compileChart(makeSpec(['Germany', 'France']), compileOptions);
77
+ const marks = layout.marks as LineMark[];
78
+
79
+ const germany = marks.find((m) => m.seriesKey === 'Germany');
80
+ const france = marks.find((m) => m.seriesKey === 'France');
81
+ const italy = marks.find((m) => m.seriesKey === 'Italy');
82
+
83
+ // Both highlighted series should have distinct palette colors
84
+ expect(germany!.stroke).not.toBe(MUTED_COLOR);
85
+ expect(france!.stroke).not.toBe(MUTED_COLOR);
86
+ expect(germany!.stroke).not.toBe(france!.stroke);
87
+
88
+ // Non-highlighted series should be muted
89
+ expect(italy!.stroke).toBe(MUTED_COLOR);
90
+ });
91
+
92
+ it('assigns all series palette colors when no highlight is active', () => {
93
+ const layout = compileChart(makeSpec(), compileOptions);
94
+ const marks = layout.marks as LineMark[];
95
+
96
+ // Every series should get a real palette color
97
+ for (const mark of marks) {
98
+ expect(mark.stroke).not.toBe(MUTED_COLOR);
99
+ }
100
+ });
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Z-order: muted marks sort before highlighted marks
105
+ // ---------------------------------------------------------------------------
106
+
107
+ describe('highlight — z-order', () => {
108
+ it('places muted series marks before highlighted series marks', () => {
109
+ const layout = compileChart(makeSpec(['Germany']), compileOptions);
110
+ const marks = layout.marks as LineMark[];
111
+
112
+ // Find the index of the first highlighted mark and the last muted mark
113
+ const germanyIndex = marks.findIndex((m) => m.seriesKey === 'Germany');
114
+ const lastMutedIndex = marks.reduce(
115
+ (maxIdx, m, i) => (m.seriesKey !== 'Germany' ? Math.max(maxIdx, i) : maxIdx),
116
+ -1,
117
+ );
118
+
119
+ // All muted marks should come before the highlighted mark in the array
120
+ expect(germanyIndex).toBeGreaterThan(lastMutedIndex);
121
+ });
122
+
123
+ it('places all muted marks before all highlighted marks with multiple highlights', () => {
124
+ const layout = compileChart(makeSpec(['Germany', 'France']), compileOptions);
125
+ const marks = layout.marks as LineMark[];
126
+ const highlightSet = new Set(['Germany', 'France']);
127
+
128
+ const firstHighlightIndex = marks.findIndex((m) => highlightSet.has(m.seriesKey ?? ''));
129
+ const lastMutedIndex = marks.reduce(
130
+ (maxIdx, m, i) => (!highlightSet.has(m.seriesKey ?? '') ? Math.max(maxIdx, i) : maxIdx),
131
+ -1,
132
+ );
133
+
134
+ expect(firstHighlightIndex).toBeGreaterThan(lastMutedIndex);
135
+ });
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Muted line stroke width
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe('highlight — muted line stroke width', () => {
143
+ it('reduces stroke width for muted series', () => {
144
+ const layout = compileChart(makeSpec(['Germany']), compileOptions);
145
+ const marks = layout.marks as LineMark[];
146
+
147
+ const germany = marks.find((m) => m.seriesKey === 'Germany');
148
+ const france = marks.find((m) => m.seriesKey === 'France');
149
+
150
+ expect(germany).toBeDefined();
151
+ expect(france).toBeDefined();
152
+
153
+ expect(germany!.strokeWidth).toBeDefined();
154
+ expect(france!.strokeWidth).toBeDefined();
155
+ expect(france!.strokeWidth).toBeLessThan(germany!.strokeWidth);
156
+ expect(france!.strokeWidth).toBeGreaterThanOrEqual(1);
157
+ });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Endpoint label suppression
162
+ // ---------------------------------------------------------------------------
163
+
164
+ describe('highlight — endpoint labels', () => {
165
+ it('only includes highlighted series in endpoint labels', () => {
166
+ const spec = { ...makeSpec(['Germany']), endpointLabels: { show: true } } as ChartSpec;
167
+ const layout = compileChart(spec, compileOptions);
168
+
169
+ expect(layout.endpointLabels).toBeDefined();
170
+ expect(layout.endpointLabels!.entries.length).toBeGreaterThan(0);
171
+ const labelSeriesKeys = layout.endpointLabels!.entries.map((e) => e.seriesKey);
172
+
173
+ expect(labelSeriesKeys).toContain('Germany');
174
+ expect(labelSeriesKeys).not.toContain('France');
175
+ expect(labelSeriesKeys).not.toContain('Italy');
176
+ expect(labelSeriesKeys).not.toContain('Spain');
177
+ expect(labelSeriesKeys).not.toContain('Poland');
178
+ });
179
+
180
+ it('includes all series in endpoint labels when no highlight is active', () => {
181
+ const spec = makeSpec();
182
+ const layout = compileChart(spec, compileOptions);
183
+
184
+ expect(layout.endpointLabels).toBeDefined();
185
+ expect(layout.endpointLabels!.entries.length).toBeGreaterThan(0);
186
+ const labelSeriesKeys = layout.endpointLabels!.entries.map((e) => e.seriesKey);
187
+
188
+ expect(labelSeriesKeys).toContain('Germany');
189
+ expect(labelSeriesKeys).toContain('France');
190
+ expect(labelSeriesKeys).toContain('Italy');
191
+ });
192
+ });
@@ -73,6 +73,9 @@ const WIDTH_REDUCED_THRESHOLD = 300;
73
73
  /** Ordered densities from most to fewest ticks. */
74
74
  const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
75
75
 
76
+ /** Marks grounded on the x baseline that warrant a domain line. */
77
+ const GROUNDED_MARKS = new Set(['bar', 'tick', 'lollipop']);
78
+
76
79
  /**
77
80
  * Compute effective axis tick density by considering available space.
78
81
  *
@@ -460,9 +463,12 @@ export function computeAxes(
460
463
  // because tick labels need horizontal room around their x position).
461
464
  const xTickPosition = axisConfig?.tickPosition ?? 'gutter';
462
465
 
466
+ const xDomainLine =
467
+ axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? '') ? undefined : false);
468
+
463
469
  result.x = {
464
470
  ticks,
465
- gridlines: axisConfig?.grid ? gridlines : [],
471
+ gridlines: (axisConfig?.grid ?? dataContext?.markType === 'point') ? gridlines : [],
466
472
  label: axisTitle,
467
473
  labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
468
474
  tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
@@ -470,7 +476,7 @@ export function computeAxes(
470
476
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
471
477
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
472
478
  orient: axisConfig?.orient,
473
- domainLine: axisConfig?.domain,
479
+ domainLine: xDomainLine,
474
480
  tickMarks: axisConfig?.ticks,
475
481
  offset: axisConfig?.offset,
476
482
  titlePadding: axisConfig?.titlePadding,
@@ -42,7 +42,7 @@ import {
42
42
  import { format as d3Format } from 'd3-format';
43
43
 
44
44
  import type { NormalizedChartSpec } from '../compiler/types';
45
- import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
45
+ import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
47
  import { legendGap } from '../legend/wrap';
48
48
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
@@ -362,6 +362,7 @@ export function computeDimensions(
362
362
  seriesCount,
363
363
  labelsHiddenByStrategy,
364
364
  labelsDensityNone: labelDensity === 'none',
365
+ endpointLabelsDemoted: plan?.endpointLabelsDemoted,
365
366
  });
366
367
 
367
368
  // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
@@ -373,6 +374,10 @@ export function computeDimensions(
373
374
  if (endpointWidth > 0) {
374
375
  // 16px gap between chart area edge and the column.
375
376
  margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
377
+ // Both-ends mode: mirror the column on the left for first-value labels.
378
+ if (isEndsBoth(spec)) {
379
+ margins.left = Math.max(margins.left, hPad) + endpointWidth + 16;
380
+ }
376
381
  }
377
382
  }
378
383
 
@@ -0,0 +1,80 @@
1
+ import type { Rect } from '@opendata-ai/openchart-core';
2
+
3
+ const MIN_PANEL_WIDTH = 200;
4
+ const PANEL_GAP = 12;
5
+ const HEADER_HEIGHT = 20;
6
+
7
+ export interface FacetGridPanel {
8
+ key: string;
9
+ area: Rect;
10
+ headerPos: { x: number; y: number };
11
+ row: number;
12
+ col: number;
13
+ }
14
+
15
+ export interface FacetGridResult {
16
+ panels: FacetGridPanel[];
17
+ columns: number;
18
+ rows: number;
19
+ }
20
+
21
+ export function autoColumns(panelCount: number, availableWidth: number): number {
22
+ const sqrtCols = Math.ceil(Math.sqrt(panelCount));
23
+ const fitCols = Math.max(1, Math.floor(availableWidth / MIN_PANEL_WIDTH));
24
+ return Math.min(sqrtCols, fitCols, panelCount);
25
+ }
26
+
27
+ export function computeFacetGrid(
28
+ facetValues: string[],
29
+ requestedColumns: number | undefined,
30
+ totalArea: Rect,
31
+ outerAxisReservation: { left: number; bottom: number },
32
+ ): FacetGridResult {
33
+ const n = facetValues.length;
34
+ if (n === 0) return { panels: [], columns: 0, rows: 0 };
35
+
36
+ const usableWidth = totalArea.width - outerAxisReservation.left;
37
+ let columns = Math.min(requestedColumns ?? autoColumns(n, usableWidth), n);
38
+
39
+ // Responsive degradation: reduce columns until panels are wide enough
40
+ while (columns > 1) {
41
+ const panelWidth = (usableWidth - (columns - 1) * PANEL_GAP) / columns;
42
+ if (panelWidth >= MIN_PANEL_WIDTH) break;
43
+ columns--;
44
+ }
45
+
46
+ const rows = Math.ceil(n / columns);
47
+ const panelWidth = Math.max(0, (usableWidth - (columns - 1) * PANEL_GAP) / columns);
48
+ const usableHeight = totalArea.height - outerAxisReservation.bottom;
49
+ const panelHeight = Math.max(
50
+ 0,
51
+ (usableHeight - rows * HEADER_HEIGHT - (rows - 1) * PANEL_GAP) / rows,
52
+ );
53
+
54
+ const panels: FacetGridPanel[] = [];
55
+
56
+ for (let i = 0; i < n; i++) {
57
+ const col = i % columns;
58
+ const row = Math.floor(i / columns);
59
+ const x = totalArea.x + outerAxisReservation.left + col * (panelWidth + PANEL_GAP);
60
+ const y = totalArea.y + row * (HEADER_HEIGHT + panelHeight + PANEL_GAP);
61
+
62
+ panels.push({
63
+ key: facetValues[i],
64
+ area: {
65
+ x,
66
+ y: y + HEADER_HEIGHT,
67
+ width: panelWidth,
68
+ height: panelHeight,
69
+ },
70
+ headerPos: {
71
+ x: x + panelWidth / 2,
72
+ y: y + HEADER_HEIGHT * 0.6,
73
+ },
74
+ row,
75
+ col,
76
+ });
77
+ }
78
+
79
+ return { panels, columns, rows };
80
+ }
@@ -30,6 +30,7 @@ import {
30
30
  import { format as d3Format } from 'd3-format';
31
31
 
32
32
  import type { NormalizedChartSpec } from '../compiler/types';
33
+ import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
33
34
  import { computeLegendContent, type LegendContent } from '../legend/compute';
34
35
  import { legendGap } from '../legend/wrap';
35
36
  import { resolveBandTickAngle } from './axes/rotation';
@@ -58,6 +59,8 @@ export interface LayoutPlan {
58
59
  yTickCount: number;
59
60
  converged: boolean;
60
61
  inlineYLabelInset: number;
62
+ /** True when endpoint labels were demoted by auto-thinning (don't fit at current width). */
63
+ endpointLabelsDemoted?: boolean;
61
64
  }
62
65
 
63
66
  // ---------------------------------------------------------------------------
@@ -291,6 +294,17 @@ export function resolveLayoutPlan(
291
294
  let prev: { gutter: number; rowCount: number; xAxisExtent: number } | null = null;
292
295
  let finalPlan: LayoutPlan | null = null;
293
296
 
297
+ // Endpoint-label fit check: when autoThin is on, demote endpoint labels to
298
+ // legend if the column would consume more than 30% of the total width.
299
+ const MAX_ENDPOINT_FRACTION = 0.3;
300
+ let endpointLabelsDemoted = false;
301
+ if (chartSpec.autoThin) {
302
+ const epWidth = predictEndpointLabelsWidth(chartSpec, theme);
303
+ if (epWidth > 0 && epWidth / width > MAX_ENDPOINT_FRACTION) {
304
+ endpointLabelsDemoted = true;
305
+ }
306
+ }
307
+
294
308
  for (let iter = 0; iter < 2; iter++) {
295
309
  // Legend content
296
310
  const legendAvailWidth = width - padding - leftGutter;
@@ -302,6 +316,7 @@ export function resolveLayoutPlan(
302
316
  height,
303
317
  watermark,
304
318
  measure,
319
+ endpointLabelsDemoted,
305
320
  );
306
321
 
307
322
  // Chrome
@@ -537,6 +552,7 @@ export function resolveLayoutPlan(
537
552
  yTickCount,
538
553
  converged: prev === null || converged,
539
554
  inlineYLabelInset,
555
+ endpointLabelsDemoted,
540
556
  };
541
557
  break;
542
558
  }
@@ -0,0 +1,108 @@
1
+ import { resolveTheme } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import type { NormalizedChartSpec } from '../../compiler/types';
5
+ import { computeLegendContent } from '../compute';
6
+
7
+ const theme = resolveTheme({});
8
+ const strategy = {
9
+ axisLabelDensity: 'full' as const,
10
+ labelMode: 'all' as const,
11
+ legendPosition: 'top' as const,
12
+ legendMaxHeight: 1,
13
+ fontScale: 1,
14
+ };
15
+
16
+ function makeBarSpec(overrides: Partial<NormalizedChartSpec> = {}): NormalizedChartSpec {
17
+ return {
18
+ markType: 'bar',
19
+ markDef: { type: 'bar' },
20
+ data: [
21
+ { category: 'A', value: 10 },
22
+ { category: 'B', value: 20 },
23
+ { category: 'C', value: 30 },
24
+ ],
25
+ encoding: {
26
+ x: { field: 'category', type: 'nominal' },
27
+ y: { field: 'value', type: 'quantitative' },
28
+ color: { field: 'category', type: 'nominal' },
29
+ },
30
+ chrome: {},
31
+ annotations: [],
32
+ responsive: true,
33
+ theme: {},
34
+ darkMode: 'off',
35
+ labels: { density: 'auto', format: '', prefix: '' },
36
+ hiddenSeries: [],
37
+ seriesStyles: {},
38
+ watermark: true,
39
+ display: 'full',
40
+ userExplicit: {
41
+ chrome: false,
42
+ legend: false,
43
+ endpointLabels: false,
44
+ xAxis: false,
45
+ yAxis: false,
46
+ labels: false,
47
+ animation: false,
48
+ watermark: false,
49
+ crosshair: false,
50
+ },
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ describe('bar/column legend redundancy rule', () => {
56
+ it('hides legend when color.field matches the category axis field', () => {
57
+ const spec = makeBarSpec();
58
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
59
+ expect(content.entries).toHaveLength(0);
60
+ });
61
+
62
+ it('shows legend when user explicitly sets legend config', () => {
63
+ const spec = makeBarSpec({
64
+ legend: { show: true },
65
+ userExplicit: {
66
+ chrome: false,
67
+ legend: true,
68
+ endpointLabels: false,
69
+ xAxis: false,
70
+ yAxis: false,
71
+ labels: false,
72
+ animation: false,
73
+ watermark: false,
74
+ crosshair: false,
75
+ },
76
+ });
77
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
78
+ expect(content.entries.length).toBeGreaterThan(0);
79
+ });
80
+
81
+ it('shows legend when color.field differs from category axis field', () => {
82
+ const spec = makeBarSpec({
83
+ data: [
84
+ { category: 'A', value: 10, series: 'X' },
85
+ { category: 'B', value: 20, series: 'Y' },
86
+ ],
87
+ encoding: {
88
+ x: { field: 'category', type: 'nominal' },
89
+ y: { field: 'value', type: 'quantitative' },
90
+ color: { field: 'series', type: 'nominal' },
91
+ },
92
+ });
93
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
94
+ expect(content.entries.length).toBeGreaterThan(0);
95
+ });
96
+
97
+ it('works for horizontal bars (y is category, x is quantitative)', () => {
98
+ const spec = makeBarSpec({
99
+ encoding: {
100
+ x: { field: 'value', type: 'quantitative' },
101
+ y: { field: 'category', type: 'nominal' },
102
+ color: { field: 'category', type: 'nominal' },
103
+ },
104
+ });
105
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
106
+ expect(content.entries).toHaveLength(0);
107
+ });
108
+ });
@@ -291,4 +291,76 @@ describe('resolveSuppression - special cases', () => {
291
291
  // Legend auto-suppressed
292
292
  expect(result.showTraditionalLegend).toBe(false);
293
293
  });
294
+
295
+ it('endpointLabels: { maxSeries: 12 } alone does NOT count as explicit on', () => {
296
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: { maxSeries: 12 } });
297
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 14 });
298
+ expect(result.showEndpointLabels).toBe(false);
299
+ expect(result.showTraditionalLegend).toBe(true);
300
+ });
301
+ });
302
+
303
+ describe('resolveSuppression - series-count cutoff', () => {
304
+ it('10-series line chart exceeds default cutoff (8): legend shown, endpoint labels hidden', () => {
305
+ const spec = makeMultiSeriesLineSpec();
306
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 10 });
307
+ expect(result.showEndpointLabels).toBe(false);
308
+ expect(result.showTraditionalLegend).toBe(true);
309
+ });
310
+
311
+ it('7-series line chart within default cutoff: endpoint labels shown', () => {
312
+ const spec = makeMultiSeriesLineSpec();
313
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 7 });
314
+ expect(result.showEndpointLabels).toBe(true);
315
+ expect(result.showTraditionalLegend).toBe(false);
316
+ });
317
+
318
+ it('8-series line chart at exact cutoff: endpoint labels shown', () => {
319
+ const spec = makeMultiSeriesLineSpec();
320
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 8 });
321
+ expect(result.showEndpointLabels).toBe(true);
322
+ expect(result.showTraditionalLegend).toBe(false);
323
+ });
324
+
325
+ it('10-series with endpointLabels: true overrides cutoff', () => {
326
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: true });
327
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 10 });
328
+ expect(result.showEndpointLabels).toBe(true);
329
+ expect(result.showTraditionalLegend).toBe(false);
330
+ });
331
+
332
+ it('10-series with endpointLabels: { show: true } overrides cutoff', () => {
333
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: { show: true } });
334
+ const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 10 });
335
+ expect(result.showEndpointLabels).toBe(true);
336
+ });
337
+
338
+ it('custom maxSeries: 12 allows up to 12 series', () => {
339
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: { maxSeries: 12 } });
340
+ const result12 = resolveSuppression(spec, { ...baseCtx, seriesCount: 12 });
341
+ expect(result12.showEndpointLabels).toBe(true);
342
+
343
+ const result13 = resolveSuppression(spec, { ...baseCtx, seriesCount: 13 });
344
+ expect(result13.showEndpointLabels).toBe(false);
345
+ expect(result13.showTraditionalLegend).toBe(true);
346
+ });
347
+
348
+ it('endpointLabelsDemoted hides endpoint labels and shows legend', () => {
349
+ const spec = makeMultiSeriesLineSpec();
350
+ const result = resolveSuppression(spec, {
351
+ ...baseCtx,
352
+ endpointLabelsDemoted: true,
353
+ });
354
+ expect(result.showEndpointLabels).toBe(false);
355
+ expect(result.showTraditionalLegend).toBe(true);
356
+ });
357
+
358
+ it('endpointLabelsDemoted does not override explicit endpointLabels: true', () => {
359
+ const spec = makeMultiSeriesLineSpec({ endpointLabels: true });
360
+ const result = resolveSuppression(spec, {
361
+ ...baseCtx,
362
+ endpointLabelsDemoted: true,
363
+ });
364
+ expect(result.showEndpointLabels).toBe(true);
365
+ });
294
366
  });
@@ -260,6 +260,7 @@ export function computeLegendContent(
260
260
  availableHeight: number,
261
261
  watermark: boolean = true,
262
262
  measure?: MeasureFn,
263
+ endpointLabelsDemoted?: boolean,
263
264
  ): LegendContent {
264
265
  const measureWidth = measure ?? estimateTextWidth;
265
266
 
@@ -284,11 +285,28 @@ export function computeLegendContent(
284
285
  seriesCount,
285
286
  labelsHiddenByStrategy: strategy.labelMode === 'none',
286
287
  labelsDensityNone: spec.labels.density === 'none',
288
+ endpointLabelsDemoted,
287
289
  });
288
290
  if (!suppression.showTraditionalLegend) {
289
291
  entries = [];
290
292
  }
291
293
 
294
+ // Bar/column/lollipop redundancy rule: when color.field matches the category
295
+ // axis field, the legend just repeats the axis labels. Hide it unless the
296
+ // user explicitly configured the legend.
297
+ const hasRedundantLegend = spec.markType === 'bar' || spec.markType === 'lollipop';
298
+ if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
299
+ const colorEnc = spec.encoding.color;
300
+ const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
301
+ if (colorField) {
302
+ const categoryField =
303
+ spec.encoding.y?.type === 'quantitative' ? spec.encoding.x?.field : spec.encoding.y?.field;
304
+ if (colorField === categoryField) {
305
+ entries = [];
306
+ }
307
+ }
308
+ }
309
+
292
310
  const labelStyle: TextStyle = {
293
311
  fontFamily: theme.fonts.family,
294
312
  fontSize: theme.fonts.sizes.small,
@@ -51,6 +51,8 @@ export interface SuppressionContext {
51
51
  labelsHiddenByStrategy: boolean;
52
52
  /** True when spec.labels.density === 'none'. */
53
53
  labelsDensityNone: boolean;
54
+ /** When true, endpoint labels are demoted because they don't fit at the current width. */
55
+ endpointLabelsDemoted?: boolean;
54
56
  }
55
57
 
56
58
  /**
@@ -69,18 +71,21 @@ function legendHiddenExplicitly(spec: NormalizedChartSpec): boolean {
69
71
  return spec.legend?.show === false;
70
72
  }
71
73
 
74
+ /** Keys on EndpointLabelsConfig that configure behavior but don't signal show intent. */
75
+ const EP_CONFIG_ONLY_KEYS = new Set(['show', 'maxSeries']);
76
+
72
77
  /**
73
78
  * Did the user explicitly set endpointLabels: true?
74
- * Either the bare boolean or `{ show: true }` or any other config keys count.
79
+ * Either the bare boolean or `{ show: true }` or any display-affecting config keys count.
80
+ * Config-only keys like `maxSeries` don't count as show intent.
75
81
  */
76
- function endpointLabelsExplicitlyOn(spec: NormalizedChartSpec): boolean {
82
+ export function endpointLabelsExplicitlyOn(spec: NormalizedChartSpec): boolean {
77
83
  const ep = spec.endpointLabels;
78
84
  if (ep === true) return true;
79
85
  if (ep === false || ep == null) return false;
80
- // Object form: explicit when show: true or any non-show key set.
81
86
  if (ep.show === true) return true;
82
87
  if (ep.show === false) return false;
83
- return Object.keys(ep).some((k) => k !== 'show');
88
+ return Object.keys(ep).some((k) => !EP_CONFIG_ONLY_KEYS.has(k));
84
89
  }
85
90
 
86
91
  /** Did the user explicitly set endpointLabels: false (or { show: false })? */
@@ -91,6 +96,15 @@ function endpointLabelsExplicitlyOff(spec: NormalizedChartSpec): boolean {
91
96
  return false;
92
97
  }
93
98
 
99
+ const DEFAULT_MAX_SERIES = 8;
100
+
101
+ /** Read the maxSeries cutoff from the spec's endpointLabels config. */
102
+ function getMaxSeries(spec: NormalizedChartSpec): number {
103
+ const ep = spec.endpointLabels;
104
+ if (typeof ep === 'object' && ep != null && ep.maxSeries != null) return ep.maxSeries;
105
+ return DEFAULT_MAX_SERIES;
106
+ }
107
+
94
108
  /**
95
109
  * Resolve the three booleans from spec + context.
96
110
  *
@@ -148,13 +162,20 @@ export function resolveSuppression(
148
162
  };
149
163
  }
150
164
 
165
+ // Series-count cutoff: above maxSeries (default 8), treat as if endpoint
166
+ // labels are off so a legend shows instead. Explicit user opt-in overrides.
167
+ const exceedsCutoff = !endpointLabelsExplicitlyOn(spec) && ctx.seriesCount > getMaxSeries(spec);
168
+
169
+ // Auto-thinning demotion: endpoint labels don't fit at the current width.
170
+ const demoted = !endpointLabelsExplicitlyOn(spec) && !!ctx.endpointLabelsDemoted;
171
+
151
172
  // The eight-cell truth table for ≥2-series overlap line/area charts.
152
- const epExplicitOff = endpointLabelsExplicitlyOff(spec);
173
+ const epExplicitOff = endpointLabelsExplicitlyOff(spec) || exceedsCutoff || demoted;
153
174
  const legShown = legendShownExplicitly(spec);
154
175
  const legHidden = legendHiddenExplicitly(spec);
155
176
 
156
177
  // Endpoint column: on by default; only `endpointLabels: false` (or
157
- // `{ show: false }`) turns it off.
178
+ // `{ show: false }`) or series-count cutoff turns it off.
158
179
  const showEndpointLabels = !epExplicitOff;
159
180
 
160
181
  // Traditional legend: