@opendata-ai/openchart-engine 6.12.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.
Files changed (43) hide show
  1. package/dist/index.js +1022 -648
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +390 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/index.ts +3 -0
  20. package/src/charts/bar/labels.ts +38 -14
  21. package/src/charts/column/__tests__/compute.test.ts +99 -0
  22. package/src/charts/column/compute.ts +27 -6
  23. package/src/charts/column/index.ts +3 -0
  24. package/src/charts/column/labels.ts +35 -13
  25. package/src/charts/dot/index.ts +10 -1
  26. package/src/charts/dot/labels.ts +37 -6
  27. package/src/charts/line/area.ts +31 -6
  28. package/src/charts/line/compute.ts +7 -2
  29. package/src/charts/line/index.ts +33 -2
  30. package/src/charts/post-process.ts +215 -0
  31. package/src/compile.ts +91 -158
  32. package/src/compiler/normalize.ts +2 -2
  33. package/src/layout/axes.ts +12 -15
  34. package/src/layout/dimensions.ts +3 -3
  35. package/src/layout/scales.ts +116 -36
  36. package/src/legend/compute.ts +2 -4
  37. package/src/tooltips/__tests__/compute.test.ts +188 -0
  38. package/src/tooltips/compute.ts +54 -12
  39. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  40. package/src/transforms/__tests__/fold.test.ts +79 -0
  41. package/src/transforms/aggregate.ts +130 -0
  42. package/src/transforms/fold.ts +49 -0
  43. package/src/transforms/index.ts +8 -0
@@ -140,6 +140,23 @@ function uniqueStrings(values: unknown[]): string[] {
140
140
  return result;
141
141
  }
142
142
 
143
+ /**
144
+ * Apply sort order to categorical domain values.
145
+ * - 'ascending': sort alphabetically/numerically ascending
146
+ * - 'descending': sort descending
147
+ * - null | undefined: preserve data order (no sorting)
148
+ */
149
+ function applyCategoricalSort(
150
+ values: string[],
151
+ sort: 'ascending' | 'descending' | null | undefined,
152
+ ): string[] {
153
+ if (!sort) return values;
154
+
155
+ const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
156
+ if (sort === 'descending') sorted.reverse();
157
+ return sorted;
158
+ }
159
+
143
160
  // ---------------------------------------------------------------------------
144
161
  // Helpers: apply common scale config
145
162
  // ---------------------------------------------------------------------------
@@ -175,7 +192,11 @@ function buildTimeScale(
175
192
 
176
193
  const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
177
194
 
178
- 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) {
179
200
  scale.nice();
180
201
  }
181
202
  applyContinuousConfig(scale, channel);
@@ -196,7 +217,8 @@ function buildUtcScale(
196
217
 
197
218
  const scale = scaleUtc().domain(domain).range([rangeStart, rangeEnd]);
198
219
 
199
- if (channel.scale?.nice !== false) {
220
+ // Temporal scales default to nice: false (see buildTimeScale comment).
221
+ if (!channel.scale?.domain && channel.scale?.nice === true) {
200
222
  scale.nice();
201
223
  }
202
224
  applyContinuousConfig(scale, channel);
@@ -232,7 +254,7 @@ function buildLinearScale(
232
254
 
233
255
  const scale = scaleLinear().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
234
256
 
235
- if (channel.scale?.nice !== false) {
257
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
236
258
  scale.nice();
237
259
  }
238
260
  applyContinuousConfig(scale, channel);
@@ -259,7 +281,7 @@ function buildLogScale(
259
281
  if (channel.scale?.base !== undefined) {
260
282
  scale.base(channel.scale.base);
261
283
  }
262
- if (channel.scale?.nice !== false) {
284
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
263
285
  scale.nice();
264
286
  }
265
287
  applyContinuousConfig(scale, channel);
@@ -294,7 +316,7 @@ function buildPowScale(
294
316
  if (channel.scale?.exponent !== undefined) {
295
317
  scale.exponent(channel.scale.exponent);
296
318
  }
297
- if (channel.scale?.nice !== false) {
319
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
298
320
  scale.nice();
299
321
  }
300
322
  applyContinuousConfig(scale, channel);
@@ -326,7 +348,7 @@ function buildSqrtScale(
326
348
 
327
349
  const scale = scaleSqrt().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
328
350
 
329
- if (channel.scale?.nice !== false) {
351
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
330
352
  scale.nice();
331
353
  }
332
354
  applyContinuousConfig(scale, channel);
@@ -361,7 +383,7 @@ function buildSymlogScale(
361
383
  if (channel.scale?.constant !== undefined) {
362
384
  scale.constant(channel.scale.constant);
363
385
  }
364
- if (channel.scale?.nice !== false) {
386
+ if (!channel.scale?.domain && channel.scale?.nice !== false) {
365
387
  scale.nice();
366
388
  }
367
389
  applyContinuousConfig(scale, channel);
@@ -439,7 +461,7 @@ function buildBandScale(
439
461
  ): ResolvedScale {
440
462
  const values = channel.scale?.domain
441
463
  ? (channel.scale.domain as string[])
442
- : uniqueStrings(fieldValues(data, channel.field));
464
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
443
465
 
444
466
  const padding = channel.scale?.padding ?? 0.35;
445
467
  const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
@@ -466,7 +488,7 @@ function buildPointScale(
466
488
  ): ResolvedScale {
467
489
  const values = channel.scale?.domain
468
490
  ? (channel.scale.domain as string[])
469
- : uniqueStrings(fieldValues(data, channel.field));
491
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
470
492
 
471
493
  const padding = channel.scale?.padding ?? 0.5;
472
494
  const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
@@ -484,7 +506,11 @@ function buildOrdinalColorScale(
484
506
  data: DataRow[],
485
507
  palette: string[],
486
508
  ): ResolvedScale {
487
- const values = uniqueStrings(fieldValues(data, channel.field));
509
+ // Use explicit domain if provided, otherwise derive from data
510
+ const explicitDomain = channel.scale?.domain as string[] | undefined;
511
+ const values = explicitDomain
512
+ ? explicitDomain.map(String)
513
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
488
514
 
489
515
  const scale = scaleOrdinal<string>().domain(values).range(palette);
490
516
 
@@ -622,6 +648,7 @@ export function computeScales(
622
648
  // For stacked bars, the x-domain needs the max category sum, not max individual value.
623
649
  // Without this, stacked bars would clip past the chart area.
624
650
  let xData = data;
651
+ let xChannel = encoding.x;
625
652
  const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
626
653
  if (
627
654
  spec.markType === 'bar' &&
@@ -629,25 +656,51 @@ export function computeScales(
629
656
  encoding.x.type === 'quantitative' &&
630
657
  !xStackDisabled
631
658
  ) {
632
- const yField = encoding.y?.field;
633
- const xField = encoding.x.field;
634
- if (yField) {
635
- const sums = new Map<string, number>();
636
- for (const row of data) {
637
- const cat = String(row[yField] ?? '');
638
- const val = Number(row[xField] ?? 0);
639
- if (Number.isFinite(val) && val > 0) {
640
- sums.set(cat, (sums.get(cat) ?? 0) + val);
659
+ if (encoding.x.stack === 'normalize') {
660
+ // Normalize: domain is [0, 1]
661
+ xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
662
+ } else if (encoding.x.stack === 'center') {
663
+ // Center: compute max half-sum for symmetric domain
664
+ const yField = encoding.y?.field;
665
+ const xField = encoding.x.field;
666
+ if (yField) {
667
+ const sums = new Map<string, number>();
668
+ for (const row of data) {
669
+ const cat = String(row[yField] ?? '');
670
+ const val = Number(row[xField] ?? 0);
671
+ if (Number.isFinite(val) && val > 0) {
672
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
673
+ }
674
+ }
675
+ const maxSum = Math.max(...sums.values(), 0);
676
+ const half = maxSum / 2;
677
+ xChannel = {
678
+ ...encoding.x,
679
+ scale: { ...encoding.x.scale, domain: [-half, half], zero: true },
680
+ };
681
+ }
682
+ } else {
683
+ // Zero (default): domain extends to max category sum
684
+ const yField = encoding.y?.field;
685
+ const xField = encoding.x.field;
686
+ if (yField) {
687
+ const sums = new Map<string, number>();
688
+ for (const row of data) {
689
+ const cat = String(row[yField] ?? '');
690
+ const val = Number(row[xField] ?? 0);
691
+ if (Number.isFinite(val) && val > 0) {
692
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
693
+ }
641
694
  }
695
+ const maxSum = Math.max(...sums.values(), 0);
696
+ // Create a synthetic row with the max stack sum so buildLinearScale sees it
697
+ xData = [...data, { [xField]: maxSum } as DataRow];
642
698
  }
643
- const maxSum = Math.max(...sums.values(), 0);
644
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
645
- xData = [...data, { [xField]: maxSum } as DataRow];
646
699
  }
647
700
  }
648
701
 
649
702
  result.x = buildPositionalScale(
650
- encoding.x,
703
+ xChannel,
651
704
  xData,
652
705
  chartArea.x,
653
706
  chartArea.x + chartArea.width,
@@ -662,6 +715,7 @@ export function computeScales(
662
715
  // would clip above the chart area.
663
716
  // Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
664
717
  let yData = data;
718
+ let yChannel = encoding.y;
665
719
  const isVerticalBar =
666
720
  spec.markType === 'bar' &&
667
721
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
@@ -673,26 +727,52 @@ export function computeScales(
673
727
  encoding.y.type === 'quantitative' &&
674
728
  !yStackDisabled
675
729
  ) {
676
- const xField = encoding.x?.field;
677
- const yField = encoding.y.field;
678
- if (xField) {
679
- const sums = new Map<string, number>();
680
- for (const row of data) {
681
- const cat = String(row[xField] ?? '');
682
- const val = Number(row[yField] ?? 0);
683
- if (Number.isFinite(val) && val > 0) {
684
- sums.set(cat, (sums.get(cat) ?? 0) + val);
730
+ if (encoding.y.stack === 'normalize') {
731
+ // Normalize: domain is [0, 1] (VL convention)
732
+ yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
733
+ } else if (encoding.y.stack === 'center') {
734
+ // Center: compute max half-sum for symmetric domain
735
+ const xField = encoding.x?.field;
736
+ const yField = encoding.y.field;
737
+ if (xField) {
738
+ const sums = new Map<string, number>();
739
+ for (const row of data) {
740
+ const cat = String(row[xField] ?? '');
741
+ const val = Number(row[yField] ?? 0);
742
+ if (Number.isFinite(val) && val > 0) {
743
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
744
+ }
745
+ }
746
+ const maxSum = Math.max(...sums.values(), 0);
747
+ const half = maxSum / 2;
748
+ yChannel = {
749
+ ...encoding.y,
750
+ scale: { ...encoding.y.scale, domain: [-half, half], zero: true },
751
+ };
752
+ }
753
+ } else {
754
+ // Zero (default): domain extends to max category sum
755
+ const xField = encoding.x?.field;
756
+ const yField = encoding.y.field;
757
+ if (xField) {
758
+ const sums = new Map<string, number>();
759
+ for (const row of data) {
760
+ const cat = String(row[xField] ?? '');
761
+ const val = Number(row[yField] ?? 0);
762
+ if (Number.isFinite(val) && val > 0) {
763
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
764
+ }
685
765
  }
766
+ const maxSum = Math.max(...sums.values(), 0);
767
+ // Create a synthetic row with the max stack sum so buildLinearScale sees it
768
+ yData = [...data, { [yField]: maxSum } as DataRow];
686
769
  }
687
- const maxSum = Math.max(...sums.values(), 0);
688
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
689
- yData = [...data, { [yField]: maxSum } as DataRow];
690
770
  }
691
771
  }
692
772
 
693
773
  // Y axis: range is inverted (SVG y goes down, data y goes up)
694
774
  result.y = buildPositionalScale(
695
- encoding.y,
775
+ yChannel,
696
776
  yData,
697
777
  chartArea.y + chartArea.height,
698
778
  chartArea.y,
@@ -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
  }
@@ -384,6 +384,194 @@ describe('computeTooltipDescriptors', () => {
384
384
  });
385
385
  });
386
386
 
387
+ describe('tooltip title and format on encoding channels', () => {
388
+ it('uses channel title as label instead of field name in explicit tooltip', () => {
389
+ const spec: NormalizedChartSpec = {
390
+ ...makeBarSpec(),
391
+ encoding: {
392
+ ...makeBarSpec().encoding,
393
+ tooltip: [
394
+ { field: 'category', type: 'nominal', title: 'Category Name' },
395
+ { field: 'value', type: 'quantitative', title: 'Total Sales' },
396
+ ],
397
+ },
398
+ };
399
+ const rectMarks: RectMark[] = [
400
+ {
401
+ type: 'rect',
402
+ x: 50,
403
+ y: 30,
404
+ width: 200,
405
+ height: 40,
406
+ fill: '#1b7fa3',
407
+ data: { category: 'A', value: 100 },
408
+ aria: { label: 'bar' },
409
+ },
410
+ ];
411
+
412
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
413
+ const content = descriptors.get('rect-0')!;
414
+
415
+ expect(content.fields[0].label).toBe('Category Name');
416
+ expect(content.fields[1].label).toBe('Total Sales');
417
+ });
418
+
419
+ it('uses channel format to format values in explicit tooltip', () => {
420
+ const spec: NormalizedChartSpec = {
421
+ ...makeBarSpec(),
422
+ encoding: {
423
+ ...makeBarSpec().encoding,
424
+ tooltip: [{ field: 'value', type: 'quantitative', format: '$,.0f' }],
425
+ },
426
+ };
427
+ const rectMarks: RectMark[] = [
428
+ {
429
+ type: 'rect',
430
+ x: 50,
431
+ y: 30,
432
+ width: 200,
433
+ height: 40,
434
+ fill: '#1b7fa3',
435
+ data: { category: 'A', value: 1500 },
436
+ aria: { label: 'bar' },
437
+ },
438
+ ];
439
+
440
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
441
+ const content = descriptors.get('rect-0')!;
442
+
443
+ expect(content.fields[0].value).toBe('$1,500');
444
+ });
445
+
446
+ it('channel title takes precedence over axis.title', () => {
447
+ const spec: NormalizedChartSpec = {
448
+ ...makeBarSpec(),
449
+ encoding: {
450
+ ...makeBarSpec().encoding,
451
+ tooltip: [
452
+ {
453
+ field: 'value',
454
+ type: 'quantitative',
455
+ title: 'Channel Title',
456
+ axis: { title: 'Axis Title' },
457
+ },
458
+ ],
459
+ },
460
+ };
461
+ const rectMarks: RectMark[] = [
462
+ {
463
+ type: 'rect',
464
+ x: 50,
465
+ y: 30,
466
+ width: 200,
467
+ height: 40,
468
+ fill: '#1b7fa3',
469
+ data: { category: 'A', value: 100 },
470
+ aria: { label: 'bar' },
471
+ },
472
+ ];
473
+
474
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
475
+ const content = descriptors.get('rect-0')!;
476
+
477
+ expect(content.fields[0].label).toBe('Channel Title');
478
+ });
479
+
480
+ it('channel format takes precedence over axis.format', () => {
481
+ const spec: NormalizedChartSpec = {
482
+ ...makeBarSpec(),
483
+ encoding: {
484
+ ...makeBarSpec().encoding,
485
+ tooltip: [
486
+ {
487
+ field: 'value',
488
+ type: 'quantitative',
489
+ format: ',.0f',
490
+ axis: { format: '.2f' },
491
+ },
492
+ ],
493
+ },
494
+ };
495
+ const rectMarks: RectMark[] = [
496
+ {
497
+ type: 'rect',
498
+ x: 50,
499
+ y: 30,
500
+ width: 200,
501
+ height: 40,
502
+ fill: '#1b7fa3',
503
+ data: { category: 'A', value: 1500 },
504
+ aria: { label: 'bar' },
505
+ },
506
+ ];
507
+
508
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
509
+ const content = descriptors.get('rect-0')!;
510
+
511
+ expect(content.fields[0].value).toBe('1,500');
512
+ });
513
+
514
+ it('uses title and format on auto-generated tooltip fields (no explicit tooltip)', () => {
515
+ const spec: NormalizedChartSpec = {
516
+ ...makeBarSpec(),
517
+ encoding: {
518
+ x: { field: 'value', type: 'quantitative', title: 'Sales', format: '$,.0f' },
519
+ y: { field: 'category', type: 'nominal', title: 'Product' },
520
+ },
521
+ };
522
+ const rectMarks: RectMark[] = [
523
+ {
524
+ type: 'rect',
525
+ x: 50,
526
+ y: 30,
527
+ width: 200,
528
+ height: 40,
529
+ fill: '#1b7fa3',
530
+ data: { category: 'A', value: 2000 },
531
+ aria: { label: 'bar' },
532
+ },
533
+ ];
534
+
535
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
536
+ const content = descriptors.get('rect-0')!;
537
+
538
+ const productField = content.fields.find((f) => f.label === 'Product');
539
+ expect(productField).toBeDefined();
540
+ expect(productField!.value).toBe('A');
541
+
542
+ const salesField = content.fields.find((f) => f.label === 'Sales');
543
+ expect(salesField).toBeDefined();
544
+ expect(salesField!.value).toBe('$2,000');
545
+ });
546
+
547
+ it('falls back to axis.title when channel title is not set', () => {
548
+ const spec: NormalizedChartSpec = {
549
+ ...makeBarSpec(),
550
+ encoding: {
551
+ ...makeBarSpec().encoding,
552
+ tooltip: [{ field: 'value', type: 'quantitative', axis: { title: 'Axis Label' } }],
553
+ },
554
+ };
555
+ const rectMarks: RectMark[] = [
556
+ {
557
+ type: 'rect',
558
+ x: 50,
559
+ y: 30,
560
+ width: 200,
561
+ height: 40,
562
+ fill: '#1b7fa3',
563
+ data: { category: 'A', value: 100 },
564
+ aria: { label: 'bar' },
565
+ },
566
+ ];
567
+
568
+ const descriptors = computeTooltipDescriptors(spec, rectMarks);
569
+ const content = descriptors.get('rect-0')!;
570
+
571
+ expect(content.fields[0].label).toBe('Axis Label');
572
+ });
573
+ });
574
+
387
575
  describe('empty data', () => {
388
576
  it('returns empty map for no marks', () => {
389
577
  const spec = makeLineSpec();
@@ -57,11 +57,21 @@ function formatValue(value: unknown, fieldType?: string, format?: string): strin
57
57
  return String(value);
58
58
  }
59
59
 
60
+ /** Resolve the display label for an encoding channel: title > axis.title > field name. */
61
+ function resolveLabel(ch: EncodingChannel): string {
62
+ return ch.title ?? ch.axis?.title ?? ch.field;
63
+ }
64
+
65
+ /** Resolve the format string for an encoding channel: format > axis.format. */
66
+ function resolveFormat(ch: EncodingChannel): string | undefined {
67
+ return ch.format ?? ch.axis?.format;
68
+ }
69
+
60
70
  /** Build tooltip fields from explicit tooltip encoding channels. */
61
71
  function buildExplicitTooltipFields(row: DataRow, channels: EncodingChannel[]): TooltipField[] {
62
72
  return channels.map((ch) => ({
63
- label: ch.axis?.label ?? ch.field,
64
- value: formatValue(row[ch.field], ch.type, ch.axis?.format),
73
+ label: resolveLabel(ch),
74
+ value: formatValue(row[ch.field], ch.type, resolveFormat(ch)),
65
75
  }));
66
76
  }
67
77
 
@@ -74,28 +84,45 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
74
84
 
75
85
  const fields: TooltipField[] = [];
76
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
+
77
100
  // Y-axis value (the "main" value in most charts)
78
101
  if (encoding.y) {
79
102
  fields.push({
80
- label: encoding.y.axis?.label ?? encoding.y.field,
81
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
82
- color,
103
+ label: resolveLabel(encoding.y),
104
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
105
+ color: encoding.color ? undefined : color,
83
106
  });
84
107
  }
85
108
 
86
109
  // X-axis value (often the category or date)
87
110
  if (encoding.x) {
88
111
  fields.push({
89
- label: encoding.x.axis?.label ?? encoding.x.field,
90
- value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x.axis?.format),
112
+ label: resolveLabel(encoding.x),
113
+ value: formatValue(row[encoding.x.field], encoding.x.type, resolveFormat(encoding.x)),
91
114
  });
92
115
  }
93
116
 
94
117
  // Size (for scatter/bubble) - skip conditional size definitions
95
118
  if (encoding.size && 'field' in encoding.size) {
96
119
  fields.push({
97
- label: encoding.size.axis?.label ?? encoding.size.field,
98
- value: formatValue(row[encoding.size.field], encoding.size.type, encoding.size.axis?.format),
120
+ label: resolveLabel(encoding.size),
121
+ value: formatValue(
122
+ row[encoding.size.field],
123
+ encoding.size.type,
124
+ resolveFormat(encoding.size),
125
+ ),
99
126
  });
100
127
  }
101
128
 
@@ -124,6 +151,21 @@ function getTooltipTitle(row: DataRow, encoding: Encoding): string | undefined {
124
151
  return String(row[encoding.y.field] ?? '');
125
152
  }
126
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
+
127
169
  // For color-encoded series, use the series name (skip conditional defs)
128
170
  if (encoding.color && 'field' in encoding.color) {
129
171
  return String(row[encoding.color.field] ?? '');
@@ -191,14 +233,14 @@ function tooltipsForArc(
191
233
  if (encoding.y) {
192
234
  fields.push({
193
235
  label: categoryName,
194
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
236
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
195
237
  color: getRepresentativeColor(mark.fill),
196
238
  });
197
239
  }
198
240
  } else if (encoding.y) {
199
241
  fields.push({
200
- label: encoding.y.field,
201
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
242
+ label: resolveLabel(encoding.y),
243
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
202
244
  color: getRepresentativeColor(mark.fill),
203
245
  });
204
246
  }