@opendata-ai/openchart-engine 6.25.4 → 6.27.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 +82 -4
- package/dist/index.js +1027 -76
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/__tests__/compound-labels.test.ts +147 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +222 -17
- package/src/compiler/normalize.ts +83 -1
- package/src/compiler/types.ts +41 -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 +36 -3
- package/src/layout/dimensions.ts +98 -5
- package/src/legend/compute.ts +6 -1
- 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,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US state tile grid layout computation.
|
|
3
|
+
*
|
|
4
|
+
* Defines the fixed grid positions for US state tiles (12 columns x 8 rows)
|
|
5
|
+
* and computes pixel positions from a given available area.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const US_STATE_TILES: Array<{ state: string; col: number; row: number }> = [
|
|
9
|
+
// Row 0
|
|
10
|
+
{ state: 'ME', col: 10, row: 0 },
|
|
11
|
+
|
|
12
|
+
// Row 1
|
|
13
|
+
{ state: 'VT', col: 9, row: 1 },
|
|
14
|
+
{ state: 'NH', col: 10, row: 1 },
|
|
15
|
+
|
|
16
|
+
// Row 2
|
|
17
|
+
{ state: 'WA', col: 0, row: 2 },
|
|
18
|
+
{ state: 'ID', col: 1, row: 2 },
|
|
19
|
+
{ state: 'MT', col: 2, row: 2 },
|
|
20
|
+
{ state: 'ND', col: 3, row: 2 },
|
|
21
|
+
{ state: 'MN', col: 4, row: 2 },
|
|
22
|
+
{ state: 'WI', col: 5, row: 2 },
|
|
23
|
+
{ state: 'MI', col: 6, row: 2 },
|
|
24
|
+
{ state: 'NY', col: 8, row: 2 },
|
|
25
|
+
{ state: 'MA', col: 9, row: 2 },
|
|
26
|
+
|
|
27
|
+
// Row 3
|
|
28
|
+
{ state: 'OR', col: 0, row: 3 },
|
|
29
|
+
{ state: 'NV', col: 1, row: 3 },
|
|
30
|
+
{ state: 'WY', col: 2, row: 3 },
|
|
31
|
+
{ state: 'SD', col: 3, row: 3 },
|
|
32
|
+
{ state: 'IA', col: 4, row: 3 },
|
|
33
|
+
{ state: 'IL', col: 5, row: 3 },
|
|
34
|
+
{ state: 'IN', col: 6, row: 3 },
|
|
35
|
+
{ state: 'OH', col: 7, row: 3 },
|
|
36
|
+
{ state: 'PA', col: 8, row: 3 },
|
|
37
|
+
{ state: 'NJ', col: 9, row: 3 },
|
|
38
|
+
{ state: 'CT', col: 10, row: 3 },
|
|
39
|
+
|
|
40
|
+
// Row 4
|
|
41
|
+
{ state: 'CA', col: 0, row: 4 },
|
|
42
|
+
{ state: 'UT', col: 1, row: 4 },
|
|
43
|
+
{ state: 'CO', col: 2, row: 4 },
|
|
44
|
+
{ state: 'NE', col: 3, row: 4 },
|
|
45
|
+
{ state: 'MO', col: 4, row: 4 },
|
|
46
|
+
{ state: 'KY', col: 5, row: 4 },
|
|
47
|
+
{ state: 'WV', col: 6, row: 4 },
|
|
48
|
+
{ state: 'VA', col: 7, row: 4 },
|
|
49
|
+
{ state: 'MD', col: 8, row: 4 },
|
|
50
|
+
{ state: 'DE', col: 9, row: 4 },
|
|
51
|
+
{ state: 'RI', col: 10, row: 4 },
|
|
52
|
+
|
|
53
|
+
// Row 5
|
|
54
|
+
{ state: 'AZ', col: 1, row: 5 },
|
|
55
|
+
{ state: 'NM', col: 2, row: 5 },
|
|
56
|
+
{ state: 'KS', col: 3, row: 5 },
|
|
57
|
+
{ state: 'AR', col: 4, row: 5 },
|
|
58
|
+
{ state: 'TN', col: 5, row: 5 },
|
|
59
|
+
{ state: 'NC', col: 6, row: 5 },
|
|
60
|
+
{ state: 'SC', col: 7, row: 5 },
|
|
61
|
+
{ state: 'DC', col: 8, row: 5 },
|
|
62
|
+
|
|
63
|
+
// Row 6
|
|
64
|
+
{ state: 'AK', col: 0, row: 6 },
|
|
65
|
+
{ state: 'OK', col: 3, row: 6 },
|
|
66
|
+
{ state: 'LA', col: 4, row: 6 },
|
|
67
|
+
{ state: 'MS', col: 5, row: 6 },
|
|
68
|
+
{ state: 'AL', col: 6, row: 6 },
|
|
69
|
+
{ state: 'GA', col: 7, row: 6 },
|
|
70
|
+
|
|
71
|
+
// Row 7
|
|
72
|
+
{ state: 'HI', col: 1, row: 7 },
|
|
73
|
+
{ state: 'TX', col: 3, row: 7 },
|
|
74
|
+
{ state: 'FL', col: 7, row: 7 },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export const STATE_CODE_SET = new Set(US_STATE_TILES.map((t) => t.state));
|
|
78
|
+
|
|
79
|
+
export const STATE_NAMES: Record<string, string> = {
|
|
80
|
+
AL: 'Alabama',
|
|
81
|
+
AK: 'Alaska',
|
|
82
|
+
AZ: 'Arizona',
|
|
83
|
+
AR: 'Arkansas',
|
|
84
|
+
CA: 'California',
|
|
85
|
+
CO: 'Colorado',
|
|
86
|
+
CT: 'Connecticut',
|
|
87
|
+
DE: 'Delaware',
|
|
88
|
+
DC: 'District of Columbia',
|
|
89
|
+
FL: 'Florida',
|
|
90
|
+
GA: 'Georgia',
|
|
91
|
+
HI: 'Hawaii',
|
|
92
|
+
ID: 'Idaho',
|
|
93
|
+
IL: 'Illinois',
|
|
94
|
+
IN: 'Indiana',
|
|
95
|
+
IA: 'Iowa',
|
|
96
|
+
KS: 'Kansas',
|
|
97
|
+
KY: 'Kentucky',
|
|
98
|
+
LA: 'Louisiana',
|
|
99
|
+
ME: 'Maine',
|
|
100
|
+
MD: 'Maryland',
|
|
101
|
+
MA: 'Massachusetts',
|
|
102
|
+
MI: 'Michigan',
|
|
103
|
+
MN: 'Minnesota',
|
|
104
|
+
MS: 'Mississippi',
|
|
105
|
+
MO: 'Missouri',
|
|
106
|
+
MT: 'Montana',
|
|
107
|
+
NE: 'Nebraska',
|
|
108
|
+
NV: 'Nevada',
|
|
109
|
+
NH: 'New Hampshire',
|
|
110
|
+
NJ: 'New Jersey',
|
|
111
|
+
NM: 'New Mexico',
|
|
112
|
+
NY: 'New York',
|
|
113
|
+
NC: 'North Carolina',
|
|
114
|
+
ND: 'North Dakota',
|
|
115
|
+
OH: 'Ohio',
|
|
116
|
+
OK: 'Oklahoma',
|
|
117
|
+
OR: 'Oregon',
|
|
118
|
+
PA: 'Pennsylvania',
|
|
119
|
+
RI: 'Rhode Island',
|
|
120
|
+
SC: 'South Carolina',
|
|
121
|
+
SD: 'South Dakota',
|
|
122
|
+
TN: 'Tennessee',
|
|
123
|
+
TX: 'Texas',
|
|
124
|
+
UT: 'Utah',
|
|
125
|
+
VT: 'Vermont',
|
|
126
|
+
VA: 'Virginia',
|
|
127
|
+
WA: 'Washington',
|
|
128
|
+
WV: 'West Virginia',
|
|
129
|
+
WI: 'Wisconsin',
|
|
130
|
+
WY: 'Wyoming',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const GRID_COLS = 12;
|
|
134
|
+
const GRID_ROWS = 8;
|
|
135
|
+
|
|
136
|
+
export interface TilePositions {
|
|
137
|
+
tileSize: number;
|
|
138
|
+
gap: number;
|
|
139
|
+
positions: Map<string, { x: number; y: number }>;
|
|
140
|
+
gridWidth: number;
|
|
141
|
+
gridHeight: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compute pixel positions for all US state tiles.
|
|
146
|
+
*
|
|
147
|
+
* Calculates the largest tile size that fits the available area,
|
|
148
|
+
* preserving the grid's aspect ratio and returning a map of
|
|
149
|
+
* state codes to pixel coordinates.
|
|
150
|
+
*/
|
|
151
|
+
export function computeTilePositions(
|
|
152
|
+
availableWidth: number,
|
|
153
|
+
availableHeight: number,
|
|
154
|
+
gap = 4,
|
|
155
|
+
): TilePositions {
|
|
156
|
+
const maxTileW = (availableWidth - gap * (GRID_COLS - 1)) / GRID_COLS;
|
|
157
|
+
const maxTileH = (availableHeight - gap * (GRID_ROWS - 1)) / GRID_ROWS;
|
|
158
|
+
const tileSize = Math.max(1, Math.floor(Math.min(maxTileW, maxTileH)));
|
|
159
|
+
|
|
160
|
+
const gridWidth = tileSize * GRID_COLS + gap * (GRID_COLS - 1);
|
|
161
|
+
const gridHeight = tileSize * GRID_ROWS + gap * (GRID_ROWS - 1);
|
|
162
|
+
|
|
163
|
+
const positions = new Map<string, { x: number; y: number }>();
|
|
164
|
+
for (const { state, col, row } of US_STATE_TILES) {
|
|
165
|
+
positions.set(state, {
|
|
166
|
+
x: col * (tileSize + gap),
|
|
167
|
+
y: row * (tileSize + gap),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { tileSize, gap, positions, gridWidth, gridHeight };
|
|
172
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal normalized tilemap spec type used by the compilation pipeline.
|
|
3
|
+
*
|
|
4
|
+
* This mirrors NormalizedSankeySpec: all optional fields have been filled
|
|
5
|
+
* with sensible defaults. It's an engine implementation detail, not a public contract.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
AnimationSpec,
|
|
10
|
+
DarkMode,
|
|
11
|
+
LegendConfig,
|
|
12
|
+
ThemeConfig,
|
|
13
|
+
TileMapEncoding,
|
|
14
|
+
TileMapPalette,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
|
|
17
|
+
import type { NormalizedChrome } from '../compiler/types';
|
|
18
|
+
|
|
19
|
+
/** A TileMapSpec with all optional fields filled with defaults. */
|
|
20
|
+
export interface NormalizedTileMapSpec {
|
|
21
|
+
type: 'tilemap';
|
|
22
|
+
data: Record<string, unknown>[];
|
|
23
|
+
encoding: TileMapEncoding;
|
|
24
|
+
palette: TileMapPalette;
|
|
25
|
+
chrome: NormalizedChrome;
|
|
26
|
+
legend?: LegendConfig;
|
|
27
|
+
theme: ThemeConfig;
|
|
28
|
+
darkMode: DarkMode;
|
|
29
|
+
watermark: boolean;
|
|
30
|
+
animation?: AnimationSpec;
|
|
31
|
+
valueFormat?: string;
|
|
32
|
+
}
|
|
@@ -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
|
+
});
|