---
icon: lucide/spline
---
# Color Interpolation

Interpolation is a type of estimation that finds new data points based on the range of a discrete set of known data
points. When used in the context of color, it is finding one or more colors that reside between any two given colors.
This is often used to simulate mixing colors, creating gradients, or even create color palettes.

ColorAide provides a number of useful utilities based on interpolation.

## Linear Interpolation

> [!success] Linear interpolation is registered in `Color` by Default

One of the most common, and easiest, ways to interpolate data between two points is to use linear interpolation. An easy
way of thinking about this concept is to imagine drawing a straight line that connects two colors within a color space.
We could then navigate along that line and return colors at different points to simulate mixing colors at various
percentages or return the whole range and create a continuous, smooth gradient.

To further illustrate this point, the example below shows a slice of the Oklab color space at a lightness of 70%. On
this 2D plane, we select two colors: `#!color oklab(0.7 0.15 0.1)` and `#!color oklab(0.7 -0.03 -0.12)`. We then connect
these two colors with a line. We can then select any point on the line to simulate the mixing of these colors. 0% would
yield the first color, 100% would yield the second color, and 50% would yield a new color:
`#!color Color.interpolate(['oklab(0.7 0.15 0.1)', 'oklab(0.7 -0.03 -0.12)'])(0.5)`.

![Linear Interpolation](images/linear-interpolation.png)
/// figure-caption
Interpolation performed at 50%
///

The `interpolate` method allows a user to create a linear interpolation function using two or more colors. By default, a
returned interpolation function accepts numerical input in the [domain](#domains) of [0, 1] and will cause a new color
between the specified colors to be returned.

By default, colors are interpolated in the perceptually uniform Oklab color space, though any supported color space can
be used instead. This also applies to all methods that use interpolation, such as [`discrete`](#discrete-interpolation),
[`steps`](#steps), [`mix`](#mixing), etc.

As an example, below we create an interpolation between `#!color rebeccapurple` and `#!color lch(85% 100 85)`. We then
step through values of `0.0`, `0.1`, `0.2`, etc. This returns colors at various positions on the line that connects
the two colors, `0` returning `#!color rebeccapurple` and `1` returning `#!color lch(85% 100 85)`.

```py play
i = Color.interpolate(["rebeccapurple", "lch(85% 60 85)"], space='lch')
[i(x / 10).to_string() for x in range(10 + 1)]
```

If we create enough steps, we can create a gradient.

```py play
i = Color.interpolate(
    ["rebeccapurple", "lch(85% 60 85)"],
    space='lch'
)
```

## Piecewise Interpolation

Piecewise interpolation takes the idea of linear interpolation and then applies it to multiple colors. As drawing a
straight line through a series of points greater than two can be difficult to achieve, piecewise interpolation creates
straight lines between each color in a chain of colors.

![Piecewise Interpolation](images/piecewise-interpolation.png)

```py play
Color.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'])
```

When the `interpolate` method receives more that two colors, the interpolation will utilize piecewise interpolation
and interpolation will be broken up between each pair of colors. The function, just like when interpolating between two
colors, still operates by default in the domain of [0, 1], only it will now apply to the entire range of colors.

Piecewise interpolation simply breaks up a series of data points into segments in order to apply interpolation
individually on each segment.

This approach generally works well, but since the placement of colors may not be in a straight line, you will often
have pivot points and the transition may not be quite as smooth at these locations.

## Continuous Interpolation

> [!success] Continuous interpolation is registered in `Color` by Default

In this document, we use the term "continuous" in two ways when talking about interpolation: continuous vs
[discrete ](#discrete-interpolation) and the interpolation method whose literal name is `continuous`.

The `continuous` interpolation approach, is simply a piecewise, linear interpolation method that interpolates defined
channels continuously across two or more segments with undefined channels, essentially taking into account all the
segments in the piecewise chain when processing undefined channels. This differs from the default CSS piecewise
interpolation approach which only takes into context two segments at any given time. What this means is that if you have
multiple colors, and one or more of the colors have the same channel undefined, the colors with that channel defined
will be interpolated across the undefined gaps spanning one or more segments in the chain.

In this example, we have 3 colors. The end colors both define lightness, but the middle color is undefined. We can see
that when we use normal, linear piecewise interpolation that we get a discontinuity. But with `continuous`
interpolation, we get a smooth interpolation of the lightness through the undefined channel.

```py play
colors = [
    Color('oklab', [0, 0, 0]),
    Color('oklab', [NaN, -0.03246, -0.31153]),
    Color('oklab', [1, 0, 0])
]
Color.interpolate(colors, space='oklab', method='linear')
Color.interpolate(colors, space='oklab', method='continuous')
```

Now, if we have colors on the side that are not between two defined colors, all those colors will adopt the defined
value of the first color on either the right or left that is defined. In the example below, we have a single color with
all components defined, but all the colors to the left are missing the lightness. Again, in the normal `linear`
approach, we see a discontinuity, but with the `continous` approach, all colors with the undefined lightness will assume
the lightness of the defined color.

```py play
colors = [
    Color('oklab', [NaN, 0.22486, 0.12585]),
    Color('oklab', [NaN, -0.1403, 0.10768]),
    Color('oklab', [0.45201, -0.03246, -0.31153])
]
Color.interpolate(colors, space='oklab', method='linear')
Color.interpolate(colors, space='oklab', method='continuous')
```

This approach is useful for more natural, multi-color interpolations and is used as foundation for our spline
interpolation approaches which often require the context of at least four colors when applying their logic.

## Spectral

> [!new] New in 8.8

> [!failure] Spectral interpolation is not registered in `Color` by Default

![Spectral](images/spectral-interpolation.png)

```py play
from coloraide import Color
from coloraide.interpolate.spectral import Spectral

class Custom(Color): ...

Custom.register(Spectral())

Custom.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='spectral')
```

Spectral interpolation attempts to mix colors similar to how they are done in paint using Kubelka-Munk theory.

```py play
red = Color('rgb(128, 2, 46)').mix('white', 0.25, method='spectral')
yellow = Color('rgb(252, 211, 0)').mix('white', 0.25, method='spectral')
blue = Color('rgb(13, 27, 68)').mix('white', 0.25, method='spectral')
Wheel(Color.steps([red, yellow, blue, red], steps=13, method='spectral', out_space='srgb')[:-1])
Steps(Color.steps([red, yellow, blue, red], steps=13, method='spectral', out_space='srgb')[:-1])
```

> [!tip]
> Unlike other interpolation methods, the default interpolation space is treated as XYZ D65 instead of Oklab. This is
> true regardless of what the default for the `Color` object is. If a space is explicitly defined, the color will be
> output in that space (unless `output_space` overrides it), but the color will still be processed in XYZ D65.

Light, on its own, doesn't mix like pigments due to the way pigments absorb and scatter light. [Kubelka-Munk theory](
https://en.wikipedia.org/wiki/Kubelka%E2%80%93Munk_theory) is a fundamental approach to modelling the appearance of
paint films and predicting this absorption and scattering. Utilizing Kubelka-Munk theory, colors can be simulated to
mix more like paints.

/// tab | Spectral Mix

```py play
c1 = Color('#002185')
c2 = Color('#FCD200')
Color.interpolate([c1, c2], method='spectral')
Steps(Color.steps([c1, c2], method='spectral', steps=9))
```
///

/// tab | RGB Mix
```py play
c1 = Color('#002185')
c2 = Color('#FCD200')
Color.interpolate([c1, c2], space='srgb')
Steps(Color.steps([c1, c2], space='srgb', steps=9))
```
///

It is important to note that actual paint mixing is more complicated than what we do here in ColorAide. Every paint has
its own specific properties related to the absorption and scattering of light due to the properties of the paint
particles. Since we are not basing our approach off real paint data, we do not have these specific constants and opt
for a simplified, single-constant approach when applying Kubelka-Munk theory, opposed to the more accurate two-constant
approach.

Paint-like mixing, as is implemented here, is a more simplified approach that approximates the look and feel of paint
mixing in general. This is based on the work of [Scott Burns][scott-burns-reflect] who developed a way to approximate
reflectance curves from sRGB triplets. The project [Spectral.js][spectral-js] ran with this idea and applied
Kubelka-Munk theory to transform these reflectance curves into scattering/absorption coefficients so that the curves
could be mixed linearly, producing paint like mixing. ColorAide adopts this same approach with minor modifications.

In principle, if we were to approximate the reflectance curves of `#!color rgb(255 0 0)`, `#!color rgb(0 255 0 )`, and
`#!color rgb(0 0 255)`, using the approach by Scott Burns, such that they also summed up to 1, we could likely calculate
reflectance curves from these three colors and approximate reflectance curves for almost any other color in the sRGB
gamut. Unfortunately, this creates a non-linearity problem that is difficult to solve with high precision, so instead,
we calculate primary reflectance curves from 7 colors so that we can generate smooth approximation for almost any color
within the sRGB gamut.

Primary\ Reflectance\ Curves |
---------------------------- |
`#!color rgb(255 0 0)`       |
`#!color rgb(0 255 0)`       |
`#!color rgb(0 0 255)`       |
`#!color rgb(0 255 255)`     |
`#!color rgb(255 0 255)`     |
`#!color rgb(255 255 0)`     |
`#!color rgb(255 255 255)`   |

![Primary Reflectance Curves](images/reflectance-curves.png)
/// figure-caption
Reflectance curves of white, cyan, magenta, yellow, red, green, and blue as approximated using the methods proposed by
Scott Burns.
///

With our primary colors selected, and the reflectance curves created for each one, we can use these curves to
approximate smooth reflectance curves for almost any color within the sRGB gamut by deconstructing a given color into
concentrations of these primary reflectance curves and then combining them into a new curve that represents the color.

![Decomposition of Color Reflectance Concentrations](images/reflect-orange.png)
/// figure-caption
Orange decomposed into white, cyan, magenta, yellow, red, green, and blue reflectance curves and then reconstructed into
its own curve.
///

With the ability to represent almost any color within our gamut as a reflectance curve, we then can mix colors by
generating their curve and then applying Kubelka-Munk theory by converting those curves into absorption and scattering
coefficients and mixing them linearly. Once mixed, we can transform them back to a reflectance curve and then back to
our target color space.

![Reflectance Mix](images/reflect-mix.png)
/// figure-caption
Mixing a blue and yellow color with Kubelka-Munk theory.
///

It should be noted that using this approach, the mixing of the colors can turn out a bit dark. The author of Spectral.js
noticed this and found that weighting the mix such that more luminous colors had more weight produced more natural
mixing.

One way that ColorAide deviates from the original implementation is that we ensure sane behavior for colors that extend
beyond the sRGB gamut.

Using the method as outlined previously works for most colors within the sRGB gamut, but doesn't precisely cover every
color perfectly, and will be less usable for colors outside the sRGB gamut. This is because Kubelka-Munk does not handle
reflectance values above 1 well.

Borrowing an idea from the [paper][mixbox-paper] released by the creators of [Mixbox][mixbox], ColorAide goes further
and first ensures a given approximated color curve does not exceed a reflectance of 1 at any point within the curve.
Then we calculate the residual XYZ values between the original color and what are limited reflectance curve can produce.
The residuals of the colors being mixed are interpolated directly within the XYZ space and added back into the final
result once the reflectance curves have been mixed. This allows for sane handling for wider gamut colors.

```py play
c1 = Color('color(display-p3 0 0 1)')
c2 = Color('color(display-p3 1 1 0)')
Color.interpolate([c1, c2], method='spectral')
Steps(Color.steps([c1, c2], method='spectral', steps=9))
```

Spectral mixing comes in too flavors: normal and continuous.

```py play
colors = [
    Color('#00000000'),
    Color('#002185').set('alpha', NaN),
    Color('#FFFFFFFF')
]
Color.interpolate(colors, method='spectral')
Color.interpolate(colors, method='spectral-continuous')
```

To enable `spectral-continuous`, simply register register it like you would `spectral`.

```py play
from coloraide import Color
from coloraide.interpolate.spectral import SpectralContinuous

class Custom(Color): ...

Custom.register(SpectralContinuous())

Custom.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='spectral-continuous')
```

## Cubic Spline Interpolation

Linear interpolation is nice because it is easy to implement, and due to its straight forward nature, pretty fast. With
that said, it doesn't always have the smoothest transitions. It turns out that there are other piecewise ways to
interpolate that can yield smoother results.

Inspired by some efforts seen on the [web][catmull-observe] and in the great JavaScript library
[Culori](https://culorjs.org), ColorAide implements a number of spline based interpolation methods.

Because splines require taking into account more than two colors at a time, all spline based interpolation methods are
built off of the [`continuous` interpolation](#continuous-interpolation) approach of handling undefined values.

### B-Spline

> [!success] B-Spline interpolation is registered in `Color` by Default

![B-spline](images/bspline-interpolation.png)

```py play
Color.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='bspline')
```

B-spline is a piecewise spline similar to Bezier curves. It utilizes "control points" that help shape the interpolation
path through a series of colors. Like Bezier Curves, the path is not guaranteed to pass through the control points.

> [!tip]
> By default, this approach uses `not-a-knot` end conditions which doesn't introduce end point bias, but if you'd like to
> ensure the interpolation passes through the end points, you can set `end_cond` to `natural`.
>
> If you need an interpolation to pass through all points, see the [Natural spline](#natural).

### Natural

> [!success] Natural interpolation is registered in `Color` by Default

![Natural](images/natural-interpolation.png)

```py play
Color.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='natural')
```

The "natural" spline is the same as the B-spline approach except an algorithm is applied that uses the colors as data
points and calculates new control points such that the interpolation passes through all the data points. This means
that the path will pass through all the colors. The resultant spline has the continuity and properties of a natural
spline, hence the name.

- **Interpolation**: Passes through all control points.
- **Continuity**: C^2^ continuous (both first and second derivatives are continuous).
- **Local control**: No, changing one point affects the entire curve.
- **Boundary Conditions**: Uses "natural" conditions (second derivative = 0 at endpoints).

Natural provides a nice, smooth curve, but may have unwanted oscillations. Occasionally it can cause the interpolation
path to pass out of gamut if interpolating on an edge.

It can be used by specifying `natural` as the interpolation method.

> [!tip]
> By default, end conditions are set to `natural`, and while not recommended, you can set `end_cond` to `not-a-not`, but
> that would undo the behavior that constrains the interpolation to pass through the end points, a characteristic of the
> natural boundary conditions.

### Catmull-Rom

> [!failure] Catmull-Rom interpolation is not registered in `Color` by Default

![Catmull-Rom](images/catmull-rom-interpolation.png)

```py play
from coloraide import Color
from coloraide.interpolate.catmull_rom import CatmullRom

class Custom(Color): ...

Custom.register(CatmullRom())

Custom.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='catrom')
```

The Catmull-Rom spline is another "interpolating" spline that passes through all of its data points, similar to the
"natural" spline in that it passes through all the points, but it but does not share the same continuity and properties
of a "natural" spline.

- **Interpolation**: Passes through all control points.
- **Continuity**: C^1^ continuous (first derivative is continuous).
- **Local Control**: Adjustments of one point won't affect the entire curve.

With Catmull-Rom, changing one point won't affect the entire curve, but has a more local effect. Depending on the data
set, can have more overshoot than Natural and can exhibit unwanted oscillation.

> [!tip]
> By default, `catrom` uses `not-a-knot` end conditions which do not introduce end point bias, but you can set
> `end_cond` to `natural` if preferred which will guide the interpolation through the end points in a more linear way at
> the ends.

### Monotone

> [!success] Monotone interpolation is registered in `Color` by Default

![Monotone](images/monotone-interpolation.png)

```py play
Color.interpolate(['#51A5E6', '#79AF58', '#92A854', '#A097BE', '#CF8E38', '#CF83A1', '#F76B5C'], method='monotone')
```

The "monotone" spline is a piecewise interpolation spline that passes through all its data points and helps to preserve
monotonicity. As far as we are concerned, the important thing to note is that it greatly reduces any overshoot or
undershoot in the interpolation.

- **Interpolation**: Passes through all control points.
- **Continuity**: C^1^ continuous (first derivative continuous).
- **Local control**: Adjustments of one point won't affect the entire curve.

Much like Catmull-Rom, adjusting one point won't affect the entire curve, but it ensures the interpolated curve
maintains the increasing or decreasing trend. As previously mentioned, this also prevents oscillations, but can
sometimes be too constrained and give less smooth results.

> [!tip]
> By default, `monotone` uses `not-a-knot` end conditions which do not introduce end point bias, but you can set
> `end_cond` to `natural` if preferred which will guide the interpolation through the end points in a more linear way at
> the ends.

## Discrete Interpolation

So far, we've only shown examples of continuous interpolation methods. To clarify, we are using "continuous" in a
slightly different way than we discussed [earlier](#continuous-interpolation). When we say "continuous" here, we simply
mean that the colors in the interpolation smoothly transition from one color to the other. But when creating charts or
graphs, some times you'd like to categorize data such that a range of values correspond to a specific color. For this,
we can use `discrete`, which like `intrpolate`, returns an interpolation object, but the ranges will be discrete.

By default, ranges are calculated directly form the input colors. So if you had three colors, the interpolation would be
broken up into 3 ranges. Compare this with the "continuous" interpolation we methods we showed earlier.

```py play
Color.discrete(['red', 'green', 'blue'])
Color.interpolate(['red', 'green', 'blue'])
```

If we specify `step`, we can create a larger or smaller color scale using the input colors to interpolate the new color
scale. And we can use any of the aforementioned interpolation methods to help generate this new discrete scale.

```py play
Color.discrete(['red', 'green', 'blue'], steps=5, method='catrom')
```

What makes this really useful is if you combine it with custom domains to process data. By default, the domain is
`[0, 1]`, but we can change this to directly correlate the data with our quantized color samples. For instance, let's
use a series of discrete colors to represent temperature. Additionally, let's use [`domain`](#domains) to associate a
temperature ranges with the given colors. Now when we input a temperature value, it will align with our discrete color
scale.

```py play
i = Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], domain=[-32, 32, 60, 85, 95])
i(-32)
i(40)
i(87)
i(100)
i
```

Additionally, color scales can be limited using the [`padding`](#padding) parameter.

```py play
Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'])
Color.discrete(['blue', 'green', 'yellow', 'orange', 'red'], padding=[0.25, 0])
```

As `discrete()` is built on [`steps()`](#steps), it can take all the same arguments. Check out [`steps()`](#steps) to
learn more.

## Hue Interpolation

In interpolation, hues are handled special allowing us to control the way in which hues are evaluated. By default, the
shortest angle between two hues is targeted for interpolation, but the `hue` option allows us to redefine this behavior
in a number of interesting ways: `shorter`, `longer`, `increasing`, `decreasing`, and `specified`. These hue "fix-ups"
identify all possible ways in which we can interpolate a hue and come from the
[CSS level 4 specification](https://drafts.csswg.org/css-color-4/#hue-interpolation).

> [!note] Specified
> The `specified` fix-up was at one time specified in the CSS Color Level 4 specification, but is no longer mentioned
> there. While CSS no longer supports this hue fix-up, we still do. `specified` simply does not apply any hue fix-up and
> will use hues as _specified_, hence the name.

To help visualize the different hue methods, consider the following evaluation between `#!color hsl(270 50 40)` and
`#!color hsl(780 100 40)`. Below we will demonstrate each of the different hue evaluations and explain what it is that
they do.

/// tab | shorter
`shorter` interpolates along the shortest arc length after normalizing the hues.

![Shorter](images/hue-fixup-shorter.png)

```py play
Color.interpolate(
    ["hsl(270 50 40)", "hsl(780 100 40)"],
    space='hsl',
    hue="shorter"
)
```
///

/// tab | longer
`longer` interpolates along the longest arc length after normalizing the hues.

![Longer](images/hue-fixup-longer.png)

```py play
Color.interpolate(
    ["hsl(270 50 40)", "hsl(780 100 40)"],
    space='hsl',
    hue="longer"
)
```
///

/// tab | increasing
`increasing` interpolates counter clockwise (after normalizing the hues), such that the hues are increasing.

![Increasing](images/hue-fixup-increasing.png)

```py play
Color.interpolate(
    ["hsl(270 50 40)", "hsl(780 100 40)"],
    space='hsl',
    hue="increasing"
)
```
///

/// tab | decreasing
`decreasing` interpolates clockwise (after normalizing the hues), such that the hues are decreasing.

![Decreasing](images/hue-fixup-decreasing.png)

```py play
Color.interpolate(
    ["hsl(270 50 40)", "hsl(780 100 40)"],
    space='hsl',
    hue="decreasing"
)
```
///

/// tab | specified
`specified` interpolates from the first color to the second color without applying any hue fix-ups.

![Specified](images/hue-fixup-specified.png)

```py play
Color.interpolate(
    ["hsl(270 50 40)", "hsl(780 100 40)"],
    space='hsl',
    hue="specified"
)
```
///

## Interpolating with Alpha

Interpolating color channels is pretty straight forward and uses traditional linear interpolation logic, but when
introducing transparency to a color, interpolation uses a concept known as premultiplication which alters the normal
interpolation process.

Premultiplication is a technique that tends to produce better results when two colors have differing transparency. It
essentially accounts for the transparency and uses it to _weight_ how may a given color channel will contribute to the
interpolation. A more transparent color's channels will naturally contribute less.

Consider the following example. Normally, when transitioning to a "transparent" color, the colors will be more gray
during the transition. This is because `#!color transparent` is actually black. But when using premultiplication, the
transition looks just as one would expect as the transparent color's channels are weighted less due to the high
transparency.

```py play
Color.interpolate(['white', 'transparent'], space='srgb', premultiplied=False)
Color.interpolate(['white', 'transparent'], space='srgb')
```

As a final example, below we have an opaque orange and a blue that is quite transparent. Logically, the blue shouldn't
have as big an affect on the overall color as it is so faint, and yet, in the un-premultiplied example, when mixing the
colors equally, we see that the resultant color is also equally influenced by the hue of both colors. In the
premultiplied example, we see that orange is still quite dominant at 50% as it is fully opaque.


```py play
Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb', premultiplied=False)
Color('orange').mix(Color('blue').set('alpha', 0.25), space='srgb')
```

If we interpolate it, we can see the difference in transition.

```py play
Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb', premultiplied=False)
Color.interpolate(['orange', Color('blue').set('alpha', 0.25)], space='srgb')
```

There may be some cases where it is desired to use no premultiplication in alpha blending. One could simply be that you
need to mimic the same behavior of a system that does not use premultiplied interpolation. If so, simply set
`premultiplied` to `#!py3 False` as shown above.

## Mixing

> [!tip] Interpolation Options
> Any options not consumed by `mix` will be passed to the underlying `interpolation` function. This includes options
> like `hue`, `progress`, etc.

The `mix` function is built on top of the [`interpolate`](#linear-interpolation) function and provides a simple, quick,
and intuitive simple mixing of two colors. Just pass in a color to mix with the base color, and you'll get an equal mix
of the two.

```py play
Color("red").mix(Color("blue"))
```

By default, colors are mixed at 50%, but the percentage can be controlled. Here we mix the color `#!color blue` into
the color `#!color red` at 20%. With `#!color blue` at 20% and `#!color red` at 80%, this gives us a more reddish color.

```py play
Color("red").mix(Color("blue"), 0.2)
```

As with all interpolation based functions, if needed, a different color space can be specified with the `space`
parameter or even a different interpolation method via `method`. `mix` accepts all the same parameters used in
`interpolate`, though concepts like [stops and hints](#color-stops-and-hints) are not allowed with mixing.

```py play
Color("red").mix(Color("blue"), space="hsl", method='bspline')
```

Mix can also accept a string and will create the color for us which is great if we don't need to work with the second
color afterwards.

```py play
Color("red").mix("blue", 0.2)
```

Mixing will always return a new color unless `in_place` is set `#!py3 True`.

## Weighted Mixing

> [!new] New in 8.8

While [`mix`](#mixing) provides a way to mix any two colors together, `weighted_mix` allows the mixing of any number of
colors.

Instead of using a single percentage to indicate  what color on an interpolation line you desire, `weighted_mix` takes a
list of weights that allows you to specify ratios of colors.

Below, we are going to mix 3 parts orange to 2 parts purple to 1 part white within Oklab.

```py play
Color.weighted_mix(['orange', 'purple', 'white'], [3, 2, 1])
```

By changing the weights, we can change the color.

```py play
from coloraide import algebra as alg
Steps(
    [Color.weighted_mix(['orange', 'purple', 'white'], [3, i, j])
    for i, j in zip(alg.linspace(0, 10, 100), alg.linspace(10, 0, 100))]
)
```

Weights can be any positive value at any scale; weights are relative to each other. Any negative weights are clamped to
zero.

```py play
Color.weighted_mix(['orange', 'purple', 'white'], [1, 2 / 3, 1 / 3])
```

If weights are omitted, each color is considered at full-weight.

```py play
Color.weighted_mix(['orange', 'purple', 'white'])
Color.weighted_mix(['orange', 'purple', 'white'], [1, 1, 1])
```

If more colors are provided than weights, colors without defined weights are assumed to be full weight.

```py play
Color.weighted_mix(['orange', 'purple', 'white'], [0, 1])
```

If mixing within a rectangular space, order of colors shouldn't matter, and the result will be the same.

```py play
Color.weighted_mix(['orange', 'purple', 'green'], [3, 2, 1], space='oklab', hue='longer')
Color.weighted_mix(['purple', 'green', 'orange'], [2, 1, 3], space='oklab', hue='longer')
Color.weighted_mix(['green', 'orange', 'purple'], [1, 3, 2], space='oklab', hue='longer')
```

If mixing in a polar color space, due to the way hues are mixed, the result could differ depending on the order of
colors, especially when mixing in certain hue modes.

```py play
Color.weighted_mix(['orange', 'purple', 'green'], [3, 2, 1], space='oklch', hue='longer')
Color.weighted_mix(['purple', 'green', 'orange'], [2, 1, 3], space='oklch', hue='longer')
Color.weighted_mix(['green', 'orange', 'purple'], [1, 3, 2], space='oklch', hue='longer')
```

While you can specify any interpolation method, most just default to linear logic when not mixing a long sequence of
colors with no observable differences, but methods like spectral mixing still provide unique, novel outputs.

```py play
from coloraide import algebra as alg
colors = ['#002185', '#FCD200', '#FFFFEE']
Steps(colors)
Steps([Color.weighted_mix(colors, [1, 1, i], method='linear') for i in alg.linspace(0.0, 3.0, 9)])
Steps([Color.weighted_mix(colors, [1, 1, i], method='spectral') for i in alg.linspace(0.0, 3.0, 9)])
```

Like normal `mix`, the default space is Oklab, and it supports options such as [`carryforward`](#carrying-forward),
[`powerless`](#powerless-hues), and disabling premultiplication with `premultiply`.

Unlike normal `mix`, it ignore special progress easings, requests to specify domains and padding, and does not perform
extrapolation.

## Averaging

Color averaging is a specialized form of [`weighted_mix`](#weighted-mixing) that takes a list of colors and returns the
average of a rectangular color space, the default being linear sRGB.

![Average RGB](images/avg-rgb.png)

```py play
Color.average(['red', 'green', 'yellow', 'blue'], [1, 1, 1, 1])
```

Rectangular space averaging results will often be comparable to just using `weighted_mix`, but there are some
differences; when performed in a polar spaces, the hue will be returned as the circular mean. Averaging this way will
always return the same results regardless of ordering.

![Average HSL](images/avg-hsl.png)

```py play
Color.average(['red', 'green', 'yellow', 'blue'], space='hsl')
```

It should be noted that when averaging colors with hues which are evenly distributed around the color space, the result
will produce an achromatic hue. When achromatic hues are produced during circular mean, the color will discard
chroma/saturation information, producing an achromatic color.

![Evenly Distributed Angles](images/avg-evenly-distributed.png)

```py play
Color.average(['red', 'green', 'blue'], space='hsl')
```

When averaging with transparency, results will be similar to `weighted_mix` if using premultiplication (the default).
If premultiplication is disabled, averaging will specifically ignore colors with full transparency as such colors
provide no meaningful information to an average. This differs from how `weighted_mix` behaves.

```py play
Steps(
    [
        Color.average(
            [f'color(srgb 0 1 0 / {i / 11})', 'color(srgb 0 0 1)'],
            premultiplied=False, space='srgb-linear'
        )
        for i in range(12)
    ]
)
Steps(
    [
        Color.weighted_mix(
            [f'color(srgb 0 1 0 / {i / 11})', 'color(srgb 0 0 1)'],
            premultiplied=False, space='srgb-linear'
        )
        for i in range(12)
    ]
)
```

Lastly, [`powerless`](#powerless-hues) logic is always applied when averaging. This also differs from `weighted_miz` and
means that implied achromatic colors are treated as such. This is to prevent powerless hues from distorting the color
average.

```py play
Color.average(['hsl(30 0 100)', 'hsl(240 100 50 / 1)'], space='hsl')
Color.weighted_mix(['hsl(30 0 100)', 'hsl(240 100 50 / 1)'], space='hsl')
```

## Steps

> [!tip] Interpolation Options
> Any options not consumed by `mix` will be passed to the underlying `interpolation` function. This includes options
> like `hue`, `progress`, etc.

The `steps` method provides an intuitive interface to create lists of discrete colors. Like mixing, it is also built on
[`interpolate`](#linear-interpolation). Just provide two or more colors, and specify how many `steps` are wanted.

```py play
Color.steps(["red", "blue"], steps=10)
```

If desired, multiple colors can be provided, and steps will be returned for all the interpolated segments. When
interpolating multiple colors, [piecewise interpolation](#piecewise-interpolation) is used (which is covered in more
detail later).

```py play
Color.steps(["red", "orange", "yellow", "green"], steps=10)
```

Steps can also be configured to return colors based on a maximum Delta E distance. This means you can ensure the
distance between all colors is no greater than a certain value.

In this example, we specify the color `#!color color(display-p3 0 1 0)` and interpolate steps between `#!color red`.
The result gives us an array of colors, where the distance between any two colors should be no greater than the Delta E
result of 10.

```py play
Color.steps(
    [Color("display-p3", [0, 1, 0]), "red"],
    space="lch",
    out_space="srgb",
    max_delta_e=10
)
```

`max_steps` can be used to limit the results of `max_delta_e` in case result balloons to an unexpected size. Obviously,
this affects the Delta E between the colors inversely. It should be noted that steps are injected equally between every
color when satisfying a max Delta E limit in order to avoid shifting the midpoint. In some cases, in order to satisfy
both the `max_delta_e` and the `max_steps` requirement, the number of steps may even be clipped such that they are less
than the `max_steps` limit. `max_steps` is set to `#!py3 1000` by default, but can be set to `#!py3 None` if no limit is
desired.

```py play
Color.steps(
    [Color("display-p3", [0, 1, 0]), "red"],
    space="lch",
    out_space="srgb",
    max_delta_e=10,
    max_steps=10
)
```

When specifying a `max_delta_e`, `steps` will function as a minimum required steps and will push the delta even smaller
if the required steps is greater than the calculated steps via the maximum Delta E limit.

```py play
Color.steps(
    [Color("display-p3", [0, 1, 0]), "red"],
    space="lch",
    out_space="srgb",
    max_delta_e=10,
    steps=50
)
```

`steps` uses the color class's default ∆E method to calculate max ∆E, the current default ∆E being ∆E^\*^~ab~. While
using something like ∆E^\*^~00~ is far more accurate, it is a much more expensive operation. If desired, the class's
default ∆E can be changed via subclassing the color object and changing `DELTA_E` class variable or by manually
specifying the method via the `delta_e` parameter.


/// tab | ∆E^\*^~ab~.
```py play
Color.steps(
    [Color("display-p3", [0, 1, 0]), "red"],
    space="lch",
    out_space="srgb",
    max_delta_e=10,
    delta_e="76"
)
```
///

/// tab | ∆E^\*^~00~
```py play
Color.steps(
    [Color("display-p3", [0, 1, 0]), "red"],
    space="lch",
    out_space="srgb",
    max_delta_e=10,
    delta_e="2000"
)
```
///

And much like [`interpolate`](#linear-interpolation), we can use [`stops` and `hints`](#color-stops-and-hints) and any
of the other supported `interpolate` features as well.

```py play
Color.steps(['orange', stop('purple', 0.25), 'green'], method='bspline', steps=10)
```

## Masking

If desired, we can mask off specific channels that we do not wish to interpolate. Masking works by cloning the color
and setting the specified channels as undefined (internally set to `NaN`). When interpolating, if one color's channel
has a `NaN`, the other color's channel will be used as the result, keeping that channel at a constant value. If both
colors have a `NaN` for the same channel, then `NaN` will be returned.

> [!tip] Magic Behind NaN
> There are times when `NaN` values can happen naturally, such as with achromatic colors with hues. To learn more,
> check out [Undefined Handling/NaN Handling](#null-handling).

In the following example, we have a base color of `#!color lch(52% 58.1 22.7)` which we then interpolate with
`#!color lch(56% 49.1 257.1)`. We then mask off the second color's channels except for `hue`. Applying this logic, we
will end up with a range of colors that maintains the same lightness and chroma as the first color, but with different
hues. We can see as we step through the colors that only the hue is interpolated.

```py play
i = Color.interpolate(
    ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask(['lightness', 'chroma', 'alpha'])],
    space="lch"
)
[i(x/10).to_string() for x in range(10)]
```

You can also create inverted masks. An inverted mask will mask all *except* the specified channel.

```py play
i = Color.interpolate(
    ["lch(52% 58.1 22.7)", Color("lch(56% 49.1 257.1)").mask('hue', invert=True)],
    space="lch"
)
[i(x/10).to_string() for x in range(10)]
```

## Easing Functions

When interpolating, whether using linear interpolation or something like B-Spline interpolation, the transitioning
between colors is always linear in time, even if the path to those colors is not. For example, if you are interpolating
between 2 colors and you request a `#!py3 0.5` point on that line, it will always be in the middle. This is because, no
matter how crooked the path, the rate of change on that path is always linear.

By default, ColorAide uses linear transitions when interpolating, but there are times that a different, more dynamic
transition may be desired. This can be achieved by using the `progress` parameter on any of the interpolation related
functions provided by ColorAide.

`progress` accepts an easing function that takes a single `time` input and returns a new `time` input. This allows for a
user to augment the rate of change when transitioning from one color to another. Inputs are almost always between 0 - 1
unless [`extrapolate`](#extrapolation) is enabled and the user has manually input a range beyond 0 - 1. Even a change in
[domain](#domains) will not affect the range as once the domain is accounted for, internally the domain [0, 1] is used.

ColorAide provides 5 basic easing functions out of the box along with `cubic_bezier` which is used to create all of the
aforementioned easing function except `linear`, which simply returns what is given as an input.

> [!tip] Create Your Own Cubic Bezier Easings Online: https://cubic-bezier.com

> [!tip]- | More Common Cubic Bezier Easings
> The following were all acquired from https://matthewlein.com/tools/ceaser.js.
>
> ```py
> ease_in_quad = cubic_bezier(0.550, 0.085, 0.680, 0.530)
> ease_in_cubic = cubic_bezier(0.550, 0.055, 0.675, 0.190)
> ease_in_quart = cubic_bezier(0.895, 0.030, 0.685, 0.220)
> ease_in_quint = cubic_bezier(0.755, 0.050, 0.855, 0.060)
> ease_in_sine = cubic_bezier(0.470, 0.000, 0.745, 0.715)
> ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
> ease_in_circ = cubic_bezier(0.600, 0.040, 0.980, 0.335)
> ease_in_back = cubic_bezier(0.600, -0.280, 0.735, 0.045)
>
> ease_out_quad = cubic_bezier(0.250, 0.460, 0.450, 0.940)
> ease_out_cubic = cubic_bezier(0.215, 0.610, 0.355, 1.000)
> ease_out_quart = cubic_bezier(0.165, 0.840, 0.440, 1.000)
> ease_out_quint = cubic_bezier(0.230, 1.000, 0.320, 1.000)
> ease_out_sine = cubic_bezier(0.390, 0.575, 0.565, 1.000)
> ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
> ease_out_circ = cubic_bezier(0.075, 0.820, 0.165, 1.000)
> ease_out_back = cubic_bezier(0.175, 0.885, 0.320, 1.275)
>
> ease_in_out_quad = cubic_bezier(0.455, 0.030, 0.515, 0.955)
> ease_in_out_cubic = cubic_bezier(0.645, 0.045, 0.355, 1.000)
> ease_in_out_quart = cubic_bezier(0.770, 0.000, 0.175, 1.000)
> ease_in_out_quint = cubic_bezier(0.860, 0.000, 0.070, 1.000)
> ease_in_out_sine = cubic_bezier(0.445, 0.050, 0.550, 0.950)
> ease_in_out_expo = cubic_bezier(1.000, 0.000, 0.000, 1.000)
> ease_in_out_circ = cubic_bezier(0.785, 0.135, 0.150, 0.860)
> ease_in_out_back = cubic_bezier(0.680, -0.550, 0.265, 1.550)
> ```

/// tab | Linear
![Linear](./images/easing_linear.png)
///

/// tab | Ease
![Ease](./images/easing_ease.png)
///

/// tab | Ease In
![Ease In](./images/easing_ease_in.png)
///

/// tab | Ease Out
![Ease Out](./images/easing_ease_out.png)
///

/// tab | Ease In/Out
![Ease In/Out](./images/easing_ease_in_out.png)
///

/// tab | Cubic Bezier
![Ease Cubic Bezier](./images/easing_cubic.png)
///

Here, we are using the default "ease in" and "ease out" easing functions provided by ColorAide.

```py play
from coloraide import ease_in, ease_out

Color.interpolate(
    ["green", "blue"],
    progress=ease_in
)
Color.interpolate(
    ["green", "blue"]
)
Color.interpolate(
    ["green", "blue"],
    progress=ease_out
)
```

Additionally, easing functions can be injected inline which allows a user to control how easing is performed between
specific sub-interpolations within piecewise interpolation.

```py play
Color.interpolate(["red", "green", ease_out, "blue"])
```

ColorAide even lets you apply easing functions to specific channels, though they can only be done this way for the
entire operation. This can be done to one or more channels at a time. Below, we apply an exponential "ease in" to
`alpha` while allowing all other channels to interpolate normally.

```py play
ease_in_expo = cubic_bezier(0.950, 0.050, 0.795, 0.035)
Color.interpolate(
    ["lch(50% 50 0)", "lch(90% 50 260 / 0.5)"],
    progress={
        'alpha': ease_in_expo
    }
)
```

We can also set all the channels to an easing function via `all` and then override specific channels. In this case,
we exponentially "ease out" on all channels except the red channel, which we then force to be linear.

```py play
ease_out_expo = cubic_bezier(0.190, 1.000, 0.220, 1.000)
Color.interpolate(
    ["color(srgb 0 1 1)", "color(srgb 1 0 0)"],
    progress={
        'all': ease_out_expo,
        'r': linear
    },
    space='srgb'
)
```

## Color Stops and Hints

Color stops are the position where the transition to and from a color starts and ends. By default, color stops are
evenly distributed within the domain of [0, 1], but if desired, these color stops can be shifted.

To specify color stops, simply wrap a color in a `coloraide.stop` object and specify the stop position. Stop positions
will then cause the transition of the targeted color to be moved.

```py play
from coloraide import stop
Color.interpolate(['orange', 'purple', 'green'])
Color.interpolate(['orange', stop('purple', 0.25), 'green'])
```

Color stops follow the rules as laid out in the [CSS spec](https://drafts.csswg.org/css-images-4/#color-stop-fixup).

CSS gradients also have a concept of "hints". Hints essentially define the midpoint between two colors. Instead of
reinventing the wheel, and further complicating the interface, we've decided to just demonstrate color hints with
easing functions. The logic comes directly from the [CSS spec](https://drafts.csswg.org/css-images-4/#coloring-gradient-line).

Using the `hint` function, we can generate a midpoint easing method that moves the middle of the interpolation
transition to the specified point which is relative to the two color stops it is between.

```py play
from coloraide import hint

Color.interpolate(['orange', 'purple', 'green'])
Color.interpolate(['orange', hint(0.75), 'purple', 'green'])
```

## Padding

Particularly when interpolating a color scale, it can be useful to "resize" the area of the color scale being evaluated.
This can generally be done using the `padding` parameter. Consider the following example using the ColorBrewer scale
`OrRd`.

```py play
scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
Color.interpolate(scale, space='srgb')
Color.discrete(scale, space='srgb', steps=5)
Color.interpolate(scale, space='srgb', padding=0.25)
Color.discrete(scale, space='srgb', steps=5, padding=0.25)
```

Padding can be applied to both sides by specifying a single number, or it can be controlled per side by sending in a
sequence of two values.

```py play
scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
Color.discrete(scale, space='srgb', steps=5, padding=[0.25, 0])
```

Negative padding is allowed as well.

```py play
scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
Color.discrete(scale, space='srgb', steps=5, padding=[-0.25, 0])
```

If the result extends past the limits, [`extrapolate`](#extrapolation) needs to be enabled or the values will be clamped
to the ends.

```py play
scale = ['#fff7ec', '#fee8c8', '#fdd49e', '#fdbb84', '#fc8d59', '#ef6548', '#d7301f', '#b30000', '#7f0000']
Color.discrete(scale, space='srgb', steps=5, padding=[1, 1])
Color.discrete(scale, space='srgb', steps=5, padding=[1, 1], extrapolate=True)
```

## Domains

By default, interpolation has an input domain of [0, 1]. This domain applies to an entire interpolation, even ones that
span multiple colors. Generally, this is sufficient and can be used to generate color scales, mixes, and steps in any
way that a user needs. When generating colors that should align with data, custom domains can be quite helpful.

For instance, associating colors with temperature.

```py play
i = Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 32, 60, 85, 95]
)
i(-32)
i(47)
i(89)
i
```

It should be noted that you are not constrained to provide the exact same amount of domain values as you have colors and
can have differing amounts, but if you want to align specific colors to certain data points, then it helps.

```py play
Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 32, 60, 85, 95]
)
Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 95]
)
```

Domains can also be specified in descending order.


```py play
Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[95, 85, 60, 32, -32]
)
Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[95, -32]
)
```

Lastly, while domains can be specified in either ascending or descending order, it needs to be consistent. If a values
increase in values, and suddenly decreases in value, you will get discontinuities.

```py play
i = Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 32, 60, 20, 95]
)
i.domain
i
```

Custom domains are most useful when working with [`discrete`](#discrete-interpolation) or
[`interpolate`](#linear-interpolation) directly, but you can use it in other methods like [`steps`](#steps) as well. As
`steps` does not take data point inputs like `interpolate`, we do not need to use the temperature data as an input
except to set the domain, but the steps will be generated with the same alignment relative to the domain range.

```py play
Color.interpolate(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 32, 60, 85, 95]
)
Color.discrete(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    domain=[-32, 32, 60, 85, 95]
)
Color.steps(
    ['blue', 'green', 'yellow', 'orange', 'red'],
    steps=11,
    domain=[-32, 32, 60, 85, 95]
)
```

Wile you can technically feed `domain` into [`mix`](#mixing), it is probably not as useful. It will respect the domain
alignment, but mix always accepts a percentage of [0, 1], regardless of the underlying domain.

## Extrapolation

By default, ColorAide clamps the entire progress of an interpolation to always be within the domain ([0, 1] by default).
In most cases, this is more what most user expects and why this is the default. It should be noted that this does not
affect easing functions, as the clamping is done prior to any easing function calls.

If it is desired to extrapolate past 0 and 1, `extrapolate` can set to `#!py3 True` on all interpolation methods.

```py play
Color('red').mix('blue', 0.5)
Color('red').mix('blue', -0.5, extrapolate=True)
```

As a larger example, we can purposely interpolate over a range with values beyond 0 and 1. Here we extended the range
to -0.5 and 1.5.

```py play
offset, factor = 0.25, 1.5
i = Color.interpolate(['red', 'blue'], space='srgb')
Ramp([i((r * factor / 100) - offset) for r in range(101)])
i = Color.interpolate(['red', 'blue'], space='srgb', extrapolate=True)
Ramp([i((r * factor / 100) - offset) for r in range(101)])
```

Lastly, it is important to note that this affects stops as well, mainly stops applied to interpolation endpoints. When
an endpoint is moved inwards via a color stop, the end range of the interpolation is clamped, extending the star and
end color. But when extrapolation is enabled, a color stop on an endpoint essentially moves the start and end
interpolation. And since there are no other colors on either end to interpolate with, extrapolation occurs.

```py play
Color.interpolate([stop('red', 0.25), stop('blue', 0.75)], space='srgb')
Color.interpolate([stop('red', 0.25), stop('blue', 0.75)], space='srgb', extrapolate=True)
```

## Undefined/NaN Handling {#null-handling}

Color spaces that have hue coordinates often have rules about when the hue is considered relevant. For instance, in the
HSL color space, if saturation is zero, the hue is essentially powerless. This is because the color is "without color"
or achromatic; therefore, the hue can have no affect on the actual color.

ColorAide will generally respect the values a user provides, so if an achromatic HSL color is given a hue of 270
degrees, ColorAide will accept it, but the hue will not affect the color in any meaningful way.

During conversions, such context is lost, and if an achromatic color is converted to the color space like HSL, the
resultant color will have a hue that is noted as undefined. This is simply because there is no good hue for achromatic
colors as they play no part in the color. Any hue is actually incorrect as achromatic colors have no real hue. Instead,
colors will be returned with a value that represents that the hue is missing or undefined, or maybe better worded, could
not be defined.

Many libraries, like [d3-color](https://github.com/d3/d3-color), [chroma.js](https://gka.github.io/chroma.js/), and
[color.js](https://github.com/LeaVerou/color.js), represent null hues with `NaN` (not a number). This is usually done
to make color interpolation easier. Some, like d3-color, are a bit more liberal with `NaN` and will target special cases
that are above and beyond the normal rules to help ensure good interpolation. For instance, they not only mark hue
undefined on HSL colors when saturation is zero, but they'll mark saturation as `NaN` when lightness indicates "black"
or "white".

ColorAide also uses `NaN`, or in Python `#!py3 float('nan')`, to represent undefined channels. In certain situations,
when a hue is deemed undefined, the hue value will be set to `coloraide.NaN`, which is just a constant containing
`#!py3 float('nan')`.

When performing linear interpolation, where only two color's channels are ever being evaluated together at a given time,
if one color's channel has a `NaN`, the other color's channel will be used as the result. If both colors have a `NaN`
for the same channel, then `NaN` will be returned.

> [!tip] Continuous NaN Handling
> `NaN` handling is a bit different for the [Continuous](#continuous-interpolation) and
> [Cubic Spline](#cubic-spline-interpolation) interpolation approaches. Linear only evaluates colors at a given time,
> while the others will take into consideration more than two colors. Because the context is much wider and more
> complicated, `NaN` values will often get context from both sides.

Notice that in this example, because white's saturation is zero, the hue is undefined. Because the hue is undefined,
when the color is mixed with a second color (`#!color green`), the hue of the second color is used.

```py play
color = Color('white').convert('hsl')
color[:-1]
color2 = Color('green').convert('hsl')
color2[:-1]
color.mix(color2, space="hsl")
```

But if we manually set the hue to `#!py 0` instead of `NaN`, we can see that the mixing goes quite differently.

```py play
color = Color('white').convert('hsl').set('hue', 0)
color[:-1]
color2 = Color('green').convert('hsl')
color2[:-1]
color.mix(color2, space="hsl")
```

Technically, any channel can be set to `NaN`. And there are various ways to do this. The
[Color Manipulation documentation](./manipulation.md#undefined-values) goes into the details of how these `NaN` values
naturally occur and the various ways a user and manipulate them.

## Carrying-Forward

> [!example] Experimental
> This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change
> or feature could be removed entirely. Use at your own risk.

CSS introduces the concept of carrying-forward undefined channels of like color spaces during conversion to the
interpolating color space. The idea is to provide a sane handling to users who specified undefined channels for
interpolation, but did not account for the conversion to the interpolating color space.

If a color has undefined channels, and is converting to a like color space, after conversion the new color will have the
same undefined channels, assuming the channels support carrying-forward. The example below demonstrates the concept.

```py play
rgb = Color('srgb', [0.5, NaN, 0.8])
p3 = rgb.convert('display-p3').set('green', NaN)
rgb, p3
```

ColorAide, by default, expects the user to be aware that undefined values are lost if conversion is required for
interpolation. This is mainly because the intent of the color can be changed during this process, but some users may
find the automatic carrying-forward more convenient. For this reason, ColorAide has implemented carrying-forward as an
optional feature via the `carryforward` option.

In this example, interpolating without carrying-forward results in an interpolation between a purplish color and white.
Using carrying-forward, we get a purplish color with an undefined green channel. The green channel takes on the white's
green channel giving us an interpolation between a more greenish color and white.

```py play
Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3')
Color.interpolate(['color(srgb 0.5 none 0.8)', 'white'], space='display-p3', carryforward=True)
```

Depending on the color space, carrying-forward may have better or worse results.

The following table shows channel components supported for `carryforward`. Spaces may use different names for their
channels, but if they are derived from the related space classes, their channels are supported. For instance, `xyz` is
derived from `RGBish`, so `x`, `y`, and `z` is treated like super saturated `r`, `g`, and `b`.

Space\ Type  | Channel\ Equivalents
------------ | --------
`RGBish`     | `r`, `g`, `b`
`LABish`     | `l`
`LCHish`     | `l`, `c`, `h`
`HSLish`     | `h`, `s`, `l`
`HSVish`     | `h`, `s`, `v`
`Cylindrical`| `h`

Carrying-forward is applied within categories.

Category     | Components
------------ | ----------
Reds         | `r`
Greens       | `g`
Blues        | `b`
Lightness    | `l`
Colorfulness | `c`, `s`
Hue          | `h`
Opponent a   | `a`
Opponent b   | `b`
Value        | `v`

## Powerless Hues

> [!example] Experimental
> This feature is provided to give parity with CSS behavior. As the spec is still in flux, behavior is subject to change
> or feature could be removed entirely. Use at your own risk.

Normally, ColorAide respects the user's explicitly defined hues. This gives the user power to do things like masking
off all channels but the hue in order to interpolate only the hue channel.

```py play
Color.interpolate(
    ['oklch(none none 0)', 'oklch(0.75 0.2 360)'],
    space='oklch',
    hue='longer'
)
```

But when doing this, a user must explicitly define the hue as achromatic if they want the hue to be ignored. Conversions
of achromatic colors to a cylindrical space will, in most cases, have the hue automatically set to undefined.

```py play
Color.interpolate(['oklch(1 0 0)', 'oklch(0.75 0.2 180)'], space='oklch')
Color.interpolate(['oklch(1 0 None)', 'oklch(0.75 0.2 180)'], space='oklch')
```

CSS has the concept of powerless hues which causes explicitly defined hues to be powerless (or act as
[undefined](#null-handling)) when a color is considered achromatic. This means a user never has to think about
achromatic hues, so even if they erroneously define a hue, the hue will automatically be treated as undefined when
interpolating. ColorAide implements this behavior via the `powerless` option.

```py play
Color.interpolate(
    ['oklch(1 0 0)', 'oklch(0.75 0.2 180)'],
    space='oklch',
    powerless=True
)
Color.interpolate(
    ['oklch(1 0 None)', 'oklch(0.75 0.2 180)'],
    space='oklch',
    powerless=True
)
```

The one downside is that control over the hue will be diminished to some degree as ColorAide will no longer respect
a user's explicit hue if the color is determined to be achromatic.

```py play
Color.interpolate(
    ['oklch(none none 0)', 'oklch(0.75 0.2 360)'],
    space='oklch',
    hue='specified'
)
Color.interpolate(
    ['oklch(none none 0)', 'oklch(0.75 0.2 360)'],
    space='oklch',
    hue='specified',
    powerless=True
)
```
