Animating elements within a D3.js force layout

I am looking to create a unique data visualization that resembles floating bubbles with text inside each bubble.

Currently, I have a prototype using mock data available here: JSfiddle

// Here lies the code snippet
// ...

However, my current challenge lies in animating the bubbles. I am unsure how to animate nodes within a force diagram. I attempted adjusting the values of the data object and calling the .tick() method within setInterval, but did not succeed. The D3 force layout is being utilized for this project.

My main questions are:

  • How can I achieve a "floating" effect for the bubbles on screen?

  • What is the best approach to animate changes in circle radius?

Your input and ideas would be greatly appreciated.

Answer â„–1

Actually, I find this particular approach more pleasant...


Main Points

  1. Zero charge and 0.9 friction settings
  2. Concurrent transitions on radius and line in timer callback
  3. Dynamic radius for collision calculations
  4. Transform nodes using g element to separate text and line position from node position; adjust transform x and y only in tick callback
  5. Replace CSS transitions with d3 transitions for synchronization purposes
  6. Changed r = d.rt + 10 to r = d.rt + rmax for tighter control over overlaps in the collision function
  7. Closed loop speed regulator—despite 0.9 friction dampening movement, speed regulator ensures continuous motion
  8. Utilize parallel transitions for coordinating geometry changes
  9. Added a touch of gravity

Example Demo

// assistance functions
    var random = function(min, max) {
        if (max == null) {
            max = min;
            min = 0;
        }
        return min + Math.floor(Math.random() * (max - min + 1));
        },
        metrics = d3.select('.bubble-cloud').append("div")
            .attr("id", "metrics")
            .style({"white-space": "pre", "font-size": "8px"}),
        elapsedTime = outputs.ElapsedTime("#metrics", {
            border: 0, margin: 0, "box-sizing": "border-box",
            padding: "0 0 0 6px", background: "black", "color": "orange"
        })
            .message(function(value) {
                var this_lap = this.lap().lastLap, aveLap = this.aveLap(this_lap)
                return 'alpha:' + d3.format(" >7,.3f")(value)
                    + '\tframe rate:' + d3.format(" >4,.1f")(1 / aveLap) + " fps"
            }),
        hist = d3.ui.FpsMeter("#metrics", {display: "inline-block"}, {
            height: 8, width: 100,
            values: function(d){return 1/d},
            domain: [0, 60]
        }),

    // mock data
        colors = [
        {
            fill: 'rgba(242,216,28,0.3)',
            stroke: 'rgba(242,216,28,1)'
        },
        {
            fill: 'rgba(207,203,196,0.3)',
            stroke: 'rgba(207,203,196,1)'
        },
        {
            fill: 'rgba(0,0,0,0.2)',
            stroke: 'rgba(100,100,100,1)'
        }
    ];

    // initialize
    var container = d3.select('.bubble-cloud');
    var $container = $('.bubble-cloud');
    var containerWidth = 600;
    var containerHeight = 180 - elapsedTime.selection.node().clientHeight;
    var svgContainer = container
        .append('svg')
        .attr('width', containerWidth)
        .attr('height', containerHeight);

    var data = [],
        rmin = 15,
        rmax = 30;

    d3.range(0, 3).forEach(function(j){
        d3.range(0, 6).forEach(function(i){
            var r = random(rmin, rmax);
            data.push({
                text: 'text' + i,
                category: 'category' + j,
                x: random(rmax, containerWidth - rmax),
                y: random(rmax, containerHeight - rmax),
                r: r,
                fill: colors[j].fill,
                stroke: colors[j].stroke,
                get v() {
                    var d = this;
                    return {x: d.x - d.px || 0, y: d.y - d.py || 0}
                },
                set v(v) {
                    var d = this;
                    d.px = d.x - v.x;
                    d.py = d.y - v.y;
                },
                get s() {
                    var v = this.v;
                    return Math.sqrt(v.x * v.x + v.y * v.y)
                },
                set s(s1){
                    var s0 = this.s, v0 = this.v;
                    if(!v0 || s0 == 0) {
                        var theta = Math.random() * Math.PI * 2;
                        this.v = {x: Math.cos(theta) * s1, y: Math.sin(theta) * s1}
                    } else this.v = {x: v0.x * s1/s0, y: v0.y * s1/s0};
                },
                set sx(s) {
                    this.v = {x: s, y: this.v.y}
                },
                set sy(s) {
                    this.v = {y: s, x: this.v.x}
                },
            });
        })
    });

    // collision detection
    // adapted from http://bl.ocks.org/mbostock/1748247
    function collide(alpha) {
        var quadtree = d3.geom.quadtree(data);
        return function(d) {
            var r = d.rt + rmax,
                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.rt + quad.point.rt;
                    if (l < r) {
                        l = (l - r) / l * (0.5 + 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;
            });
        };
    }

    // layout setup
    var force = d3.layout
            .force()
            .size([containerWidth, containerHeight])
        .gravity(0.001)
            .charge(0)
        .friction(1)
        .on("start", function() {
            elapsedTime.start(100);
        });

    // load data
    force.nodes(data)
        .start();

    // create item groups
    var node = svgContainer.selectAll('.node')
        .data(data)
        .enter()
        .append('g')
        .attr('class', 'node')
        .call(force.drag);

    // create circle shapes
    var circles = node.append('circle')
        .classed('circle', true)
        .attr('r', function (d) {
            return d.r;
        })
        .style('fill', function (d) {
            return d.fill;
        })
        .style('stroke', function (d) {
            return d.stroke;
        })
        .each(function(d){
            // add dynamic r getter
            var n= d3.select(this);
            Object.defineProperty(d, "rt", {get: function(){
                return +n.attr("r")
            }})
        });

    // create labels
    node.append('text')
        .text(function(d) {
            return d.text
        })
        .classed('text', true)
        .style({
            'fill': '#ffffff',
            'text-anchor': 'middle',
            'font-size': '6px',
            'font-weight': 'bold',
            'text-transform': 'uppercase',
            'font-family': 'Tahoma, Arial, sans-serif'
        })
        .attr('x', function (d) {
            return 0;
        })
        .attr('y', function (d) {
            return - rmax/5;
        });

    node.append('text')
        .text(function(d) {
            return d.category
        })
        .classed('category', true)
        .style({
            'fill': '#ffffff',
            'font-family': 'Tahoma, Arial, sans-serif',
            'text-anchor': 'middle',
            'font-size': '4px'
        })
        .attr('x', function (d) {
            return 0;
        })
        .attr('y', function (d) {
            return rmax/4;
        });

    var lines = node.append('line')
        .classed('line', true)
        .attr({
            x1: function (d) {
                return - d.r + rmax/10;
            },
            y1: function (d) {
                return 0;
            },
            x2: function (d) {
                return d.r - rmax/10;
            },
            y2: function (d) {
                return 0;
            }
        })
        .attr('stroke-width', 1)
        .attr('stroke',  function (d) {
            return d.stroke;
        })
        .each(function(d){
            // add dynamic x getter
            var n= d3.select(this);
            Object.defineProperty(d, "lxt", {get: function(){
                return {x1: +n.attr("x1"), x2: +n.attr("x2")}
            }})
        });

    // initiate circle movement
    force.on('tick', function t(e){
        var s0 = 0.25, k = 0.3;

        a = e.alpha ? e.alpha : force.alpha();

        elapsedTime.mark(a);
        if(elapsedTime.aveLap.history.length)
            hist(elapsedTime.aveLap.history);

        for ( var i = 0; i < 3; i++) {
            circles
                .each(collide(a))
                .each(function(d) {
                    var moreThan, v0;
                    // boundaries

                    //reflect off the edges of the container
                    // check for boundary collisions and reverse velocity if necessary
                    if((moreThan = d.x > (containerWidth - d.rt)) || d.x < d.rt) {
                        d.escaped |= 2;
                        // if the object is outside the boundaries
                        // manage the sign of its x velocity component to ensure it is moving back into the bounds
                        if(~~d.v.x) d.sx = d.v.x * (moreThan && d.v.x > 0 || !moreThan && d.v.x < 0 ? -1 : 1);
                        // if vx is too small, then steer it back in
                        else d.sx = (~~Math.abs(d.v.y) || Math.min(s0, 1)*2) * (moreThan ? -1 : 1);
                        // clear the boundary without affecting the velocity
                        v0 = d.v;
                        d.x = moreThan ? containerWidth - d.rt : d.rt;
                        d.v = v0;
                        // add a bit of hysteresis to quench limit cycles
                    } else if (d.x < (containerWidth - 2*d.rt) && d.x > 2*d.rt) d.escaped &= ~2;

                    if((moreThan = d.y > (containerHeight - d.rt)) || d.y < d.rt) {
                        d.escaped |= 4;
                        if(~~d.v.y) d.sy = d.v.y * (moreThan && d.v.y > 0 || !moreThan && d.v.y < 0 ? -1 : 1);
                        else d.sy = (~~Math.abs(d.v.x) || Math.min(s0, 1)*2) * (moreThan ? -1 : 1);
                        v0 = d.v;
                        d.y = moreThan ? containerHeight - d.rt : d.rt;
                        d.v = v0;
                    }  else  if (d.y < (containerHeight - 2*d.rt) && d.y > 2*d.rt) d.escaped &= ~4;
                });
        }


        // regulate the speed of the circles
        data.forEach(function reg(d){
            if(!d.escaped) d.s =  (s0 - d.s * k) / (1 - k);
        });

        node.attr("transform", function position(d){return "translate(" + [d.x, d.y] + ")"});

        force.alpha(0.05);
    });

    // animate
    window.setInterval(function(){
        var tinfl = 3000, tdefl = 1000, inflate = "elastic", deflate = "cubic-out";

        for(var i = 0; i < data.length; i++) {
            if(Math.random()>0.8) data[i].r = random(rmin,rmax);
        }
        var changes = circles.filter(function(d){return d.r != d.rt});
        changes.filter(function(d){return d.r > d.rt})
            .transition("r").duration(tinfl).ease(inflate)
            .attr('r', function (d) {
                return d.r;
            });
        changes.filter(function(d){return d.r < d.rt})
            .transition("r").duration(tdefl).ease(deflate)
            .attr('r', function (d) {
                return d.r;
            });
        // this runs with an error of less than 1% of rmax
        changes = lines.filter(function(d){return d.r != d.rt});
        changes.filter(function(d){return d.r > d.rt})
            .transition("l").duration(tinfl).ease(inflate)
            .attr({
                x1: function lx1(d) {
                    return -d.r + rmax / 10;
                },
                x2: function lx2(d) {
                    return d.r - rmax / 10;
                }
            });
        changes.filter(function(d){return d.r < d.rt})
            .transition("l").duration(tdefl).ease(deflate)
        .attr({
            x1: function lx1(d) {
                return -d.r + rmax / 10;
            },
            x2: function lx2(d) {
                return d.r - rmax / 10;
            }
        });

    }, 2 * 500);
body {
    background: black;
    margin:0;
    padding:0;
}

.bubble-cloud {
    background: url("http://dummyimage.com/100x100/111/333?text=sample") 0 0;
    width: 600px;
    height: 190px;
    overflow: hidden;
    position: relative;
    margin:0 auto;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<script src="https://gitcdn.xyz/repo/cool-Blue/d3-lib/master/elapsedTime/elapsed-time-2.0.js"></script>
<script src="https://gitcdn.xyz/repo/cool-Blue/d3-lib/master/plot/plot-transform.js"></script>
<script src="https://gitcdn.xyz/repo/cool-Blue/d3-lib/master/plot/fps-histogram.js"></script>
<link rel="stylesheet" type="text/css" href="https://gitcdn.xyz/repo/cool-Blue/d3-lib/master/plot/fps-histogram.css">
<div class="bubble-cloud"></div>

I prefer using this formula for dynamic spacing...

l = (l - r) / l * (1+ alpha);

and then applying an alpha value around 0.05

No necessity for gravity or charge, simply adjusting friction to 1. This maintains velocity, but if users experience motion sickness, reduce it to 0.99.

UPDATE:

Altered to a slightly gentler and more accurate collision model
l = (l - r) / l * (1/2 + alpha); Also introduced some gravity for a "cloud-like" effect and adjusted friction as mentioned above.


CSS Transitions

Experimented with CSS transitions, but support seems inconsistent especially on SVG elements.

  • Transition works on circle radius but not on line in Chrome (45.0) and Opera
  • In IE 11 and FF (40.0.3) none of the CSS transitions work for me

    Feedback on browser compatibility would be appreciated since information online is scarce.

I explored velocity.js following this experiment, and personally prefer it for transitions.

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What is the best way to retrieve a previously used jQuery function?

The dynamic table I've created has the functionality to search, display search results in pagination, and auto-compute fields. This is how it works: Users search for their items in the search box If the search results are extensive, the system displ ...

Trouble with AJAX GET request functionality

I am new to AJAX and attempting to make my first AJAX call. Here is the code I have so far: $.get( "validate.php", { 'userinput':'x'}, function(response) { if( response.status ) alert( "Matches found" ); else alert( "No matches ...

How to position button components at the center in a NextJS application?

Incorporating a button within my nextjs page has been a challenge as I am striving to position it in the center of the page for optimal viewing on both PCs and mobile devices. Despite various attempts, the button remains fixed on the far left side of the p ...

What is the process for importing a React component that relies on a dependency?

This is my first experience using React (with babel) and jsx, along with Webpack to create my bundle.js I have developed two React components - Header1.jsx and Header2.jsx. The logic I want to implement is as follows: If the date is before July 4th, 2016 ...

Implementing conditional rendering using custom division return functions with onClick attribute on a submit button in React: A step-by-step guide

import React from "react"; class Input extends React.Component { constructor() { super(); this.state = { phone: "", weight: "", height: "", gender: "", smoke: "", lazy: "", bmi: "", pain: "", ...

Is there a way to inject C++ text into an input field on a webpage using QWebEngine?

I want to integrate a website with QWebEngine to manipulate input commands using Qt's event filters and more. The specific website I am working with requires a username/email and password, and I aim to manage the input of this text on my end. This inv ...

There is a method in TypeScript called "Mapping IDs to Object Properties"

I'm currently developing a React app that features several input fields, each with its own unique id: interface IFormInput { name: string; surname: string; address: string; born: Date; etc.. } const [input, setInput] = useState< ...

Having trouble identifying the issue with the dependent select drop down in my Active Admin setup (Rails 3.2, Active Admin 1.0)

I am currently working on developing a Ruby on Rails application that involves three models: Games that can be categorized into a Sector (referred to as GameSector) and a subsector (known as GameSubsector) A sector consists of multiple subsectors. A Subs ...

What is the best way to create a reset button for a timing device?

Is there a way to reset the timer when a button is clicked? Having a reset button would allow users to revisit the timer multiple times without it displaying the combined time from previous uses. Check out this code snippet for one of the timers along wit ...

Enhance Vaadin 14: Automatically adjust TextArea size when window is resized

Using Vaadin 14.1.19 in a project called "My Starter Project," I attempted to create a TextArea that supports multiple lines. Initially, everything seemed fine, but upon resizing the TextArea, it failed to adjust the number of visible lines. Here is the co ...

Information not displaying correctly on the screen

My latest project is a recipe app called Forkify where I am utilizing JavaScript, npm, Babel, Webpack, and a custom API for data retrieval. API URL Search Example Get Example The app displays recipes with their required ingredients on the screen. Addit ...

The system encountered an issue while trying to access the 'bannable' property, as it was undefined

message.mentions.members.forEach(men => { let member = message.mentions.members.get(men) if (!member.bannable) return message.reply(not_bannable) const reason = args.slice(1).join(" "); member.ban({reason: reason ...

Transforming a JSON object property value from an array into a string using JavaScript

I am facing an issue with an API call I am using, as it is sending objects with a single property that contains an array value (seen in the keys property in the response below). However, I need to work with nested arrays in order to utilize the outputted v ...

Adjusting ng-required in AngularJS for both empty values and -1

When an empty value or -1 is selected in my form, it should display an error message saying "This field is required". I have explored two possible solutions: 1. Using ng-required with regular expression. 2. Writing ctrl.$validators.required in the dir ...

Updating pages dynamically using AJAX

There's a glitch I can't seem to shake off. I've successfully implemented AJAX for page loading, but an issue persists. When I navigate to another page after the initial load, the new page contains duplicate tags like <head> and <f ...

What are the benefits of removing event listeners in Reactjs?

In my opinion, the event listeners need to be reliable and consistent. React.useEffect(() => { const height = window.addEventListener("resize", () => { setWindowSize(window.innerHeight); }); return () => window.remov ...

What is the best way to connect an event in Angular 2?

This is an input label. <input type="text" (blur) = "obj.action"/> The obj is an object from the corresponding component, obj.action = preCheck($event). A function in the same component, preCheck(input: any) { code ....}, is being used. Will it wor ...

When using React.js with Leaflet, ensure that the useEffect hook is only run on Mount when in the

I have encountered an issue where I need to ensure that the useEffect Hook in React runs only once. This is mainly because I am initializing a leaflet.js map that should not be initialized more than once. However, anytime I make changes to the component&a ...

The AngularJS promise is not resolving before the external Braintree.js script finishes loading after the HTML content has been fully

Using an external braintree.js script is necessary to generate a payment widget, and unfortunately I have no control over it. The code that needs to be included in my .html page is as follows: <div id="myClient" ng-show="false">{{myClientToken}}&l ...

What is causing express.js not to authenticate properly?

I'm currently in the process of developing a server application using node.js, which is up and running on localhost:8080. As I attempt to make a login request, I've encountered an issue where one method works while the other fails. My suspicion i ...