@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.
- package/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- 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
|
+
});
|