@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,186 @@
1
+ /**
2
+ * Legend computation.
3
+ *
4
+ * Derives legend entries from the color encoding's unique values in data,
5
+ * computes position based on layout strategy, and returns a LegendLayout
6
+ * that dimensions.ts uses to reserve space in the chart area.
7
+ *
8
+ * The legend is computed early (before marks) so the chartArea accounts
9
+ * for legend space. Entries come from data + encoding, not marks.
10
+ */
11
+
12
+ import type {
13
+ LayoutStrategy,
14
+ LegendEntry,
15
+ LegendLayout,
16
+ Rect,
17
+ ResolvedTheme,
18
+ TextStyle,
19
+ } from '@opendata-ai/openchart-core';
20
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
21
+
22
+ import type { NormalizedChartSpec } from '../compiler/types';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const SWATCH_SIZE = 12;
29
+ const SWATCH_GAP = 6;
30
+ const ENTRY_GAP = 16;
31
+ const LEGEND_PADDING = 8;
32
+ const LEGEND_RIGHT_WIDTH = 120;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Determine the swatch shape based on chart type. */
39
+ function swatchShapeForType(chartType: string): LegendEntry['shape'] {
40
+ switch (chartType) {
41
+ case 'line':
42
+ return 'line';
43
+ case 'scatter':
44
+ case 'dot':
45
+ return 'circle';
46
+ default:
47
+ return 'square';
48
+ }
49
+ }
50
+
51
+ /** Extract unique color values from data based on the color encoding. */
52
+ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): LegendEntry[] {
53
+ const colorEnc = spec.encoding.color;
54
+ if (!colorEnc) return [];
55
+
56
+ // Sequential (quantitative) color doesn't produce discrete legend entries
57
+ if (colorEnc.type === 'quantitative') return [];
58
+
59
+ const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
60
+ const palette = theme.colors.categorical;
61
+ const shape = swatchShapeForType(spec.type);
62
+
63
+ return uniqueValues.map((value, i) => ({
64
+ label: value,
65
+ color: palette[i % palette.length],
66
+ shape,
67
+ active: true,
68
+ }));
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Public API
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Compute legend layout for a chart spec.
77
+ *
78
+ * @param spec - Normalized chart spec.
79
+ * @param strategy - Responsive layout strategy.
80
+ * @param theme - Resolved theme.
81
+ * @param chartArea - The available chart area (before legend space is reserved).
82
+ * @returns LegendLayout with position, entries, and bounds.
83
+ */
84
+ export function computeLegend(
85
+ spec: NormalizedChartSpec,
86
+ strategy: LayoutStrategy,
87
+ theme: ResolvedTheme,
88
+ chartArea: Rect,
89
+ ): LegendLayout {
90
+ const entries = extractColorEntries(spec, theme);
91
+
92
+ const labelStyle: TextStyle = {
93
+ fontFamily: theme.fonts.family,
94
+ fontSize: theme.fonts.sizes.small,
95
+ fontWeight: theme.fonts.weights.normal,
96
+ fill: theme.colors.text,
97
+ lineHeight: 1.3,
98
+ };
99
+
100
+ // Resolve position: spec-level override wins, then responsive strategy
101
+ const resolvedPosition =
102
+ spec.legend?.position ?? (strategy.legendPosition === 'right' ? 'right' : 'top');
103
+
104
+ // No entries = empty legend with no space
105
+ if (entries.length === 0) {
106
+ return {
107
+ position: resolvedPosition,
108
+ entries: [],
109
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
110
+ labelStyle,
111
+ swatchSize: SWATCH_SIZE,
112
+ swatchGap: SWATCH_GAP,
113
+ entryGap: ENTRY_GAP,
114
+ };
115
+ }
116
+
117
+ if (resolvedPosition === 'right' || resolvedPosition === 'bottom-right') {
118
+ // Right-positioned legend: vertical stack
119
+ const maxLabelWidth = Math.max(
120
+ ...entries.map((e) => estimateTextWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
121
+ );
122
+ const legendWidth = Math.min(
123
+ LEGEND_RIGHT_WIDTH,
124
+ SWATCH_SIZE + SWATCH_GAP + maxLabelWidth + LEGEND_PADDING * 2,
125
+ );
126
+ const entryHeight = Math.max(SWATCH_SIZE, labelStyle.fontSize * labelStyle.lineHeight);
127
+ const legendHeight =
128
+ entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
129
+ const clampedHeight = Math.min(legendHeight, chartArea.height);
130
+
131
+ // bottom-right anchors to the bottom of the chart area
132
+ const legendY =
133
+ resolvedPosition === 'bottom-right'
134
+ ? chartArea.y + chartArea.height - clampedHeight
135
+ : chartArea.y;
136
+
137
+ // Apply user-provided legend offset
138
+ const offsetDx = spec.legend?.offset?.dx ?? 0;
139
+ const offsetDy = spec.legend?.offset?.dy ?? 0;
140
+
141
+ return {
142
+ position: resolvedPosition,
143
+ entries,
144
+ bounds: {
145
+ x: chartArea.x + chartArea.width - legendWidth + offsetDx,
146
+ y: legendY + offsetDy,
147
+ width: legendWidth,
148
+ height: clampedHeight,
149
+ },
150
+ labelStyle,
151
+ swatchSize: SWATCH_SIZE,
152
+ swatchGap: SWATCH_GAP,
153
+ entryGap: 4,
154
+ };
155
+ }
156
+
157
+ // Top/bottom-positioned legend: horizontal flow
158
+ const totalWidth = entries.reduce((sum, entry) => {
159
+ const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
160
+ return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
161
+ }, 0);
162
+
163
+ const legendHeight = SWATCH_SIZE + LEGEND_PADDING * 2;
164
+
165
+ // Apply user-provided legend offset
166
+ const offsetDx = spec.legend?.offset?.dx ?? 0;
167
+ const offsetDy = spec.legend?.offset?.dy ?? 0;
168
+
169
+ return {
170
+ position: resolvedPosition,
171
+ entries,
172
+ bounds: {
173
+ x: chartArea.x + offsetDx,
174
+ y:
175
+ (resolvedPosition === 'bottom'
176
+ ? chartArea.y + chartArea.height - legendHeight
177
+ : chartArea.y) + offsetDy,
178
+ width: Math.min(totalWidth, chartArea.width),
179
+ height: legendHeight,
180
+ },
181
+ labelStyle,
182
+ swatchSize: SWATCH_SIZE,
183
+ swatchGap: SWATCH_GAP,
184
+ entryGap: ENTRY_GAP,
185
+ };
186
+ }
@@ -0,0 +1,147 @@
1
+ import type { BarColumnConfig } from '@opendata-ai/openchart-core';
2
+ import { resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { computeBarCell, computeColumnMax, computeColumnMin } from '../bar-column';
5
+
6
+ const theme = resolveTheme();
7
+
8
+ describe('computeBarCell', () => {
9
+ it('computes correct percentage for mid-range value', () => {
10
+ const config: BarColumnConfig = {};
11
+ const result = computeBarCell(50, config, 100, 0, theme, false);
12
+ expect(result.barPercent).toBeCloseTo(0.5);
13
+ expect(result.barOffset).toBe(0);
14
+ expect(result.isNegative).toBe(false);
15
+ });
16
+
17
+ it('computes 100% for max value', () => {
18
+ const config: BarColumnConfig = {};
19
+ const result = computeBarCell(100, config, 100, 0, theme, false);
20
+ expect(result.barPercent).toBeCloseTo(1);
21
+ });
22
+
23
+ it('computes 0% for zero value', () => {
24
+ const config: BarColumnConfig = {};
25
+ const result = computeBarCell(0, config, 100, 0, theme, false);
26
+ expect(result.barPercent).toBeCloseTo(0);
27
+ });
28
+
29
+ it('uses config maxValue when provided', () => {
30
+ const config: BarColumnConfig = { maxValue: 200 };
31
+ const result = computeBarCell(100, config, 50, 0, theme, false);
32
+ // Should use config maxValue (200), not columnMax (50)
33
+ expect(result.barPercent).toBeCloseTo(0.5);
34
+ });
35
+
36
+ it('clamps negative values to 0 when column has no negatives', () => {
37
+ const config: BarColumnConfig = {};
38
+ // columnMin=0 means no negative values in the column
39
+ const result = computeBarCell(-10, config, 100, 0, theme, false);
40
+ expect(result.barPercent).toBe(0);
41
+ });
42
+
43
+ it('clamps values above max to 1', () => {
44
+ const config: BarColumnConfig = {};
45
+ const result = computeBarCell(150, config, 100, 0, theme, false);
46
+ expect(result.barPercent).toBe(1);
47
+ });
48
+
49
+ it('uses custom color from config', () => {
50
+ const config: BarColumnConfig = { color: '#ff0000' };
51
+ const result = computeBarCell(50, config, 100, 0, theme, false);
52
+ expect(result.barColor).toBe('#ff0000');
53
+ });
54
+
55
+ it('defaults to first categorical palette color', () => {
56
+ const config: BarColumnConfig = {};
57
+ const result = computeBarCell(50, config, 100, 0, theme, false);
58
+ expect(result.barColor).toBe(theme.colors.categorical[0]);
59
+ });
60
+
61
+ it('handles NaN value', () => {
62
+ const config: BarColumnConfig = {};
63
+ const result = computeBarCell(NaN, config, 100, 0, theme, false);
64
+ expect(result.barPercent).toBe(0);
65
+ });
66
+
67
+ it('handles Infinity value', () => {
68
+ const config: BarColumnConfig = {};
69
+ const result = computeBarCell(Infinity, config, 100, 0, theme, false);
70
+ expect(result.barPercent).toBe(0);
71
+ });
72
+
73
+ it('handles zero columnMax', () => {
74
+ const config: BarColumnConfig = {};
75
+ const result = computeBarCell(50, config, 0, 0, theme, false);
76
+ expect(result.barPercent).toBe(0);
77
+ });
78
+
79
+ // Negative value support
80
+ it('renders negative value as bidirectional bar', () => {
81
+ const config: BarColumnConfig = {};
82
+ // Column range: -20 to 80, totalRange = 100, zeroPos = 0.2
83
+ const result = computeBarCell(-10, config, 80, -20, theme, false);
84
+ expect(result.isNegative).toBe(true);
85
+ expect(result.barPercent).toBeCloseTo(0.1); // 10/100
86
+ expect(result.barOffset).toBeCloseTo(0.1); // zeroPos(0.2) - barPercent(0.1)
87
+ });
88
+
89
+ it('renders positive value with offset when column has negatives', () => {
90
+ const config: BarColumnConfig = {};
91
+ // Column range: -20 to 80, totalRange = 100, zeroPos = 0.2
92
+ const result = computeBarCell(40, config, 80, -20, theme, false);
93
+ expect(result.isNegative).toBe(false);
94
+ expect(result.barPercent).toBeCloseTo(0.4); // 40/100
95
+ expect(result.barOffset).toBeCloseTo(0.2); // zeroPos
96
+ });
97
+
98
+ it('uses red color for negative bars by default', () => {
99
+ const config: BarColumnConfig = {};
100
+ const result = computeBarCell(-10, config, 80, -20, theme, false);
101
+ expect(result.isNegative).toBe(true);
102
+ expect(result.barColor).toBe('#c44e52');
103
+ });
104
+
105
+ it('uses custom color for negative bars when config.color is set', () => {
106
+ const config: BarColumnConfig = { color: '#ff0000' };
107
+ const result = computeBarCell(-10, config, 80, -20, theme, false);
108
+ expect(result.barColor).toBe('#ff0000');
109
+ });
110
+ });
111
+
112
+ describe('computeColumnMax', () => {
113
+ it('finds the max numeric value in a column', () => {
114
+ const data = [{ value: 10 }, { value: 50 }, { value: 30 }];
115
+ expect(computeColumnMax(data, 'value')).toBe(50);
116
+ });
117
+
118
+ it('ignores non-numeric values', () => {
119
+ const data = [{ value: 10 }, { value: 'hello' }, { value: null }, { value: 30 }];
120
+ expect(computeColumnMax(data, 'value')).toBe(30);
121
+ });
122
+
123
+ it('returns 0 for empty data', () => {
124
+ expect(computeColumnMax([], 'value')).toBe(0);
125
+ });
126
+
127
+ it('returns 0 when all values are non-numeric', () => {
128
+ const data = [{ value: 'a' }, { value: 'b' }];
129
+ expect(computeColumnMax(data, 'value')).toBe(0);
130
+ });
131
+ });
132
+
133
+ describe('computeColumnMin', () => {
134
+ it('finds the min numeric value in a column', () => {
135
+ const data = [{ value: 10 }, { value: -5 }, { value: 30 }];
136
+ expect(computeColumnMin(data, 'value')).toBe(-5);
137
+ });
138
+
139
+ it('returns 0 for all-positive data', () => {
140
+ const data = [{ value: 10 }, { value: 20 }];
141
+ expect(computeColumnMin(data, 'value')).toBe(0);
142
+ });
143
+
144
+ it('returns 0 for empty data', () => {
145
+ expect(computeColumnMin([], 'value')).toBe(0);
146
+ });
147
+ });
@@ -0,0 +1,153 @@
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 { computeCategoryColors } from '../category-colors';
5
+
6
+ function getTheme(dark = false): ResolvedTheme {
7
+ const theme = resolveTheme();
8
+ return dark ? adaptTheme(theme) : theme;
9
+ }
10
+
11
+ describe('computeCategoryColors', () => {
12
+ const data = [
13
+ { status: 'active' },
14
+ { status: 'inactive' },
15
+ { status: 'active' },
16
+ { status: 'pending' },
17
+ ];
18
+
19
+ it('applies explicit color mapping correctly', () => {
20
+ const col: ColumnConfig = {
21
+ key: 'status',
22
+ categoryColors: {
23
+ active: '#00ff00',
24
+ inactive: '#ff0000',
25
+ },
26
+ };
27
+ const theme = getTheme();
28
+ const colors = computeCategoryColors(data, col, theme, false);
29
+
30
+ expect(colors.size).toBe(4);
31
+ // "active" rows (indices 0, 2) should have green background
32
+ expect(colors.get(0)!.backgroundColor).toBe('#00ff00');
33
+ expect(colors.get(2)!.backgroundColor).toBe('#00ff00');
34
+ // "inactive" row (index 1) should have red background
35
+ expect(colors.get(1)!.backgroundColor).toBe('#ff0000');
36
+ });
37
+
38
+ it('unmapped values get palette colors', () => {
39
+ const col: ColumnConfig = {
40
+ key: 'status',
41
+ categoryColors: {
42
+ active: '#00ff00',
43
+ },
44
+ };
45
+ const theme = getTheme();
46
+ const colors = computeCategoryColors(data, col, theme, false);
47
+
48
+ // "inactive" and "pending" are not in the explicit map, should get palette colors
49
+ const inactiveBg = colors.get(1)!.backgroundColor!;
50
+ const pendingBg = colors.get(3)!.backgroundColor!;
51
+
52
+ // They should be assigned from the categorical palette
53
+ expect(inactiveBg).toBeTruthy();
54
+ expect(pendingBg).toBeTruthy();
55
+ // They should not be the explicit mapped color
56
+ expect(inactiveBg).not.toBe('#00ff00');
57
+ });
58
+
59
+ it('same category value gets consistent colors', () => {
60
+ const col: ColumnConfig = {
61
+ key: 'status',
62
+ categoryColors: { active: '#00ff00' },
63
+ };
64
+ const theme = getTheme();
65
+ const colors = computeCategoryColors(data, col, theme, false);
66
+
67
+ // Both "active" rows should have the same color
68
+ expect(colors.get(0)!.backgroundColor).toBe(colors.get(2)!.backgroundColor);
69
+ });
70
+
71
+ it('text contrast meets AA (at least 3:1)', () => {
72
+ const col: ColumnConfig = {
73
+ key: 'status',
74
+ categoryColors: {
75
+ active: '#00ff00',
76
+ inactive: '#ff0000',
77
+ pending: '#0000ff',
78
+ },
79
+ };
80
+ const theme = getTheme();
81
+ const colors = computeCategoryColors(data, col, theme, false);
82
+
83
+ for (const [, style] of colors) {
84
+ const bg = style.backgroundColor!;
85
+ const fg = style.color!;
86
+ const ratio = contrastRatio(fg, bg);
87
+ // accessibleTextColor picks black or white; both should exceed 3:1
88
+ expect(ratio).toBeGreaterThanOrEqual(3);
89
+ }
90
+ });
91
+
92
+ it('dark mode adapts colors', () => {
93
+ const col: ColumnConfig = {
94
+ key: 'status',
95
+ categoryColors: {
96
+ active: '#00ff00',
97
+ inactive: '#ff0000',
98
+ },
99
+ };
100
+ const lightTheme = getTheme(false);
101
+ const darkTheme = getTheme(true);
102
+
103
+ const lightColors = computeCategoryColors(data, col, lightTheme, false);
104
+ const darkColors = computeCategoryColors(data, col, darkTheme, true);
105
+
106
+ expect(lightColors.size).toBe(darkColors.size);
107
+
108
+ // Dark mode colors should be adapted (different from light mode)
109
+ const lightBg = lightColors.get(0)!.backgroundColor;
110
+ const darkBg = darkColors.get(0)!.backgroundColor;
111
+ expect(darkBg).not.toBe(lightBg);
112
+ });
113
+
114
+ it('dark mode text contrast still meets AA', () => {
115
+ const col: ColumnConfig = {
116
+ key: 'status',
117
+ categoryColors: {
118
+ active: '#00ff00',
119
+ inactive: '#ff0000',
120
+ },
121
+ };
122
+ const theme = getTheme(true);
123
+ const colors = computeCategoryColors(data, col, theme, true);
124
+
125
+ for (const [, style] of colors) {
126
+ const bg = style.backgroundColor!;
127
+ const fg = style.color!;
128
+ const ratio = contrastRatio(fg, bg);
129
+ expect(ratio).toBeGreaterThanOrEqual(3);
130
+ }
131
+ });
132
+
133
+ it('returns empty map when no categoryColors config', () => {
134
+ const col: ColumnConfig = { key: 'status' };
135
+ const theme = getTheme();
136
+ const colors = computeCategoryColors(data, col, theme, false);
137
+ expect(colors.size).toBe(0);
138
+ });
139
+
140
+ it('skips null values', () => {
141
+ const dataWithNull = [{ status: 'active' }, { status: null }, { status: 'inactive' }];
142
+ const col: ColumnConfig = {
143
+ key: 'status',
144
+ categoryColors: { active: '#00ff00', inactive: '#ff0000' },
145
+ };
146
+ const theme = getTheme();
147
+ const colors = computeCategoryColors(dataWithNull, col, theme, false);
148
+
149
+ // null row (index 1) should not be included
150
+ expect(colors.has(1)).toBe(false);
151
+ expect(colors.size).toBe(2);
152
+ });
153
+ });
@@ -0,0 +1,208 @@
1
+ import type { CompileTableOptions } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compileTable } from '../../compile';
4
+
5
+ const sampleData = [
6
+ { name: 'Alice', age: 30, score: 88.5 },
7
+ { name: 'Bob', age: 25, score: 92.1 },
8
+ { name: 'Charlie', age: 35, score: 76.3 },
9
+ { name: 'Diana', age: 28, score: 95.0 },
10
+ ];
11
+
12
+ const baseSpec = {
13
+ type: 'table' as const,
14
+ data: sampleData,
15
+ columns: [
16
+ { key: 'name', label: 'Name' },
17
+ { key: 'age', label: 'Age' },
18
+ { key: 'score', label: 'Score' },
19
+ ],
20
+ };
21
+
22
+ const baseOptions: CompileTableOptions = {
23
+ width: 800,
24
+ height: 600,
25
+ };
26
+
27
+ describe('compileTable', () => {
28
+ it('produces a valid TableLayout from a basic spec', () => {
29
+ const layout = compileTable(baseSpec, baseOptions);
30
+
31
+ expect(layout.columns).toHaveLength(3);
32
+ expect(layout.rows).toHaveLength(4);
33
+ expect(layout.theme).toBeDefined();
34
+ expect(layout.chrome).toBeDefined();
35
+ expect(layout.search.enabled).toBe(false);
36
+ expect(layout.stickyFirstColumn).toBe(false);
37
+ expect(layout.compact).toBe(false);
38
+ });
39
+
40
+ it('formats cell values correctly', () => {
41
+ const layout = compileTable(baseSpec, baseOptions);
42
+
43
+ // First row, first cell should be "Alice"
44
+ const firstRow = layout.rows[0];
45
+ expect(firstRow.cells[0].formattedValue).toBe('Alice');
46
+ // Age should be formatted as number
47
+ expect(firstRow.cells[1].formattedValue).toBe('30');
48
+ // Score should be formatted with decimal
49
+ expect(firstRow.cells[2].formattedValue).toBe('88.50');
50
+ });
51
+
52
+ it('applies d3-format strings to numeric columns', () => {
53
+ const spec = {
54
+ ...baseSpec,
55
+ columns: [{ key: 'name' }, { key: 'age', format: '.1f' }, { key: 'score', format: ',.0f' }],
56
+ };
57
+ const layout = compileTable(spec, baseOptions);
58
+
59
+ expect(layout.rows[0].cells[1].formattedValue).toBe('30.0');
60
+ expect(layout.rows[0].cells[2].formattedValue).toBe('89');
61
+ });
62
+
63
+ it('right-aligns number columns by default', () => {
64
+ const layout = compileTable(baseSpec, baseOptions);
65
+
66
+ // name should be left, age and score should be right
67
+ expect(layout.columns[0].align).toBe('left');
68
+ expect(layout.columns[1].align).toBe('right');
69
+ expect(layout.columns[2].align).toBe('right');
70
+ });
71
+
72
+ it('generates stable row IDs from data index', () => {
73
+ const layout = compileTable(baseSpec, baseOptions);
74
+
75
+ expect(layout.rows[0].id).toBe('0');
76
+ expect(layout.rows[1].id).toBe('1');
77
+ expect(layout.rows[2].id).toBe('2');
78
+ expect(layout.rows[3].id).toBe('3');
79
+ });
80
+
81
+ it('uses rowKey for row IDs when specified', () => {
82
+ const spec = { ...baseSpec, rowKey: 'name' };
83
+ const layout = compileTable(spec, baseOptions);
84
+
85
+ expect(layout.rows[0].id).toBe('Alice');
86
+ expect(layout.rows[1].id).toBe('Bob');
87
+ expect(layout.rows[2].id).toBe('Charlie');
88
+ expect(layout.rows[3].id).toBe('Diana');
89
+ });
90
+
91
+ it('applies tabular-nums font variant on number cells', () => {
92
+ const layout = compileTable(baseSpec, baseOptions);
93
+
94
+ // age cell should have tabular-nums
95
+ const ageCell = layout.rows[0].cells[1];
96
+ expect(ageCell.style.fontVariant).toBe('tabular-nums');
97
+
98
+ // name cell should not
99
+ const nameCell = layout.rows[0].cells[0];
100
+ expect(nameCell.style.fontVariant).toBeUndefined();
101
+ });
102
+
103
+ it('computes reasonable column widths (header text not clipped)', () => {
104
+ const layout = compileTable(baseSpec, baseOptions);
105
+
106
+ for (const col of layout.columns) {
107
+ // Each column should be at least 60px
108
+ expect(col.width).toBeGreaterThanOrEqual(60);
109
+ }
110
+ });
111
+
112
+ it('computes chrome from spec', () => {
113
+ const spec = {
114
+ ...baseSpec,
115
+ chrome: { title: 'Test Table', subtitle: 'A test' },
116
+ };
117
+ const layout = compileTable(spec, baseOptions);
118
+
119
+ expect(layout.chrome.title?.text).toBe('Test Table');
120
+ expect(layout.chrome.subtitle?.text).toBe('A test');
121
+ });
122
+
123
+ it('produces valid a11y metadata', () => {
124
+ const spec = {
125
+ ...baseSpec,
126
+ chrome: { title: 'Student Scores' },
127
+ };
128
+ const layout = compileTable(spec, baseOptions);
129
+
130
+ expect(layout.a11y.caption).toBe('Table: Student Scores');
131
+ expect(layout.a11y.summary).toContain('3 columns');
132
+ expect(layout.a11y.summary).toContain('4 rows');
133
+ });
134
+
135
+ it('handles empty data gracefully', () => {
136
+ // Validation catches empty data, so we test with a minimal valid dataset
137
+ const spec = {
138
+ type: 'table' as const,
139
+ data: [{ x: 1 }],
140
+ columns: [{ key: 'x' }],
141
+ };
142
+ const layout = compileTable(spec, baseOptions);
143
+ expect(layout.rows).toHaveLength(1);
144
+ });
145
+
146
+ it('preserves original data in row.data', () => {
147
+ const layout = compileTable(baseSpec, baseOptions);
148
+
149
+ expect(layout.rows[0].data).toBe(sampleData[0]);
150
+ expect(layout.rows[0].data.name).toBe('Alice');
151
+ });
152
+
153
+ it('throws for non-table spec', () => {
154
+ expect(() =>
155
+ compileTable(
156
+ {
157
+ type: 'line',
158
+ data: [
159
+ { date: '2020-01-01', y: 2 },
160
+ { date: '2021-01-01', y: 4 },
161
+ ],
162
+ encoding: {
163
+ x: { field: 'date', type: 'temporal' },
164
+ y: { field: 'y', type: 'quantitative' },
165
+ },
166
+ },
167
+ baseOptions,
168
+ ),
169
+ ).toThrow('Use compileChart instead');
170
+ });
171
+
172
+ it('determines column cellType from visual features', () => {
173
+ const spec = {
174
+ type: 'table' as const,
175
+ data: [
176
+ {
177
+ val: 10,
178
+ cat: 'A',
179
+ trend: [1, 2, 3],
180
+ bar: 50,
181
+ img: 'http://example.com/a.png',
182
+ flag: 'US',
183
+ },
184
+ ],
185
+ columns: [
186
+ { key: 'val', heatmap: { palette: 'blue' } },
187
+ { key: 'cat', categoryColors: { A: '#ff0000' } },
188
+ { key: 'trend', sparkline: { type: 'line' as const } },
189
+ { key: 'bar', bar: {} },
190
+ { key: 'img', image: {} },
191
+ { key: 'flag', flag: true },
192
+ ],
193
+ };
194
+ const layout = compileTable(spec, baseOptions);
195
+
196
+ expect(layout.columns[0].cellType).toBe('heatmap');
197
+ expect(layout.columns[1].cellType).toBe('category');
198
+ expect(layout.columns[2].cellType).toBe('sparkline');
199
+ expect(layout.columns[3].cellType).toBe('bar');
200
+ expect(layout.columns[4].cellType).toBe('image');
201
+ expect(layout.columns[5].cellType).toBe('flag');
202
+ });
203
+
204
+ it('applies dark mode theme', () => {
205
+ const layout = compileTable(baseSpec, { ...baseOptions, darkMode: true });
206
+ expect(layout.theme.isDark).toBe(true);
207
+ });
208
+ });