DOM.context2d vs DOM.canvas -- what am I doing wrong?

I recently used HTML Canvas for the first time ever (in or out of Observable), and wound up finding DOM.context2d really hard to work with.

I guess I’m hoping someone could help me understand what is going on under the hood.

I initially used DOM.canvas2d(myWidth + margin.left + margin.right, myHeight + margin.top + margin.bottom) to make the canvas.

I then made content using ctx.createImageData(myWidth, myHeight) and attached it with ctx.putImageData(img, margin.left, margin.top). When I inspected the img (data), it was the right number of values for a rectangle of size myWidth x myHeight. I wanted to maintain bottom and right margins as well, but using DOM.canvas2d, the img was stretched to take up those areas. I also noticed the the style on the specified a width but not a height? (Though had both width and height attributes that I had specified)

When I switched to just using DOM.canvas(myWidth + margins, myHeight + margins), and then set the context as 2d using JS, everything worked as expected.

Is this the expected behavior of DOM.context2d? Maybe this could be made more clear in the documentation?

DOM.context2d() automatically scales the canvas to the current devicePixelRatio. For many modern devices this is 2 (or even 3), so that the actual canvas resolution, when initialized with context2d(), becomes width * dpr and height * dpr.

To avoid this behavior, pass the function a third parameter with your desired dpr (usually 1):

const ctx = DOM.context2d(width, height, 1);

The complete implementation of DOM.context2d() is rather short:

export default function(width, height, dpi) {
  if (dpi == null) dpi = devicePixelRatio;
  var canvas = document.createElement("canvas");
  canvas.width = width * dpi;
  canvas.height = height * dpi;
  canvas.style.width = width + "px";
  var context = canvas.getContext("2d");
  context.scale(dpi, dpi);
  return context;
}

You can obtain the actual dimensions of a scaled context from its canvas:

const w = ctx.canvas.width, h = ctx.canvas.height;

imageData operations are not affected by transformations and will always use the real dimensions and offsets.

If you want to use drawImage() with a scaled context, you need to reset the scaling beforehand. The easiest way to do that is to temporarily reset all transformations:

ctx.save();
ctx.resetTransform();
ctx.drawImage(img, 0, 0);
ctx.restore();

Fun fact: If the performance of your canvas operations is heavily impacted by the size of the drawing area, then you can temporarily lower the resolution by passing a pixel ratio < 1, e.g.

const ctx = DOM.context2d(width, height, .5);

to halve the resolution.

6 Likes

Thanks! Very clear explanation!