block by nascif 25aaeda463c8b666e6ad

Yin-Yang animation using D3.js

Full Screen

Yin-Yang Animation

The yin-yang symbol (called taijitu) looked to me like a snapshot of a system in motion. A moment where everything is suspended in perfect balance. But also a pause before an imminent change, where this balance is lost and then restored, over and over again.

Having learned how to create animations in D3.js thanks to tutorials like Jerome Cukier‘s Creating Animations and Transitions With D3 and examples like Roland Dunn‘s attrTween in a Transition to Move an Element Using a Function I decided to give this idea a try. I quickly realized that I also needed a better understanding of the SVG coordinate system and how it is affected by nested transformations. To that end Sara Soueidan‘s tutorial on SVG transformations was invaluable.

This experiment breaks down the taijitu into components, represented as SVG circles and arcs. The initial angle of each component is calculated and used as the starting point of an animated transition coverting a full cycle. At each step, the circles are scaled, translated to compensate for the change in position introduced by the scaling, and then rotated using a polar to cartesian transformation.

Every aspect of the animation is parameterized. In a future version I might look into adding a simple control panel to expose the parameters for experimentation. That will likely require a change on how the continous animation is achieved, replacing the “recursive” callback call made by transition.end() with d3.timer().

Update: Check out the latest, customizable version at ObservableHQ

index.html

<!doctype html>
<html lang="en">
<head lang=en>
  <meta charset="utf-8">
  <title>Yin-Yang Animation</title>
  <link rel="stylesheet" type="text/css" href="yinyang.css" />
</head>

<body>
  <div id="chart"></div>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js">
    </script>
  <script src="yinyang.js"></script>
</body>
</html>

yinyang.css

svg {
  background: #eee;
}

.yin {
  fill: black;
}

.yang {
  fill: white;
}

yinyang.js

(function() {
var width = window.innerWidth;
var height = window.innerHeight;
var padding = 2;
var external_padding = 10;
var yy_r = (Math.min(width, height) - (2 * padding) - external_padding) / 2;
var movable_id = 0;
var big = 0.5 * yy_r;
var small = 0.125 * yy_r;
var NORTH = 0, EAST = 1, SOUTH = 2, WEST = 3;

// Time to complete one transition / iteration 
var timeparam = 10000;

var svg = d3.select("#chart").append("svg")
      .attr("width", width)
      .attr("height", height)
    .append("g")
      .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")")
    .append("g");

// fixed enclosure
svg.append("circle")
    .attr("cx", 0)
    .attr("cy", 0)
    .attr("r",  yy_r + padding)
    .attr("class", "yin");

function makeArc(sx, sy, rx, ry, x_axis_rotation, 
                 large_arc_flag, sweep_flag, ex, ey) {
    return "M " + sx + "," + sy + 
          " a " + rx + "," + ry + 
          " " + x_axis_rotation + " " + large_arc_flag + 
          " " + sweep_flag + " " + ex + "," + ey + " z";
}

function makeCircle(radius, location, class_name) {
  var cx = -1, cy = -1;

  switch (location) {
    case NORTH:
      cx = 0; cy = -yy_r / 2;
      break;
    case EAST:
      cx = yy_r / 2; cy = 0;
      break;
    case SOUTH:
      cx = 0; cy = yy_r / 2;
      break;
    case WEST:
      cx = -yy_r / 2; cy = 0;
      break;
  }

  return { 
    "movable_id": ++movable_id,
    "cx": cx,
    "cy": cy,
    "r":  radius,
    "class_name": class_name + " movable"
  };
}

var circleData = [
  makeCircle(big, NORTH, "yin"),
  makeCircle(small, NORTH, "yang"),
  makeCircle(big, SOUTH, "yang"),
  makeCircle(small, SOUTH, "yin")
];

var arcData = [
  {
   "movable_id": ++movable_id,
   "path": makeArc(0, yy_r, yy_r, yy_r, 0, 0, 1, 0, -yy_r * 2),
   "class_name": "yang movable arc"
  }
];

var arc = svg.selectAll("path.movable")
  .data(arcData, function(d) { return d.movable_id; })
  .enter()
  .append("path")
  .attr("d", function(d)  { return d.path; })
  .attr("class", function(d)  { return d.class_name; });

var circles = svg.selectAll("circle.movable")
  .data(circleData, function(d) { return d.movable_id; })
  .enter()
  .append("circle")
  .attr("cx", function(d)  { return d.cx; })
  .attr("cy", function(d)  { return d.cy; })
  .attr("r", function(d)  { return d.r; })
  .attr("class", function(d)  { return d.class_name; });

var max_iter = 100;
var iter = 0;
var easeparam = "linear";
function repeat() {
  if (iter >= max_iter) {
    return;
  }
  iter++;
  var d_value = 0;
  circles
    .transition()
    .delay(d_value)
    .duration(timeparam)
    .ease(easeparam)
    .attrTween("transform", transformCircleFn())
    .each("end", repeat);

  arc
    .transition()
    .delay(d_value)
    .duration(timeparam)
    .ease(easeparam)
    .attrTween("transform", transformArcFn())
    .each("end", repeat);
}

var min_scale = 0.25;
function transformCircleFn() {
  var circleScaleFn = d3.scale.linear()
                    .domain([-1, 1])
                    .range([0, 1]);

  var ratioScaleFn = d3.scale.linear()
                    .domain([-1, 1])
                    .range([min_scale, 2 - min_scale]);
  
  return function(d, i, a) {
    var icx = d.cx;
    var icy = d.cy;
    var radius = d.r;
    var start_angle = Math.atan2(icx, icy);
    var rotation_radius = yy_r / 2;    // console.log("factory params: " + d.cx + ", " + d.cy + ", " + start_angle);

    return function(t) {
      var t_angle = (2 * Math.PI) * t + start_angle;

      // scale circles based on current angle
      // sum of radii of opposite inner large circles
      // stays constant and equal to grand radius
      var scale_factor = ratioScaleFn(Math.cos(t_angle));
      var scaleStr =  "scale("+ scale_factor + ")";

      // add an offset to scaled inner circles 
      // to keep them algined and inside grand circle
      var offset = (1 - scale_factor) * 2 * rotation_radius;
      // compensate for SVG coordinate system changes after scaling
      offset = offset  / scale_factor;
      var bx = (offset * Math.cos(t_angle) );
      var by = (offset * Math.sin(t_angle) );
      var translateOffsetStr = "translate(" + bx + "," + by + ")";

      // basic rotation: polar to cartesian transformation
      var ax = (rotation_radius * Math.cos(t_angle) ) - icx;
      var ay = (rotation_radius * Math.sin(t_angle) ) - icy;
      var translateRotationStr = "translate(" + ax + "," + ay + ")";

      // putting it all together
      var transf = [scaleStr, translateOffsetStr, translateRotationStr];
      return transf.join(" ");
    };
  };
}

function transformArcFn() {
  return function(d, i, a) {
    return function(t) {
      var r_degrees = 360 * t + 90;
      var rotateStr = "rotate(" + r_degrees + ")";
      return rotateStr;
    };
  };
}

repeat();
})();