@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.
Files changed (51) hide show
  1. package/README.md +130 -0
  2. package/dist/index.d.ts +2030 -0
  3. package/dist/index.js +1176 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/styles.css +757 -0
  6. package/package.json +61 -0
  7. package/src/accessibility/__tests__/alt-text.test.ts +110 -0
  8. package/src/accessibility/__tests__/aria.test.ts +125 -0
  9. package/src/accessibility/alt-text.ts +120 -0
  10. package/src/accessibility/aria.ts +73 -0
  11. package/src/accessibility/index.ts +6 -0
  12. package/src/colors/__tests__/colorblind.test.ts +63 -0
  13. package/src/colors/__tests__/contrast.test.ts +71 -0
  14. package/src/colors/__tests__/palettes.test.ts +54 -0
  15. package/src/colors/colorblind.ts +122 -0
  16. package/src/colors/contrast.ts +94 -0
  17. package/src/colors/index.ts +27 -0
  18. package/src/colors/palettes.ts +118 -0
  19. package/src/helpers/__tests__/spec-builders.test.ts +336 -0
  20. package/src/helpers/spec-builders.ts +410 -0
  21. package/src/index.ts +129 -0
  22. package/src/labels/__tests__/collision.test.ts +197 -0
  23. package/src/labels/collision.ts +154 -0
  24. package/src/labels/index.ts +6 -0
  25. package/src/layout/__tests__/chrome.test.ts +114 -0
  26. package/src/layout/__tests__/text-measure.test.ts +49 -0
  27. package/src/layout/chrome.ts +223 -0
  28. package/src/layout/index.ts +6 -0
  29. package/src/layout/text-measure.ts +54 -0
  30. package/src/locale/__tests__/format.test.ts +90 -0
  31. package/src/locale/format.ts +132 -0
  32. package/src/locale/index.ts +6 -0
  33. package/src/responsive/__tests__/breakpoints.test.ts +58 -0
  34. package/src/responsive/breakpoints.ts +92 -0
  35. package/src/responsive/index.ts +18 -0
  36. package/src/styles/viz.css +757 -0
  37. package/src/theme/__tests__/dark-mode.test.ts +68 -0
  38. package/src/theme/__tests__/defaults.test.ts +47 -0
  39. package/src/theme/__tests__/resolve.test.ts +61 -0
  40. package/src/theme/dark-mode.ts +123 -0
  41. package/src/theme/defaults.ts +85 -0
  42. package/src/theme/index.ts +7 -0
  43. package/src/theme/resolve.ts +190 -0
  44. package/src/types/__tests__/spec.test.ts +387 -0
  45. package/src/types/encoding.ts +144 -0
  46. package/src/types/events.ts +96 -0
  47. package/src/types/index.ts +141 -0
  48. package/src/types/layout.ts +794 -0
  49. package/src/types/spec.ts +563 -0
  50. package/src/types/table.ts +105 -0
  51. 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
+ });