Puckering Lips Animation in D3

lip_main.png

I like to experiment with various things in D3, and had made a radial bar chart animation that I thought looked a little bit like a mouth.

So I decided to take it a step further and do something similar, using an outline of lips instead of a circle shape.

Just in time for Valentine's day!

So in this post I will go through how I created this puckering lips animation.

You can also find the code on my Observable, here.

The Basic Steps

  1. I started with an SVG path of an outline of lips.
  2. Found points along the path of the lips.
  3. Drew lines between each point and the center of the lips.
  4. Animated the lines by interpolating the x1,y1,x2 and y2 values of each line.

As the animation progresses, the length of the lines change, to make it look like the lips are puckering up for a kiss.

D3 concepts used

Some of the concepts you will learn about in this post:

  1. SVG paths
  2. D3 transitions and .attrTween
  3. D3 interpolations
  4. Trigonometry! (Don't be frightened...)

Alright, let's get started.

SVG path of lips

First, create or obtain an SVG path of lips.

I used this one from the Noun Project, by Star and Anchor Design.

I just extracted the part of the file that is the path of the lips and copy and pasted it into my file.

let lipsPath = "m695.88 293.71c-100.43-120.44-182.21-154.64-242.43-154.66-55.621 0.10156-90.73 28.961-103.78 42-32.375-29.973-67.734-42.199-102.37-42.125-126.72 1.0078-242.47 154.82-243.28 155.05l-4.0234 5.2227 4.5352 4.7891c122.2 128.78 240.43 172.67 343.34 172.67h0.21484c205.98-0.39453 347.2-172.61 347.82-173.07l4.0781-4.9531zm-36.418 26.027c-48.258 47.316-163.72 141.6-311.38 141.46-97.207-0.0625-209.15-40.254-327.7-162.98 3.3359-4.0703 8.6133-10.352 15.613-18.125 37.785-42.434 125.84-126.23 211.31-125.71 32.926 0.078125 65.688 11.742 97.223 43.176l6.1328 6.1406 5.3984-6.7969c0-0.015625 1.832-2.3477 5.957-6.3047 12.453-11.957 43.508-36.102 91.441-36.086 53.195-0.015625 129.38 29.68 226.35 144.08-4.0156 4.4883-10.844 11.867-20.34 21.152z"

Create the SVG for our visualization

First let's create our SVG, along with a container element to hold the animation.

const svg = div.append('svg')
        .attr("viewBox", [0, 0, 1500, 1500])
        .style("background-color", svgBackgroundColor),

    container = svg.append("g")
    .attr("id", "container")
        .attr("transform", `translate(${400}, ${400})`),

Calculating points along the lips path

Now we are going to use a couple of SVG path methods to calculate some points along the lips path.

The first thing I had to do was to append the lips path icon to the SVG.

const lipsDrawing = svg.append("defs")
            .append("g")
            .attr("id","iconCustom")
            .append("path")
            .attr("id", "lipsPath")
            .attr("d", lipsPath)

I'm only using this to calculate the points along the path, so I am not actually going to draw the path on the SVG.

Now we can use a couple of SVG path element methods to calculate the points along the path.

SVGGeometryElement methods .getTotalLength() and .getPointAtLength()

Now we are going to get the total length of the lips path, and then divide it by the number of points we want to draw along it.

These points will be the lines in the animation.

Get the total length of the path:

let lpLength = d3.select('#lipsPath').node(); 
let totalLength = lpLength.getTotalLength();

Here I selected the path node that I appended to the SVG - I gave it the ID #lipsPath just to make it easier to select.

Then I called the getTotalLength() method on this element.

Now we can divide the total length by the number of points.

How many points do we want to get?

This is something where you can experiment a bit, but for demo purposes, let's start with 100.

const numPoints = 100;

Now let's generate some initial data points.

let dataPoints = d3.range(numPoints).map(point => {
    let step = point * (totalLength/numPoints);
    let pt = lpLength.getPointAtLength(step);
    return {
        x: pt.x,
        y: pt.y
    }
})

I've divided the path into sections or steps, where each step has length totalLength/numPoints, and then multiplied by the current point number to get the correct location along the path.

The SVG path element method, .getPointAtLength(), returns the x and y coordinates of each point along the path in SVG user units.

Here are the current points.

lip path outline circles

Next, we will do a little bit more data processing on these points, and calculate the distance between each point and the center of the lips path.

//get the min and max x and y values
let xExtent = d3.extent(dataPoints, d => d.x);
let yExtent = d3.extent(dataPoints, d => d.y);

//calculate the center x and center y
let xCenter = (xExtent[1] - xExtent[0])/2;
let yCenter = (yExtent[1] - yExtent[0]);

//here is our center point
let centerPoint = {x: xCenter, y: yCenter};

See the center point in green.

lip circles  with center point

Trigonometry

So now we will go through the points one more time to add in the distance from each point to the center, as well as the angle created.

lip point triangles demo

If you remember SOHCAHTOA, we already know the opposite and adjacent sides of the triangle, so we can get the angle theta with tangent.

  • The line connecting each point to the center is the hypotenuse of a right triangle, giving us the distance between the point and the center of the lips path.
  • The green dashed line is the opposite side.
  • The blue dashed line is the adjacent side.
let data = dataPoints.map(pt => {
    let hyp = Math.sqrt(Math.pow(Math.abs(centerPoint.y - pt.y), 2) + Math.pow(Math.abs(centerPoint.x - pt.x), 2));

    theta = Math.atan2((pt.y - centerPoint.y), (pt.x - centerPoint.x));

    return {
        x: pt.x,
        y: pt.y,
        centerDist: hyp,
        theta: theta
    }
})

This is the data we will pass to D3 to create the lines.

Draw the lines

Let's do a quick data join.

let lipLines = container.selectAll('line')
    .data(data)

lipLines.join(
    enter => enter.append('line')
        .attr("stroke", "red")
        .attr('x1', d => {
            let x1 = d.x + (d.centerDist * Math.cos(d.theta));
            return x1
        })
        .attr('y1', d =>{
            let y1 = d.y + (d.centerDist * Math.sin(d.theta));
            return y1
        })
        .attr('x2', d =>{
            let x2 = d.x - (dist * Math.cos(d.theta));
            return x2;
        })
        .attr('y2', d =>{
            let y2 = d.y - (dist * Math.sin(d.theta));
            return y2;
        })
    )

x2 and y2

As I mentioned earlier with SOHCAHTOA, we have the centerDist for each point, which is the distance between it and the center of the lips path - the hypotenuse of the triangle.

  • Cosine Θ = adjacent / hypotenuse
  • Sine Θ = opposite / hypotenuse

To find the x coordinate of the center, multiply Cosine Θ by the hypotenuse, and then. subtract that value from d.x.

To find the y coordinate of of the center, multiply Sine Θ by the hypotenuse, and subtract that value from d.y.

x1 and y1

I mentioned that I am actually doubling the size of the lips for this animation, and to do that I extended the line further out, simply by adding the Cosine/Sin * hypotenuse calculation to d.x and d.y.

Here's an image with these lines, as well as the circles from earlier to show the original points.

lip lines demo

Adjusting the lines a bit

This is just me experimenting.

Now we have some static lines between the path points and the center, and they kind of overlap a bit, so I experimented and found that for this animation, an ideal distance is about 150.

let idealDist = 150;

So I just made sure that the distance did not exceed 150 in length by adjusting the second point of each line, x2 and y2.

.attr('x2', d =>{
    let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
    let x2 = (d.x - (dist * Math.cos(d.theta)));
    return x2;

})
.attr('y2', d =>{
    let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
    let y2 = (d.y - (dist * Math.sin(d.theta)));
    return y2;
})

Animating it

Now we are ready to animate the lips.

So far we have only used the enter part of the enter, update, exit join pattern.

Now we will add the update part, with a transition to animate the line lengths.

It looks like a lot, but I am doing the same thing for each point, just substituting some different numbers or cosine/sine.

I will go over what is happening here below!

update => update.call(e => e.transition(t)
            .attrTween("x1", d => {
                let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
                let x1 = d.x + (dist * Math.cos(d.theta));
                let x11 = d.x + ((dist/getRandomInteger(2,5)) * Math.cos(d.theta));
                let intp = d3.interpolate(x1,x11);
                return t => {
                    return intp(t);
                }
            })
            .attrTween("y1", d => {
                let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
                let y1 = d.y + (dist * Math.sin(d.theta));
                let y11 = d.y + ((dist/getRandomInteger(2,5)) * Math.sin(d.theta));
                let intp = d3.interpolate(y1,y11);
                return t => {
                    return intp(t);
                }
            })  
            .attrTween("x2", d => {
                let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
                let x2 = d.x - (dist * Math.cos(d.theta));
                let x22 = d.x - ((dist/getRandomInteger(2,5)) * Math.cos(d.theta));
                let intp = d3.interpolate(x2,x22);
                return t => {
                    return intp(t);
                }
            })
            .attrTween("y2", d => {
                let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
                let y2 = d.y - (dist * Math.sin(d.theta));
                let y22 = d.y - ((dist/getRandomInteger(2,5)) * Math.sin(d.theta));
                let intp = d3.interpolate(y2,y22);
                return t => {
                    return intp(t);
                }


             })

            )

        )

What is .attrTween() doing?

Let's look at x1.

.attrTween("x1", d => {
    let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
    let x1 = d.x + (dist * Math.cos(d.theta));
    let x11 = d.x + ((dist/getRandomInteger(2,5)) * Math.cos(d.theta));
    let intp = d3.interpolate(x1,x11);
    return t => {
        return intp(t);
    }
})

We are using a couple of D3 concepts here.

  1. Interpolation
  2. .attrTween()

The code starts off the same as before, with the x1 value we calculated in the enter part of the join.

But then we want to change the size of the line, so we need to calculate where we want the x1 point to move to.

I wanted to introduce some randomness to this to make it more interesting.

dist/getRandomInteger(2,5)

So here the distance will be divided by some random number between 2 and 5.

Feel free to play around with that.

Interpolation

Now we need to interpolate the x1 values between these two numbers, x1 and x11.

So we can do that with d3.interpolate() which takes in two numbers and returns a function that you can pass in a range from [0,1] and it will return an interpolated value. See the docs for more.

.attrTween()

With attrTween, we are simply defining the interpolator, and then returning a new function that takes a time, t from 0-1.

let intp = d3.interpolate(x1,x11);
    return t => {
        return intp(t);
    }

Notice that I've done the same thing for y1, x2 and y2 as well.

Puckering

One other detail is that when you pucker your lips, generally the two sides come in a bit, so I wanted to account for that somehow in this animation.

I created a puckerScale for this as an experiment.

let puckerScale = d3.scaleLog()
        .domain(d3.extent(data, d => d.centerDist))
        .range([0,1]);

The domain is the min to max values of the centerDist attribute, and the range is just 0 to 1.

I used a log scale because I didn't want there to be to much change for the smaller values, and only really wanted the largest values to be affected.

I only used this on the x1 values, and you will see that I also divided it by 3, which was just another experimentation where I liked the end result.

.attr('x1', d => {
    let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;
    let x1 = d.x + ((puckerScale(dist)/3 * dist) * Math.cos(d.theta));
    return x1

})

And in the update transition.

.attrTween("x1", d => {
    let dist = d.centerDist < idealDist ? d.centerDist : d.centerDist;

    let x1 = d.x + ((puckerScale(dist)/3 * dist) * Math.cos(d.theta));
    //let x1 = d.x + (dist * Math.cos(d.theta));
    let x11 = d.x + ((dist/getRandomInteger(2,5)) * Math.cos(d.theta));
    let intp = d3.interpolate(x1,x11);
    return t => {
        return intp(t);
    }
})

Other experiments

I also changed the color, stroke-width and opacity of the elements throughout the animation.

let colors = ["590d22","800f2f","a4133c","c9184a","ff4d6d","ff758f"];

I just browsed color palettes on Coolors for these.

In the enter and update functions, I adjusted the attributes.

.attr("stroke", d => `#${colors[getRandomInteger(1,colors.length-1)]}`)

I changed the stroke to a random color from this palette of reds and pinks.

.attr("stroke-width", (d,i) => Math.random() * 10)
.style("opacity", (d,i) => Math.random())

Then I change the stroke-width and opacity to a random number.

Noise

I've also experimented with noise functions - one in particular from P5.js.

let noise = new p5();

And then I used noise for the above attributes, aside from color.

.attr("stroke-width", (d,i) => noise.noise(i) * 7)
.style("opacity", (d,i) => noise.noise(i))

Thanks for reading!

I'm sure there are countless other ways you could experiment with this as well!

Let me know if you experiment with this code, or come up with something else!

Tagged In
blog comments powered by Disqus

Recent Posts

mortonzcurve.png
Computing Morton Codes with a WebGPU Compute Shader
May 29, 2024

Starting out with general purpose computing on the GPU, we are going to write a WebGPU compute shader to compute Morton Codes from an array of 3-D coordinates. This is the first step to detecting collisions between pairs of points.

Read More
webgpuCollide.png
WebGPU: Building a Particle Simulation with Collision Detection
May 13, 2024

In this post, I am dipping my toes into the world of compute shaders in WebGPU. This is the first of a series on building a particle simulation with collision detection using the GPU.

Read More
abstract_tree.png
Solving the Lowest Common Ancestor Problem in Python
May 9, 2023

Finding the Lowest Common Ancestor of a pair of nodes in a tree can be helpful in a variety of problems in areas such as information retrieval, where it is used with suffix trees for string matching. Read on for the basics of this in Python.

Read More
Get the latest posts as soon as they come out!