Radial Gradients
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,
];
}
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);
}
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;
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))];
}
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);
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);
But you could do something else like make those pixels transparent:
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;
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;
}
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);
}
Conic Gradients
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;
}
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);
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);
}
We can also choose whether to clamp or clip like above when rValue
is greater than 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;
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.
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));
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)
}
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));
}
}
Just add a new rotation term and subtract (remember we're going counter-clockwise).
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));
}
}
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);
That last part fills the center since we know the exact coordinate and it will always be exactly the first color stop.
That works.
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
Top comments (0)