Adding zoom and panning to your react-konva stage
— JavaScript, Software Development — 3 min read
![zooming and panning in Konva](/0d0cce259e54191a8075c366b675db24/part-1.gif)
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}
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.clientY63 };64 var p2 = {65 x: touch2.clientX,66 y: touch2.clientY67 };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 point82 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 stage93 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 + dy99 };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 <Stage117 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}
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 bad performance caused by perfectDraw](/static/fc0d3095064fbbce4de795cfb0a2f564/7842b/part-2.png)
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 better performance by turning perfectDraw off](/static/198107280b69701854bb929b16e3dccf/7842b/part-3.png)
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.