@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.
Files changed (35) hide show
  1. package/dist/index.js +878 -606
  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 +389 -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/labels.ts +7 -1
  20. package/src/charts/column/__tests__/compute.test.ts +99 -0
  21. package/src/charts/column/compute.ts +27 -6
  22. package/src/charts/line/area.ts +19 -2
  23. package/src/charts/post-process.ts +215 -0
  24. package/src/compile.ts +90 -158
  25. package/src/compiler/normalize.ts +2 -2
  26. package/src/layout/axes.ts +10 -13
  27. package/src/layout/dimensions.ts +3 -3
  28. package/src/layout/scales.ts +106 -29
  29. package/src/tooltips/__tests__/compute.test.ts +188 -0
  30. package/src/tooltips/compute.ts +25 -11
  31. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  32. package/src/transforms/__tests__/fold.test.ts +79 -0
  33. package/src/transforms/aggregate.ts +130 -0
  34. package/src/transforms/fold.ts +49 -0
  35. package/src/transforms/index.ts +8 -0
@@ -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
- const values = uniqueStrings(fieldValues(data, channel.field));
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
- 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);
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
- encoding.x,
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
- 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);
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
- encoding.y,
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();
@@ -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
 
@@ -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.axis?.label ?? encoding.y.field,
81
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
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.axis?.label ?? encoding.x.field,
90
- value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x.axis?.format),
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.axis?.label ?? encoding.size.field,
98
- value: formatValue(row[encoding.size.field], encoding.size.type, encoding.size.axis?.format),
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.axis?.format),
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.field,
201
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
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
+ });