Adam_K
Adam_K

Reputation: 43

How to animate transition over grouped, nested json coordinate data with D3.js?

I am new to D3 and JS and trying to figure out where I'm going wrong. I have time series x/y data that I'm looking to animate with D3. The goal is a plot that shows all 4 points and transitions them through each of the 5 timestamps in the sample data, with a speed that is calculated with the timestamp.

I've structed the data in what I think is the proper format for D3 to be able to animate it efficiently. The nested json I'm using follows this format:

[{"time": 0, "info":[{"id": "A", "x": 10, "y": 20},
                    {"id": "B", "x": 40, "y": 90},
                    {"id": "C", "x": 10, "y": 90},
                    {"id": "D", "x": 80, "y": 70}]},
 {"time": 0.5, "info":[{"id": "A", "x": 20, "y": 30},
                       {"id": "B", "x": 60, "y": 70},
                       {"id": "C", "x": 100, "y": 10},
                       {"id": "D", "x": 10, "y": 32}]},
 {"time": 1, "info":[{"id": "A", "x": 50, "y": 60},
                     {"id": "B", "x": 0, "y": 0},
                     {"id": "C", "x": 80, "y": 10},
                     {"id": "D", "x": 50, "y": 50}]},
 {"time": 1.5, "info":[{"id": "A", "x": 40, "y": 50},
                       {"id": "B", "x": 100, "y": 30},
                       {"id": "C", "x": 80, "y": 90},
                       {"id": "D", "x": 80, "y": 40}]},
 {"time": 2, "info":[{"id": "A", "x": 60, "y": 10},
                     {"id": "B", "x": 0, "y": 50},
                     {"id": "C", "x": 10, "y": 50},
                     {"id": "D", "x": 30, "y": 60}]}]

I can plot individual times of this data but there are a few issues when trying to animate this: Here's a Fiddle with my code in it. The transition duration is set by the difference between the current time and the previous time.

There looks to be many ways of chaining transitions iterating through data, but they seem to be inefficient. Here's the way that I've gotten it to (partially) work, it can also be seen in the Fiddle.

var data = [{"time": 0, "info":[{"id": "A", "x": 10, "y": 20},
                                                        {"id": "B", "x": 40, "y": 90},
                                    {"id": "C", "x": 10, "y": 90},
                                    {"id": "D", "x": 80, "y": 70}]},
        {"time": 0.5, "info":[{"id": "A", "x": 20, "y": 30},
                                                        {"id": "B", "x": 60, "y": 70},
                            {"id": "C", "x": 100, "y": 10},
                            {"id": "D", "x": 10, "y": 32}]},
        {"time": 1, "info":[{"id": "A", "x": 50, "y": 60},
                           {"id": "B", "x": 80, "y": 50},
                           {"id": "C", "x": 80, "y": 10},
                           {"id": "D", "x": 50, "y": 50}]},
                {"time": 1.5, "info":[{"id": "A", "x": 40, "y": 50},
                           {"id": "B", "x": 100, "y": 30},
                           {"id": "C", "x": 80, "y": 90},
                           {"id": "D", "x": 80, "y": 40}]},
        {"time": 2, "info":[{"id": "A", "x": 60, "y": 10},
                              {"id": "B", "x": 0, "y": 50},
                              {"id": "C", "x": 10, "y": 50},
                              {"id": "D", "x": 30, "y": 60}]}]
                           

 var margin = {top: 30, right: 30, bottom: 30, left: 30},
        width = 300 - margin.left - margin.right,
        height = 300 - margin.top - margin.bottom;

    // append the svg object to the body of the page
    var svg = d3.select("#container")
      .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
      .append("g")
        .attr("transform",
              "translate(" + margin.left + "," + margin.top + ")");

     // Add X axis
    var x = d3.scaleLinear()
      .domain([0, 100])
      .range([ 0, width ]);
    svg.append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x));

    // Add Y axis
    var y = d3.scaleLinear()
      .domain([0, 100])
      .range([ height, 0]);
    svg.append("g")
      .call(d3.axisLeft(y));
  function runTheSimulation() {
        var id = 0
        if (id == 0) {
      var points = svg.selectAll('circle')
          .data(data[0].info, function(d, i) {return d.id})

          points.enter().append('circle')
            .attr('cx', function(d) {return x(d.x)})
            .attr('cy', function(d) {return y(d.y)})
            .attr('r', 8)
            .attr('fill', 'white')
            .attr('stroke', 'black')
            
            id++;
            next_frame()

      } else {
      next_frame()
      }
      function next_frame() {
      
        var delta_time = (data[id].time - data[id-1].time)*1000
        var players = svg.selectAll('circle')
          .data(data[id].info, function(d) {return d.id})
          .transition()
          .duration(delta_time)
          .ease(d3.easeLinear)
          .attr('cx', function(d) {return x(d.x)})
          .attr('cy', function(d) {return y(d.y)})
          .on('end', function() {
                id++;
              //console.log(id)
              if (id >= data.length) {
                return;
              }
              next_frame();
          })
        }
    }
        
body {
  background: #20262E;
  padding: 20px;
  font-family: Helvetica;
}

#app {
  background: #fff;
  border-radius: 4px;
  padding: 20px;
  transition: all 0.2s;
}

li {
  margin: 8px 0;
}

h2 {
  font-weight: bold;
  margin-bottom: 15px;
}

.done {
  color: rgba(0, 0, 0, 0.3);
  text-decoration: line-through;
}

input {
  margin-right: 5px;
}
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
  <!-- jQuery -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Bootstrap JavaScript -->
  <script src="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <!-- Load d3.js -->
  <script src="https://d3js.org/d3.v4.js"></script>
</head>
<body>
<button onclick="runTheSimulation()">RUN THE SIMULATION</button>
<div id="container"></div>

The issues I have:

  1. Some elements are getting skipping in transition. The first run only shows three times and any subsequent runs only show two times. For example: B never goes to (0,0)
  2. I console.log() the index (id) when iterating through the json and somehow I'm getting double its length?

Thank you for any thoughts and help!

Upvotes: 4

Views: 350

Answers (2)

Andrew Reid
Andrew Reid

Reputation: 38211

I believe you'll find this a lot easier if you restructure your data. Instead of having a data array with frames containing each node's position, try a data array containing each node over time, eg:

var data = [ {"id":"A", frames: [{time:0, x: 1},{time:1,x: 2}]}, ...

This allows you to bind the data once, transition each element separately (triggering an end event without having to count how many transitions have occured as you aren't waiting for a batch to finish), and you could even store the current frame on the datum, rather than tracking it separately.

I used the following structure:

enter image description here

Which I got by running your code through a basic transpose, located in the snippet below.

Each item in the data array represents one element in the DOM - consistent with the D3 idiom. The index of current frame being drawn is stored in d.currentFrame, while all frames are stored in d.frames.

Basically your transition functionality would be fairly simple:

  function transition(d) {
  
    // Is there another frame?
    if(d.currentFrame == d.frames.length-1 ) return; // don't keep going if there is no more data.
    
    // Change in time
    var dt = -d.frames[d.currentFrame++].time + d.frames[d.currentFrame].time;

    // Do the transition:
    d3.select(this)
     .transition()
     .duration(dt*1000)
     .attr('cx', function(d) {return x(d.frames[d.currentFrame].x)})
     .attr('cy', function(d) {return y(d.frames[d.currentFrame].y)})   
     .on("end", transition) // and repeat for this element
   
  } 

We can trigger the run simulation by resetting the current frame and triggering the transition:

  function runTheSimulation() {
    svg.selectAll("circle")
     .each(function(d) { d.currentFrame = 0; })
     .attr('cx', function(d) {return x(d.frames[d.currentFrame].x)})
     .attr('cy', function(d) {return y(d.frames[d.currentFrame].y)})
     .each(transition);
  }

Each data point in this case doesn't actually need to have the same steps, either in number or values, which I believe is in keeping with D3's idioim: each datum is separate and independent from the processing of the others: there is no need to wait for a batch of transitions to complete, or use the time periods of the others.

Putting it together:

var data = [{"time": 0, "info":[{"id": "A", "x": 10, "y": 20},
                                                        {"id": "B", "x": 40, "y": 90},
                                    {"id": "C", "x": 10, "y": 90},
                                    {"id": "D", "x": 80, "y": 70}]},
        {"time": 0.5, "info":[{"id": "A", "x": 20, "y": 30},
                                                        {"id": "B", "x": 60, "y": 70},
                            {"id": "C", "x": 100, "y": 10},
                            {"id": "D", "x": 10, "y": 32}]},
        {"time": 1, "info":[{"id": "A", "x": 50, "y": 60},
                           {"id": "B", "x": 80, "y": 50},
                           {"id": "C", "x": 80, "y": 10},
                           {"id": "D", "x": 50, "y": 50}]},
                {"time": 1.5, "info":[{"id": "A", "x": 40, "y": 50},
                           {"id": "B", "x": 100, "y": 30},
                           {"id": "C", "x": 80, "y": 90},
                           {"id": "D", "x": 80, "y": 40}]},
        {"time": 2, "info":[{"id": "A", "x": 60, "y": 10},
                              {"id": "B", "x": 0, "y": 50},
                              {"id": "C", "x": 10, "y": 50},
                              {"id": "D", "x": 30, "y": 60}]}];


// Manipulate array:
var newData = data[0].info.map((_, i) => { return  {currentFrame: 0, id: data[0].info[i].id ,frames:data.map((row,t) => { return { x: row.info[i].x, y: row.info[i].y, time: data[t].time }}) } });

var margin = {top: 30, right: 30, bottom: 30, left: 30},
        width = 300 - margin.left - margin.right,
        height = 300 - margin.top - margin.bottom;

    // append the svg object to the body of the page
    var svg = d3.select("#container")
      .append("svg")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
      .append("g")
        .attr("transform",
              "translate(" + margin.left + "," + margin.top + ")");

     // Add X axis
    var x = d3.scaleLinear()
      .domain([0, 100])
      .range([ 0, width ]);
    svg.append("g")
      .attr("transform", "translate(0," + height + ")")
      .call(d3.axisBottom(x));

    // Add Y axis
    var y = d3.scaleLinear()
      .domain([0, 100])
      .range([ height, 0]);
    svg.append("g")
      .call(d3.axisLeft(y));
      
      
   var points = svg.selectAll('circle')
          .data(newData)
          .enter()
          .append('circle')
            .attr('cx', function(d) {return x(d.frames[d.currentFrame].x)})
            .attr('cy', function(d) {return y(d.frames[d.currentFrame].y)})
            .attr('r', 8)
            .attr('fill', 'white')
            .attr('stroke', 'black')

  
  function runTheSimulation() {
    svg.selectAll("circle")
     .each(function(d) { d.currentFrame = 0; })
     .attr('cx', function(d) {return x(d.frames[d.currentFrame].x)})
     .attr('cy', function(d) {return y(d.frames[d.currentFrame].y)})
     .each(transition);
  }
  
  function transition(d) {
  
    // Is there another frame?
    if(d.currentFrame == d.frames.length-1 ) return; // don't keep going if there is no more data.
    
    // Change in time
    var dt = -d.frames[d.currentFrame++].time + d.frames[d.currentFrame].time;

    // Do the transition:
    d3.select(this)
     .transition()
     .duration(dt*1000)
     .attr('cx', function(d) {return x(d.frames[d.currentFrame].x)})
     .attr('cy', function(d) {return y(d.frames[d.currentFrame].y)})   
     .on("end", transition)
     
 
  
  } 
body {
  background: #20262E;
  padding: 20px;
  font-family: Helvetica;
}

#app {
  background: #fff;
  border-radius: 4px;
  padding: 20px;
  transition: all 0.2s;
}

li {
  margin: 8px 0;
}

h2 {
  font-weight: bold;
  margin-bottom: 15px;
}

.done {
  color: rgba(0, 0, 0, 0.3);
  text-decoration: line-through;
}

input {
  margin-right: 5px;
}
<head>
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
  <!-- jQuery -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
  <!-- Bootstrap JavaScript -->
  <script src="http://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script>
  <!-- Load d3.js -->
  <script src="https://d3js.org/d3.v4.js"></script>
</head>
<body>
<button onclick="runTheSimulation()">RUN THE SIMULATION</button>
<div id="container"></div>
</body>

Upvotes: 2

Mark
Mark

Reputation: 108567

So I took some liberties with your code to make it more d3-ish. My attempt here is to make your data drive the visualization by chaining all the transitions instead of hooking the end event and moving to the next index.

<head>
  <!-- Load d3.js -->
  <script src="https://d3js.org/d3.v4.js"></script>
  <style>

  </style>
</head>
<body>
  <button onclick="runTheSimulation()">RUN THE SIMULATION</button>
  <div id="container"></div>
  <script>
    var data = [
      {
        time: 0,
        info: [
          { id: 'A', x: 10, y: 20 },
          { id: 'B', x: 40, y: 90 },
          { id: 'C', x: 10, y: 90 },
          { id: 'D', x: 80, y: 70 },
        ],
      },
      {
        time: 0.5,
        info: [
          { id: 'A', x: 20, y: 30 },
          { id: 'B', x: 60, y: 70 },
          { id: 'C', x: 100, y: 10 },
          { id: 'D', x: 10, y: 32 },
        ],
      },
      {
        time: 1,
        info: [
          { id: 'A', x: 50, y: 60 },
          { id: 'B', x: 80, y: 50 },
          { id: 'C', x: 80, y: 10 },
          { id: 'D', x: 50, y: 50 },
        ],
      },
      {
        time: 1.5,
        info: [
          { id: 'A', x: 40, y: 50 },
          { id: 'B', x: 100, y: 30 },
          { id: 'C', x: 80, y: 90 },
          { id: 'D', x: 80, y: 40 },
        ],
      },
      {
        time: 2,
        info: [
          { id: 'A', x: 60, y: 10 },
          { id: 'B', x: 0, y: 50 },
          { id: 'C', x: 10, y: 50 },
          { id: 'D', x: 30, y: 60 },
        ],
      },
    ];

    var margin = { top: 30, right: 30, bottom: 30, left: 30 },
      width = 300 - margin.left - margin.right,
      height = 300 - margin.top - margin.bottom;

    // append the svg object to the body of the page
    var svg = d3
      .select('#container')
      .append('svg')
      .attr('width', width + margin.left + margin.right)
      .attr('height', height + margin.top + margin.bottom)
      .append('g')
      .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');

    // Add X axis
    var x = d3.scaleLinear().domain([0, 100]).range([0, width]);
    svg
      .append('g')
      .attr('transform', 'translate(0,' + height + ')')
      .call(d3.axisBottom(x));

    // Add Y axis
    var y = d3.scaleLinear().domain([0, 100]).range([height, 0]);
    svg.append('g').call(d3.axisLeft(y));
    
    
    function runTheSimulation() {

        var chainedTransition,
            prevTime = 0,
            elapsedTime = 0;

        // create a null selection which reprensents each timepiont
        svg
          .selectAll(null)
          .data(data)
          .enter()
          .each(function(d,i){

            // for each timepoint, update the data
            points = svg.selectAll('circle')
              .data(d.info, (d) => d.id);

            // handle enter to create circles first time through
            points
              .enter()
              .append('circle')
              .attr('r', 8)
              .attr('fill', 'white')
              .attr('stroke', 'black')
              .attr('cx', d => x(d.x))
              .attr('cy', d => y(d.y));   

            // calculate how long we'll transition 
            // between this timepoint and last
            let transTime = (d.time - prevTime) * 1000;

            // chain the transition for all data updates
            chainedTransition = points
              .transition()
              .duration(transTime)
              .delay(elapsedTime)
              .ease(d3.easeLinear)
              .attr('cx', d => x(d.x))
              .attr('cy', d => y(d.y));

            // rememeber this time
            prevTime = d.time;
            // and elpased time for chaining
            elapsedTime += transTime;

          });
    }
  </script>
</body>

Upvotes: 2

Related Questions