@opendata-ai/openchart-engine 6.25.4 → 6.26.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 +46 -4
- package/dist/index.js +862 -66
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/compile.ts +47 -13
- package/src/compiler/normalize.ts +57 -1
- package/src/compiler/types.ts +3 -1
- package/src/compiler/validate.ts +124 -5
- package/src/index.ts +16 -1
- package/src/layout/axes/ticks.ts +34 -2
- package/src/layout/axes.ts +27 -1
- package/src/layout/dimensions.ts +21 -3
- package/src/sankey/compile-sankey.ts +1 -1
- package/src/tilemap/__tests__/compile-tilemap.test.ts +322 -0
- package/src/tilemap/compile-tilemap.ts +383 -0
- package/src/tilemap/layout.ts +172 -0
- package/src/tilemap/types.ts +32 -0
- package/src/transforms/__tests__/filter-relative.test.ts +202 -0
- package/src/transforms/__tests__/window.test.ts +286 -0
- package/src/transforms/filter.ts +108 -3
- package/src/transforms/index.ts +5 -1
- package/src/transforms/predicates.ts +39 -9
- package/src/transforms/window.ts +185 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runFilter } from '../filter';
|
|
3
|
+
import { runTransforms } from '../index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate monthly rows: one row per month for `months` months starting from `startDate`.
|
|
7
|
+
*/
|
|
8
|
+
function monthlyData(startDate: string, months: number) {
|
|
9
|
+
const rows = [];
|
|
10
|
+
const start = new Date(startDate);
|
|
11
|
+
for (let i = 0; i < months; i++) {
|
|
12
|
+
const d = new Date(start);
|
|
13
|
+
d.setMonth(d.getMonth() + i);
|
|
14
|
+
rows.push({ date: d.toISOString().slice(0, 10), value: i + 1 });
|
|
15
|
+
}
|
|
16
|
+
return rows;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate daily rows: one row per day for `days` days starting from `startDate`.
|
|
21
|
+
*/
|
|
22
|
+
function dailyData(startDate: string, days: number) {
|
|
23
|
+
const rows = [];
|
|
24
|
+
const start = new Date(startDate);
|
|
25
|
+
for (let i = 0; i < days; i++) {
|
|
26
|
+
const d = new Date(start);
|
|
27
|
+
d.setDate(d.getDate() + i);
|
|
28
|
+
rows.push({ date: d.toISOString().slice(0, 10), value: i + 1 });
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('relative-time filter', () => {
|
|
34
|
+
it('last 1 year from max: 60 monthly rows, gte anchor max offset -1 year returns ~12 rows', () => {
|
|
35
|
+
const data = monthlyData('2020-01-01', 60); // Jan 2020 through Dec 2024
|
|
36
|
+
const result = runFilter(data, {
|
|
37
|
+
field: 'date',
|
|
38
|
+
gte: { anchor: 'max', offset: -1, unit: 'year' },
|
|
39
|
+
});
|
|
40
|
+
// Max date is 2024-12-01, minus 1 year = 2023-12-01
|
|
41
|
+
// Should include Dec 2023 through Dec 2024 = 13 rows
|
|
42
|
+
expect(result.length).toBeGreaterThanOrEqual(12);
|
|
43
|
+
expect(result.length).toBeLessThanOrEqual(13);
|
|
44
|
+
// All returned dates should be >= 2023-12-01
|
|
45
|
+
for (const row of result) {
|
|
46
|
+
expect(new Date(row.date as string).getTime()).toBeGreaterThanOrEqual(
|
|
47
|
+
new Date('2023-12-01').getTime(),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('last 3 years from max: 60 monthly rows, offset -3 returns ~36 rows', () => {
|
|
53
|
+
const data = monthlyData('2020-01-01', 60);
|
|
54
|
+
const result = runFilter(data, {
|
|
55
|
+
field: 'date',
|
|
56
|
+
gte: { anchor: 'max', offset: -3, unit: 'year' },
|
|
57
|
+
});
|
|
58
|
+
// Max is 2024-12-01, minus 3 years = 2021-12-01
|
|
59
|
+
// Dec 2021 through Dec 2024 = 37 rows
|
|
60
|
+
expect(result.length).toBeGreaterThanOrEqual(36);
|
|
61
|
+
expect(result.length).toBeLessThanOrEqual(37);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('last 30 days: 90 daily rows, offset -30 day returns 30-31 rows', () => {
|
|
65
|
+
const data = dailyData('2024-01-01', 90);
|
|
66
|
+
const result = runFilter(data, {
|
|
67
|
+
field: 'date',
|
|
68
|
+
gte: { anchor: 'max', offset: -30, unit: 'day' },
|
|
69
|
+
});
|
|
70
|
+
expect(result.length).toBeGreaterThanOrEqual(30);
|
|
71
|
+
expect(result.length).toBeLessThanOrEqual(31);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('anchor min + offset: lte anchor min offset +2 year returns rows within 2 years of earliest', () => {
|
|
75
|
+
const data = monthlyData('2020-01-01', 60);
|
|
76
|
+
const result = runFilter(data, {
|
|
77
|
+
field: 'date',
|
|
78
|
+
lte: { anchor: 'min', offset: 2, unit: 'year' },
|
|
79
|
+
});
|
|
80
|
+
// Min is 2020-01-01, plus 2 years = 2022-01-01
|
|
81
|
+
// Jan 2020 through Jan 2022 = 25 rows
|
|
82
|
+
expect(result.length).toBeGreaterThanOrEqual(24);
|
|
83
|
+
expect(result.length).toBeLessThanOrEqual(25);
|
|
84
|
+
for (const row of result) {
|
|
85
|
+
expect(new Date(row.date as string).getTime()).toBeLessThanOrEqual(
|
|
86
|
+
new Date('2022-01-01').getTime(),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('quarter unit: offset -4 quarter returns last ~4 quarters', () => {
|
|
92
|
+
// Generate quarterly data (every 3 months)
|
|
93
|
+
const data = [];
|
|
94
|
+
const start = new Date('2020-01-01');
|
|
95
|
+
for (let i = 0; i < 20; i++) {
|
|
96
|
+
const d = new Date(start);
|
|
97
|
+
d.setMonth(d.getMonth() + i * 3);
|
|
98
|
+
data.push({ date: d.toISOString().slice(0, 10), value: i + 1 });
|
|
99
|
+
}
|
|
100
|
+
const result = runFilter(data, {
|
|
101
|
+
field: 'date',
|
|
102
|
+
gte: { anchor: 'max', offset: -4, unit: 'quarter' },
|
|
103
|
+
});
|
|
104
|
+
// 4 quarters back from max = ~4-5 rows of quarterly data
|
|
105
|
+
expect(result.length).toBeGreaterThanOrEqual(4);
|
|
106
|
+
expect(result.length).toBeLessThanOrEqual(5);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('non-ISO dates: data with timestamp numbers resolves correctly', () => {
|
|
110
|
+
// Use numeric timestamps instead of date strings
|
|
111
|
+
const baseDate = new Date('2024-01-01').getTime();
|
|
112
|
+
const dayMs = 86400000;
|
|
113
|
+
const data = Array.from({ length: 90 }, (_, i) => ({
|
|
114
|
+
date: baseDate + i * dayMs,
|
|
115
|
+
value: i,
|
|
116
|
+
}));
|
|
117
|
+
const result = runFilter(data, {
|
|
118
|
+
field: 'date',
|
|
119
|
+
gte: { anchor: 'max', offset: -30, unit: 'day' },
|
|
120
|
+
});
|
|
121
|
+
expect(result.length).toBeGreaterThanOrEqual(30);
|
|
122
|
+
expect(result.length).toBeLessThanOrEqual(31);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('empty dataset returns [] without throwing', () => {
|
|
126
|
+
const result = runFilter([], {
|
|
127
|
+
field: 'date',
|
|
128
|
+
gte: { anchor: 'max', offset: -1, unit: 'year' },
|
|
129
|
+
});
|
|
130
|
+
expect(result).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('single row: returns that row when anchor matches', () => {
|
|
134
|
+
const data = [{ date: '2024-06-15', value: 42 }];
|
|
135
|
+
const result = runFilter(data, {
|
|
136
|
+
field: 'date',
|
|
137
|
+
gte: { anchor: 'max', offset: -1, unit: 'year' },
|
|
138
|
+
});
|
|
139
|
+
// The single row is both min and max; subtracting 1 year puts the threshold before it
|
|
140
|
+
expect(result).toHaveLength(1);
|
|
141
|
+
expect(result[0].value).toBe(42);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('chained transforms: calculate first, then relative filter works on post-transform data', () => {
|
|
145
|
+
const data = monthlyData('2020-01-01', 60);
|
|
146
|
+
const result = runTransforms(data, [
|
|
147
|
+
// Copy date field to a new field (identity via multiply by 1 won't work for dates,
|
|
148
|
+
// so we just verify the filter operates on the same data)
|
|
149
|
+
{
|
|
150
|
+
filter: {
|
|
151
|
+
field: 'date',
|
|
152
|
+
gte: { anchor: 'max', offset: -1, unit: 'year' },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
expect(result.length).toBeGreaterThanOrEqual(12);
|
|
157
|
+
expect(result.length).toBeLessThanOrEqual(13);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('range predicate with two relative refs', () => {
|
|
161
|
+
const data = monthlyData('2020-01-01', 60);
|
|
162
|
+
const maxDate = new Date(data[data.length - 1].date as string);
|
|
163
|
+
const loBound = new Date(maxDate);
|
|
164
|
+
loBound.setFullYear(loBound.getFullYear() - 2);
|
|
165
|
+
const hiBound = new Date(maxDate);
|
|
166
|
+
hiBound.setFullYear(hiBound.getFullYear() - 1);
|
|
167
|
+
|
|
168
|
+
const result = runFilter(data, {
|
|
169
|
+
field: 'date',
|
|
170
|
+
range: [
|
|
171
|
+
{ anchor: 'max', offset: -2, unit: 'year' },
|
|
172
|
+
{ anchor: 'max', offset: -1, unit: 'year' },
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
expect(result.length).toBeGreaterThanOrEqual(12);
|
|
176
|
+
expect(result.length).toBeLessThanOrEqual(13);
|
|
177
|
+
for (const row of result) {
|
|
178
|
+
const ts = new Date(row.date as string).getTime();
|
|
179
|
+
expect(ts).toBeGreaterThanOrEqual(loBound.getTime());
|
|
180
|
+
expect(ts).toBeLessThanOrEqual(hiBound.getTime());
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('logical AND with relative ref and static filter: both applied', () => {
|
|
185
|
+
const data = monthlyData('2020-01-01', 60).map((r, i) => ({
|
|
186
|
+
...r,
|
|
187
|
+
category: i % 2 === 0 ? 'A' : 'B',
|
|
188
|
+
}));
|
|
189
|
+
const result = runFilter(data, {
|
|
190
|
+
and: [
|
|
191
|
+
{ field: 'date', gte: { anchor: 'max', offset: -1, unit: 'year' } },
|
|
192
|
+
{ field: 'category', equal: 'A' },
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
// ~12-13 rows in last year, roughly half are category A
|
|
196
|
+
expect(result.length).toBeGreaterThanOrEqual(5);
|
|
197
|
+
expect(result.length).toBeLessThanOrEqual(7);
|
|
198
|
+
for (const row of result) {
|
|
199
|
+
expect(row.category).toBe('A');
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runTransforms } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('window transform', () => {
|
|
5
|
+
it('lag(1) on 5 sorted rows: first row null, rest match previous', () => {
|
|
6
|
+
const data = [
|
|
7
|
+
{ month: '2024-01', value: 10 },
|
|
8
|
+
{ month: '2024-02', value: 20 },
|
|
9
|
+
{ month: '2024-03', value: 30 },
|
|
10
|
+
{ month: '2024-04', value: 40 },
|
|
11
|
+
{ month: '2024-05', value: 50 },
|
|
12
|
+
];
|
|
13
|
+
const result = runTransforms(data, [
|
|
14
|
+
{
|
|
15
|
+
window: [{ op: 'lag', field: 'value', offset: 1, as: 'prev_value' }],
|
|
16
|
+
sort: [{ field: 'month' }],
|
|
17
|
+
},
|
|
18
|
+
]);
|
|
19
|
+
expect(result.map((r) => r.prev_value)).toEqual([null, 10, 20, 30, 40]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('lag(12) on 24 monthly rows: rows 1-12 null, row 13 matches row 1', () => {
|
|
23
|
+
const data = Array.from({ length: 24 }, (_, i) => {
|
|
24
|
+
const d = new Date(2023, i, 1);
|
|
25
|
+
return { month: d.toISOString().slice(0, 7), value: (i + 1) * 100 };
|
|
26
|
+
});
|
|
27
|
+
const result = runTransforms(data, [
|
|
28
|
+
{
|
|
29
|
+
window: [{ op: 'lag', field: 'value', offset: 12, as: 'prev_year' }],
|
|
30
|
+
sort: [{ field: 'month' }],
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
// First 12 rows should be null
|
|
34
|
+
for (let i = 0; i < 12; i++) {
|
|
35
|
+
expect(result[i].prev_year).toBe(null);
|
|
36
|
+
}
|
|
37
|
+
// Row 13 (index 12) should match row 1's value (100)
|
|
38
|
+
expect(result[12].prev_year).toBe(100);
|
|
39
|
+
// Row 24 (index 23) should match row 12's value (1200)
|
|
40
|
+
expect(result[23].prev_year).toBe(1200);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('pct_change with offset 1 on [100, 110, 121, 133.1]', () => {
|
|
44
|
+
const data = [
|
|
45
|
+
{ idx: 1, value: 100 },
|
|
46
|
+
{ idx: 2, value: 110 },
|
|
47
|
+
{ idx: 3, value: 121 },
|
|
48
|
+
{ idx: 4, value: 133.1 },
|
|
49
|
+
];
|
|
50
|
+
const result = runTransforms(data, [
|
|
51
|
+
{
|
|
52
|
+
window: [{ op: 'pct_change', field: 'value', offset: 1, as: 'pct' }],
|
|
53
|
+
sort: [{ field: 'idx' }],
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
expect(result[0].pct).toBe(null);
|
|
57
|
+
expect(result[1].pct).toBeCloseTo(0.1, 5);
|
|
58
|
+
expect(result[2].pct).toBeCloseTo(0.1, 5);
|
|
59
|
+
expect(result[3].pct).toBeCloseTo(0.1, 5);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('pct_change with zero denominator returns null, not Infinity', () => {
|
|
63
|
+
const data = [
|
|
64
|
+
{ idx: 1, value: 0 },
|
|
65
|
+
{ idx: 2, value: 50 },
|
|
66
|
+
];
|
|
67
|
+
const result = runTransforms(data, [
|
|
68
|
+
{
|
|
69
|
+
window: [{ op: 'pct_change', field: 'value', offset: 1, as: 'pct' }],
|
|
70
|
+
sort: [{ field: 'idx' }],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
expect(result[0].pct).toBe(null);
|
|
74
|
+
expect(result[1].pct).toBe(null); // 0 denominator -> null
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('diff on [10, 15, 12, 20]', () => {
|
|
78
|
+
const data = [
|
|
79
|
+
{ idx: 1, value: 10 },
|
|
80
|
+
{ idx: 2, value: 15 },
|
|
81
|
+
{ idx: 3, value: 12 },
|
|
82
|
+
{ idx: 4, value: 20 },
|
|
83
|
+
];
|
|
84
|
+
const result = runTransforms(data, [
|
|
85
|
+
{
|
|
86
|
+
window: [{ op: 'diff', field: 'value', offset: 1, as: 'delta' }],
|
|
87
|
+
sort: [{ field: 'idx' }],
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
expect(result.map((r) => r.delta)).toEqual([null, 5, -3, 8]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('cumsum on [1, 2, 3, 4, 5]', () => {
|
|
94
|
+
const data = [
|
|
95
|
+
{ idx: 1, value: 1 },
|
|
96
|
+
{ idx: 2, value: 2 },
|
|
97
|
+
{ idx: 3, value: 3 },
|
|
98
|
+
{ idx: 4, value: 4 },
|
|
99
|
+
{ idx: 5, value: 5 },
|
|
100
|
+
];
|
|
101
|
+
const result = runTransforms(data, [
|
|
102
|
+
{
|
|
103
|
+
window: [{ op: 'cumsum', field: 'value', as: 'running' }],
|
|
104
|
+
sort: [{ field: 'idx' }],
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
expect(result.map((r) => r.running)).toEqual([1, 3, 6, 10, 15]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('cumsum with nulls [1, null, 3, null, 5] treats nulls as 0', () => {
|
|
111
|
+
const data = [
|
|
112
|
+
{ idx: 1, value: 1 },
|
|
113
|
+
{ idx: 2, value: null },
|
|
114
|
+
{ idx: 3, value: 3 },
|
|
115
|
+
{ idx: 4, value: null },
|
|
116
|
+
{ idx: 5, value: 5 },
|
|
117
|
+
];
|
|
118
|
+
const result = runTransforms(data, [
|
|
119
|
+
{
|
|
120
|
+
window: [{ op: 'cumsum', field: 'value', as: 'running' }],
|
|
121
|
+
sort: [{ field: 'idx' }],
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
expect(result.map((r) => r.running)).toEqual([1, 1, 4, 4, 9]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rank on values [30, 10, 40, 20] sorted ascending gives ranks [1, 2, 3, 4]', () => {
|
|
128
|
+
// Input is unsorted; window sorts by 'value' ascending
|
|
129
|
+
const data = [
|
|
130
|
+
{ id: 'a', value: 30 },
|
|
131
|
+
{ id: 'b', value: 10 },
|
|
132
|
+
{ id: 'c', value: 40 },
|
|
133
|
+
{ id: 'd', value: 20 },
|
|
134
|
+
];
|
|
135
|
+
const result = runTransforms(data, [
|
|
136
|
+
{
|
|
137
|
+
window: [{ op: 'rank', field: 'value', as: 'rank' }],
|
|
138
|
+
sort: [{ field: 'value', order: 'ascending' }],
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
// Results should be in ORIGINAL input order, so:
|
|
142
|
+
// a(30) -> rank 3, b(10) -> rank 1, c(40) -> rank 4, d(20) -> rank 2
|
|
143
|
+
expect(result.map((r) => r.rank)).toEqual([3, 1, 4, 2]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('first_value on [100, 200, 300] all get 100', () => {
|
|
147
|
+
const data = [
|
|
148
|
+
{ idx: 1, value: 100 },
|
|
149
|
+
{ idx: 2, value: 200 },
|
|
150
|
+
{ idx: 3, value: 300 },
|
|
151
|
+
];
|
|
152
|
+
const result = runTransforms(data, [
|
|
153
|
+
{
|
|
154
|
+
window: [{ op: 'first_value', field: 'value', as: 'first' }],
|
|
155
|
+
sort: [{ field: 'idx' }],
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
expect(result.map((r) => r.first)).toEqual([100, 100, 100]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('groupby partitioning: 2 groups of 3 rows, lag(1) computed independently', () => {
|
|
162
|
+
const data = [
|
|
163
|
+
{ group: 'A', idx: 1, value: 10 },
|
|
164
|
+
{ group: 'B', idx: 1, value: 100 },
|
|
165
|
+
{ group: 'A', idx: 2, value: 20 },
|
|
166
|
+
{ group: 'B', idx: 2, value: 200 },
|
|
167
|
+
{ group: 'A', idx: 3, value: 30 },
|
|
168
|
+
{ group: 'B', idx: 3, value: 300 },
|
|
169
|
+
];
|
|
170
|
+
const result = runTransforms(data, [
|
|
171
|
+
{
|
|
172
|
+
window: [{ op: 'lag', field: 'value', offset: 1, as: 'prev' }],
|
|
173
|
+
sort: [{ field: 'idx' }],
|
|
174
|
+
groupby: ['group'],
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
// Results in original input order
|
|
178
|
+
// A idx 1 -> null, B idx 1 -> null, A idx 2 -> 10, B idx 2 -> 100, A idx 3 -> 20, B idx 3 -> 200
|
|
179
|
+
expect(result.map((r) => r.prev)).toEqual([null, null, 10, 100, 20, 200]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('sorts ISO dates correctly before computing lag', () => {
|
|
183
|
+
// Input deliberately unsorted
|
|
184
|
+
const data = [
|
|
185
|
+
{ date: '2024-03-15', value: 300 },
|
|
186
|
+
{ date: '2024-01-10', value: 100 },
|
|
187
|
+
{ date: '2024-02-20', value: 200 },
|
|
188
|
+
];
|
|
189
|
+
const result = runTransforms(data, [
|
|
190
|
+
{
|
|
191
|
+
window: [{ op: 'lag', field: 'value', offset: 1, as: 'prev' }],
|
|
192
|
+
sort: [{ field: 'date' }],
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
// Sorted order: Jan(100), Feb(200), Mar(300)
|
|
196
|
+
// Jan -> null, Feb -> 100, Mar -> 200
|
|
197
|
+
// Original order: Mar, Jan, Feb -> [200, null, 100]
|
|
198
|
+
expect(result.map((r) => r.prev)).toEqual([200, null, 100]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('sorts numeric timestamp strings numerically, not lexicographically', () => {
|
|
202
|
+
// Lexicographic: "9" > "10" but numeric: 9 < 10
|
|
203
|
+
const data = [
|
|
204
|
+
{ ts: '10', value: 'b' },
|
|
205
|
+
{ ts: '9', value: 'a' },
|
|
206
|
+
{ ts: '100', value: 'c' },
|
|
207
|
+
];
|
|
208
|
+
const result = runTransforms(data, [
|
|
209
|
+
{
|
|
210
|
+
window: [{ op: 'lag', field: 'value', offset: 1, as: 'prev' }],
|
|
211
|
+
sort: [{ field: 'ts' }],
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
// Numeric sort: 9, 10, 100 -> values: a, b, c
|
|
215
|
+
// ts=10(b) -> lag is a, ts=9(a) -> lag is null, ts=100(c) -> lag is b
|
|
216
|
+
expect(result.map((r) => r.prev)).toEqual(['a', null, 'b']);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('lead(1) on 4 rows: last row null, rest match next', () => {
|
|
220
|
+
const data = [
|
|
221
|
+
{ idx: 1, value: 10 },
|
|
222
|
+
{ idx: 2, value: 20 },
|
|
223
|
+
{ idx: 3, value: 30 },
|
|
224
|
+
{ idx: 4, value: 40 },
|
|
225
|
+
];
|
|
226
|
+
const result = runTransforms(data, [
|
|
227
|
+
{
|
|
228
|
+
window: [{ op: 'lead', field: 'value', offset: 1, as: 'next_value' }],
|
|
229
|
+
sort: [{ field: 'idx' }],
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
expect(result.map((r) => r.next_value)).toEqual([20, 30, 40, null]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('rank with tied values uses competition ranking', () => {
|
|
236
|
+
const data = [
|
|
237
|
+
{ id: 'a', value: 10 },
|
|
238
|
+
{ id: 'b', value: 20 },
|
|
239
|
+
{ id: 'c', value: 10 },
|
|
240
|
+
{ id: 'd', value: 30 },
|
|
241
|
+
];
|
|
242
|
+
const result = runTransforms(data, [
|
|
243
|
+
{
|
|
244
|
+
window: [{ op: 'rank', field: 'value', as: 'rank' }],
|
|
245
|
+
sort: [{ field: 'value', order: 'ascending' }],
|
|
246
|
+
},
|
|
247
|
+
]);
|
|
248
|
+
// Sorted: a(10), c(10), b(20), d(30). Tied values get same rank.
|
|
249
|
+
// Original order: a(10)->1, b(20)->3, c(10)->1, d(30)->4
|
|
250
|
+
expect(result.map((r) => r.rank)).toEqual([1, 3, 1, 4]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns [] for empty data', () => {
|
|
254
|
+
const result = runTransforms(
|
|
255
|
+
[],
|
|
256
|
+
[
|
|
257
|
+
{
|
|
258
|
+
window: [{ op: 'lag', field: 'value', offset: 1, as: 'prev' }],
|
|
259
|
+
sort: [{ field: 'value' }],
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
);
|
|
263
|
+
expect(result).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('window chained with filter: window then filter on computed field', () => {
|
|
267
|
+
const data = [
|
|
268
|
+
{ idx: 1, value: 100 },
|
|
269
|
+
{ idx: 2, value: 110 },
|
|
270
|
+
{ idx: 3, value: 90 },
|
|
271
|
+
{ idx: 4, value: 120 },
|
|
272
|
+
];
|
|
273
|
+
const result = runTransforms(data, [
|
|
274
|
+
{
|
|
275
|
+
window: [{ op: 'diff', field: 'value', offset: 1, as: 'delta' }],
|
|
276
|
+
sort: [{ field: 'idx' }],
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
filter: { field: 'delta', gt: 0 },
|
|
280
|
+
},
|
|
281
|
+
]);
|
|
282
|
+
// Diffs: [null, 10, -20, 30] -> filter gt 0 -> [10, 30]
|
|
283
|
+
expect(result).toHaveLength(2);
|
|
284
|
+
expect(result.map((r) => r.delta)).toEqual([10, 30]);
|
|
285
|
+
});
|
|
286
|
+
});
|
package/src/transforms/filter.ts
CHANGED
|
@@ -1,17 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filter transform: removes rows that don't match a predicate.
|
|
3
|
+
*
|
|
4
|
+
* Supports RelativeTimeRef values on comparison properties (lt, lte, gt, gte, range).
|
|
5
|
+
* These are resolved against the data extent before filtering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
DataRow,
|
|
10
|
+
FieldPredicate,
|
|
11
|
+
FilterPredicate,
|
|
12
|
+
RelativeTimeRef,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
14
|
+
import { evaluatePredicate, isRelativeTimeRef } from './predicates';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply a time offset to a Date, returning the resulting timestamp.
|
|
18
|
+
*/
|
|
19
|
+
function applyOffset(anchor: Date, offset: number, unit: RelativeTimeRef['unit']): number {
|
|
20
|
+
const d = new Date(anchor.getTime());
|
|
21
|
+
switch (unit) {
|
|
22
|
+
case 'year':
|
|
23
|
+
d.setFullYear(d.getFullYear() + offset);
|
|
24
|
+
break;
|
|
25
|
+
case 'quarter':
|
|
26
|
+
d.setMonth(d.getMonth() + offset * 3);
|
|
27
|
+
break;
|
|
28
|
+
case 'month':
|
|
29
|
+
d.setMonth(d.getMonth() + offset);
|
|
30
|
+
break;
|
|
31
|
+
case 'week':
|
|
32
|
+
d.setDate(d.getDate() + offset * 7);
|
|
33
|
+
break;
|
|
34
|
+
case 'day':
|
|
35
|
+
d.setDate(d.getDate() + offset);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return d.getTime();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a single RelativeTimeRef against a data array.
|
|
43
|
+
* Scans the field for min/max date values, then applies the offset.
|
|
44
|
+
*/
|
|
45
|
+
function resolveRef(data: DataRow[], field: string, ref: RelativeTimeRef): number {
|
|
46
|
+
let anchorMs = ref.anchor === 'max' ? -Infinity : Infinity;
|
|
47
|
+
|
|
48
|
+
for (const row of data) {
|
|
49
|
+
const val = row[field];
|
|
50
|
+
if (val == null) continue;
|
|
51
|
+
const ms = new Date(val as string | number).getTime();
|
|
52
|
+
if (Number.isNaN(ms)) continue;
|
|
53
|
+
if (ref.anchor === 'max' && ms > anchorMs) anchorMs = ms;
|
|
54
|
+
if (ref.anchor === 'min' && ms < anchorMs) anchorMs = ms;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!Number.isFinite(anchorMs)) return 0;
|
|
58
|
+
|
|
59
|
+
return applyOffset(new Date(anchorMs), ref.offset, ref.unit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk a predicate tree and resolve any RelativeTimeRef values to concrete numbers.
|
|
64
|
+
* Returns a new predicate tree (does not mutate the original).
|
|
3
65
|
*/
|
|
66
|
+
function resolveRelativeRefs(data: DataRow[], predicate: FilterPredicate): FilterPredicate {
|
|
67
|
+
if ('and' in predicate) {
|
|
68
|
+
return { and: predicate.and.map((p) => resolveRelativeRefs(data, p)) };
|
|
69
|
+
}
|
|
70
|
+
if ('or' in predicate) {
|
|
71
|
+
return { or: predicate.or.map((p) => resolveRelativeRefs(data, p)) };
|
|
72
|
+
}
|
|
73
|
+
if ('not' in predicate) {
|
|
74
|
+
return { not: resolveRelativeRefs(data, predicate.not) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// FieldPredicate: check each comparison property for RelativeTimeRef
|
|
78
|
+
if ('field' in predicate) {
|
|
79
|
+
const fp = predicate as FieldPredicate;
|
|
80
|
+
let needsCopy = false;
|
|
81
|
+
const resolved: Partial<FieldPredicate> = {};
|
|
4
82
|
|
|
5
|
-
|
|
6
|
-
|
|
83
|
+
for (const prop of ['lt', 'lte', 'gt', 'gte'] as const) {
|
|
84
|
+
if (isRelativeTimeRef(fp[prop])) {
|
|
85
|
+
resolved[prop] = resolveRef(data, fp.field, fp[prop] as RelativeTimeRef);
|
|
86
|
+
needsCopy = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (fp.range) {
|
|
91
|
+
const [lo, hi] = fp.range;
|
|
92
|
+
const loResolved = isRelativeTimeRef(lo) ? resolveRef(data, fp.field, lo) : lo;
|
|
93
|
+
const hiResolved = isRelativeTimeRef(hi) ? resolveRef(data, fp.field, hi) : hi;
|
|
94
|
+
if (isRelativeTimeRef(lo) || isRelativeTimeRef(hi)) {
|
|
95
|
+
resolved.range = [loResolved as number, hiResolved as number];
|
|
96
|
+
needsCopy = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (needsCopy) {
|
|
101
|
+
return { ...fp, ...resolved };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return predicate;
|
|
106
|
+
}
|
|
7
107
|
|
|
8
108
|
/**
|
|
9
109
|
* Filter data rows by a predicate.
|
|
10
110
|
*
|
|
111
|
+
* If the predicate contains RelativeTimeRef values, they are resolved
|
|
112
|
+
* against the data extent first, then the standard evaluatePredicate
|
|
113
|
+
* logic runs per-row.
|
|
114
|
+
*
|
|
11
115
|
* @param data - Input rows.
|
|
12
116
|
* @param predicate - Filter predicate to evaluate per row.
|
|
13
117
|
* @returns Rows that pass the predicate.
|
|
14
118
|
*/
|
|
15
119
|
export function runFilter(data: DataRow[], predicate: FilterPredicate): DataRow[] {
|
|
16
|
-
|
|
120
|
+
const resolved = resolveRelativeRefs(data, predicate);
|
|
121
|
+
return data.filter((datum) => evaluatePredicate(datum, resolved));
|
|
17
122
|
}
|
package/src/transforms/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { runCalculate } from './calculate';
|
|
|
13
13
|
import { runFilter } from './filter';
|
|
14
14
|
import { runFold } from './fold';
|
|
15
15
|
import { runTimeUnit } from './timeunit';
|
|
16
|
+
import { runWindow } from './window';
|
|
16
17
|
|
|
17
18
|
export { runAggregate } from './aggregate';
|
|
18
19
|
export { runBin } from './bin';
|
|
@@ -20,8 +21,9 @@ export { runCalculate } from './calculate';
|
|
|
20
21
|
export { isConditionalValueDef, resolveConditionalValue } from './conditional';
|
|
21
22
|
export { runFilter } from './filter';
|
|
22
23
|
export { runFold } from './fold';
|
|
23
|
-
export { evaluatePredicate } from './predicates';
|
|
24
|
+
export { evaluatePredicate, isRelativeTimeRef } from './predicates';
|
|
24
25
|
export { runTimeUnit } from './timeunit';
|
|
26
|
+
export { runWindow } from './window';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Run a sequence of transforms against a data array.
|
|
@@ -49,6 +51,8 @@ export function runTransforms(data: DataRow[], transforms: Transform[]): DataRow
|
|
|
49
51
|
result = runAggregate(result, transform);
|
|
50
52
|
} else if ('fold' in transform) {
|
|
51
53
|
result = runFold(result, transform);
|
|
54
|
+
} else if ('window' in transform) {
|
|
55
|
+
result = runWindow(result, transform);
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|