@opendata-ai/openchart-engine 6.23.1 → 6.24.1
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.js +148 -84
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +25 -4
- package/src/__tests__/dimensions.test.ts +48 -0
- package/src/__tests__/legend.test.ts +63 -25
- package/src/annotations/compute.ts +5 -4
- package/src/annotations/resolve-refline.ts +4 -2
- package/src/charts/bar/labels.ts +26 -9
- package/src/charts/dot/__tests__/compute.test.ts +31 -0
- package/src/charts/dot/compute.ts +6 -1
- package/src/charts/line/__tests__/compute.test.ts +28 -0
- package/src/charts/line/area.ts +12 -2
- package/src/compile.ts +5 -3
- package/src/compiler/normalize.ts +2 -0
- package/src/layout/axes.ts +10 -5
- package/src/layout/dimensions.ts +22 -6
- package/src/legend/compute.ts +66 -26
- package/src/legend/wrap.ts +13 -2
- package/src/sankey/compile-sankey.ts +1 -0
package/src/legend/compute.ts
CHANGED
|
@@ -20,10 +20,10 @@ import type {
|
|
|
20
20
|
ResolvedTheme,
|
|
21
21
|
TextStyle,
|
|
22
22
|
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
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 { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
|
|
26
|
+
import { ENTRY_GAP, ENTRY_GAP_COMPACT, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
// Constants
|
|
@@ -67,27 +67,41 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
67
67
|
// Sequential (quantitative) color doesn't produce discrete legend entries
|
|
68
68
|
if (colorEnc.type === 'quantitative') return [];
|
|
69
69
|
|
|
70
|
-
const
|
|
70
|
+
const dataValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
|
|
71
71
|
const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
|
|
72
72
|
const explicitRange = colorEnc.scale?.range as string[] | undefined;
|
|
73
73
|
const palette = explicitRange ?? theme.colors.categorical;
|
|
74
74
|
const shape = swatchShapeForType(spec.markType);
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
76
|
+
// Order legend entries by explicit domain when provided so the author
|
|
77
|
+
// controls which entries render first (and which get truncated last when
|
|
78
|
+
// symbolLimit applies). Without explicit domain, preserve data order.
|
|
79
|
+
const uniqueValues = explicitDomain
|
|
80
|
+
? [
|
|
81
|
+
...explicitDomain.filter((v) => dataValues.includes(v)),
|
|
82
|
+
...dataValues.filter((v) => !explicitDomain.includes(v)),
|
|
83
|
+
]
|
|
84
|
+
: dataValues;
|
|
85
|
+
|
|
86
|
+
const excludeSet = new Set(spec.legend?.exclude ?? []);
|
|
87
|
+
|
|
88
|
+
return uniqueValues
|
|
89
|
+
.map((value, i) => {
|
|
90
|
+
// When explicit domain+range are provided, look up the color by domain index
|
|
91
|
+
// so legend colors match the mark colors exactly.
|
|
92
|
+
let colorIndex = i;
|
|
93
|
+
if (explicitDomain && explicitRange) {
|
|
94
|
+
const domainIdx = explicitDomain.indexOf(value);
|
|
95
|
+
if (domainIdx >= 0) colorIndex = domainIdx;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
label: value,
|
|
99
|
+
color: palette[colorIndex % palette.length],
|
|
100
|
+
shape,
|
|
101
|
+
active: true,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((entry) => !excludeSet.has(entry.label));
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
/**
|
|
@@ -152,12 +166,22 @@ export function computeLegend(
|
|
|
152
166
|
|
|
153
167
|
// Auto-suppress legend when endpoint labels identify series on line/area charts.
|
|
154
168
|
// Guards: keep legend at compact breakpoints (labels hidden), for stacked areas
|
|
155
|
-
// (endpoint labels overlap), and when user
|
|
169
|
+
// (endpoint labels overlap), and when user has configured any legend property
|
|
170
|
+
// (position, columns, maxRows, etc.) — any explicit legend config signals intent
|
|
171
|
+
// to show a legend, not just show: true.
|
|
156
172
|
const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
|
|
157
173
|
const hasLabels = spec.labels.density !== 'none';
|
|
158
174
|
const labelsWillRender = strategy.labelMode !== 'none';
|
|
159
175
|
const hasColorEncoding = spec.encoding.color != null;
|
|
160
|
-
|
|
176
|
+
// Legend is "forced" when the user set show: true OR specified any legend config
|
|
177
|
+
// other than show: false. Vega-Lite convention: legend is shown by default for
|
|
178
|
+
// multi-series charts; auto-suppression only fires when no legend config is present.
|
|
179
|
+
const userConfiguredLegend =
|
|
180
|
+
spec.legend != null &&
|
|
181
|
+
Object.keys(spec.legend).some(
|
|
182
|
+
(k) => k !== 'show' || spec.legend![k as keyof typeof spec.legend] !== false,
|
|
183
|
+
);
|
|
184
|
+
const legendNotForced = !userConfiguredLegend;
|
|
161
185
|
|
|
162
186
|
if (isLineOrArea && hasLabels && labelsWillRender && hasColorEncoding && legendNotForced) {
|
|
163
187
|
const isArea = spec.markType === 'area';
|
|
@@ -258,8 +282,12 @@ export function computeLegend(
|
|
|
258
282
|
// Reserve space on the right for bottom legends so they don't overlap the brand
|
|
259
283
|
// watermark. Top legends don't need this since the brand renders at the bottom.
|
|
260
284
|
const reserveBrand = watermark && resolvedPosition === 'bottom';
|
|
285
|
+
// Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
|
|
286
|
+
const isCompact = chartArea.width < COMPACT_WIDTH;
|
|
287
|
+
const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
|
|
288
|
+
const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
|
|
261
289
|
const availableWidth =
|
|
262
|
-
chartArea.width -
|
|
290
|
+
chartArea.width - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
|
|
263
291
|
|
|
264
292
|
// Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
|
|
265
293
|
if (spec.legend?.symbolLimit != null) {
|
|
@@ -276,7 +304,13 @@ export function computeLegend(
|
|
|
276
304
|
: spec.legend?.columns != null
|
|
277
305
|
? Math.ceil(entries.length / spec.legend.columns)
|
|
278
306
|
: TOP_LEGEND_MAX_ROWS;
|
|
279
|
-
const { fittingCount } = measureLegendWrap(
|
|
307
|
+
const { fittingCount } = measureLegendWrap(
|
|
308
|
+
entries,
|
|
309
|
+
availableWidth,
|
|
310
|
+
labelStyle,
|
|
311
|
+
maxRows,
|
|
312
|
+
effectiveEntryGap,
|
|
313
|
+
);
|
|
280
314
|
|
|
281
315
|
if (fittingCount < entries.length) {
|
|
282
316
|
entries = truncateEntries(entries, fittingCount);
|
|
@@ -284,14 +318,20 @@ export function computeLegend(
|
|
|
284
318
|
|
|
285
319
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
286
320
|
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
287
|
-
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth +
|
|
321
|
+
return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
|
|
288
322
|
}, 0);
|
|
289
323
|
|
|
290
324
|
// Calculate actual row count for height (recompute after truncation).
|
|
291
|
-
const { rowCount } = measureLegendWrap(
|
|
325
|
+
const { rowCount } = measureLegendWrap(
|
|
326
|
+
entries,
|
|
327
|
+
availableWidth,
|
|
328
|
+
labelStyle,
|
|
329
|
+
undefined,
|
|
330
|
+
effectiveEntryGap,
|
|
331
|
+
);
|
|
292
332
|
|
|
293
333
|
const rowHeight = SWATCH_SIZE + 4;
|
|
294
|
-
const legendHeight = rowCount * rowHeight +
|
|
334
|
+
const legendHeight = rowCount * rowHeight + effectivePadding * 2;
|
|
295
335
|
|
|
296
336
|
// Apply user-provided legend offset
|
|
297
337
|
const offsetDx = spec.legend?.offset?.dx ?? 0;
|
|
@@ -312,6 +352,6 @@ export function computeLegend(
|
|
|
312
352
|
labelStyle,
|
|
313
353
|
swatchSize: SWATCH_SIZE,
|
|
314
354
|
swatchGap: SWATCH_GAP,
|
|
315
|
-
entryGap:
|
|
355
|
+
entryGap: effectiveEntryGap,
|
|
316
356
|
};
|
|
317
357
|
}
|
package/src/legend/wrap.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { LegendEntry, TextStyle } from '@opendata-ai/openchart-core';
|
|
18
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
18
|
+
import { COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Constants
|
|
@@ -28,6 +28,16 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
|
28
28
|
export const SWATCH_SIZE = 12;
|
|
29
29
|
export const SWATCH_GAP = 6;
|
|
30
30
|
export const ENTRY_GAP = 16;
|
|
31
|
+
/** Tighter inter-entry gap for narrow viewports where every pixel matters. */
|
|
32
|
+
export const ENTRY_GAP_COMPACT = 10;
|
|
33
|
+
|
|
34
|
+
/** Default gap between legend bounds and chart area. Zero on narrow viewports. */
|
|
35
|
+
export const LEGEND_GAP = 4;
|
|
36
|
+
|
|
37
|
+
/** Gap between legend and chart area, responsive to container width. */
|
|
38
|
+
export function legendGap(width: number): number {
|
|
39
|
+
return width < COMPACT_WIDTH ? 0 : LEGEND_GAP;
|
|
40
|
+
}
|
|
31
41
|
|
|
32
42
|
// ---------------------------------------------------------------------------
|
|
33
43
|
// Public API
|
|
@@ -55,6 +65,7 @@ export function measureLegendWrap(
|
|
|
55
65
|
maxWidth: number,
|
|
56
66
|
labelStyle: TextStyle,
|
|
57
67
|
maxRows?: number,
|
|
68
|
+
entryGap: number = ENTRY_GAP,
|
|
58
69
|
): LegendWrapResult {
|
|
59
70
|
if (entries.length === 0) {
|
|
60
71
|
return { rowCount: 0, fittingCount: 0, rowWidths: [] };
|
|
@@ -72,7 +83,7 @@ export function measureLegendWrap(
|
|
|
72
83
|
labelStyle.fontSize,
|
|
73
84
|
labelStyle.fontWeight,
|
|
74
85
|
);
|
|
75
|
-
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth +
|
|
86
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
|
|
76
87
|
|
|
77
88
|
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
78
89
|
rowWidths.push(rowWidth);
|