@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color blindness simulation and palette distinguishability checks.
|
|
3
|
+
*
|
|
4
|
+
* Uses Brettel, Vienot, and Mollon (1997) simulation matrices for
|
|
5
|
+
* protanopia, deuteranopia, and tritanopia.
|
|
6
|
+
*
|
|
7
|
+
* These are approximations suitable for checking palette accessibility,
|
|
8
|
+
* not medical-grade simulations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { rgb } from 'd3-color';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** The three common types of color vision deficiency. */
|
|
18
|
+
export type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Simulation matrices
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
// 3x3 color transformation matrices for each deficiency type.
|
|
25
|
+
// Applied in linear RGB space to simulate how colors appear.
|
|
26
|
+
// Source: Brettel, Vienot & Mollon (1997), simplified.
|
|
27
|
+
|
|
28
|
+
type Matrix3x3 = readonly [
|
|
29
|
+
readonly [number, number, number],
|
|
30
|
+
readonly [number, number, number],
|
|
31
|
+
readonly [number, number, number],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const PROTAN_MATRIX: Matrix3x3 = [
|
|
35
|
+
[0.567, 0.433, 0.0],
|
|
36
|
+
[0.558, 0.442, 0.0],
|
|
37
|
+
[0.0, 0.242, 0.758],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const DEUTAN_MATRIX: Matrix3x3 = [
|
|
41
|
+
[0.625, 0.375, 0.0],
|
|
42
|
+
[0.7, 0.3, 0.0],
|
|
43
|
+
[0.0, 0.3, 0.7],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const TRITAN_MATRIX: Matrix3x3 = [
|
|
47
|
+
[0.95, 0.05, 0.0],
|
|
48
|
+
[0.0, 0.433, 0.567],
|
|
49
|
+
[0.0, 0.475, 0.525],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const MATRICES: Record<ColorBlindnessType, Matrix3x3> = {
|
|
53
|
+
protanopia: PROTAN_MATRIX,
|
|
54
|
+
deuteranopia: DEUTAN_MATRIX,
|
|
55
|
+
tritanopia: TRITAN_MATRIX,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// sRGB linearization helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
function linearize(v: number): number {
|
|
63
|
+
const s = v / 255;
|
|
64
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function delinearize(v: number): number {
|
|
68
|
+
const s = v <= 0.0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
69
|
+
return Math.round(Math.max(0, Math.min(255, s * 255)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Public API
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Simulate how a color appears under a given color blindness type.
|
|
78
|
+
* Returns a hex color string.
|
|
79
|
+
*/
|
|
80
|
+
export function simulateColorBlindness(color: string, type: ColorBlindnessType): string {
|
|
81
|
+
const c = rgb(color);
|
|
82
|
+
if (c == null) return color;
|
|
83
|
+
|
|
84
|
+
const lin = [linearize(c.r), linearize(c.g), linearize(c.b)];
|
|
85
|
+
const m = MATRICES[type];
|
|
86
|
+
|
|
87
|
+
const r = m[0][0] * lin[0] + m[0][1] * lin[1] + m[0][2] * lin[2];
|
|
88
|
+
const g = m[1][0] * lin[0] + m[1][1] * lin[1] + m[1][2] * lin[2];
|
|
89
|
+
const b = m[2][0] * lin[0] + m[2][1] * lin[1] + m[2][2] * lin[2];
|
|
90
|
+
|
|
91
|
+
return rgb(delinearize(r), delinearize(g), delinearize(b)).formatHex();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if colors in a palette are distinguishable under a given
|
|
96
|
+
* color blindness type.
|
|
97
|
+
*
|
|
98
|
+
* Uses a minimum perceptual distance threshold in simulated space.
|
|
99
|
+
* Returns true if all pairs of colors are sufficiently different.
|
|
100
|
+
*/
|
|
101
|
+
export function checkPaletteDistinguishability(
|
|
102
|
+
colors: string[],
|
|
103
|
+
type: ColorBlindnessType,
|
|
104
|
+
minDistance = 30,
|
|
105
|
+
): boolean {
|
|
106
|
+
const simulated = colors.map((c) => {
|
|
107
|
+
const s = rgb(simulateColorBlindness(c, type));
|
|
108
|
+
return s ? [s.r, s.g, s.b] : [0, 0, 0];
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < simulated.length; i++) {
|
|
112
|
+
for (let j = i + 1; j < simulated.length; j++) {
|
|
113
|
+
const dr = simulated[i][0] - simulated[j][0];
|
|
114
|
+
const dg = simulated[i][1] - simulated[j][1];
|
|
115
|
+
const db = simulated[i][2] - simulated[j][2];
|
|
116
|
+
const dist = Math.sqrt(dr * dr + dg * dg + db * db);
|
|
117
|
+
if (dist < minDistance) return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WCAG contrast ratio utilities.
|
|
3
|
+
*
|
|
4
|
+
* Uses d3-color for color space parsing and manipulation.
|
|
5
|
+
* All functions accept CSS color strings (hex, rgb, hsl, named colors).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { rgb } from 'd3-color';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Relative luminance (WCAG 2.1)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compute the relative luminance of a color per WCAG 2.1 definition.
|
|
16
|
+
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
|
17
|
+
*/
|
|
18
|
+
function relativeLuminance(color: string): number {
|
|
19
|
+
const c = rgb(color);
|
|
20
|
+
if (c == null) return 0;
|
|
21
|
+
|
|
22
|
+
const srgb = [c.r / 255, c.g / 255, c.b / 255];
|
|
23
|
+
const linear = srgb.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
24
|
+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Public API
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute the WCAG contrast ratio between two colors.
|
|
33
|
+
* Returns a value between 1 (identical) and 21 (black on white).
|
|
34
|
+
*/
|
|
35
|
+
export function contrastRatio(fg: string, bg: string): number {
|
|
36
|
+
const l1 = relativeLuminance(fg);
|
|
37
|
+
const l2 = relativeLuminance(bg);
|
|
38
|
+
const lighter = Math.max(l1, l2);
|
|
39
|
+
const darker = Math.min(l1, l2);
|
|
40
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if two colors meet WCAG AA contrast requirements.
|
|
45
|
+
* Normal text: 4.5:1, large text (18px+ bold or 24px+): 3:1.
|
|
46
|
+
*/
|
|
47
|
+
export function meetsAA(fg: string, bg: string, largeText = false): boolean {
|
|
48
|
+
const ratio = contrastRatio(fg, bg);
|
|
49
|
+
return largeText ? ratio >= 3 : ratio >= 4.5;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Find an accessible variant of `baseColor` against `bg`.
|
|
54
|
+
*
|
|
55
|
+
* Preserves the hue and saturation of baseColor but adjusts lightness
|
|
56
|
+
* until the target contrast ratio is met. Returns the original color
|
|
57
|
+
* if it already meets the target.
|
|
58
|
+
*/
|
|
59
|
+
export function findAccessibleColor(baseColor: string, bg: string, targetRatio = 4.5): string {
|
|
60
|
+
if (contrastRatio(baseColor, bg) >= targetRatio) {
|
|
61
|
+
return baseColor;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const c = rgb(baseColor);
|
|
65
|
+
if (c == null) return baseColor;
|
|
66
|
+
|
|
67
|
+
const bgLum = relativeLuminance(bg);
|
|
68
|
+
// Determine direction: darken if bg is light, lighten if bg is dark.
|
|
69
|
+
const bgIsLight = bgLum > 0.5;
|
|
70
|
+
|
|
71
|
+
// Binary search for the lightness adjustment that hits the target ratio.
|
|
72
|
+
let lo = 0;
|
|
73
|
+
let hi = 1;
|
|
74
|
+
let best = baseColor;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < 20; i++) {
|
|
77
|
+
const mid = (lo + hi) / 2;
|
|
78
|
+
const adjusted = bgIsLight
|
|
79
|
+
? rgb(c.r * (1 - mid), c.g * (1 - mid), c.b * (1 - mid))
|
|
80
|
+
: rgb(c.r + (255 - c.r) * mid, c.g + (255 - c.g) * mid, c.b + (255 - c.b) * mid);
|
|
81
|
+
|
|
82
|
+
const hex = adjusted.formatHex();
|
|
83
|
+
const ratio = contrastRatio(hex, bg);
|
|
84
|
+
|
|
85
|
+
if (ratio >= targetRatio) {
|
|
86
|
+
best = hex;
|
|
87
|
+
hi = mid; // try less adjustment
|
|
88
|
+
} else {
|
|
89
|
+
lo = mid; // need more adjustment
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return best;
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color system barrel export.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type { ColorBlindnessType } from './colorblind';
|
|
6
|
+
export {
|
|
7
|
+
checkPaletteDistinguishability,
|
|
8
|
+
simulateColorBlindness,
|
|
9
|
+
} from './colorblind';
|
|
10
|
+
|
|
11
|
+
export { contrastRatio, findAccessibleColor, meetsAA } from './contrast';
|
|
12
|
+
export type {
|
|
13
|
+
CategoricalPalette,
|
|
14
|
+
DivergingPalette,
|
|
15
|
+
SequentialPalette,
|
|
16
|
+
} from './palettes';
|
|
17
|
+
export {
|
|
18
|
+
CATEGORICAL_PALETTE,
|
|
19
|
+
DIVERGING_BROWN_TEAL,
|
|
20
|
+
DIVERGING_PALETTES,
|
|
21
|
+
DIVERGING_RED_BLUE,
|
|
22
|
+
SEQUENTIAL_BLUE,
|
|
23
|
+
SEQUENTIAL_GREEN,
|
|
24
|
+
SEQUENTIAL_ORANGE,
|
|
25
|
+
SEQUENTIAL_PALETTES,
|
|
26
|
+
SEQUENTIAL_PURPLE,
|
|
27
|
+
} from './palettes';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color palettes for @opendata-ai.
|
|
3
|
+
*
|
|
4
|
+
* Categorical palette is Infrographic-influenced with WCAG AA contrast
|
|
5
|
+
* for large text (3:1 ratio) on both light (#ffffff) and dark (#1a1a2e)
|
|
6
|
+
* backgrounds. Several colors do not meet the stricter 4.5:1 ratio
|
|
7
|
+
* required for normal-sized body text. This is acceptable because chart
|
|
8
|
+
* marks (bars, lines, areas, points) are large visual elements.
|
|
9
|
+
*
|
|
10
|
+
* Sequential palettes: 5-7 stops from light to dark.
|
|
11
|
+
* Diverging palettes: 7 stops with a neutral midpoint.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Categorical
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default categorical palette. 10 visually distinct colors that meet
|
|
20
|
+
* WCAG AA contrast for large text (3:1) on both white and near-black
|
|
21
|
+
* backgrounds. Some colors fall below the 4.5:1 threshold for normal
|
|
22
|
+
* body text. Influenced by Infrographic's editorial palette with tweaks
|
|
23
|
+
* for accessibility and colorblind distinguishability.
|
|
24
|
+
*/
|
|
25
|
+
export const CATEGORICAL_PALETTE = [
|
|
26
|
+
'#1b7fa3', // teal-blue (primary)
|
|
27
|
+
'#c44e52', // warm red (secondary)
|
|
28
|
+
'#6a9f58', // softer green (tertiary)
|
|
29
|
+
'#d47215', // orange
|
|
30
|
+
'#507e79', // muted teal
|
|
31
|
+
'#9a6a8d', // purple
|
|
32
|
+
'#c4636b', // rose
|
|
33
|
+
'#9c755f', // brown
|
|
34
|
+
'#a88f22', // olive gold
|
|
35
|
+
'#858078', // warm gray
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type CategoricalPalette = typeof CATEGORICAL_PALETTE;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Sequential
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Sequential palette definition: an array of color stops from light to dark. */
|
|
45
|
+
export interface SequentialPalette {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly stops: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const SEQUENTIAL_BLUE: SequentialPalette = {
|
|
51
|
+
name: 'blue',
|
|
52
|
+
stops: ['#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#3182bd', '#08519c'],
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export const SEQUENTIAL_GREEN: SequentialPalette = {
|
|
56
|
+
name: 'green',
|
|
57
|
+
stops: ['#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#31a354', '#006d2c'],
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
export const SEQUENTIAL_ORANGE: SequentialPalette = {
|
|
61
|
+
name: 'orange',
|
|
62
|
+
stops: ['#fee6ce', '#fdd0a2', '#fdae6b', '#fd8d3c', '#e6550d', '#a63603'],
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export const SEQUENTIAL_PURPLE: SequentialPalette = {
|
|
66
|
+
name: 'purple',
|
|
67
|
+
stops: ['#efedf5', '#dadaeb', '#bcbddc', '#9e9ac8', '#756bb1', '#54278f'],
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
/** All sequential palettes keyed by name. */
|
|
71
|
+
export const SEQUENTIAL_PALETTES: Record<string, string[]> = {
|
|
72
|
+
blue: [...SEQUENTIAL_BLUE.stops],
|
|
73
|
+
green: [...SEQUENTIAL_GREEN.stops],
|
|
74
|
+
orange: [...SEQUENTIAL_ORANGE.stops],
|
|
75
|
+
purple: [...SEQUENTIAL_PURPLE.stops],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Diverging
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/** Diverging palette definition: an array of color stops with a neutral midpoint. */
|
|
83
|
+
export interface DivergingPalette {
|
|
84
|
+
readonly name: string;
|
|
85
|
+
readonly stops: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const DIVERGING_RED_BLUE: DivergingPalette = {
|
|
89
|
+
name: 'redBlue',
|
|
90
|
+
stops: [
|
|
91
|
+
'#b2182b', // strong red
|
|
92
|
+
'#d6604d', // medium red
|
|
93
|
+
'#f4a582', // light red
|
|
94
|
+
'#f7f7f7', // neutral
|
|
95
|
+
'#92c5de', // light blue
|
|
96
|
+
'#4393c3', // medium blue
|
|
97
|
+
'#2166ac', // strong blue
|
|
98
|
+
],
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
export const DIVERGING_BROWN_TEAL: DivergingPalette = {
|
|
102
|
+
name: 'brownTeal',
|
|
103
|
+
stops: [
|
|
104
|
+
'#8c510a', // strong brown
|
|
105
|
+
'#bf812d', // medium brown
|
|
106
|
+
'#dfc27d', // light brown
|
|
107
|
+
'#f6e8c3', // neutral
|
|
108
|
+
'#80cdc1', // light teal
|
|
109
|
+
'#35978f', // medium teal
|
|
110
|
+
'#01665e', // strong teal
|
|
111
|
+
],
|
|
112
|
+
} as const;
|
|
113
|
+
|
|
114
|
+
/** All diverging palettes keyed by name. */
|
|
115
|
+
export const DIVERGING_PALETTES: Record<string, string[]> = {
|
|
116
|
+
redBlue: [...DIVERGING_RED_BLUE.stops],
|
|
117
|
+
brownTeal: [...DIVERGING_BROWN_TEAL.stops],
|
|
118
|
+
};
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { EncodingChannel } from '../../types/spec';
|
|
3
|
+
import {
|
|
4
|
+
barChart,
|
|
5
|
+
columnChart,
|
|
6
|
+
dataTable,
|
|
7
|
+
inferFieldType,
|
|
8
|
+
lineChart,
|
|
9
|
+
pieChart,
|
|
10
|
+
scatterChart,
|
|
11
|
+
} from '../spec-builders';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Test data
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const timeSeriesData = [
|
|
18
|
+
{ date: '2020-01-01', value: 10, country: 'US' },
|
|
19
|
+
{ date: '2021-01-01', value: 40, country: 'US' },
|
|
20
|
+
{ date: '2020-01-01', value: 15, country: 'UK' },
|
|
21
|
+
{ date: '2021-01-01', value: 35, country: 'UK' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const categoricalData = [
|
|
25
|
+
{ name: 'Apples', count: 50, price: 1.2 },
|
|
26
|
+
{ name: 'Bananas', count: 30, price: 0.8 },
|
|
27
|
+
{ name: 'Oranges', count: 45, price: 1.5 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const numericData = [
|
|
31
|
+
{ x: 1, y: 2, size: 10, group: 'A' },
|
|
32
|
+
{ x: 3, y: 4, size: 20, group: 'B' },
|
|
33
|
+
{ x: 5, y: 1, size: 15, group: 'A' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const mixedData = [
|
|
37
|
+
{ id: 1, name: 'Alice', score: 95, joined: '2020-03-15' },
|
|
38
|
+
{ id: 2, name: 'Bob', score: 87, joined: '2021-07-22' },
|
|
39
|
+
{ id: 3, name: 'Carol', score: 92, joined: '2019-11-01' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// inferFieldType
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
describe('inferFieldType', () => {
|
|
47
|
+
it('infers quantitative for number fields', () => {
|
|
48
|
+
expect(inferFieldType(categoricalData, 'count')).toBe('quantitative');
|
|
49
|
+
expect(inferFieldType(categoricalData, 'price')).toBe('quantitative');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('infers temporal for ISO date strings', () => {
|
|
53
|
+
expect(inferFieldType(timeSeriesData, 'date')).toBe('temporal');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('infers temporal for partial ISO dates (YYYY-MM format)', () => {
|
|
57
|
+
const data = [{ period: '2020-01' }, { period: '2020-02' }];
|
|
58
|
+
expect(inferFieldType(data, 'period')).toBe('temporal');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('infers temporal for year-only strings (YYYY format)', () => {
|
|
62
|
+
const data = [{ year: '2020' }, { year: '2021' }];
|
|
63
|
+
expect(inferFieldType(data, 'year')).toBe('temporal');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('infers nominal for plain string fields', () => {
|
|
67
|
+
expect(inferFieldType(categoricalData, 'name')).toBe('nominal');
|
|
68
|
+
expect(inferFieldType(timeSeriesData, 'country')).toBe('nominal');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('infers nominal for mixed types', () => {
|
|
72
|
+
const data = [{ value: 10 }, { value: 'text' }];
|
|
73
|
+
expect(inferFieldType(data, 'value')).toBe('nominal');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles null/undefined values gracefully', () => {
|
|
77
|
+
const data = [{ value: null }, { value: undefined }, { value: 42 }];
|
|
78
|
+
expect(inferFieldType(data, 'value')).toBe('quantitative');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles Date objects as temporal', () => {
|
|
82
|
+
const data = [{ date: new Date('2020-01-01') }, { date: new Date('2021-01-01') }];
|
|
83
|
+
expect(inferFieldType(data, 'date')).toBe('temporal');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('samples at most 20 values', () => {
|
|
87
|
+
// Create 100 items, all numbers. Should still work without issue.
|
|
88
|
+
const data = Array.from({ length: 100 }, (_, i) => ({ v: i }));
|
|
89
|
+
expect(inferFieldType(data, 'v')).toBe('quantitative');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns nominal for empty data', () => {
|
|
93
|
+
expect(inferFieldType([], 'anything')).toBe('nominal');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// lineChart
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
describe('lineChart', () => {
|
|
102
|
+
it('creates a line chart spec with string field names', () => {
|
|
103
|
+
const spec = lineChart(timeSeriesData, 'date', 'value');
|
|
104
|
+
|
|
105
|
+
expect(spec.type).toBe('line');
|
|
106
|
+
expect(spec.data).toBe(timeSeriesData);
|
|
107
|
+
expect(spec.encoding.x).toEqual({ field: 'date', type: 'temporal' });
|
|
108
|
+
expect(spec.encoding.y).toEqual({ field: 'value', type: 'quantitative' });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('accepts full EncodingChannel objects', () => {
|
|
112
|
+
const xChannel: EncodingChannel = {
|
|
113
|
+
field: 'date',
|
|
114
|
+
type: 'temporal',
|
|
115
|
+
axis: { label: 'Year' },
|
|
116
|
+
};
|
|
117
|
+
const yChannel: EncodingChannel = {
|
|
118
|
+
field: 'value',
|
|
119
|
+
type: 'quantitative',
|
|
120
|
+
scale: { zero: true },
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const spec = lineChart(timeSeriesData, xChannel, yChannel);
|
|
124
|
+
|
|
125
|
+
expect(spec.encoding.x).toEqual(xChannel);
|
|
126
|
+
expect(spec.encoding.y).toEqual(yChannel);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('includes color encoding from options', () => {
|
|
130
|
+
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
131
|
+
color: 'country',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(spec.encoding.color).toEqual({ field: 'country', type: 'nominal' });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes through chrome and annotations', () => {
|
|
138
|
+
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
139
|
+
chrome: { title: 'GDP Growth' },
|
|
140
|
+
annotations: [{ type: 'refline', y: 0, label: 'Zero' }],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(spec.chrome).toEqual({ title: 'GDP Growth' });
|
|
144
|
+
expect(spec.annotations).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('passes through theme and darkMode', () => {
|
|
148
|
+
const spec = lineChart(timeSeriesData, 'date', 'value', {
|
|
149
|
+
theme: { borderRadius: 8 },
|
|
150
|
+
darkMode: 'auto',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(spec.theme).toEqual({ borderRadius: 8 });
|
|
154
|
+
expect(spec.darkMode).toBe('auto');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// barChart
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
describe('barChart', () => {
|
|
163
|
+
it('maps category to y-axis and value to x-axis', () => {
|
|
164
|
+
const spec = barChart(categoricalData, 'name', 'count');
|
|
165
|
+
|
|
166
|
+
expect(spec.type).toBe('bar');
|
|
167
|
+
// Bar chart convention: category on y, value on x
|
|
168
|
+
expect(spec.encoding.y).toEqual({ field: 'name', type: 'nominal' });
|
|
169
|
+
expect(spec.encoding.x).toEqual({ field: 'count', type: 'quantitative' });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('accepts full channel objects', () => {
|
|
173
|
+
const catChannel: EncodingChannel = {
|
|
174
|
+
field: 'name',
|
|
175
|
+
type: 'ordinal',
|
|
176
|
+
axis: { label: 'Fruit' },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const spec = barChart(categoricalData, catChannel, 'count');
|
|
180
|
+
|
|
181
|
+
expect(spec.encoding.y).toEqual(catChannel);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// columnChart
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
describe('columnChart', () => {
|
|
190
|
+
it('creates a column chart spec with x and y', () => {
|
|
191
|
+
const spec = columnChart(categoricalData, 'name', 'count');
|
|
192
|
+
|
|
193
|
+
expect(spec.type).toBe('column');
|
|
194
|
+
expect(spec.encoding.x).toEqual({ field: 'name', type: 'nominal' });
|
|
195
|
+
expect(spec.encoding.y).toEqual({ field: 'count', type: 'quantitative' });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// pieChart
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('pieChart', () => {
|
|
204
|
+
it('maps category to color channel and value to y', () => {
|
|
205
|
+
const spec = pieChart(categoricalData, 'name', 'count');
|
|
206
|
+
|
|
207
|
+
expect(spec.type).toBe('pie');
|
|
208
|
+
// Pie chart convention: value on y, category on color, no x
|
|
209
|
+
expect(spec.encoding.y).toEqual({ field: 'count', type: 'quantitative' });
|
|
210
|
+
expect(spec.encoding.color).toEqual({ field: 'name', type: 'nominal' });
|
|
211
|
+
expect(spec.encoding.x).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('includes size encoding when specified', () => {
|
|
215
|
+
const spec = pieChart(numericData, 'group', 'y', { size: 'size' });
|
|
216
|
+
|
|
217
|
+
expect(spec.encoding.size).toEqual({ field: 'size', type: 'quantitative' });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// scatterChart
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
describe('scatterChart', () => {
|
|
226
|
+
it('creates a scatter chart with both axes quantitative', () => {
|
|
227
|
+
const spec = scatterChart(numericData, 'x', 'y');
|
|
228
|
+
|
|
229
|
+
expect(spec.type).toBe('scatter');
|
|
230
|
+
expect(spec.encoding.x).toEqual({ field: 'x', type: 'quantitative' });
|
|
231
|
+
expect(spec.encoding.y).toEqual({ field: 'y', type: 'quantitative' });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('supports color and size encoding', () => {
|
|
235
|
+
const spec = scatterChart(numericData, 'x', 'y', {
|
|
236
|
+
color: 'group',
|
|
237
|
+
size: 'size',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(spec.encoding.color).toEqual({ field: 'group', type: 'nominal' });
|
|
241
|
+
expect(spec.encoding.size).toEqual({ field: 'size', type: 'quantitative' });
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// dataTable
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
describe('dataTable', () => {
|
|
250
|
+
it('auto-generates columns from data keys', () => {
|
|
251
|
+
const spec = dataTable(categoricalData);
|
|
252
|
+
|
|
253
|
+
expect(spec.type).toBe('table');
|
|
254
|
+
expect(spec.data).toBe(categoricalData);
|
|
255
|
+
expect(spec.columns).toHaveLength(3);
|
|
256
|
+
expect(spec.columns[0]).toEqual({ key: 'name', label: 'name', align: 'left' });
|
|
257
|
+
expect(spec.columns[1]).toEqual({ key: 'count', label: 'count', align: 'right' });
|
|
258
|
+
expect(spec.columns[2]).toEqual({ key: 'price', label: 'price', align: 'right' });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('uses provided columns instead of auto-generating', () => {
|
|
262
|
+
const columns = [
|
|
263
|
+
{ key: 'name', label: 'Fruit Name' },
|
|
264
|
+
{ key: 'count', label: 'Quantity', format: ',.0f' },
|
|
265
|
+
];
|
|
266
|
+
const spec = dataTable(categoricalData, { columns });
|
|
267
|
+
|
|
268
|
+
expect(spec.columns).toBe(columns);
|
|
269
|
+
expect(spec.columns).toHaveLength(2);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('right-aligns numeric columns in auto-generated config', () => {
|
|
273
|
+
const spec = dataTable(mixedData);
|
|
274
|
+
|
|
275
|
+
const idCol = spec.columns.find((c) => c.key === 'id');
|
|
276
|
+
const scoreCol = spec.columns.find((c) => c.key === 'score');
|
|
277
|
+
const nameCol = spec.columns.find((c) => c.key === 'name');
|
|
278
|
+
|
|
279
|
+
expect(idCol?.align).toBe('right');
|
|
280
|
+
expect(scoreCol?.align).toBe('right');
|
|
281
|
+
expect(nameCol?.align).toBe('left');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('passes through all table options', () => {
|
|
285
|
+
const spec = dataTable(categoricalData, {
|
|
286
|
+
rowKey: 'name',
|
|
287
|
+
chrome: { title: 'Fruit Data' },
|
|
288
|
+
search: true,
|
|
289
|
+
pagination: { pageSize: 10 },
|
|
290
|
+
stickyFirstColumn: true,
|
|
291
|
+
compact: true,
|
|
292
|
+
darkMode: 'auto',
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(spec.rowKey).toBe('name');
|
|
296
|
+
expect(spec.chrome).toEqual({ title: 'Fruit Data' });
|
|
297
|
+
expect(spec.search).toBe(true);
|
|
298
|
+
expect(spec.pagination).toEqual({ pageSize: 10 });
|
|
299
|
+
expect(spec.stickyFirstColumn).toBe(true);
|
|
300
|
+
expect(spec.compact).toBe(true);
|
|
301
|
+
expect(spec.darkMode).toBe('auto');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('returns empty columns for empty data', () => {
|
|
305
|
+
const spec = dataTable([]);
|
|
306
|
+
|
|
307
|
+
expect(spec.columns).toEqual([]);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Mixed field ref (string vs object) across builders
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
describe('mixed FieldRef usage', () => {
|
|
316
|
+
it('allows mixing string and object field refs', () => {
|
|
317
|
+
const yChannel: EncodingChannel = {
|
|
318
|
+
field: 'value',
|
|
319
|
+
type: 'quantitative',
|
|
320
|
+
aggregate: 'mean',
|
|
321
|
+
axis: { label: 'Average Value', format: ',.1f' },
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const spec = lineChart(timeSeriesData, 'date', yChannel, {
|
|
325
|
+
color: { field: 'country', type: 'nominal' },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// x was a string, so type was inferred
|
|
329
|
+
expect(spec.encoding.x?.type).toBe('temporal');
|
|
330
|
+
// y was a full object, so it's passed through
|
|
331
|
+
expect(spec.encoding.y?.aggregate).toBe('mean');
|
|
332
|
+
expect(spec.encoding.y?.axis?.label).toBe('Average Value');
|
|
333
|
+
// color was a full object
|
|
334
|
+
expect(spec.encoding.color?.type).toBe('nominal');
|
|
335
|
+
});
|
|
336
|
+
});
|