Csswg-drafts: [css-color-5] When mixing hue, there are two ways round the hue range

Created on 4 Feb 2020  ·  34Comments  ·  Source: w3c/csswg-drafts

https://drafts.csswg.org/css-color-5/#colormix

Mixing hue in lch() leads to ambiguity (or undesirable results) because hue is an angle around the circle, so there are two paths between any two values. In some cases, authors may want a hue mix that traverses the 0deg/360deg point, at other times not.

css-color-5

Most helpful comment

As I was defining the keywords

clockwise is the direction of _decreasing_ hue angle; anticlockwise is the direction of _increasing_ hue angle

I realized that those are stupid names that people will get mixed up so I went with increasing and decreasing instead.

All 34 comments

I have been wondering about that myself. Maybe an extra parameter with two values, to go 'the long way round' or 'the short way round' with shorter path being the default if unspecified? It would also need tie logic, so that both paths are available when the path is exactly 180deg.

Unless explicitly specified, this should always be the shortest arc.

Once the spec is fixed to not mod the angle eagerly, the consistent answer is to just do a linear interpolation from the start to the end angle. If you want a particular direction, you can specify the angles to cause that direction, just like you do with 'rotate'.

the consistent answer is to just do a linear interpolation from the start to the end angle

The big problem is that many authors are going to want to set gradient stops or color mix endpoints either (a) from coordinates provided by a graphical tool, (b) in a different coordinate system [mixing in polar-coordinate CIELAB (L, C, h*) space is apparently the default irrespective of the space in which a color is specified], or (c) from a specific named color not specified adjacent to the usage in a gradient/color mix.

When this happens for e.g. a red and a purple, the gradient should go the short way through red–purple, but from what I can tell according to this proposal will instead go all the way around via orange→yellow→green→blue.

This is going to be very confusing for authors, and difficult or even impossible for them to fix in a straightforward way.


For creators of graphical tools for creating gradients or color mixtures, outputting to css, this is going to create some sticky edge cases which are difficult to work around. The hue angle specified for each color will need to be adjusted by an arbitrary multiple of 360° based on the specified angle of the previous color.

If trying to make a collection of color mixes between several different colors, each original color may need to be specified multiple times for different numbers of turns around the circle, so that mixtures with others end up behaving as expected.

You've mentioned authors using "a graphical tool" consistently in your case for using the shortest path between the angles. Presumably this tool could also convert a gradient from 340° to 20° into -20° to 20° in the generated CSS.

There are 2 cases: (1) an author uses a graphical tool to obtain coordinates, and then copy/pastes those coordinates into a document. That puts the burden of figuring out what multiple of 360° to get the desired behavior onto the end user; (2) the graphical tool generates a whole gradient or color mix or whatever, and outputs CSS. In that case you are complicating the tool programmer’s job and making it more likely that their tools will have undesired behavior.

For e.g. a gradient, the tool is going to need to keep track of the turning number of every gradient stop, and potentially change every one in response to any change to the gradient. What would previously be a local update to 1 value becomes a fiddly global update.

@tab wrote:

Once the spec is fixed to not mod the angle eagerly, the consistent answer is to just do a linear interpolation from the start to the end angle. If you want a particular direction, you can specify the angles to cause that direction, just like you do with 'rotate'

Suppose I have

--start: lch(52% 58.1 22.7) 
--end: lch(56% 49.1 257.1)

// currently, this goes the long way around
color-mix(var(--start) var(--end) hue(75.23%));

//  22.7 * 0.7523 + 257.1 * 0.2477 = 80.76
// mixed result is lch(52% 58.1 80.76)

You seem to be saying that going the other way round I would need to inspect the values of the two custom properties and if needed, add or subtract 360 to make it do what I want.

--otherend: lch(56% 49.1 -102.9)

// now it goes the short way around
color-mix(var(--start) var(--otherend) hue(75.23%));

// 22.7 * 0.7523 + -102.9 * ( 1 - 0.7523 ) = -8.41112
// mixed result is lch(52% 58.1 -8.41112)

while what I am suggesting does not require making adjusted copies of custom properties:

```
// this would go the short way around
color-mix(var(--start) var(--end) hue(75.23% short));
// this would go the long way around
color-mix(var(--start) var(--end) hue(75.23% long));
// new default if unspecified, go the short way around
color-mix(var(--start) var(--end) hue(75.23%));
````

Also, doing calculations in LCH does not mean that the colors being mixed were originally specified in LCH. So tweaking the angle by adding or subtracting 360 is not even an option in those cases.

Consider mixing a hex color (thus, sRGB) with a color in prophoto-rgb:

color-mix(#123456 color(prophoto-rgb 0.9137 0.5882 0.4784));

The two input colors are auto-converted to LCH and then mixed. Relying on the user tweaking hue angle implies always requiring the user to first convert the colors to LCH.

Yeah, there's tradeoffs.

Just doing a linear interpolation is simpler and more consistent with how all other values in CSS transition, and gives authors that are hand-authoring the values simple, straightforward control over how their transition proceeds. It does mean that authors doing generic work on custom property values don't have insight into how it'll work (tho the users of the component in question can always hand-tweak their values to get the desired result), and yes, when your endpoints aren't in a cylindrical space you don't have any control.

Doing a "shortest-path" interpolation is a break from that: it might be more commonly what's desired, but it means we have to make an arbitrary decision for 180deg separation, and we have to add further controls to let the author opt into the other modes when that's desired (at least four settings, as {longest path, shortest path}×{CW when 180deg, CCW when 180deg} are all possible).

After reading Chris's posts, I'm going to change my opinion. Under any other circumstances I'm very strongly for normal linear interpolation like we do everywhere else, but for color-mix, I think you have to take the shortest route.

The critical difference is that colors are likely being mixed in a different space to the one they're specified - even if both colors are specified in the same space. So the user has no real control over the components being mixed.

I expect the vast, vast majority of people using this will be doing so in RGB, and most of them won't have even heard of Lch. If someone tries to mix from green to yellow and find it goes "the long way round", it's going to seem wrong and won't be obvious how to solve it.

One solution is to explain the Lch colorwheel and give them a flag to set; another is for them to specify both colors in Lch. Neither is good.

I think the principle of least surprise requires color-mix to interpolate in the shortest direction, certainly when either color is _not_ specified in Lch. Myself, I think the 180° decision should be arbitrary and fixed (e.g. to clockwise).

If both colors are Lch to start with, you could go either way - shortest path for consistency, normal interpolation for full control. I lean towards the latter, as it means for most people in most colorspaces it will do the right thing, and those that really care about the details can specify their colors in Lch.

So I'm thinking of an optional second argument to the hue adjuster. Instead of

hue: 'hue(' <percent> ')'

it will be

hue: 'hue(' <percent> [shorter | longer | clockwise | anticlockwise]? ')'

with the default, if omitted, being 'shorter' and the direction, if the hue difference is exactly 180deg, being 'clockwise'. So you get what you want most of the time, and you can get exactly what you want by being specific.

shorter and longer still have the problem that they're ambiguous if the difference is 180deg; we'd either have to make an arbitrary choice or make the grammar [shorter | longer] || [clockwise | anticlockwise].

@tabatkins wrote:

shorter and longer still have the problem that they're ambiguous if the difference is 180deg;

which is why I wrote, earlier:

and the direction, if the hue difference is exactly 180deg, being 'clockwise'

I would swear I didn't read that, but it's in my email client, so it was part of the original too. Sorry about that. ^_^

After playing a little bit with interpolation code, I stumbled on the same issue @svgeesus is describing, and it's nasty.
Consider this (actual real example with real colors):

--color-red: hsl(0 80% 50%);
--color-blue: hsl(210 80% 55%);
--color-red-blue: color-mix(--color-red, --color-blue); /* using default lch interpolation */

--color-red is hsl(0 80% 50%) so lch(49.857% 91.826 38.012) in LCH.
--color-blue is hsl(210 80% 55%) so lch(56.522% 55.117 267.397) in LCH
Therefore, --color-red-blue is:

  • With simple linear interpolation: lch(53.19% 73.471 152.704) which is hsl(154.025 100.002% 28.685%) (after gamut mapping).
  • With shortest arc interpolation: Blue becomes lch(57% 55 -93) (hsl(210, 65.455%, 55%)) and their midpoint is lch(52.706%, 64.871, 332.17) which is hsl(309 61% 54%).

Green is certainly not what I have in mind when combining red and blue. And basically, based on what angle you start with, you could get any angle back. The result is nonsensical, and if your color is not lch there is no way to control that.

My calculations:
image

As I was defining the keywords

clockwise is the direction of _decreasing_ hue angle; anticlockwise is the direction of _increasing_ hue angle

I realized that those are stupid names that people will get mixed up so I went with increasing and decreasing instead.

I realized that those are stupid names that people will get mixed up so I went with increasing and decreasing instead.

Indeed!

The hue adjuster takes optional keywords, because there are two ways around the hue circle. If no keyword is specified, it is as if ''shorter'' were specified. ''increasing'' is the direction of increasing hue angle; ''decreasing'' is the direction of decreasing hue angle. If the hue difference is exactly 180 degrees,
it is as if ''decreasing'' were specified.

The recent edits are a good start, but there are still a bunch of things we need to define in regards to how these algorithms work.

If interpolating between e.g. -360 and 720, which of those keywords give us 3 rainbows? It's obvious that shorter doesn't, but what about all the others? Does longer give you one or three rainbows? Does longer just mean "the long arc around the hue wheelor "unclipped angles as specified"". And if the former (which seems more reasonable), do we need another keyword that just does dumb numerical interpolation between the coordinates as specified? I can't really think of use cases, but that doesn't mean there aren't any.

I wonder if these are correct (and optimal) for the pre-interpolation fixup. If so, I can put them in the spec.

// angle1, angle2 are hue angles in degrees
angle1 = ((angle1 % 360) + 360) % 360; // constrain to [0, 360)
angle2 = ((angle1 % 360) + 360) % 360; // constrain to [0, 360)


// Increasing:
if (angle2 < angle1) {
    angle2 += 360;
}

// Decreasing:
if (angle1 < angle2) {
    angle1 += 360;
}

// Longer:
if (angle2 - angle1 < 180) {
    angle2 += 360;
}
else if (angle2 - angle1 > -180) {
    angle1 += 360;
}

// Shorter:
if (angle2 - angle1 > 180) {
    angle1 += 360;
}
else if (angle2 - angle1 < -180) {
    angle2 += 360;
}

The recent edits are a good start, but there are still a bunch of things we need to define in regards to how these algorithms work.

Alright, since these seem to work in my experiments, and nobody has expressed any dissent about them, I'm keen to put them in the spec, and clarify that longer and shorter still start with angle constraining to [0, 360). I think we should also have a value that leaves the values as specified and does dumb interpolation (raw? intact? specified?) if that's possible implementation-wise. I'm not sure however if color-mix() is the right place for all this, we do interpolation in many other places.
I'm thinking of adding an interpolation section describing how colors interpolate, optional interpolation parameters if the context supports them, and other things like e.g. does gamut mapping happen before or after interpolation?
Thoughts?

Agenda+ to discuss whether longer, increasing, decreasing also constrain the arc to < 360 degrees, and if so, whether we need a hue adjuster for "as specified".

I'm thinking of adding an interpolation section describing how colors interpolate, optional interpolation parameters if the context supports them, and other things like e.g. does gamut mapping happen before or after interpolation?

I agree it makes sense to split this out into another sections, so it can then be referenced both in Color 5 and also outside it.

I suggest gamut mapping should happen before interpolation. This is because we wish to avoid multiple mapping stages, so it should happen as late as possible and ideally, once.

I suggest gamut mapping should happen before interpolation. This is because we wish to avoid multiple mapping stages, so it should happen as late as possible and ideally, once.

I'm not so sure. Given that in CSS gamut mapping has to be per-color and not perceptually for a whole image, doing gamut mapping late means you can get bands of near solid color and general perceptual non-uniformity, even in perceptually uniform spaces.
OTOH, even if we do gamut mapping prior to interpolation, it doesn't guarantee that all intermediate colors will be in gamut, especially for polar spaces like LCH.

In any case, gamut mapping should probably be a separate issue. AFAIK we are not defining this at all right now and browsers just clip to 0-100% in current color formats.

I agree the gamut mapping is a large and separate issue.

The mathematical definitions proposed by @LeaVerou are working well for me in testing.

I went ahead and added an Interpolation section for now.
@smfr let me know if this solves your issue!

Two comments on the pseudo-javascript in the Hue interpolation section:

First, above you wrote:

// angle1, angle2 are hue angles in degrees
angle1 = ((angle1 % 360) + 360) % 360; // constrain to [0, 360)
angle2 = ((angle1 % 360) + 360) % 360; // constrain to [0, 360)

I think it would be good to include this explicitly in the definitions of shorter, longer, increasing, and decreasing, both since I think that's the intent, and I think it's the intent that it not be done for specified.

Second, I think the pseudo-code for longer is incorrect. For example, it adjusts θ₁=315 and θ₂=45 using the first conditional branch into θ₂=405 and θ₁=315, when I think it should be left untouched. I think the code should instead be (in addition to the change in my first comment):

if (θ₂ > θ₁ && θ₂ - θ₁ < 180) {
  θ₁ += 360;
}
else if (θ₁ > θ₂ && θ₁ - θ₂ < 180) {
  θ₂ += 360;
}

(Though it's not clear to me how longer should handle the case of hue angles that are identical to start with!)

... or how shorter should handle hue angles that differ by exactly 180.

LGTM. For readability, I would change it to:

if (0 < θ₂ - θ₁ >  && θ₂ - θ₁ < 180) {
  θ₁ += 360;
}
else if (0 < θ₁ - θ₂ && θ₁ - θ₂ < 180) {
  θ₂ += 360;
}

or even (since this is pseudo-JS):

if (0 < θ₂ - θ₁ < 180) {
  θ₁ += 360;
}
else if (0 < θ₁ - θ₂ < 180) {
  θ₂ += 360;
}

Earlier, I wrote:

with the default, if omitted, being 'shorter' _and the direction, if the hue difference is exactly 180deg, being 'clockwise'._

The clockwise keyword soon became the more descriptive decreasing. I agree that this logic should be apparent from the pseudo-code.

Done in 4e88f2c4ac6983857fce23599f2f42d03d3d81c1, forgot to cross-link in commit message.

The CSS Working Group just discussed [css-color-5] When mixing hue, there are two ways round the hue range, and agreed to the following:

  • RESOLVED: Publish a version with all keywords but longer

The full IRC log of that discussion
<dael> Topic: [css-color-5] When mixing hue, there are two ways round the hue range

<dael> github: https://github.com/w3c/csswg-drafts/issues/4735

<dael> leaverou: When interpolate between hues usually you don't want interpolate in same way. If going between hue 0 and hue 400 you don't want a whole rainbow

<dael> leaverou: What we put in spec is by dfault use shortest arc which does expected in common. Have keywords for longest arc etc and also as-specified keyword to allow raw interp

<dael> leaverou: Wasn't sure if all needed. Esp specified one. If impl want to store value as normalized keyword doesn't allow

<dael> leaverou: I put algo in spec which tweaked by dbaron. Good to get sanity check.

<smfr> https://drafts.csswg.org/css-color-5/#hue-interpolation

<Rossen_> q?

<dael> fantasai: Can you summerize the proposal?

<dael> leaverou: Do we need all 5 keywords?

<dael> leaverou: We need shorter b/c that's what you expect in most cases. Do we need specified which is interp as specified so if you go between 0 and 720 2 rainbows. Need increasing, decreasing, longest or is that completist

<dael> fantasai: Are there use cases? We can add keywords. If there's not a use case might want to note possibility for future reference in case we need to add later. If not a use case don't need to add.

<dael> fantasai: I think it's usefult o think of all and makes sure keywords are a set that make sense even if we only include 1 or 2 in spec

<dael> dbaron: Intent is these would eventually apply to all gradients, animations, and color mix funct or only some?

<dael> leaverou: Good to design with that in mind. Not sure how text for animation snad gradients but if we have a syntax making sense it would be good to have the option

<miriam> q+

<astearns> for gradients and animations the workaround would be to add more steps/stops to mimic the non-short behavior?

<dael> fantasai: My suggestion is draft all in spec, put an issue in saying we're not sure if we need all and we might limit to a subset with the subset that makes sense to you and also note might expand to gradients. Encourage people to think what that would look like

<tantek> +1 to publishing at least one draft with more keywords to get the ideas published

<dael> fantasai: Early stage WD so makes sense to put ideas and poke at them with people like Una to make cases

<leaverou> http://localhost:8002/csswg-drafts/css-color-5/Overview.html#hue-interpolation

<leaverou> https://drafts.csswg.org/css-color-5/#hue-interpolation

<dael> leaverou: Does math make sense? This is the section ^

<florian> q+

<Rossen_> ack miriam

<dael> miriam: THinking of specified I'd have use cases when comes to gradient. As pointed out in chat that could be do with extra stops.

<dael> miriam: Can't think of cases when mixing colors. I don't know if that's separate but might be. Math makes sense. Shorter and longer fall apart at 180 which maybe implies need to determine direction without them

<Rossen_> ack florian

<dael> florian: I haven't reviewed math for correctnss, but intuitive seems right. Longer seems least useful. Wanting longer for being longer seems odd. Might pick if gives right thing.

<miriam> +1 to longer being less useful than increase/decrease

<dael> florian: Approach about putting in spec now with note for use cases sounds good

<dael> dbaron: On math have a PR to tweak. I think set notation doesn't match pseudo code and I think pseudo code is right. I have some weaks for 180 case but it's not clear that's what we want

<dael> leaverou: 180 chris said we can pick one as long as it's well defined. Doesn't matter increasing or decreasing

<dael> florian: Makes sense. If you have a preference you can say it.

<dael> fantasai: We use 'closest' in radial gradients so maybe that instead of 'shorter'?

<dael> leaverou: Than what longer?

<dael> fantasai: 'father'?

<dael> florian: I don't think longer is needed so I don't mind not having a good replacement

<fantasai> s/father/farthest/

<tantek> near and far, close and distant, short and long ?

<dael> fantasai: We have farthest and closest side

<dael> leaverou: That's differ than angles

<dael> Rossen_: Apart from bikeshedding I hear 2 proposals. 1) let's push a version of the spec with all the keywords initially or as many as we want so we encourage more incubation.

<dael> Rossen_: 2) I hear agreement that longer doesn't seem useful. I didn't hear a use case to prove otherwise.

<dael> Rossen_: I don't want to bikeshed.

<dael> Rossen_: SHould we resolve to keep the keywords becides longer and publish?

<dael> leaverou: I'd rather hear from Una and Adam before we resolve.

<dael> fantasai: This isn't final. We're drafting for dicussion to encourage participation. I think it's fine to put it all in the draft, explain the thoughts, and enougage feedback. We can publish often

<dael> Rossen_: Objections to Publish a version with all keywords but longer?

<fantasai> "Publish early, publish often"

<tantek> +1

<dael> RESOLVED: Publish a version with all keywords but longer

I'd like to experiment with adding these five hue interpolation types to my library, and I wondered — at the hue fixup step, should we also do something about grays, which in LCh have c: 0 and h: 0? The hue value is misleading here, in the absence of a chroma.

In the case of mixing two colors, the achromatic one can inherit the hue from the other color, if it has chroma. But if we were to extend hue interpolation to gradients, which accept more than one color stop, how should hues for achromatic colors be handled?

__Edit:__ Oops, found a separate discussion in #4928

I've added an interactive visualization of the various hue fixup methods, and changed the fixup algorithms to work with any number of hues. Here are the formulas I use currently; they need to be tested more thoroughly, but at first blush they seem correct.

// shorter: 
θ₂ = Math.abs(θ₂ - θ₁) <= 180 ? θ₂ : θ₂ - 360 * Math.sign(θ₂ - θ₁);

// longer: 
θ₂ = Math.abs(θ₂ - θ₁) >= 180 || θ₂ === θ₁ ? θ₂ : θ₂ - 360 * Math.sign(θ₂ - θ₁);

// increasing:
θ₂ = θ₂ >= θ₁ ? θ₂ : θ₂ + 360 * (1 + Math.floor(Math.abs(θ₂ - θ₁) / 360));

// decreasing:
θ₂ = θ₂ <= θ₁ ? θ₂ : θ₂ - 360 * (1 + Math.floor(Math.abs(θ₂ - θ₁) / 360));

In the case of longer, repeated equal values produce a zero-width interval, to remain consistent with the other methods, but that only works in some cases:

[0, 190, 190, 360] // => [0, 190, 190, 0]
[0, 160, 160, 360] // => [0, -200, 160, 360]

__Edit:__ To get it to work consistently, I ended up converting the list of hues from absolutes to relative values (deltas), apply the fixup rules, then convert back to absolutes.

Trying to find justifying use-cases for each of the fixup methods, a couple of questions came up:

  • To allow specified to produce more than "one rainbow", does that mean that hsl(), lch() etc. will not normalize their hue to the interval [0, 360)?
  • Should there be a normalized fixup method that just normalizes the hues? (like specified but with normalized hues)
Was this page helpful?
0 / 5 - 0 ratings