@opendata-ai/openchart-engine 7.6.1 → 7.8.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/dist/index.d.ts +13 -0
- package/dist/index.js +1037 -187
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +6 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
- package/src/__tests__/axes.test.ts +3 -2
- package/src/__tests__/facet.test.ts +215 -0
- package/src/annotations/__tests__/compute.test.ts +18 -0
- package/src/annotations/__tests__/thinning.test.ts +137 -0
- package/src/annotations/compute.ts +3 -1
- package/src/annotations/thinning.ts +116 -0
- package/src/barlist/compile-barlist.ts +1 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/area.ts +16 -2
- package/src/charts/line/compute.ts +17 -10
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/__tests__/color-scale-range.test.ts +80 -1
- package/src/compile/color-scale-range.ts +44 -2
- package/src/compile.ts +314 -5
- package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
- package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
- package/src/compiler/normalize.ts +67 -2
- package/src/compiler/types.ts +10 -0
- package/src/compiler/validate.ts +79 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
- package/src/endpoint-labels/compute.ts +179 -5
- package/src/endpoint-labels/predict.ts +17 -0
- package/src/layout/__tests__/scales-highlight.test.ts +192 -0
- package/src/layout/axes.ts +8 -2
- package/src/layout/dimensions.ts +6 -1
- package/src/layout/facet.ts +80 -0
- package/src/layout/plan.ts +16 -0
- package/src/legend/__tests__/redundancy.test.ts +108 -0
- package/src/legend/__tests__/suppression.test.ts +72 -0
- package/src/legend/compute.ts +18 -0
- package/src/legend/suppression.ts +27 -6
- package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
- package/src/tilemap/compile-tilemap.ts +379 -117
- 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
|
+
});
|
package/src/layout/axes.ts
CHANGED
|
@@ -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:
|
|
479
|
+
domainLine: xDomainLine,
|
|
474
480
|
tickMarks: axisConfig?.ticks,
|
|
475
481
|
offset: axisConfig?.offset,
|
|
476
482
|
titlePadding: axisConfig?.titlePadding,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/layout/plan.ts
CHANGED
|
@@ -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
|
});
|
package/src/legend/compute.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|