Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
LeaVerou opened this issue Apr 25, 2024 · 6 comments
Open
Labels
API change For PRs that require API design review enhancement New feature or request Topic: JS API

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Apr 25, 2024

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?

@LeaVerou LeaVerou added enhancement New feature or request API change For PRs that require API design review Topic: JS API labels Apr 25, 2024
@facelessuser
Copy link
Collaborator

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
Copy link
Collaborator

facelessuser commented Apr 25, 2024

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

@svgeesus
Copy link
Member

svgeesus commented May 1, 2024

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

@facelessuser
Copy link
Collaborator

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.

@aeharding
Copy link

I think this has a fair amount of overlap with #18, unless I'm misunderstanding.

I would be very interested in a convenience function (or just a flexible interpolation API) to interpolate over a custom domain (instead of fixed [0-1]) similar to chroma.js. A real world example being mapping colors to temperature, and then being able to easily call colorScale(myTemperature). (see: #18 (comment))

@facelessuser
Copy link
Collaborator

I think custom domains would be useful. I think this could easily be done now with a simple external function that you pass your value through before passing it to the interpolation function that coerces a defined domain to 0 - 1. It doesn't have to be implemented directly in Color.js, though I'm sure it would be appreciated if the domain could be specified in range or mix and then just handled automatically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
API change For PRs that require API design review enhancement New feature or request Topic: JS API
Projects
None yet
Development

No branches or pull requests

4 participants