HTML5 canvas - stroke with an elliptical gradient

72 views Asked by At

In a HTML5 canvas, I can stroke a shape with a radial gradient, see https://jsfiddle.net/s03gz7wy/1/

var cnv = document.createElement("canvas"),
  ctx = cnv.getContext("2d");
document.body.appendChild(cnv);

ctx.lineWidth = 10;
ctx.rect(20, 20, 100, 100);
var g = ctx.strokeStyle = ctx.createRadialGradient(70, 70, 0, 70, 70, 100);
g.addColorStop(0.4, "blue");
g.addColorStop(0.7, "red");

//ctx.translate(70,70);  ctx.scale(0.5,1);  ctx.translate(-70,-70);

ctx.stroke();

I would like the gradient to be "squeezed" like an ellipse, so I apply a transform to a canvas (line 10 - try to uncomment it). However, it also transforms the stroke (it is thinner at certain places).

Is there any way to "stroke with an elliptical gradient" in the 2D context? It is possible in SVG and PDF, but I can not do it on a canvas.

1

There are 1 answers

0
Kaiido On

This is indeed a missing feature of the Canvas 2D API, the good news is that it's actively discussed that the same setTransform() method that CanvasPattern objects have will be added to the CanvasGradient interface (along with other missing gradient features, like defining the spread method, or the color-space used for interpolation, and maybe others). Given the latest discussions on this subject, I'd expect it to be one of the 2023 additions to the API.

But for the time being, I'm afraid we're stuck with compositing.

One of the most flexible solutions is to use another canvas to do that compositing and then draw that canvas on the visible one. The steps could be:

  • Draw your stroke (in solid color) over a transparent canvas.
  • Change the context's globalCompositeOperation mode to "source-in".
  • Set your transform.
  • Fill a rectangle as big as the canvas using the CanvasGradient as fill rule.

(You could reorder this list by changing the gCO mode).

const cnv = document.createElement("canvas");
const ctx = cnv.getContext("2d");
document.body.appendChild(cnv);
// create a new detached canvas the same size as the visible one,
// this allows to not affect the previous drawings on the visible canvas
const off = cnv.cloneNode(); 
const oCtx = off.getContext("2d");
oCtx.lineWidth = 10;
oCtx.rect(20, 20, 100, 100);
oCtx.stroke();
// next drawing will "stay" only where already painted
oCtx.globalCompositeOperation = "source-in";
// note: we set the 'fillStyle' to 'g', not the 'strokeStyle'
var g = oCtx.fillStyle = oCtx.createRadialGradient(70, 70, 0, 70, 70, 100);
g.addColorStop(0.4, "blue");
g.addColorStop(0.7, "red");

oCtx.translate(70, 70);
oCtx.scale(0.5, 1);
oCtx.translate(-70, -70);
// in order for our fillRect call to cover the whole canvas
// even when the context is transformed, we need to transform
// our points through the inverse CTM
const invertMat = oCtx.getTransform().invertSelf();
const transformedXY = invertMat.transformPoint({ x: 0, y: 0 });
const transformedWH = invertMat.transformPoint({ x: off.width, y: off.height });
oCtx.fillRect(transformedXY.x, transformedXY.y, transformedWH.x, transformedWH.y);
// Draw back to the visible canvas
ctx.drawImage(off, 0, 0);