@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
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@opendata-ai/openchart-engine",
3
+ "version": "1.2.0",
4
+ "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
+ "license": "Apache-2.0",
6
+ "author": "Riley Hilliard",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/tryopendata/openchart.git",
10
+ "directory": "packages/engine"
11
+ },
12
+ "homepage": "https://github.com/tryopendata/openchart#readme",
13
+ "bugs": "https://github.com/tryopendata/openchart/issues",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "type": "module",
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "sideEffects": false,
34
+ "keywords": [
35
+ "chart",
36
+ "visualization",
37
+ "compiler",
38
+ "layout",
39
+ "scales",
40
+ "d3"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit"
46
+ },
47
+ "dependencies": {
48
+ "@opendata-ai/openchart-core": "2.0.0",
49
+ "d3-array": "^3.2.0",
50
+ "d3-format": "^3.1.2",
51
+ "d3-interpolate": "^3.0.0",
52
+ "d3-scale": "^4.0.0",
53
+ "d3-shape": "^3.2.0"
54
+ },
55
+ "devDependencies": {
56
+ "@types/d3-array": "^3.2.1",
57
+ "@types/d3-format": "^3.0.4",
58
+ "@types/d3-interpolate": "^3.0.4",
59
+ "@types/d3-scale": "^4.0.8",
60
+ "@types/d3-shape": "^3.1.6"
61
+ }
62
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared test fixtures for engine tests.
3
+ *
4
+ * Factory functions for NormalizedChartSpec and shared layout objects.
5
+ * Each factory returns a fresh object to prevent cross-test contamination.
6
+ */
7
+
8
+ import type { LayoutStrategy, Rect } from '@opendata-ai/openchart-core';
9
+ import type { NormalizedChartSpec } from '../compiler/types';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Shared layout objects
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Standard chart area used across engine tests. */
16
+ export function makeChartArea(): Rect {
17
+ return { x: 50, y: 20, width: 500, height: 300 };
18
+ }
19
+
20
+ /** Full-width layout strategy (labels visible, legend on right). */
21
+ export function makeFullStrategy(): LayoutStrategy {
22
+ return {
23
+ labelMode: 'all',
24
+ legendPosition: 'right',
25
+ annotationPosition: 'inline',
26
+ axisLabelDensity: 'full',
27
+ };
28
+ }
29
+
30
+ /** Compact layout strategy (no labels, legend on top). */
31
+ export function makeCompactStrategy(): LayoutStrategy {
32
+ return {
33
+ labelMode: 'none',
34
+ legendPosition: 'top',
35
+ annotationPosition: 'tooltip-only',
36
+ axisLabelDensity: 'minimal',
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Line chart spec factories
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Single-series line chart with temporal x-axis. */
45
+ export function makeLineSpec(): NormalizedChartSpec {
46
+ return {
47
+ type: 'line',
48
+ data: [
49
+ { date: '2020-01-01', value: 10, country: 'US' },
50
+ { date: '2021-01-01', value: 40, country: 'US' },
51
+ { date: '2020-01-01', value: 15, country: 'UK' },
52
+ { date: '2021-01-01', value: 35, country: 'UK' },
53
+ ],
54
+ encoding: {
55
+ x: { field: 'date', type: 'temporal' },
56
+ y: { field: 'value', type: 'quantitative' },
57
+ color: { field: 'country', type: 'nominal' },
58
+ },
59
+ chrome: {
60
+ title: { text: 'GDP Growth' },
61
+ subtitle: { text: 'US vs UK over time' },
62
+ source: { text: 'World Bank' },
63
+ },
64
+ annotations: [],
65
+ responsive: true,
66
+ theme: {},
67
+ darkMode: 'off',
68
+ labels: { density: 'auto', format: '' },
69
+ };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Bar chart spec factories
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Basic bar chart (horizontal) with nominal y and quantitative x. */
77
+ export function makeBarSpec(): NormalizedChartSpec {
78
+ return {
79
+ type: 'bar',
80
+ data: [
81
+ { name: 'A', value: 10 },
82
+ { name: 'B', value: 30 },
83
+ { name: 'C', value: 20 },
84
+ ],
85
+ encoding: {
86
+ x: { field: 'value', type: 'quantitative' },
87
+ y: { field: 'name', type: 'nominal' },
88
+ },
89
+ chrome: {},
90
+ annotations: [],
91
+ responsive: true,
92
+ theme: {},
93
+ darkMode: 'off',
94
+ labels: { density: 'auto', format: '' },
95
+ };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Scatter chart spec factories
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /** Basic scatter chart with quantitative x and y. */
103
+ export function makeScatterSpec(): NormalizedChartSpec {
104
+ return {
105
+ type: 'scatter',
106
+ data: [
107
+ { x: 10, y: 20 },
108
+ { x: 30, y: 50 },
109
+ { x: 50, y: 40 },
110
+ { x: 70, y: 80 },
111
+ { x: 90, y: 60 },
112
+ ],
113
+ encoding: {
114
+ x: { field: 'x', type: 'quantitative' },
115
+ y: { field: 'y', type: 'quantitative' },
116
+ },
117
+ chrome: {},
118
+ annotations: [],
119
+ responsive: true,
120
+ theme: {},
121
+ darkMode: 'off',
122
+ labels: { density: 'auto', format: '' },
123
+ };
124
+ }
@@ -0,0 +1,114 @@
1
+ import type { LayoutStrategy } 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 { computeAxes } from '../layout/axes';
6
+ import { computeScales } from '../layout/scales';
7
+
8
+ const lineSpec: NormalizedChartSpec = {
9
+ type: 'line',
10
+ data: [
11
+ { date: '2020-01-01', value: 100 },
12
+ { date: '2021-01-01', value: 500 },
13
+ { date: '2022-01-01', value: 300 },
14
+ ],
15
+ encoding: {
16
+ x: { field: 'date', type: 'temporal' },
17
+ y: { field: 'value', type: 'quantitative' },
18
+ },
19
+ chrome: {},
20
+ annotations: [],
21
+ responsive: true,
22
+ theme: {},
23
+ darkMode: 'off',
24
+ labels: { density: 'auto', format: '' },
25
+ };
26
+
27
+ const chartArea = { x: 50, y: 50, width: 500, height: 300 };
28
+ const theme = resolveTheme();
29
+
30
+ const fullStrategy: LayoutStrategy = {
31
+ labelMode: 'all',
32
+ legendPosition: 'right',
33
+ annotationPosition: 'inline',
34
+ axisLabelDensity: 'full',
35
+ };
36
+
37
+ const minimalStrategy: LayoutStrategy = {
38
+ labelMode: 'none',
39
+ legendPosition: 'top',
40
+ annotationPosition: 'tooltip-only',
41
+ axisLabelDensity: 'minimal',
42
+ };
43
+
44
+ describe('computeAxes', () => {
45
+ it('produces x and y axes for a line chart', () => {
46
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
47
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
48
+
49
+ expect(axes.x).toBeDefined();
50
+ expect(axes.y).toBeDefined();
51
+ });
52
+
53
+ it('generates ticks for both axes', () => {
54
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
55
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
56
+
57
+ expect(axes.x!.ticks.length).toBeGreaterThan(0);
58
+ expect(axes.y!.ticks.length).toBeGreaterThan(0);
59
+ });
60
+
61
+ it('tick positions are within chart area', () => {
62
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
63
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
64
+
65
+ for (const tick of axes.x!.ticks) {
66
+ expect(tick.position).toBeGreaterThanOrEqual(chartArea.x - 1);
67
+ expect(tick.position).toBeLessThanOrEqual(chartArea.x + chartArea.width + 1);
68
+ }
69
+
70
+ for (const tick of axes.y!.ticks) {
71
+ expect(tick.position).toBeGreaterThanOrEqual(chartArea.y - 1);
72
+ expect(tick.position).toBeLessThanOrEqual(chartArea.y + chartArea.height + 1);
73
+ }
74
+ });
75
+
76
+ it('tick labels are formatted strings', () => {
77
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
78
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
79
+
80
+ for (const tick of axes.y!.ticks) {
81
+ expect(typeof tick.label).toBe('string');
82
+ expect(tick.label.length).toBeGreaterThan(0);
83
+ }
84
+ });
85
+
86
+ it('produces fewer ticks with minimal density', () => {
87
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
88
+ const axesFull = computeAxes(scales, chartArea, fullStrategy, theme);
89
+ const axesMinimal = computeAxes(scales, chartArea, minimalStrategy, theme);
90
+
91
+ // Minimal should have fewer or equal ticks
92
+ expect(axesMinimal.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
93
+ });
94
+
95
+ it('y-axis has gridlines by default', () => {
96
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
97
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
98
+
99
+ expect(axes.y!.gridlines.length).toBeGreaterThan(0);
100
+ });
101
+
102
+ it('axes have correct start/end positions', () => {
103
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
104
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
105
+
106
+ // X axis sits at the bottom of the chart area
107
+ expect(axes.x!.start.y).toBe(chartArea.y + chartArea.height);
108
+ expect(axes.x!.end.y).toBe(chartArea.y + chartArea.height);
109
+
110
+ // Y axis sits at the left of the chart area
111
+ expect(axes.y!.start.x).toBe(chartArea.x);
112
+ expect(axes.y!.end.x).toBe(chartArea.x);
113
+ });
114
+ });
@@ -0,0 +1,337 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compileChart, compileGraph, compileTable } from '../compile';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Test data
6
+ // ---------------------------------------------------------------------------
7
+
8
+ const lineSpec = {
9
+ type: 'line' as const,
10
+ data: [
11
+ { date: '2020-01-01', value: 10, country: 'US' },
12
+ { date: '2021-01-01', value: 40, country: 'US' },
13
+ { date: '2020-01-01', value: 15, country: 'UK' },
14
+ { date: '2021-01-01', value: 35, country: 'UK' },
15
+ ],
16
+ encoding: {
17
+ x: { field: 'date', type: 'temporal' as const },
18
+ y: { field: 'value', type: 'quantitative' as const },
19
+ color: { field: 'country', type: 'nominal' as const },
20
+ },
21
+ chrome: {
22
+ title: 'GDP Growth',
23
+ subtitle: 'US vs UK over time',
24
+ source: 'World Bank',
25
+ },
26
+ };
27
+
28
+ const barSpec = {
29
+ type: 'bar' as const,
30
+ data: [
31
+ { name: 'A', value: 10 },
32
+ { name: 'B', value: 30 },
33
+ { name: 'C', value: 20 },
34
+ ],
35
+ encoding: {
36
+ x: { field: 'value', type: 'quantitative' as const },
37
+ y: { field: 'name', type: 'nominal' as const },
38
+ },
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Tests
43
+ // ---------------------------------------------------------------------------
44
+
45
+ describe('compileChart', () => {
46
+ it('returns a ChartLayout with all required fields populated', () => {
47
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
48
+
49
+ // Chart area has real dimensions within the total viewport
50
+ expect(layout.area.width).toBeGreaterThan(0);
51
+ expect(layout.area.height).toBeGreaterThan(0);
52
+ expect(layout.area.x).toBeGreaterThanOrEqual(0);
53
+ expect(layout.area.y).toBeGreaterThanOrEqual(0);
54
+
55
+ // Chrome is resolved with positions
56
+ expect(layout.chrome.topHeight).toBeGreaterThan(0);
57
+ expect(layout.chrome.title?.text).toBe('GDP Growth');
58
+
59
+ // Axes have ticks
60
+ expect(layout.axes.x?.ticks.length).toBeGreaterThan(0);
61
+ expect(layout.axes.y?.ticks.length).toBeGreaterThan(0);
62
+
63
+ // Marks were produced by the registered renderer
64
+ expect(layout.marks.length).toBeGreaterThan(0);
65
+
66
+ // Annotations array exists (empty for this spec since none were specified)
67
+ expect(layout.annotations).toEqual([]);
68
+
69
+ // Legend has entries for the two series
70
+ expect(layout.legend.entries.length).toBe(2);
71
+
72
+ // Tooltip descriptors is a Map (may or may not have entries depending on marks)
73
+ expect(layout.tooltipDescriptors).toBeInstanceOf(Map);
74
+
75
+ // Accessibility metadata is populated
76
+ expect(layout.a11y.altText.length).toBeGreaterThan(0);
77
+ expect(layout.a11y.role).toBe('img');
78
+
79
+ // Theme is resolved with actual color values
80
+ expect(layout.theme.colors.background).toBeTruthy();
81
+ expect(layout.theme.colors.categorical.length).toBeGreaterThan(0);
82
+
83
+ // Dimensions match the input
84
+ expect(layout.dimensions).toEqual({ width: 600, height: 400 });
85
+ });
86
+
87
+ it('has correct total dimensions', () => {
88
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
89
+ expect(layout.dimensions.width).toBe(600);
90
+ expect(layout.dimensions.height).toBe(400);
91
+ });
92
+
93
+ it('chart area fits within total dimensions', () => {
94
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
95
+ expect(layout.area.x).toBeGreaterThan(0);
96
+ expect(layout.area.y).toBeGreaterThan(0);
97
+ expect(layout.area.x + layout.area.width).toBeLessThan(600);
98
+ expect(layout.area.y + layout.area.height).toBeLessThan(400);
99
+ });
100
+
101
+ it('chrome title and subtitle are resolved with correct text and positions', () => {
102
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
103
+ expect(layout.chrome.topHeight).toBeGreaterThan(0);
104
+
105
+ // Title
106
+ expect(layout.chrome.title!.text).toBe('GDP Growth');
107
+ expect(layout.chrome.title!.x).toBeGreaterThanOrEqual(0);
108
+ expect(layout.chrome.title!.y).toBeGreaterThan(0);
109
+ expect(layout.chrome.title!.maxWidth).toBeGreaterThan(0);
110
+ expect(layout.chrome.title!.style.fontSize).toBeGreaterThan(0);
111
+
112
+ // Subtitle
113
+ expect(layout.chrome.subtitle!.text).toBe('US vs UK over time');
114
+ expect(layout.chrome.subtitle!.y).toBeGreaterThan(layout.chrome.title!.y);
115
+ });
116
+
117
+ it('bottom chrome source is resolved with text and position', () => {
118
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
119
+ expect(layout.chrome.bottomHeight).toBeGreaterThan(0);
120
+ expect(layout.chrome.source!.text).toBe('World Bank');
121
+ expect(layout.chrome.source!.x).toBeGreaterThanOrEqual(0);
122
+ expect(layout.chrome.source!.y).toBeGreaterThan(0);
123
+ expect(layout.chrome.source!.style.fontSize).toBeGreaterThan(0);
124
+ });
125
+
126
+ it('has axes with ticks and valid positions for a line chart', () => {
127
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
128
+
129
+ // X axis
130
+ const xTicks = layout.axes.x!.ticks;
131
+ expect(xTicks.length).toBeGreaterThan(0);
132
+ for (const tick of xTicks) {
133
+ expect(tick.position).toBeGreaterThanOrEqual(0);
134
+ expect(tick.label).toBeTruthy();
135
+ }
136
+
137
+ // Y axis
138
+ const yTicks = layout.axes.y!.ticks;
139
+ expect(yTicks.length).toBeGreaterThan(0);
140
+ for (const tick of yTicks) {
141
+ expect(tick.position).toBeGreaterThanOrEqual(0);
142
+ expect(tick.label).toBeTruthy();
143
+ }
144
+
145
+ // Axes have start and end points
146
+ expect(layout.axes.x!.start.x).toBeLessThan(layout.axes.x!.end.x);
147
+ expect(layout.axes.y!.start.y).not.toBe(layout.axes.y!.end.y);
148
+ });
149
+
150
+ it('has a legend when color encoding is present', () => {
151
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
152
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
153
+ expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
154
+ expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
155
+ });
156
+
157
+ it('legend entries have colors and shapes', () => {
158
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
159
+ for (const entry of layout.legend.entries) {
160
+ expect(entry.color).toBeTruthy();
161
+ expect(['circle', 'square', 'line']).toContain(entry.shape);
162
+ }
163
+ });
164
+
165
+ it('legend position is "right" at full width', () => {
166
+ const layout = compileChart(lineSpec, { width: 800, height: 400 });
167
+ expect(layout.legend.position).toBe('right');
168
+ });
169
+
170
+ it('legend position is "top" at compact width', () => {
171
+ const layout = compileChart(lineSpec, { width: 320, height: 400 });
172
+ expect(layout.legend.position).toBe('top');
173
+ });
174
+
175
+ it('produces line and point marks with the registered renderer', () => {
176
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
177
+ expect(layout.marks.length).toBeGreaterThan(0);
178
+
179
+ const lineMarks = layout.marks.filter((m) => m.type === 'line');
180
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
181
+ expect(lineMarks.length).toBeGreaterThan(0);
182
+ expect(pointMarks.length).toBeGreaterThan(0);
183
+
184
+ // Line marks should have points with valid coordinates
185
+ for (const mark of lineMarks) {
186
+ if (mark.type === 'line') {
187
+ expect(mark.points.length).toBeGreaterThan(0);
188
+ expect(mark.stroke).toBeTruthy();
189
+ expect(mark.strokeWidth).toBeGreaterThan(0);
190
+ }
191
+ }
192
+ });
193
+
194
+ it('includes accessibility metadata with meaningful content', () => {
195
+ const layout = compileChart(lineSpec, { width: 600, height: 400 });
196
+ expect(layout.a11y.altText).toContain('Line chart');
197
+ expect(layout.a11y.altText).toContain('GDP Growth');
198
+ expect(layout.a11y.role).toBe('img');
199
+ expect(layout.a11y.dataTableFallback.length).toBeGreaterThan(0);
200
+ // With marks present, keyboard navigation should be enabled
201
+ expect(layout.a11y.keyboardNavigable).toBe(true);
202
+ });
203
+
204
+ it('produces different strategies at different widths', () => {
205
+ const narrow = compileChart(lineSpec, { width: 320, height: 400 });
206
+ const wide = compileChart(lineSpec, { width: 800, height: 400 });
207
+
208
+ // At 320px the legend should be on top, at 800px on the right
209
+ expect(narrow.legend.position).toBe('top');
210
+ expect(wide.legend.position).toBe('right');
211
+ });
212
+
213
+ it('dark mode adapts the theme colors', () => {
214
+ const light = compileChart(lineSpec, { width: 600, height: 400 });
215
+ const dark = compileChart(lineSpec, { width: 600, height: 400, darkMode: true });
216
+
217
+ expect(light.theme.isDark).toBe(false);
218
+ expect(dark.theme.isDark).toBe(true);
219
+ expect(dark.theme.colors.background).not.toBe(light.theme.colors.background);
220
+ // Dark mode text should be light, light mode text should be dark
221
+ expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
222
+ });
223
+
224
+ it('compiles a bar chart with rect marks and both axes', () => {
225
+ const layout = compileChart(barSpec, { width: 600, height: 400 });
226
+
227
+ // Bar charts produce rect marks
228
+ expect(layout.marks.length).toBe(3);
229
+ for (const mark of layout.marks) {
230
+ expect(mark.type).toBe('rect');
231
+ if (mark.type === 'rect') {
232
+ expect(mark.width).toBeGreaterThan(0);
233
+ expect(mark.height).toBeGreaterThan(0);
234
+ expect(mark.fill).toBeTruthy();
235
+ }
236
+ }
237
+
238
+ // Both axes should be present with ticks
239
+ expect(layout.axes.x!.ticks.length).toBeGreaterThan(0);
240
+ expect(layout.axes.y!.ticks.length).toBe(3); // A, B, C
241
+ });
242
+
243
+ it('bar chart y-axis labels match the nominal field values', () => {
244
+ const layout = compileChart(barSpec, { width: 600, height: 400 });
245
+ const yLabels = layout.axes.y!.ticks.map((t) => t.label);
246
+ expect(yLabels).toContain('A');
247
+ expect(yLabels).toContain('B');
248
+ expect(yLabels).toContain('C');
249
+ });
250
+
251
+ it('throws on invalid spec', () => {
252
+ expect(() => compileChart({ type: 'bogus' }, { width: 600, height: 400 })).toThrow();
253
+ });
254
+
255
+ it('throws if a table spec is passed', () => {
256
+ expect(() =>
257
+ compileChart(
258
+ { type: 'table', data: [{ a: 1 }], columns: [{ key: 'a' }] },
259
+ { width: 600, height: 400 },
260
+ ),
261
+ ).toThrow('compileTable');
262
+ });
263
+ });
264
+
265
+ describe('compileTable', () => {
266
+ it('returns a valid TableLayout with resolved columns and rows', () => {
267
+ const layout = compileTable(
268
+ {
269
+ type: 'table',
270
+ data: [{ name: 'Alice', age: 30 }],
271
+ columns: [{ key: 'name' }, { key: 'age' }],
272
+ chrome: { title: 'People' },
273
+ },
274
+ { width: 600, height: 400 },
275
+ );
276
+
277
+ // Chrome title is resolved
278
+ expect(layout.chrome.title!.text).toBe('People');
279
+ expect(layout.chrome.topHeight).toBeGreaterThan(0);
280
+
281
+ // Columns are resolved with labels and widths
282
+ expect(layout.columns).toHaveLength(2);
283
+ expect(layout.columns[0].key).toBe('name');
284
+ expect(layout.columns[0].label).toBeTruthy();
285
+ expect(layout.columns[0].width).toBeGreaterThan(0);
286
+ expect(layout.columns[1].key).toBe('age');
287
+
288
+ // Rows contain formatted cell values
289
+ expect(layout.rows).toHaveLength(1);
290
+ expect(layout.rows[0].cells).toHaveLength(2);
291
+ expect(layout.rows[0].cells[0].formattedValue).toBe('Alice');
292
+ expect(layout.rows[0].cells[1].formattedValue).toBe('30');
293
+
294
+ // Accessibility caption includes the title
295
+ expect(layout.a11y.caption).toContain('People');
296
+
297
+ // Theme is resolved
298
+ expect(layout.theme.isDark).toBe(false);
299
+ expect(layout.theme.colors.background).toBeTruthy();
300
+ });
301
+
302
+ it('throws if a chart spec is passed', () => {
303
+ expect(() => compileTable(lineSpec, { width: 600, height: 400 })).toThrow('compileChart');
304
+ });
305
+ });
306
+
307
+ describe('compileGraph', () => {
308
+ it('returns a valid GraphCompilation for a graph spec', () => {
309
+ const result = compileGraph(
310
+ {
311
+ type: 'graph',
312
+ nodes: [{ id: 'a' }],
313
+ edges: [],
314
+ },
315
+ { width: 600, height: 400 },
316
+ );
317
+ expect(result.nodes).toHaveLength(1);
318
+ expect(result.edges).toHaveLength(0);
319
+ expect(result.dimensions).toEqual({ width: 600, height: 400 });
320
+ });
321
+
322
+ it('throws for non-graph specs', () => {
323
+ expect(() =>
324
+ compileGraph(
325
+ {
326
+ type: 'scatter',
327
+ data: [{ x: 1, y: 2 }],
328
+ encoding: {
329
+ x: { field: 'x', type: 'quantitative' },
330
+ y: { field: 'y', type: 'quantitative' },
331
+ },
332
+ },
333
+ { width: 600, height: 400 },
334
+ ),
335
+ ).toThrow('compileGraph received a scatter spec');
336
+ });
337
+ });