@opendata-ai/openchart-core 2.0.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/README.md +130 -0
- package/dist/index.d.ts +2030 -0
- package/dist/index.js +1176 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +757 -0
- package/package.json +61 -0
- package/src/accessibility/__tests__/alt-text.test.ts +110 -0
- package/src/accessibility/__tests__/aria.test.ts +125 -0
- package/src/accessibility/alt-text.ts +120 -0
- package/src/accessibility/aria.ts +73 -0
- package/src/accessibility/index.ts +6 -0
- package/src/colors/__tests__/colorblind.test.ts +63 -0
- package/src/colors/__tests__/contrast.test.ts +71 -0
- package/src/colors/__tests__/palettes.test.ts +54 -0
- package/src/colors/colorblind.ts +122 -0
- package/src/colors/contrast.ts +94 -0
- package/src/colors/index.ts +27 -0
- package/src/colors/palettes.ts +118 -0
- package/src/helpers/__tests__/spec-builders.test.ts +336 -0
- package/src/helpers/spec-builders.ts +410 -0
- package/src/index.ts +129 -0
- package/src/labels/__tests__/collision.test.ts +197 -0
- package/src/labels/collision.ts +154 -0
- package/src/labels/index.ts +6 -0
- package/src/layout/__tests__/chrome.test.ts +114 -0
- package/src/layout/__tests__/text-measure.test.ts +49 -0
- package/src/layout/chrome.ts +223 -0
- package/src/layout/index.ts +6 -0
- package/src/layout/text-measure.ts +54 -0
- package/src/locale/__tests__/format.test.ts +90 -0
- package/src/locale/format.ts +132 -0
- package/src/locale/index.ts +6 -0
- package/src/responsive/__tests__/breakpoints.test.ts +58 -0
- package/src/responsive/breakpoints.ts +92 -0
- package/src/responsive/index.ts +18 -0
- package/src/styles/viz.css +757 -0
- package/src/theme/__tests__/dark-mode.test.ts +68 -0
- package/src/theme/__tests__/defaults.test.ts +47 -0
- package/src/theme/__tests__/resolve.test.ts +61 -0
- package/src/theme/dark-mode.ts +123 -0
- package/src/theme/defaults.ts +85 -0
- package/src/theme/index.ts +7 -0
- package/src/theme/resolve.ts +190 -0
- package/src/types/__tests__/spec.test.ts +387 -0
- package/src/types/encoding.ts +144 -0
- package/src/types/events.ts +96 -0
- package/src/types/index.ts +141 -0
- package/src/types/layout.ts +794 -0
- package/src/types/spec.ts +563 -0
- package/src/types/table.ts +105 -0
- package/src/types/theme.ts +159 -0
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opendata-ai/openchart-core",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Types, theme, colors, accessibility, and utilities for openchart",
|
|
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/core"
|
|
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
|
+
"./styles.css": "./dist/styles.css"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": [
|
|
35
|
+
"./dist/styles.css"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"chart",
|
|
39
|
+
"visualization",
|
|
40
|
+
"data-table",
|
|
41
|
+
"svg",
|
|
42
|
+
"typescript",
|
|
43
|
+
"d3",
|
|
44
|
+
"declarative"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsup",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"d3-color": "^3.1.0",
|
|
53
|
+
"d3-format": "^3.1.0",
|
|
54
|
+
"d3-time-format": "^4.1.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/d3-color": "^3.1.3",
|
|
58
|
+
"@types/d3-format": "^3.0.4",
|
|
59
|
+
"@types/d3-time-format": "^4.0.3"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ChartSpec } from '../../types/spec';
|
|
3
|
+
import { generateAltText, generateDataTable } from '../alt-text';
|
|
4
|
+
|
|
5
|
+
const lineSpec: ChartSpec = {
|
|
6
|
+
type: 'line',
|
|
7
|
+
data: [
|
|
8
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
9
|
+
{ date: '2021-01-01', value: 20, country: 'US' },
|
|
10
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
11
|
+
{ date: '2021-01-01', value: 25, country: 'UK' },
|
|
12
|
+
],
|
|
13
|
+
encoding: {
|
|
14
|
+
x: { field: 'date', type: 'temporal' },
|
|
15
|
+
y: { field: 'value', type: 'quantitative' },
|
|
16
|
+
color: { field: 'country', type: 'nominal' },
|
|
17
|
+
},
|
|
18
|
+
chrome: { title: 'GDP Growth Rate' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const barSpec: ChartSpec = {
|
|
22
|
+
type: 'bar',
|
|
23
|
+
data: [
|
|
24
|
+
{ category: 'A', value: 10 },
|
|
25
|
+
{ category: 'B', value: 20 },
|
|
26
|
+
{ category: 'C', value: 30 },
|
|
27
|
+
],
|
|
28
|
+
encoding: {
|
|
29
|
+
x: { field: 'category', type: 'nominal' },
|
|
30
|
+
y: { field: 'value', type: 'quantitative' },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('generateAltText', () => {
|
|
35
|
+
it('includes chart type', () => {
|
|
36
|
+
const alt = generateAltText(lineSpec, lineSpec.data);
|
|
37
|
+
expect(alt).toContain('Line chart');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('includes title when present', () => {
|
|
41
|
+
const alt = generateAltText(lineSpec, lineSpec.data);
|
|
42
|
+
expect(alt).toContain('GDP Growth Rate');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('includes temporal range', () => {
|
|
46
|
+
const alt = generateAltText(lineSpec, lineSpec.data);
|
|
47
|
+
expect(alt).toContain('from 2020 to 2021');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('includes series count and names', () => {
|
|
51
|
+
const alt = generateAltText(lineSpec, lineSpec.data);
|
|
52
|
+
expect(alt).toContain('2 series');
|
|
53
|
+
expect(alt).toContain('US');
|
|
54
|
+
expect(alt).toContain('UK');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('includes data point count', () => {
|
|
58
|
+
const alt = generateAltText(lineSpec, lineSpec.data);
|
|
59
|
+
expect(alt).toContain('4 data points');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles categorical x-axis', () => {
|
|
63
|
+
const alt = generateAltText(barSpec, barSpec.data);
|
|
64
|
+
expect(alt).toContain('Bar chart');
|
|
65
|
+
expect(alt).toContain('3 categories');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles missing chrome gracefully', () => {
|
|
69
|
+
const alt = generateAltText(barSpec, barSpec.data);
|
|
70
|
+
expect(alt).toBeTruthy();
|
|
71
|
+
expect(alt).not.toContain('showing undefined');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('generateDataTable', () => {
|
|
76
|
+
it('includes headers as first row', () => {
|
|
77
|
+
const table = generateDataTable(lineSpec, lineSpec.data);
|
|
78
|
+
expect(table[0]).toEqual(['date', 'value', 'country']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('includes data rows', () => {
|
|
82
|
+
const table = generateDataTable(lineSpec, lineSpec.data);
|
|
83
|
+
expect(table).toHaveLength(5); // 1 header + 4 data rows
|
|
84
|
+
expect(table[1]).toEqual(['2020-01-01', 10, 'US']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('only includes encoded fields', () => {
|
|
88
|
+
const spec: ChartSpec = {
|
|
89
|
+
type: 'bar',
|
|
90
|
+
data: [{ category: 'A', value: 10, extra: 'ignored' }],
|
|
91
|
+
encoding: {
|
|
92
|
+
x: { field: 'category', type: 'nominal' },
|
|
93
|
+
y: { field: 'value', type: 'quantitative' },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
const table = generateDataTable(spec, spec.data);
|
|
97
|
+
expect(table[0]).toEqual(['category', 'value']);
|
|
98
|
+
expect(table[1]).toEqual(['A', 10]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns empty for spec with no encoding fields', () => {
|
|
102
|
+
const spec: ChartSpec = {
|
|
103
|
+
type: 'pie',
|
|
104
|
+
data: [{ value: 10 }],
|
|
105
|
+
encoding: {},
|
|
106
|
+
};
|
|
107
|
+
const table = generateDataTable(spec, spec.data);
|
|
108
|
+
expect(table).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Mark, MarkAria } from '../../types/layout';
|
|
3
|
+
import { generateAriaLabels } from '../aria';
|
|
4
|
+
|
|
5
|
+
const defaultAria: MarkAria = { label: 'test' };
|
|
6
|
+
|
|
7
|
+
describe('generateAriaLabels', () => {
|
|
8
|
+
it('generates labels for line marks', () => {
|
|
9
|
+
const marks: Mark[] = [
|
|
10
|
+
{
|
|
11
|
+
type: 'line',
|
|
12
|
+
points: [
|
|
13
|
+
{ x: 0, y: 0 },
|
|
14
|
+
{ x: 10, y: 10 },
|
|
15
|
+
],
|
|
16
|
+
stroke: '#333',
|
|
17
|
+
strokeWidth: 2,
|
|
18
|
+
seriesKey: 'US',
|
|
19
|
+
data: [],
|
|
20
|
+
aria: defaultAria,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const labels = generateAriaLabels(marks);
|
|
25
|
+
expect(labels.get('mark-0')).toContain('Line series: US');
|
|
26
|
+
expect(labels.get('mark-0')).toContain('2 points');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('generates labels for rect marks', () => {
|
|
30
|
+
const marks: Mark[] = [
|
|
31
|
+
{
|
|
32
|
+
type: 'rect',
|
|
33
|
+
x: 0,
|
|
34
|
+
y: 0,
|
|
35
|
+
width: 50,
|
|
36
|
+
height: 100,
|
|
37
|
+
fill: '#1b7fa3',
|
|
38
|
+
data: { category: 'Tech', value: 42 },
|
|
39
|
+
aria: defaultAria,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const labels = generateAriaLabels(marks);
|
|
44
|
+
expect(labels.get('mark-0')).toContain('Data point');
|
|
45
|
+
expect(labels.get('mark-0')).toContain('category: Tech');
|
|
46
|
+
expect(labels.get('mark-0')).toContain('value: 42');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('generates labels for arc marks', () => {
|
|
50
|
+
const marks: Mark[] = [
|
|
51
|
+
{
|
|
52
|
+
type: 'arc',
|
|
53
|
+
path: 'M...',
|
|
54
|
+
centroid: { x: 50, y: 50 },
|
|
55
|
+
innerRadius: 0,
|
|
56
|
+
outerRadius: 100,
|
|
57
|
+
startAngle: 0,
|
|
58
|
+
endAngle: Math.PI,
|
|
59
|
+
fill: '#e15759',
|
|
60
|
+
stroke: '#fff',
|
|
61
|
+
strokeWidth: 1,
|
|
62
|
+
data: { sector: 'Healthcare', percent: 25 },
|
|
63
|
+
aria: defaultAria,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const labels = generateAriaLabels(marks);
|
|
68
|
+
expect(labels.get('mark-0')).toContain('Slice');
|
|
69
|
+
expect(labels.get('mark-0')).toContain('Healthcare');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('generates labels for point marks', () => {
|
|
73
|
+
const marks: Mark[] = [
|
|
74
|
+
{
|
|
75
|
+
type: 'point',
|
|
76
|
+
cx: 100,
|
|
77
|
+
cy: 200,
|
|
78
|
+
r: 5,
|
|
79
|
+
fill: '#333',
|
|
80
|
+
stroke: '#333',
|
|
81
|
+
strokeWidth: 1,
|
|
82
|
+
data: { x: 10, y: 20 },
|
|
83
|
+
aria: defaultAria,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const labels = generateAriaLabels(marks);
|
|
88
|
+
expect(labels.get('mark-0')).toContain('Data point');
|
|
89
|
+
expect(labels.get('mark-0')).toContain('x: 10');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('handles empty marks array', () => {
|
|
93
|
+
const labels = generateAriaLabels([]);
|
|
94
|
+
expect(labels.size).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('handles multiple marks with sequential keys', () => {
|
|
98
|
+
const marks: Mark[] = [
|
|
99
|
+
{
|
|
100
|
+
type: 'rect',
|
|
101
|
+
x: 0,
|
|
102
|
+
y: 0,
|
|
103
|
+
width: 10,
|
|
104
|
+
height: 10,
|
|
105
|
+
fill: '#333',
|
|
106
|
+
data: { a: 1 },
|
|
107
|
+
aria: defaultAria,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'rect',
|
|
111
|
+
x: 20,
|
|
112
|
+
y: 0,
|
|
113
|
+
width: 10,
|
|
114
|
+
height: 10,
|
|
115
|
+
fill: '#666',
|
|
116
|
+
data: { a: 2 },
|
|
117
|
+
aria: defaultAria,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const labels = generateAriaLabels(marks);
|
|
122
|
+
expect(labels.has('mark-0')).toBe(true);
|
|
123
|
+
expect(labels.has('mark-1')).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic alt text generation for chart accessibility.
|
|
3
|
+
*
|
|
4
|
+
* Produces human-readable descriptions of chart content for screen readers
|
|
5
|
+
* and a tabular fallback for data access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChartSpec, DataRow } from '../types/spec';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Alt text generation
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** Friendly display names for chart types. */
|
|
15
|
+
const CHART_TYPE_NAMES: Record<string, string> = {
|
|
16
|
+
line: 'Line chart',
|
|
17
|
+
area: 'Area chart',
|
|
18
|
+
bar: 'Bar chart',
|
|
19
|
+
column: 'Column chart',
|
|
20
|
+
pie: 'Pie chart',
|
|
21
|
+
donut: 'Donut chart',
|
|
22
|
+
dot: 'Dot plot',
|
|
23
|
+
scatter: 'Scatter plot',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate alt text describing a chart's content.
|
|
28
|
+
*
|
|
29
|
+
* Produces a description like:
|
|
30
|
+
* "Line chart showing GDP Growth Rate from 2020 to 2024 with 2 series (US, UK)"
|
|
31
|
+
*
|
|
32
|
+
* @param spec - The chart spec.
|
|
33
|
+
* @param data - The data array.
|
|
34
|
+
*/
|
|
35
|
+
export function generateAltText(spec: ChartSpec, data: DataRow[]): string {
|
|
36
|
+
const chartName = CHART_TYPE_NAMES[spec.type] ?? `${spec.type} chart`;
|
|
37
|
+
const parts: string[] = [chartName];
|
|
38
|
+
|
|
39
|
+
// Add title context if present
|
|
40
|
+
const title = spec.chrome?.title;
|
|
41
|
+
if (title) {
|
|
42
|
+
const titleText = typeof title === 'string' ? title : title.text;
|
|
43
|
+
parts.push(`showing ${titleText}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Describe the data range
|
|
47
|
+
if (spec.encoding.x && data.length > 0) {
|
|
48
|
+
const field = spec.encoding.x.field;
|
|
49
|
+
const values = data.map((d) => d[field]).filter((v) => v != null);
|
|
50
|
+
|
|
51
|
+
if (values.length > 0) {
|
|
52
|
+
if (spec.encoding.x.type === 'temporal') {
|
|
53
|
+
const dates = values.map((v) => (v instanceof Date ? v : new Date(String(v))));
|
|
54
|
+
const validDates = dates.filter((d) => !Number.isNaN(d.getTime()));
|
|
55
|
+
if (validDates.length >= 2) {
|
|
56
|
+
validDates.sort((a, b) => a.getTime() - b.getTime());
|
|
57
|
+
const first = validDates[0].getUTCFullYear();
|
|
58
|
+
const last = validDates[validDates.length - 1].getUTCFullYear();
|
|
59
|
+
if (first !== last) {
|
|
60
|
+
parts.push(`from ${first} to ${last}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else if (spec.encoding.x.type === 'nominal' || spec.encoding.x.type === 'ordinal') {
|
|
64
|
+
const uniqueValues = [...new Set(values.map(String))];
|
|
65
|
+
parts.push(`across ${uniqueValues.length} categories`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Describe series if color encoding is present
|
|
71
|
+
if (spec.encoding.color && data.length > 0) {
|
|
72
|
+
const colorField = spec.encoding.color.field;
|
|
73
|
+
const uniqueSeries = [...new Set(data.map((d) => String(d[colorField])).filter(Boolean))];
|
|
74
|
+
if (uniqueSeries.length > 0) {
|
|
75
|
+
parts.push(`with ${uniqueSeries.length} series (${uniqueSeries.join(', ')})`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Add data point count
|
|
80
|
+
parts.push(`(${data.length} data points)`);
|
|
81
|
+
|
|
82
|
+
return parts.join(' ');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Data table fallback
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Generate a tabular data fallback for screen readers.
|
|
91
|
+
*
|
|
92
|
+
* Returns a 2D array where the first row is headers and subsequent
|
|
93
|
+
* rows are data values. Only includes fields referenced in the encoding.
|
|
94
|
+
*
|
|
95
|
+
* @param spec - The chart spec.
|
|
96
|
+
* @param data - The data array.
|
|
97
|
+
*/
|
|
98
|
+
export function generateDataTable(spec: ChartSpec, data: DataRow[]): unknown[][] {
|
|
99
|
+
// Collect fields from encoding
|
|
100
|
+
const fields: string[] = [];
|
|
101
|
+
const encoding = spec.encoding;
|
|
102
|
+
|
|
103
|
+
if (encoding.x) fields.push(encoding.x.field);
|
|
104
|
+
if (encoding.y) fields.push(encoding.y.field);
|
|
105
|
+
if (encoding.color) fields.push(encoding.color.field);
|
|
106
|
+
if (encoding.size) fields.push(encoding.size.field);
|
|
107
|
+
|
|
108
|
+
// Deduplicate
|
|
109
|
+
const uniqueFields = [...new Set(fields)];
|
|
110
|
+
|
|
111
|
+
if (uniqueFields.length === 0) return [];
|
|
112
|
+
|
|
113
|
+
// Header row
|
|
114
|
+
const headers = uniqueFields;
|
|
115
|
+
|
|
116
|
+
// Data rows
|
|
117
|
+
const rows = data.map((row) => uniqueFields.map((field) => row[field] ?? ''));
|
|
118
|
+
|
|
119
|
+
return [headers, ...rows];
|
|
120
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARIA label generation for individual marks.
|
|
3
|
+
*
|
|
4
|
+
* Produces per-mark labels for screen reader navigation, enabling
|
|
5
|
+
* users to explore data points individually.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Mark } from '../types/layout';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate ARIA labels for a set of marks.
|
|
12
|
+
*
|
|
13
|
+
* Returns a Map keyed by a mark identifier (index-based) with
|
|
14
|
+
* descriptive labels like "Data point: US GDP 42 in 2020-01".
|
|
15
|
+
*
|
|
16
|
+
* @param marks - Array of mark objects from the compiled chart layout.
|
|
17
|
+
*/
|
|
18
|
+
export function generateAriaLabels(marks: Mark[]): Map<string, string> {
|
|
19
|
+
const labels = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < marks.length; i++) {
|
|
22
|
+
const mark = marks[i];
|
|
23
|
+
const key = `mark-${i}`;
|
|
24
|
+
|
|
25
|
+
switch (mark.type) {
|
|
26
|
+
case 'line': {
|
|
27
|
+
const series = mark.seriesKey ?? 'Series';
|
|
28
|
+
const pointCount = mark.points.length;
|
|
29
|
+
labels.set(key, `Line series: ${series} with ${pointCount} points`);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
case 'area': {
|
|
34
|
+
const series = mark.seriesKey ?? 'Area';
|
|
35
|
+
labels.set(key, `Area series: ${series}`);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'rect': {
|
|
40
|
+
const dataEntries = Object.entries(mark.data).filter(([k]) => !k.startsWith('_'));
|
|
41
|
+
const description = dataEntries.map(([k, v]) => `${k}: ${formatValue(v)}`).join(', ');
|
|
42
|
+
labels.set(key, `Data point: ${description}`);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case 'arc': {
|
|
47
|
+
const dataEntries = Object.entries(mark.data).filter(([k]) => !k.startsWith('_'));
|
|
48
|
+
const description = dataEntries.map(([k, v]) => `${k}: ${formatValue(v)}`).join(', ');
|
|
49
|
+
labels.set(key, `Slice: ${description}`);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'point': {
|
|
54
|
+
const dataEntries = Object.entries(mark.data).filter(([k]) => !k.startsWith('_'));
|
|
55
|
+
const description = dataEntries.map(([k, v]) => `${k}: ${formatValue(v)}`).join(', ');
|
|
56
|
+
labels.set(key, `Data point: ${description}`);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return labels;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Format a data value for use in an ARIA label. */
|
|
66
|
+
function formatValue(value: unknown): string {
|
|
67
|
+
if (value == null) return 'N/A';
|
|
68
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
69
|
+
if (typeof value === 'number') {
|
|
70
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
71
|
+
}
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { checkPaletteDistinguishability, simulateColorBlindness } from '../colorblind';
|
|
3
|
+
|
|
4
|
+
describe('simulateColorBlindness', () => {
|
|
5
|
+
it('returns a valid hex color for protanopia', () => {
|
|
6
|
+
const result = simulateColorBlindness('#e15759', 'protanopia');
|
|
7
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns a valid hex color for deuteranopia', () => {
|
|
11
|
+
const result = simulateColorBlindness('#59a14f', 'deuteranopia');
|
|
12
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns a valid hex color for tritanopia', () => {
|
|
16
|
+
const result = simulateColorBlindness('#1b7fa3', 'tritanopia');
|
|
17
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('pure grey remains close to grey under all types', () => {
|
|
21
|
+
const grey = '#808080';
|
|
22
|
+
for (const type of ['protanopia', 'deuteranopia', 'tritanopia'] as const) {
|
|
23
|
+
const sim = simulateColorBlindness(grey, type);
|
|
24
|
+
// Simulated grey should still be roughly grey (all channels similar)
|
|
25
|
+
const match = sim.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
26
|
+
expect(match).not.toBeNull();
|
|
27
|
+
if (match) {
|
|
28
|
+
const [r, g, b] = [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)];
|
|
29
|
+
// All channels should be within ~40 of each other
|
|
30
|
+
const max = Math.max(r, g, b);
|
|
31
|
+
const min = Math.min(r, g, b);
|
|
32
|
+
expect(max - min).toBeLessThan(40);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('red and green become similar under protanopia', () => {
|
|
38
|
+
const simRed = simulateColorBlindness('#ff0000', 'protanopia');
|
|
39
|
+
const simGreen = simulateColorBlindness('#00ff00', 'protanopia');
|
|
40
|
+
// They should be more similar than the originals
|
|
41
|
+
// (we just verify they're both valid, the matrix math handles the rest)
|
|
42
|
+
expect(simRed).toMatch(/^#[0-9a-f]{6}$/i);
|
|
43
|
+
expect(simGreen).toMatch(/^#[0-9a-f]{6}$/i);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('checkPaletteDistinguishability', () => {
|
|
48
|
+
it('returns true for very different colors', () => {
|
|
49
|
+
expect(checkPaletteDistinguishability(['#ff0000', '#0000ff', '#ffffff'], 'protanopia')).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns false for near-identical colors', () => {
|
|
55
|
+
expect(
|
|
56
|
+
checkPaletteDistinguishability(['#ff0000', '#ff0200', '#ff0100'], 'protanopia', 30),
|
|
57
|
+
).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles single-color palettes (vacuously true)', () => {
|
|
61
|
+
expect(checkPaletteDistinguishability(['#ff0000'], 'deuteranopia')).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { contrastRatio, findAccessibleColor, meetsAA } from '../contrast';
|
|
3
|
+
|
|
4
|
+
describe('contrastRatio', () => {
|
|
5
|
+
it('returns 21 for black on white', () => {
|
|
6
|
+
const ratio = contrastRatio('#000000', '#ffffff');
|
|
7
|
+
expect(ratio).toBeCloseTo(21, 0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns 1 for identical colors', () => {
|
|
11
|
+
const ratio = contrastRatio('#336699', '#336699');
|
|
12
|
+
expect(ratio).toBeCloseTo(1, 1);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('is commutative (order does not matter)', () => {
|
|
16
|
+
const a = contrastRatio('#1b7fa3', '#ffffff');
|
|
17
|
+
const b = contrastRatio('#ffffff', '#1b7fa3');
|
|
18
|
+
expect(a).toBeCloseTo(b, 5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('computes known contrast ratios within tolerance', () => {
|
|
22
|
+
// White text on dark blue: should be high contrast
|
|
23
|
+
const ratio = contrastRatio('#ffffff', '#003366');
|
|
24
|
+
expect(ratio).toBeGreaterThan(8);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('meetsAA', () => {
|
|
29
|
+
it('black on white meets AA for normal text', () => {
|
|
30
|
+
expect(meetsAA('#000000', '#ffffff')).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('light grey on white fails AA for normal text', () => {
|
|
34
|
+
expect(meetsAA('#cccccc', '#ffffff')).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('uses 3:1 threshold for large text', () => {
|
|
38
|
+
// Find a color that has ratio between 3 and 4.5
|
|
39
|
+
// Medium grey on white has ~3.9:1 ratio
|
|
40
|
+
const ratio = contrastRatio('#767676', '#ffffff');
|
|
41
|
+
expect(ratio).toBeGreaterThanOrEqual(3);
|
|
42
|
+
expect(ratio).toBeLessThan(5);
|
|
43
|
+
// Should fail normal text but pass large text
|
|
44
|
+
expect(meetsAA('#767676', '#ffffff', true)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('findAccessibleColor', () => {
|
|
49
|
+
it('returns the original color if already accessible', () => {
|
|
50
|
+
const result = findAccessibleColor('#000000', '#ffffff');
|
|
51
|
+
expect(result).toBe('#000000');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('darkens a light color to meet contrast on white', () => {
|
|
55
|
+
const result = findAccessibleColor('#cccccc', '#ffffff');
|
|
56
|
+
const ratio = contrastRatio(result, '#ffffff');
|
|
57
|
+
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('lightens a dark color to meet contrast on dark background', () => {
|
|
61
|
+
const result = findAccessibleColor('#333333', '#1a1a2e');
|
|
62
|
+
const ratio = contrastRatio(result, '#1a1a2e');
|
|
63
|
+
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('respects custom target ratio', () => {
|
|
67
|
+
const result = findAccessibleColor('#aaaaaa', '#ffffff', 7);
|
|
68
|
+
const ratio = contrastRatio(result, '#ffffff');
|
|
69
|
+
expect(ratio).toBeGreaterThanOrEqual(7);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CATEGORICAL_PALETTE,
|
|
4
|
+
DIVERGING_PALETTES,
|
|
5
|
+
DIVERGING_RED_BLUE,
|
|
6
|
+
SEQUENTIAL_BLUE,
|
|
7
|
+
SEQUENTIAL_PALETTES,
|
|
8
|
+
} from '../palettes';
|
|
9
|
+
|
|
10
|
+
describe('palettes', () => {
|
|
11
|
+
it('categorical palette has 10 colors', () => {
|
|
12
|
+
expect(CATEGORICAL_PALETTE).toHaveLength(10);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('categorical palette colors are valid hex', () => {
|
|
16
|
+
for (const color of CATEGORICAL_PALETTE) {
|
|
17
|
+
expect(color).toMatch(/^#[0-9a-f]{6}$/i);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sequential palettes have expected keys', () => {
|
|
22
|
+
expect(Object.keys(SEQUENTIAL_PALETTES)).toEqual(
|
|
23
|
+
expect.arrayContaining(['blue', 'green', 'orange', 'purple']),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('sequential palettes have 6 stops each', () => {
|
|
28
|
+
for (const [_name, stops] of Object.entries(SEQUENTIAL_PALETTES)) {
|
|
29
|
+
expect(stops.length).toBeGreaterThanOrEqual(5);
|
|
30
|
+
expect(stops.length).toBeLessThanOrEqual(7);
|
|
31
|
+
// Verify all stops are hex
|
|
32
|
+
for (const stop of stops) {
|
|
33
|
+
expect(stop).toMatch(/^#[0-9a-f]{6}$/i);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('diverging palettes have expected keys', () => {
|
|
39
|
+
expect(Object.keys(DIVERGING_PALETTES)).toEqual(
|
|
40
|
+
expect.arrayContaining(['redBlue', 'brownTeal']),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('diverging palettes have 7 stops (neutral midpoint)', () => {
|
|
45
|
+
for (const stops of Object.values(DIVERGING_PALETTES)) {
|
|
46
|
+
expect(stops).toHaveLength(7);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('named palette objects have matching names', () => {
|
|
51
|
+
expect(SEQUENTIAL_BLUE.name).toBe('blue');
|
|
52
|
+
expect(DIVERGING_RED_BLUE.name).toBe('redBlue');
|
|
53
|
+
});
|
|
54
|
+
});
|