DEV Community

Cover image for Circular Color Gradients from Scratch
ndesmic
ndesmic

Posted on

Circular Color Gradients from Scratch

Radial Gradients

download

Radial gradients are not too dissimilar from linear gradients. In fact the math is still linear it's just in a different coordinate system. We can use the same functions we made last time!

function linearGradient(stops, value) {
    let stopIndex = 0;
    while (stops[stopIndex + 1][4] < value) {
        stopIndex++;
    }

    const remainder = value - stops[stopIndex][4];
    const stopFraction = remainder / (stops[stopIndex + 1][4] - stops[stopIndex][4]);

    return lerp(stops[stopIndex], stops[stopIndex + 1], stopFraction);
}

function lerp(pointA, pointB, normalValue) {
    return [
        pointA[0] + (pointB[0] - pointA[0]) * normalValue,
        pointA[1] + (pointB[1] - pointA[1]) * normalValue,
        pointA[2] + (pointB[2] - pointA[2]) * normalValue,
        pointA[3] + (pointB[3] - pointA[3]) * normalValue,
    ];
}
Enter fullscreen mode Exit fullscreen mode

The difference is that the line we are iterating over isn't a row of pixels but rather the radius of a circle.

This will require some conversions. Firstly, to keep things oriented correctly we need to flip the y-axis when iterating over the pixels on the canvas. This is because many drawing APIs consider the top-left to be location 0,0 and HTML Canvas is one of them.

So let's consider the pixel iteration loop:

renderGradient() {
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0, 0, this.dom.canvas.width, this.dom.canvas.height);
    for (let i = 0; i < imageData.width; i++) {
        for (let j = 0; j < imageData.height; j++) {
                   //...do something with imageData
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

This grabs the canvas, takes the pixel data, iterates over every pixel and then shoves it back into the canvas. i is columns and j is rows. The first thing we want to do is flip j:

const y = imageData.height - j; 
Enter fullscreen mode Exit fullscreen mode

and y will be the normalized row location starting at the top.

Next we need to convert our cartesian coordinates to polar coordinates.

export function cartesianToPolar(x, y, cx = 0, cy = 0) {
    return [Math.sqrt((x - cx) ** 2 + (y - cy) ** 2), Math.atan2((y - cy), (x - cx))];
}
Enter fullscreen mode Exit fullscreen mode

This gives us coordinates in terms of r and theta where r is a radius position and theta is the amount we've turned around the center point. Geometrically this should make sense. The r or "radius" is the length of the hypotenuse of a triangle where x and y are the sides. theta is the angle of that triangle. cx and cy are "center x" and "center y" and are used to translate the center of the circle so we can draw it at different places.

Also, if you're curious about Math.atan vs Math.atan2 since I also learned this, Math.atan2 takes the parameters y,x which is often more common than the ratio y/x so it's a bit of convenience.

However, we don't actually need to use the theta component right now because the gradient does not vary in terms of theta.

const [r, _] = cartesianToPolar(i, y, cx, cy);
Enter fullscreen mode Exit fullscreen mode

We want to be able to vary our gradient's maximum radius so we can stretch the circle how we want. But this introduces a problem, what do we do with points that are outside of the maximum radius? In the example above I'm simply clamping it to the final stop:

const rValue = Math.min(1.0, r / this.#r);
Enter fullscreen mode Exit fullscreen mode

But you could do something else like make those pixels transparent:

download (1)

If you choose to clamp it, then just pass the rValue into linearGradient

const color = linearGradient(this.#stops, rValue);
imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
Enter fullscreen mode Exit fullscreen mode

Otherwise use an if statement to figure out what you want to do if the rValue is greater than 1:

if(rValue > 1){
    imageData.data[(j * imageData.width * 4) + 4 * i] = 255;
    imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = 255;
    imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = 255;
    imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = 0;
}
Enter fullscreen mode Exit fullscreen mode

Here's a the full loop with clamping:

renderGradient() {
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0, 0, this.dom.canvas.width, this.dom.canvas.height);
    for (let i = 0; i < imageData.width; i++) {
        for (let j = 0; j < imageData.height; j++) {
            const y = imageData.height - j; 
            const [r, _] = cartesianToPolar(i, y, this.#cx, this.#cy);
            const rValue = Math.min(1.0, r / this.#r);
            const color = linearGradient(this.#stops, rValue);
                imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
            }
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

Conic Gradients

download

Conic gradients are relatively new feature to the web platform and it's honestly a bit surprising. Conic gradients aren't exactly complicated to implement and they are incredibly useful. You can use them to draw pie charts, or even checkerboard patterns. Intuitively a conic gradient is no different than our other gradients, instead it uses normalized theta as the value to interpolate across.

The code is nearly identical to the radial gradient but we are going to iterate over theta instead and there's a little bit of cleanup necessary to normalize it. When we take the Math.atan2 we get a value between -π an π radians. This isn't so useful as we want to sweep in one direction across the circle. So we normalize it using a familiar function (if you read my post about the knob component):

const TWO_PI = Math.PI * 2;
export function normalizeAngle(angle) {
    if (angle < 0) {
        return TWO_PI - (Math.abs(angle) % TWO_PI);
    }
    return angle % TWO_PI;
}
Enter fullscreen mode Exit fullscreen mode

This will take values greater than 2π or less than 0 and normalize them to be between 0 and 2π. Since all our calculation are in radians we're going to stick to them.

But we're not quite done. The value should be between 0 and 1, so we'll just divide the output by 2π.

const tValue = normalizeAngle(theta) / TWO_PI;
const color = linearGradient(this.#stops, tValue);
Enter fullscreen mode Exit fullscreen mode

The full method:

renderGradient() {
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0, 0, this.dom.canvas.width, this.dom.canvas.height);
    for (let i = 0; i < imageData.width; i++) {
        for (let j = 0; j < imageData.height; j++) {
            const y = imageData.height - j;
            const [_, theta] = cartesianToPolar(i, y, this.#cx, this.#cy);
            const tValue = normalizeAngle(theta) / TWO_PI;
            const color = linearGradient(this.#stops, tValue);
            imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
            imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
            }
        }
    }
    context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

We can also choose whether to clamp or clip like above when rValue is greater than 1:

download (1)

You might have an opinion about where that seam is. You can either construct the stops such that the seam is where you want it but I also made a somewhat arbitrary choice to put theta = 0 on the right side like in school geometry. There's no reason we can't change that default, for example CSS conic gradients have 0 at the top. Maybe instead of debate, let's add a parameter.

const tValue = normalizeAngle(theta - initalTheta) / TWO_PI;
Enter fullscreen mode Exit fullscreen mode

where initalTheta is a value in radians to turn it. One thing to think about is whether we should add or subtract initialTheta? Well if we add it, it might not work the way you think it should. If we are adding say 30 degrees to the value when drawn it will be rotated clockwise 30 degrees which is probably not what you wanted. In school math angles grow counter-clockwise so if we keep that convention then we expect it to be rotated 30 degrees counter-clockwise which means we need to subtract. Try it out if you want.

download (2)

Elliptic Gradients

Elliptical Conic Gradient

We may wish to squish and stretch part of the circle forming our gradient. The easiest place I think to start is the conic gradient. In fact, for a clamped conic gradient, the radius is not even a term we use, so it doesn't matter if it's an oval or not. For the clipped conic gradient we do use the radius to determine if we want to clip or not so lets try that.

If you try to look up equations for ellipses in polar coordinate you'll get a lot of confusing diagrams, confusing variable names, equations in terms of foci instead of a variable radii and I'm pretty sure some are not even correct. I can't explain why textbook math has failed so hard here. Anyway the equation you want is:

//the radius of an ellipse at a given theta value
const r = ((rx * ry) / Math.sqrt((ry * Math.cos(theta))**2 + (rx * Math.sin(theta))**2));
Enter fullscreen mode Exit fullscreen mode

This gets the radius at a particular theta value. We can replace the line const rValue = r / rmax which gets the ratio of the radius for a circle with the following:

function getRValue(r, theta){
  return r / ((this.#rx * this.#ry) / Math.sqrt((this.#ry * Math.cos(theta))**2 + (this.#rx * Math.sin(theta))**2)
}
Enter fullscreen mode Exit fullscreen mode

download (3)

That's maybe what we want. Of course this now means there's 2 "thetas", one which determines the rotation value of 0, and another which determines the rotation of the clipping edge.

Rotation

Again I'll let this be an option:

getRValue(r, theta){
    const transformedTheta = normalizeAngle(theta + this.#clipTheta);
    if(!this.#rx && !this.ry){
        return r / this.#r;
    } else if (this.#rx && this.#ry){
        return r / ((this.#rx * this.#ry) / Math.sqrt((this.#ry * Math.cos(transformedTheta))**2 + (this.
#rx * Math.sin(transformedTheta))**2));
    }
}
Enter fullscreen mode Exit fullscreen mode

Just add a new rotation term and subtract (remember we're going counter-clockwise).

download (1)

Elliptical Radial Gradients

This is nearly the same as above. We need to take theta into account now and use the same getRValue function (there's no clip angle so I just call it #angle):

getRValue(r, theta) {
    const transformedTheta = normalizeAngle(theta - this.#angle);
    if (!this.#rx && !this.ry) {
        return r / this.#r;
    } else if (this.#rx && this.#ry) {
        return r / ((this.#rx * this.#ry) / Math.sqrt((this.#ry * Math.cos(transformedTheta)) ** 2 + (this.#rx * Math.sin
(transformedTheta)) ** 2));
    }
}
Enter fullscreen mode Exit fullscreen mode

download

Hmmm...there's something weird here. At least in Javascript the floating point rounding is such that it misses the center point. If you can in your stack you can try to use higher precision but here we're probably stuck. Hack time:

renderGradient() {
    const context = this.dom.canvas.getContext("2d");
    const imageData = context.getImageData(0, 0, this.dom.canvas.width, this.dom.canvas.height);
    for (let i = 0; i < imageData.width; i++) {
        for (let j = 0; j < imageData.height; j++) {
            const y = imageData.height - j; 
            const [r, theta] = cartesianToPolar(i, y, this.#cx, this.#cy);
            let rValue = this.getRValue(r, theta);
            if(this.#clip !== "clamp" && rValue > 1){
                imageData.data[(j * imageData.width * 4) + 4 * i] = this.#clip[0] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = this.#clip[1] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = this.#clip[2] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = this.#clip[3] * 255;
            } else {
                rValue = Math.min(1.0, rValue);
                const color = linearGradient(this.#stops, rValue);
                imageData.data[(j * imageData.width * 4) + 4 * i] = color[0] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 1] = color[1] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 2] = color[2] * 255;
                imageData.data[(j * imageData.width * 4) + (4 * i) + 3] = color[3] * 255;
            }
        }
    }
    //hack to fill in center
    const y = imageData.height - this.#cy;
    imageData.data[((y * imageData.width * 4) + 4 * this.#cx)] = this.#stops[0][0] * 255;
    imageData.data[((y * imageData.width * 4) + (4 * this.#cx) + 1)] = this.#stops[0][1] * 255;
    imageData.data[((y * imageData.width * 4) + (4 * this.#cx) + 2)] = this.#stops[0][2] * 255;
    imageData.data[((y * imageData.width * 4) + (4 * this.#cx) + 3)] = this.#stops[0][3] * 255;
    context.putImageData(imageData, 0, 0);
Enter fullscreen mode Exit fullscreen mode

That last part fills the center since we know the exact coordinate and it will always be exactly the first color stop.

download

That works.

download (2)

Angles and clipping work too.

If you want to see complete code I have a radial gradient component: https://github.com/ndesmic/wc-lib/blob/master/gradient/wc-radial-gradient.js
and a conic gradient component: https://github.com/ndesmic/wc-lib/blob/master/gradient/wc-conic-gradient.js

Discussion (0)