@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,151 @@
1
+ import type { LegendLayout } from '@opendata-ai/openchart-core';
2
+ import { adaptTheme, resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { NormalizedChartSpec } from '../compiler/types';
5
+ import { computeDimensions } from '../layout/dimensions';
6
+
7
+ const baseSpec: NormalizedChartSpec = {
8
+ type: 'line',
9
+ data: [
10
+ { date: '2020-01-01', value: 10 },
11
+ { date: '2021-01-01', value: 20 },
12
+ ],
13
+ encoding: {
14
+ x: { field: 'date', type: 'temporal' },
15
+ y: { field: 'value', type: 'quantitative' },
16
+ },
17
+ chrome: { title: { text: 'Test Chart' } },
18
+ annotations: [],
19
+ responsive: true,
20
+ theme: {},
21
+ darkMode: 'off',
22
+ labels: { density: 'auto', format: '' },
23
+ };
24
+
25
+ const lightTheme = resolveTheme(baseSpec.theme);
26
+ const darkTheme = adaptTheme(lightTheme);
27
+
28
+ const emptyLegend: LegendLayout = {
29
+ position: 'top',
30
+ entries: [],
31
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
32
+ labelStyle: {
33
+ fontFamily: 'Inter',
34
+ fontSize: 11,
35
+ fontWeight: 400,
36
+ fill: '#333',
37
+ lineHeight: 1.3,
38
+ },
39
+ swatchSize: 12,
40
+ swatchGap: 6,
41
+ entryGap: 16,
42
+ };
43
+
44
+ const rightLegend: LegendLayout = {
45
+ ...emptyLegend,
46
+ position: 'right',
47
+ entries: [{ label: 'US', color: '#1b7fa3', shape: 'line' }],
48
+ bounds: { x: 500, y: 0, width: 100, height: 200 },
49
+ };
50
+
51
+ const topLegend: LegendLayout = {
52
+ ...emptyLegend,
53
+ position: 'top',
54
+ entries: [{ label: 'US', color: '#1b7fa3', shape: 'line' }],
55
+ bounds: { x: 0, y: 0, width: 400, height: 28 },
56
+ };
57
+
58
+ describe('computeDimensions', () => {
59
+ it('computes chart area within total dimensions', () => {
60
+ const dims = computeDimensions(baseSpec, { width: 600, height: 400 }, emptyLegend, lightTheme);
61
+
62
+ expect(dims.total).toEqual({ x: 0, y: 0, width: 600, height: 400 });
63
+ expect(dims.chartArea.width).toBeLessThan(600);
64
+ expect(dims.chartArea.height).toBeLessThan(400);
65
+ expect(dims.chartArea.width).toBeGreaterThan(0);
66
+ expect(dims.chartArea.height).toBeGreaterThan(0);
67
+ });
68
+
69
+ it('accounts for chrome height in chart area', () => {
70
+ const noChrome: NormalizedChartSpec = { ...baseSpec, chrome: {} };
71
+ const withChrome = baseSpec;
72
+
73
+ const dimsNoChrome = computeDimensions(
74
+ noChrome,
75
+ { width: 600, height: 400 },
76
+ emptyLegend,
77
+ lightTheme,
78
+ );
79
+ const dimsWithChrome = computeDimensions(
80
+ withChrome,
81
+ { width: 600, height: 400 },
82
+ emptyLegend,
83
+ lightTheme,
84
+ );
85
+
86
+ // With chrome, the chart area should be shorter (less height available)
87
+ expect(dimsWithChrome.chartArea.height).toBeLessThan(dimsNoChrome.chartArea.height);
88
+ // Chrome should have nonzero top height
89
+ expect(dimsWithChrome.chrome.topHeight).toBeGreaterThan(0);
90
+ });
91
+
92
+ it('reserves space for right-positioned legend', () => {
93
+ const withoutLegend = computeDimensions(
94
+ baseSpec,
95
+ { width: 600, height: 400 },
96
+ emptyLegend,
97
+ lightTheme,
98
+ );
99
+ const withLegend = computeDimensions(
100
+ baseSpec,
101
+ { width: 600, height: 400 },
102
+ rightLegend,
103
+ lightTheme,
104
+ );
105
+
106
+ expect(withLegend.chartArea.width).toBeLessThan(withoutLegend.chartArea.width);
107
+ });
108
+
109
+ it('reserves space for top-positioned legend', () => {
110
+ const withoutLegend = computeDimensions(
111
+ baseSpec,
112
+ { width: 600, height: 400 },
113
+ emptyLegend,
114
+ lightTheme,
115
+ );
116
+ const withLegend = computeDimensions(
117
+ baseSpec,
118
+ { width: 600, height: 400 },
119
+ topLegend,
120
+ lightTheme,
121
+ );
122
+
123
+ expect(withLegend.chartArea.height).toBeLessThan(withoutLegend.chartArea.height);
124
+ });
125
+
126
+ it('applies dark mode theme adaptation', () => {
127
+ const lightDims = computeDimensions(
128
+ baseSpec,
129
+ { width: 600, height: 400 },
130
+ emptyLegend,
131
+ lightTheme,
132
+ );
133
+ const darkDims = computeDimensions(
134
+ baseSpec,
135
+ { width: 600, height: 400, darkMode: true },
136
+ emptyLegend,
137
+ darkTheme,
138
+ );
139
+
140
+ expect(lightDims.theme.isDark).toBe(false);
141
+ expect(darkDims.theme.isDark).toBe(true);
142
+ expect(darkDims.theme.colors.background).not.toBe(lightDims.theme.colors.background);
143
+ });
144
+
145
+ it('prevents negative chart area dimensions', () => {
146
+ // Tiny container
147
+ const dims = computeDimensions(baseSpec, { width: 50, height: 30 }, emptyLegend, lightTheme);
148
+ expect(dims.chartArea.width).toBeGreaterThanOrEqual(0);
149
+ expect(dims.chartArea.height).toBeGreaterThanOrEqual(0);
150
+ });
151
+ });
@@ -0,0 +1,113 @@
1
+ import type { LayoutStrategy, Rect, ResolvedTheme } from '@opendata-ai/openchart-core';
2
+ import { resolveTheme } from '@opendata-ai/openchart-core';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { NormalizedChartSpec } from '../compiler/types';
5
+ import { computeLegend } from '../legend/compute';
6
+
7
+ const specWithColor: NormalizedChartSpec = {
8
+ type: 'line',
9
+ data: [
10
+ { date: '2020', value: 10, country: 'US' },
11
+ { date: '2021', value: 20, country: 'UK' },
12
+ { date: '2022', value: 30, country: 'Germany' },
13
+ ],
14
+ encoding: {
15
+ x: { field: 'date', type: 'temporal' },
16
+ y: { field: 'value', type: 'quantitative' },
17
+ color: { field: 'country', type: 'nominal' },
18
+ },
19
+ chrome: {},
20
+ annotations: [],
21
+ responsive: true,
22
+ theme: {},
23
+ darkMode: 'off',
24
+ labels: { density: 'auto', format: '' },
25
+ };
26
+
27
+ const specWithoutColor: NormalizedChartSpec = {
28
+ ...specWithColor,
29
+ encoding: {
30
+ x: { field: 'date', type: 'temporal' },
31
+ y: { field: 'value', type: 'quantitative' },
32
+ },
33
+ };
34
+
35
+ const chartArea: Rect = { x: 50, y: 50, width: 500, height: 300 };
36
+ const theme: ResolvedTheme = resolveTheme();
37
+
38
+ const fullStrategy: LayoutStrategy = {
39
+ labelMode: 'all',
40
+ legendPosition: 'right',
41
+ annotationPosition: 'inline',
42
+ axisLabelDensity: 'full',
43
+ };
44
+
45
+ const compactStrategy: LayoutStrategy = {
46
+ labelMode: 'none',
47
+ legendPosition: 'top',
48
+ annotationPosition: 'tooltip-only',
49
+ axisLabelDensity: 'minimal',
50
+ };
51
+
52
+ describe('computeLegend', () => {
53
+ it('derives entries from color encoding unique values', () => {
54
+ const legend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
55
+ expect(legend.entries).toHaveLength(3);
56
+ expect(legend.entries.map((e) => e.label)).toEqual(['US', 'UK', 'Germany']);
57
+ });
58
+
59
+ it('assigns distinct colors from the theme palette', () => {
60
+ const legend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
61
+ const colors = legend.entries.map((e) => e.color);
62
+ const uniqueColors = new Set(colors);
63
+ expect(uniqueColors.size).toBe(3);
64
+ });
65
+
66
+ it('returns empty entries when no color encoding', () => {
67
+ const legend = computeLegend(specWithoutColor, fullStrategy, theme, chartArea);
68
+ expect(legend.entries).toHaveLength(0);
69
+ expect(legend.bounds.width).toBe(0);
70
+ expect(legend.bounds.height).toBe(0);
71
+ });
72
+
73
+ it('positions legend on right at full width', () => {
74
+ const legend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
75
+ expect(legend.position).toBe('right');
76
+ expect(legend.bounds.width).toBeGreaterThan(0);
77
+ });
78
+
79
+ it('positions legend on top at compact width', () => {
80
+ const legend = computeLegend(specWithColor, compactStrategy, theme, chartArea);
81
+ expect(legend.position).toBe('top');
82
+ expect(legend.bounds.height).toBeGreaterThan(0);
83
+ });
84
+
85
+ it('uses correct swatch shape for chart type', () => {
86
+ const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
87
+ expect(lineLegend.entries[0].shape).toBe('line');
88
+
89
+ const barSpec: NormalizedChartSpec = {
90
+ ...specWithColor,
91
+ type: 'bar',
92
+ encoding: {
93
+ x: { field: 'value', type: 'quantitative' },
94
+ y: { field: 'date', type: 'nominal' },
95
+ color: { field: 'country', type: 'nominal' },
96
+ },
97
+ };
98
+ const barLegend = computeLegend(barSpec, fullStrategy, theme, chartArea);
99
+ expect(barLegend.entries[0].shape).toBe('square');
100
+
101
+ const scatterSpec: NormalizedChartSpec = {
102
+ ...specWithColor,
103
+ type: 'scatter',
104
+ encoding: {
105
+ x: { field: 'value', type: 'quantitative' },
106
+ y: { field: 'value', type: 'quantitative' },
107
+ color: { field: 'country', type: 'nominal' },
108
+ },
109
+ };
110
+ const scatterLegend = computeLegend(scatterSpec, fullStrategy, theme, chartArea);
111
+ expect(scatterLegend.entries[0].shape).toBe('circle');
112
+ });
113
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { NormalizedChartSpec } from '../compiler/types';
3
+ import { computeScales } from '../layout/scales';
4
+
5
+ const lineSpec: NormalizedChartSpec = {
6
+ type: 'line',
7
+ data: [
8
+ { date: '2020-01-01', value: 10, country: 'US' },
9
+ { date: '2021-01-01', value: 50, country: 'US' },
10
+ { date: '2022-01-01', value: 30, country: 'UK' },
11
+ ],
12
+ encoding: {
13
+ x: { field: 'date', type: 'temporal' },
14
+ y: { field: 'value', type: 'quantitative' },
15
+ color: { field: 'country', type: 'nominal' },
16
+ },
17
+ chrome: {},
18
+ annotations: [],
19
+ responsive: true,
20
+ theme: {},
21
+ darkMode: 'off',
22
+ labels: { density: 'auto', format: '' },
23
+ };
24
+
25
+ const barSpec: NormalizedChartSpec = {
26
+ type: 'bar',
27
+ data: [
28
+ { category: 'A', count: 10 },
29
+ { category: 'B', count: 30 },
30
+ { category: 'C', count: 20 },
31
+ ],
32
+ encoding: {
33
+ x: { field: 'count', type: 'quantitative' },
34
+ y: { field: 'category', type: 'nominal' },
35
+ },
36
+ chrome: {},
37
+ annotations: [],
38
+ responsive: true,
39
+ theme: {},
40
+ darkMode: 'off',
41
+ labels: { density: 'auto', format: '' },
42
+ };
43
+
44
+ const chartArea = { x: 50, y: 50, width: 500, height: 300 };
45
+
46
+ describe('computeScales', () => {
47
+ it('creates a time scale for temporal x encoding', () => {
48
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
49
+ expect(scales.x).toBeDefined();
50
+ expect(scales.x!.type).toBe('time');
51
+
52
+ // The scale should map dates within the range
53
+ const pos = scales.x!.scale(new Date('2020-06-01'));
54
+ expect(pos).toBeGreaterThanOrEqual(chartArea.x);
55
+ expect(pos).toBeLessThanOrEqual(chartArea.x + chartArea.width);
56
+ });
57
+
58
+ it('creates a linear scale for quantitative y encoding', () => {
59
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
60
+ expect(scales.y).toBeDefined();
61
+ expect(scales.y!.type).toBe('linear');
62
+
63
+ // Y is inverted (SVG coordinates)
64
+ const domain = scales.y!.scale.domain();
65
+ expect(domain[0]).toBeLessThanOrEqual(0); // Should include 0
66
+ expect(domain[1]).toBeGreaterThanOrEqual(50);
67
+ });
68
+
69
+ it('includes zero in quantitative domain by default', () => {
70
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
71
+ const domain = scales.y!.scale.domain();
72
+ expect(domain[0]).toBe(0);
73
+ });
74
+
75
+ it('creates an ordinal scale for color encoding', () => {
76
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
77
+ expect(scales.color).toBeDefined();
78
+ expect(scales.color!.type).toBe('ordinal');
79
+
80
+ const usColor = scales.color!.scale('US');
81
+ const ukColor = scales.color!.scale('UK');
82
+ expect(typeof usColor).toBe('string');
83
+ expect(usColor).not.toBe(ukColor);
84
+ });
85
+
86
+ it('creates band scales for bar chart categorical axis', () => {
87
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
88
+ expect(scales.y).toBeDefined();
89
+ expect(scales.y!.type).toBe('band');
90
+
91
+ // Band scale should have bandwidth
92
+ expect(scales.y!.scale.bandwidth()).toBeGreaterThan(0);
93
+ });
94
+
95
+ it('derives correct domain from data', () => {
96
+ const scales = computeScales(barSpec, chartArea, barSpec.data);
97
+
98
+ // Y should have all categories
99
+ const yDomain = scales.y!.scale.domain();
100
+ expect(yDomain).toContain('A');
101
+ expect(yDomain).toContain('B');
102
+ expect(yDomain).toContain('C');
103
+
104
+ // X domain should span 0 to >= max value
105
+ const xDomain = scales.x!.scale.domain();
106
+ expect(xDomain[0]).toBe(0);
107
+ expect(xDomain[1]).toBeGreaterThanOrEqual(30);
108
+ });
109
+ });