Earlier this month on the Animation at Work Slack, we had a discussion about finding a way to let users pan inside an SVG.
I made this demo below to show how I’d approach this question:
Here are the four steps to make the above demo work:
- Get mouse and touch events from the user
- Calculate the mouse offsets from its origin
- Save the new
viewBox
coordinates - Handle dynamic viewport
Let’s check those steps one by one more thoroughly.
#1. Mouse & Touch Events
To get the mouse or touch position, we first need to add event listeners on our SVG. We can use the Pointer Events to handle all kind of pointers (mouse/touch/stylus/…) but those events are not yet supported by all browsers. We will need to add some fallback to make sure all users will be able to drag the SVG.
// We select the SVG into the page
var svg = document.querySelector('svg');
// If browser supports pointer events
if (window.PointerEvent) {
svg.addEventListener('pointerdown', onPointerDown); // Pointer is pressed
svg.addEventListener('pointerup', onPointerUp); // Releasing the pointer
svg.addEventListener('pointerleave', onPointerUp); // Pointer gets out of the SVG area
svg.addEventListener('pointermove', onPointerMove); // Pointer is moving
} else {
// Add all mouse events listeners fallback
svg.addEventListener('mousedown', onPointerDown); // Pressing the mouse
svg.addEventListener('mouseup', onPointerUp); // Releasing the mouse
svg.addEventListener('mouseleave', onPointerUp); // Mouse gets out of the SVG area
svg.addEventListener('mousemove', onPointerMove); // Mouse is moving
// Add all touch events listeners fallback
svg.addEventListener('touchstart', onPointerDown); // Finger is touching the screen
svg.addEventListener('touchend', onPointerUp); // Finger is no longer touching the screen
svg.addEventListener('touchmove', onPointerMove); // Finger is moving
}
Because we could have touch events and pointer events, we need to create a tiny function to returns to coordinates either from the first finger either from a pointer.
// This function returns an object with X & Y values from the pointer event
function getPointFromEvent (event) {
var point = {x:0, y:0};
// If event is triggered by a touch event, we get the position of the first finger
if (event.targetTouches) {
point.x = event.targetTouches[0].clientX;
point.y = event.targetTouches[0].clientY;
} else {
point.x = event.clientX;
point.y = event.clientY;
}
return point;
}
Once the page is ready and waiting for any user interactions, we can start handling the mousedown/touchstart events to save the original coordinates of the pointer and create a variable to let us know if the pointer is down or not.
// This variable will be used later for move events to check if pointer is down or not
var isPointerDown = false;
// This variable will contain the original coordinates when the user start pressing the mouse or touching the screen
var pointerOrigin = {
x: 0,
y: 0
};
// Function called by the event listeners when user start pressing/touching
function onPointerDown(event) {
isPointerDown = true; // We set the pointer as down
// We get the pointer position on click/touchdown so we can get the value once the user starts to drag
var pointerPosition = getPointFromEvent(event);
pointerOrigin.x = pointerPosition.x;
pointerOrigin.y = pointerPosition.y;
}
#2. Calculate Mouse Offsets
Now that we have the coordinates of the original position where the user started to drag inside the SVG, we can calculate the distance between the current pointer position and its origin. We do this for both the X and Y axis and we apply the calculated values on the viewBox
.
// We save the original values from the viewBox
var viewBox = {
x: 0,
y: 0,
width: 500,
height: 500
};
// The distances calculated from the pointer will be stored here
var newViewBox = {
x: 0,
y: 0
};
// Function called by the event listeners when user start moving/dragging
function onPointerMove (event) {
// Only run this function if the pointer is down
if (!isPointerDown) {
return;
}
// This prevent user to do a selection on the page
event.preventDefault();
// Get the pointer position
var pointerPosition = getPointFromEvent(event);
// We calculate the distance between the pointer origin and the current position
// The viewBox x & y values must be calculated from the original values and the distances
newViewBox.x = viewBox.x - (pointerPosition.x - pointerOrigin.x);
newViewBox.y = viewBox.y - (pointerPosition.y - pointerOrigin.y);
// We create a string with the new viewBox values
// The X & Y values are equal to the current viewBox minus the calculated distances
var viewBoxString = `${newViewBox.x} ${newViewBox.y} ${viewBox.width} ${viewBox.height}`;
// We apply the new viewBox values onto the SVG
svg.setAttribute('viewBox', viewBoxString);
document.querySelector('.viewbox').innerHTML = viewBoxString;
}
If you don’t feel comfortable with the concept of viewBox
, I would suggest you first read this great article by Sara Soueidan.
#3. Save Updated viewBox
Now that the viewBox
has been updated, we need to save its new values when the user stops dragging the SVG.
This step is important because otherwise we would always calculate the pointer offsets from the original viewBox
values and the user will drag the SVG from the starting point every time.
function onPointerUp() {
// The pointer is no longer considered as down
isPointerDown = false;
// We save the viewBox coordinates based on the last pointer offsets
viewBox.x = newViewBox.x;
viewBox.y = newViewBox.y;
}
#4. Handle Dynamic Viewport
If we set a custom width on our SVG, you may notice while dragging on the demo below that the bird is moving either faster or slower than your pointer.
On the original demo, the SVG’s width is exactly matching its viewBox
width. The actual size of your SVG may also be called viewport
. In a perfect situation, when the user is moving their pointer by 1px
, we want the viewBox
to translate by 1px
.
But, most of the time, the SVG has a responsive size and the viewBox
will most likely not match the SVG viewport
. If the SVG’s width is twice as big than the viewBox
, when the user moves their pointer by 1px
, the image inside the SVG will translate by 2px
.
To fix this, we need to calculate the ratio between the viewBox
and the viewport
and apply this ratio while calculating the new viewBox
. This ratio must also be updated whenever the SVG size may change.
// Calculate the ratio based on the viewBox width and the SVG width
var ratio = viewBox.width / svg.getBoundingClientRect().width;
window.addEventListener('resize', function() {
ratio = viewBox.width / svg.getBoundingClientRect().width;
});
Once we know the ratio, we need to multiply the mouse offsets by the ratio to proportionally increase or reduce the offsets.
function onMouseMove (e) {
[...]
newViewBox.x = viewBox.x - ((pointerPosition.x - pointerOrigin.x) * ratio);
newViewBox.y = viewBox.y - ((pointerPosition.y - pointerOrigin.y) * ratio);
[...]
}
Here’s how this works with a smaller viewport
than the viewBox
width:
And another demo with a viewport
bigger than the viewBox
width:
#[Bonus] Optimizing the code
To make our code a bit shorter, there are two very useful concepts in SVG we could use.
#SVG Points
The first concept is to use SVG Points instead of basic Javascript objects to save the pointer’s positions. After creating a new SVG Point variable, we can apply some Matrix Transformation on it to convert the position relative to the screen to a position relative to the current SVG user units.
Check the code below to see how the functions getPointFromEvent()
and onPointerDown()
have changed.
// Create an SVG point that contains x & y values
var point = svg.createSVGPoint();
function getPointFromEvent (event) {
if (event.targetTouches) {
point.x = event.targetTouches[0].clientX;
point.y = event.targetTouches[0].clientY;
} else {
point.x = event.clientX;
point.y = event.clientY;
}
// We get the current transformation matrix of the SVG and we inverse it
var invertedSVGMatrix = svg.getScreenCTM().inverse();
return point.matrixTransform(invertedSVGMatrix);
}
var pointerOrigin;
function onPointerDown(event) {
isPointerDown = true; // We set the pointer as down
// We get the pointer position on click/touchdown so we can get the value once the user starts to drag
pointerOrigin = getPointFromEvent(event);
}
By using SVG Points, you don’t even have to handle transformations applied on your SVG! Compare the following two examples where the first is broken when a rotation is applied on the SVG and the second example uses SVG Points.
#SVG Animated Rect
The second unknown concept in SVG we can use to shorten our code, is the usage of Animated Rect.
Because the viewBox
is actually considered as an SVG Rectangle (x, y, width, height), we can create a variable from its base value that will automatically update the viewBox
if we update this variable.
See how easier it is now to update the viewBox
of our SVG!
// We save the original values from the viewBox
var viewBox = svg.viewBox.baseVal;
function onPointerMove (event) {
if (!isPointerDown) {
return;
}
event.preventDefault();
// Get the pointer position as an SVG Point
var pointerPosition = getPointFromEvent(event);
// Update the viewBox variable with the distance from origin and current position
// We don't need to take care of a ratio because this is handled in the getPointFromEvent function
viewBox.x -= (pointerPosition.x - pointerOrigin.x);
viewBox.y -= (pointerPosition.y - pointerOrigin.y);
}
And here is the final demo. See how much shorter the code is now? 😀