Skip to content
Colin Wren
Twitter

Adding zoom and panning to your react-konva stage

JavaScript, Software Development3 min read

zooming and panning in Konva
Apologies for the motions but I wanted to demo the zooming and panning ability

In my last blog I talked about some of the work I’ve been doing with react-konva in order to produce an example user journey map for the new product I’m working on — reciprocal.dev .

In that post I created a very basic user journey which had one logical branch, which, while great for showing the techniques being used isn’t really reflective of the scale of most user journey maps once they have a high level of functionality.

As the width of the Stage was set to window.innerWidth in the example, there is also the constraint of the user’s browser window. Anyone viewing on a small screen or with a small window size would have the map cut off.

Rendering content larger than the stage size

Without adding some additional functionality to your Konva based view there will be no means of accessing anything plotted outside the bounds of the initial stage coordinates.

In order to access this content you can use and combine two techniques that will allow the user to change the stage’s viewbox coordinates (panning) and the scale of the content rendered (zooming).

Panning

Panning is probably the easiest solution to implement, it requires you to add one prop to your Stage component which is draggable .

With the draggable prop the user will now be able to click on your stage and drag it in order to change the current viewbox and by doing so, change what content is visible.

If you already have draggable content on your Stage then you won’t need to worry about those breaking as the Stage drag events only fire when the user selects the ‘background’ of the stage.

1import { Stage, Layer } from 'react-konva';
2import MyLargeComponent from './thingToRenderOnStage';
3
4function App() {
5 return (
6 <Stage width={window.innerWidth} height={window.innerHeight} draggable>
7 <Layer id='stuffToShow'>
8 <MyLargeComponent />
9 </Layer>
10 </Stage>
11 )
12}
The draggable prop allows the user to drag the stage in order to pan around it

Zooming

Zooming is a little more complicated as we need to implement some custom logic to update the scale that the objects in the Stage are rendered to.

The scale of a Stage is comprised of two values — the scaleX and scaleY which can have different values but for uniform scaling they should be set to the same value.

By default the scale of the Stage is 1 . Setting the scale to a value higher than 1 will make the items larger and setting the scale to a value betweem 0 and 1 will make them smaller.

In order to implement a zoom behaviour to our stage we need to add event listeners for the following events:

  • onWheel
  • onTouchMove
  • OnTouchEnd

The first event OnWheel will listen for a user scrolling up and down while the last two are used for touch screen devices in order to detect the user using two fingers to pinch and pull the stage.

When the user does these events we want to:

  • Understand what the current position is relative to the current scale
  • Calculate the new scale based on a zoom factor
  • Calculate that same position based on the new scale
  • Update the Stage to render using the new scale
  • Update the Stage ‘s position to be the same as before but with the new value based on the new scale

For the touch screen based approach we use onTouchEnd to reset the values used to calculate the zoom. Another important step for touch based zooming is that the draggable prop needs to be set to force otherwise Konva gets confused and doesn’t listen to the events properly.

An implementation of this behaviour can be found below.

1import { Stage, Layer } from 'react-konva';
2import MyLargeComponent from './thingToRenderOnStage';
3
4const scaleBy = 1.01;
5
6function getDistance(p1, p2) {
7 return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
8}
9
10function getCenter(p1, p2) {
11 return {
12 x: (p1.x + p2.x) / 2,
13 y: (p1.y + p2.y) / 2,
14 };
15}
16
17function isTouchEnabled() {
18 return ( 'ontouchstart' in window ) ||
19 ( navigator.maxTouchPoints > 0 ) ||
20 ( navigator.msMaxTouchPoints > 0 );
21}
22
23function App() {
24 const stageRef = useRef(null);
25 let lastCenter = null;
26 let lastDist = 0;
27
28 function zoomStage(event) {
29 event.evt.preventDefault();
30 if (stageRef.current !== null) {
31 const stage = stageRef.current;
32 const oldScale = stage.scaleX();
33 const { x: pointerX, y: pointerY } = stage.getPointerPosition();
34 const mousePointTo = {
35 x: (pointerX - stage.x()) / oldScale,
36 y: (pointerY - stage.y()) / oldScale,
37 };
38 const newScale = event.evt.deltaY > 0 ? oldScale * scaleBy : oldScale / scaleBy;
39 stage.scale({ x: newScale, y: newScale });
40 const newPos = {
41 x: pointerX - mousePointTo.x * newScale,
42 y: pointerY - mousePointTo.y * newScale,
43 }
44 stage.position(newPos);
45 stage.batchDraw();
46 }
47 }
48
49 function handleTouch(e) {
50 e.evt.preventDefault();
51 var touch1 = e.evt.touches[0];
52 var touch2 = e.evt.touches[1];
53 const stage = stageRef.current;
54 if (stage !== null) {
55 if (touch1 && touch2) {
56 if (stage.isDragging()) {
57 stage.stopDrag();
58 }
59
60 var p1 = {
61 x: touch1.clientX,
62 y: touch1.clientY
63 };
64 var p2 = {
65 x: touch2.clientX,
66 y: touch2.clientY
67 };
68
69 if (!lastCenter) {
70 lastCenter = getCenter(p1, p2);
71 return;
72 }
73 var newCenter = getCenter(p1, p2);
74
75 var dist = getDistance(p1, p2);
76
77 if (!lastDist) {
78 lastDist = dist;
79 }
80
81 // local coordinates of center point
82 var pointTo = {
83 x: (newCenter.x - stage.x()) / stage.scaleX(),
84 y: (newCenter.y - stage.y()) / stage.scaleX()
85 };
86
87 var scale = stage.scaleX() * (dist / lastDist);
88
89 stage.scaleX(scale);
90 stage.scaleY(scale);
91
92 // calculate new position of the stage
93 var dx = newCenter.x - lastCenter.x;
94 var dy = newCenter.y - lastCenter.y;
95
96 var newPos = {
97 x: newCenter.x - pointTo.x * scale + dx,
98 y: newCenter.y - pointTo.y * scale + dy
99 };
100
101 stage.position(newPos);
102 stage.batchDraw();
103
104 lastDist = dist;
105 lastCenter = newCenter;
106 }
107 }
108 }
109
110 function handleTouchEnd() {
111 lastCenter = null;
112 lastDist = 0;
113 }
114
115 return (
116 <Stage
117 width={window.innerWidth}
118 height={window.innerHeight}
119 draggable={!isTouchEnabled()}
120 onWheel={zoomStage}
121 onTouchMove={handleTouch}
122 onTouchEnd={handleTouchEnd}
123 ref={stageRef}
124 >
125 <Layer id='stuffToShow'>
126 <MyLargeComponent />
127 </Layer>
128 </Stage>
129 )
130}
I’m not a massive fan of having the lastCenter and lastDist as mutable values but as they get reset at the end of the touch event there should be minimal side effects

Keeping things performant

If you have a complex or large map to render on your stage you may find that performance takes a nosedive as soon as you try to pan the stage.

The image shows the drop in the frames per second rendering in Chrome when I performed a pan on my initial implementation.

bad performance caused by perfectDraw
With perfectDraw is enabled it takes a long time to process one frame, let alone 60

Luckily there is an easy fix for this — disabling perfect drawing. By default all objects in Konva are set to be drawn to a high accuracy, which is fine for static rendering but once you start moving things about the computational needs of doing this results in frames taking longer than 16ms to render.

To disable perfect drawing you just need to add a perfectDrawEnabled={false} prop to all your react-konva components, after doing so I had a far more responsive panning experience.

better performance by turning perfectDraw off
With perfectDraw disabled the responsiveness of panning and zooming the Stage improves massively

Summary

Using panning and zooming allows you to render content that is larger than the initial Stage while giving the user tools to interact with the content in a manner that feels familiar.

Konva by default will enable perfect drawing which works well for static content but this will kill the performance of the app when the user interacts with it so turn this off to keep those interactions snappy.