@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,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { contrastRatio } from '../../colors/contrast';
|
|
3
|
+
import { adaptColorForDarkMode, adaptTheme } from '../dark-mode';
|
|
4
|
+
import { resolveTheme } from '../resolve';
|
|
5
|
+
|
|
6
|
+
describe('adaptColorForDarkMode', () => {
|
|
7
|
+
const lightBg = '#ffffff';
|
|
8
|
+
const darkBg = '#1a1a2e';
|
|
9
|
+
|
|
10
|
+
it('adapted color has similar contrast on dark bg as original on light bg', () => {
|
|
11
|
+
const original = '#1b7fa3'; // teal from palette
|
|
12
|
+
const adapted = adaptColorForDarkMode(original, lightBg, darkBg);
|
|
13
|
+
|
|
14
|
+
const originalRatio = contrastRatio(original, lightBg);
|
|
15
|
+
const adaptedRatio = contrastRatio(adapted, darkBg);
|
|
16
|
+
|
|
17
|
+
// Should be within 30% of the original ratio
|
|
18
|
+
const tolerance = originalRatio * 0.3;
|
|
19
|
+
expect(Math.abs(adaptedRatio - originalRatio)).toBeLessThan(tolerance);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns a valid hex color', () => {
|
|
23
|
+
const result = adaptColorForDarkMode('#e15759', lightBg, darkBg);
|
|
24
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('handles pure black gracefully', () => {
|
|
28
|
+
const result = adaptColorForDarkMode('#000000', lightBg, darkBg);
|
|
29
|
+
expect(result).toMatch(/^#[0-9a-f]{6}$/i);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('adaptTheme', () => {
|
|
34
|
+
it('sets isDark to true', () => {
|
|
35
|
+
const light = resolveTheme();
|
|
36
|
+
const dark = adaptTheme(light);
|
|
37
|
+
expect(dark.isDark).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('swaps to dark background', () => {
|
|
41
|
+
const light = resolveTheme();
|
|
42
|
+
const dark = adaptTheme(light);
|
|
43
|
+
expect(dark.colors.background).toBe('#1a1a2e');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('updates text color for dark mode', () => {
|
|
47
|
+
const light = resolveTheme();
|
|
48
|
+
const dark = adaptTheme(light);
|
|
49
|
+
expect(dark.colors.text).not.toBe(light.colors.text);
|
|
50
|
+
// Dark text should be light
|
|
51
|
+
const ratio = contrastRatio(dark.colors.text, dark.colors.background);
|
|
52
|
+
expect(ratio).toBeGreaterThan(4);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('adapts categorical palette colors', () => {
|
|
56
|
+
const light = resolveTheme();
|
|
57
|
+
const dark = adaptTheme(light);
|
|
58
|
+
// Colors should be different (adjusted for dark bg)
|
|
59
|
+
expect(dark.colors.categorical).not.toEqual(light.colors.categorical);
|
|
60
|
+
expect(dark.colors.categorical).toHaveLength(light.colors.categorical.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('updates chrome text colors', () => {
|
|
64
|
+
const light = resolveTheme();
|
|
65
|
+
const dark = adaptTheme(light);
|
|
66
|
+
expect(dark.chrome.title.color).not.toBe(light.chrome.title.color);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { contrastRatio } from '../../colors/contrast';
|
|
3
|
+
import { DEFAULT_THEME } from '../defaults';
|
|
4
|
+
|
|
5
|
+
describe('DEFAULT_THEME', () => {
|
|
6
|
+
it('has all required top-level fields', () => {
|
|
7
|
+
expect(DEFAULT_THEME.colors).toBeDefined();
|
|
8
|
+
expect(DEFAULT_THEME.fonts).toBeDefined();
|
|
9
|
+
expect(DEFAULT_THEME.spacing).toBeDefined();
|
|
10
|
+
expect(DEFAULT_THEME.borderRadius).toBeDefined();
|
|
11
|
+
expect(DEFAULT_THEME.chrome).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('uses Inter as primary font', () => {
|
|
15
|
+
expect(DEFAULT_THEME.fonts.family).toContain('Inter');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('title is 22px bold', () => {
|
|
19
|
+
expect(DEFAULT_THEME.chrome.title.fontSize).toBe(22);
|
|
20
|
+
expect(DEFAULT_THEME.chrome.title.fontWeight).toBe(700);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('subtitle is 15px normal weight', () => {
|
|
24
|
+
expect(DEFAULT_THEME.chrome.subtitle.fontSize).toBe(15);
|
|
25
|
+
expect(DEFAULT_THEME.chrome.subtitle.fontWeight).toBe(400);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('source is 12px normal weight', () => {
|
|
29
|
+
expect(DEFAULT_THEME.chrome.source.fontSize).toBe(12);
|
|
30
|
+
expect(DEFAULT_THEME.chrome.source.fontWeight).toBe(400);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('categorical palette has sufficient contrast on white background', () => {
|
|
34
|
+
const bg = DEFAULT_THEME.colors.background;
|
|
35
|
+
for (const color of DEFAULT_THEME.colors.categorical) {
|
|
36
|
+
const ratio = contrastRatio(color, bg);
|
|
37
|
+
// AA for large text is 3:1. Some editorial palette colors may not
|
|
38
|
+
// hit 4.5:1 on pure white, but they should all clear 3:1.
|
|
39
|
+
expect(ratio).toBeGreaterThanOrEqual(3);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('has sequential and diverging palette entries', () => {
|
|
44
|
+
expect(Object.keys(DEFAULT_THEME.colors.sequential).length).toBeGreaterThan(0);
|
|
45
|
+
expect(Object.keys(DEFAULT_THEME.colors.diverging).length).toBeGreaterThan(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { DEFAULT_THEME } from '../defaults';
|
|
3
|
+
import { resolveTheme } from '../resolve';
|
|
4
|
+
|
|
5
|
+
describe('resolveTheme', () => {
|
|
6
|
+
it('returns default theme when no overrides given', () => {
|
|
7
|
+
const resolved = resolveTheme();
|
|
8
|
+
expect(resolved.colors.background).toBe(DEFAULT_THEME.colors.background);
|
|
9
|
+
expect(resolved.fonts.family).toBe(DEFAULT_THEME.fonts.family);
|
|
10
|
+
expect(resolved.isDark).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('deep merges color overrides without losing other color fields', () => {
|
|
14
|
+
const resolved = resolveTheme({
|
|
15
|
+
colors: { background: '#111111' },
|
|
16
|
+
});
|
|
17
|
+
expect(resolved.colors.background).toBe('#111111');
|
|
18
|
+
// Other color fields preserved from defaults
|
|
19
|
+
expect(resolved.colors.text).toBe(DEFAULT_THEME.colors.text);
|
|
20
|
+
expect(resolved.colors.categorical).toEqual(DEFAULT_THEME.colors.categorical);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('overrides font family', () => {
|
|
24
|
+
const resolved = resolveTheme({
|
|
25
|
+
fonts: { family: 'Helvetica' },
|
|
26
|
+
});
|
|
27
|
+
expect(resolved.fonts.family).toBe('Helvetica');
|
|
28
|
+
// Mono should still be default
|
|
29
|
+
expect(resolved.fonts.mono).toBe(DEFAULT_THEME.fonts.mono);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('overrides spacing partially', () => {
|
|
33
|
+
const resolved = resolveTheme({
|
|
34
|
+
spacing: { padding: 24 },
|
|
35
|
+
});
|
|
36
|
+
expect(resolved.spacing.padding).toBe(24);
|
|
37
|
+
expect(resolved.spacing.chromeGap).toBe(DEFAULT_THEME.spacing.chromeGap);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('overrides border radius', () => {
|
|
41
|
+
const resolved = resolveTheme({ borderRadius: 8 });
|
|
42
|
+
expect(resolved.borderRadius).toBe(8);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('overrides categorical palette completely', () => {
|
|
46
|
+
const custom = ['#ff0000', '#00ff00', '#0000ff'];
|
|
47
|
+
const resolved = resolveTheme({
|
|
48
|
+
colors: { categorical: custom },
|
|
49
|
+
});
|
|
50
|
+
expect(resolved.colors.categorical).toEqual(custom);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts a custom base theme', () => {
|
|
54
|
+
const customBase = {
|
|
55
|
+
...DEFAULT_THEME,
|
|
56
|
+
borderRadius: 12,
|
|
57
|
+
};
|
|
58
|
+
const resolved = resolveTheme(undefined, customBase);
|
|
59
|
+
expect(resolved.borderRadius).toBe(12);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dark mode theme adaptation.
|
|
3
|
+
*
|
|
4
|
+
* Preserves hue of colors while adjusting lightness and saturation
|
|
5
|
+
* to maintain the same relative contrast ratios on a dark background.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hsl, rgb } from 'd3-color';
|
|
9
|
+
import { contrastRatio } from '../colors/contrast';
|
|
10
|
+
import type { ResolvedTheme } from '../types/theme';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Dark mode background
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Default dark mode background color. */
|
|
17
|
+
const DARK_BG = '#1a1a2e';
|
|
18
|
+
/** Default dark mode text color. */
|
|
19
|
+
const DARK_TEXT = '#e0e0e0';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Color adaptation
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Adapt a single color for dark mode.
|
|
27
|
+
*
|
|
28
|
+
* Preserves the hue and adjusts lightness/saturation so the adapted
|
|
29
|
+
* color has the same contrast ratio against darkBg as the original
|
|
30
|
+
* had against lightBg.
|
|
31
|
+
*/
|
|
32
|
+
export function adaptColorForDarkMode(color: string, lightBg: string, darkBg: string): string {
|
|
33
|
+
const originalRatio = contrastRatio(color, lightBg);
|
|
34
|
+
const c = hsl(color);
|
|
35
|
+
if (c == null || Number.isNaN(c.h)) {
|
|
36
|
+
// Achromatic or invalid color: just adjust lightness
|
|
37
|
+
const r = rgb(color);
|
|
38
|
+
if (r == null) return color;
|
|
39
|
+
const darkBgLum = _luminanceFromHex(darkBg);
|
|
40
|
+
const isLight = darkBgLum < 0.5;
|
|
41
|
+
if (isLight) return color;
|
|
42
|
+
// Invert the lightness
|
|
43
|
+
const inverted = hsl(color);
|
|
44
|
+
if (inverted == null) return color;
|
|
45
|
+
inverted.l = 1 - inverted.l;
|
|
46
|
+
return inverted.formatHex();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Binary search for lightness that gives equivalent contrast on dark bg
|
|
50
|
+
let lo = 0.0;
|
|
51
|
+
let hi = 1.0;
|
|
52
|
+
let bestColor = color;
|
|
53
|
+
let bestDiff = Infinity;
|
|
54
|
+
|
|
55
|
+
for (let i = 0; i < 20; i++) {
|
|
56
|
+
const mid = (lo + hi) / 2;
|
|
57
|
+
const candidate = hsl(c.h, c.s, mid);
|
|
58
|
+
const hex = candidate.formatHex();
|
|
59
|
+
const ratio = contrastRatio(hex, darkBg);
|
|
60
|
+
const diff = Math.abs(ratio - originalRatio);
|
|
61
|
+
|
|
62
|
+
if (diff < bestDiff) {
|
|
63
|
+
bestDiff = diff;
|
|
64
|
+
bestColor = hex;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ratio < originalRatio) {
|
|
68
|
+
// Need more contrast = more lightness on dark bg
|
|
69
|
+
lo = mid;
|
|
70
|
+
} else {
|
|
71
|
+
hi = mid;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return bestColor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Quick luminance estimation from a hex color. */
|
|
79
|
+
function _luminanceFromHex(color: string): number {
|
|
80
|
+
const c = rgb(color);
|
|
81
|
+
if (c == null) return 0;
|
|
82
|
+
return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Full theme adaptation
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Adapt an entire resolved theme for dark mode.
|
|
91
|
+
*
|
|
92
|
+
* Swaps background/text, adapts categorical and annotation colors,
|
|
93
|
+
* adjusts gridline and axis colors for the dark background.
|
|
94
|
+
*/
|
|
95
|
+
export function adaptTheme(theme: ResolvedTheme): ResolvedTheme {
|
|
96
|
+
const lightBg = theme.colors.background;
|
|
97
|
+
const darkBg = DARK_BG;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...theme,
|
|
101
|
+
isDark: true,
|
|
102
|
+
colors: {
|
|
103
|
+
...theme.colors,
|
|
104
|
+
background: darkBg,
|
|
105
|
+
text: DARK_TEXT,
|
|
106
|
+
gridline: '#333344',
|
|
107
|
+
axis: '#888899',
|
|
108
|
+
annotationFill: 'rgba(255,255,255,0.08)',
|
|
109
|
+
annotationText: '#bbbbcc',
|
|
110
|
+
categorical: theme.colors.categorical.map((c) => adaptColorForDarkMode(c, lightBg, darkBg)),
|
|
111
|
+
// Sequential and diverging palettes are kept as-is since they're
|
|
112
|
+
// typically used for fills where the lightness range still works.
|
|
113
|
+
// If a specific use case needs adaptation, it can be done per-color.
|
|
114
|
+
},
|
|
115
|
+
chrome: {
|
|
116
|
+
title: { ...theme.chrome.title, color: DARK_TEXT },
|
|
117
|
+
subtitle: { ...theme.chrome.subtitle, color: '#aaaaaa' },
|
|
118
|
+
source: { ...theme.chrome.source, color: '#888888' },
|
|
119
|
+
byline: { ...theme.chrome.byline, color: '#888888' },
|
|
120
|
+
footer: { ...theme.chrome.footer, color: '#888888' },
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default theme definition.
|
|
3
|
+
*
|
|
4
|
+
* Tuned to match Infrographic's visual weight and editorial style.
|
|
5
|
+
* Inter font family, typography hierarchy for chrome, and color
|
|
6
|
+
* palettes from the colors module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CATEGORICAL_PALETTE, DIVERGING_PALETTES, SEQUENTIAL_PALETTES } from '../colors/palettes';
|
|
10
|
+
import type { Theme } from '../types/theme';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The default theme. All fields are required and fully specified.
|
|
14
|
+
* resolveTheme() deep-merges user overrides onto this base.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_THEME: Theme = {
|
|
17
|
+
colors: {
|
|
18
|
+
categorical: [...CATEGORICAL_PALETTE],
|
|
19
|
+
sequential: SEQUENTIAL_PALETTES,
|
|
20
|
+
diverging: DIVERGING_PALETTES,
|
|
21
|
+
background: '#ffffff',
|
|
22
|
+
text: '#1d1d1d',
|
|
23
|
+
gridline: '#e8e8e8',
|
|
24
|
+
axis: '#888888',
|
|
25
|
+
annotationFill: 'rgba(0,0,0,0.04)',
|
|
26
|
+
annotationText: '#555555',
|
|
27
|
+
},
|
|
28
|
+
fonts: {
|
|
29
|
+
family: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
30
|
+
mono: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
|
31
|
+
sizes: {
|
|
32
|
+
title: 22,
|
|
33
|
+
subtitle: 15,
|
|
34
|
+
body: 13,
|
|
35
|
+
small: 11,
|
|
36
|
+
axisTick: 11,
|
|
37
|
+
},
|
|
38
|
+
weights: {
|
|
39
|
+
normal: 400,
|
|
40
|
+
medium: 500,
|
|
41
|
+
semibold: 600,
|
|
42
|
+
bold: 700,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
spacing: {
|
|
46
|
+
padding: 12,
|
|
47
|
+
chromeGap: 4,
|
|
48
|
+
chromeToChart: 8,
|
|
49
|
+
chartToFooter: 8,
|
|
50
|
+
axisMargin: 6,
|
|
51
|
+
},
|
|
52
|
+
borderRadius: 4,
|
|
53
|
+
chrome: {
|
|
54
|
+
title: {
|
|
55
|
+
fontSize: 22,
|
|
56
|
+
fontWeight: 700,
|
|
57
|
+
color: '#333333',
|
|
58
|
+
lineHeight: 1.3,
|
|
59
|
+
},
|
|
60
|
+
subtitle: {
|
|
61
|
+
fontSize: 15,
|
|
62
|
+
fontWeight: 400,
|
|
63
|
+
color: '#666666',
|
|
64
|
+
lineHeight: 1.4,
|
|
65
|
+
},
|
|
66
|
+
source: {
|
|
67
|
+
fontSize: 12,
|
|
68
|
+
fontWeight: 400,
|
|
69
|
+
color: '#999999',
|
|
70
|
+
lineHeight: 1.3,
|
|
71
|
+
},
|
|
72
|
+
byline: {
|
|
73
|
+
fontSize: 12,
|
|
74
|
+
fontWeight: 400,
|
|
75
|
+
color: '#999999',
|
|
76
|
+
lineHeight: 1.3,
|
|
77
|
+
},
|
|
78
|
+
footer: {
|
|
79
|
+
fontSize: 12,
|
|
80
|
+
fontWeight: 400,
|
|
81
|
+
color: '#999999',
|
|
82
|
+
lineHeight: 1.3,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme resolver: deep-merges user overrides onto default theme.
|
|
3
|
+
*
|
|
4
|
+
* Produces a ResolvedTheme where every property is guaranteed to have
|
|
5
|
+
* a value. The engine uses ResolvedTheme internally so it never needs
|
|
6
|
+
* null checks on theme properties.
|
|
7
|
+
*
|
|
8
|
+
* Auto-detects dark backgrounds and adapts chrome text colors so
|
|
9
|
+
* custom themes with dark canvases are readable without explicitly
|
|
10
|
+
* enabling darkMode.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ThemeConfig } from '../types/spec';
|
|
14
|
+
import type { ResolvedTheme, Theme } from '../types/theme';
|
|
15
|
+
import { DEFAULT_THEME } from './defaults';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Deep merge source into target, creating a new object.
|
|
19
|
+
* Only merges plain objects; arrays and primitives replace directly.
|
|
20
|
+
*/
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: recursive object merge requires dynamic typing
|
|
22
|
+
function deepMerge(target: any, source: any): any {
|
|
23
|
+
const result = { ...target };
|
|
24
|
+
|
|
25
|
+
for (const key of Object.keys(source)) {
|
|
26
|
+
const sourceVal = source[key];
|
|
27
|
+
const targetVal = target[key];
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
sourceVal !== undefined &&
|
|
31
|
+
sourceVal !== null &&
|
|
32
|
+
typeof sourceVal === 'object' &&
|
|
33
|
+
!Array.isArray(sourceVal) &&
|
|
34
|
+
typeof targetVal === 'object' &&
|
|
35
|
+
targetVal !== null &&
|
|
36
|
+
!Array.isArray(targetVal)
|
|
37
|
+
) {
|
|
38
|
+
result[key] = deepMerge(targetVal, sourceVal);
|
|
39
|
+
} else if (sourceVal !== undefined) {
|
|
40
|
+
result[key] = sourceVal;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert a user-facing ThemeConfig (partial) into the full Theme shape
|
|
49
|
+
* that deepMerge can work with.
|
|
50
|
+
*/
|
|
51
|
+
function themeConfigToPartial(config: ThemeConfig): Partial<Theme> {
|
|
52
|
+
const partial: Partial<Theme> = {};
|
|
53
|
+
|
|
54
|
+
if (config.colors) {
|
|
55
|
+
const colors: Partial<Theme['colors']> = {};
|
|
56
|
+
if (config.colors.categorical) colors.categorical = config.colors.categorical;
|
|
57
|
+
if (config.colors.sequential) colors.sequential = config.colors.sequential;
|
|
58
|
+
if (config.colors.diverging) colors.diverging = config.colors.diverging;
|
|
59
|
+
if (config.colors.background) colors.background = config.colors.background;
|
|
60
|
+
if (config.colors.text) colors.text = config.colors.text;
|
|
61
|
+
if (config.colors.gridline) colors.gridline = config.colors.gridline;
|
|
62
|
+
if (config.colors.axis) colors.axis = config.colors.axis;
|
|
63
|
+
partial.colors = colors as Theme['colors'];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.fonts) {
|
|
67
|
+
const fonts: Partial<Theme['fonts']> = {};
|
|
68
|
+
if (config.fonts.family) fonts.family = config.fonts.family;
|
|
69
|
+
if (config.fonts.mono) fonts.mono = config.fonts.mono;
|
|
70
|
+
partial.fonts = fonts as Theme['fonts'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (config.spacing) {
|
|
74
|
+
const spacing: Partial<Theme['spacing']> = {};
|
|
75
|
+
if (config.spacing.padding !== undefined) spacing.padding = config.spacing.padding;
|
|
76
|
+
if (config.spacing.chromeGap !== undefined) spacing.chromeGap = config.spacing.chromeGap;
|
|
77
|
+
partial.spacing = spacing as Theme['spacing'];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (config.borderRadius !== undefined) {
|
|
81
|
+
partial.borderRadius = config.borderRadius;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return partial;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a hex color to sRGB relative luminance (WCAG 2.1).
|
|
89
|
+
* Returns 0 for invalid/unparseable colors.
|
|
90
|
+
*/
|
|
91
|
+
function relativeLuminance(hex: string): number {
|
|
92
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
93
|
+
if (!m) return 0;
|
|
94
|
+
const r = parseInt(m[1].slice(0, 2), 16) / 255;
|
|
95
|
+
const g = parseInt(m[1].slice(2, 4), 16) / 255;
|
|
96
|
+
const b = parseInt(m[1].slice(4, 6), 16) / 255;
|
|
97
|
+
const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
98
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Returns true if the hex color is perceptually dark (luminance < 0.2). */
|
|
102
|
+
function isDarkBackground(hex: string): boolean {
|
|
103
|
+
return relativeLuminance(hex) < 0.2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Adapt chrome text colors for a dark background.
|
|
108
|
+
* Only overrides values that still match the light-mode defaults,
|
|
109
|
+
* so explicit user overrides are preserved.
|
|
110
|
+
*/
|
|
111
|
+
function adaptChromeForDarkBg(theme: Theme, textColor: string): Theme {
|
|
112
|
+
const light = DEFAULT_THEME.chrome;
|
|
113
|
+
return {
|
|
114
|
+
...theme,
|
|
115
|
+
chrome: {
|
|
116
|
+
title: {
|
|
117
|
+
...theme.chrome.title,
|
|
118
|
+
color:
|
|
119
|
+
theme.chrome.title.color === light.title.color ? textColor : theme.chrome.title.color,
|
|
120
|
+
},
|
|
121
|
+
subtitle: {
|
|
122
|
+
...theme.chrome.subtitle,
|
|
123
|
+
color:
|
|
124
|
+
theme.chrome.subtitle.color === light.subtitle.color
|
|
125
|
+
? adjustOpacity(textColor, 0.7)
|
|
126
|
+
: theme.chrome.subtitle.color,
|
|
127
|
+
},
|
|
128
|
+
source: {
|
|
129
|
+
...theme.chrome.source,
|
|
130
|
+
color:
|
|
131
|
+
theme.chrome.source.color === light.source.color
|
|
132
|
+
? adjustOpacity(textColor, 0.5)
|
|
133
|
+
: theme.chrome.source.color,
|
|
134
|
+
},
|
|
135
|
+
byline: {
|
|
136
|
+
...theme.chrome.byline,
|
|
137
|
+
color:
|
|
138
|
+
theme.chrome.byline.color === light.byline.color
|
|
139
|
+
? adjustOpacity(textColor, 0.5)
|
|
140
|
+
: theme.chrome.byline.color,
|
|
141
|
+
},
|
|
142
|
+
footer: {
|
|
143
|
+
...theme.chrome.footer,
|
|
144
|
+
color:
|
|
145
|
+
theme.chrome.footer.color === light.footer.color
|
|
146
|
+
? adjustOpacity(textColor, 0.5)
|
|
147
|
+
: theme.chrome.footer.color,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Blend a hex color toward white/black by a factor to simulate opacity. */
|
|
154
|
+
function adjustOpacity(hex: string, opacity: number): string {
|
|
155
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
|
156
|
+
if (!m) return hex;
|
|
157
|
+
const r = parseInt(m[1].slice(0, 2), 16);
|
|
158
|
+
const g = parseInt(m[1].slice(2, 4), 16);
|
|
159
|
+
const b = parseInt(m[1].slice(4, 6), 16);
|
|
160
|
+
// Blend toward mid-gray for muted secondary text
|
|
161
|
+
const mix = (c: number) => Math.round(c * opacity + 128 * (1 - opacity));
|
|
162
|
+
const toHex = (n: number) => n.toString(16).padStart(2, '0');
|
|
163
|
+
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a theme by deep-merging user overrides onto a base theme.
|
|
168
|
+
*
|
|
169
|
+
* Auto-detects dark backgrounds: if the resolved background color is
|
|
170
|
+
* perceptually dark and chrome text colors are still the light-mode
|
|
171
|
+
* defaults, they're adapted for readability.
|
|
172
|
+
*
|
|
173
|
+
* @param userTheme - Optional partial theme overrides from the spec.
|
|
174
|
+
* @param base - Base theme to merge onto. Defaults to DEFAULT_THEME.
|
|
175
|
+
* @returns A ResolvedTheme with all properties guaranteed.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveTheme(userTheme?: ThemeConfig, base: Theme = DEFAULT_THEME): ResolvedTheme {
|
|
178
|
+
let merged: Theme = userTheme ? deepMerge(base, themeConfigToPartial(userTheme)) : { ...base };
|
|
179
|
+
|
|
180
|
+
// Auto-adapt chrome for dark backgrounds
|
|
181
|
+
const dark = isDarkBackground(merged.colors.background);
|
|
182
|
+
if (dark) {
|
|
183
|
+
merged = adaptChromeForDarkBg(merged, merged.colors.text);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
...merged,
|
|
188
|
+
isDark: dark,
|
|
189
|
+
} as ResolvedTheme;
|
|
190
|
+
}
|