@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.
- package/dist/index.js +1022 -648
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +390 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/index.ts +3 -0
- package/src/charts/bar/labels.ts +38 -14
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/column/index.ts +3 -0
- package/src/charts/column/labels.ts +35 -13
- package/src/charts/dot/index.ts +10 -1
- package/src/charts/dot/labels.ts +37 -6
- package/src/charts/line/area.ts +31 -6
- package/src/charts/line/compute.ts +7 -2
- package/src/charts/line/index.ts +33 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +91 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +12 -15
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +116 -36
- package/src/legend/compute.ts +2 -4
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +54 -12
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- package/src/transforms/index.ts +8 -0
package/src/layout/scales.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
775
|
+
yChannel,
|
|
696
776
|
yData,
|
|
697
777
|
chartArea.y + chartArea.height,
|
|
698
778
|
chartArea.y,
|
package/src/legend/compute.ts
CHANGED
|
@@ -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();
|
package/src/tooltips/compute.ts
CHANGED
|
@@ -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
|
|
64
|
-
value: formatValue(row[ch.field], ch.type, ch
|
|
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
|
|
81
|
-
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y
|
|
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
|
|
90
|
-
value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x
|
|
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
|
|
98
|
-
value: formatValue(
|
|
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
|
|
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
|
|
201
|
-
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y
|
|
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
|
}
|