@opendata-ai/openchart-engine 6.13.0 → 6.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.13.0",
3
+ "version": "6.15.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.13.0",
48
+ "@opendata-ai/openchart-core": "6.15.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -234,11 +234,12 @@ describe('categorical sort', () => {
234
234
  seriesStyles: {},
235
235
  });
236
236
 
237
- it('sorts domain ascending by default (undefined sort)', () => {
237
+ it('preserves data order by default (undefined sort)', () => {
238
238
  const spec = makeBarSpec(undefined);
239
239
  const scales = computeScales(spec, chartArea, spec.data);
240
240
  const domain = (scales.y!.scale as ScaleBand<string>).domain();
241
- expect(domain).toEqual(['Apple', 'Banana', 'Cherry']);
241
+ // Data order: Banana, Apple, Cherry
242
+ expect(domain).toEqual(['Banana', 'Apple', 'Cherry']);
242
243
  });
243
244
 
244
245
  it('sorts domain ascending when sort is "ascending"', () => {
@@ -17,12 +17,15 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
17
17
  const marks = computeBarMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
20
+ const valueField =
21
+ spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
20
22
  const labels = computeBarLabels(
21
23
  marks,
22
24
  chartArea,
23
25
  spec.labels.density,
24
26
  spec.labels.format,
25
27
  spec.labels.prefix,
28
+ valueField,
26
29
  );
27
30
  for (let i = 0; i < marks.length && i < labels.length; i++) {
28
31
  marks[i].label = labels[i];
@@ -18,8 +18,10 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
+ abbreviateNumber,
21
22
  buildD3Formatter,
22
23
  estimateTextWidth,
24
+ formatNumber,
23
25
  getRepresentativeColor,
24
26
  resolveCollisions,
25
27
  } from '@opendata-ai/openchart-core';
@@ -28,6 +30,12 @@ import {
28
30
  // Helpers
29
31
  // ---------------------------------------------------------------------------
30
32
 
33
+ /** Format a bar value for display (abbreviate large numbers). */
34
+ function formatBarValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
31
39
  /** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
32
40
  const SUFFIX_MULTIPLIERS: Record<string, number> = {
33
41
  K: 1_000,
@@ -90,6 +98,7 @@ export function computeBarLabels(
90
98
  density: LabelDensity = 'auto',
91
99
  labelFormat?: string,
92
100
  labelPrefix?: string,
101
+ valueField?: string,
93
102
  ): ResolvedLabel[] {
94
103
  // 'none': no labels at all
95
104
  if (density === 'none') return [];
@@ -106,19 +115,28 @@ export function computeBarLabels(
106
115
  const formatter = buildD3Formatter(labelFormat);
107
116
 
108
117
  for (const mark of targetMarks) {
109
- // Extract the display value from the aria label.
110
- // Format is "category: value" or "category, group: value".
111
- // Use the last colon to split, which handles colons in category names.
112
- const ariaLabel = mark.aria.label;
113
- const lastColon = ariaLabel.lastIndexOf(':');
114
- const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
115
- if (!rawValue) continue;
116
-
117
- // Apply label format if provided (re-parse the number from the aria string)
118
- let valuePart = rawValue;
119
- if (formatter) {
120
- const num = parseDisplayNumber(rawValue);
121
- if (!Number.isNaN(num)) valuePart = formatter(num);
118
+ // Get the original numeric value from the data row when possible,
119
+ // falling back to parsing the aria label (which may lose precision
120
+ // due to abbreviation rounding, e.g. 1955 "2K" 2000).
121
+ let valuePart: string;
122
+ const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
123
+
124
+ if (formatter && Number.isFinite(rawNum)) {
125
+ valuePart = formatter(rawNum);
126
+ } else if (Number.isFinite(rawNum)) {
127
+ valuePart = formatBarValue(rawNum);
128
+ } else {
129
+ // Fallback: extract from aria label
130
+ const ariaLabel = mark.aria.label;
131
+ const lastColon = ariaLabel.lastIndexOf(':');
132
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
133
+ if (!rawValue) continue;
134
+ if (formatter) {
135
+ const num = parseDisplayNumber(rawValue);
136
+ valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
137
+ } else {
138
+ valuePart = rawValue;
139
+ }
122
140
  }
123
141
  if (labelPrefix) valuePart = labelPrefix + valuePart;
124
142
 
@@ -17,12 +17,15 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
17
17
  const marks = computeColumnMarks(spec, scales, chartArea, strategy);
18
18
 
19
19
  // Compute and attach value labels (respects spec.labels.density)
20
+ const valueField =
21
+ spec.encoding?.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
20
22
  const labels = computeColumnLabels(
21
23
  marks,
22
24
  chartArea,
23
25
  spec.labels.density,
24
26
  spec.labels.format,
25
27
  spec.labels.prefix,
28
+ valueField,
26
29
  );
27
30
  for (let i = 0; i < marks.length && i < labels.length; i++) {
28
31
  marks[i].label = labels[i];
@@ -18,12 +18,24 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
+ abbreviateNumber,
21
22
  buildD3Formatter,
22
23
  estimateTextWidth,
24
+ formatNumber,
23
25
  getRepresentativeColor,
24
26
  resolveCollisions,
25
27
  } from '@opendata-ai/openchart-core';
26
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Format a column value for display (abbreviate large numbers). */
34
+ function formatColumnValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
27
39
  // ---------------------------------------------------------------------------
28
40
  // Constants
29
41
  // ---------------------------------------------------------------------------
@@ -47,6 +59,7 @@ export function computeColumnLabels(
47
59
  density: LabelDensity = 'auto',
48
60
  labelFormat?: string,
49
61
  labelPrefix?: string,
62
+ valueField?: string,
50
63
  ): ResolvedLabel[] {
51
64
  // 'none': no labels at all
52
65
  if (density === 'none') return [];
@@ -60,19 +73,28 @@ export function computeColumnLabels(
60
73
  const candidates: LabelCandidate[] = [];
61
74
 
62
75
  for (const mark of targetMarks) {
63
- // Extract the display value from the aria label.
64
- // Format is "category: value" or "category, group: value".
65
- // Use the last colon to split, which handles colons in category names.
66
- const ariaLabel = mark.aria.label;
67
- const lastColon = ariaLabel.lastIndexOf(':');
68
- const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
69
- if (!rawValue) continue;
70
-
71
- // Apply label format if provided (re-parse the number from the aria string)
72
- let valuePart = rawValue;
73
- if (formatter) {
74
- const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
75
- if (!Number.isNaN(num)) valuePart = formatter(num);
76
+ // Get the original numeric value from the data row when possible,
77
+ // falling back to parsing the aria label (which may lose precision
78
+ // due to abbreviation rounding, e.g. 1955 "2K" 2000).
79
+ let valuePart: string;
80
+ const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
81
+
82
+ if (formatter && Number.isFinite(rawNum)) {
83
+ valuePart = formatter(rawNum);
84
+ } else if (Number.isFinite(rawNum)) {
85
+ valuePart = formatColumnValue(rawNum);
86
+ } else {
87
+ // Fallback: extract from aria label
88
+ const ariaLabel = mark.aria.label;
89
+ const lastColon = ariaLabel.lastIndexOf(':');
90
+ const rawValue = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
91
+ if (!rawValue) continue;
92
+ if (formatter) {
93
+ const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
94
+ valuePart = !Number.isNaN(num) ? formatter(num) : rawValue;
95
+ } else {
96
+ valuePart = rawValue;
97
+ }
76
98
  }
77
99
  if (labelPrefix) valuePart = labelPrefix + valuePart;
78
100
 
@@ -26,7 +26,16 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
26
26
  const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
27
27
 
28
28
  // Compute and attach labels to point marks (respects spec.labels.density)
29
- const labels = computeDotLabels(pointMarks, chartArea, spec.labels.density, spec.labels.prefix);
29
+ const valueField =
30
+ spec.encoding?.x && 'field' in spec.encoding.x ? spec.encoding.x.field : undefined;
31
+ const labels = computeDotLabels(
32
+ pointMarks,
33
+ chartArea,
34
+ spec.labels.density,
35
+ spec.labels.prefix,
36
+ spec.labels.format,
37
+ valueField,
38
+ );
30
39
  let labelIdx = 0;
31
40
  for (const mark of marks) {
32
41
  if (mark.type === 'point' && labelIdx < labels.length) {
@@ -18,11 +18,24 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
+ abbreviateNumber,
22
+ buildD3Formatter,
21
23
  estimateTextWidth,
24
+ formatNumber,
22
25
  getRepresentativeColor,
23
26
  resolveCollisions,
24
27
  } from '@opendata-ai/openchart-core';
25
28
 
29
+ // ---------------------------------------------------------------------------
30
+ // Helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Format a dot value for display (abbreviate large numbers). */
34
+ function formatDotValue(value: number): string {
35
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
+ return formatNumber(value);
37
+ }
38
+
26
39
  // ---------------------------------------------------------------------------
27
40
  // Constants
28
41
  // ---------------------------------------------------------------------------
@@ -45,6 +58,8 @@ export function computeDotLabels(
45
58
  _chartArea: Rect,
46
59
  density: LabelDensity = 'auto',
47
60
  labelPrefix?: string,
61
+ labelFormat?: string,
62
+ valueField?: string,
48
63
  ): ResolvedLabel[] {
49
64
  // 'none': no labels at all
50
65
  if (density === 'none') return [];
@@ -53,15 +68,31 @@ export function computeDotLabels(
53
68
  const targetMarks =
54
69
  density === 'endpoints' && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
55
70
 
71
+ const formatter = buildD3Formatter(labelFormat);
56
72
  const candidates: LabelCandidate[] = [];
57
73
 
58
74
  for (const mark of targetMarks) {
59
- // Extract the display value from the aria label.
60
- // Format is "category: value". Use the last colon to handle colons in category names.
61
- const ariaLabel = mark.aria.label;
62
- const lastColon = ariaLabel.lastIndexOf(':');
63
- let valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
64
- if (!valuePart) continue;
75
+ // Get the original numeric value from the data row when possible,
76
+ // falling back to parsing the aria label (which may lose precision
77
+ // due to abbreviation rounding, e.g. 1955 → "2K" → 2000).
78
+ let valuePart: string;
79
+ const rawNum = valueField != null ? Number(mark.data[valueField]) : NaN;
80
+
81
+ if (formatter && Number.isFinite(rawNum)) {
82
+ valuePart = formatter(rawNum);
83
+ } else if (Number.isFinite(rawNum)) {
84
+ valuePart = formatDotValue(rawNum);
85
+ } else {
86
+ // Fallback: extract from aria label
87
+ const ariaLabel = mark.aria.label;
88
+ const lastColon = ariaLabel.lastIndexOf(':');
89
+ valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
90
+ if (!valuePart) continue;
91
+ if (formatter) {
92
+ const num = Number(valuePart.replace(/[^0-9.-]/g, ''));
93
+ if (!Number.isNaN(num)) valuePart = formatter(num);
94
+ }
95
+ }
65
96
  if (labelPrefix) valuePart = labelPrefix + valuePart;
66
97
 
67
98
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
@@ -74,8 +74,12 @@ function computeSingleArea(
74
74
  for (const [seriesKey, rows] of groups) {
75
75
  const color = getColor(scales, seriesKey);
76
76
 
77
- // Sort rows by x-axis field so areas draw left-to-right
78
- const sortedRows = sortByField(rows, xChannel.field);
77
+ // Sort rows by x-axis field so areas draw left-to-right.
78
+ // For nominal/ordinal axes, preserve data order.
79
+ const sortedRows =
80
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
81
+ ? rows
82
+ : sortByField(rows, xChannel.field);
79
83
 
80
84
  // Compute points, filtering out null values
81
85
  const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
@@ -162,8 +166,12 @@ function computeStackedArea(
162
166
  return computeSingleArea(spec, scales, chartArea);
163
167
  }
164
168
 
165
- // Sort data by x field so stacked areas render left-to-right
166
- const sortedData = sortByField(spec.data, xChannel.field);
169
+ // Sort data by x field so stacked areas render left-to-right.
170
+ // For nominal/ordinal axes, preserve data order.
171
+ const sortedData =
172
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
173
+ ? spec.data
174
+ : sortByField(spec.data, xChannel.field);
167
175
 
168
176
  // Collect unique series keys and x values, and build a lookup from
169
177
  // (x-value, series-key) -> original data row so stacked area marks
@@ -75,8 +75,13 @@ export function computeLineMarks(
75
75
  : getColor(scales, seriesKey);
76
76
  const strokeColor = getRepresentativeColor(color);
77
77
 
78
- // Sort rows by x-axis field so lines draw left-to-right
79
- const sortedRows = sortByField(rows, xChannel.field);
78
+ // Sort rows by x-axis field so lines draw left-to-right.
79
+ // For nominal/ordinal axes, preserve data order since there's no
80
+ // natural sort and the scale domain already reflects intended order.
81
+ const sortedRows =
82
+ xChannel.type === 'nominal' || xChannel.type === 'ordinal'
83
+ ? rows
84
+ : sortByField(rows, xChannel.field);
80
85
 
81
86
  // Compute pixel positions for each data point, preserving nulls
82
87
  // for line break handling
@@ -4,7 +4,8 @@
4
4
  * Exports line and area chart renderers and computation functions.
5
5
  */
6
6
 
7
- import type { LineMark, Mark } from '@opendata-ai/openchart-core';
7
+ import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
8
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
8
9
  import type { ChartRenderer } from '../registry';
9
10
  import { computeAreaMarks } from './area';
10
11
  import { computeLineMarks } from './compute';
@@ -53,12 +54,42 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
53
54
  */
54
55
  export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
55
56
  const areas = computeAreaMarks(spec, scales, chartArea);
56
- const lines = computeLineMarks(spec, scales, chartArea, strategy);
57
+
58
+ const encoding = spec.encoding;
59
+ const hasColor = !!(encoding.color && 'field' in encoding.color);
60
+
61
+ // For stacked areas, derive line marks from the area top paths so lines
62
+ // align with stacked positions. For non-stacked, compute lines normally.
63
+ const lines = hasColor
64
+ ? linesFromAreas(areas)
65
+ : computeLineMarks(spec, scales, chartArea, strategy);
57
66
 
58
67
  // Areas go first (rendered behind lines), then lines on top
59
68
  return [...areas, ...lines] as Mark[];
60
69
  };
61
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Derive LineMark[] from stacked AreaMark[] using each area's top boundary.
77
+ * This ensures lines sit on top of their corresponding stacked area bands.
78
+ */
79
+ function linesFromAreas(areas: AreaMark[]): LineMark[] {
80
+ return areas.map((a) => ({
81
+ type: 'line' as const,
82
+ points: a.topPoints,
83
+ path: a.topPath,
84
+ stroke: getRepresentativeColor(a.fill),
85
+ strokeWidth: a.strokeWidth ?? 1,
86
+ seriesKey: a.seriesKey,
87
+ data: a.data,
88
+ dataPoints: a.dataPoints,
89
+ aria: { label: `${a.seriesKey ?? 'Series'}: line with ${a.topPoints.length} data points` },
90
+ }));
91
+ }
92
+
62
93
  // ---------------------------------------------------------------------------
63
94
  // Public exports
64
95
  // ---------------------------------------------------------------------------
package/src/compile.ts CHANGED
@@ -503,6 +503,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
503
503
  },
504
504
  animation: resolvedAnimation,
505
505
  watermark,
506
+ measureText: options.measureText,
506
507
  };
507
508
  }
508
509
 
@@ -38,8 +38,8 @@ import type {
38
38
 
39
39
  /** Base tick counts by axis label density. */
40
40
  const TICK_COUNTS: Record<AxisLabelDensity, number> = {
41
- full: 8,
42
- reduced: 5,
41
+ full: 10,
42
+ reduced: 7,
43
43
  minimal: 3,
44
44
  };
45
45
 
@@ -141,18 +141,16 @@ function uniqueStrings(values: unknown[]): string[] {
141
141
  }
142
142
 
143
143
  /**
144
- * Apply sort order to categorical domain values (Vega-Lite aligned).
144
+ * Apply sort order to categorical domain values.
145
145
  * - 'ascending': sort alphabetically/numerically ascending
146
146
  * - 'descending': sort descending
147
- * - null: preserve data order (no sorting)
148
- * - undefined: ascending (VL default)
147
+ * - null | undefined: preserve data order (no sorting)
149
148
  */
150
149
  function applyCategoricalSort(
151
150
  values: string[],
152
151
  sort: 'ascending' | 'descending' | null | undefined,
153
152
  ): string[] {
154
- // null means use data order
155
- if (sort === null) return values;
153
+ if (!sort) return values;
156
154
 
157
155
  const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
158
156
  if (sort === 'descending') sorted.reverse();
@@ -194,7 +192,11 @@ function buildTimeScale(
194
192
 
195
193
  const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
196
194
 
197
- if (channel.scale?.nice !== false) {
195
+ // Temporal scales default to nice: false because date data typically starts
196
+ // at clean boundaries and nice() rounds the domain outward, creating visible
197
+ // gaps (e.g. data starting 2018-01-01 gets rounded to 2017-01-01).
198
+ // Users can opt in with scale: { nice: true }.
199
+ if (!channel.scale?.domain && channel.scale?.nice === true) {
198
200
  scale.nice();
199
201
  }
200
202
  applyContinuousConfig(scale, channel);
@@ -215,7 +217,8 @@ function buildUtcScale(
215
217
 
216
218
  const scale = scaleUtc().domain(domain).range([rangeStart, rangeEnd]);
217
219
 
218
- if (channel.scale?.nice !== false) {
220
+ // Temporal scales default to nice: false (see buildTimeScale comment).
221
+ if (!channel.scale?.domain && channel.scale?.nice === true) {
219
222
  scale.nice();
220
223
  }
221
224
  applyContinuousConfig(scale, channel);
@@ -251,7 +254,7 @@ function buildLinearScale(
251
254
 
252
255
  const scale = scaleLinear().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
253
256
 
254
- if (channel.scale?.nice !== false) {
257
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
255
258
  scale.nice();
256
259
  }
257
260
  applyContinuousConfig(scale, channel);
@@ -278,7 +281,7 @@ function buildLogScale(
278
281
  if (channel.scale?.base !== undefined) {
279
282
  scale.base(channel.scale.base);
280
283
  }
281
- if (channel.scale?.nice !== false) {
284
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
282
285
  scale.nice();
283
286
  }
284
287
  applyContinuousConfig(scale, channel);
@@ -313,7 +316,7 @@ function buildPowScale(
313
316
  if (channel.scale?.exponent !== undefined) {
314
317
  scale.exponent(channel.scale.exponent);
315
318
  }
316
- if (channel.scale?.nice !== false) {
319
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
317
320
  scale.nice();
318
321
  }
319
322
  applyContinuousConfig(scale, channel);
@@ -345,7 +348,7 @@ function buildSqrtScale(
345
348
 
346
349
  const scale = scaleSqrt().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
347
350
 
348
- if (channel.scale?.nice !== false) {
351
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
349
352
  scale.nice();
350
353
  }
351
354
  applyContinuousConfig(scale, channel);
@@ -380,7 +383,7 @@ function buildSymlogScale(
380
383
  if (channel.scale?.constant !== undefined) {
381
384
  scale.constant(channel.scale.constant);
382
385
  }
383
- if (channel.scale?.nice !== false) {
386
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
384
387
  scale.nice();
385
388
  }
386
389
  applyContinuousConfig(scale, channel);
@@ -219,11 +219,9 @@ export function computeLegend(
219
219
  1,
220
220
  Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
221
221
  );
222
- // symbolLimit overrides the space-based limit when set (minimum 1)
222
+ // symbolLimit overrides the space-based limit when explicitly set (minimum 1)
223
223
  const maxEntries =
224
- spec.legend?.symbolLimit != null
225
- ? Math.min(Math.max(1, spec.legend.symbolLimit), maxFromSpace)
226
- : maxFromSpace;
224
+ spec.legend?.symbolLimit != null ? Math.max(1, spec.legend.symbolLimit) : maxFromSpace;
227
225
  if (entries.length > maxEntries) {
228
226
  entries = truncateEntries(entries, maxEntries);
229
227
  }
@@ -84,12 +84,25 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
84
84
 
85
85
  const fields: TooltipField[] = [];
86
86
 
87
+ // Color/series field (e.g. "Source: Coal") so the user knows which series
88
+ if (encoding.color && 'field' in encoding.color) {
89
+ fields.push({
90
+ label: resolveLabel(encoding.color),
91
+ value: formatValue(
92
+ row[encoding.color.field],
93
+ encoding.color.type,
94
+ resolveFormat(encoding.color),
95
+ ),
96
+ color,
97
+ });
98
+ }
99
+
87
100
  // Y-axis value (the "main" value in most charts)
88
101
  if (encoding.y) {
89
102
  fields.push({
90
103
  label: resolveLabel(encoding.y),
91
104
  value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
92
- color,
105
+ color: encoding.color ? undefined : color,
93
106
  });
94
107
  }
95
108
 
@@ -138,6 +151,21 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
138
151
  return String(row[encoding.y.field] ?? '');
139
152
  }
140
153
 
154
+ // For scatter/bubble (both axes quantitative), find a name-like string field
155
+ // in the data row that isn't already used by an encoding channel
156
+ if (encoding.x?.type === 'quantitative' && encoding.y?.type === 'quantitative') {
157
+ const encodedFields = new Set(
158
+ [encoding.x, encoding.y, encoding.color, encoding.size, encoding.detail]
159
+ .filter((ch): ch is EncodingChannel => !!ch && 'field' in ch)
160
+ .map((ch) => ch.field),
161
+ );
162
+ for (const [key, value] of Object.entries(row)) {
163
+ if (!encodedFields.has(key) && typeof value === 'string') {
164
+ return value;
165
+ }
166
+ }
167
+ }
168
+
141
169
  // For color-encoded series, use the series name (skip conditional defs)
142
170
  if (encoding.color && 'field' in encoding.color) {
143
171
  return String(row[encoding.color.field] ?? '');