Search code examples
javascripthtml5-canvastransformationpath-2d

Transform only the styling of a Path2D


In the canvas 2D API, we can first define a subpath using one context's transformation and then change that context's transformation for only the fill() or stroke() calls, which would have effect on the stylings, like fillStyle, lineWidth and other visible properties, but which will leave the sub-path as defined. This is quite convenient when we want to zoom in vector-shapes while keeping the same stroke-width.

Here is a simple example where only the lineWidth is affected by the variable zoom transformation:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

let zoom = 1;
let speed = 0.1;
requestAnimationFrame(update);

function update() {
  if( zoom >= 10 || zoom <= 0.1 ) speed *= -1;
  zoom += speed;
  draw();
  requestAnimationFrame(update);
}

function draw() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0,0,canvas.width,canvas.height);
  // define the subpath at identity matrix
  ctx.beginPath();
  ctx.moveTo(10 ,80);
  ctx.quadraticCurveTo(52.5,10,95,80);
  ctx.quadraticCurveTo(137.5,150,180,80);
  // stroke zoomed
  ctx.setTransform(zoom, 0, 0, zoom, 0, 0);
  ctx.stroke();
}
<canvas id="canvas"></canvas>

With the Path2D API, we have to pass this subpath directly in either ctx.fill(path) or ctx.stroke(path) methods.
This means we can't separate the stylings from the subpath declaration like we did before:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

let zoom = 1;
let speed = 0.1;
requestAnimationFrame(update);

function update() {
  if( zoom >= 10 || zoom <= 0.1 ) speed *= -1;
  zoom += speed;
  draw();
  requestAnimationFrame(update);
}

function draw() {
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0,0,canvas.width,canvas.height);
  // define the subpath at identity matrix
  // (declared in 'draw' just for the example, would be the same anyway outside)
  const path = new Path2D("M 10 80 Q 52.5 10, 95 80 T 180 80");
  // stroke zoomed
  ctx.setTransform(zoom, 0, 0, zoom, 0, 0);
  ctx.stroke(path);
}
<canvas id="canvas"></canvas>

Is there no way of doing this while using this otherwise convenient Path2D API?


Solution

  • There is a way to transform a Path2D object by passing a DOMMatrix1 to the Path2D.prototype.addPath method.

    So we can actually achieve the same result by passing a transformed copy of our Path2d:

    const transformPath = (path, matrix) => {
      const copy = new Path2D();
      copy.addPath(path, matrix);
      return copy;
    };
    // ...
    ctx.stroke( transformPath( path, {a: 1/zoom, d: 1/zoom } );
    

    However, you'll notice that we have to make our path-matrix relatively from the styling one.
    The new DOMMatrix API eases matrix transforms a lot2, but it makes this approach definitely more convoluted than the beginPath() way, that's quite unfortunate we can't act on the Path2D object itself or even just have this transform parameter on the constructor too, but that's the only way I know of...

    const transformPath = (path, matrix) => {
      const copy = new Path2D();
      copy.addPath(path, matrix);
      return copy;
    };
    
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    // define the subpath
    const path = new Path2D("M 10 80 Q 52.5 10, 95 80 T 180 80");
    
    let zoom = 1;
    let speed = 0.1;
    requestAnimationFrame(update);
    
    function update() {
      if( zoom >= 10 || zoom <= 0.1 ) speed *= -1;
      zoom += speed;
      draw();
      requestAnimationFrame(update);
    }
    
    function draw() {
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0,0,canvas.width,canvas.height);  
      // zoom the stylings
      ctx.setTransform(zoom, 0, 0, zoom, 0, 0);
      // create our transformed path
      const invertMatrix = {a: 1/zoom, d: 1/zoom};
      ctx.stroke(transformPath(path, invertMatrix));
    }
    <canvas id="canvas"></canvas>

    1. Actually it doesn't need to be an actual DOMMatrix, any object with its properties will do
    2. We can now even use such objects in ctx.setTransform(matrix).