D3 - 如何在数据值变化时更新力模拟

D3 - How to update force simulation when data values change

我在 D3 中遇到了一个关于力模拟的小问题。

我有代表每个国家/地区从 1998 年到 2008 年的贫困率的数据。它是一个气泡图,分为三组,代表贫穷国家、不贫穷国家和没有信息的国家。

最初加载应用程序时,它加载了 1998 年的数据。但是,我在顶部有一些按钮,单击它们会更改年份,随后气泡会自行重新排列。我所能做的就是在单击按钮时更改变量 year。但是,在整个代码中有使用 year 的函数和变量。当 year 发生变化时,我想重新计算所有依赖于 year

的节点属性和强制参数

这是我的代码。如果您想尝试一下,我已经包含了所有内容。数据文件在这个post.

的最后
 async function init() {
    // Set up the canvas
    var height = 1000,  width = 2000;
    var svg = d3.select("#panel1").append("svg")
        .attr("height", height)
        .attr("width", width)
        .attr("class", "bubblePanel");

    var canvas = svg.append("g")
        .attr("transform", "translate(0,0)");

    // Choose what year to look at, based on button clicks.
    var year = "X1998"
    d3.select("#b1998").on("click", function() { 
        year = "X1998"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })
    d3.select("#b1999").on("click", function() { 
        year = "X1999"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })
    d3.select("#b2000").on("click", function() { 
        year = "X2000"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })

    // Implement the physics of the elements. Three forces act according to the poverty level (poor, not poor, and no info)
    var simulation = d3.forceSimulation()
        .force("x", d3.forceX(function(d) {
            if (parseFloat(d[year]) >= 10) {
                return 1700
            } else if (parseFloat(d[year]) === 0) {
                return 1000
            } else {
                return 300
            }
            }).strength(0.05))
        .force("y", d3.forceY(300).strength(0.05))
        .force("collide", d3.forceCollide(function(d) {
            return radiusScale(d[year])
        }));

    // Function to pick colour of circles according to region
    function pickColor(d) {
        if (d === "East Asia & Pacific") { 
            return "red" 
        } else if (d === "Europe & Central Asia") {
            return "orange"
        } else if (d === "Latin America & Caribbean") {
            return "yellow"
        } else if (d === "Middle East & North Africa") {
            return "green"
        } else if (d === "North America") {
            return "blue"
        } else if (d === "South Asia") {
            return "indigo"
        } else {
            return "violet"
        }
    }

    // Set the scales for bubble radius, and text size.
    var radiusScale = d3.scaleSqrt().domain([0, 50]).range([20,80]);
    var labelScale = d3.scaleSqrt().domain([0,50]).range([10,40]);

    // Read the data
    await d3.csv("wd3.csv").then(function(data) {
        // Assign each data point to a circle that is colored according to region and has radius according to its poverty level
        var bubbles = svg.selectAll("circle")
            .data(data)
            .enter().append("circle")
            .attr("cx", 100)
            .attr("cy", 100)
            .attr("fill", function(d) {
                return pickColor(d.Region)
            })
            .attr("r", function(d) {
                return radiusScale(d[year])
            });
            // Assign each ddata point to a text element that shows the counry code of the data point. The text is scaled according to the poverty level
        var labels = svg.selectAll("text")
            .data(data)
            .enter().append("text")
            .attr("x", 100)
            .attr("y", 100)
            .attr("dominant-baseline", "central")
            .text(function(d) { return d.XCountryCode })
            .style("stroke", "black")
            .style("text-anchor", "middle")
            .style("font-size", function(d) { return labelScale(d[year]); });


        // Code to handle the physics of the bubble and the text
        simulation.nodes(data)
                .on("tick", ticked)
        function ticked() {
            bubbles.attr("transform", function(d) {
                var k = "translate(" + d.x + "," + d.y + ")";
                    return k;
            })
            labels.attr("transform", function(d) {
                var k = "translate(" + d.x + "," + d.y + ")";
                    return k;
            })

        }
    });
}

year 发生变化时,每个国家/地区的数据值都会发生变化。我希望我的代码的以下部分得到更新。

节点上的x力:国家可以在一年内从贫困变为另一年不贫困,因此他们的集群将发生变化

圆圈的半径: 半径代表贫困程度。这些每年都会发生变化,因此单击按钮时圆圈的大小会发生变化

国家标签的坐标:这些标签也附加到数据中。因此,当圆圈上的 x 力导致圆圈移动时,标签也应该移动。

非常感谢您的帮助。

可以找到数据文件here。我不小心将它命名为 povertyCSV,但在代码中,它被引用为 "wd3.csv"

如果我理解正确:

重新初始化部队

用于设置 d3 力(例如 forceX 或 forceCollision)参数的函数在模拟初始化时(当节点最初分配给布局时)每个节点执行一次。一旦模拟开始,这会节省很多时间:我们不会在每个刻度都重新计算力参数。

但是,如果您有一个现有的力布局,并且想用新的 x 值或新的强度修改 forceX,或者用新的半径修改 forceCollision,例如,我们可以重新初始化强制执行重新计算:

 // assign a force to the force diagram:
 simulation.force("someForce", d3.forceSomeForce().someProperty(function(d) { ... }) )

// re-initialize the force
simulation.force("someForce").initialize(nodes);

这意味着如果我们有这样的力:

simulation.force("x",d3.forceX().x(function(d) { return fn(d["year"]); }))

然后我们更新变量year,我们需要做的就是:

year = "newValue";

simulation.force("x").initialize(nodes);

定位

如果力量被重新初始化(或重新分配),则无需触摸刻度功能:它会根据需要更新节点。标签和圈子将继续正确更新。

此外,还需要在事件处理程序中更新颜色等非位置性内容,该事件处理程序也会重新初始化力。除了半径之外,大多数东西应该通过强制或直接修改元素来更新,而不是两者都更新。

半径是一个特例:

  • 使用d3.forceCollide,半径影响定位
  • 然而,半径不需要每次更新都更新。

因此,在更新半径的时候,我们需要更新碰撞力,修改每个圆的r属性。

如果寻找以图形和碰撞力反映的半径的平滑过渡,这应该是一个单独的问题。

实施

我借鉴了您的代码来制作一个相当通用的示例。下面的代码包含一些按钮的以下事件侦听器,其中每个按钮的数据是一年:

buttons.on("click", function(d) {
  // d is the year:
  year = d;

  // reheat the simulation:
  simulation
    .alpha(0.5)
    .alphaTarget(0.3)
    .restart();

  // (re)initialize the forces
  simulation.force("x").initialize(data);
  simulation.force("collide").initialize(data);

  // update altered visual properties:
  bubbles.attr("r", function(d) { 
      return radiusScale(d[year]);
    }).attr("fill", function(d) {
      return colorScale(d[year]);
    })
})

以下代码段使用任意数据,由于其大小,可能不允许节点每次都完美地重新组织。为简单起见,位置、颜色和半径都基于同一个变量。最终,它应该解决问题的关键部分:当 year 更改时,我想更新使用 year 设置节点和强制属性的所有内容。

var data = [
  {year1:2,year2:1,year3:3,label:"a"},
  {year1:3,year2:4,year3:5,label:"b"},
  {year1:5,year2:9,year3:7,label:"c"},
  {year1:8,year2:16,year3:11,label:"d"},
  {year1:13,year2:25,year3:13,label:"e"},
  {year1:21,year2:36,year3:17,label:"f"},
  {year1:34,year2:1,year3:19,label:"g"},
  {year1:2,year2:4,year3:23,label:"h"},
  {year1:3,year2:9,year3:29,label:"i"},
  {year1:5,year2:16,year3:31,label:"j"},
  {year1:8,year2:25,year3:37,label:"k"},
  {year1:13,year2:36,year3:3,label:"l"},
  {year1:21,year2:1,year3:5,label:"m"}
];

// Create some buttons:
var buttons = d3.select("body").selectAll("button")
  .data(["year1","year2","year3"])
  .enter()
  .append("button")
  .text(function(d) { return d; })
  
  
// Go about setting the force layout:
var svg = d3.select("body")
  .append("svg")
  .attr("width", 500)
  .attr("height", 300);

var radiusScale = d3.scaleSqrt()
   .domain([0, 40])
   .range([5,30]);

var colorScale = d3.scaleLinear()
   .domain([0,10,37])
   .range(["#c7e9b4","#41b6c4","#253494"]);  

var year = "year1";
  
var simulation = d3.forceSimulation()
   .force("x", d3.forceX(function(d) {
         if (parseFloat(d[year]) >= 15) {
            return 100
         } else if (parseFloat(d[year]) > 5) {
            return 250
         } else {
            return 400
        }
   }).strength(0.05))
   .force("y", d3.forceY(150).strength(0.05))
   .force("collide", d3.forceCollide()
      .radius(function(d) {
        return radiusScale(d[year])
   }));
   
var bubbles = svg.selectAll("circle")
  .data(data)
  .enter().append("circle")
  .attr("r", function(d) {
     return radiusScale(d[year])
  })
  .attr("fill", function(d) {
    return colorScale(d[year]);
  });
  
var labels = svg.selectAll("text")
  .data(data)
  .enter()
  .append("text")
  .text(function(d) {
    return d.label;
  })
  .style("text-anchor","middle");
 
simulation.nodes(data)
  .on("tick", ticked) 
 
 
function ticked() {

  bubbles.attr("cx", function(d) {
    return d.x;
  }).attr("cy", function(d) {
    return d.y;
  })
  
  labels.attr("x", function(d) {
    return d.x;
  })
  .attr("y", function(d) {
    return d.y +5;
  })
  
}

buttons.on("click", function(d) {
  // d is the year:
  year = d;
  
  simulation
    .alpha(0.5)
    .alphaTarget(0.3)
    .restart();
    
  simulation.force("x").initialize(data);
  simulation.force("collide").initialize(data);

  bubbles.attr("r", function(d) { 
      return radiusScale(d[year]);
    }).attr("fill", function(d) {
      return colorScale(d[year]);
    })
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>