@opendata-ai/openchart-engine 6.28.6 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.d.ts +8 -11
- package/dist/index.js +12296 -11337
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
- package/src/__tests__/axes.test.ts +75 -0
- package/src/__tests__/compile-chart.test.ts +304 -0
- package/src/__tests__/dimensions.test.ts +224 -0
- package/src/__tests__/legend.test.ts +44 -3
- package/src/annotations/__tests__/compute.test.ts +111 -0
- package/src/annotations/__tests__/resolve-text.test.ts +288 -0
- package/src/annotations/constants.ts +20 -0
- package/src/annotations/resolve-text.ts +161 -7
- package/src/charts/bar/compute.ts +24 -0
- package/src/charts/bar/labels.ts +1 -0
- package/src/charts/column/compute.ts +33 -1
- package/src/charts/column/labels.ts +1 -0
- package/src/charts/dot/labels.ts +1 -0
- package/src/charts/line/__tests__/compute.test.ts +153 -3
- package/src/charts/line/area.ts +111 -23
- package/src/charts/line/compute.ts +40 -10
- package/src/charts/line/index.ts +34 -7
- package/src/charts/line/labels.ts +29 -0
- package/src/charts/pie/labels.ts +1 -0
- package/src/compile/layer.ts +497 -0
- package/src/compile.ts +211 -586
- package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
- package/src/compiler/normalize.ts +6 -1
- package/src/compiler/sparkline-defaults.ts +138 -0
- package/src/compiler/types.ts +8 -0
- package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
- package/src/endpoint-labels/compute.ts +417 -0
- package/src/endpoint-labels/constants.ts +54 -0
- package/src/endpoint-labels/format.ts +30 -0
- package/src/endpoint-labels/predict.ts +108 -0
- package/src/graphs/compile-graph.ts +1 -0
- package/src/layout/axes.ts +27 -2
- package/src/layout/dimensions.ts +270 -33
- package/src/layout/metrics.ts +118 -0
- package/src/layout/scales.ts +41 -4
- package/src/legend/__tests__/suppression.test.ts +294 -0
- package/src/legend/compute.ts +50 -40
- package/src/legend/suppression.ts +204 -0
- package/src/sankey/compile-sankey.ts +2 -0
|
@@ -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
|
+
});
|
package/src/legend/compute.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
359
|
-
|
|
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: {
|