HTML5 Canvas bottoms-up with Konva

Or, how to get the Y-axis to go bottom-up when the normal situation is for it to increase from the top-down. While we are at it, we’ll develop the bones of a plotting class.

I’ll be developing my code with the use of the Konva JS canvas library which is a wrapper for the HTML5 canvas that simplifies canvas concepts and increases developer productivity. Other libs are available and the same concepts should translate.

Is there an out-of-the-tin solution?

The ‘cheap’ solution you will see for this is to use scaleY(-1), which we use to tell Konva to flip everything through the Y axis whilst leaving the X axis alone. This works except it makes text appear reflected.

I wondered for a while about using a matrix transform solution. You see Konva has a baked-in transform capability. Surely, I thought, we can translate the the stage origin from the top left to the centre of the canvas. That would mean positive X would be heading to the right, negative X to the left,… oh but still positive Y heading down from the centre line and negative Y upwards. Hmmm – not a solution.

Also, with a translation solution, we would have the issue of the Y component of paths being upside-down.

So in summary, there is no simple, reliable and robust out-of-the-tin solution.

I decided that, given the time, a more flexible solution would be to front the Konva lib with a structure where we take care of the issue about top-down or bottom-up Y. Actually, I mused, if we commit to that, we can create an entire plotting class that wraps all of the functionality of a graph plotter that we care to code up. We can write that once and use it repeatedly – a good plan overall. Whilst I don’t have time to do the complete job, I’ll use this article to feel through the approach we can use for flipping the Y-axis. I can always come back to it later if I need to do the proper job. So, lets roll up our sleeves and get into it.

Drawing the Grid

Besides X & Y axes, it’s usually a good idea to have a faint grid behind a graph. So lets create these now. I’m going to start by defining a class to wrap everything – besides keeping things tidy, this is a good technique that encourages reuse. I like to think it also keeps my bugs corralled 😉

function vwGraph(opts){
 
  this.drawGrid = function drawGrid(...){
   ... 
  }
}
...
let myGraph = new vwGraph({graph settings...});
// now we can use myGraph.plot() and other functions inside myGraph.

Let’s consider the options we pass in to a new vwGraph object. When we set up a graph we will want a title, and some data to plot. Faced with real-world piece of graph plotting paper we would be free of the next consideration, which is what size to draw the graph. But since we don’t have that certainty here, I’m going to add an option for setting the plot size, though for your homework you could later make this depend on the data we are plotting. Just before I do that, I’m also passing it a reference to the Konva stage object which is handling the actual drawing on the canvas – this will make more sense a little later on when it is time to draw.

let myGraph = new vwGraph({
     stage: stage,
     range: {posX: 1000, posY: 800, negX: -500, negY: -300}
});

So I’m telling the new vwGraph object about the positive and negative extent of the X & Y axes. In this example, I am expecting a 1000 pixel plot space for positive X values, and -500 for negative X. For Y it’s 800 and -300. Which will produce something like the image below.

Important point: Because we have not moved the origin of our plot it lies at the default for a canvas which is where (0, 0) indicated by A in the image, is located – at the top left corner of the canvas – so the yellow shaded area is shown to illustrate that our output is being plotted ‘top-down’ with the negative Y heading into the hidden area off the top of the canvas, and the negative X is in the boonies to the left.

We haven’t fixed up the axes, numbers or grid yet so lets get that done then we can look at how best to modify for bottom-up plotting which was, after all, the main reason for starting all this!

The Axes & Grid Lines

We need our X-axis to be drawn from the extreme left of our plot to the extreme right. From our options that means the range.negX to range.posX, or -300 to +1000 in the example we are using . For the Y-axis, in top-down mode, we want our axis line to run from range.negY to range.posY, or -330 to +800.

/** Developing a graphing component based on Konva.
*/

// Set up a stage
let stage = new Konva.Stage({
  container: "container",
  width: window.innerWidth,
  height: 400,
  draggable: true
  }),
    
  // add a layer to draw on
  layer = new Konva.Layer();
   
stage.add(layer);
stage.draw();

// Instantiate a vwGraph object and pass it the initial options to draw the grid.
let d = new vwGraph({
  stage: stage,
  range: {posX: 1000, posY: 1000, negX: -500, negY: -300} 
  })

d.drawGrid(50);

function vwGraph(opts){
  
  let range = opts.range, stage = opts.stage;

  // calculate the width and height of the range
  range.width = range.posX + Math.abs(range.negX);
  range.height = range.posY + Math.abs(range.negY);

  // draw the grid
  this.drawGrid = function drawGrid(step){
    let gridLayer = new Konva.Layer({listening: false}),
        ln = new Konva.Line({x: 0, y: 0, points: [], stroke: 'black', strokeWidth: 0.5, listening: false, perfectDrawEnabled: false, transformsEnabled: 'position', shadowEnabled: false, shadowForStrokeEnabled: false }),
        txt = new Konva.Text({x:0, y: 0, width: 40, height: 10, fontSize: 10, fontFamily: 'Calibri', align: 'left', fill: 'black', listening: false, perfectDrawEnabled: false, transformsEnabled: 'position', shadowEnabled: false, shadowForStrokeEnabled: false}),
        ln1 = null, 
        t1 = null;

    // output the x-axis
    ln1 = ln.clone({x: range.negX, y: 0, points: [0, 0, range.width, 0], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)
    
    // output the Y axis
    ln1 = ln.clone({x: 0, y: range.negY, points: [0, 0, 0, range.height], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)

    // X axis lines & text
    for (let i = 0; i <= range.width; i = i + step ){
      ln1 = ln.clone({ x:  range.negX + i, y: range.negY, points: [0, 0, 0, range.height]});
      t1 = txt.clone({x:  range.negX + i - 20, y: 5, text:  range.negX + i, align: 'center'});
      ln1.cache();
      t1.cache();
      gridLayer.add(ln1, t1);      
    }    
 
    // Y axis lines & text
    for (let i = 0; i <= range.height; i = i + step ){
      ln1 = ln.clone({x: range.negX, y: range.negY + i, points: [0, 0, range.width, 0]});
      t1 = txt.clone({x: 10, y: range.negY + i - 5, text:   range.negY + i});
      ln1.cache();
      t1.cache();
      gridLayer.add(ln1, t1);      
    }    
   
    stage.add(gridLayer);
    gridLayer.batchDraw();
  }  

  return this;
}

Lines 1 – 16 are housekeeping for Konva where we define a stage and layer to draw on. The stage is draggable so that you can explore the negative regions of the plot.

Line 26 onwards is where the vwGraph class is defined.

Line 18 is where we call for a vwGraph instance to come to life, passing the Konva stage object and plot range data, then line 24 is where we ask it to draw the grid. Lets look at that specifically.

// Instantiate a vwGraph object and pass it the initial options to draw the grid.
let d = new vwGraph({
  stage: stage,
  range: {posX: 1000, posY: 1000, negX: -500, negY: -300} 
  })

d.drawGrid(50);

Inside the vwGraph, we store the stage and range information – we aren’t going to use it immediately so we need to store it. A couple of things we will need to know at the time we create the grid are the width and height of the X and Y-axes, so we calculate the range width and height and store those values in the range object. That’s it at this point in time – we just instantiated the vwGraph object into the myGraph variable, but nothing visible will happen until we call myGraph.drawGrid(), passing the value 50 which is used to define the gap between the numbers and grid lines.

Lines 36 – 40 are related to Konva and set up the line and text shapes that we will clone as we need them for our axes and grid lines. Line 42 is the first time we do anything interesting – we output the X axis line.

    // output the x-axis
    ln1 = ln.clone({x: range.negX, y: 0, points: [0, 0, range.width, 0], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)

The intent of the command here is to create a Konva.Line object. The ‘x’ and ‘y’ attributes of the definition object are the position on the canvas where the line will start – namely x = range.negX and y = origin Y. We then use the ‘points’ attribute to provide the four co-ordinates of the line ends relative to the (x, y) position. So that means drawing from a line from (0, 0) that is range.width long. If we got that right we will have a bold line extending from x = -500 to x = 1500. The same process is repeated for the Y axis.

The code that refers to ln1.cache() and gridLayer.add(ln1) are telling Konva something important. The cache line is an optimisation technique that reduces the processing strain on Konva when it has to redraw the canvas output, and the gridlayer.add() tells Konva to add the line object to the output.

Drawing the grid lines

The grid lines are drawn at line 53. How this works is that we set up a for-next loop starting at zero and extending by the step value we passed into the drawGrid() function, until the value equals the range.width. That means a sequence from 0 to 1300 in steps of 50, based on the data we fed the graph.

For the vertical grid lines, we draw at every step a vertical line from the range.negY to range.posY.

    // X axis lines & text
    for (let i = 0; i <= range.width; i = i + step ){
      ln1 = ln.clone({ x:  range.negX + i, y: range.negY, points: [0, 0, 0, range.height]});
      t1 = txt.clone({x:  range.negX + i - 20, y: 5, text:  range.negX + i, align: 'center'});
      ln1.cache();
      t1.cache();
      gridLayer.add(ln1, t1);      
    }    

The Y-axis grid lines are output from line 62. This follows that same pattern as the X-axis and grid. Importantly this is part of what we will focus on when we switch to bottom-up mode!

Finally, with the line and text objects all set up and added into the Konva layer, we add the layer to Konva and tell Konva to draw the grid layer on its next drawing pass so we can see it. Here’s the full working code so far.

Moving the Origin

I know – I promised you a bottom-up Y axis, we’ll hit that next, but I just wanted to take the time to move the origin away from the top corner. In the code below, you can see I added the origin to the definition object, setting the origin at x = 100 and y = 250.

let d = new vwGraph({
  stage: stage,
  origin: {x: 100, y: 250},
  range: {posX: 1000, posY: 1000, negX: -500, negY: -300} 
  })

Now we modify the top of the vwGraph class to store the origin.

  let origin = opts.origin,
      range = opts.range, stage = opts.stage;

At this point we need to start to use our origin. We aren’t going to use anything clever like a canvas transform – we’re going with plain math. We add to the vwGraph() class the getX() and getY() methods as below.

  // turn a logical x into a physical x
  function getX(x){
    return x + origin.x;
  }

  // turn a logical x into a physical x
  function getY(y, msg){
    return origin.y + y;
  }

Now, we need to use these functions whenever we would give an X or Y position. Meaning the code from our X-axis changes as below:

// BEFORE //
    // output the x-axis
    ln1 = ln.clone({x: range.negX, y: 0, points: [0, 0, range.width, 0], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)

// AFTER //
// output the x-axis
    ln1 = ln.clone({x: getX(range.negX), y: getY(0), points: [0, 0, range.width, 0], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)

The result of this is that our plot is now centered at a canvas point (x: 100, y: 250), which we can see by counting using the axis grid markers.

Making the Y-axis bottom up

At last – back to the main reason for this post ! Ok – so we need to make our Y-axis increase as we go up the page, and decrease as we go down. We can’t say for sure that we will always want every plot to operate from the bottom-up, so I’m doing to make another initialisation parameter for the vbGraph to inform it how I want the Y-axis to work.

let d = new vwGraph({
  stage: stage,
  origin: {x: 100, y: 250},
  range: {posX: 1000, posY: 1000, negX: -500, negY: -300},
  direction: 'y-up'
  })

You can see above, at line 5, the new ‘direction’ parameter with the value ‘y-up’. And below the corresponding modification inside the class to turn the direction value into a positive or negative value of 1. If you aren’t familiar with the format of the direction setting line, there’s nothing clever there but a shorthand replacement for if-then called the JavaScript ternary operator. The point is that is the direction requested is ‘y-up’ we’ll be using the value -1, otherwise we’ll be using +1.

  let origin = opts.origin,
      range = opts.range, stage = opts.stage,
      direction = opts.direction === 'y-up' ? -1 : 1;

How this gets used is mostly inside our getY() function, which changes as below. The cheap -1/+1 trick allows us to flip any given value of Y around so that instead of a negative heading up the page, the opposite occurs.

  // turn a logical x into a physical x
  function getY(y){
    return origin.y + (direction * y);
  }

However, there are a couple of associated issues to cover. First, we previously used the range.negY value, which was at the top of the plot, as a fixed position from which to draw. But now we may be flipping that value meaning we need something else more concrete as out top Y position for drawing the grid. To achieve this we add a computed minY value to the range object as below.

  // Get a reliable top Y position regardless of direction 
  range.minY = direction === 1 ? range.negY : range.posY;

We then replace range.negY with range.minY wherever it is used. The only trick here is line 18 below where, to set the text value we need to use the direction to get the correct grid line number.

    // output the Y axis
    ln1 = ln.clone({x: getX(0), y: getY(range.minY), points: [0, 0, 0, range.height], strokeWidth: 1.5});
    ln1.cache();
    gridLayer.add(ln1)

    // X axis lines & text
    for (let i = 0; i <= range.width; i = i + step ){
      ln1 = ln.clone({ x:  getX(range.negX) + i, y: getY(range.minY), points: [0, 0, 0, range.height]});
      t1 = txt.clone({x:  getX(range.negX) + i - 20, y: getY(0) + 5, text:  range.negX + i, align: 'center'});
      ln1.cache();
      t1.cache();
      gridLayer.add(ln1, t1);      
    }    
 
    // Y axis lines & text
    for (let i = 0; i <= range.height; i = i + step ){
      ln1 = ln.clone({x: getX(range.negX), y: getY(range.minY) + i, points: [0, 0, range.width, 0]});
      t1 = txt.clone({x: getX(10), y: getY(range.minY) + i - 5, text:   range.minY + (direction * i)});      
      ln1.cache();
      t1.cache();
      gridLayer.add(ln1, t1);      
    }  

The image below shows the difference between y-up and y-down. With y-up we see ascending values of Y along the upwards Y axis. Meanwhile y-down is the opposite.

The code for all this is show below in codepen. Click the ‘Edit on Codepen’ link to see it in a larger form.

Plotting data

Now we have a grid it would be a shame not to plot some data. I modified the code to accept a title object, and include a plotData() function that takes a definition object including plot type, color, and in this case some points to plot. I used the Konva.Path object which takes a data object for the plot points.

Below is an image of the plotted data for average Tasmanian wombat IQ over the last 40 years – looks like our IQ is suffering with global warming, but maybe the stats are wrong.

The extra trick here that is worthy of note is the introduction of a new getRelativeY() function which is used with the path conversion to bottom-up. The getRelativeY() function differs from plain getY() in that it does not add the origin.y value.

Here’s the code:

Points to note

This example has avoided any user-driven interaction. Since we went bottom-up, you might have to similarly convert the mouse or touch event co-ordinates for clicks, etc, that will depend on your use-case.

It has to be said that there are very capable graphing libs around – D3 from D3.org is possibly the pinnacle and worthy of your time to understand how to use it. Many, many more are available.

Summary

We set out to find a way to use Konva to present graphed data where the Y-axis is displayed up the page, rather than the default top-down mode. We’ve achieved that, and finished with a viable, if primitive, graphing component. The code I presented is based on Konva, but you could replace the few Konva commands with your preferred canvas lib – which is, of course, the point of wrapping the lower-down canvas interactions in this way.

Thanks for reading.

VW Jan 2021

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: