javascriptd3.jsforce-layoutbubble-chart

Upgrading bubble chart to v4+ with gravity/collide detection/effects


I've got a converted d3v4 bubble chart, but there used to be so much more features like gravity/charge and collision detection for these kind of things in d3v3. When the chart loads - I want to see a consistent/movement of the bubbles like watching frog spawn in a pond - they get close to each other through gravity - but have repellant/charge type properties. The bubbles also need to try and keep away from the edge.

Here's what I'm looking for with v3:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://d3js.org/d3.v3.min.js"></script>



<div class="bubblechart" data-role="bubblechart" data-width="300" data-height="300" data="">
</div>


<script>
  $(document).ready(function() {

    var $this = $('.bubblechart');
    //console.log("rendered div now engage d3", $this);
    // set el height and width etc.

    var w = $this.data("width");
    var h = $this.data("height");

    var data = [{
      "label": "Chinese",
      "value": 20
    }, {
      "label": "American",
      "value": 10
    }, {
      "label": "Indian",
      "value": 50
    }];


    function colores_google(n) {
      var colores_g = ["#ff7276", "#4baad2", "#eaa2a5", "#e75763", "#a6a19e"];
      return colores_g[n % colores_g.length];
    }


    var methods = {
      el: "",
      init: function(el, options) {

        var clone = options["data"];
        var that = this;

        //console.log("clone", clone);

        w = options["width"];
        h = options["height"];

        methods.el = el;

        methods.setup(clone, w, h);
        //methods.resizeChart(methods.el["selector"]);
      },
      resizeChart: function(selector) {
        //alert(selector);
        var svg = $(selector + " .bubblechart");


        var aspect = svg.width() / svg.height();
        var targetWidth = svg.parent().parent().width();

        if (targetWidth != null) {
          svg.attr("width", targetWidth);
          svg.attr("height", Math.round(targetWidth / aspect));
        }
      },
      funnelData: function(data, width, height) {
        function getRandom(min, max) {
          return Math.floor(Math.random() * (max - min + 1)) + min;
        }

        var max_amount = d3.max(data, function(d) {
          return parseInt(d.value)
        })
        var radius_scale = d3.scale.pow().exponent(0.5).domain([0, max_amount]).range([2, 85])

        $.each(data, function(index, elem) {
          elem.radius = radius_scale(elem.value) * .8;
          elem.all = 'all';
          elem.x = getRandom(0, width);
          elem.y = getRandom(0, height);
        });

        return data;
      },
      getMargin: function() {
        return {
          top: 30,
          right: 25,
          bottom: 50,
          left: 25
        };
      },
      setup: function(data, w, h) {

        methods.width = w;
        methods.height = h;

        methods.fill = d3.scale.ordinal()
          .range(["#d84b2a", "#beccae", "#7aa25c", "#008000"])

        var margin = methods.getMargin();

        var selector = methods.el;

        var padding = 50;

        /*
        var svg = d3.select(selector)
            .append("svg")
                .attr("class", "bubblechart")
                .attr("width", parseInt(w + margin.left + margin.right,10))
                .attr("height", parseInt(h + margin.top + margin.bottom,10))
                .attr('viewBox', "0 0 "+parseInt(w + margin.left + margin.right,10)+" "+parseInt(h + margin.top + margin.bottom,10))
                .attr('perserveAspectRatio', "xMinYMid")
            .append("g")
                .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
        */

        var chart = d3.select(selector).append("svg:svg")
          .attr("class", "chart")
          .attr("width", w - (w / 5))
          .attr("height", h)
          .attr("preserveAspectRatio", "none")
          .attr("viewBox", "0 0 " + (w - (w / 5)) + " " + h + "")
          .append("svg:g")
          .attr("class", "bubblechart")
          .attr("transform", "translate(-10,0)");


        methods.force = d3.layout.force()
          .charge(100)
          .gravity(1200)
          .size([methods.width, methods.height])


        var bubbleholder = chart.append("g")
          .attr("class", "bubbleholder")

        var bubbles = bubbleholder.append("g")
          .attr("class", "bubbles")

        var labelbubble = bubbleholder.append("g")
          .attr("class", "labelbubble")



        //add legend
        var legendPaddingTop = 30;

        var legend = d3.select($this[0]).append("svg:svg")
          .attr("class", "legend")
          .attr("width", w / 5)
          .attr("height", h)
          .append("svg:g")
          .attr("class", "legendsection")
          .attr("transform", "translate(" + ((w / 4) + padding) + "," + legendPaddingTop + ")");


        var label_group = legend.append("svg:g")
          .attr("class", "label_group")
          .attr("transform", "translate(" + (-(w / 3) + 20) + "," + 0 + ")");

        var legend_group = legend.append("svg:g")
          .attr("class", "legend_group")
          .attr("transform", "translate(" + (-(w / 3) - 100) + "," + 0 + ")");



        //draw labels                       
        var labels = label_group.selectAll("text.labels")
          .data(data);

        var legendHeight = legendPaddingTop;
        var ySpace = 18;
        var labelPadding = 3;

        labels.enter().append("svg:text")
          .attr("class", "labels")
          .attr("dy", function(d, i) {
            legendHeight += ySpace;
            return (ySpace * i) + labelPadding;
          })
          .attr("text-anchor", function(d) {
            return "start";
          })
          .text(function(d) {
            return d.label;
          });

        labels.exit().remove();


        var legend = legend_group.selectAll("circle").data(data);

        legend.enter().append("svg:circle")
          .attr("cx", 100)
          .attr("cy", function(d, i) {
            return ySpace * i;
          })
          .attr("r", 7)
          .attr("width", 18)
          .attr("height", 18)
          .style("fill", function(d, i) {
            return colores_google(i);
          });

        legend.exit().remove();


        //reset legend height
        //console.log("optimum height for legend", legendHeight);
        $this.find('.legend').attr("height", legendHeight);


        //add data

        data = this.funnelData(data, methods.width, methods.height);

        var padding = 4;
        var maxRadius = d3.max(data, function(d) {
          return parseInt(d.radius)
        });


        var scale = (methods.width / 6) / 100;


        var nodes = bubbles.selectAll("circle")
          .data(data);


        // Enter
        nodes.enter()
          .append("circle")
          .attr("class", "node")
          .attr("cx", function(d) {
            return d.x;
          })
          .attr("cy", function(d) {
            return d.y;
          })
          .attr("r", 1)
          .style("fill", function(d, i) {
            return colores_google(i);
          })
          .call(methods.force.drag);

        // Update
        nodes
          .transition()
          .delay(300)
          .duration(1000)
          .attr("r", function(d) {
            return d.radius * scale;
          })

        // Exit
        nodes.exit()
          .transition()
          .duration(250)
          .attr("cx", function(d) {
            return d.x;
          })
          .attr("cy", function(d) {
            return d.y;
          })
          .attr("r", 1)
          .remove();



        var bubblelabels = labelbubble.selectAll("text")
          .data(data);


        // Enter
        bubblelabels.enter()
          .append("text")
          .attr("class", function(d) {
            var cls = "title";

            if (d.count > 9) {
              cls += " largetxt";
            }

            return cls;
          })
          .text(function(d) {
            return d.count;
          })
          .attr("x", function(d) {
            return d.x;
          })
          .attr("y", function(d) {
            return (d.y) + 5;
          });

        // Update
        bubblelabels
          .transition()
          .delay(300)
          .duration(1000)

        // Exit
        bubblelabels.exit()
          .transition()
          .duration(250)
          .remove();



        draw('all');


        function draw(varname) {
          var foci = {
            "all": {
              name: "All",
              x: methods.width / 2,
              y: methods.height / 2
            }
          };
          methods.force.on("tick", tick(foci, varname, .55));
          methods.force.start();
        }

        function tick(foci, varname, k) {
          return function(e) {
            data.forEach(function(o, i) {
              var f = foci[o[varname]];
              o.y += (f.y - o.y) * k * e.alpha;
              o.x += (f.x - o.x) * k * e.alpha;
            });
            nodes
              .each(collide(.1))
              .attr("cx", function(d) {
                return d.x;
              })
              .attr("cy", function(d) {
                return d.y;
              });


            bubblelabels
              .each(collide(.1))
              .attr("x", function(d) {
                var displacementText = -5;
                if (d.count > 9) {
                  displacementText = -14;
                }

                return (d.x + displacementText);
              })
              .attr("y", function(d) {
                var displacementText = 5;
                if (d.count > 9) {
                  displacementText = 7;
                }

                return (d.y + displacementText);
              });

          }
        }


        function collide(alpha) {
          var quadtree = d3.geom.quadtree(data);
          return function(d) {
            var r = d.radius + maxRadius + padding,
              nx1 = d.x - r,
              nx2 = d.x + r,
              ny1 = d.y - r,
              ny2 = d.y + r;
            quadtree.visit(function(quad, x1, y1, x2, y2) {
              if (quad.point && (quad.point !== d)) {
                var x = d.x - quad.point.x,
                  y = d.y - quad.point.y,
                  l = Math.sqrt(x * x + y * y),
                  r = d.radius + quad.point.radius + padding;
                if (l < r) {
                  l = (l - r) / l * alpha;
                  d.x -= x *= l;
                  d.y -= y *= l;
                  quad.point.x += x;
                  quad.point.y += y;
                }
              }
              return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
            });
          };
        }


      },
      update: function(data) {
        methods.el = this;
        var selector = methods.el;

        //console.log("new data", data);

        methods.animateBubbles(selector, data);
      },
      animateBubbles: function(selector, data) {



      },
      oldData: ""
    };


    var el = $this[0];

    var options = {
      data: data,
      width: $(el).data("width"),
      height: $(el).data("height")
    }


    if (data) {
      methods.init(el, options);
    }

  });

</script>


<style>
  .bubblechart {
    text-align: center;
    font-size: 12px;
  }

  .bubblechart .legend .label_group text,
  .bubblechart .labelbubble text {
    fill: #ffffff;
  }

  .bubblechart .labelbubble text {
    font-size: 15px;
  }

  .bubblechart .labelbubble text.largetxt {
    font-size: 25px;
  }

  @media screen and (max-width: 501px) {
    .bubblechart .chart {
      width: 100%;
      height: 100%;
    }
  }

</style>

This is what I have for v4, but I'm not able to reproduce the functionality of v3:

var $this = $('.bubblechart');

var data = [{
  "label": "Chinese",
  "value": 20
}, {
  "label": "American",
  "value": 10
}, {
  "label": "Indian",
  "value": 50
}];

var width = $this.data('width'),
  height = $this.data('height');

var color = d3.scaleOrdinal()
  .range(["#ff5200", "red", "green"]);

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

var svg = d3.select($this[0])
  .append("svg")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .append("g")
  .attr('class', 'bubblechart')
  .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

var bubbles = svg.append('g').attr('class', 'bubbles');

var force = d3.forceSimulation()
  .force("collide", d3.forceCollide(12))
  .force("center", d3.forceCenter(width / 2, height / 2))
  .nodes(data);

var bubbles = svg.append("g")
  .attr("class", "bubbles")

data = funnelData(data, width, height);

var padding = 4;
var maxRadius = d3.max(data, function(d) {
  return parseInt(d.radius)
});

var scale = (width / 6) / 100;

var nodes = bubbles.selectAll("circle")
  .data(data);

// Enter
nodes.enter()
  .append("circle")
  .attr("class", "node")
  .attr("cx", function(d) {
    return d.x;
  })
  .attr("cy", function(d) {
    return d.y;
  })
  .attr("r", 10)
  .style("fill", function(d, i) {
    return color(i);
  })
  .call(d3.drag());

// Update
nodes
  .transition()
  .delay(300)
  .duration(1000)
  .attr("r", function(d) {
    return d.radius * scale;
  })

// Exit
nodes.exit()
  .transition()
  .duration(250)
  .attr("cx", function(d) {
    return d.x;
  })
  .attr("cy", function(d) {
    return d.y;
  })
  .attr("r", 1)
  .remove();


draw('all');


function funnelData(data, width, height) {
  function getRandom(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  var max_amount = d3.max(data, function(d) {
    return parseInt(d.value)
  })
  var radius_scale = d3.scalePow().exponent(0.5).domain([0, max_amount]).range([2, 85])

  $.each(data, function(index, elem) {
    elem.radius = radius_scale(elem.value) * .8;
    elem.all = 'all';
    elem.x = width / 2;
    elem.y = height / 2;
  });

  return data;
}


function draw(varname) {
  var foci = {
    "all": {
      name: "All",
      x: width / 2,
      y: height / 2
    }
  };
  force.on("tick", tick(foci, varname, .55));
}

function tick(foci, varname, k) {
  return function(e) {
    bubbles.selectAll("circle")
      .attr("cx", function(d) {
        return d.x;
      })
      .attr("cy", function(d) {
        return d.y;
      });
  }
}
body {
  background: #eeeeee;
}

.line {
  fill: none;
  stroke-width: 2px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<h1>BubbleChart I</h1>
<div class="bubblechart" data-width="300" data-height="300" />


Solution

  • there used to be so much more features like gravity/charge and collision detection for these kind of things in d3v3

    D3v4+ has better functionality than d3v3 when it comes to force layouts. But, ironically, looking at the fiddle you shared, you actually aren't using a force layout to actually place your nodes. Try changing the gravity or charge to wildly different values - nothing changes, you never pass the nodes to the simulation with force.nodes(data).

    That said, the force simulation is providing an alpha value, calling the tick function repeatedly, etc, but there is no direct interaction between the nodes and the force simulation. All positioning is done manually in the tick function. The force could be replaced with a timer to achieve the same result here.

    This suggests you are asking the wrong question, instead of

    It is likely:


    To start with, let's look at your two positioning forces:

    Here's a simplified example of what you have which is much easier to work with here:

    var width = 300,
    height = 300,
    svg = d3.select("body").append("svg")
       .attr("width", width)
       .attr("height", height)
    
    var force = d3.layout.force()
            
    var foci = {x: 150, y:150}
     
    var data = funnelData([{"label": "Chinese","value": 20}, {"label": "American","value": 10}, {"label": "Indian","value": 50}],width,height)
        .map(function(d) { d.foci = foci; return d; })
    
    force.start();
                    
    var node = svg.selectAll("circle")
        .data(data)
      .enter()
        .append('circle')
      .attr('r', 1)
      .attr('fill', function (d,i) {
          return ["#ff7276", "#4baad2", "#eaa2a5", "#e75763","#a6a19e"][i%5];
       })
                    
    node.transition()
        .delay(300)
        .duration(1000)
        .attr("r", function(d) { return d.value * (width / 6) / 100; })
                
    force.on("tick", function () {
        data.forEach(cluster);
        data.forEach(collide(0.1));
      
        node.attr("cx", function(d) { return d.x; })
          .attr("cy", function(d) { return d.y; })          
    }); 
    
    function funnelData(data, width, height) {
       function getRandom(min, max) {
          return Math.floor(Math.random() * (max - min + 1)) + min;
       }
       
       var max_amount = d3.max(data, function(d) {
          return parseInt(d.value)
       })
       var radius_scale = d3.scale.pow().exponent(0.5).domain([0, max_amount]).range([2, 85])
    
       data.forEach(function(elem, index) {
          elem.radius = radius_scale(elem.value) * .8;
          elem.all = 'all';
          elem.x = getRandom(0, width);
          elem.y = getRandom(0, height);
       });
    
       return data;
    }
        
    function cluster(d,i) {
        var f = d.foci;
        var k = 0.55;
        d.y += (f.y - d.y) * k * force.alpha()
        d.x += (f.x - d.x) * k * force.alpha()
    }
        
    var maxRadius = d3.max(data, function(d) { return d.radius; })
    var padding = 4;
        
    function collide(alpha) {
      var quadtree = d3.geom.quadtree(data);
         return function(d) {
              var r = d.radius + maxRadius + padding,
              nx1 = d.x - r,
              nx2 = d.x + r,
              ny1 = d.y - r,
              ny2 = d.y + r;
             quadtree.visit(function(quad, x1, y1, x2, y2) {
               if (quad.point && (quad.point !== d)) {
                  var x = d.x - quad.point.x,
                  y = d.y - quad.point.y,
                  l = Math.sqrt(x * x + y * y),
                  r = d.radius + quad.point.radius + padding;
                 if (l < r) {
                    l = (l - r) / l * alpha;
                    d.x -= x *= l;
                    d.y -= y *= l;
                    quad.point.x += x;
                    quad.point.y += y;
                 }
              }
              return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
             });
            };
        }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>

    If you remove the parts of the tick function that modify the data and the transition, you'll see the nodes remain perfectly still.

    We could change this to d3v4+ rather easily (well, d3-quad takes a 2nd glance to update, otherwise, all changes are namespace changes, eg: d3.scale.pow ⟶ d3.scalePow). This leaves us still utilizing your positioning functions and using the force layout only for triggering the ticks, altering alpha, etc:

    var width = 300,
    height = 300,
    svg = d3.select("body").append("svg")
       .attr("width", width)
       .attr("height", height)
    
    var force = d3.forceSimulation();
            
    var foci = {x: 150, y:150}
     
    var data = funnelData([{"label": "Chinese","value": 20}, {"label": "American","value": 10}, {"label": "Indian","value": 50}],width,height)
        .map(function(d) { d.foci = foci; return d; })
    
    force.nodes(data);
                    
    var node = svg.selectAll("circle")
        .data(data)
      .enter()
        .append('circle')
      .attr('r', 1)
      .attr('fill', function (d,i) {
          return ["#ff7276", "#4baad2", "#eaa2a5", "#e75763","#a6a19e"][i%5];
       })
                    
    node.transition()
        .delay(300)
        .duration(1000)
        .attr("r", function(d) { return d.value * (width / 6) / 100; })
                
    force.on("tick", function () {
        data.forEach(cluster);
        data.forEach(collide(0.1));
      
        node.attr("cx", function(d) { return d.x; })
          .attr("cy", function(d) { return d.y; })          
    }); 
    
    function funnelData(data, width, height) {
       function getRandom(min, max) {
          return Math.floor(Math.random() * (max - min + 1)) + min;
       }
       
       var max_amount = d3.max(data, function(d) {
          return parseInt(d.value)
       })
       var radius_scale = d3.scalePow().exponent(0.5).domain([0, max_amount]).range([2, 85])
    
       data.forEach(function(elem, index) {
          elem.radius = radius_scale(elem.value) * .8;
          elem.all = 'all';
          elem.x = getRandom(0, width);
          elem.y = getRandom(0, height);
       });
    
       return data;
    }
        
    function cluster(d,i) {
        var f = d.foci;
        var k = 0.55;
        d.y += (f.y - d.y) * k * force.alpha()
        d.x += (f.x - d.x) * k * force.alpha()
    }
        
    var maxRadius = d3.max(data, function(d) { return d.radius; })
    var padding = 4;
    
    function collide(alpha) {
      var quadtree = d3.quadtree().x(function(d) { return d.x; }).y(function(d) { return d.y; }).extent([[-1, -1], [width + 1, height + 1]]).addAll(data);
        
         return function(d) {
              var r = d.radius + maxRadius + padding,
              nx1 = d.x - r,
              nx2 = d.x + r,
              ny1 = d.y - r,
              ny2 = d.y + r;
             quadtree.visit(function(quad, x1, y1, x2, y2) {
               if (!quad.length && (quad.data !== d)) {
                  var x = d.x - quad.data.x,
                  y = d.y - quad.data.y,
                  l = Math.sqrt(x * x + y * y),
                  r = d.radius + quad.data.radius + padding;
                  
                  
                 if (l < r) {
                    l = (l - r) / l * alpha;
                        
                    d.x -= x *= l;
                    d.y -= y *= l;
                    quad.data.x += x;
                    quad.data.y += y;
                 }
              }
              return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
             });
            };
        }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

    But let's not do this, instead we could use the collision code the same as before in the tick function, but we can simplify it. To use d3-forceSimulation to do the collision, we can use d3.forceCollide(). We want to specify a radius for it and modify the strength of the collision force (which is the parameter alpha in your snippet). This can be done as follows:

    d3.forceCollide().radius(function(d) { return d.radius + padding; })
      .strength(0.1)
    

    As you only have one focal point, I will use d3's forceX and forceY to nudge nodes towards that point. The equivalent of the manual centering in the fiddle is:

      .force("x", d3.forceX().x(width/2)
               .strength(0.55))
      .force("y", d3.forceY().y(height/2)
               .strength(0.55))
    

    Altogether that gives us:

    var width = 300,
    height = 300,
    svg = d3.select("body").append("svg")
       .attr("width", width)
       .attr("height", height)
       
       
    var data = funnelData([{"label": "Chinese","value": 20}, {"label": "American","value": 10}, {"label": "Indian","value": 50}],width,height)
    
    var maxRadius = d3.max(data, function(d) { return d.radius; })
    var padding = 4;
    
    
    var force = d3.forceSimulation()
      .force("collide", d3.forceCollide()
               .radius(function(d) { return d.radius + padding + maxRadius; })
               .strength(0.1))
      .force("x", d3.forceX().x(width/2)
               .strength(0.55))
      .force("y", d3.forceY().y(height/2)
               .strength(0.55))
      .alpha(0.1) // same as v3.           
    
    force.nodes(data);
                    
    var node = svg.selectAll("circle")
        .data(data)
      .enter()
        .append('circle')
      .attr('r', 1)
      .attr('fill', function (d,i) {
          return ["#ff7276", "#4baad2", "#eaa2a5", "#e75763","#a6a19e"][i%5];
       })
                    
    node.transition()
        .delay(300)
        .duration(1000)
        .attr("r", function(d) { return d.value * (width / 6) / 100; })
                
    force.on("tick", function () {
        // Tinkered a bit here, did not dive into why a clearly identical solution was not immediately apparent.
        var alpha = this.alpha();
        this.force("collide").strength(0.2*alpha) 
          
        node.attr("cx", function(d) { return d.x; })
          .attr("cy", function(d) { return d.y; })          
    }); 
    
    function funnelData(data, width, height) {
       function getRandom(min, max) {
          return Math.floor(Math.random() * (max - min + 1)) + min;
       }
       
       var max_amount = d3.max(data, function(d) {
          return parseInt(d.value)
       })
       var radius_scale = d3.scalePow().exponent(0.5).domain([0, max_amount]).range([2, 85])
    
       data.forEach(function(elem, index) {
          elem.radius = radius_scale(elem.value) * .8;
          elem.all = 'all';
          elem.x = getRandom(0, width);
          elem.y = getRandom(0, height);
       });
    
       return data;
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

    Compared (timing differences are just lag on my part - I mixed the order up, they don't follow the above):

    enter image description here