@opendata-ai/openchart-engine 6.11.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.d.ts +7 -0
- package/dist/index.js +944 -629
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- 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 +113 -169
- package/src/compiler/__tests__/normalize.test.ts +110 -0
- package/src/compiler/normalize.ts +22 -3
- package/src/compiler/types.ts +4 -0
- package/src/graphs/compile-graph.ts +8 -0
- package/src/graphs/types.ts +2 -0
- package/src/layout/axes.ts +10 -13
- package/src/layout/dimensions.ts +6 -3
- package/src/layout/scales.ts +106 -29
- package/src/legend/compute.ts +3 -1
- package/src/sankey/compile-sankey.ts +12 -2
- package/src/sankey/types.ts +1 -0
- package/src/tables/compile-table.ts +5 -0
- 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
|
@@ -382,6 +382,7 @@ export function compileTableLayout(
|
|
|
382
382
|
});
|
|
383
383
|
|
|
384
384
|
// 9. Compute chrome
|
|
385
|
+
const watermark = spec.watermark;
|
|
385
386
|
const chrome = computeChrome(
|
|
386
387
|
{
|
|
387
388
|
title: spec.chrome.title,
|
|
@@ -393,6 +394,9 @@ export function compileTableLayout(
|
|
|
393
394
|
theme,
|
|
394
395
|
options.width,
|
|
395
396
|
options.measureText,
|
|
397
|
+
'full',
|
|
398
|
+
undefined,
|
|
399
|
+
watermark,
|
|
396
400
|
);
|
|
397
401
|
|
|
398
402
|
// 10. Build a11y
|
|
@@ -418,5 +422,6 @@ export function compileTableLayout(
|
|
|
418
422
|
},
|
|
419
423
|
theme,
|
|
420
424
|
animation: resolveAnimation(spec.animation),
|
|
425
|
+
watermark,
|
|
421
426
|
};
|
|
422
427
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runFold } from '../fold';
|
|
3
|
+
|
|
4
|
+
describe('runFold', () => {
|
|
5
|
+
const data = [
|
|
6
|
+
{ country: 'US', gold: 10, silver: 20, bronze: 30 },
|
|
7
|
+
{ country: 'UK', gold: 5, silver: 15, bronze: 25 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
it('folds two columns with default key/value names', () => {
|
|
11
|
+
const result = runFold(data, {
|
|
12
|
+
fold: ['gold', 'silver'],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(result).toHaveLength(4); // 2 rows x 2 fold fields
|
|
16
|
+
expect(result[0]).toEqual({ country: 'US', bronze: 30, key: 'gold', value: 10 });
|
|
17
|
+
expect(result[1]).toEqual({ country: 'US', bronze: 30, key: 'silver', value: 20 });
|
|
18
|
+
expect(result[2]).toEqual({ country: 'UK', bronze: 25, key: 'gold', value: 5 });
|
|
19
|
+
expect(result[3]).toEqual({ country: 'UK', bronze: 25, key: 'silver', value: 15 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('folds three columns', () => {
|
|
23
|
+
const result = runFold(data, {
|
|
24
|
+
fold: ['gold', 'silver', 'bronze'],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result).toHaveLength(6); // 2 rows x 3 fold fields
|
|
28
|
+
// First row's fold outputs
|
|
29
|
+
expect(result[0].key).toBe('gold');
|
|
30
|
+
expect(result[0].value).toBe(10);
|
|
31
|
+
expect(result[1].key).toBe('silver');
|
|
32
|
+
expect(result[1].value).toBe(20);
|
|
33
|
+
expect(result[2].key).toBe('bronze');
|
|
34
|
+
expect(result[2].value).toBe(30);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('uses custom as names', () => {
|
|
38
|
+
const result = runFold(data, {
|
|
39
|
+
fold: ['gold', 'silver'],
|
|
40
|
+
as: ['medal', 'count'],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result[0].medal).toBe('gold');
|
|
44
|
+
expect(result[0].count).toBe(10);
|
|
45
|
+
expect(result[1].medal).toBe('silver');
|
|
46
|
+
expect(result[1].count).toBe(20);
|
|
47
|
+
// Default key/value shouldn't be present
|
|
48
|
+
expect(result[0].key).toBeUndefined();
|
|
49
|
+
expect(result[0].value).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('preserves non-fold fields', () => {
|
|
53
|
+
const result = runFold(data, {
|
|
54
|
+
fold: ['gold'],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// country and bronze are non-fold fields
|
|
58
|
+
expect(result[0].country).toBe('US');
|
|
59
|
+
expect(result[0].bronze).toBe(30);
|
|
60
|
+
// gold should not be a direct field (it's now key/value)
|
|
61
|
+
expect(result[0].gold).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('handles undefined fold field values', () => {
|
|
65
|
+
const sparse = [{ name: 'test', a: 1 }]; // no 'b' field
|
|
66
|
+
const result = runFold(sparse, {
|
|
67
|
+
fold: ['a', 'b'],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result).toHaveLength(2);
|
|
71
|
+
expect(result[0].value).toBe(1);
|
|
72
|
+
expect(result[1].value).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('handles empty data', () => {
|
|
76
|
+
const result = runFold([], { fold: ['gold', 'silver'] });
|
|
77
|
+
expect(result).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate transform: groups rows and computes summary statistics.
|
|
3
|
+
*
|
|
4
|
+
* Follows Vega-Lite aggregate transform conventions.
|
|
5
|
+
* Groups input data by the specified fields, then applies aggregate
|
|
6
|
+
* operations (sum, mean, count, etc.) to produce one row per group.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AggregateOp, AggregateTransform, DataRow } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute a single aggregate operation over an array of numeric values.
|
|
13
|
+
*/
|
|
14
|
+
function computeAggregate(op: AggregateOp, values: number[]): number {
|
|
15
|
+
if (values.length === 0) return 0;
|
|
16
|
+
|
|
17
|
+
switch (op) {
|
|
18
|
+
case 'count':
|
|
19
|
+
return values.length;
|
|
20
|
+
case 'sum':
|
|
21
|
+
return values.reduce((a, b) => a + b, 0);
|
|
22
|
+
case 'mean': {
|
|
23
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
24
|
+
return sum / values.length;
|
|
25
|
+
}
|
|
26
|
+
case 'median': {
|
|
27
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
28
|
+
const mid = Math.floor(sorted.length / 2);
|
|
29
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
30
|
+
}
|
|
31
|
+
case 'min':
|
|
32
|
+
return Math.min(...values);
|
|
33
|
+
case 'max':
|
|
34
|
+
return Math.max(...values);
|
|
35
|
+
case 'variance': {
|
|
36
|
+
if (values.length < 2) return 0;
|
|
37
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
38
|
+
return values.reduce((a, v) => a + (v - mean) ** 2, 0) / values.length;
|
|
39
|
+
}
|
|
40
|
+
case 'stdev': {
|
|
41
|
+
if (values.length < 2) return 0;
|
|
42
|
+
const m = values.reduce((a, b) => a + b, 0) / values.length;
|
|
43
|
+
return Math.sqrt(values.reduce((a, v) => a + (v - m) ** 2, 0) / values.length);
|
|
44
|
+
}
|
|
45
|
+
case 'q1': {
|
|
46
|
+
const s = [...values].sort((a, b) => a - b);
|
|
47
|
+
const i = (s.length - 1) * 0.25;
|
|
48
|
+
const lo = Math.floor(i);
|
|
49
|
+
const frac = i - lo;
|
|
50
|
+
return s[lo] + frac * ((s[lo + 1] ?? s[lo]) - s[lo]);
|
|
51
|
+
}
|
|
52
|
+
case 'q3': {
|
|
53
|
+
const s = [...values].sort((a, b) => a - b);
|
|
54
|
+
const i = (s.length - 1) * 0.75;
|
|
55
|
+
const lo = Math.floor(i);
|
|
56
|
+
const frac = i - lo;
|
|
57
|
+
return s[lo] + frac * ((s[lo + 1] ?? s[lo]) - s[lo]);
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build a composite group key from a row's groupby field values.
|
|
66
|
+
*/
|
|
67
|
+
function groupKey(row: DataRow, groupby: string[]): string {
|
|
68
|
+
return groupby.map((f) => String(row[f] ?? '')).join('\x00');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Apply an aggregate transform to data rows.
|
|
73
|
+
*
|
|
74
|
+
* Groups rows by the groupby fields, then computes each aggregate
|
|
75
|
+
* operation within each group. Returns one row per group containing
|
|
76
|
+
* the groupby field values plus computed aggregate fields.
|
|
77
|
+
*
|
|
78
|
+
* @param data - Input rows.
|
|
79
|
+
* @param transform - Aggregate transform definition.
|
|
80
|
+
* @returns Aggregated rows (one per group).
|
|
81
|
+
*/
|
|
82
|
+
export function runAggregate(data: DataRow[], transform: AggregateTransform): DataRow[] {
|
|
83
|
+
const { aggregate, groupby } = transform;
|
|
84
|
+
|
|
85
|
+
// Group rows by the groupby fields
|
|
86
|
+
const groups = new Map<string, DataRow[]>();
|
|
87
|
+
for (const row of data) {
|
|
88
|
+
const key = groupKey(row, groupby);
|
|
89
|
+
const existing = groups.get(key);
|
|
90
|
+
if (existing) {
|
|
91
|
+
existing.push(row);
|
|
92
|
+
} else {
|
|
93
|
+
groups.set(key, [row]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Compute aggregates for each group
|
|
98
|
+
const result: DataRow[] = [];
|
|
99
|
+
for (const rows of groups.values()) {
|
|
100
|
+
// Start with groupby field values from the first row in the group
|
|
101
|
+
const outRow: DataRow = {};
|
|
102
|
+
for (const field of groupby) {
|
|
103
|
+
outRow[field] = rows[0][field];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Compute each aggregate operation
|
|
107
|
+
for (const agg of aggregate) {
|
|
108
|
+
// distinct counts unique raw values (not just numeric)
|
|
109
|
+
if (agg.op === 'distinct') {
|
|
110
|
+
outRow[agg.as] = new Set(rows.map((r) => r[agg.field])).size;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const values = rows
|
|
115
|
+
.map((r) => {
|
|
116
|
+
// For count, the field value doesn't matter, just count rows
|
|
117
|
+
if (agg.op === 'count') return 1;
|
|
118
|
+
const v = Number(r[agg.field]);
|
|
119
|
+
return Number.isFinite(v) ? v : NaN;
|
|
120
|
+
})
|
|
121
|
+
.filter((v) => !Number.isNaN(v));
|
|
122
|
+
|
|
123
|
+
outRow[agg.as] = computeAggregate(agg.op, values);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
result.push(outRow);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
130
|
+
}
|