@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.
@@ -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 uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
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
- return uniqueValues.map((value, i) => {
77
- // When explicit domain+range are provided, look up the color by domain index
78
- // so legend colors match the mark colors exactly.
79
- let colorIndex = i;
80
- if (explicitDomain && explicitRange) {
81
- const domainIdx = explicitDomain.indexOf(value);
82
- if (domainIdx >= 0) colorIndex = domainIdx;
83
- }
84
- return {
85
- label: value,
86
- color: palette[colorIndex % palette.length],
87
- shape,
88
- active: true,
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 explicitly forces legend on.
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
- const legendNotForced = spec.legend?.show !== true;
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 - LEGEND_PADDING * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
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(entries, availableWidth, labelStyle, maxRows);
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 + ENTRY_GAP;
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(entries, availableWidth, labelStyle);
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 + LEGEND_PADDING * 2;
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: ENTRY_GAP,
355
+ entryGap: effectiveEntryGap,
316
356
  };
317
357
  }
@@ -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 + ENTRY_GAP;
86
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
76
87
 
77
88
  if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
78
89
  rowWidths.push(rowWidth);
@@ -507,6 +507,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
507
507
  },
508
508
  animation: resolvedAnimation,
509
509
  watermark,
510
+ measureText: options.measureText,
510
511
  };
511
512
  }
512
513