@opendata-ai/openchart-engine 6.12.0 → 6.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/index.js +1022 -648
  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 +390 -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/index.ts +3 -0
  20. package/src/charts/bar/labels.ts +38 -14
  21. package/src/charts/column/__tests__/compute.test.ts +99 -0
  22. package/src/charts/column/compute.ts +27 -6
  23. package/src/charts/column/index.ts +3 -0
  24. package/src/charts/column/labels.ts +35 -13
  25. package/src/charts/dot/index.ts +10 -1
  26. package/src/charts/dot/labels.ts +37 -6
  27. package/src/charts/line/area.ts +31 -6
  28. package/src/charts/line/compute.ts +7 -2
  29. package/src/charts/line/index.ts +33 -2
  30. package/src/charts/post-process.ts +215 -0
  31. package/src/compile.ts +91 -158
  32. package/src/compiler/normalize.ts +2 -2
  33. package/src/layout/axes.ts +12 -15
  34. package/src/layout/dimensions.ts +3 -3
  35. package/src/layout/scales.ts +116 -36
  36. package/src/legend/compute.ts +2 -4
  37. package/src/tooltips/__tests__/compute.test.ts +188 -0
  38. package/src/tooltips/compute.ts +54 -12
  39. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  40. package/src/transforms/__tests__/fold.test.ts +79 -0
  41. package/src/transforms/aggregate.ts +130 -0
  42. package/src/transforms/fold.ts +49 -0
  43. package/src/transforms/index.ts +8 -0
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Fold transform: unpivots wide-format data into long format.
3
+ *
4
+ * Follows Vega-Lite fold transform conventions.
5
+ * For each input row, creates N new rows (one per fold field),
6
+ * copying all non-fold fields and adding key/value columns.
7
+ */
8
+
9
+ import type { DataRow, FoldTransform } from '@opendata-ai/openchart-core';
10
+
11
+ /**
12
+ * Apply a fold transform to data rows.
13
+ *
14
+ * For each input row, creates one output row per fold field. Each output
15
+ * row contains all fields from the original row (except the fold fields),
16
+ * plus a key field (the fold field name) and a value field (the fold field value).
17
+ *
18
+ * @param data - Input rows.
19
+ * @param transform - Fold transform definition.
20
+ * @returns Folded rows (N rows per input row, where N = fold fields count).
21
+ */
22
+ export function runFold(data: DataRow[], transform: FoldTransform): DataRow[] {
23
+ const { fold } = transform;
24
+ const [keyAs, valueAs] = transform.as ?? ['key', 'value'];
25
+ const foldSet = new Set(fold);
26
+
27
+ const result: DataRow[] = [];
28
+
29
+ for (const row of data) {
30
+ // Copy all non-fold fields
31
+ const base: DataRow = {};
32
+ for (const [k, v] of Object.entries(row)) {
33
+ if (!foldSet.has(k)) {
34
+ base[k] = v;
35
+ }
36
+ }
37
+
38
+ // Create one row per fold field
39
+ for (const field of fold) {
40
+ result.push({
41
+ ...base,
42
+ [keyAs]: field,
43
+ [valueAs]: row[field],
44
+ });
45
+ }
46
+ }
47
+
48
+ return result;
49
+ }
@@ -7,15 +7,19 @@
7
7
  */
8
8
 
9
9
  import type { DataRow, Transform } from '@opendata-ai/openchart-core';
10
+ import { runAggregate } from './aggregate';
10
11
  import { runBin } from './bin';
11
12
  import { runCalculate } from './calculate';
12
13
  import { runFilter } from './filter';
14
+ import { runFold } from './fold';
13
15
  import { runTimeUnit } from './timeunit';
14
16
 
17
+ export { runAggregate } from './aggregate';
15
18
  export { runBin } from './bin';
16
19
  export { runCalculate } from './calculate';
17
20
  export { isConditionalValueDef, resolveConditionalValue } from './conditional';
18
21
  export { runFilter } from './filter';
22
+ export { runFold } from './fold';
19
23
  export { evaluatePredicate } from './predicates';
20
24
  export { runTimeUnit } from './timeunit';
21
25
 
@@ -41,6 +45,10 @@ export function runTransforms(data: DataRow[], transforms: Transform[]): DataRow
41
45
  result = runCalculate(result, transform);
42
46
  } else if ('timeUnit' in transform) {
43
47
  result = runTimeUnit(result, transform);
48
+ } else if ('aggregate' in transform) {
49
+ result = runAggregate(result, transform);
50
+ } else if ('fold' in transform) {
51
+ result = runFold(result, transform);
44
52
  }
45
53
  }
46
54