@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +584 -209
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +14 -3
  7. package/dist/index.d.ts +14 -3
  8. package/dist/index.js +585 -210
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +57 -23
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
  15. package/src/charts/bar-chart/private/index.ts +1 -0
  16. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
  18. package/src/charts/bar-chart/types.ts +5 -1
  19. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  20. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  21. package/src/charts/geo-chart/types.ts +5 -0
  22. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  23. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  24. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  25. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  26. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  27. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  28. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  29. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  30. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  31. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  32. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  33. package/src/providers/chart-context/private/series-palette.ts +1 -1
  34. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  35. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  36. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  37. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  38. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  39. package/src/utils/color-utils.ts +5 -3
  40. package/src/utils/test/date-formatting.test.ts +1 -1
  41. 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 = ( slots: Array< string | undefined > ) => {
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( 'keeps an unparseable value without crashing', () => {
2003
+ it( 'compacts out an unparseable value without crashing', () => {
1994
2004
  expect( () => renderWithSlots( [ '#invalid', '#ff0000' ] ) ).not.toThrow();
1995
2005
 
1996
- expect( colorAt( 0 ) ).toBe( '#invalid' );
1997
- expect( colorAt( 1 ) ).toBe( '#ff0000' );
2006
+ expect( colorAt( 0 ) ).toBe( '#ff0000' );
1998
2007
  } );
1999
2008
 
2000
- it( 'keeps malformed hex values without crashing', () => {
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( '#ff' );
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
- // `@wordpress/jest-console` registers the matcher but ships no types for it.
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 {
@@ -52,9 +52,11 @@ export const hexToRgba = ( hex: string, alpha: number ): string => {
52
52
  };
53
53
 
54
54
  /**
55
- * Calculate the perceptual distance between two HSL colors
56
- * @param hsl1 - first color in HSL format [h, s, l]
57
- * @param hsl2 - second color in HSL format [h, s, l]
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
- // `@wordpress/jest-console` registers the matcher but ships no types for it.
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 {