Skip to content

Studying the masters, canvas edition

Recreating Suprematist Composition (1916) with HTML canvas

  • code
  • svg

CSS art is fun. SVG⁠s are neat. Animation? Good enough for Disney; good enough for me. Time to learn a little of all of the above. The second in a series.

This rendition of Malevich’s Suprematist Composition is very similar to the one I put together using SVG in August 2021. My process then was intent on not shipping any unnecessary JavaScript to the client, even though I used JS to write out the SVG’s XML, and then copied the XML source into the page source.

This time, however, JS is a firm requirement, since that’s how the HTML canvas element works. I’m reusing the array of objects that stores the coordinates, colours, and rotation properties of each rectangle on Malevich’s original that I worked out in my earlier post:

rects = [
    { x: 539, y:   9, w:  65, h: 470, Θ: 32, hex: '282a29' },
    { x: 564, y:  82, w:  67, h: 490, Θ: 32, hex: '282a29' },
    { x: 357, y: 575, w: 107, h: 530, Θ: 35, hex: '2b2c2c' },

When working with canvas, inner DOM object can be used to provide an accessible fallback. There’s not much need these days, as browser support for canvas is widespread. Nevertheless, my blank canvas is setup like so, containing a fallback image with a useful alt attribute:

<canvas id="malevich" class="art">
    <img src="" alt="Suprematist Composition (1916), oil on canvas, by Kazimir Malevich">

Below is the JavaScript that renders the painting. I’ve annotated a number of lines with circled numbers, and explain below what they’re doing. All in all, the code is pretty similar to SVG-generating code I hacked together for the first post in this series.

const c = document.querySelector("canvas");
const ctx = c.getContext("2d");  // ①
const originalW = 2296;
const originalH = 2869;
const scaleFactor = originalW / originalH;
c.height = Math.min(window.innerHeight, window.innerWidth) * 0.98;
c.width = c.height * scaleFactor; = "#e3e3e1"; = "1px solid var(--color-border)";
rects.forEach((r) => {
  const X = (r.x * c.width) / originalW;
  const Y = (r.y * c.height) / originalH;
  const W = (r.w * c.width) / originalW;
  const H = (r.h * c.height) / originalH;;  // ②
  ctx.setTransform(1, 0, 0, 1, X, Y);  // ③
  ctx.fillStyle = `#${r.hex}`;
  ctx.rotate((r.Θ * Math.PI) / 180);
  if (r.hex === "d4a7b5") {  // ④ pink exception
    ctx.moveTo(0, 0);
    ctx.lineTo(W, 0);
    ctx.lineTo(W * 0.68, H);
    ctx.lineTo(0, H);
  } else {
    ctx.fillRect(0, 0, W, H);
  ctx.restore();  // ②

At note ①, getContext("2d") is called on the canvas object. There are a bunch of possible contexts now for that first parameter, with webgl, webgl2, and webgpu roughly corresponding to successive generations of browser APIs for 3D-rendering in the browser using shaders. I’ve never used any of the others. JavaScript libraries like Three.js and Babylon.js make 3D visualizations and games easier to write. One can do some pretty impressive things in the browser.

At note ②, there are calls to .save() and .restore() on the context object. The drawing state is stored on a stack (a last in, first out data structure). The drawing state includes many properties, including stroke- and fill styles, as well as the transformation matrix. Here, I’m mostly interested in the transformation matrix.

.save() pushes some drawing state onto the stack; .restore() pops that state back off the stack. In the p5.js library, the .push and .pop methods are wrappers for this save/restore functionality. Because p5.js is open source, you can see this implementation for yourself here.

At note ③, for each item in the rects array, I call the .setTransform method. It takes six arguments. I’m only interested in changing the location of the origin, which is why I pass in X and Y; the other arguments control scale and skew on the two axes. (I set the scale to 1 — no change — and the skew to 0 — no skew.)

Finally, at note ④, I process the pink quadrilateral exception. When I handled this shape with SVG in the first post of this series, I drew a full rectangle, and used the clip-path property to mask the final shape. In this case, I set the fill before the shape, which is drawn explicitly as a polygon. The moveTo and lineTo methods take X and Y coordinates on the canvas as arguments; the closePath method draws a straight line back to the starting point. There’s a close conceptual relationship between these arguments and the polygon arguments in the clip-path property in the earlier post. Note that clip-path uses percentage based arguments because it’s masking a parent rectangle; in this canvas approach, the parent element is the full canvas, so the coordinates are absolute values within that canvas.

Compare the two side by side, and recalling that for the polygon/clip-path approach, the analogous operation to the closePath method is implicit:

CSS/SVG Approach JS/canvas approach
  clip-path: ctx.beginPath();
          0 0, ctx.moveTo(0, 0);
       100% 0, ctx.lineTo(W, 0);
     68% 100%, ctx.lineTo(W * 0.68, H);
       0 100% ctx.lineTo(0, H);
) ctx.closePath();

The math is made tidier by doing this within a saved drawing context — by shifting the effective origin prior to drawing each shape, there’s no need for any trigonometric calculations to determine where the points should go, only to use the known width and height properties of each object in the rects array.

If I’m feeling pluckish, I might revisit this to do the same with trig, instead of using save and restore.

Anyway, that’s a wrap. Here’s that same approximation of Malevich’s oil again:

Suprematist Composition (1916), oil on canvas, by Kazimir Malevich