@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.
Files changed (45) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +944 -629
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +3 -0
  6. package/src/__tests__/axes.test.ts +12 -30
  7. package/src/__tests__/compile-chart.test.ts +4 -4
  8. package/src/__tests__/dimensions.test.ts +2 -2
  9. package/src/__tests__/encoding-sugar.test.ts +389 -0
  10. package/src/annotations/collisions.ts +268 -0
  11. package/src/annotations/compute.ts +9 -912
  12. package/src/annotations/constants.ts +32 -0
  13. package/src/annotations/geometry.ts +167 -0
  14. package/src/annotations/position.ts +95 -0
  15. package/src/annotations/resolve-range.ts +98 -0
  16. package/src/annotations/resolve-refline.ts +148 -0
  17. package/src/annotations/resolve-text.ts +134 -0
  18. package/src/charts/__tests__/post-process.test.ts +258 -0
  19. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  20. package/src/charts/bar/compute.ts +27 -6
  21. package/src/charts/bar/labels.ts +7 -1
  22. package/src/charts/column/__tests__/compute.test.ts +99 -0
  23. package/src/charts/column/compute.ts +27 -6
  24. package/src/charts/line/area.ts +19 -2
  25. package/src/charts/post-process.ts +215 -0
  26. package/src/compile.ts +113 -169
  27. package/src/compiler/__tests__/normalize.test.ts +110 -0
  28. package/src/compiler/normalize.ts +22 -3
  29. package/src/compiler/types.ts +4 -0
  30. package/src/graphs/compile-graph.ts +8 -0
  31. package/src/graphs/types.ts +2 -0
  32. package/src/layout/axes.ts +10 -13
  33. package/src/layout/dimensions.ts +6 -3
  34. package/src/layout/scales.ts +106 -29
  35. package/src/legend/compute.ts +3 -1
  36. package/src/sankey/compile-sankey.ts +12 -2
  37. package/src/sankey/types.ts +1 -0
  38. package/src/tables/compile-table.ts +5 -0
  39. package/src/tooltips/__tests__/compute.test.ts +188 -0
  40. package/src/tooltips/compute.ts +25 -11
  41. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  42. package/src/transforms/__tests__/fold.test.ts +79 -0
  43. package/src/transforms/aggregate.ts +130 -0
  44. package/src/transforms/fold.ts +49 -0
  45. 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();
@@ -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
+ });
@@ -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
+ }