@opendata-ai/openchart-engine 6.0.0 → 6.1.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 (65) hide show
  1. package/dist/index.d.ts +155 -19
  2. package/dist/index.js +1513 -164
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -3
  6. package/src/__tests__/axes.test.ts +168 -4
  7. package/src/__tests__/compile-chart.test.ts +23 -12
  8. package/src/__tests__/compile-layer.test.ts +386 -0
  9. package/src/__tests__/dimensions.test.ts +6 -3
  10. package/src/__tests__/legend.test.ts +6 -3
  11. package/src/__tests__/scales.test.ts +176 -2
  12. package/src/annotations/__tests__/compute.test.ts +8 -4
  13. package/src/charts/bar/__tests__/compute.test.ts +12 -6
  14. package/src/charts/bar/compute.ts +21 -5
  15. package/src/charts/column/__tests__/compute.test.ts +14 -7
  16. package/src/charts/column/compute.ts +21 -6
  17. package/src/charts/dot/__tests__/compute.test.ts +10 -5
  18. package/src/charts/dot/compute.ts +10 -4
  19. package/src/charts/line/__tests__/compute.test.ts +102 -11
  20. package/src/charts/line/__tests__/curves.test.ts +51 -0
  21. package/src/charts/line/__tests__/labels.test.ts +2 -1
  22. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  23. package/src/charts/line/area.ts +19 -8
  24. package/src/charts/line/compute.ts +64 -25
  25. package/src/charts/line/curves.ts +40 -0
  26. package/src/charts/pie/__tests__/compute.test.ts +10 -5
  27. package/src/charts/pie/compute.ts +2 -1
  28. package/src/charts/rule/index.ts +127 -0
  29. package/src/charts/scatter/__tests__/compute.test.ts +10 -5
  30. package/src/charts/scatter/compute.ts +15 -5
  31. package/src/charts/text/index.ts +92 -0
  32. package/src/charts/tick/index.ts +84 -0
  33. package/src/charts/utils.ts +1 -1
  34. package/src/compile.ts +175 -23
  35. package/src/compiler/__tests__/compile.test.ts +4 -4
  36. package/src/compiler/__tests__/normalize.test.ts +4 -4
  37. package/src/compiler/__tests__/validate.test.ts +25 -26
  38. package/src/compiler/index.ts +1 -1
  39. package/src/compiler/normalize.ts +77 -4
  40. package/src/compiler/types.ts +6 -2
  41. package/src/compiler/validate.ts +167 -35
  42. package/src/graphs/__tests__/compile-graph.test.ts +2 -2
  43. package/src/graphs/compile-graph.ts +2 -2
  44. package/src/index.ts +17 -1
  45. package/src/layout/axes.ts +122 -20
  46. package/src/layout/dimensions.ts +15 -9
  47. package/src/layout/scales.ts +320 -31
  48. package/src/legend/compute.ts +9 -6
  49. package/src/tables/__tests__/compile-table.test.ts +1 -1
  50. package/src/tooltips/__tests__/compute.test.ts +10 -5
  51. package/src/tooltips/compute.ts +32 -14
  52. package/src/transforms/__tests__/bin.test.ts +88 -0
  53. package/src/transforms/__tests__/calculate.test.ts +146 -0
  54. package/src/transforms/__tests__/conditional.test.ts +109 -0
  55. package/src/transforms/__tests__/filter.test.ts +59 -0
  56. package/src/transforms/__tests__/index.test.ts +93 -0
  57. package/src/transforms/__tests__/predicates.test.ts +176 -0
  58. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  59. package/src/transforms/bin.ts +87 -0
  60. package/src/transforms/calculate.ts +60 -0
  61. package/src/transforms/conditional.ts +46 -0
  62. package/src/transforms/filter.ts +17 -0
  63. package/src/transforms/index.ts +48 -0
  64. package/src/transforms/predicates.ts +90 -0
  65. package/src/transforms/timeunit.ts +88 -0
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { runTimeUnit } from '../timeunit';
3
+
4
+ describe('runTimeUnit', () => {
5
+ // Use a specific date: 2024-03-15 14:30:45.123 (Friday)
6
+ const testDate = new Date(2024, 2, 15, 14, 30, 45, 123);
7
+ const data = [{ ts: testDate }];
8
+
9
+ describe('single time units', () => {
10
+ it('extracts year', () => {
11
+ const result = runTimeUnit(data, { timeUnit: 'year', field: 'ts', as: 'y' });
12
+ expect(result[0].y).toBe(2024);
13
+ });
14
+
15
+ it('extracts quarter', () => {
16
+ const result = runTimeUnit(data, { timeUnit: 'quarter', field: 'ts', as: 'q' });
17
+ expect(result[0].q).toBe(1); // March is Q1
18
+ });
19
+
20
+ it('extracts month (0-indexed)', () => {
21
+ const result = runTimeUnit(data, { timeUnit: 'month', field: 'ts', as: 'm' });
22
+ expect(result[0].m).toBe(2); // March = 2
23
+ });
24
+
25
+ it('extracts day of week', () => {
26
+ const result = runTimeUnit(data, { timeUnit: 'day', field: 'ts', as: 'd' });
27
+ expect(result[0].d).toBe(5); // Friday
28
+ });
29
+
30
+ it('extracts date (day of month)', () => {
31
+ const result = runTimeUnit(data, { timeUnit: 'date', field: 'ts', as: 'd' });
32
+ expect(result[0].d).toBe(15);
33
+ });
34
+
35
+ it('extracts hours', () => {
36
+ const result = runTimeUnit(data, { timeUnit: 'hours', field: 'ts', as: 'h' });
37
+ expect(result[0].h).toBe(14);
38
+ });
39
+
40
+ it('extracts minutes', () => {
41
+ const result = runTimeUnit(data, { timeUnit: 'minutes', field: 'ts', as: 'min' });
42
+ expect(result[0].min).toBe(30);
43
+ });
44
+
45
+ it('extracts seconds', () => {
46
+ const result = runTimeUnit(data, { timeUnit: 'seconds', field: 'ts', as: 's' });
47
+ expect(result[0].s).toBe(45);
48
+ });
49
+
50
+ it('extracts milliseconds', () => {
51
+ const result = runTimeUnit(data, { timeUnit: 'milliseconds', field: 'ts', as: 'ms' });
52
+ expect(result[0].ms).toBe(123);
53
+ });
54
+
55
+ it('extracts week number', () => {
56
+ const result = runTimeUnit(data, { timeUnit: 'week', field: 'ts', as: 'w' });
57
+ expect(typeof result[0].w).toBe('number');
58
+ expect(result[0].w).toBeGreaterThan(0);
59
+ expect(result[0].w).toBeLessThanOrEqual(53);
60
+ });
61
+
62
+ it('extracts dayofyear', () => {
63
+ const result = runTimeUnit(data, { timeUnit: 'dayofyear', field: 'ts', as: 'doy' });
64
+ expect(typeof result[0].doy).toBe('number');
65
+ // March 15 is day 75 in a leap year (2024)
66
+ expect(result[0].doy).toBe(75);
67
+ });
68
+ });
69
+
70
+ describe('compound time units', () => {
71
+ it('extracts yearmonth', () => {
72
+ const result = runTimeUnit(data, { timeUnit: 'yearmonth', field: 'ts', as: 'ym' });
73
+ expect(result[0].ym).toBe('2024-03');
74
+ });
75
+
76
+ it('extracts yearmonthdate', () => {
77
+ const result = runTimeUnit(data, { timeUnit: 'yearmonthdate', field: 'ts', as: 'ymd' });
78
+ expect(result[0].ymd).toBe('2024-03-15');
79
+ });
80
+
81
+ it('extracts monthdate', () => {
82
+ const result = runTimeUnit(data, { timeUnit: 'monthdate', field: 'ts', as: 'md' });
83
+ expect(result[0].md).toBe('03-15');
84
+ });
85
+
86
+ it('extracts hoursminutes', () => {
87
+ const result = runTimeUnit(data, { timeUnit: 'hoursminutes', field: 'ts', as: 'hm' });
88
+ expect(result[0].hm).toBe('14:30');
89
+ });
90
+ });
91
+
92
+ describe('date parsing', () => {
93
+ it('parses ISO string dates', () => {
94
+ const stringData = [{ ts: '2024-03-15T14:30:00Z' }];
95
+ const result = runTimeUnit(stringData, { timeUnit: 'year', field: 'ts', as: 'y' });
96
+ expect(result[0].y).toBe(2024);
97
+ });
98
+
99
+ it('parses numeric timestamps', () => {
100
+ const numData = [{ ts: testDate.getTime() }];
101
+ const result = runTimeUnit(numData, { timeUnit: 'year', field: 'ts', as: 'y' });
102
+ expect(result[0].y).toBe(2024);
103
+ });
104
+
105
+ it('returns null for unparseable dates', () => {
106
+ const badData = [{ ts: 'not-a-date' }];
107
+ const result = runTimeUnit(badData, { timeUnit: 'year', field: 'ts', as: 'y' });
108
+ expect(result[0].y).toBeNull();
109
+ });
110
+
111
+ it('returns null for null values', () => {
112
+ const nullData = [{ ts: null }];
113
+ const result = runTimeUnit(nullData, { timeUnit: 'year', field: 'ts', as: 'y' });
114
+ expect(result[0].y).toBeNull();
115
+ });
116
+ });
117
+
118
+ it('preserves existing fields', () => {
119
+ const extraData = [{ ts: testDate, label: 'test' }];
120
+ const result = runTimeUnit(extraData, { timeUnit: 'year', field: 'ts', as: 'y' });
121
+ expect(result[0].label).toBe('test');
122
+ expect(result[0].ts).toBe(testDate);
123
+ });
124
+
125
+ it('handles empty data', () => {
126
+ const result = runTimeUnit([], { timeUnit: 'year', field: 'ts', as: 'y' });
127
+ expect(result).toHaveLength(0);
128
+ });
129
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bin transform: discretizes a continuous field into bins.
3
+ *
4
+ * Adds bin start (and optionally bin end) fields to each row.
5
+ */
6
+
7
+ import type { BinParams, BinTransform, DataRow } from '@opendata-ai/openchart-core';
8
+
9
+ /**
10
+ * Compute a nice step size for binning.
11
+ */
12
+ function computeStep(extent: [number, number], maxbins: number, nice: boolean): number {
13
+ const span = extent[1] - extent[0];
14
+ if (span === 0) return 1;
15
+
16
+ let step = span / maxbins;
17
+
18
+ if (nice) {
19
+ // Round to a nice step: 1, 2, 5, 10, 20, 50, etc.
20
+ const magnitude = 10 ** Math.floor(Math.log10(step));
21
+ const residual = step / magnitude;
22
+
23
+ if (residual <= 1.5) {
24
+ step = magnitude;
25
+ } else if (residual <= 3.5) {
26
+ step = 2 * magnitude;
27
+ } else if (residual <= 7.5) {
28
+ step = 5 * magnitude;
29
+ } else {
30
+ step = 10 * magnitude;
31
+ }
32
+ }
33
+
34
+ return step;
35
+ }
36
+
37
+ /**
38
+ * Apply a bin transform to data rows.
39
+ *
40
+ * Adds one or two fields to each row:
41
+ * - If `as` is a string: adds `as` with the bin start value.
42
+ * - If `as` is [start, end]: adds both bin start and bin end fields.
43
+ *
44
+ * @param data - Input rows.
45
+ * @param transform - Bin transform definition.
46
+ * @returns New rows with binned field(s) added.
47
+ */
48
+ export function runBin(data: DataRow[], transform: BinTransform): DataRow[] {
49
+ const params: BinParams = transform.bin === true ? {} : transform.bin;
50
+ const maxbins = params.maxbins ?? 10;
51
+ const nice = params.nice ?? true;
52
+ const field = transform.field;
53
+
54
+ // Compute extent from data if not provided
55
+ let extent = params.extent;
56
+ if (!extent) {
57
+ let min = Infinity;
58
+ let max = -Infinity;
59
+ for (const row of data) {
60
+ const v = Number(row[field]);
61
+ if (Number.isFinite(v)) {
62
+ if (v < min) min = v;
63
+ if (v > max) max = v;
64
+ }
65
+ }
66
+ extent = [min === Infinity ? 0 : min, max === -Infinity ? 0 : max];
67
+ }
68
+
69
+ const step = params.step ?? computeStep(extent, maxbins, nice);
70
+ const [startAs, endAs] = Array.isArray(transform.as) ? transform.as : [transform.as, undefined];
71
+
72
+ return data.map((row) => {
73
+ const v = Number(row[field]);
74
+ const newRow = { ...row };
75
+
76
+ if (!Number.isFinite(v)) {
77
+ newRow[startAs] = null;
78
+ if (endAs) newRow[endAs] = null;
79
+ } else {
80
+ const binStart = Math.floor((v - extent![0]) / step) * step + extent![0];
81
+ newRow[startAs] = binStart;
82
+ if (endAs) newRow[endAs] = binStart + step;
83
+ }
84
+
85
+ return newRow;
86
+ });
87
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Calculate transform: adds a computed field to each row.
3
+ *
4
+ * Supports arithmetic operations on fields and constant values.
5
+ */
6
+
7
+ import type { CalculateExpression, CalculateTransform, DataRow } from '@opendata-ai/openchart-core';
8
+
9
+ /**
10
+ * Evaluate a calculate expression against a datum.
11
+ */
12
+ function evaluateExpression(datum: DataRow, expr: CalculateExpression): number {
13
+ const fieldValue = Number(datum[expr.field]);
14
+
15
+ // Unary operations (single field)
16
+ switch (expr.op) {
17
+ case 'abs':
18
+ return Math.abs(fieldValue);
19
+ case 'round':
20
+ return Math.round(fieldValue);
21
+ case 'floor':
22
+ return Math.floor(fieldValue);
23
+ case 'ceil':
24
+ return Math.ceil(fieldValue);
25
+ case 'log':
26
+ return Math.log(fieldValue);
27
+ case 'sqrt':
28
+ return Math.sqrt(fieldValue);
29
+ }
30
+
31
+ // Binary operations (field + field2 or field + value)
32
+ const operand = expr.field2 !== undefined ? Number(datum[expr.field2]) : (expr.value ?? 0);
33
+
34
+ switch (expr.op) {
35
+ case '+':
36
+ return fieldValue + operand;
37
+ case '-':
38
+ return fieldValue - operand;
39
+ case '*':
40
+ return fieldValue * operand;
41
+ case '/':
42
+ return operand === 0 ? NaN : fieldValue / operand;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Apply a calculate transform to data rows.
48
+ *
49
+ * Adds a new field with the computed value to each row.
50
+ *
51
+ * @param data - Input rows.
52
+ * @param transform - Calculate transform definition.
53
+ * @returns New rows with the calculated field added.
54
+ */
55
+ export function runCalculate(data: DataRow[], transform: CalculateTransform): DataRow[] {
56
+ return data.map((row) => ({
57
+ ...row,
58
+ [transform.as]: evaluateExpression(row, transform.calculate),
59
+ }));
60
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Conditional encoding evaluation.
3
+ *
4
+ * Resolves conditional value definitions per-datum by evaluating
5
+ * predicates and returning the first matching condition's value.
6
+ */
7
+
8
+ import type { ConditionalValueDef, DataRow } from '@opendata-ai/openchart-core';
9
+ import { evaluatePredicate } from './predicates';
10
+
11
+ /**
12
+ * Resolve a conditional value definition for a datum.
13
+ *
14
+ * Evaluates conditions in order, returning the value from the first
15
+ * condition whose test passes. Falls back to the default value if
16
+ * no condition matches.
17
+ *
18
+ * @param datum - The data row to evaluate against.
19
+ * @param channelDef - The conditional value definition.
20
+ * @returns The resolved value, or undefined if no condition matches and no default.
21
+ */
22
+ export function resolveConditionalValue(datum: DataRow, channelDef: ConditionalValueDef): unknown {
23
+ const conditions = Array.isArray(channelDef.condition)
24
+ ? channelDef.condition
25
+ : [channelDef.condition];
26
+
27
+ for (const cond of conditions) {
28
+ if (evaluatePredicate(datum, cond.test)) {
29
+ // If the condition specifies a field, return the datum's field value
30
+ if (cond.field !== undefined) {
31
+ return datum[cond.field];
32
+ }
33
+ return cond.value;
34
+ }
35
+ }
36
+
37
+ // No condition matched, return default
38
+ return channelDef.value;
39
+ }
40
+
41
+ /**
42
+ * Check if a channel definition is a ConditionalValueDef.
43
+ */
44
+ export function isConditionalValueDef(def: unknown): def is ConditionalValueDef {
45
+ return def !== null && typeof def === 'object' && 'condition' in (def as Record<string, unknown>);
46
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Filter transform: removes rows that don't match a predicate.
3
+ */
4
+
5
+ import type { DataRow, FilterPredicate } from '@opendata-ai/openchart-core';
6
+ import { evaluatePredicate } from './predicates';
7
+
8
+ /**
9
+ * Filter data rows by a predicate.
10
+ *
11
+ * @param data - Input rows.
12
+ * @param predicate - Filter predicate to evaluate per row.
13
+ * @returns Rows that pass the predicate.
14
+ */
15
+ export function runFilter(data: DataRow[], predicate: FilterPredicate): DataRow[] {
16
+ return data.filter((datum) => evaluatePredicate(datum, predicate));
17
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Data transform pipeline.
3
+ *
4
+ * Runs an ordered sequence of transforms (filter, bin, calculate, timeUnit)
5
+ * against a data array. Each transform produces a new array, feeding into
6
+ * the next transform in sequence.
7
+ */
8
+
9
+ import type { DataRow, Transform } from '@opendata-ai/openchart-core';
10
+ import { runBin } from './bin';
11
+ import { runCalculate } from './calculate';
12
+ import { runFilter } from './filter';
13
+ import { runTimeUnit } from './timeunit';
14
+
15
+ export { runBin } from './bin';
16
+ export { runCalculate } from './calculate';
17
+ export { isConditionalValueDef, resolveConditionalValue } from './conditional';
18
+ export { runFilter } from './filter';
19
+ export { evaluatePredicate } from './predicates';
20
+ export { runTimeUnit } from './timeunit';
21
+
22
+ /**
23
+ * Run a sequence of transforms against a data array.
24
+ *
25
+ * Each transform is applied in order, producing a new data array
26
+ * that feeds into the next transform. The original data is not mutated.
27
+ *
28
+ * @param data - Input data rows.
29
+ * @param transforms - Ordered array of transform definitions.
30
+ * @returns Transformed data rows.
31
+ */
32
+ export function runTransforms(data: DataRow[], transforms: Transform[]): DataRow[] {
33
+ let result = data;
34
+
35
+ for (const transform of transforms) {
36
+ if ('filter' in transform) {
37
+ result = runFilter(result, transform.filter);
38
+ } else if ('bin' in transform) {
39
+ result = runBin(result, transform);
40
+ } else if ('calculate' in transform) {
41
+ result = runCalculate(result, transform);
42
+ } else if ('timeUnit' in transform) {
43
+ result = runTimeUnit(result, transform);
44
+ }
45
+ }
46
+
47
+ return result;
48
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Predicate evaluation for filter transforms and conditional encoding.
3
+ *
4
+ * Supports field predicates (equal, lt, gt, range, oneOf, valid)
5
+ * and logical combinators (and, or, not).
6
+ */
7
+
8
+ import type { DataRow, FieldPredicate, FilterPredicate } from '@opendata-ai/openchart-core';
9
+
10
+ /**
11
+ * Check if a predicate is a FieldPredicate (has a 'field' property).
12
+ */
13
+ function isFieldPredicate(pred: FilterPredicate): pred is FieldPredicate {
14
+ return 'field' in pred;
15
+ }
16
+
17
+ /**
18
+ * Evaluate a single field predicate against a datum.
19
+ */
20
+ function evaluateFieldPredicate(datum: DataRow, pred: FieldPredicate): boolean {
21
+ const value = datum[pred.field];
22
+
23
+ // valid: check for non-null, non-undefined, non-NaN
24
+ if (pred.valid !== undefined) {
25
+ const isValid = value !== null && value !== undefined && !Number.isNaN(value);
26
+ return pred.valid ? isValid : !isValid;
27
+ }
28
+
29
+ // equal
30
+ if (pred.equal !== undefined) {
31
+ return value === pred.equal;
32
+ }
33
+
34
+ // Numeric comparisons
35
+ const numValue = Number(value);
36
+
37
+ if (pred.lt !== undefined) {
38
+ return numValue < pred.lt;
39
+ }
40
+ if (pred.lte !== undefined) {
41
+ return numValue <= pred.lte;
42
+ }
43
+ if (pred.gt !== undefined) {
44
+ return numValue > pred.gt;
45
+ }
46
+ if (pred.gte !== undefined) {
47
+ return numValue >= pred.gte;
48
+ }
49
+
50
+ // range: inclusive [min, max]
51
+ if (pred.range !== undefined) {
52
+ const [min, max] = pred.range;
53
+ return numValue >= min && numValue <= max;
54
+ }
55
+
56
+ // oneOf: value is in the set
57
+ if (pred.oneOf !== undefined) {
58
+ return pred.oneOf.includes(value);
59
+ }
60
+
61
+ // No condition specified, default to true
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Evaluate a filter predicate (field predicate or logical combinator) against a datum.
67
+ *
68
+ * @param datum - The data row to test.
69
+ * @param predicate - The filter predicate to evaluate.
70
+ * @returns Whether the datum passes the predicate.
71
+ */
72
+ export function evaluatePredicate(datum: DataRow, predicate: FilterPredicate): boolean {
73
+ if (isFieldPredicate(predicate)) {
74
+ return evaluateFieldPredicate(datum, predicate);
75
+ }
76
+
77
+ if ('and' in predicate) {
78
+ return predicate.and.every((p) => evaluatePredicate(datum, p));
79
+ }
80
+
81
+ if ('or' in predicate) {
82
+ return predicate.or.some((p) => evaluatePredicate(datum, p));
83
+ }
84
+
85
+ if ('not' in predicate) {
86
+ return !evaluatePredicate(datum, predicate.not);
87
+ }
88
+
89
+ return true;
90
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Time unit transform: extracts temporal components from date fields.
3
+ *
4
+ * Follows Vega-Lite time unit conventions.
5
+ */
6
+
7
+ import type { DataRow, TimeUnit, TimeUnitTransform } from '@opendata-ai/openchart-core';
8
+
9
+ /**
10
+ * Extract a time unit value from a Date object.
11
+ */
12
+ function extractTimeUnit(date: Date, unit: TimeUnit): number | string {
13
+ switch (unit) {
14
+ case 'year':
15
+ return date.getFullYear();
16
+ case 'quarter':
17
+ return Math.floor(date.getMonth() / 3) + 1;
18
+ case 'month':
19
+ return date.getMonth(); // 0-indexed like JS Date
20
+ case 'week': {
21
+ // ISO week number
22
+ const d = new Date(date.getTime());
23
+ d.setHours(0, 0, 0, 0);
24
+ d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
25
+ const yearStart = new Date(d.getFullYear(), 0, 1);
26
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
27
+ }
28
+ case 'day':
29
+ return date.getDay(); // 0 = Sunday
30
+ case 'dayofyear': {
31
+ const start = new Date(date.getFullYear(), 0, 0);
32
+ const diff = date.getTime() - start.getTime();
33
+ return Math.floor(diff / 86400000);
34
+ }
35
+ case 'date':
36
+ return date.getDate(); // 1-31
37
+ case 'hours':
38
+ return date.getHours();
39
+ case 'minutes':
40
+ return date.getMinutes();
41
+ case 'seconds':
42
+ return date.getSeconds();
43
+ case 'milliseconds':
44
+ return date.getMilliseconds();
45
+ // Compound units
46
+ case 'yearmonth':
47
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
48
+ case 'yearmonthdate':
49
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
50
+ case 'monthdate':
51
+ return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
52
+ case 'hoursminutes':
53
+ return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Parse a value into a Date object.
59
+ * Handles Date objects, ISO strings, and numeric timestamps.
60
+ */
61
+ function toDate(value: unknown): Date | null {
62
+ if (value instanceof Date) return value;
63
+ if (typeof value === 'string' || typeof value === 'number') {
64
+ const d = new Date(value);
65
+ return Number.isNaN(d.getTime()) ? null : d;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Apply a time unit transform to data rows.
72
+ *
73
+ * Parses the field as a date and extracts the specified time unit,
74
+ * storing the result in a new field.
75
+ *
76
+ * @param data - Input rows.
77
+ * @param transform - Time unit transform definition.
78
+ * @returns New rows with the time unit field added.
79
+ */
80
+ export function runTimeUnit(data: DataRow[], transform: TimeUnitTransform): DataRow[] {
81
+ return data.map((row) => {
82
+ const date = toDate(row[transform.field]);
83
+ return {
84
+ ...row,
85
+ [transform.as]: date ? extractTimeUnit(date, transform.timeUnit) : null,
86
+ };
87
+ });
88
+ }