How to Drag a Shape Along an SVG Ellipse Path: A Step-by-Step Guide
Tags: svg, javascript, typescript, tutorial
Published: 2023-06-05 16:04:49

[SVG](https://developer.mozilla.org/en-US/docs/Web/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](https://www.typescriptlang.org/).

{% codepen https://codepen.io/1rosehip/pen/oNaKRyN %}

## Table of Contents
- [HTML Markup](#html-markup)
- [TypeScript](#typescript)
- [Drag and Drop](#drag-and-drop)
- [Ellipse Movement](#ellipse-movement)
- [Summary](#summary)

## 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.

```html
<!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](https://developer.mozilla.org/en-US/docs/Web/SVG/Element) with a width of 500 pixels and a height of 300 pixels using the following code:
```html
<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](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/ellipse) 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.

```html
<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](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/circle) that will be dragged by mouse along the ellipse path:

```html
<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:

```html
<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](https://www.typescriptlang.org/). 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](https://esbuild.github.io/), which is a fast and highly efficient builder for JavaScript and TypeScript.

Make sure you have [node.js](https://nodejs.org/en) 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](https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) 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:

```ts
console.log('test');
```

Now we can add the **start** command to `package.json`:

```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 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](https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById):

```ts
/**
 * 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:

```html
<!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.

```ts
/**
 * 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:

```ts
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.

```ts
// 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()](https://developer.mozilla.org/en-US/docs/Web/API/Element/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.

```ts
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.

```ts
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](https://www.mathopenref.com/coordparamellipse.html):

```
x(angle) = radius1 * cos(angle)
y(angle) = radius2 * sin(angle)
```

So the final part of the `onValueChange()` function would be:

```ts
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](https://codepen.io/1rosehip/pen/oNaKRyN) and also on [GitHub](https://github.com/mzusin/articles/tree/main/how-to-drag-a-shape-along-an-svg-ellipse-path). 

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](https://codepen.io/1rosehip/pen/poQzKyP) 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](https://github.com/mzusin/toolcool-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.