@automattic/charts 4.3.0 → 4.5.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/CHANGELOG.md +20 -0
- package/TOKENS.md +2 -2
- package/dist/index.cjs +584 -209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +4 -0
- package/dist/index.d.cts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +585 -210
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +57 -23
- package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +1 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
- package/src/charts/bar-chart/types.ts +5 -1
- package/src/charts/geo-chart/geo-chart.tsx +4 -0
- package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
- package/src/charts/geo-chart/types.ts +5 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
- package/src/charts/pie-chart/pie-chart.module.scss +4 -0
- package/src/charts/pie-chart/pie-chart.tsx +103 -7
- package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
- package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
- package/src/components/tooltip/accessible-tooltip.tsx +40 -4
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
- package/src/providers/chart-context/global-charts-provider.tsx +57 -41
- package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
- package/src/providers/chart-context/private/palette-generator.ts +305 -0
- package/src/providers/chart-context/private/perceptual-color.ts +219 -0
- package/src/providers/chart-context/private/series-palette.ts +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
- package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
- package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
- package/src/test-utils/zoned-parsing-suite.ts +1 -1
- package/src/utils/color-utils.ts +5 -3
- package/src/utils/test/date-formatting.test.ts +1 -1
- package/src/providers/chart-context/private/get-chart-color.ts +0 -223
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { relativeLuminance, validateHexColor } from '../../../utils';
|
|
2
|
+
|
|
3
|
+
export type Lab = readonly [ number, number, number ];
|
|
4
|
+
|
|
5
|
+
/** A color as seen in normal vision, then under deuteranopia and protanopia. */
|
|
6
|
+
export type ColorViews = readonly [ Lab, Lab, Lab ];
|
|
7
|
+
|
|
8
|
+
type Matrix = readonly number[];
|
|
9
|
+
|
|
10
|
+
// Machado, Oliveira and Fernandes (2009), severity 1.0, on linear sRGB.
|
|
11
|
+
const DEUTERANOPIA: Matrix = [
|
|
12
|
+
0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881,
|
|
13
|
+
];
|
|
14
|
+
const PROTANOPIA: Matrix = [
|
|
15
|
+
0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const DEGREES = Math.PI / 180;
|
|
19
|
+
|
|
20
|
+
const toLinear = ( channel: number ): number =>
|
|
21
|
+
channel <= 0.04045 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
|
|
22
|
+
|
|
23
|
+
const fromLinear = ( channel: number ): number =>
|
|
24
|
+
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow( channel, 1 / 2.4 ) - 0.055;
|
|
25
|
+
|
|
26
|
+
const hexToLinear = ( hex: string ): number[] => {
|
|
27
|
+
validateHexColor( hex );
|
|
28
|
+
return [ 1, 3, 5 ].map( start =>
|
|
29
|
+
toLinear( parseInt( hex.slice( start, start + 2 ), 16 ) / 255 )
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const linearToHex = ( linear: number[] ): string =>
|
|
34
|
+
'#' +
|
|
35
|
+
linear
|
|
36
|
+
.map( channel =>
|
|
37
|
+
Math.round( Math.min( 1, Math.max( 0, fromLinear( channel ) ) ) * 255 )
|
|
38
|
+
.toString( 16 )
|
|
39
|
+
.padStart( 2, '0' )
|
|
40
|
+
)
|
|
41
|
+
.join( '' );
|
|
42
|
+
|
|
43
|
+
const simulate = ( matrix: Matrix, [ r, g, b ]: number[] ): number[] =>
|
|
44
|
+
[ 0, 3, 6 ].map( row =>
|
|
45
|
+
Math.min( 1, Math.max( 0, matrix[ row ] * r + matrix[ row + 1 ] * g + matrix[ row + 2 ] * b ) )
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const linearToLab = ( [ r, g, b ]: number[] ): Lab => {
|
|
49
|
+
const x = ( 0.4124564 * r + 0.3575761 * g + 0.1804375 * b ) / 0.95047;
|
|
50
|
+
const y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
|
|
51
|
+
const z = ( 0.0193339 * r + 0.119192 * g + 0.9503041 * b ) / 1.08883;
|
|
52
|
+
const f = ( t: number ): number =>
|
|
53
|
+
t > 216 / 24389 ? Math.cbrt( t ) : ( ( 24389 / 27 ) * t + 16 ) / 116;
|
|
54
|
+
return [ 116 * f( y ) - 16, 500 * ( f( x ) - f( y ) ), 200 * ( f( y ) - f( z ) ) ];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* CIEDE2000 color difference between two CIELAB colors.
|
|
59
|
+
*
|
|
60
|
+
* @param first - First color in CIELAB.
|
|
61
|
+
* @param second - Second color in CIELAB.
|
|
62
|
+
* @return ΔE00; about 10 is where two chart categories stop being reliably distinguishable.
|
|
63
|
+
*/
|
|
64
|
+
export const deltaE2000 = ( first: Lab, second: Lab ): number => {
|
|
65
|
+
const [ l1, a1, b1 ] = first;
|
|
66
|
+
const [ l2, a2, b2 ] = second;
|
|
67
|
+
const chromaMean = ( Math.hypot( a1, b1 ) + Math.hypot( a2, b2 ) ) / 2;
|
|
68
|
+
const g = 0.5 * ( 1 - Math.sqrt( chromaMean ** 7 / ( chromaMean ** 7 + 25 ** 7 ) ) );
|
|
69
|
+
const a1p = a1 * ( 1 + g );
|
|
70
|
+
const a2p = a2 * ( 1 + g );
|
|
71
|
+
const c1p = Math.hypot( a1p, b1 );
|
|
72
|
+
const c2p = Math.hypot( a2p, b2 );
|
|
73
|
+
const hueOf = ( a: number, b: number ): number =>
|
|
74
|
+
a === 0 && b === 0 ? 0 : ( Math.atan2( b, a ) / DEGREES + 360 ) % 360;
|
|
75
|
+
const h1p = hueOf( a1p, b1 );
|
|
76
|
+
const h2p = hueOf( a2p, b2 );
|
|
77
|
+
const chromatic = c1p * c2p !== 0;
|
|
78
|
+
|
|
79
|
+
let hueDelta = chromatic ? h2p - h1p : 0;
|
|
80
|
+
if ( hueDelta > 180 ) {
|
|
81
|
+
hueDelta -= 360;
|
|
82
|
+
} else if ( hueDelta < -180 ) {
|
|
83
|
+
hueDelta += 360;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lightnessDelta = l2 - l1;
|
|
87
|
+
const chromaDelta = c2p - c1p;
|
|
88
|
+
const hueDifference = 2 * Math.sqrt( c1p * c2p ) * Math.sin( ( hueDelta * DEGREES ) / 2 );
|
|
89
|
+
const lightnessMean = ( l1 + l2 ) / 2;
|
|
90
|
+
const chromaMeanPrime = ( c1p + c2p ) / 2;
|
|
91
|
+
|
|
92
|
+
let hueMean = h1p + h2p;
|
|
93
|
+
if ( chromatic ) {
|
|
94
|
+
if ( Math.abs( h1p - h2p ) > 180 ) {
|
|
95
|
+
hueMean += hueMean < 360 ? 360 : -360;
|
|
96
|
+
}
|
|
97
|
+
hueMean /= 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const t =
|
|
101
|
+
1 -
|
|
102
|
+
0.17 * Math.cos( ( hueMean - 30 ) * DEGREES ) +
|
|
103
|
+
0.24 * Math.cos( 2 * hueMean * DEGREES ) +
|
|
104
|
+
0.32 * Math.cos( ( 3 * hueMean + 6 ) * DEGREES ) -
|
|
105
|
+
0.2 * Math.cos( ( 4 * hueMean - 63 ) * DEGREES );
|
|
106
|
+
const lightnessScale =
|
|
107
|
+
1 + ( 0.015 * ( lightnessMean - 50 ) ** 2 ) / Math.sqrt( 20 + ( lightnessMean - 50 ) ** 2 );
|
|
108
|
+
const chromaScale = 1 + 0.045 * chromaMeanPrime;
|
|
109
|
+
const hueScale = 1 + 0.015 * chromaMeanPrime * t;
|
|
110
|
+
const rotation =
|
|
111
|
+
-2 *
|
|
112
|
+
Math.sqrt( chromaMeanPrime ** 7 / ( chromaMeanPrime ** 7 + 25 ** 7 ) ) *
|
|
113
|
+
Math.sin( 60 * Math.exp( -( ( ( hueMean - 275 ) / 25 ) ** 2 ) ) * DEGREES );
|
|
114
|
+
|
|
115
|
+
const lightnessTerm = lightnessDelta / lightnessScale;
|
|
116
|
+
const chromaTerm = chromaDelta / chromaScale;
|
|
117
|
+
const hueTerm = hueDifference / hueScale;
|
|
118
|
+
|
|
119
|
+
return Math.sqrt(
|
|
120
|
+
lightnessTerm ** 2 + chromaTerm ** 2 + hueTerm ** 2 + rotation * chromaTerm * hueTerm
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A hex color in CIELAB as seen in normal vision, under deuteranopia and under protanopia.
|
|
126
|
+
*
|
|
127
|
+
* @param hex - Six-digit hex color.
|
|
128
|
+
* @return The three views.
|
|
129
|
+
* @throws {Error} if hex string is malformed
|
|
130
|
+
*/
|
|
131
|
+
export const hexToViews = ( hex: string ): ColorViews => {
|
|
132
|
+
const linear = hexToLinear( hex );
|
|
133
|
+
return [
|
|
134
|
+
linearToLab( linear ),
|
|
135
|
+
linearToLab( simulate( DEUTERANOPIA, linear ) ),
|
|
136
|
+
linearToLab( simulate( PROTANOPIA, linear ) ),
|
|
137
|
+
];
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The smallest ΔE00 between two colors across the three views.
|
|
142
|
+
*
|
|
143
|
+
* @param first - First color's views.
|
|
144
|
+
* @param second - Second color's views.
|
|
145
|
+
* @return The distance in the view where the two are hardest to tell apart.
|
|
146
|
+
*/
|
|
147
|
+
export const viewDistance = ( first: ColorViews, second: ColorViews ): number =>
|
|
148
|
+
Math.min(
|
|
149
|
+
deltaE2000( first[ 0 ], second[ 0 ] ),
|
|
150
|
+
deltaE2000( first[ 1 ], second[ 1 ] ),
|
|
151
|
+
deltaE2000( first[ 2 ], second[ 2 ] )
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convert an OKLCH color to sRGB hex.
|
|
156
|
+
*
|
|
157
|
+
* @param lightness - OKLab L, 0 to 1.
|
|
158
|
+
* @param chroma - OKLCH C.
|
|
159
|
+
* @param hue - Hue in degrees.
|
|
160
|
+
* @return Hex color, or null when the color is outside the sRGB gamut.
|
|
161
|
+
*/
|
|
162
|
+
export const oklchToHex = ( lightness: number, chroma: number, hue: number ): string | null => {
|
|
163
|
+
const a = chroma * Math.cos( hue * DEGREES );
|
|
164
|
+
const b = chroma * Math.sin( hue * DEGREES );
|
|
165
|
+
const l = ( lightness + 0.3963377774 * a + 0.2158037573 * b ) ** 3;
|
|
166
|
+
const m = ( lightness - 0.1055613458 * a - 0.0638541728 * b ) ** 3;
|
|
167
|
+
const s = ( lightness - 0.0894841775 * a - 1.291485548 * b ) ** 3;
|
|
168
|
+
const linear = [
|
|
169
|
+
4.0767416621 * l - 3.3077633306 * m + 0.2309645873 * s,
|
|
170
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
171
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
172
|
+
];
|
|
173
|
+
const tolerance = 1e-4;
|
|
174
|
+
if ( linear.some( channel => channel < -tolerance || channel > 1 + tolerance ) ) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return linearToHex( linear );
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* OKLCH chroma and hue of a hex color.
|
|
182
|
+
*
|
|
183
|
+
* @param hex - Six-digit hex color.
|
|
184
|
+
* @return Chroma, and hue in degrees from 0 to 360.
|
|
185
|
+
* @throws {Error} if hex string is malformed
|
|
186
|
+
*/
|
|
187
|
+
export const hexToOklch = ( hex: string ): { chroma: number; hue: number } => {
|
|
188
|
+
const [ r, g, b ] = hexToLinear( hex );
|
|
189
|
+
const l = Math.cbrt( 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b );
|
|
190
|
+
const m = Math.cbrt( 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b );
|
|
191
|
+
const s = Math.cbrt( 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b );
|
|
192
|
+
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
193
|
+
const bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
194
|
+
return {
|
|
195
|
+
chroma: Math.hypot( a, bAxis ),
|
|
196
|
+
hue: ( Math.atan2( bAxis, a ) / DEGREES + 360 ) % 360,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* WCAG contrast ratio between two relative luminances.
|
|
202
|
+
*
|
|
203
|
+
* @param first - First relative luminance.
|
|
204
|
+
* @param second - Second relative luminance.
|
|
205
|
+
* @return Ratio from 1 to 21.
|
|
206
|
+
*/
|
|
207
|
+
export const luminanceContrastRatio = ( first: number, second: number ): number =>
|
|
208
|
+
( Math.max( first, second ) + 0.05 ) / ( Math.min( first, second ) + 0.05 );
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* WCAG contrast ratio between two hex colors.
|
|
212
|
+
*
|
|
213
|
+
* @param first - First hex color.
|
|
214
|
+
* @param second - Second hex color.
|
|
215
|
+
* @return Ratio from 1 to 21.
|
|
216
|
+
* @throws {Error} if either hex string is malformed
|
|
217
|
+
*/
|
|
218
|
+
export const contrastRatio = ( first: string, second: string ): number =>
|
|
219
|
+
luminanceContrastRatio( relativeLuminance( first ), relativeLuminance( second ) );
|
|
@@ -23,7 +23,7 @@ export const seriesRole = ( slot: number ): string => `--a8c-charts-color-series
|
|
|
23
23
|
* the bare hex rather than that slot's full `var()` chain: this string is resolved against the DOM,
|
|
24
24
|
* where the chain has already been walked, and is only read at all when no DOM answered.
|
|
25
25
|
*/
|
|
26
|
-
const SERIES_SLOT_1_FALLBACK = '#3858e9';
|
|
26
|
+
export const SERIES_SLOT_1_FALLBACK = '#3858e9';
|
|
27
27
|
|
|
28
28
|
/** The catalog pointer for every slot, in slot order. */
|
|
29
29
|
export const SERIES_PALETTE_POINTERS: readonly string[] = Array.from(
|
|
@@ -4,6 +4,7 @@ import { GlobalChartsProvider } from '../global-charts-provider';
|
|
|
4
4
|
import { useChartId } from '../hooks/use-chart-id';
|
|
5
5
|
import { useChartRegistration } from '../hooks/use-chart-registration';
|
|
6
6
|
import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
|
|
7
|
+
import { createPaletteGenerator } from '../private/palette-generator';
|
|
7
8
|
import { defaultTheme } from '../themes';
|
|
8
9
|
import type { BaseLegendItem } from '../../../components/legend';
|
|
9
10
|
import type { SeriesData } from '../../../types';
|
|
@@ -1893,13 +1894,22 @@ describe( 'ChartContext', () => {
|
|
|
1893
1894
|
return <div>Test</div>;
|
|
1894
1895
|
};
|
|
1895
1896
|
|
|
1896
|
-
const renderWithSlots = (
|
|
1897
|
+
const renderWithSlots = (
|
|
1898
|
+
slots: Array< string | undefined >,
|
|
1899
|
+
extraRoles?: Record< string, string >
|
|
1900
|
+
) => {
|
|
1897
1901
|
slots.forEach( ( color, slot ) => {
|
|
1898
1902
|
if ( color !== undefined ) {
|
|
1899
1903
|
container.style.setProperty( `--a8c-charts-color-series-${ slot + 1 }`, color );
|
|
1900
1904
|
}
|
|
1901
1905
|
} );
|
|
1902
1906
|
|
|
1907
|
+
if ( extraRoles ) {
|
|
1908
|
+
Object.entries( extraRoles ).forEach( ( [ property, value ] ) =>
|
|
1909
|
+
container.style.setProperty( property, value )
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1903
1913
|
return render(
|
|
1904
1914
|
<GlobalChartsProvider>
|
|
1905
1915
|
<TestComponent />
|
|
@@ -1990,19 +2000,16 @@ describe( 'ChartContext', () => {
|
|
|
1990
2000
|
} );
|
|
1991
2001
|
|
|
1992
2002
|
describe( 'Error Handling', () => {
|
|
1993
|
-
it( '
|
|
2003
|
+
it( 'compacts out an unparseable value without crashing', () => {
|
|
1994
2004
|
expect( () => renderWithSlots( [ '#invalid', '#ff0000' ] ) ).not.toThrow();
|
|
1995
2005
|
|
|
1996
|
-
expect( colorAt( 0 ) ).toBe( '#
|
|
1997
|
-
expect( colorAt( 1 ) ).toBe( '#ff0000' );
|
|
2006
|
+
expect( colorAt( 0 ) ).toBe( '#ff0000' );
|
|
1998
2007
|
} );
|
|
1999
2008
|
|
|
2000
|
-
it( '
|
|
2009
|
+
it( 'compacts out malformed hex values without crashing', () => {
|
|
2001
2010
|
expect( () => renderWithSlots( [ '#ff', '#gggggg', '#00ff00' ] ) ).not.toThrow();
|
|
2002
2011
|
|
|
2003
|
-
expect( colorAt( 0 ) ).toBe( '#
|
|
2004
|
-
expect( colorAt( 1 ) ).toBe( '#gggggg' );
|
|
2005
|
-
expect( colorAt( 2 ) ).toBe( '#00ff00' );
|
|
2012
|
+
expect( colorAt( 0 ) ).toBe( '#00ff00' );
|
|
2006
2013
|
} );
|
|
2007
2014
|
|
|
2008
2015
|
it( 'keeps resolving the later slots after one fails to convert', () => {
|
|
@@ -2034,6 +2041,79 @@ describe( 'ChartContext', () => {
|
|
|
2034
2041
|
} );
|
|
2035
2042
|
} );
|
|
2036
2043
|
|
|
2044
|
+
describe( 'Background-aware generation', () => {
|
|
2045
|
+
const seed = '#3858e9';
|
|
2046
|
+
const labels = [ '#1e1e1e', '#f0f0f0' ];
|
|
2047
|
+
|
|
2048
|
+
it( 'wires the resolved dark background into the generator', () => {
|
|
2049
|
+
// Fixture guard: proves index 1 actually depends on the background for this seed,
|
|
2050
|
+
// so a match below can't pass by coincidence.
|
|
2051
|
+
const onDark = createPaletteGenerator( [ seed ], '#1e1e1e', labels )( 1 );
|
|
2052
|
+
const onLight = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
|
|
2053
|
+
expect( onDark ).not.toBe( onLight );
|
|
2054
|
+
|
|
2055
|
+
renderWithSlots( [ seed ], { '--a8c-charts-color-background': '#1e1e1e' } );
|
|
2056
|
+
|
|
2057
|
+
expect( colorAt( 1 ) ).toBe( onDark );
|
|
2058
|
+
} );
|
|
2059
|
+
|
|
2060
|
+
it( 'falls back to a white background when the resolved value does not normalize to hex', () => {
|
|
2061
|
+
renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'not-a-real-color' } );
|
|
2062
|
+
|
|
2063
|
+
expect( colorAt( 1 ) ).toBe( createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 ) );
|
|
2064
|
+
} );
|
|
2065
|
+
|
|
2066
|
+
it( 'falls back to a white background when the background is transparent', () => {
|
|
2067
|
+
const onWhite = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
|
|
2068
|
+
const onBlack = createPaletteGenerator( [ seed ], '#000000', labels )( 1 );
|
|
2069
|
+
expect( onWhite ).not.toBe( onBlack );
|
|
2070
|
+
|
|
2071
|
+
renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'transparent' } );
|
|
2072
|
+
|
|
2073
|
+
expect( colorAt( 1 ) ).toBe( onWhite );
|
|
2074
|
+
} );
|
|
2075
|
+
|
|
2076
|
+
it( 'falls back to a white background when the background is a translucent color', () => {
|
|
2077
|
+
const onWhite = createPaletteGenerator( [ seed ], '#ffffff', labels )( 1 );
|
|
2078
|
+
const onBlack = createPaletteGenerator( [ seed ], '#000000', labels )( 1 );
|
|
2079
|
+
expect( onWhite ).not.toBe( onBlack );
|
|
2080
|
+
|
|
2081
|
+
renderWithSlots( [ seed ], { '--a8c-charts-color-background': 'rgba(0, 0, 0, 0.5)' } );
|
|
2082
|
+
|
|
2083
|
+
expect( colorAt( 1 ) ).toBe( onWhite );
|
|
2084
|
+
} );
|
|
2085
|
+
|
|
2086
|
+
it( 'wires the resolved label colors into the generator, leaving out a see-through one', () => {
|
|
2087
|
+
const firstSix = ( at: ( index: number ) => string ) =>
|
|
2088
|
+
Array.from( { length: 6 }, ( _, index ) => at( index ) );
|
|
2089
|
+
const inverseOnly = firstSix(
|
|
2090
|
+
createPaletteGenerator( [ seed ], '#ffffff', [ '#f0f0f0' ] )
|
|
2091
|
+
);
|
|
2092
|
+
expect( inverseOnly ).not.toEqual(
|
|
2093
|
+
firstSix( createPaletteGenerator( [ seed ], '#ffffff', labels ) )
|
|
2094
|
+
);
|
|
2095
|
+
|
|
2096
|
+
renderWithSlots( [ seed ], { '--a8c-charts-color-label': 'transparent' } );
|
|
2097
|
+
|
|
2098
|
+
expect( firstSix( colorAt ) ).toEqual( inverseOnly );
|
|
2099
|
+
} );
|
|
2100
|
+
|
|
2101
|
+
it( 'uses the resolved background for group colors when no seed resolves', () => {
|
|
2102
|
+
const background = '#1e1e1e';
|
|
2103
|
+
|
|
2104
|
+
// An unparseable slot 1 keeps the resolved palette empty even after resolution;
|
|
2105
|
+
// an unset slot 1 would instead fall back to its own terminal literal.
|
|
2106
|
+
renderWithSlots( [ '#invalid' ], { '--a8c-charts-color-background': background } );
|
|
2107
|
+
|
|
2108
|
+
const groupColor = contextValue.getElementStyles( {
|
|
2109
|
+
data: createMockDataWithGroup( 'test-group' ),
|
|
2110
|
+
index: 0,
|
|
2111
|
+
} ).color;
|
|
2112
|
+
|
|
2113
|
+
expect( groupColor ).toBe( createPaletteGenerator( [], background, labels )( 0 ) );
|
|
2114
|
+
} );
|
|
2115
|
+
} );
|
|
2116
|
+
|
|
2037
2117
|
describe( 'Server-Side Rendering', () => {
|
|
2038
2118
|
// Slot 1's terminal literal is the palette's only carrier under SSR.
|
|
2039
2119
|
it( 'falls back to the seed when getComputedStyle is unavailable', () => {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../global-charts-provider';
|
|
3
|
+
import { useGlobalChartsContext } from '../hooks/use-global-charts-context';
|
|
4
|
+
|
|
5
|
+
describe( 'GlobalChartsProvider before the palette resolves', () => {
|
|
6
|
+
it( 'renders the same colors an unthemed page resolves to', () => {
|
|
7
|
+
const renders: string[][] = [];
|
|
8
|
+
|
|
9
|
+
const FirstColors = () => {
|
|
10
|
+
const { getElementStyles } = useGlobalChartsContext();
|
|
11
|
+
renders.push(
|
|
12
|
+
Array.from( { length: 6 }, ( _, index ) => getElementStyles( { index } ).color )
|
|
13
|
+
);
|
|
14
|
+
return null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
render(
|
|
18
|
+
<GlobalChartsProvider>
|
|
19
|
+
<FirstColors />
|
|
20
|
+
</GlobalChartsProvider>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect( renders.length ).toBeGreaterThan( 1 );
|
|
24
|
+
expect( renders[ 0 ][ 0 ] ).toBe( '#3858e9' );
|
|
25
|
+
expect( renders[ 0 ] ).toEqual( renders[ renders.length - 1 ] );
|
|
26
|
+
} );
|
|
27
|
+
} );
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPaletteGenerator,
|
|
3
|
+
MIN_BACKGROUND_CONTRAST,
|
|
4
|
+
MIN_LABEL_CONTRAST,
|
|
5
|
+
PREFERRED_SEPARATION,
|
|
6
|
+
} from '../private/palette-generator';
|
|
7
|
+
import { contrastRatio, hexToOklch, hexToViews, viewDistance } from '../private/perceptual-color';
|
|
8
|
+
|
|
9
|
+
// `--wp-admin-theme-color` for each wp-admin color scheme, as `@wordpress/base-styles` sets it.
|
|
10
|
+
const WP_ADMIN_THEME_COLORS = Object.entries( {
|
|
11
|
+
light: '#007cba',
|
|
12
|
+
modern: '#3858e9',
|
|
13
|
+
blue: '#437aa8',
|
|
14
|
+
coffee: '#916745',
|
|
15
|
+
ectoplasm: '#646c3e',
|
|
16
|
+
midnight: '#cf4339',
|
|
17
|
+
ocean: '#567958',
|
|
18
|
+
sunrise: '#ad631e',
|
|
19
|
+
} );
|
|
20
|
+
const PALETTE_SIZE = 6;
|
|
21
|
+
const LABEL_COLORS = [ '#1e1e1e', '#f0f0f0' ];
|
|
22
|
+
|
|
23
|
+
const paletteOf = (
|
|
24
|
+
seeds: string[],
|
|
25
|
+
background: string,
|
|
26
|
+
size = PALETTE_SIZE,
|
|
27
|
+
labelColors: string[] = LABEL_COLORS
|
|
28
|
+
): string[] => {
|
|
29
|
+
const colorAt = createPaletteGenerator( seeds, background, labelColors );
|
|
30
|
+
return Array.from( { length: size }, ( _, index ) => colorAt( index ) );
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const minPairwiseDistance = ( palette: string[] ): number => {
|
|
34
|
+
const views = palette.map( hexToViews );
|
|
35
|
+
let min = Infinity;
|
|
36
|
+
for ( let i = 0; i < views.length; i++ ) {
|
|
37
|
+
for ( let j = i + 1; j < views.length; j++ ) {
|
|
38
|
+
min = Math.min( min, viewDistance( views[ i ], views[ j ] ) );
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return min;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe( 'createPaletteGenerator', () => {
|
|
45
|
+
it( 'returns the seeds first, in order', () => {
|
|
46
|
+
const colorAt = createPaletteGenerator( [ '#ff0000', '#00ff00' ], '#ffffff' );
|
|
47
|
+
expect( colorAt( 0 ) ).toBe( '#ff0000' );
|
|
48
|
+
expect( colorAt( 1 ) ).toBe( '#00ff00' );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
describe.each( [ '#ffffff', '#1e1e1e' ] )( 'on %s', background => {
|
|
52
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
53
|
+
'keeps a palette seeded with the %s scheme (%s) separable in every vision view up to six colors',
|
|
54
|
+
( _scheme, seed ) => {
|
|
55
|
+
expect( minPairwiseDistance( paletteOf( [ seed ], background ) ) ).toBeGreaterThanOrEqual(
|
|
56
|
+
PREFERRED_SEPARATION
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
62
|
+
'gives every color generated from the %s scheme (%s) 3:1 against the background',
|
|
63
|
+
( _scheme, seed ) => {
|
|
64
|
+
paletteOf( [ seed ], background )
|
|
65
|
+
.slice( 1 )
|
|
66
|
+
.forEach( color => {
|
|
67
|
+
expect( contrastRatio( color, background ) ).toBeGreaterThanOrEqual(
|
|
68
|
+
MIN_BACKGROUND_CONTRAST
|
|
69
|
+
);
|
|
70
|
+
} );
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
75
|
+
'gives every color generated from the %s scheme (%s) a label color with 4.5:1 on it',
|
|
76
|
+
( _scheme, seed ) => {
|
|
77
|
+
paletteOf( [ seed ], background )
|
|
78
|
+
.slice( 1 )
|
|
79
|
+
.forEach( color => {
|
|
80
|
+
const best = Math.max( ...LABEL_COLORS.map( label => contrastRatio( color, label ) ) );
|
|
81
|
+
expect( best ).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
|
|
82
|
+
} );
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
it( 'keeps 3:1 against the background when no fill can reach 4.5:1 with the label colors', () => {
|
|
88
|
+
paletteOf( [ '#3858e9' ], '#ffffff', PALETTE_SIZE, [ '#777777' ] )
|
|
89
|
+
.slice( 1 )
|
|
90
|
+
.forEach( color => {
|
|
91
|
+
expect( contrastRatio( color, '#ffffff' ) ).toBeGreaterThanOrEqual(
|
|
92
|
+
MIN_BACKGROUND_CONTRAST
|
|
93
|
+
);
|
|
94
|
+
} );
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it.each( WP_ADMIN_THEME_COLORS )(
|
|
98
|
+
'sets the first generated color further from the %s scheme seed (%s) than the second',
|
|
99
|
+
( _scheme, seed ) => {
|
|
100
|
+
const [ seedColor, first, second ] = paletteOf( [ seed ], '#ffffff', 3 ).map( hexToViews );
|
|
101
|
+
expect( viewDistance( seedColor, first ) ).toBeGreaterThan(
|
|
102
|
+
viewDistance( seedColor, second )
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
it.each( [ '#007cba', '#3858e9', '#437aa8' ] )(
|
|
108
|
+
'places the second generated color just ahead of %s on the hue wheel',
|
|
109
|
+
seed => {
|
|
110
|
+
const seedHue = hexToOklch( seed ).hue;
|
|
111
|
+
const walkedHue = hexToOklch( paletteOf( [ seed ], '#ffffff', 3 )[ 2 ] ).hue;
|
|
112
|
+
expect( ( walkedHue - seedHue + 360 ) % 360 ).toBeLessThan( 45 );
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
it( 'shares one generator between calls with the same inputs, whatever their case', () => {
|
|
117
|
+
const first = createPaletteGenerator( [ '#3858E9' ], '#FFFFFF', LABEL_COLORS );
|
|
118
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#ffffff', LABEL_COLORS ) ).toBe( first );
|
|
119
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#1e1e1e', LABEL_COLORS ) ).not.toBe( first );
|
|
120
|
+
expect( createPaletteGenerator( [ '#3858e9' ], '#ffffff' ) ).not.toBe( first );
|
|
121
|
+
} );
|
|
122
|
+
|
|
123
|
+
it( 'returns the same colors whatever order they are asked for in', () => {
|
|
124
|
+
const inOrder = paletteOf( [ '#3858e9' ], '#ffffff', 8 );
|
|
125
|
+
const colorAt = createPaletteGenerator( [ '#3858e9' ], '#ffffff', LABEL_COLORS );
|
|
126
|
+
expect( colorAt( 7 ) ).toBe( inOrder[ 7 ] );
|
|
127
|
+
expect( colorAt( 5 ) ).toBe( inOrder[ 5 ] );
|
|
128
|
+
} );
|
|
129
|
+
|
|
130
|
+
it( 'never repeats a seed or an earlier generated color', () => {
|
|
131
|
+
const palette = paletteOf( [ '#3858e9', '#e14d43', '#04a4cc' ], '#ffffff', 12 );
|
|
132
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
133
|
+
} );
|
|
134
|
+
|
|
135
|
+
it( 'still generates when no candidate reaches 3:1 against the background', () => {
|
|
136
|
+
const palette = paletteOf( [ '#3858e9' ], '#777777' );
|
|
137
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
138
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
it( 'generates from nothing when there are no seeds', () => {
|
|
142
|
+
const palette = paletteOf( [], '#ffffff', 3 );
|
|
143
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
144
|
+
expect( minPairwiseDistance( palette ) ).toBeGreaterThanOrEqual( 15 );
|
|
145
|
+
} );
|
|
146
|
+
|
|
147
|
+
it( 'keeps generating past a small non-empty legible set without throwing', () => {
|
|
148
|
+
// #9b9b9b has 5 legible candidates in the grid, so asking for 10 falls back to the rest of it.
|
|
149
|
+
const palette = paletteOf( [], '#9b9b9b', 10 );
|
|
150
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
151
|
+
expect( new Set( palette ).size ).toBe( palette.length );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
it( 'never regenerates a color equal to a seed', () => {
|
|
155
|
+
// #9b9b9b has exactly 5 legible candidates, so seeding one and asking for the rest of
|
|
156
|
+
// the pool (plus one more, which forces the first grid fallback) exhausts it deterministically.
|
|
157
|
+
const legible = paletteOf( [], '#9b9b9b', 5 );
|
|
158
|
+
const seed = legible[ 0 ];
|
|
159
|
+
const generated = paletteOf( [ seed ], '#9b9b9b', 6 ).slice( 1 );
|
|
160
|
+
expect( generated ).not.toContain( seed );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
it( 'excludes a seed from the grid even when it is given in uppercase', () => {
|
|
164
|
+
const legible = paletteOf( [], '#9b9b9b', 5 );
|
|
165
|
+
const seed = legible[ 0 ];
|
|
166
|
+
const generated = paletteOf( [ seed.toUpperCase() ], '#9b9b9b', 6 ).slice( 1 );
|
|
167
|
+
expect( generated ).not.toContain( seed );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'repeats rather than throws once the whole grid is used up', () => {
|
|
171
|
+
// The candidate grid holds roughly 2,757 distinct in-gamut colors; 3000 comfortably exceeds it.
|
|
172
|
+
const palette = paletteOf( [], '#ffffff', 3000 );
|
|
173
|
+
palette.forEach( color => expect( color ).toMatch( /^#[0-9a-f]{6}$/ ) );
|
|
174
|
+
const placedCount = new Set( palette ).size;
|
|
175
|
+
expect( placedCount ).toBeLessThan( palette.length );
|
|
176
|
+
|
|
177
|
+
// The repeats cycle through the colors placed before the grid ran out, not always the first.
|
|
178
|
+
expect( palette[ placedCount ] ).toBe( palette[ 0 ] );
|
|
179
|
+
expect( palette[ placedCount + 1 ] ).toBe( palette[ 1 ] );
|
|
180
|
+
} );
|
|
181
|
+
} );
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contrastRatio,
|
|
3
|
+
deltaE2000,
|
|
4
|
+
hexToOklch,
|
|
5
|
+
hexToViews,
|
|
6
|
+
oklchToHex,
|
|
7
|
+
viewDistance,
|
|
8
|
+
} from '../private/perceptual-color';
|
|
9
|
+
|
|
10
|
+
describe( 'deltaE2000', () => {
|
|
11
|
+
// Sharma, Wu and Dalal (2005) reference pairs.
|
|
12
|
+
it.each( [
|
|
13
|
+
[ [ 50, 2.6772, -79.7751 ], [ 50, 0, -82.7485 ], 2.0425 ],
|
|
14
|
+
[ [ 50, 2.5, 0 ], [ 50, 0, -2.5 ], 4.3065 ],
|
|
15
|
+
[ [ 50, 2.5, 0 ], [ 73, 25, -18 ], 27.1492 ],
|
|
16
|
+
[ [ 2.0776, 0.0795, -1.135 ], [ 0.9033, -0.0636, -0.5514 ], 0.9082 ],
|
|
17
|
+
] as const )( 'matches the reference value for %j vs %j', ( a, b, expected ) => {
|
|
18
|
+
expect( deltaE2000( a, b ) ).toBeCloseTo( expected, 4 );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
it( 'is zero for identical colors', () => {
|
|
22
|
+
expect( deltaE2000( [ 40, 10, -20 ], [ 40, 10, -20 ] ) ).toBe( 0 );
|
|
23
|
+
} );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
describe( 'viewDistance', () => {
|
|
27
|
+
it( 'collapses a red/green pair under color vision deficiency', () => {
|
|
28
|
+
const red = hexToViews( '#cc4444' );
|
|
29
|
+
const green = hexToViews( '#669900' );
|
|
30
|
+
const [ normal ] = red.map( ( view, i ) => deltaE2000( view, green[ i ] ) );
|
|
31
|
+
expect( normal ).toBeGreaterThan( 30 );
|
|
32
|
+
expect( viewDistance( red, green ) ).toBeLessThan( 15 );
|
|
33
|
+
} );
|
|
34
|
+
|
|
35
|
+
it( 'keeps a blue/orange pair apart in every view', () => {
|
|
36
|
+
expect( viewDistance( hexToViews( '#1f4bd0' ), hexToViews( '#dd823b' ) ) ).toBeGreaterThan(
|
|
37
|
+
30
|
|
38
|
+
);
|
|
39
|
+
} );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
describe( 'oklchToHex', () => {
|
|
43
|
+
it( 'converts an in-gamut color', () => {
|
|
44
|
+
expect( oklchToHex( 0.627955, 0.257683, 29.2339 ) ).toBe( '#ff0000' );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
it( 'returns null outside the sRGB gamut', () => {
|
|
48
|
+
expect( oklchToHex( 0.9, 0.3, 140 ) ).toBeNull();
|
|
49
|
+
} );
|
|
50
|
+
} );
|
|
51
|
+
|
|
52
|
+
describe( 'hexToOklch', () => {
|
|
53
|
+
it( 'is the inverse of oklchToHex', () => {
|
|
54
|
+
const { chroma, hue } = hexToOklch( '#ff0000' );
|
|
55
|
+
expect( chroma ).toBeCloseTo( 0.257683, 3 );
|
|
56
|
+
expect( hue ).toBeCloseTo( 29.2339, 1 );
|
|
57
|
+
} );
|
|
58
|
+
|
|
59
|
+
it( 'keeps hue between 0 and 360 for a color whose angle is negative', () => {
|
|
60
|
+
const { hue } = hexToOklch( '#ff00ff' );
|
|
61
|
+
expect( hue ).toBeGreaterThanOrEqual( 0 );
|
|
62
|
+
expect( hue ).toBeLessThan( 360 );
|
|
63
|
+
} );
|
|
64
|
+
} );
|
|
65
|
+
|
|
66
|
+
describe( 'contrastRatio', () => {
|
|
67
|
+
it( 'is 21 for black on white, in either order', () => {
|
|
68
|
+
expect( contrastRatio( '#000000', '#ffffff' ) ).toBeCloseTo( 21, 5 );
|
|
69
|
+
expect( contrastRatio( '#ffffff', '#000000' ) ).toBeCloseTo( 21, 5 );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
it( 'is 1 for identical colors', () => {
|
|
73
|
+
expect( contrastRatio( '#3858e9', '#3858e9' ) ).toBe( 1 );
|
|
74
|
+
} );
|
|
75
|
+
} );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseAsLocalDate } from '../utils/date-parsing';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
|
|
4
4
|
declare global {
|
|
5
5
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
6
|
namespace jest {
|
package/src/utils/color-utils.ts
CHANGED
|
@@ -52,9 +52,11 @@ export const hexToRgba = ( hex: string, alpha: number ): string => {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* @
|
|
55
|
+
* Weighted Euclidean distance between two HSL colors.
|
|
56
|
+
*
|
|
57
|
+
* @deprecated Not perceptual and blind to color vision deficiency, so charts no longer use it; it will be removed in a future major version.
|
|
58
|
+
* @param hsl1 - first color in HSL format [h, s, l]
|
|
59
|
+
* @param hsl2 - second color in HSL format [h, s, l]
|
|
58
60
|
* @return distance value (0-100+, lower means more similar)
|
|
59
61
|
*/
|
|
60
62
|
export const getColorDistance = (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createDateFormatter, createZonedClock, sanitizeFormatting } from '../date-formatting';
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// The `jetpack-js-tools` console guard registers the matcher but ships no types for it.
|
|
4
4
|
declare global {
|
|
5
5
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
6
|
namespace jest {
|