@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.
- package/dist/index.js +1022 -648
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +390 -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/index.ts +3 -0
- package/src/charts/bar/labels.ts +38 -14
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/column/index.ts +3 -0
- package/src/charts/column/labels.ts +35 -13
- package/src/charts/dot/index.ts +10 -1
- package/src/charts/dot/labels.ts +37 -6
- package/src/charts/line/area.ts +31 -6
- package/src/charts/line/compute.ts +7 -2
- package/src/charts/line/index.ts +33 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +91 -158
- package/src/compiler/normalize.ts +2 -2
- package/src/layout/axes.ts +12 -15
- package/src/layout/dimensions.ts +3 -3
- package/src/layout/scales.ts +116 -36
- package/src/legend/compute.ts +2 -4
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +54 -12
- 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
|
@@ -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
|
+
}
|
package/src/transforms/index.ts
CHANGED
|
@@ -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
|
|