SVG (Scalable Vector Graphics) is a powerful tool for creating interactive and dynamic graphics on the web. One interesting feature is the ability to animate and manipulate shapes along a predefined path. In this article, we will explore the step-by-step process of implementing this feature using TypeScript.
Table of Contents
HTML Markup
First, let's define an HTML file called index.html
that will serve as the container for our SVG element and any accompanying JavaScript and CSS files.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1">
<title>Tutorial</title>
</head>
<body>
</body>
</html>
Let's add an SVG element with a width of 500 pixels and a height of 300 pixels using the following code:
<svg
id="svg"
xmlns="http://www.w3.org/2000/svg"
width="500"
height="300">
</svg>
The <svg>
tag serves as the container for our upcoming animation along the SVG ellipse path.
Now let's create an ellipse element that will be used as the motion path. The ellipse is defined with a center point at coordinates (250, 150), which is half the height and width of the SVG container.
<svg
id="svg"
xmlns="http://www.w3.org/2000/svg"
width="500"
height="300">
<ellipse
cx="250"
cy="150"
rx="245"
ry="145"
fill="none"
stroke="#3d8ea7"
stroke-width="10"></ellipse>
</svg>
The stroke of an ellipse determines the outline or boundary of the shape. In our case, the ellipse has a stroke defined by the attributes stroke="#3d8ea7"
and stroke-width="10"
. This means the stroke color is set to a shade of green, and the stroke width is set to 10 units.
When a stroke is applied to an ellipse, it typically follows the boundary of the shape. In this case, with a stroke width of 10 units
, the stroke will extend 5 units
inside the ellipse and 5 units
outside the ellipse.
This results in half of the stroke being rendered inside the ellipse, while the other half is rendered outside. Therefore, the horizontal and vertical radius should be 5 units less (245, 145)
, otherwise, the ellipse will go outside the SVG container.
Now let's add the circle shape that will be dragged by mouse along the ellipse path:
<svg
id="svg"
xmlns="http://www.w3.org/2000/svg"
width="500"
height="300">
<ellipse
cx="250"
cy="150"
rx="245"
ry="145"
fill="none"
stroke="#3d8ea7"
stroke-width="10"></ellipse>
<circle
id="pointer"
cx="495"
cy="150"
r="30"
cursor="pointer"
fill="#efefef"></circle>
</svg>
This circle element is positioned at coordinates (495, 150)
with a radius (r)
of 30 units. It has a cursor style set to "pointer," indicating that it should change to a pointer cursor when hovered over.
The x position is 495 because we're subtracting half of the stroke-width
(5) from the container's width (500) so that the circle is in the middle of the path.
But after we look at the result, it turns out that the circle is outside the SVG container. To fix this, we can change the markup like this:
<svg
id="svg"
xmlns="http://www.w3.org/2000/svg"
width="500"
height="300">
<ellipse
cx="250"
cy="150"
rx="220"
ry="120"
fill="none"
stroke="#3d8ea7"
stroke-width="10"></ellipse>
<circle
id="pointer"
cx="470"
cy="150"
r="30"
cursor="pointer"
fill="#efefef"></circle>
</svg>
Here in the ellipse, the horizontal radius 220 = (500/2 - 30)
is equal to half the width of the SVG container minus the width of the pointer circle. The same with the vertical radius 120 = (300/2 - 30)
.
The horizontal position of the pointer circle is equal to the
SVG width minus the width of the circle 470 = 500 - 30
.
TypeScript
In this tutorial, we will be using TypeScript. TypeScript is a programming language that is a superset of JavaScript that adds a layer of static typing, allowing developers to define types for variables, function parameters, and return values. This helps catch errors during development and provides better tooling support for code completion and refactoring.
I will use TypeScript with esbuild bundler, which is a fast and highly efficient builder for JavaScript and TypeScript.
Make sure you have node.js installed and open a terminal in your project folder:
npm init -y
npm install esbuild typescript --save-dev
tsc --init
The command npm init -y
is used to initialize a new npm
project with default settings. When you run this command in your project's root directory, it creates a package.json file with basic information about your project.
The command npm install esbuild typescript --save-dev
is used to install the "esbuild" and "typescript" packages as devDependencies in your npm project.
The command tsc --init
is used to initialize a TypeScript project by generating a tsconfig.json file in the current directory. This file contains various configuration options for the TypeScript compiler. By customizing the tsconfig.json file, you can configure TypeScript compilation settings, define the project's file structure, enable specific features, and more.
Let's create an index.ts
file with the following content:
console.log('test');
Now we can add the start command to package.json
:
{
"name": "tutorial",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "./node_modules/.bin/esbuild index.ts --bundle --watch --outfile=index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.17.19",
"typescript": "^5.1.3"
}
}
The command ./node_modules/.bin/esbuild index.ts --bundle --watch --outfile=index.js
uses the locally installed esbuild package to bundle the index.ts
file and generate an index.js
output file. Here's a breakdown of the command:
-
./node_modules/.bin/esbuild
: This specifies the path to the esbuild executable script located in the node_modules/.bin directory of your project. By running this script, you invoke the esbuild bundler. -
index.ts
: This is the entry file for the bundling process. It specifies the TypeScript file that will be processed and bundled. -
--bundle
: This flag tells esbuild to perform the bundling process, which combines multiple files into a single output file. -
--watch
: This flag instructs esbuild to watch for changes in the source files and automatically trigger a new build whenever a change is detected. -
--outfile=index.js
: This flag specifies the output file name and path for the bundled JavaScript file. In this case, the output file will be namedindex.js
.
Now you can run the npm start
command in the terminal to generate the index.js file. Esbuild will watch for changes in source files and automatically recompile the code. To exit watch mode, simply press Ctrl C
in the terminal.
Drag and Drop
To implement the drag and drop functionality inside SVG, we can start by selecting the svg and circle elements with getElementById:
/**
* This is where we place all the initialization logic.
*/
const init = () => {
const $svg = document.getElementById('svg');
const $pointer = document.getElementById('pointer');
if(!$svg || !$pointer) return;
};
init();
The HTML file should have a javascript reference now:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1">
<title>Tutorial</title>
</head>
<body>
<svg
id="svg"
xmlns="http://www.w3.org/2000/svg"
width="500"
height="300">
<ellipse
cx="250"
cy="150"
rx="220"
ry="120"
fill="none"
stroke="#3d8ea7"
stroke-width="10"></ellipse>
<circle
id="pointer"
cx="470"
cy="150"
r="30"
cursor="pointer"
fill="#efefef"></circle>
</svg>
<!-- Reference to the compiled JavaScript file -->
<script src="index.js"></script>
</body>
</html>
Now let's implement the basic drag and drop flow. The code below attaches event listeners to the $pointer element to handle mouse and touch events.
/**
* This is where we place all the initialization logic.
*/
const init = () => {
const $svg = document.getElementById('svg');
const $pointer = document.getElementById('pointer');
if(!$svg || !$pointer) return;
/**
* Here will be all the logic related to pointer movement.
*/
const onValueChange = (evt: MouseEvent | TouchEvent) => {
console.log(evt);
}
/**
* Add event listeners as soon as
* the user presses the mouse button.
*/
const onMouseDown = (evt: MouseEvent) => {
evt.preventDefault();
onValueChange(evt);
window.addEventListener('mousemove', onValueChange);
window.addEventListener('mouseup', onMouseUp);
};
/**
* Remove event listeners as soon as
* the user releases the mouse button.
*/
const onMouseUp = () => {
window.removeEventListener('mousemove', onValueChange);
window.removeEventListener('mouseup', onValueChange);
};
// Attach event listeners to the $pointer element
// to handle mouse and touch events.
$pointer.addEventListener('mousedown', onMouseDown);
$pointer.addEventListener('mouseup', onMouseUp);
$pointer.addEventListener('touchmove', onValueChange);
$pointer.addEventListener('touchstart', onValueChange);
};
init();
In the code above we add event listeners for the mouse and touch events on the $pointer element. When the user presses or releases the mouse button on the element, the relevant function will be called to handle the event.
Ellipse Movement
First we need to find out the mouse coordinates. The way it is determined will be different for mouse events and touch events. One way to do this is to use the evt.type
property, which represents the type of event that occurred:
const onValueChange = (evt: MouseEvent | TouchEvent) => {
let mouseX, mouseY;
const isMouse = evt.type.indexOf('mouse') !== -1;
if(isMouse){
// Mouse events
mouseX = (evt as MouseEvent).clientX;
mouseY = (evt as MouseEvent).clientY;
}
else{
// Touch events
mouseX = (evt as TouchEvent).touches[0].clientX;
mouseY = (evt as TouchEvent).touches[0].clientY;
}
console.log(mouseX, mouseY);
}
Let's also define some constants, such as the radii of the ellipse and its center. In actual production code, we can find these values dynamically, but in this demo, we define them as constants to keep the code simple.
// The ellipse path radii constants.
const RADIUS_X = 220;
const RADIUS_Y = 120;
// The absolute top left coordinates of the SVG.
const {
left: ABS_SVG_LEFT,
top: ABS_SVG_TOP,
width: SVG_WIDTH,
height: SVG_HEIGHT
} = $svg.getBoundingClientRect();
// The center of the SVG.
const SVG_CENTER_LEFT = SVG_WIDTH/2;
const SVG_CENTER_TOP = SVG_HEIGHT/2;
These lines calculate the center coordinates of the SVG element and store them in variables SVG_CENTER_LEFT
and SVG_CENTER_TOP
. The getBoundingClientRect() method is called on the $svg element to obtain its bounding rectangle, which includes properties like left
, top
, width
, and height
. Using destructuring assignment, the values of left and top properties are extracted and assigned to ABS_SVG_LEFT
and ABS_SVG_TOP
variables respectively.
const onValueChange = (evt: MouseEvent | TouchEvent) => {
let mouseX, mouseY;
const isMouse = evt.type.indexOf('mouse') !== -1;
if(isMouse){
mouseX = (evt as MouseEvent).clientX;
mouseY = (evt as MouseEvent).clientY;
}
else{
mouseX = (evt as TouchEvent).touches[0].clientX;
mouseY = (evt as TouchEvent).touches[0].clientY;
}
// Calculate the relative mouse position.
const relativeMouseX = mouseX - ABS_SVG_LEFT - SVG_CENTER_LEFT;
const relativeMouseY = mouseY - ABS_SVG_TOP - SVG_CENTER_TOP;
}
By subtracting the coordinates of the SVG center from the mouse coordinates, the resulting values relativeMouseX
and relativeMouseY
represent the mouse's position relative to the center of the SVG element.
Now we need to calculate the angle inside the ellipse that represents the current mouse position. We can use the Math.atan2()
function that returns the angle in radians between the positive x-axis and the point represented by the mouse.
const angle = Math.atan2(
relativeMouseY / RADIUS_Y,
relativeMouseX / RADIUS_X
);
relativeMouseX / RADIUS_X
: This expression calculates the relative horizontal distance from the SVG center along the x-axis.relativeMouseY / RADIUS_Y
: This expression calculates the relative vertical distance from the SVG center along the y-axis.
Now that we have the angle, we can calculate the new coordinate of the pointer's center along the ellipse path. To do this, we can use the parametric equation of an ellipse:
x(angle) = radius1 * cos(angle)
y(angle) = radius2 * sin(angle)
So the final part of the onValueChange()
function would be:
const newX = SVG_CENTER_LEFT + Math.cos(angle) * RADIUS_X;
const newY = SVG_CENTER_TOP + Math.sin(angle) * RADIUS_Y;
// Update the pointer's center coordinates.
$pointer.setAttribute('cx', newX.toString());
$pointer.setAttribute('cy', newY.toString());
In the provided code, two variables newX
and newY
are calculated based on the angle and the radii of the SVG ellipse. These variables represent the new center coordinates for the $pointer
element. Then, the cx
and cy
attributes of the $pointer element are updated to move it to the new coordinates.
Summary
The final result can be found in this codepen and also on GitHub.
This code can easily be adapted to a circular path instead of an ellipse by making one radius instead of two. Please check this codepen as an example.
I hope this article was interesting and helpful in your programming journey. Happy coding! 🙃
Also please take a look at Tool Cool Range Slider project. It is a responsive range slider library written in typescript and using web component technologies. It has a rich set of settings, including any number of pointers (knobs), vertical and horizontal slider, touch, mousewheel and keyboard support, local and session storage, range dragging, and RTL support.
Top comments (0)