color-js / color.js

Color conversion & manipulation library by the editors of the CSS Color specifications
https://colorjs.io
MIT License
1.83k stars 81 forks source link

Color scales: gradient-like interpolation across multiple color stops #506

Open LeaVerou opened 3 months ago

LeaVerou commented 3 months ago

Currently we have Color.range() and friends which only interpolates between 2 colors. I just had to write this for a color component I’m writing (<color-slider>), and it was no fun:

colorAt (p) {
    let bands = this.scales?.length;

    if (bands <= 0) {
        return null;
    }

    // FIXME the values outside of [0, 1] should be scaled
    if (p >= 1) {
        return this.scales.at(-1)(p);
    }
    else if (p <= 0) {
        return this.scales[0](p);
    }

    let band = 1 / bands;
    let scaleIndex = Math.max(0, Math.min(Math.floor(p / band), bands - 1));
    let scale = this.scales[scaleIndex];
    let color = scale((p % band) * bands);

    return color;
}

And this is just equally spaced color stops. I dread to think about implementing the general case. Which is why I think Color.js should provide it 😁

I’m thinking of something like:

/**
  @param {Array<Color | {color: Color, at: number}>} stops
  @param {RangeOptions} [options]
 */
function colorScale(colors: Array<Color>, options): function;

Thoughts?

facelessuser commented 3 months ago

I think gradients across multiple color stops is a good idea. I know I implemented this behavior in ColorAide, so I definitely think it is useful.

facelessuser commented 3 months ago

Do you plan on supporting adjusting stop position? It may be something to consider up front if this is desired for the future.

svgeesus commented 2 months ago

As soon as we move from linear two-stop interpolation we need to consider how we want to handle smooth curves and curve continuity (regardless of whether we also need to support piecewise linear with bolted-on fix-ups like midpoint position, ease-in ease-out, and the like which are crude approximations to true, easily-animatable multi-point curves).

Ideally, multi-point smooth curves that can be

facelessuser commented 2 months ago

As soon as we move from linear two-stop interpolation we need to consider how we want to handle smooth curves and curve continuity (regardless of whether we also need to support piecewise linear with bolted-on fix-ups like midpoint position, ease-in ease-out, and the like which are crude approximations to true, easily-animatable multi-point curves).

Ideally, multi-point smooth curves that can be

constrained to lie within an arbitrary polyhedron, avoid excessive overshoot

I'll let others define the API to introduce such functionality and I'll speak generally to multi-point smooth curves.

Generally, I like the approach that Culori employed using cubic splines: https://culorijs.org/api/#interpolatorSplineBasis. The one thing I think Culori did not address is what to do with undefined channels when applying these approaches. I will speak to how I decided to handle such things.

Using splines requires us to broaden the definition of what a piece is. While you must handle 2 color and 3 color cases, splines need a bit more context and ideally should take into account 4 colors if available. This will help shape the curve smoothly through the colors.

Personally, I found undefined handling for the current linear interpolation insufficient considering the requirements of 4 colors (if available) for splines. In order to settle on what I found to be a reasonable solution, I needed to adjust undefined channel handling. To do this, undefined channels, for anything other than piecewise linear, are evaluated throughout the entire chain of colors, essentially filling in any gaps and holes before interpolation takes place. This is currently done by using simple linear interpolation to create points between the defined channels. Such gaps are considered and filled before easing functions are applied.

Screenshot 2024-05-01 at 6 53 39 AM

Undefined channels not between two defined channels simply take on the value of the defined channel on either its left or right.

Screenshot 2024-05-01 at 6 53 46 AM

Using this logic as a basis, splines were implemented on top of that. Overshoot is dependant upon the spline approach chosen. I'm not sure any approach perfectly eliminates overshoot, but monotone at least will keep it within the bounds of the data.

catmull-rom-interpolation

monotone-interpolation

Extrapolation beyond the endpoints is handled in a linear fashion, at least in my implementation.

Stops and easing functions are generally handled as they were previously in normal linear, piecewise interpolation. Stops are applied to the current color stops, and easing functions adjust the progress between the two colors currently under evaluation, even if that evaluation is looking past those two colors to shape the curve in the spline.

Hopefully, some of this is helpful for considering what direction Color.js should take.