@opendata-ai/openchart-engine 6.25.3 → 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 +888 -80
- 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 +64 -21
- package/src/compiler/normalize.ts +74 -7
- 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
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* and logical combinators (and, or, not).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
DataRow,
|
|
10
|
+
FieldPredicate,
|
|
11
|
+
FilterPredicate,
|
|
12
|
+
RelativeTimeRef,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* Check if a predicate is a FieldPredicate (has a 'field' property).
|
|
@@ -14,6 +19,27 @@ function isFieldPredicate(pred: FilterPredicate): pred is FieldPredicate {
|
|
|
14
19
|
return 'field' in pred;
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Check if a value is a RelativeTimeRef (has anchor, offset, and unit properties).
|
|
24
|
+
*/
|
|
25
|
+
export function isRelativeTimeRef(value: unknown): value is RelativeTimeRef {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
'anchor' in value &&
|
|
30
|
+
'offset' in value &&
|
|
31
|
+
'unit' in value
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Coerce a comparison value to a number. RelativeTimeRef objects that weren't
|
|
37
|
+
* resolved upstream become NaN, which makes all comparisons return false (safe).
|
|
38
|
+
*/
|
|
39
|
+
function toNum(v: number | RelativeTimeRef): number {
|
|
40
|
+
return typeof v === 'number' ? v : NaN;
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
/**
|
|
18
44
|
* Evaluate a single field predicate against a datum.
|
|
19
45
|
*/
|
|
@@ -33,26 +59,30 @@ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
|
|
|
33
59
|
return value == pred.equal;
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
// Numeric comparisons
|
|
37
|
-
|
|
62
|
+
// Numeric comparisons (with date string fallback)
|
|
63
|
+
let numValue = Number(value);
|
|
64
|
+
if (Number.isNaN(numValue) && value != null) {
|
|
65
|
+
const ms = new Date(value as string | number).getTime();
|
|
66
|
+
if (!Number.isNaN(ms)) numValue = ms;
|
|
67
|
+
}
|
|
38
68
|
|
|
39
69
|
if (pred.lt !== undefined) {
|
|
40
|
-
return numValue < pred.lt;
|
|
70
|
+
return numValue < toNum(pred.lt);
|
|
41
71
|
}
|
|
42
72
|
if (pred.lte !== undefined) {
|
|
43
|
-
return numValue <= pred.lte;
|
|
73
|
+
return numValue <= toNum(pred.lte);
|
|
44
74
|
}
|
|
45
75
|
if (pred.gt !== undefined) {
|
|
46
|
-
return numValue > pred.gt;
|
|
76
|
+
return numValue > toNum(pred.gt);
|
|
47
77
|
}
|
|
48
78
|
if (pred.gte !== undefined) {
|
|
49
|
-
return numValue >= pred.gte;
|
|
79
|
+
return numValue >= toNum(pred.gte);
|
|
50
80
|
}
|
|
51
81
|
|
|
52
82
|
// range: inclusive [min, max]
|
|
53
83
|
if (pred.range !== undefined) {
|
|
54
|
-
const [
|
|
55
|
-
return numValue >=
|
|
84
|
+
const [lo, hi] = pred.range;
|
|
85
|
+
return numValue >= toNum(lo) && numValue <= toNum(hi);
|
|
56
86
|
}
|
|
57
87
|
|
|
58
88
|
// oneOf: use loose equality (same rationale as equal above)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window transform: computes values relative to other rows in sort order
|
|
3
|
+
* within a partition (lag, lead, diff, pct_change, cumsum, rank, first_value).
|
|
4
|
+
*
|
|
5
|
+
* Follows the same grouping pattern as aggregate.ts, but preserves all
|
|
6
|
+
* input rows and appends computed fields rather than collapsing groups.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DataRow, WindowTransform } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a composite group key from a row's groupby field values.
|
|
13
|
+
* Uses null-char delimiter (same convention as aggregate.ts).
|
|
14
|
+
*/
|
|
15
|
+
function groupKey(row: DataRow, groupby: string[]): string {
|
|
16
|
+
return groupby.map((f) => String(row[f] ?? '')).join('\x00');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Try to parse a value as a date, returning its timestamp if valid.
|
|
21
|
+
* Only recognizes ISO-8601 strings (must contain '-' or 'T') and numeric timestamps.
|
|
22
|
+
* Bare numeric strings like "9" or "100" are not treated as dates.
|
|
23
|
+
*/
|
|
24
|
+
function tryParseDate(val: unknown): number {
|
|
25
|
+
if (val == null) return NaN;
|
|
26
|
+
if (typeof val === 'number') return new Date(val).getTime();
|
|
27
|
+
if (typeof val === 'string' && (val.includes('-') || val.includes('T'))) {
|
|
28
|
+
const ms = new Date(val).getTime();
|
|
29
|
+
return ms;
|
|
30
|
+
}
|
|
31
|
+
return NaN;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Type-aware comparison for sorting:
|
|
36
|
+
* 1. Try ISO date parsing first
|
|
37
|
+
* 2. If both are numeric, compare as numbers
|
|
38
|
+
* 3. Otherwise lexicographic string comparison
|
|
39
|
+
*/
|
|
40
|
+
function compareValues(a: unknown, b: unknown, order: 'ascending' | 'descending'): number {
|
|
41
|
+
const dir = order === 'descending' ? -1 : 1;
|
|
42
|
+
|
|
43
|
+
// Try dates first
|
|
44
|
+
const dateA = tryParseDate(a);
|
|
45
|
+
const dateB = tryParseDate(b);
|
|
46
|
+
if (!Number.isNaN(dateA) && !Number.isNaN(dateB)) {
|
|
47
|
+
return dir * (dateA - dateB);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Try numeric comparison
|
|
51
|
+
const numA = Number(a);
|
|
52
|
+
const numB = Number(b);
|
|
53
|
+
if (Number.isFinite(numA) && Number.isFinite(numB)) {
|
|
54
|
+
return dir * (numA - numB);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fallback to string comparison
|
|
58
|
+
return dir * String(a ?? '').localeCompare(String(b ?? ''));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply a window transform to data rows.
|
|
63
|
+
*
|
|
64
|
+
* Groups rows by the groupby fields, sorts within each group, computes
|
|
65
|
+
* window operations, then returns all rows in their original input order
|
|
66
|
+
* with computed fields appended.
|
|
67
|
+
*
|
|
68
|
+
* @param data - Input rows.
|
|
69
|
+
* @param transform - Window transform definition.
|
|
70
|
+
* @returns Rows with computed window fields appended.
|
|
71
|
+
*/
|
|
72
|
+
export function runWindow(data: DataRow[], transform: WindowTransform): DataRow[] {
|
|
73
|
+
if (data.length === 0) return [];
|
|
74
|
+
|
|
75
|
+
const { window: windowDefs, sort, groupby = [] } = transform;
|
|
76
|
+
|
|
77
|
+
// Track original indices so we can restore input order
|
|
78
|
+
const indexed = data.map((row, i) => ({ row, originalIndex: i }));
|
|
79
|
+
|
|
80
|
+
// Group rows by groupby fields
|
|
81
|
+
const groups = new Map<string, { row: DataRow; originalIndex: number }[]>();
|
|
82
|
+
for (const entry of indexed) {
|
|
83
|
+
const key = groupby.length > 0 ? groupKey(entry.row, groupby) : '';
|
|
84
|
+
const existing = groups.get(key);
|
|
85
|
+
if (existing) {
|
|
86
|
+
existing.push(entry);
|
|
87
|
+
} else {
|
|
88
|
+
groups.set(key, [entry]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build result array indexed by original position
|
|
93
|
+
const result: DataRow[] = new Array(data.length);
|
|
94
|
+
|
|
95
|
+
for (const groupEntries of groups.values()) {
|
|
96
|
+
// Sort the group entries by the sort fields
|
|
97
|
+
const sorted = [...groupEntries].sort((a, b) => {
|
|
98
|
+
for (const s of sort) {
|
|
99
|
+
const cmp = compareValues(a.row[s.field], b.row[s.field], s.order ?? 'ascending');
|
|
100
|
+
if (cmp !== 0) return cmp;
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Compute window operations for each row in sorted order
|
|
106
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
107
|
+
const entry = sorted[i];
|
|
108
|
+
const outRow: DataRow = { ...entry.row };
|
|
109
|
+
|
|
110
|
+
for (const def of windowDefs) {
|
|
111
|
+
const offset = def.offset ?? 1;
|
|
112
|
+
let computed: unknown = null;
|
|
113
|
+
|
|
114
|
+
switch (def.op) {
|
|
115
|
+
case 'lag': {
|
|
116
|
+
const lagIdx = i - offset;
|
|
117
|
+
computed = lagIdx >= 0 ? (sorted[lagIdx].row[def.field] ?? null) : null;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'lead': {
|
|
121
|
+
const leadIdx = i + offset;
|
|
122
|
+
computed = leadIdx < sorted.length ? (sorted[leadIdx].row[def.field] ?? null) : null;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'diff': {
|
|
126
|
+
const lagIdx = i - offset;
|
|
127
|
+
if (lagIdx >= 0) {
|
|
128
|
+
const current = Number(entry.row[def.field]);
|
|
129
|
+
const lagged = Number(sorted[lagIdx].row[def.field]);
|
|
130
|
+
computed =
|
|
131
|
+
Number.isFinite(current) && Number.isFinite(lagged) ? current - lagged : null;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case 'pct_change': {
|
|
136
|
+
const lagIdx = i - offset;
|
|
137
|
+
if (lagIdx >= 0) {
|
|
138
|
+
const current = Number(entry.row[def.field]);
|
|
139
|
+
const lagged = Number(sorted[lagIdx].row[def.field]);
|
|
140
|
+
if (Number.isFinite(current) && Number.isFinite(lagged) && lagged !== 0) {
|
|
141
|
+
computed = (current - lagged) / lagged;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case 'cumsum': {
|
|
147
|
+
const val = Number(entry.row[def.field]);
|
|
148
|
+
const addend = Number.isFinite(val) ? val : 0;
|
|
149
|
+
if (i === 0) {
|
|
150
|
+
computed = addend;
|
|
151
|
+
} else {
|
|
152
|
+
const prev = Number(result[sorted[i - 1].originalIndex]?.[def.as] ?? 0);
|
|
153
|
+
computed = prev + addend;
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'rank': {
|
|
158
|
+
let rank = i + 1;
|
|
159
|
+
for (let j = 0; j < i; j++) {
|
|
160
|
+
const isTie = sort.every(
|
|
161
|
+
(s) => String(sorted[j].row[s.field]) === String(entry.row[s.field]),
|
|
162
|
+
);
|
|
163
|
+
if (isTie) {
|
|
164
|
+
rank = result[sorted[j].originalIndex]?.[def.as] as number;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
computed = rank;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'first_value': {
|
|
172
|
+
computed = sorted[0].row[def.field] ?? null;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
outRow[def.as] = computed;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
result[entry.originalIndex] = outRow;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|