@opendata-ai/openchart-engine 6.12.0 → 6.13.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 +878 -606
- 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 +389 -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/labels.ts +7 -1
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/line/area.ts +19 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +90 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +10 -13
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +106 -29
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +25 -11
- 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,25 @@ function uniqueStrings(values: unknown[]): string[] {
|
|
|
140
140
|
return result;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Apply sort order to categorical domain values (Vega-Lite aligned).
|
|
145
|
+
* - 'ascending': sort alphabetically/numerically ascending
|
|
146
|
+
* - 'descending': sort descending
|
|
147
|
+
* - null: preserve data order (no sorting)
|
|
148
|
+
* - undefined: ascending (VL default)
|
|
149
|
+
*/
|
|
150
|
+
function applyCategoricalSort(
|
|
151
|
+
values: string[],
|
|
152
|
+
sort: 'ascending' | 'descending' | null | undefined,
|
|
153
|
+
): string[] {
|
|
154
|
+
// null means use data order
|
|
155
|
+
if (sort === null) return values;
|
|
156
|
+
|
|
157
|
+
const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
158
|
+
if (sort === 'descending') sorted.reverse();
|
|
159
|
+
return sorted;
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
// ---------------------------------------------------------------------------
|
|
144
163
|
// Helpers: apply common scale config
|
|
145
164
|
// ---------------------------------------------------------------------------
|
|
@@ -439,7 +458,7 @@ function buildBandScale(
|
|
|
439
458
|
): ResolvedScale {
|
|
440
459
|
const values = channel.scale?.domain
|
|
441
460
|
? (channel.scale.domain as string[])
|
|
442
|
-
: uniqueStrings(fieldValues(data, channel.field));
|
|
461
|
+
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
443
462
|
|
|
444
463
|
const padding = channel.scale?.padding ?? 0.35;
|
|
445
464
|
const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
|
|
@@ -466,7 +485,7 @@ function buildPointScale(
|
|
|
466
485
|
): ResolvedScale {
|
|
467
486
|
const values = channel.scale?.domain
|
|
468
487
|
? (channel.scale.domain as string[])
|
|
469
|
-
: uniqueStrings(fieldValues(data, channel.field));
|
|
488
|
+
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
470
489
|
|
|
471
490
|
const padding = channel.scale?.padding ?? 0.5;
|
|
472
491
|
const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
|
|
@@ -484,7 +503,11 @@ function buildOrdinalColorScale(
|
|
|
484
503
|
data: DataRow[],
|
|
485
504
|
palette: string[],
|
|
486
505
|
): ResolvedScale {
|
|
487
|
-
|
|
506
|
+
// Use explicit domain if provided, otherwise derive from data
|
|
507
|
+
const explicitDomain = channel.scale?.domain as string[] | undefined;
|
|
508
|
+
const values = explicitDomain
|
|
509
|
+
? explicitDomain.map(String)
|
|
510
|
+
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
488
511
|
|
|
489
512
|
const scale = scaleOrdinal<string>().domain(values).range(palette);
|
|
490
513
|
|
|
@@ -622,6 +645,7 @@ export function computeScales(
|
|
|
622
645
|
// For stacked bars, the x-domain needs the max category sum, not max individual value.
|
|
623
646
|
// Without this, stacked bars would clip past the chart area.
|
|
624
647
|
let xData = data;
|
|
648
|
+
let xChannel = encoding.x;
|
|
625
649
|
const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
|
|
626
650
|
if (
|
|
627
651
|
spec.markType === 'bar' &&
|
|
@@ -629,25 +653,51 @@ export function computeScales(
|
|
|
629
653
|
encoding.x.type === 'quantitative' &&
|
|
630
654
|
!xStackDisabled
|
|
631
655
|
) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
656
|
+
if (encoding.x.stack === 'normalize') {
|
|
657
|
+
// Normalize: domain is [0, 1]
|
|
658
|
+
xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
|
|
659
|
+
} else if (encoding.x.stack === 'center') {
|
|
660
|
+
// Center: compute max half-sum for symmetric domain
|
|
661
|
+
const yField = encoding.y?.field;
|
|
662
|
+
const xField = encoding.x.field;
|
|
663
|
+
if (yField) {
|
|
664
|
+
const sums = new Map<string, number>();
|
|
665
|
+
for (const row of data) {
|
|
666
|
+
const cat = String(row[yField] ?? '');
|
|
667
|
+
const val = Number(row[xField] ?? 0);
|
|
668
|
+
if (Number.isFinite(val) && val > 0) {
|
|
669
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
673
|
+
const half = maxSum / 2;
|
|
674
|
+
xChannel = {
|
|
675
|
+
...encoding.x,
|
|
676
|
+
scale: { ...encoding.x.scale, domain: [-half, half], zero: true },
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
} else {
|
|
680
|
+
// Zero (default): domain extends to max category sum
|
|
681
|
+
const yField = encoding.y?.field;
|
|
682
|
+
const xField = encoding.x.field;
|
|
683
|
+
if (yField) {
|
|
684
|
+
const sums = new Map<string, number>();
|
|
685
|
+
for (const row of data) {
|
|
686
|
+
const cat = String(row[yField] ?? '');
|
|
687
|
+
const val = Number(row[xField] ?? 0);
|
|
688
|
+
if (Number.isFinite(val) && val > 0) {
|
|
689
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
690
|
+
}
|
|
641
691
|
}
|
|
692
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
693
|
+
// Create a synthetic row with the max stack sum so buildLinearScale sees it
|
|
694
|
+
xData = [...data, { [xField]: maxSum } as DataRow];
|
|
642
695
|
}
|
|
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
696
|
}
|
|
647
697
|
}
|
|
648
698
|
|
|
649
699
|
result.x = buildPositionalScale(
|
|
650
|
-
|
|
700
|
+
xChannel,
|
|
651
701
|
xData,
|
|
652
702
|
chartArea.x,
|
|
653
703
|
chartArea.x + chartArea.width,
|
|
@@ -662,6 +712,7 @@ export function computeScales(
|
|
|
662
712
|
// would clip above the chart area.
|
|
663
713
|
// Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
|
|
664
714
|
let yData = data;
|
|
715
|
+
let yChannel = encoding.y;
|
|
665
716
|
const isVerticalBar =
|
|
666
717
|
spec.markType === 'bar' &&
|
|
667
718
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
@@ -673,26 +724,52 @@ export function computeScales(
|
|
|
673
724
|
encoding.y.type === 'quantitative' &&
|
|
674
725
|
!yStackDisabled
|
|
675
726
|
) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
727
|
+
if (encoding.y.stack === 'normalize') {
|
|
728
|
+
// Normalize: domain is [0, 1] (VL convention)
|
|
729
|
+
yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
|
|
730
|
+
} else if (encoding.y.stack === 'center') {
|
|
731
|
+
// Center: compute max half-sum for symmetric domain
|
|
732
|
+
const xField = encoding.x?.field;
|
|
733
|
+
const yField = encoding.y.field;
|
|
734
|
+
if (xField) {
|
|
735
|
+
const sums = new Map<string, number>();
|
|
736
|
+
for (const row of data) {
|
|
737
|
+
const cat = String(row[xField] ?? '');
|
|
738
|
+
const val = Number(row[yField] ?? 0);
|
|
739
|
+
if (Number.isFinite(val) && val > 0) {
|
|
740
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
744
|
+
const half = maxSum / 2;
|
|
745
|
+
yChannel = {
|
|
746
|
+
...encoding.y,
|
|
747
|
+
scale: { ...encoding.y.scale, domain: [-half, half], zero: true },
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
// Zero (default): domain extends to max category sum
|
|
752
|
+
const xField = encoding.x?.field;
|
|
753
|
+
const yField = encoding.y.field;
|
|
754
|
+
if (xField) {
|
|
755
|
+
const sums = new Map<string, number>();
|
|
756
|
+
for (const row of data) {
|
|
757
|
+
const cat = String(row[xField] ?? '');
|
|
758
|
+
const val = Number(row[yField] ?? 0);
|
|
759
|
+
if (Number.isFinite(val) && val > 0) {
|
|
760
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
761
|
+
}
|
|
685
762
|
}
|
|
763
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
764
|
+
// Create a synthetic row with the max stack sum so buildLinearScale sees it
|
|
765
|
+
yData = [...data, { [yField]: maxSum } as DataRow];
|
|
686
766
|
}
|
|
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
767
|
}
|
|
691
768
|
}
|
|
692
769
|
|
|
693
770
|
// Y axis: range is inverted (SVG y goes down, data y goes up)
|
|
694
771
|
result.y = buildPositionalScale(
|
|
695
|
-
|
|
772
|
+
yChannel,
|
|
696
773
|
yData,
|
|
697
774
|
chartArea.y + chartArea.height,
|
|
698
775
|
chartArea.y,
|
|
@@ -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
|
|
|
@@ -77,8 +87,8 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
|
|
|
77
87
|
// Y-axis value (the "main" value in most charts)
|
|
78
88
|
if (encoding.y) {
|
|
79
89
|
fields.push({
|
|
80
|
-
label: encoding.y
|
|
81
|
-
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y
|
|
90
|
+
label: resolveLabel(encoding.y),
|
|
91
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
|
|
82
92
|
color,
|
|
83
93
|
});
|
|
84
94
|
}
|
|
@@ -86,16 +96,20 @@ function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipF
|
|
|
86
96
|
// X-axis value (often the category or date)
|
|
87
97
|
if (encoding.x) {
|
|
88
98
|
fields.push({
|
|
89
|
-
label: encoding.x
|
|
90
|
-
value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x
|
|
99
|
+
label: resolveLabel(encoding.x),
|
|
100
|
+
value: formatValue(row[encoding.x.field], encoding.x.type, resolveFormat(encoding.x)),
|
|
91
101
|
});
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
// Size (for scatter/bubble) - skip conditional size definitions
|
|
95
105
|
if (encoding.size && 'field' in encoding.size) {
|
|
96
106
|
fields.push({
|
|
97
|
-
label: encoding.size
|
|
98
|
-
value: formatValue(
|
|
107
|
+
label: resolveLabel(encoding.size),
|
|
108
|
+
value: formatValue(
|
|
109
|
+
row[encoding.size.field],
|
|
110
|
+
encoding.size.type,
|
|
111
|
+
resolveFormat(encoding.size),
|
|
112
|
+
),
|
|
99
113
|
});
|
|
100
114
|
}
|
|
101
115
|
|
|
@@ -191,14 +205,14 @@ function tooltipsForArc(
|
|
|
191
205
|
if (encoding.y) {
|
|
192
206
|
fields.push({
|
|
193
207
|
label: categoryName,
|
|
194
|
-
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y
|
|
208
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
|
|
195
209
|
color: getRepresentativeColor(mark.fill),
|
|
196
210
|
});
|
|
197
211
|
}
|
|
198
212
|
} else if (encoding.y) {
|
|
199
213
|
fields.push({
|
|
200
|
-
label: encoding.y
|
|
201
|
-
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y
|
|
214
|
+
label: resolveLabel(encoding.y),
|
|
215
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
|
|
202
216
|
color: getRepresentativeColor(mark.fill),
|
|
203
217
|
});
|
|
204
218
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runAggregate } from '../aggregate';
|
|
3
|
+
|
|
4
|
+
describe('runAggregate', () => {
|
|
5
|
+
const data = [
|
|
6
|
+
{ region: 'North', product: 'A', revenue: 100, qty: 10 },
|
|
7
|
+
{ region: 'North', product: 'B', revenue: 200, qty: 20 },
|
|
8
|
+
{ region: 'South', product: 'A', revenue: 150, qty: 15 },
|
|
9
|
+
{ region: 'South', product: 'B', revenue: 250, qty: 25 },
|
|
10
|
+
{ region: 'South', product: 'A', revenue: 50, qty: 5 },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
it('computes sum aggregate grouped by one field', () => {
|
|
14
|
+
const result = runAggregate(data, {
|
|
15
|
+
aggregate: [{ op: 'sum', field: 'revenue', as: 'total_revenue' }],
|
|
16
|
+
groupby: ['region'],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(result).toHaveLength(2);
|
|
20
|
+
const north = result.find((r) => r.region === 'North');
|
|
21
|
+
const south = result.find((r) => r.region === 'South');
|
|
22
|
+
expect(north?.total_revenue).toBe(300);
|
|
23
|
+
expect(south?.total_revenue).toBe(450);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('computes mean aggregate', () => {
|
|
27
|
+
const result = runAggregate(data, {
|
|
28
|
+
aggregate: [{ op: 'mean', field: 'revenue', as: 'avg_revenue' }],
|
|
29
|
+
groupby: ['region'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const north = result.find((r) => r.region === 'North');
|
|
33
|
+
const south = result.find((r) => r.region === 'South');
|
|
34
|
+
expect(north?.avg_revenue).toBe(150); // (100+200)/2
|
|
35
|
+
expect(south?.avg_revenue).toBe(150); // (150+250+50)/3
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('computes count aggregate', () => {
|
|
39
|
+
const result = runAggregate(data, {
|
|
40
|
+
aggregate: [{ op: 'count', field: 'revenue', as: 'num_rows' }],
|
|
41
|
+
groupby: ['region'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const north = result.find((r) => r.region === 'North');
|
|
45
|
+
const south = result.find((r) => r.region === 'South');
|
|
46
|
+
expect(north?.num_rows).toBe(2);
|
|
47
|
+
expect(south?.num_rows).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('computes median aggregate', () => {
|
|
51
|
+
const result = runAggregate(data, {
|
|
52
|
+
aggregate: [{ op: 'median', field: 'revenue', as: 'med_revenue' }],
|
|
53
|
+
groupby: ['region'],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const north = result.find((r) => r.region === 'North');
|
|
57
|
+
const south = result.find((r) => r.region === 'South');
|
|
58
|
+
expect(north?.med_revenue).toBe(150); // median of [100, 200]
|
|
59
|
+
expect(south?.med_revenue).toBe(150); // median of [50, 150, 250]
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('computes min and max aggregates', () => {
|
|
63
|
+
const result = runAggregate(data, {
|
|
64
|
+
aggregate: [
|
|
65
|
+
{ op: 'min', field: 'revenue', as: 'min_rev' },
|
|
66
|
+
{ op: 'max', field: 'revenue', as: 'max_rev' },
|
|
67
|
+
],
|
|
68
|
+
groupby: ['region'],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const south = result.find((r) => r.region === 'South');
|
|
72
|
+
expect(south?.min_rev).toBe(50);
|
|
73
|
+
expect(south?.max_rev).toBe(250);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('supports multiple groupby fields', () => {
|
|
77
|
+
const result = runAggregate(data, {
|
|
78
|
+
aggregate: [{ op: 'sum', field: 'revenue', as: 'total' }],
|
|
79
|
+
groupby: ['region', 'product'],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(result).toHaveLength(4);
|
|
83
|
+
const southA = result.find((r) => r.region === 'South' && r.product === 'A');
|
|
84
|
+
expect(southA?.total).toBe(200); // 150 + 50
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('supports multiple aggregate ops in one transform', () => {
|
|
88
|
+
const result = runAggregate(data, {
|
|
89
|
+
aggregate: [
|
|
90
|
+
{ op: 'sum', field: 'revenue', as: 'total_rev' },
|
|
91
|
+
{ op: 'mean', field: 'qty', as: 'avg_qty' },
|
|
92
|
+
],
|
|
93
|
+
groupby: ['region'],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const north = result.find((r) => r.region === 'North');
|
|
97
|
+
expect(north?.total_rev).toBe(300);
|
|
98
|
+
expect(north?.avg_qty).toBe(15); // (10+20)/2
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('computes variance aggregate', () => {
|
|
102
|
+
const result = runAggregate(data, {
|
|
103
|
+
aggregate: [{ op: 'variance', field: 'revenue', as: 'var_rev' }],
|
|
104
|
+
groupby: ['region'],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const south = result.find((r) => r.region === 'South');
|
|
108
|
+
// South values: [150, 250, 50], mean=150, variance = ((0)^2 + (100)^2 + (-100)^2) / 3
|
|
109
|
+
expect(south?.var_rev).toBeCloseTo(6666.667, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('computes stdev aggregate', () => {
|
|
113
|
+
const result = runAggregate(data, {
|
|
114
|
+
aggregate: [{ op: 'stdev', field: 'revenue', as: 'sd_rev' }],
|
|
115
|
+
groupby: ['region'],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const south = result.find((r) => r.region === 'South');
|
|
119
|
+
// sqrt(6666.667) ≈ 81.65
|
|
120
|
+
expect(south?.sd_rev).toBeCloseTo(81.65, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('computes distinct aggregate (counts unique raw values)', () => {
|
|
124
|
+
const result = runAggregate(data, {
|
|
125
|
+
aggregate: [{ op: 'distinct', field: 'product', as: 'n_products' }],
|
|
126
|
+
groupby: ['region'],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const north = result.find((r) => r.region === 'North');
|
|
130
|
+
const south = result.find((r) => r.region === 'South');
|
|
131
|
+
expect(north?.n_products).toBe(2); // A, B
|
|
132
|
+
expect(south?.n_products).toBe(2); // A, B (A appears twice but distinct=2)
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('computes q1 and q3 aggregates', () => {
|
|
136
|
+
const result = runAggregate(data, {
|
|
137
|
+
aggregate: [
|
|
138
|
+
{ op: 'q1', field: 'revenue', as: 'q1_rev' },
|
|
139
|
+
{ op: 'q3', field: 'revenue', as: 'q3_rev' },
|
|
140
|
+
],
|
|
141
|
+
groupby: ['region'],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const south = result.find((r) => r.region === 'South');
|
|
145
|
+
// South values sorted: [50, 150, 250]
|
|
146
|
+
// q1: index = (3-1)*0.25 = 0.5 -> 50 + 0.5*(150-50) = 100
|
|
147
|
+
// q3: index = (3-1)*0.75 = 1.5 -> 150 + 0.5*(250-150) = 200
|
|
148
|
+
expect(south?.q1_rev).toBe(100);
|
|
149
|
+
expect(south?.q3_rev).toBe(200);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('handles empty data', () => {
|
|
153
|
+
const result = runAggregate([], {
|
|
154
|
+
aggregate: [{ op: 'sum', field: 'revenue', as: 'total' }],
|
|
155
|
+
groupby: ['region'],
|
|
156
|
+
});
|
|
157
|
+
expect(result).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|