Konva – drawing a grid over the stage

Putting a grid over a Konva diagram is a common use case so I thought I would explore some of the options and explain a little about what goes on when zooming the Stage.

Questions to be covered

  • what do Stage width & height mean?
  • can we draw beyond stage size?
  • visualising stage movement when zooming at a point
  • how to draw a simple grid on the stage
  • how to draw a grid on the viewport area (what you really want and best performance)

The code for this article is available on CodePen here. The zoom-at-a-point code is included, as are the four solutions for grid drawing that are mentioned below. You might want to open it and have a play before reading onwards.

First let’s just get our heads around how the stage ‘moves’ as it is scaled. The zoom-at-a-point algorithm ‘moves’ the stage – it has to so that the point under the cursor can stay in position. Look at this diagram which represents a viewport, or HTML5 canvas element (red border) containing a Konva stage (blue rectangle). On the stage we have a small rect and a circle, and a point is shown by a green cross. How will the stage move if we zoom in and out around the green cross.

Image of the stage initially matching the dimensions of the canvas viewport
Starting point – stage fills the viewport

Just before we get to that, let’s clear up the stage width and height parameters. When we create a Konva stage we are actually creating an HTML5 canvas. Well, actually a canvas per layer. But sticking to the stage creation step, what do the width & height parameters affect? Answer – only the size of the canvas element. Don’t believe me? Hit F12 in your browser on any of the Konva demo pages and look at the elements where you see the Konva diagram. It’s a canvas element with the width and height set as HTML element as properties and in the style property.

So…do the width & height limit the area in which you can draw? Answer – no. You can make a Konva stage 100px by 100px then draw a shape at {x: 2000, y: 5000}. You will not initially see the shape but it is there – make the stage draggable and you will be able to drag a far-offscreen shape it in to view. So…how large is the stage ? Theoretically infinite. In practical terms we work with a canvas to draw diagrams and pictures that are intended to be displayed, meaning that we have to consider the performance impact that an overly large stage might produce – drawing a massive canvas scaled down to 100px square gives your graphics processor a headache. But as I said, theoretically infinite.

Ok – back to the image. Here it is after we have zoomed into the diagram – increasing the scale by around 10%. We can see the top-left corner of the stage has moved up and left and is now slightly outside the red viewport. And meanwhile the bottom-right of the stage has moved right and down, again extending outside the viewport.

Image of the stage extending outside the viewport after zooming in by 10%
After zooming in at the green cross – stage extends beyond the viewport

What does ‘outside the viewport’ mean in the physical world? Answer – the blue area outside the red rectangle is clipped off, or put another way it is invisible. You can think of this as the inside of red box being a cut-out through which we are looking, and we can only see whatever is ‘below’ the cut-out – not anything that is outside of the cut-out area. But as I mentioned above, the shapes and stage area outside of the cut-out viewport still exist in Konva’s model.

Co-incidentally, the Konva Stage (and layers, groups, and shapes) has the method toDataURL() to grab an image of the stage. Though the default mode is to snapshot only the viewport content, this method can snapshot the entire stage, even though some of it may not be visible in the canvas viewport element – you need to set the x, y, width & height of the the options for the toDataURL() function to do this. This behaviour underlines that even though it is not displayed, the out-of-view area of the Stage still exists. I added buttons to the demo code – one to snapshot the viewport and another to snapshot the viewport and a region 200px around it. Drag the stage so as some of it is outside the viewport and try both. The results are interesting.

Finally the image below shows the situation when the stage has been zoomed out from the green cross point, reducing the scale by 10%. In this case we can see that both the stage top-left and bottom-right have come inside the viewport boundary. This will be mentioned shortly when we look at drawing a grid on the stage – think about how that will look if we only draw the grid on the blue (Stage) area.

Image of the stage moving inside the viewport after zooming out by 10%
After zooming out – stage within the viewport with blank space around it

What about a grid solution?

Ah yes, so now we have visualised what occurs when the stage is scaled up and down, we can think about how to put a grid on the stage. The best UX is probably a grid that covers the entire viewport regardless of the scale applied to the stage. What I mean is, as per the image below, simply drawing a grid on the stage falls apart slightly when the stage is zoomed out because the gaps around the stage do not have a grid.

A better solution is to have the grid cover the entire viewport area as in the image below.

Some quirky computations are required to navigate the solution. They arise because the stage is scaled but the viewport, and the stage position are not. The easiest approach is to grasp the nettle and decide which scaling space to work in, then compute the rectangles in play.

To that end I give you the following code which defines simple, plain, dumb JS objects to represent the rectangles we need to consider.

   let   stageRect = {
            x1: 0, 
            y1: 0, 
            x2: stage.width(), 
            y2: stage.height(),
            offset: {
              x: unScale(this.stage.position().x),
              y: unScale(this.stage.position().y),
            }
          },
         // make a rect to describe the viewport
         viewRect = {
            x1: -stageRect.offset.x,
            y1: -stageRect.offset.y,
            x2: unScale(width) - stageRect.offset.x,
            y2: unScale(height) - stageRect.offset.y
          },
          // and find the largest rectangle that bounds both the stage and view rect.
          // This is the rect we will draw on.  
          fullRect = {
            x1: Math.min(stageRect.x1, viewRect.x1),
            y1: Math.min(stageRect.y1, viewRect.y1),
            x2: Math.max(stageRect.x2, viewRect.x2),
            y2: Math.max(stageRect.y2, viewRect.y2)          
          };

StageRect represents the stage. Because the stage’s (0, 0) point is the origin for all drawing, I set the stageRect.x and y to zero. The rect structures don’t have a width or height – instead I use x2 and y2 to represent the position of the right and bottom edges – we can do the math to find the dimensions from these as we need to. For the stageRect the x2 position is the stage.width(), and for y2 it’s stage.height().

The stage also has an offset position (do not confuse this with the Konva.Stage.offset!). At the initial draw this is (0, 0), but as soon as we start to scale up or down around a point the stage will be ‘moved’ as described earlier in this article, and so we need to compute the effective offset. I use an ‘unScale’ function for readability – this simply returns the given value divided by the scale.

Why do we have to unScale() the stage position? Answer – because the stageRect is constructed at the current scale, which could be x2, x0.9, x0.5 etc, and the stage position values are always at x1 scale. As I said, I opted to do the math with the scale applied to avoid shuttling between scaled and non-scaled measurements. Using unScale here helps with that.

Having established the stageRect, we turn to the viewRect, which represents the viewport from the perspective of the scaled stage. Remember that all drawing happens in relation to the stage – this is why I treat the scaled stage as the boss.

Having constructed the rectangle structures representing stage and viewport, we determine the rectangle that will include them both – the bounding box. This is the fullRect. The fullRect gives us the starting point, width and height of the box that we will draw our grid lines upon.

Solution 1 draws the grid on the stage – this is the most simple approach. Zoom-in works fine, but when we zoom out we get blank areas around the stage. This is because we rely on the Stage.width() & .height() which are only relevant when we define the stage.

      const stepSize = 40; // set a value for the grid step gap.
            
      function drawLinesSolution1(){      

        const xSize= stage.width(), 
              ySize= stage.height(),
              xSteps = Math.round(xSize/ stepSize), 
              ySteps = Math.round(ySize / stepSize);

        // draw vertical lines
        for (let i = 0; i <= xSteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: i * stepSize,
              points: [0, 0, 0, ySize],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })
          );
        }
        //draw Horizontal lines
        for (let i = 0; i <= ySteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              y: i * stepSize,
              points: [0, 0, xSize, 0],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })
          );
        }
        
        this.gridLayer.batchDraw();
        
      }

Next, Solution 2 draws the grid on the merged stage & viewport space. This is better because when we zoom in we draw the grid on the space around the stage.

      function drawLinesSolution2(){

        const 

          // find the x & y size of the grid
          xSize = (fullRect.x2 - fullRect.x1),
          ySize = (fullRect.y2 - fullRect.y1), 
            
          // compute the number of steps required on each axis.
          xSteps = Math.round(xSize/ stepSize), 
          ySteps = Math.round(ySize / stepSize);

        // draw vertical lines
        for (let i = 0; i <= xSteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1 + i * stepSize,
              y: fullRect.y1,
              points: [0, 0, 0, ySize],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })               
          );
        }
        //draw Horizontal lines
        for (let i = 0; i <= ySteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1,
              y: fullRect.y1 + i * stepSize,
              points: [0, 0, xSize, 0],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })
          );
        }
        
        this.gridLayer.batchDraw();        
      }

Solution 2 is a step forward to a better UX, but it has a fatal flaw, which is that the origin of the grid should be the top-left corner of the stage, but with this solution it is not. This is because we draw the grid from the stage offset point. And this is unlikely to be an exact multiple of the grid step gap. The effect is that an object on the stage will appear to dance around the grid – whereas in truth the shape remains in the same position whilst the grid moves as we zoom in & out.

The fix for this is to modify the components of the stageRect.offset to ensure that they are multiple of the grid step gap. To keep things simple I will compute an adjustment structure – gridOffset, then a gridRect that uses this offset, and finally the larger merged rect combining the gridRect and stageRect. There’s one more trick – because we mode the drawing start point left & up we have to increase the length we draw over by adding one grid step (stepSize) to both width and height. This avoids having a missing grid line at the right and bottom edges.

          gridOffset = {
            x: Math.ceil(unScale(this.stage.position().x) / stepSize) * stepSize,
            y: Math.ceil(unScale(this.stage.position().y) / stepSize) * stepSize,
          };
          gridRect = {
            x1: -gridOffset.x,
            y1: -gridOffset.y,
            x2: unScale(width) - gridOffset.x + stepSize,
            y2: unScale(height) - gridOffset.y + stepSize
          };
          gridFullRect = {
            x1: Math.min(stageRect.x1, gridRect.x1),
            y1: Math.min(stageRect.y1, gridRect.y1),
            x2: Math.max(stageRect.x2, gridRect.x2),
            y2: Math.max(stageRect.y2, gridRect.y2)          
          };

I save the output as gridFullRect and use this in Solution 3 which is identical to Solution 2 except for gridFullRect replaces fullRect.

      function drawLinesSolution3(){

        let fullRect = gridFullRect; // note the only difference between solutions 2 and 3!

        const 
          // find the x & y size of the grid
          xSize = (fullRect.x2 - fullRect.x1),
          ySize = (fullRect.y2 - fullRect.y1), 
            
          // compute the number of steps required on each axis.
          xSteps = Math.round(xSize/ stepSize), 
          ySteps = Math.round(ySize / stepSize);

        // draw vertical lines
        for (let i = 0; i <= xSteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1 + i * stepSize,
              y: fullRect.y1,
              points: [0, 0, 0, ySize],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })               
          );
        }
        //draw Horizontal lines
        for (let i = 0; i <= ySteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1,
              y: fullRect.y1 + i * stepSize,
              points: [0, 0, xSize, 0],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })
          );
        }
        
        this.gridLayer.batchDraw();        
      }

Solution 4 is the same as solution 3 with the addition of a clipping region being set on the grid layer. This stops any lines being drawn outside of the viewport potentially saving some performance burden. To witness, select Soluton #3, zoom out 2 steps and drag the stage to the right – note the blue content outside the red box, includes grid lines. Now switch to Solution #4 and repeat the test, noting that there are no grid lines outside the red box because the clipping function stops the segments that are outside the red box being drawn. Since drawing is a costly step, clipping reduces the performance cost.

      function drawLinesSolution4(){
  
        // set clip function to stop leaking lines into non-viewable space.
        gridLayer.clip({
          x: viewRect.x1,
          y: viewRect.y1,
          width: viewRect.x2 - viewRect.x1,
          height: viewRect.y2 - viewRect.y1
        });        
  
        let fullRect = gridFullRect;
        
        const 
          // find the x & y size of the grid
          xSize = (fullRect.x2 - fullRect.x1),
          ySize = (fullRect.y2 - fullRect.y1), 
            
          // compute the number of steps required on each axis.
          xSteps = Math.round(xSize/ stepSize), 
          ySteps = Math.round(ySize / stepSize);

        // draw vertical lines
        for (let i = 0; i <= xSteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1 + i * stepSize,
              y: fullRect.y1,
              points: [0, 0, 0, ySize],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })               
          );
        }
        //draw Horizontal lines
        for (let i = 0; i <= ySteps; i++) {
          this.gridLayer.add(
            new Konva.Line({
              x: fullRect.x1,
              y: fullRect.y1 + i * stepSize,
              points: [0, 0, xSize, 0],
              stroke: 'rgba(0, 0, 0, 0.2)',
              strokeWidth: 1,
            })
          );
        }
        
        this.gridLayer.batchDraw();        
      }

Summary

We’ve visualised what happens when we zoom-at-a-point on the Stage. We’ve used that knowledge to construct an approach to grid drawing and we’ve applied some optimisation to that process.

Next steps would be

  • to review further performance opportunities in the Konva docs
  • think about adding labels to the grid like a ruler, and how to change those as zoom-in and -out happens
  • think about caching zoom grid layers and switching on the layer that best matches the scale in use at any time.
  • When the diagram is scaled, so are the grid lines. How about keeping the scale of the grid lines pin-sharp and consistent while the diagram is scaled. Maybe think about keeping the grid layer scale x1…

Let me know if you have any suggestions for other improvements to the grid solutions in the article.

Thanks for reading.

VW. Dec 2021

4 thoughts on “Konva – drawing a grid over the stage

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 )

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: