Closed SebastianStehle closed 9 months ago
Black returns the same: 1.0000000000000002 4.996003610813204e-16 NaN White returns
1.0000000000000002 4.996003610813204e-16 NaN
Are you saying that a conversion from sRGB #000000
is giving you the same value that #ffffff
gives you? I'm a bit confused by your statement.
This is what I get for black and white:
> new Color('#000000').to('oklch').coords
[ 0, 0, NaN ]
> new Color('#ffffff').to('oklch').coords
[ 1.0000000000000002, 4.996003610813204e-16, NaN ]
This matches what I get in their live playground: https://colorjs.io/notebook/
Yes, you are right. I also get [0, 0, NaN] now. Not sure what I did.
What is the status?
Your issue is not reproducible, and then it also seems you are now getting what you should, so there is nothing to look into. The issue has been closed.
I am confused. My test with black was obviously wrong, but both colors do not return the expected result.
Black:
[1,5e-16,NaN]
[100, 0, 0]
(https://oklch.com/#100,0,0,100)White
[0, 0, NaN]
[0, 0, 0]
My problem was not that both return the same color, but that the conversion from black and white return invalid colors.
Unfortunately, it was not clear from your filed issue what your issue was, so let me explain.
The site you linked is giving you rounded values after conversion, if they were to give you full precision values, you would see that chroma for white is very near zero but not quite zero. This is due to the perils of floating point math.
When a color is achromatic, during conversion, hues are returned as undefined or represented by NaN
("not a number") in floating point numbers. This is because achromatic colors such as white, gray, black, etc. do not actually have a hue. Coupled with floating point math, you will often get a nonsensical hue around achromatic colors. In order to interpolate between achromatic colors, you can't treat achromatic hues as real values, or you get influences of the nonsensical hues in the interpolation. To be clear, the site you linked gives both white and black a zero hue, (redish), but white and black are not redish colors. It is a convention though to return zero as the hue for achromatic colors. When serializing CSS may do this as well. Treating these hues as undefined helps when interpolating between achromatic colors though so you don't introduce a mysterious red, blue, or some other color.
In short, Color.js, by default, returns achromatic hues as undefined for color spaces where this makes sense. Unfortunately, there is no automatic way to extract the color coordinates without NaN, so you will have to convert them to zero yourself.
This is discussed more here: https://github.com/color-js/color.js/issues/328. Color.js has not at this time decided to provide a way to easily pull values without NaN
.
Now, I have found an issue, so I'll reopen this issue. What I am experiencing is that rounding to a given precision isn't quite working. I think this is a new bug introduced by https://github.com/color-js/color.js/pull/384. This seems to specifically affect very small numbers using scientific notation.
> new Color('red').to('oklch').toString({precision: 1})
'oklch(0.6 0.3 30)'
> new Color('red').to('oklch').toString({precision: 3})
'oklch(0.628 0.258 29.2)'
> new Color('red').to('oklch').toString({precision: 16})
'oklch(0.6279553639214311 0.2576833038053608 29.23388027962784)'
> new Color('white').to('oklch').toString({precision: 3})
'oklch(1 5e-16 none)'
> new Color('white').to('oklch').toString({precision: 1})
'oklch(1 5e-16 none)'
> new Color('white').to('oklch').toString({precision: 16})
'oklch(1 4.996003610813204e-16 none)'
In their defense, precision is working much better, but it now specifically has an issue with very, very small numbers that JS will display with scientific notation. What we should get, because 5e-16
is so far below the rounding precision, is something more like this (I will have to use my own color library to demonstrate).
>>> Color('red').convert('oklch').to_string(precision=1)
'oklch(0.6 0.3 30)'
>>> Color('red').convert('oklch').to_string(precision=3)
'oklch(0.628 0.258 29.2)'
>>> Color('red').convert('oklch').to_string(precision=16)
'oklch(0.6279553639214313 0.2576833038053606 29.2338802796279)'
>>> Color('white').convert('oklch').to_string(precision=1)
'oklch(1 0 0)'
>>> Color('white').convert('oklch').to_string(precision=3)
'oklch(1 0 0)'
>>> Color('white').convert('oklch').to_string(precision=16)
'oklch(1 0.0000000000000005 0)'
I see. Thanks for your awesome explanation
I am confused. My test with black was obviously wrong, but both colors do not return the expected result.
Black:
* Result: `[1,5e-16,NaN]` * Expected `[100, 0, 0]` (https://oklch.com/#100,0,0,100)
White
* Result `[0, 0, NaN]` * Expected `[0, 0, 0]`
My problem was not that both return the same color, but that the conversion from black and white return invalid colors.
Your first problem is that you have mixed up black and white. Black should give [0, 0, NaN] and white would give [1, 0, NaN].
Your second is that you seem to believe Oklab (and thus, Oklch) Lightness goes from 0 to 100. It does not, the range is 0 to 1. See the actual defining document:
However, I can see why you think that: Oklch.com is using percentage values (but leaving out the percent sign from the picker!)
Thirdly, an undefined hue is correct for achromatic colors.
As @facelessuser said, rounding should be catching those tiny almost-zero numbers, but the scientific notation is leaking out and this is a Color.js problem that we need to fix.
My white / black mistakes are just from copy-pasting. But I do not really understand oklab yet, but it looks interesting to generate colors for things elements like avatars.
The only motivation was to convert a color, that I got from a color-input to something that CSS understands, because the CSS framework uses oklab. If there is a good reason why it should not work out of the box, it would be great to have documentation, an alternative conversion, a flag or something that works for people that are not experts in color spaces ;)
Keep up the good work and thanks again for the explanation.
There is nothing out of the box that is broken, but the current behavior for precision should be improved.
If you use the toString
output to serialize to CSS values, you will be fine:
I have to put the variable into a CSS variable without the oklch part, but I guess I can just use the "serializeNumber" function for the coords.
@facelessuser could you please edit the title of this issue to something that reflects the actual issue and apply suitable labels? Thanks!
Thanks @facelessuser! Any thoughts on how to fix this?
It seems to me we should check whether the absolute value of the number is less than 0.5 E-precision
, in other words if it would round to zero at that precision.
So for example if precision is 6
, 1.5e-16
is less than 0.0000005
so the last reported digit would round down to 0, not up to 1.
@LeaVerou I'm guessing it is how the language serializes very small floats, I ran into the same thing with Python. You can strictly control the serialization. I have some ideas. I'll post back once cobble something together.
The thing is, this only happens when people read the coords directly, which is not really something we can intercept, and we don't want to lose any precision in our internal representation. When serializing, we do limit precision.
Solutions I see:
color.coords
a getter that also adjusts precision. A big downside is that this won't work with the object literal version that supports tree-shaking. And it's even unclear how other modules will use this, since private properties are only accessible within a class. So let's not.Symbol
property, and color.coords
a getter. This works better for modularization, since the Symbol
property can be accessed from anywhere, as long as there's a reference to the Symbol. It also is possible to work for object literals, albeit a bit clumsy. getAll()
function to be a dictionary, with {space, precision}
options, and simply use that if you need to bound precision. Also make the color space optional (in fact this is so low hanging fruit I think I'll just commit it directly). Revamp our docs to use .getAll()
rather than .coords
directly. Similar overloading to get()
as well. I think this avenue would be my preference, since it does not affect the Color representation at all, is far more explicit, and customizable.@LeaVerou Okay, so I'm looking at two different things.
toPrecision
needs a fix. This is similar to what I do in my own color library.
export function toPrecision (n, precision) {
if (n === 0) {
return 0;
}
let integer = ~~n;
let digits = 0;
if (integer && precision) {
digits = ~~Math.log10(Math.abs(integer)) + 1;
}
const multiplier = 10.0 ** (precision - digits);
return Math.floor(n * multiplier + 0.5) / multiplier;
}
Do you want scientific notation output during serialization? If not, you could do this:
export function serializeNumber (n, {precision, unit }) {
n = toPrecision(n, precision);
let integer = ~~n;
if (integer && precision) {
precision = Math.max(precision - (~~Math.log10(Math.abs(integer)) + 1), 0);
}
return n.toFixed(precision) + (unit ?? "");
}
Now you get sane results. Again, the removal of scientific notation from serialization doesn't have to be included.
Solutions I see:
- Make the internal coords private, and make
color.coords
a getter that also adjusts precision. A big downside is that this won't work with the object literal version that supports tree-shaking. And it's even unclear how other modules will use this, since private properties are only accessible within a class. So let's not.- Make the internal coords private via a
Symbol
property, andcolor.coords
a getter. This works better for modularization, since theSymbol
property can be accessed from anywhere, as long as there's a reference to the Symbol. It also is possible to work for object literals, albeit a bit clumsy.- Overload the second argument of the
getAll()
function to be a dictionary, with{space, precision}
options, and simply use that if you need to bound precision. Also make the color space optional (in fact this is so low hanging fruit I think I'll just commit it directly). Revamp our docs to use.getAll()
rather than.coords
directly. Similar overloading toget()
as well. I think this avenue would be my preference, since it does not affect the Color representation at all, is far more explicit, and customizable.
I think coords should always return the actual coords. If they want to get a certain precision, they can use the utility toPrecision
which would now work properly. Serialization applies the requested precision. This is of course just my opinion.
Updated the code in my original post to fix some issues. If I moved forward with any of this, I'd obviously run some more tests.
I have a PR for the precision fix up: https://github.com/color-js/color.js/pull/416.
If it is desired to force serialization to not use scientific notation, just let me know; otherwise, I will keep changes to just to the precision fix.
I guess the most important example after the fix:
> new Color('white').to('oklch').toString()
'oklch(100% 0 none)'
> new Color('black').to('oklch').toString()
'oklch(0% 0 none)'
Coords will still be in the raw form, but you can set their precision as shown below:
> let coords = []
undefined
> new Color('white').to('oklch').coords.forEach(c => {coords.push(Color.util.toPrecision(c, 5))})
undefined
> coords
[ 1, 0, NaN ]
Hi,
I have very little experience with color spaces. I found your solution for a simple theme designer that i build for my app using DaisyUI.
They store the colors in oklch and when the user pickers a rgb color, using a color picker I have to make the conversion. It is actually super easy:
It works fine, except for black and white:
Except black and white (
#00000
and `#ffffff)1.0000000000000002 4.996003610813204e-16 NaN
1.0000000000000002 4.996003610813204e-16 NaN