@opendata-ai/openchart-engine 1.2.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 (85) hide show
  1. package/dist/index.d.ts +366 -0
  2. package/dist/index.js +4227 -0
  3. package/dist/index.js.map +1 -0
  4. package/package.json +62 -0
  5. package/src/__test-fixtures__/specs.ts +124 -0
  6. package/src/__tests__/axes.test.ts +114 -0
  7. package/src/__tests__/compile-chart.test.ts +337 -0
  8. package/src/__tests__/dimensions.test.ts +151 -0
  9. package/src/__tests__/legend.test.ts +113 -0
  10. package/src/__tests__/scales.test.ts +109 -0
  11. package/src/annotations/__tests__/compute.test.ts +454 -0
  12. package/src/annotations/compute.ts +603 -0
  13. package/src/charts/__tests__/registry.test.ts +110 -0
  14. package/src/charts/bar/__tests__/compute.test.ts +294 -0
  15. package/src/charts/bar/__tests__/labels.test.ts +75 -0
  16. package/src/charts/bar/compute.ts +205 -0
  17. package/src/charts/bar/index.ts +33 -0
  18. package/src/charts/bar/labels.ts +132 -0
  19. package/src/charts/column/__tests__/compute.test.ts +277 -0
  20. package/src/charts/column/compute.ts +282 -0
  21. package/src/charts/column/index.ts +33 -0
  22. package/src/charts/column/labels.ts +108 -0
  23. package/src/charts/dot/__tests__/compute.test.ts +344 -0
  24. package/src/charts/dot/compute.ts +257 -0
  25. package/src/charts/dot/index.ts +46 -0
  26. package/src/charts/dot/labels.ts +97 -0
  27. package/src/charts/line/__tests__/compute.test.ts +437 -0
  28. package/src/charts/line/__tests__/labels.test.ts +93 -0
  29. package/src/charts/line/area.ts +288 -0
  30. package/src/charts/line/compute.ts +177 -0
  31. package/src/charts/line/index.ts +68 -0
  32. package/src/charts/line/labels.ts +144 -0
  33. package/src/charts/pie/__tests__/compute.test.ts +276 -0
  34. package/src/charts/pie/compute.ts +234 -0
  35. package/src/charts/pie/index.ts +49 -0
  36. package/src/charts/pie/labels.ts +142 -0
  37. package/src/charts/registry.ts +64 -0
  38. package/src/charts/scatter/__tests__/compute.test.ts +304 -0
  39. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  40. package/src/charts/scatter/compute.ts +124 -0
  41. package/src/charts/scatter/index.ts +41 -0
  42. package/src/charts/scatter/trendline.ts +100 -0
  43. package/src/charts/utils.ts +120 -0
  44. package/src/compile.ts +368 -0
  45. package/src/compiler/__tests__/compile.test.ts +87 -0
  46. package/src/compiler/__tests__/normalize.test.ts +210 -0
  47. package/src/compiler/__tests__/validate.test.ts +440 -0
  48. package/src/compiler/index.ts +47 -0
  49. package/src/compiler/normalize.ts +269 -0
  50. package/src/compiler/types.ts +148 -0
  51. package/src/compiler/validate.ts +581 -0
  52. package/src/graphs/__tests__/community.test.ts +228 -0
  53. package/src/graphs/__tests__/compile-graph.test.ts +315 -0
  54. package/src/graphs/__tests__/encoding.test.ts +314 -0
  55. package/src/graphs/community.ts +92 -0
  56. package/src/graphs/compile-graph.ts +291 -0
  57. package/src/graphs/encoding.ts +302 -0
  58. package/src/graphs/types.ts +98 -0
  59. package/src/index.ts +74 -0
  60. package/src/layout/axes.ts +194 -0
  61. package/src/layout/dimensions.ts +199 -0
  62. package/src/layout/gridlines.ts +84 -0
  63. package/src/layout/scales.ts +426 -0
  64. package/src/legend/compute.ts +186 -0
  65. package/src/tables/__tests__/bar-column.test.ts +147 -0
  66. package/src/tables/__tests__/category-colors.test.ts +153 -0
  67. package/src/tables/__tests__/compile-table.test.ts +208 -0
  68. package/src/tables/__tests__/format-cells.test.ts +126 -0
  69. package/src/tables/__tests__/heatmap.test.ts +124 -0
  70. package/src/tables/__tests__/pagination.test.ts +78 -0
  71. package/src/tables/__tests__/search.test.ts +94 -0
  72. package/src/tables/__tests__/sort.test.ts +107 -0
  73. package/src/tables/__tests__/sparkline.test.ts +122 -0
  74. package/src/tables/bar-column.ts +94 -0
  75. package/src/tables/category-colors.ts +67 -0
  76. package/src/tables/compile-table.ts +420 -0
  77. package/src/tables/format-cells.ts +110 -0
  78. package/src/tables/heatmap.ts +121 -0
  79. package/src/tables/pagination.ts +46 -0
  80. package/src/tables/search.ts +66 -0
  81. package/src/tables/sort.ts +69 -0
  82. package/src/tables/sparkline.ts +113 -0
  83. package/src/tables/utils.ts +16 -0
  84. package/src/tooltips/__tests__/compute.test.ts +328 -0
  85. package/src/tooltips/compute.ts +231 -0
@@ -0,0 +1,126 @@
1
+ import type { ColumnConfig } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { formatCell, formatValueForSearch } from '../format-cells';
4
+
5
+ describe('formatCell', () => {
6
+ it('formats null as empty string', () => {
7
+ const col: ColumnConfig = { key: 'x' };
8
+ const result = formatCell(null, col);
9
+ expect(result.formattedValue).toBe('');
10
+ expect(result.value).toBeNull();
11
+ });
12
+
13
+ it('formats undefined as empty string', () => {
14
+ const col: ColumnConfig = { key: 'x' };
15
+ const result = formatCell(undefined, col);
16
+ expect(result.formattedValue).toBe('');
17
+ expect(result.value).toBeUndefined();
18
+ });
19
+
20
+ it('applies d3-format string to numbers', () => {
21
+ const col: ColumnConfig = { key: 'x', format: ',.0f' };
22
+ const result = formatCell(1234567, col);
23
+ expect(result.formattedValue).toBe('1,234,567');
24
+ });
25
+
26
+ it('applies d3-format with dollar sign', () => {
27
+ const col: ColumnConfig = { key: 'x', format: '$,.2f' };
28
+ const result = formatCell(1234.5, col);
29
+ expect(result.formattedValue).toBe('$1,234.50');
30
+ });
31
+
32
+ it('applies d3-format with percentage', () => {
33
+ const col: ColumnConfig = { key: 'x', format: '.1%' };
34
+ const result = formatCell(0.456, col);
35
+ expect(result.formattedValue).toBe('45.6%');
36
+ });
37
+
38
+ it('auto-formats numbers without explicit format', () => {
39
+ const col: ColumnConfig = { key: 'x' };
40
+ const result = formatCell(42, col);
41
+ // Should produce some formatted string (formatNumber)
42
+ expect(result.formattedValue).toBeTruthy();
43
+ expect(typeof result.formattedValue).toBe('string');
44
+ });
45
+
46
+ it('formats Date values', () => {
47
+ const col: ColumnConfig = { key: 'x' };
48
+ const date = new Date('2023-06-15');
49
+ const result = formatCell(date, col);
50
+ expect(result.formattedValue).toBeTruthy();
51
+ expect(typeof result.formattedValue).toBe('string');
52
+ });
53
+
54
+ it('handles NaN gracefully (not numeric, falls through to String)', () => {
55
+ const col: ColumnConfig = { key: 'x' };
56
+ const result = formatCell(NaN, col);
57
+ // NaN is not isFinite, so falls through to String(NaN)
58
+ expect(result.formattedValue).toBe('NaN');
59
+ });
60
+
61
+ it('handles Infinity gracefully (not numeric, falls through to String)', () => {
62
+ const col: ColumnConfig = { key: 'x' };
63
+ const result = formatCell(Infinity, col);
64
+ expect(result.formattedValue).toBe('Infinity');
65
+ });
66
+
67
+ it('handles -Infinity gracefully', () => {
68
+ const col: ColumnConfig = { key: 'x' };
69
+ const result = formatCell(-Infinity, col);
70
+ expect(result.formattedValue).toBe('-Infinity');
71
+ });
72
+
73
+ it('does not crash on invalid d3-format string', () => {
74
+ const col: ColumnConfig = { key: 'x', format: '%%%invalid%%%' };
75
+ // Should not throw, falls through to auto-format
76
+ const result = formatCell(42, col);
77
+ expect(result.formattedValue).toBeTruthy();
78
+ });
79
+
80
+ it('formats plain strings as-is', () => {
81
+ const col: ColumnConfig = { key: 'x' };
82
+ const result = formatCell('hello world', col);
83
+ expect(result.formattedValue).toBe('hello world');
84
+ });
85
+
86
+ it('formats booleans as strings', () => {
87
+ const col: ColumnConfig = { key: 'x' };
88
+ const result = formatCell(true, col);
89
+ expect(result.formattedValue).toBe('true');
90
+ });
91
+
92
+ it('preserves raw value in output', () => {
93
+ const col: ColumnConfig = { key: 'x' };
94
+ const result = formatCell(42, col);
95
+ expect(result.value).toBe(42);
96
+ });
97
+
98
+ it('returns empty style object', () => {
99
+ const col: ColumnConfig = { key: 'x' };
100
+ const result = formatCell('test', col);
101
+ expect(result.style).toBeDefined();
102
+ });
103
+
104
+ it('d3-format string ignored for non-numeric values', () => {
105
+ const col: ColumnConfig = { key: 'x', format: ',.0f' };
106
+ const result = formatCell('not a number', col);
107
+ expect(result.formattedValue).toBe('not a number');
108
+ });
109
+ });
110
+
111
+ describe('formatValueForSearch', () => {
112
+ it('returns empty string for null', () => {
113
+ const col: ColumnConfig = { key: 'x' };
114
+ expect(formatValueForSearch(null, col)).toBe('');
115
+ });
116
+
117
+ it('formats numbers with d3-format for search', () => {
118
+ const col: ColumnConfig = { key: 'x', format: ',.0f' };
119
+ expect(formatValueForSearch(1234, col)).toBe('1,234');
120
+ });
121
+
122
+ it('falls back to String for non-numeric values', () => {
123
+ const col: ColumnConfig = { key: 'x' };
124
+ expect(formatValueForSearch('hello', col)).toBe('hello');
125
+ });
126
+ });
@@ -0,0 +1,124 @@
1
+ import type { ColumnConfig, ResolvedTheme } from '@opendata-ai/openchart-core';
2
+ import { adaptTheme, contrastRatio, resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { computeHeatmapColors } from '../heatmap';
5
+
6
+ function getTheme(dark = false): ResolvedTheme {
7
+ const theme = resolveTheme();
8
+ return dark ? adaptTheme(theme) : theme;
9
+ }
10
+
11
+ describe('computeHeatmapColors', () => {
12
+ const data = [{ value: 0 }, { value: 25 }, { value: 50 }, { value: 75 }, { value: 100 }];
13
+
14
+ const column: ColumnConfig = {
15
+ key: 'value',
16
+ heatmap: { palette: 'blue' },
17
+ };
18
+
19
+ it('assigns lighter colors to lower values and darker to higher', () => {
20
+ const theme = getTheme();
21
+ const colors = computeHeatmapColors(data, column, theme, false);
22
+
23
+ expect(colors.size).toBe(5);
24
+
25
+ // Lowest value should have a lighter background
26
+ const lowBg = colors.get(0)!.backgroundColor!;
27
+ const highBg = colors.get(4)!.backgroundColor!;
28
+
29
+ // Check that they're different colors
30
+ expect(lowBg).not.toBe(highBg);
31
+ });
32
+
33
+ it('text color meets AA contrast against background', () => {
34
+ const theme = getTheme();
35
+ const colors = computeHeatmapColors(data, column, theme, false);
36
+
37
+ for (const [, style] of colors) {
38
+ const bg = style.backgroundColor!;
39
+ const fg = style.color!;
40
+ const ratio = contrastRatio(fg, bg);
41
+ // Should pick black or white, both of which should exceed 3:1
42
+ expect(ratio).toBeGreaterThanOrEqual(3);
43
+ }
44
+ });
45
+
46
+ it('supports custom domain', () => {
47
+ const col: ColumnConfig = {
48
+ key: 'value',
49
+ heatmap: { palette: 'blue', domain: [0, 200] },
50
+ };
51
+ const theme = getTheme();
52
+ const colors = computeHeatmapColors(data, col, theme, false);
53
+
54
+ // All values are in the lower half of the domain, so should all have lighter colors
55
+ expect(colors.size).toBe(5);
56
+ });
57
+
58
+ it('values outside custom domain are clamped', () => {
59
+ const col: ColumnConfig = {
60
+ key: 'value',
61
+ heatmap: { palette: 'blue', domain: [25, 75] },
62
+ };
63
+ const theme = getTheme();
64
+ const colors = computeHeatmapColors(data, col, theme, false);
65
+
66
+ // value=0 is clamped to domain min, value=100 to domain max
67
+ expect(colors.has(0)).toBe(true);
68
+ expect(colors.has(4)).toBe(true);
69
+ });
70
+
71
+ it('supports colorByField', () => {
72
+ const dataWithLabel = [
73
+ { label: 'A', score: 10 },
74
+ { label: 'B', score: 90 },
75
+ ];
76
+ const col: ColumnConfig = {
77
+ key: 'label',
78
+ heatmap: { palette: 'blue', colorByField: 'score' },
79
+ };
80
+ const theme = getTheme();
81
+ const colors = computeHeatmapColors(dataWithLabel, col, theme, false);
82
+
83
+ // Should have colors for both rows since score has numeric values
84
+ expect(colors.size).toBe(2);
85
+ });
86
+
87
+ it('returns empty map for non-numeric columns', () => {
88
+ const nonNumericData = [{ value: 'hello' }, { value: 'world' }];
89
+ const theme = getTheme();
90
+ const colors = computeHeatmapColors(nonNumericData, column, theme, false);
91
+ expect(colors.size).toBe(0);
92
+ });
93
+
94
+ it('returns empty map when no heatmap config', () => {
95
+ const col: ColumnConfig = { key: 'value' };
96
+ const theme = getTheme();
97
+ const colors = computeHeatmapColors(data, col, theme, false);
98
+ expect(colors.size).toBe(0);
99
+ });
100
+
101
+ it('adapts colors for dark mode', () => {
102
+ const theme = getTheme(true);
103
+ const colors = computeHeatmapColors(data, column, theme, true);
104
+
105
+ expect(colors.size).toBe(5);
106
+ // In dark mode, text colors should still have adequate contrast
107
+ for (const [, style] of colors) {
108
+ const bg = style.backgroundColor!;
109
+ const fg = style.color!;
110
+ const ratio = contrastRatio(fg, bg);
111
+ expect(ratio).toBeGreaterThanOrEqual(3);
112
+ }
113
+ });
114
+
115
+ it('supports array of color stops as palette', () => {
116
+ const col: ColumnConfig = {
117
+ key: 'value',
118
+ heatmap: { palette: ['#ffffff', '#ff0000'] },
119
+ };
120
+ const theme = getTheme();
121
+ const colors = computeHeatmapColors(data, col, theme, false);
122
+ expect(colors.size).toBe(5);
123
+ });
124
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { paginateData } from '../pagination';
3
+
4
+ const data = Array.from({ length: 50 }, (_, i) => ({ id: i, value: i * 10 }));
5
+
6
+ describe('paginateData', () => {
7
+ it('returns the correct page slice', () => {
8
+ const result = paginateData(data, 0, 10);
9
+ expect(result.rows).toHaveLength(10);
10
+ expect(result.rows[0].id).toBe(0);
11
+ expect(result.rows[9].id).toBe(9);
12
+ });
13
+
14
+ it('returns correct second page', () => {
15
+ const result = paginateData(data, 1, 10);
16
+ expect(result.rows).toHaveLength(10);
17
+ expect(result.rows[0].id).toBe(10);
18
+ expect(result.rows[9].id).toBe(19);
19
+ });
20
+
21
+ it('returns correct last page (partial)', () => {
22
+ // 50 items, page size 15 = 4 pages (15+15+15+5)
23
+ const result = paginateData(data, 3, 15);
24
+ expect(result.rows).toHaveLength(5);
25
+ expect(result.rows[0].id).toBe(45);
26
+ });
27
+
28
+ it('clamps page to valid range (too high)', () => {
29
+ const result = paginateData(data, 999, 10);
30
+ expect(result.page).toBe(4); // Last page (0-indexed)
31
+ expect(result.rows).toHaveLength(10);
32
+ expect(result.rows[0].id).toBe(40);
33
+ });
34
+
35
+ it('clamps page to valid range (negative)', () => {
36
+ const result = paginateData(data, -5, 10);
37
+ expect(result.page).toBe(0);
38
+ expect(result.rows[0].id).toBe(0);
39
+ });
40
+
41
+ it('computes totalPages correctly', () => {
42
+ const result = paginateData(data, 0, 10);
43
+ expect(result.totalPages).toBe(5);
44
+ expect(result.totalRows).toBe(50);
45
+ });
46
+
47
+ it('computes totalPages for non-even division', () => {
48
+ const result = paginateData(data, 0, 15);
49
+ expect(result.totalPages).toBe(4); // ceil(50/15) = 4
50
+ });
51
+
52
+ it('handles disabled pagination (pageSize <= 0)', () => {
53
+ const result = paginateData(data, 0, 0);
54
+ expect(result.rows).toHaveLength(50);
55
+ expect(result.totalPages).toBe(1);
56
+ expect(result.page).toBe(0);
57
+ });
58
+
59
+ it('handles negative pageSize as disabled', () => {
60
+ const result = paginateData(data, 0, -10);
61
+ expect(result.rows).toHaveLength(50);
62
+ expect(result.totalPages).toBe(1);
63
+ });
64
+
65
+ it('handles empty data', () => {
66
+ const result = paginateData([], 0, 10);
67
+ expect(result.rows).toHaveLength(0);
68
+ expect(result.totalRows).toBe(0);
69
+ expect(result.totalPages).toBe(1);
70
+ expect(result.page).toBe(0);
71
+ });
72
+
73
+ it('handles single row', () => {
74
+ const result = paginateData([{ id: 1 }], 0, 10);
75
+ expect(result.rows).toHaveLength(1);
76
+ expect(result.totalPages).toBe(1);
77
+ });
78
+ });
@@ -0,0 +1,94 @@
1
+ import type { ColumnConfig } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildSearchIndex, filterBySearch } from '../search';
4
+
5
+ const data = [
6
+ { name: 'Alice Smith', age: 30, score: 88.5 },
7
+ { name: 'Bob Johnson', age: 25, score: 92.1 },
8
+ { name: 'Charlie Brown', age: 35, score: 76.3 },
9
+ ];
10
+
11
+ const columns: ColumnConfig[] = [{ key: 'name' }, { key: 'age' }, { key: 'score' }];
12
+
13
+ describe('buildSearchIndex', () => {
14
+ it('builds an index for all rows', () => {
15
+ const index = buildSearchIndex(data, columns);
16
+ expect(index.size).toBe(3);
17
+ expect(index.has(0)).toBe(true);
18
+ expect(index.has(1)).toBe(true);
19
+ expect(index.has(2)).toBe(true);
20
+ });
21
+
22
+ it('includes all column values in the search string', () => {
23
+ const index = buildSearchIndex(data, columns);
24
+ const row0 = index.get(0)!;
25
+ expect(row0).toContain('alice');
26
+ expect(row0).toContain('30');
27
+ });
28
+
29
+ it('lowercases all values', () => {
30
+ const index = buildSearchIndex(data, columns);
31
+ const row0 = index.get(0)!;
32
+ // Should be lowercase
33
+ expect(row0).not.toContain('Alice');
34
+ expect(row0).toContain('alice');
35
+ });
36
+ });
37
+
38
+ describe('filterBySearch', () => {
39
+ it('returns all data for empty query', () => {
40
+ const index = buildSearchIndex(data, columns);
41
+ const indices = [0, 1, 2];
42
+ const result = filterBySearch(data, '', index, indices);
43
+ expect(result.data).toHaveLength(3);
44
+ expect(result.indices).toEqual([0, 1, 2]);
45
+ });
46
+
47
+ it('returns all data for whitespace-only query', () => {
48
+ const index = buildSearchIndex(data, columns);
49
+ const indices = [0, 1, 2];
50
+ const result = filterBySearch(data, ' ', index, indices);
51
+ expect(result.data).toHaveLength(3);
52
+ });
53
+
54
+ it('filters by substring match', () => {
55
+ const index = buildSearchIndex(data, columns);
56
+ const indices = [0, 1, 2];
57
+ const result = filterBySearch(data, 'alice', index, indices);
58
+ expect(result.data).toHaveLength(1);
59
+ expect(result.data[0].name).toBe('Alice Smith');
60
+ expect(result.indices).toEqual([0]);
61
+ });
62
+
63
+ it('is case insensitive', () => {
64
+ const index = buildSearchIndex(data, columns);
65
+ const indices = [0, 1, 2];
66
+ const result = filterBySearch(data, 'ALICE', index, indices);
67
+ expect(result.data).toHaveLength(1);
68
+ expect(result.data[0].name).toBe('Alice Smith');
69
+ });
70
+
71
+ it('matches across formatted numeric values', () => {
72
+ const index = buildSearchIndex(data, columns);
73
+ const indices = [0, 1, 2];
74
+ // Search for a number value
75
+ const result = filterBySearch(data, '35', index, indices);
76
+ expect(result.data).toHaveLength(1);
77
+ expect(result.data[0].name).toBe('Charlie Brown');
78
+ });
79
+
80
+ it('returns empty when no match', () => {
81
+ const index = buildSearchIndex(data, columns);
82
+ const indices = [0, 1, 2];
83
+ const result = filterBySearch(data, 'zzz_no_match', index, indices);
84
+ expect(result.data).toHaveLength(0);
85
+ expect(result.indices).toHaveLength(0);
86
+ });
87
+
88
+ it('preserves original indices through filtering', () => {
89
+ const index = buildSearchIndex(data, columns);
90
+ const indices = [0, 1, 2];
91
+ const result = filterBySearch(data, 'brown', index, indices);
92
+ expect(result.indices).toEqual([2]);
93
+ });
94
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { sortData } from '../sort';
3
+
4
+ describe('sortData', () => {
5
+ it('sorts numbers numerically ascending', () => {
6
+ const data = [
7
+ { name: 'C', value: 30 },
8
+ { name: 'A', value: 10 },
9
+ { name: 'B', value: 20 },
10
+ ];
11
+ const result = sortData(data, { column: 'value', direction: 'asc' });
12
+ expect(result.data.map((r) => r.value)).toEqual([10, 20, 30]);
13
+ });
14
+
15
+ it('sorts numbers numerically descending', () => {
16
+ const data = [
17
+ { name: 'A', value: 10 },
18
+ { name: 'C', value: 30 },
19
+ { name: 'B', value: 20 },
20
+ ];
21
+ const result = sortData(data, { column: 'value', direction: 'desc' });
22
+ expect(result.data.map((r) => r.value)).toEqual([30, 20, 10]);
23
+ });
24
+
25
+ it('sorts strings via localeCompare', () => {
26
+ const data = [
27
+ { name: 'Charlie', value: 1 },
28
+ { name: 'Alice', value: 2 },
29
+ { name: 'Bob', value: 3 },
30
+ ];
31
+ const result = sortData(data, { column: 'name', direction: 'asc' });
32
+ expect(result.data.map((r) => r.name)).toEqual(['Alice', 'Bob', 'Charlie']);
33
+ });
34
+
35
+ it('sorts dates by timestamp', () => {
36
+ const d1 = new Date('2020-01-01');
37
+ const d2 = new Date('2021-06-15');
38
+ const d3 = new Date('2019-12-31');
39
+ const data = [
40
+ { date: d1, val: 1 },
41
+ { date: d2, val: 2 },
42
+ { date: d3, val: 3 },
43
+ ];
44
+ const result = sortData(data, { column: 'date', direction: 'asc' });
45
+ expect(result.data.map((r) => r.val)).toEqual([3, 1, 2]);
46
+ });
47
+
48
+ it('puts null values last regardless of sort direction', () => {
49
+ const data = [
50
+ { name: 'A', value: null },
51
+ { name: 'B', value: 20 },
52
+ { name: 'C', value: 10 },
53
+ ];
54
+
55
+ const asc = sortData(data, { column: 'value', direction: 'asc' });
56
+ expect(asc.data.map((r) => r.name)).toEqual(['C', 'B', 'A']);
57
+
58
+ const desc = sortData(data, { column: 'value', direction: 'desc' });
59
+ expect(desc.data.map((r) => r.name)).toEqual(['B', 'C', 'A']);
60
+ });
61
+
62
+ it('is stable (preserves order for equal values)', () => {
63
+ const data = [
64
+ { name: 'First', value: 10 },
65
+ { name: 'Second', value: 10 },
66
+ { name: 'Third', value: 10 },
67
+ ];
68
+ const result = sortData(data, { column: 'value', direction: 'asc' });
69
+ expect(result.data.map((r) => r.name)).toEqual(['First', 'Second', 'Third']);
70
+ });
71
+
72
+ it('does not mutate the original array', () => {
73
+ const data = [{ value: 30 }, { value: 10 }, { value: 20 }];
74
+ const original = [...data];
75
+ sortData(data, { column: 'value', direction: 'asc' });
76
+ expect(data).toEqual(original);
77
+ });
78
+
79
+ it('handles single element array', () => {
80
+ const data = [{ value: 42 }];
81
+ const result = sortData(data, { column: 'value', direction: 'asc' });
82
+ expect(result.data).toHaveLength(1);
83
+ expect(result.data[0].value).toBe(42);
84
+ });
85
+
86
+ it('handles all null values', () => {
87
+ const data = [
88
+ { name: 'A', value: null },
89
+ { name: 'B', value: null },
90
+ ];
91
+ const result = sortData(data, { column: 'value', direction: 'asc' });
92
+ // Stable sort: original order preserved
93
+ expect(result.data.map((r) => r.name)).toEqual(['A', 'B']);
94
+ });
95
+
96
+ it('returns correct originalIndices for sorted data', () => {
97
+ const data = [
98
+ { name: 'C', value: 30 },
99
+ { name: 'A', value: 10 },
100
+ { name: 'B', value: 20 },
101
+ ];
102
+ const result = sortData(data, { column: 'value', direction: 'asc' });
103
+ // A (index 1) < B (index 2) < C (index 0)
104
+ expect(result.originalIndices).toEqual([1, 2, 0]);
105
+ expect(result.data.map((r) => r.name)).toEqual(['A', 'B', 'C']);
106
+ });
107
+ });
@@ -0,0 +1,122 @@
1
+ import type { SparklineColumnConfig } from '@opendata-ai/openchart-core';
2
+ import { resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { computeSparkline, computeSparklineForRow } from '../sparkline';
5
+
6
+ const theme = resolveTheme();
7
+
8
+ describe('computeSparkline', () => {
9
+ it('normalizes line points to 0-1 range', () => {
10
+ const config: SparklineColumnConfig = { type: 'line' };
11
+ const result = computeSparkline([10, 20, 30, 40, 50], config, theme, false);
12
+
13
+ expect(result).not.toBeNull();
14
+ expect(result!.type).toBe('line');
15
+ expect(result!.points).toHaveLength(5);
16
+
17
+ // First point at min should be y=0, last at max should be y=1
18
+ expect(result!.points[0].y).toBeCloseTo(0);
19
+ expect(result!.points[4].y).toBeCloseTo(1);
20
+
21
+ // X values evenly distributed
22
+ expect(result!.points[0].x).toBeCloseTo(0);
23
+ expect(result!.points[4].x).toBeCloseTo(1);
24
+ });
25
+
26
+ it('produces bar data for bar type', () => {
27
+ const config: SparklineColumnConfig = { type: 'bar' };
28
+ const result = computeSparkline([0, 50, 100], config, theme, false);
29
+
30
+ expect(result).not.toBeNull();
31
+ expect(result!.type).toBe('bar');
32
+ expect(result!.bars).toHaveLength(3);
33
+
34
+ // Normalized: 0->0, 50->0.5, 100->1
35
+ expect(result!.bars[0]).toBeCloseTo(0);
36
+ expect(result!.bars[1]).toBeCloseTo(0.5);
37
+ expect(result!.bars[2]).toBeCloseTo(1);
38
+ });
39
+
40
+ it('produces column data for column type', () => {
41
+ const config: SparklineColumnConfig = { type: 'column' };
42
+ const result = computeSparkline([10, 20, 30], config, theme, false);
43
+
44
+ expect(result).not.toBeNull();
45
+ expect(result!.type).toBe('column');
46
+ expect(result!.bars).toHaveLength(3);
47
+ });
48
+
49
+ it('returns null for empty values', () => {
50
+ const config: SparklineColumnConfig = { type: 'line' };
51
+ const result = computeSparkline([], config, theme, false);
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ it('handles all equal values (flat line)', () => {
56
+ const config: SparklineColumnConfig = { type: 'line' };
57
+ const result = computeSparkline([5, 5, 5], config, theme, false);
58
+
59
+ expect(result).not.toBeNull();
60
+ // When range is 0, all y values should be 0.5
61
+ expect(result!.points[0].y).toBeCloseTo(0.5);
62
+ expect(result!.points[1].y).toBeCloseTo(0.5);
63
+ });
64
+
65
+ it('handles single value', () => {
66
+ const config: SparklineColumnConfig = { type: 'line' };
67
+ const result = computeSparkline([42], config, theme, false);
68
+
69
+ expect(result).not.toBeNull();
70
+ expect(result!.points).toHaveLength(1);
71
+ expect(result!.points[0].x).toBeCloseTo(0.5);
72
+ expect(result!.points[0].y).toBeCloseTo(0.5);
73
+ });
74
+
75
+ it('uses custom color from config', () => {
76
+ const config: SparklineColumnConfig = { type: 'line', color: '#ff0000' };
77
+ const result = computeSparkline([1, 2, 3], config, theme, false);
78
+ expect(result!.color).toBe('#ff0000');
79
+ });
80
+
81
+ it('defaults to first categorical palette color', () => {
82
+ const config: SparklineColumnConfig = { type: 'line' };
83
+ const result = computeSparkline([1, 2, 3], config, theme, false);
84
+ expect(result!.color).toBe(theme.colors.categorical[0]);
85
+ });
86
+ });
87
+
88
+ describe('computeSparklineForRow', () => {
89
+ it('extracts values from array field', () => {
90
+ const row = { trend: [10, 20, 30] };
91
+ const config: SparklineColumnConfig = { type: 'line' };
92
+ const result = computeSparklineForRow(row, 'trend', config, theme, false);
93
+
94
+ expect(result).not.toBeNull();
95
+ expect(result!.count).toBe(3);
96
+ });
97
+
98
+ it('uses valuesField to extract from different field', () => {
99
+ const row = { label: 'Test', data: [5, 15, 25] };
100
+ const config: SparklineColumnConfig = { type: 'line', valuesField: 'data' };
101
+ const result = computeSparklineForRow(row, 'label', config, theme, false);
102
+
103
+ expect(result).not.toBeNull();
104
+ expect(result!.count).toBe(3);
105
+ });
106
+
107
+ it('returns null when field value is not an array', () => {
108
+ const row = { trend: 'not an array' };
109
+ const config: SparklineColumnConfig = { type: 'line' };
110
+ const result = computeSparklineForRow(row, 'trend', config, theme, false);
111
+ expect(result).toBeNull();
112
+ });
113
+
114
+ it('filters out non-numeric values from array', () => {
115
+ const row = { trend: [10, 'bad', null, 30] };
116
+ const config: SparklineColumnConfig = { type: 'line' };
117
+ const result = computeSparklineForRow(row, 'trend', config, theme, false);
118
+
119
+ expect(result).not.toBeNull();
120
+ expect(result!.count).toBe(2); // only 10 and 30
121
+ });
122
+ });