在D3.js中强制布局中的对象动画

时间:2015-09-11 11:02:14

标签: javascript animation d3.js

我需要创建一个数据可视化,它看起来像一堆浮动气泡,文本在气泡内。

我有一个部分工作的例子,它使用这里编写的模拟数据: JSfiddle

// helpers
var random = function(min, max) {
    if (max == null) {
        max = min;
        min = 0;
    }
    return min + Math.floor(Math.random() * (max - min + 1));
};

// mock data
var 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)'
    }
];
var data = [];
for(var j = 0; j <= 2; j++) {
    for(var i = 0; i <= 4; i++) {
        var text = 'text' + i;
        var category = 'category' + j;
        var r = random(50, 100);
        data.push({
            text: text,
            category: category,
            r: r,
            r_change_1: r + random(-20, 20),
            r_change_2:  r + random(-20, 20),
            fill: colors[j].fill,
            stroke: colors[j].stroke
        });
    }
}
// mock debug
//console.table(data);

// collision detection
// derived from http://bl.ocks.org/mbostock/1748247
function collide(alpha) {
    var quadtree = d3.geom.quadtree(data);
    return function(d) {
        var r = d.r + 10,
            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.r * 2;
                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;
        });
    };
}

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

// prepare layout
var force = d3.layout
    .force()
    .size([containerWidth, containerHeight])
    .gravity(0)
    .charge(0)
;

// 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 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;
    });

// create labels
node.append('text')
    .text(function(d) {
        return d.text
    })
    .classed('text', true)
    .style({
        'fill': '#ffffff',
        'text-anchor': 'middle',
        'font-size': '12px',
        'font-weight': 'bold',
        'font-family': 'Tahoma, Arial, sans-serif'
    })
;

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': '9px'
    })
;

node.append('line')
    .classed('line', true)
    .attr('x1', 0)
    .attr('y1', 0)
    .attr('x2', 50)
    .attr('y2', 0)
    .attr('stroke-width', 1)
    .attr('stroke',  function (d) {
        return d.stroke;
    })
;

// put circle into movement
force.on('tick', function(){

    d3.selectAll('circle')
        .each(collide(.5))
        .attr('cx', function (d) {

            // boundaries
            if(d.x <= d.r) {
                d.x = d.r + 1;
            }
            if(d.x >= containerWidth - d.r) {
                d.x = containerWidth - d.r - 1;
            }
            return d.x;
        })
        .attr('cy', function (d) {

            // boundaries
            if(d.y <= d.r) {
                d.y = d.r + 1;
            }
            if(d.y >= containerHeight - d.r) {
                d.y = containerHeight - d.r - 1;
            }
            return d.y;
        });

    d3.selectAll('line')
        .attr('x1', function (d) {
            return d.x - d.r + 10;
        })
        .attr('y1', function (d) {
            return d.y;
        })
        .attr('x2', function (d) {
            return d.x + d.r - 10;
        })
        .attr('y2', function (d) {
            return d.y;
        });

    d3.selectAll('.text')
        .attr('x', function (d) {
            return d.x;
        })
        .attr('y', function (d) {
            return d.y - 10;
        });

    d3.selectAll('.category')
        .attr('x', function (d) {
            return d.x;
        })
        .attr('y', function (d) {
            return d.y + 20;
        });
});

// animate
var interval = setInterval(function(){

    // moving of the circles
    // ...

}, 5 * 1000);

但是我现在面临动画问题。我无法弄清楚如何在力图中为节点设置动画。我试图调整数据对象的值,然后在setInterval方法中调用.tick()方法,但它没有帮助。 我正在使用D3力布局。

我的问题是:

  • 如何使气泡在屏幕周围“浮动”,即如何 动画他们?

  • 如何设置圆半径的变化动画?

感谢您的想法。

1 个答案:

答案 0 :(得分:2)

实际上,我认为这个感觉更好......

要点

  1. 充电设置为0,摩擦设置为0.9
  2. 在计时器回调中的半径和直线上安排平行过渡
  3. 使用动态半径计算碰撞
  4. 使用节点上的变换(g元素)将文本和线条定位与节点位置分离,仅在tick回调中调整变换x和y
  5. 删除CSS转换并添加d3转换,以便您可以同步所有内容
  6. 在碰撞功能中将此r = d.rt + 10更改为此r = d.rt + rmax以加强对重叠的控制
  7. 闭环速度调节器。即使摩擦设置为0.9以抑制运动,速度调节器也会使它们保持运动
  8. 使用平行过渡来协调几何变化
  9. 添加少量重力
  10. 工作示例

    // helpers
        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
        // derived 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 * (1 + 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;
                });
            };
        }
    
        // prepare layout
        var force = d3.layout
                .force()
                .size([containerWidth, containerHeight])
            .gravity(0.001)
                .charge(0)
            .friction(.8)
            .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 circles
        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")}
                }})
            });
    
        // put circle into 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>

    我喜欢将此公式用于间距动态...

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

    然后使用约0.05的alpha

    在我看来,不需要重力或充电,我唯一要改变的是将摩擦力设置为1.这意味着保持速度,但如果你的客户生病,那么将其击回0.99。

      

    编辑:

         

    改为稍微柔和且更正确的碰撞模型
          l = (l - r) / l * (1/2 + alpha);   还添加了一点重力,使其“像云一样”和摩擦(见上文)

    CSS转换

    我也试过使用CSS过渡,但支持似乎不完整,至少对SVG元素说。

    • 过渡适用于circle半径,但不适用于{45}中的line和Opera
    • 在IE 11和FF(40.0.3)中,没有任何CSS过渡对我有用

      我会对浏览器兼容性的反馈感兴趣,因为我在互联网上找不到这方面的内容。

    experimented with velocity.js就此而言,我认为我更倾向于过渡。