Be still my beating heart: SVG viewBox transitions to animate custom shapes

For the Makeover Monday challenge week 7 I wanted to try animating a more complex shape using D3, based on the data.

In my design there is a heart for each series in the dataset, and the size of the heart is based on the average spend for the year. The visualisation ticks through each year, updating the hearts' size accordingly.

My final animated design is viewable here: https://bl.ocks.org/tomshanley/raw/84ecfc69ffee2e1a0393e0ccab9d4d84/

To achieve the animated beating heart effect, I tried using a .PNG image of a heart, and setting the width/height based on the data. The issues I ran into with this approach were:

  • Keeping the centre the image the static while changing the size. As the image changed size, its centre would move.
  • Keeping surrounding elements in place. As the image changed size, the webpage would readjust accordingly.

The solution I landed on was to use a SVG image of a heart, and as the data changed:

  1. Keep the SVG width/height (aka, its "viewport") the same. This meant that the SVG element with the DOM does not change size, so surrounding element on the webpage were unaffected.
  2. Changing the SVG's viewBox dimensions to be inversely proportional to the data, ie as the data value increased, the size of the viewBox decreases. This results in a larger heart shape, as SVG then scales the viewBox to fit within the SVG's width and height.

The way SVG works within a page is that will scale the viewBox to fit inside the viewport.  If the viewBox is bigger than the viewport, the image will be scaled down to fit inside the viewport.

Simple example

The images below illustrate what is happening.

In svg 1, the width and height of the SVG is set to 400px. The SVG's viewBox attributes are set to:

  • x = -200
  • y = -200
  • width = 400
  • height = 400

The rect element has the following attributes:

  • x = -100
  • y = -100
  • width = 200
  • height = 200

This results in a square positioned in the middle of the SVG element. As the viewBox's width and height match the SVG element's width and height (ie 400), no resizing occurs:

viewBox-example.gif

For svg 2, I keep everything the same, except for multiplying the viewBox attributes by two:

  • x = -400
  • y = -400
  • width = 800
  • height = 800

As the viewBox's width and height is double the SVG element's width and height, the browser will scale down the contents of the viewBox, which results in a rect element that looks to be quarter of the size of the original rect, and which is still centred in the middle of the SVG element.

It is important to note that the rect's area is scaled by a factor of four (two squared).

* There are different methods for how this scaling occurs which I won't cover here, but see this excellent article for more information viewBox and viewports. I use "xMidYMid meet", which help keeps everything centred.

Normally you wouldn't want to use this method if you are just drawing simple shapes like squares or circles (where D3 can easily update their size and position). But as for heart shapes, it can be very useful.

Make a heart

To make sure that the heart remained in the centre of the SVG, the path for the shape is drawn with 0,0 in the middle:

const heartPath = "m-1.249995,-26.393731c19.790171,-56.111793 97.328709,0 0,72.143734c-97.328709,-72.143734 -19.790171,-128.255527 0,-72.143734z";

Draw the heart for each series

Each heart was drawn using the same SVG path data, and same height and width for the SVG element:

var heartSVG = heartDiv.append("svg")
.attr("width", chartWidthHeight)
.attr("height", chartWidthHeight)

...

heartSVG.append("path")
.attr("id", function(d){ return d.objectOfAffection; })
.attr("d", heartPath)

Set the size of the viewBox

The size of the heart was then manipulated via the viewBox setting for the SVG element:

var heartSVG = heartDiv.append("svg")
...
.attr("viewBox", viewBox)
.attr("preserveAspectRatio", "xMidYMid meet")

My viewBox function returns the variable expected for the SVG ViewBox value, ie <min-x> <min-y> <width> <height>:

function viewBox(d) {
var viewBoxWH = svgDimension(d);
var viewBoxXY = -(svgDimension(d) / 2);
return viewBoxXY + " " + viewBoxXY + " " + viewBoxWH + " " + viewBoxWH;
};

The viewBox's height and width (viewBoxWH ) was calculated:

  • the proportion the data value to the SVG element's viewport's height and width
  • this proportion was square-rooted, as we want the heart's area to represent the data value, not it's height and width.
  • The square-rooted proportion was multiplied to the SVG height and width, and used for the ViewBox's height and width.
function svgDimension(d) {
var yearCol = "y" + year; //lookup the value from the right column
var proportion = chartWidthHeight / d[yearCol];
var sqrtProp = Math.sqrt(proportion);
return chartWidthHeight * sqrtProp;
}

The ViewBox's X and Y offset (viewBoxXY) was set to be half that value, and negative, so the 0,0 coordinates remained central to the SVG element.

Animate the beating hearts

The hearts transition between values using the D3 easeElasticOut function to achieve the heart beating effect, and uses Mike Bostock's suggested pattern for creating concurrent chained transitions:

function beat(selection, duration) { 
 year = year === 2016 ? 2008 : year + 1;
 var amp = 1; var period = 0.2;

 selection.transition()
 .duration(duration)
 .ease(d3.easeElasticOut.amplitude(amp).period(period))
 .attr("viewBox", viewBox);

 selection.selectAll("path")
 .transition()
 .duration(duration)
 .ease(d3.easeElasticOut.amplitude(amp).period(period))
 .style("opacity", opacity);

 ...

 setTimeout(function() { beat(selection, duration); }, duration);

};


There may be better, more intuitive ways to achieve the same effect, and keep the other elements stable on the page (possibly using CSS positions), but I was pleased with the results.

Hugs and kisses, Tom