DEV Community

Miriam Zusin
Miriam Zusin

Posted on

How to Drag a Shape Along an SVG Ellipse Path: A Step-by-Step Guide

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 named index.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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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.typeproperty, 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);
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode
  • 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)
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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)