Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Force simulation is jittery when using svg transforms to update position

Tags:

JSFiddle example

I've noticed that when updating positions of svg elements in a d3-force diagram, updating the positions of elements using (in the case of circles) the cx and cy attributes is much smoother than using the transform attribute.

In the example JSFiddle, there are two separate force simulations side-by-side. The one on the left updates positions using the transform attribute:

sim_transform.on('tick', function () {
  circles_transform.attr('transform', function (d) {
        return 'translate(' + d.x + ',' + d.y + ')';
  });
});

The one on the right updates positions using the cx and cy attributes of a circle:

sim_position.on('tick', function () {
  circles_position
    .attr('cx', function (d) {
      return d.x;
    })
    .attr('cy', function (d) {
        return d.y;
    })
});

The simulations appear identical until they're just about to become static, at which point the one using transforms starts to jitter quite a bit. Any ideas what is causing this? Can it be fixed so that the animation remains smooth using transforms?

like image 975
atdyer Avatar asked May 10 '18 17:05

atdyer


1 Answers

It seems to me that the issue you're observing (only reproducible in FireFox, as @altocumulus noted) has something to do with the way FF uses floating numbers for the translate of the transform attribute.

We can see this if we set both simulations to use integers, doing ~~(d.x) and ~~(d.y). Have a look, both will jitter:

var svg = d3.select('svg');
var graph_transform = gen_data();
var graph_position = gen_data();

var force_left = d3.forceCenter(
  parseInt(svg.style('width')) / 3,
  parseInt(svg.style('height')) / 2
)
var force_right = d3.forceCenter(
  2 * parseInt(svg.style('width')) / 3,
  parseInt(svg.style('height')) / 2
)

var sim_transform = d3.forceSimulation()
  .force('left', force_left)
  .force('collide', d3.forceCollide(65))
  .force('link', d3.forceLink().id(id));
var sim_position = d3.forceSimulation()
  .force('right', force_right)
  .force('collide', d3.forceCollide(65))
  .force('link', d3.forceLink().id(id));

var g_transform = svg.append('g');
var g_position = svg.append('g');

var circles_transform = g_transform.selectAll('circle')
  .data(graph_transform.nodes)
  .enter()
  .append('circle')
  .attr('r', 40);

var circles_position = g_position.selectAll('circle')
  .data(graph_position.nodes)
  .enter()
  .append('circle')
  .attr('r', 40);

sim_transform
  .nodes(graph_transform.nodes)
  .force('link')
  .links(graph_transform.links);

sim_position
  .nodes(graph_position.nodes)
  .force('link')
  .links(graph_position.links);

sim_transform.on('tick', function() {
  circles_transform.attr('transform', function(d) {
    return 'translate(' + (~~(d.x)) + ',' + (~~(d.y)) + ')';
  });
});

sim_position.on('tick', function() {
  circles_position
    .attr('cx', function(d) {
      return ~~d.x;
    })
    .attr('cy', function(d) {
      return ~~d.y;
    })
});

function id(d) {
  return d.id;
}

function gen_data() {
  var nodes = [{
      id: 'a'
    },
    {
      id: 'b'
    },
    {
      id: 'c'
    },
    {
      id: 'd'
    }
  ]

  var links = [{
      source: 'a',
      target: 'b'
    },
    {
      source: 'b',
      target: 'c'
    },
    {
      source: 'c',
      target: 'd'
    },
    {
      source: 'd',
      target: 'a'
    }
  ];
  return {
    nodes: nodes,
    links: links
  }
}
svg {
  width: 100%;
  height: 500px;
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg></svg>

So, in your original code, it seems like the circles move correctly when using cx and cy, but they jump from integer to integer when using translate (or maybe half pixel, see the last demo). If the hypothesis here is correct, the reason that you just see the effect when the simulation is cooling down is because, at that moment, the movements are smaller.

Demos

Now, if we get rid of the simulation, we can see that this strange behaviour also happens with a very basic transform. To check this, I created a transition for a big black circle, using a linear ease and a very long time (to facilitate seeing the issue). The circle will move 30px to the right. I also put a gridline to make the jumps more noticeable.

(Warning: the demos below are only reproducible in FireFox, you won't see any difference in Chrome/Safari)

If we use cx, the transition is smooth:

var svg = d3.select("svg");

var gridlines = svg.selectAll(null)
  .data(d3.range(10))
  .enter()
  .append("line")
  .attr("y1", 0)
  .attr("y2", 200)
  .attr("x1", function(d) {
    return 300 + d * 3
  })
  .attr("x2", function(d) {
    return 300 + d * 3
  })
  .style("stroke", "lightgray")
  .style("stroke-width", "1px");

var circle = svg.append("circle")
  .attr("cx", 200)
  .attr("cy", 100)
  .attr("r", 98)
  .transition()
  .duration(10000)
  .ease(d3.easeLinear)
  .attr("cx", "230")
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg width="500" height="200"></svg>

However, if we use translate, you can see the circle jumping 1px at every move:

var svg = d3.select("svg");

var gridlines = svg.selectAll(null)
  .data(d3.range(10))
  .enter()
  .append("line")
  .attr("y1", 0)
  .attr("y2", 200)
  .attr("x1", function(d) {
    return 300 + d * 3
  })
  .attr("x2", function(d) {
    return 300 + d * 3
  })
  .style("stroke", "lightgray")
  .style("stroke-width", "1px");

var circle = svg.append("circle")
  .attr("cx", 200)
  .attr("cy", 100)
  .attr("r", 98)
  .transition()
  .duration(10000)
  .ease(d3.easeLinear)
  .attr("transform", "translate(30,0)")
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg width="500" height="200"></svg>

For you people running this in Chrome/Safari, this is how the last snippet looks like in Firefox. It's like the circle is being moved half a pixel at every change... definitely not as smooth as changing cx:

var svg = d3.select("svg");

var gridlines = svg.selectAll(null)
  .data(d3.range(10))
  .enter()
  .append("line")
  .attr("y1", 0)
  .attr("y2", 200)
  .attr("x1", function(d) {
    return 300 + d * 3
  })
  .attr("x2", function(d) {
    return 300 + d * 3
  })
  .style("stroke", "lightgray")
  .style("stroke-width", "1px");

var circle = svg.append("circle")
  .attr("cx", 200)
  .attr("cy", 100)
  .attr("r", 98);
  
var timer = d3.timer(function(t){
  if(t>10000) timer.stop();
  circle.attr("cx", 200 + (~~(60/(10000/t))/2));
})
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg width="500" height="200"></svg>

As this is an implementation issue only visible in FF, it may be worth reporting a bug.

like image 166
Gerardo Furtado Avatar answered Sep 28 '22 19:09

Gerardo Furtado