为 d3.js 强制布局实施通用更新模式

implement General Update Pattern for d3.js force layout

从我的特定示例的上下文中的 this example (possibly not the best choice), I set about trying to develop an application to suit my purposes, and learn d3.js in the process. After a lot of naive tinkering, I managed to get a toy force layout for my test data that satisfied me in its appearance and behavior. Now I've set about trying to understand MB's General Update Pattern 开始,以便用户可以交互式修改图形。我显然还没有掌握原理

从小处着手,我想创建一个函数,它只需在标记为 "Walteri" 和 "Roberti de Fonte" 的节点之间的图形中添加一个额外的 link(有一个按钮可以执行addedge(),或者您可以从 js 控制台执行它)。以一种破碎的方式,这得到了预期的结果;然而,现有图表仍然存在,同时生成了包含额外 link 的重复图表。很明显,关于通用更新模式,我仍然有一些不理解的地方。

如果有人看过并能提供任何见解,我将不胜感激。

管理更新
您遇到的主要问题是您在每次更新时都重新附加组元素。
你可以让 d3 像这样为你管理它...

//Nodes bag
//UPDATE
var circles = svg.selectAll(".circles")
            .data(["circles_g"]);
//ENTER
circles.enter()
    .append("svg:g")
    .attr("class", "circles");

你只需要组成一个单元阵列来驱动它。这样做的好处是它将被放置在 __data__ 成员上,该成员由 d3 添加到 g 元素,因此它也便于调试。

一般模式
一般来说,这是最防御的模式...

//UPDATE
var update = baseSelection.selectAll(elementSelector)
            .data(values, key),
    //ENTER
        enter = update.enter().append(appendElement)
            .call(initStuff),
    //enter() has side effect of adding enter nodes to the update selection
    //so anything you do to update now will include the enter nodes

    //UPDATE+ENTER
        updateEnter = update
            .call(stuffToDoEveryTimeTheDataChanges);
    //EXIT
    exit = update.exit().remove()

第一次通过 update 将是一个与数据具有相同结构的空值数组。
在这种情况下,.selectAll() return 的长度为零 selection 并且没有任何用处。

在后续更新中,.selectAll不会为空,将与values进行比较,使用keys,确定哪些节点是更新、进入和退出节点。这就是为什么在数据连接之前需要 select 的原因。

需要理解的重要一点是它必须是 .enter().append(...),因此您要在输入 selection 上追加元素。如果您将它们附加到更新 selection(由数据连接 return 编辑),那么您将重新输入相同的元素并看到与您所获得的类似的行为。

输入 selection 是 { __data__: data }
形式的简单对象数组 更新和退出 selections 是对 DOM 元素的引用数组的数组。

d3 中的数据方法在进入和退出 select 离子上保持闭包,这些离子由 .enter().exit() 方法在 update 上访问。 return 对象都是二维数组(d3 中的所有 selection 都是组数组,其中组是节点数组。)。 enter 成员也被赋予了对 update 的引用,以便它可以合并两者。这样做是因为在大多数情况下,对两个组执行相同的操作。

修改后的代码
有一个奇怪的错误,当添加一条不相关的边时,链接有时会消失,但这是由于 d.x 中的 NaN 和节点中的 d.y
如果您不在 showme 中每次都重建力布局,并且如果您这样做...

links.push({ "source": nodes[i], "target": nodes[j], "type": "is_a_tenant_of" });
force.start();
showme();

错误消失,一切正常。

This is because the internal state for the layout does not include the extra links, particularly the strengths and distances arrays. The internal force.tick() method uses these to calculate the new link lengths and if there are more links than members of these arrays, then they will return undefined and the link, length calculation will return NaN and this is then multiplied by the node x and y values to calculate the new d.x and d.y.
This is all recalculated in force.start()

此外,您可以将 force = d3.layout.force()....start(); 移动到单独的 function 中,并且只在开始时调用一次。

d3.json("force-directed-edges.json", function(error, data){
        if (error) return console.warn(error)
                nodes = data.nodes, 
                links = data.links,
                predicates = data.predicates,
                json = JSON.stringify(data, undefined, 2);

                for (n in nodes) { // don't want to require incoming data to have links array for each node
                        nodes[n].links = []
                }

                links.forEach(function(link, i) {
                        // kept the 'Or' check, in case we're building the nodes only from the links
                        link.source = nodes[link.source] || (nodes[link.source] = {name: link.source});
                        link.target = nodes[link.target] || (nodes[link.target] = { name: link.target });
                        // To do any dijkstra searching, we'll need adjacency lists: node.links. (easier than I thought)
                        link.source.links.push(link);
                        link.target.links.push(link);
                });

                nodes = d3.values(nodes);
                reStart()
                showme();
});

function randomNode(i) {
    var j;
    do {
        j = Math.round(Math.random() * (nodes.length - 1))
    } while (j === (i ? i : -1))
    return j
}
function addedge() {
    var i = randomNode(), j = randomNode(i);

    links.push({ "source": nodes[i], "target": nodes[j], "type": "is_a_tenant_of" });
    force.start();
    showme();
}

function reStart() {
    force = d3.layout.force()
        .nodes(nodes)
        .links(links)
        .size([w, h])
        .linkDistance(function (link) {
            var wt = link.target.weight;
            return wt > 2 ? wt * 10 : 60;
        })
        .charge(-600)
        .gravity(.01)
        .friction(.75)
        //.theta(0)
        .on("tick", tick)
        .start();
}
function showme() {
    //Marker Types  
    var defs = svg.selectAll("defs")
                .data(["defs"], function (d) { return d }).enter()
                .append("svg:defs")
                    .selectAll("marker")
                        .data(predicates)
                        .enter().append("svg:marker")
                            .attr("id", String)
                            .attr("viewBox", "0 -5 10 10")
                            .attr("refX", 30)
                            .attr("refY", 0)
                            .attr("markerWidth", 4)
                            .attr("markerHeight", 4)
                            .attr("orient", "auto")
                        .append("svg:path")
                            .attr("d", "M0,-5L10,0L0,5"),
    //Link bag
            //UPDATE
            paths = svg.selectAll(".paths")
                .data(["paths_g"]);
            //ENTER
    paths.enter()
        .append("svg:g")
        .attr("class", "paths");

    //Links
    //UPDATE
    path = paths.selectAll("path")
        .data(links);
    //ENTER
    path.enter()
        .append("svg:path");
    //UPDATE+ENTER
    path
        .attr("indx", function (d, i) { return i })
        .attr("id", function (d) { return d.source.index + "_" + d.target.index; })
        .attr("class", function (d) { return "link " + d.type; })
        .attr("marker-end", function (d) { return "url(#" + d.type + ")"; });
    //EXIT          
    path.exit().remove();

    //Link labels bag
    //UPDATE
    var path_labels = svg.selectAll(".labels")
            .data(["labels_g"]);
            //ENTER
    path_labels.enter()
        .append("svg:g")
        .attr("class", "labels");

    //Link labels
            //UPDATE
    var path_label = path_labels.selectAll(".path_label")
                .data(links);
            //ENTER
    path_label.enter()
        .append("svg:text")
            .append("svg:textPath")
                .attr("startOffset", "50%")
                .attr("text-anchor", "middle")
                .style("fill", "#000")
                .style("font-family", "Arial");
            //UPDATE+ENTER
    path_label
        .attr("class", function (d, i) { return "path_label " + i })
//EDIT*******************************************************************
      .selectAll('textPath')
//EDIT*******************************************************************
        .attr("xlink:href", function (d) { return "#" + d.source.index + "_" + d.target.index; })
        .text(function (d) { return d.type; }),
    //EXIT
    path_label.exit().remove();

    //Nodes bag
            //UPDATE
    var circles = svg.selectAll(".circles")
                .data(["circles_g"]);
            //ENTER
    circles.enter()
        .append("svg:g")
        .attr("class", "circles");

    //Nodes
    //UPDATE
    circle = circles.selectAll(".nodes")
                .data(nodes);
    //ENTER
    circle.enter().append("svg:circle")
                .attr("class", function (d) { return "nodes " + d.index })
                .attr("stroke", "#000");
    //UPDATE+ENTER
    circle
        .on("click", clicked)
        .on("dblclick", dblclick)
        .on("contextmenu", cmdclick)
        .attr("fill", function (d, i) {
            console.log(i + " " + d.types[0] + " " + node_colors[d.types[0]])
            return node_colors[d.types[0]];
        })
        .attr("r", function (d) { return d.types.indexOf("Document") == 0 ? 24 : 12; })
        .call(force.drag);
    //EXIT
    circle.exit().remove();

    //Anchors bag
    //UPDATE
    var textBag = svg.selectAll(".anchors")
                .data(["anchors_g"]);
            //ENTER
            textBag.enter()
                .append("svg:g")
                .attr("class", "anchors"),

    //Anchors
            //UPDATE
            textUpdate = textBag.selectAll("g")
                .data(nodes, function (d) { return d.name; }),
    //ENTER
    textEnter = textUpdate.enter()
        .append("svg:g")
        .attr("text-anchor", "middle")
        .attr("class", function (d) { return "anchors " + d.index });

    // A copy of the text with a thick white stroke for legibility.
    textEnter.append("svg:text")
                .attr("x", 8)
                .attr("y", ".31em")
                .attr("class", "shadow")
                .text(function (d) { return d.name; });

    textEnter.append("svg:text")
                .attr("x", 8)
                .attr("y", ".31em")
                .text(function (d) { return d.name; });
    textUpdate.exit().remove();
    text = textUpdate;

    // calling force.drag() here returns the drag _behavior_ on which to set a listener
    // node element event listeners
    force.drag().on("dragstart", function (d) {
        d3.selectAll(".dbox").style("z-index", 0);
        d3.select("#dbox" + d.index).style("z-index", 1);
    })
}

编辑
为了回应@jjon 的以下评论和我自己的启发,这里是对具有相同命名约定和差异注释的原始代码的最小更改。正确添加链接所需的模组未更改且未讨论...

function showme() {
    svg
    /////////////////////////////////////////////////////////////////////////////////////
    //Problem
    //  another defs element is added to the document every update
    //Solution:
    //  create a data join on defs 
    //  append the marker definitions on the resulting enter selection
    //  this will only be appended once
    /////////////////////////////////////////////////////////////////////////////////////

//ADD//////////////////////////////////////////////////////////////////////////////////
    .selectAll("defs")
    .data(["defs"], function (d) { return d }).enter()
///////////////////////////////////////////////////////////////////////////////////////

    .append("svg:defs")
        .selectAll("marker")
        .data(predicates)
        .enter().append("svg:marker")
            .attr("id", String)
            .attr("viewBox", "0 -5 10 10")
            .attr("refX", 30)
            .attr("refY", 0)
            .attr("markerWidth", 4)
            .attr("markerHeight", 4)
            .attr("orient", "auto")
        .append("svg:path")
            .attr("d", "M0,-5L10,0L0,5");

    /////////////////////////////////////////////////////////////////////////////////////
    //Problem
    //  another g element is added to the document every update
    //Solution:
    //  create a data join on the g and class it .paths 
    //  append the path g on the resulting enter selection
    //  this will only be appeneded once
    /////////////////////////////////////////////////////////////////////////////////////

//ADD//////////////////////////////////////////////////////////////////////////////////
    //Link bag
    //UPDATE
    paths = svg
        .selectAll(".paths")
        .data(["paths_g"]);
    //ENTER
    paths.enter()
///////////////////////////////////////////////////////////////////////////////////////

        .append("svg:g")

//ADD//////////////////////////////////////////////////////////////////////////////////
        .attr("class", "paths");
///////////////////////////////////////////////////////////////////////////////////////

    //Links
    //UPDATE
    path = paths    //Replace svg with paths///////////////////////////////////////////////
        .selectAll("path")
        .data(links);

    path.enter().append("svg:path")
            .attr("id", function (d) { return d.source.index + "_" + d.target.index; })
            .attr("class", function (d) { return "link " + d.type; })
            .attr("marker-end", function (d) { return "url(#" + d.type + ")"; });

    path.exit().remove();

    /////////////////////////////////////////////////////////////////////////////////////
    //Problem
    //  another g structure is added every update
    //Solution:
    //  create a data join on the g and class it .labels 
    //  append the labels g on the resulting enter selection
    //  this will only be appeneded once
    //  include .exit().remove() to be defensive
    //Note:
    //  don't chain .enter() on the object assigned to path_label
    //  .data(...) returns an update selection which includes enter() and exit() methods
    //  .enter() returns a standard selection which doesn't have a .exit() member
    //  this will be needed if links are removed or even if the node indexing changes
    /////////////////////////////////////////////////////////////////////////////////////

//ADD//////////////////////////////////////////////////////////////////////////////////
    //Link labels bag
    //UPDATE
    var path_labels = svg.selectAll(".labels")
            .data(["labels_g"]);
    //ENTER
    path_labels.enter()
///////////////////////////////////////////////////////////////////////////////////////

        .append("svg:g")

//ADD//////////////////////////////////////////////////////////////////////////////////
        .attr("class", "labels");
///////////////////////////////////////////////////////////////////////////////////////

    //Link labels
    //UPDATE
    var path_label = path_labels
        .selectAll(".path_label")
        .data(links);
    //ENTER
    path_label
        .enter().append("svg:text")
            .attr("class", "path_label")
            .append("svg:textPath")
                .attr("startOffset", "50%")
                .attr("text-anchor", "middle")
                .attr("xlink:href", function (d) { return "#" + d.source.index + "_" + d.target.index; })
                .style("fill", "#000")
                .style("font-family", "Arial")
                .text(function (d) { return d.type; });

//ADD//////////////////////////////////////////////////////////////////////////////////
    path_label.exit().remove();
///////////////////////////////////////////////////////////////////////////////////////

    /////////////////////////////////////////////////////////////////////////////////////
    //Problem
    //  another g structure is added every update
    //Solution:
    //  create a data join on the g and class it .circles 
    //  append the labels g on the resulting enter selection
    //  this will only be appeneded once
    //  include .exit().remove() to be defensive
    /////////////////////////////////////////////////////////////////////////////////////

//ADD//////////////////////////////////////////////////////////////////////////////////
    //Nodes bag
    //UPDATE
    var circles = svg.selectAll(".circles")
                .data(["circles_g"]);
    //ENTER
    circles.enter()
///////////////////////////////////////////////////////////////////////////////////////

        .append("svg:g")

//ADD//////////////////////////////////////////////////////////////////////////////////
        .attr("class", "circles");
///////////////////////////////////////////////////////////////////////////////////////

    //Nodes
    //UPDATE
    circle = circles
        .selectAll(".node") //select on class instead of tag name//////////////////////////
        .data(nodes);
    circle                              //don't chain in order to keep the update selection////////////
        .enter().append("svg:circle")
            .attr("class", "node")
            .attr("fill", function (d, i) {
                return node_colors[d.types[0]];
            })
            .attr("r", function (d) { return d.types.indexOf("Document") == 0 ? 24 : 12; })
            .attr("stroke", "#000")
            .on("click", clicked)
            .on("dblclick", dblclick)
            .on("contextmenu", cmdclick)
            .call(force.drag);

//ADD//////////////////////////////////////////////////////////////////////////////////
    circle.exit().remove();
///////////////////////////////////////////////////////////////////////////////////////

//ADD//////////////////////////////////////////////////////////////////////////////////
    //Anchors bag
    //UPDATE
    var textBag = svg.selectAll(".anchors")
                .data(["anchors_g"]);
    //ENTER
    textBag.enter()
///////////////////////////////////////////////////////////////////////////////////////

        .append("svg:g")

//ADD//////////////////////////////////////////////////////////////////////////////////
        .attr("class", "anchors");

    //Anchors
    //UPDATE
    text = textBag
///////////////////////////////////////////////////////////////////////////////////////

        .selectAll(".anchor")
            .data(nodes, function (d) { return d.name});
var textEnter = text            //don't chain in order to keep the update selection//////////
        .enter()
        .append("svg:g")
            .attr("class", "anchor")
            .attr("text-anchor", "middle");

//ADD//////////////////////////////////////////////////////////////////////////////////
    text.exit().remove;
///////////////////////////////////////////////////////////////////////////////////////

    // A copy of the text with a thick white stroke for legibility.
    textEnter.append("svg:text")
            .attr("x", 8)
            .attr("y", ".31em")
            .attr("class", "shadow")
            .text(function (d) { return d.name; });

    textEnter.append("svg:text")
            .attr("x", 8)
            .attr("y", ".31em")
            .text(function (d) { return d.name; });

    // calling force.drag() here returns the drag _behavior_ on which to set a listener
    // node element event listeners
    force.drag().on("dragstart", function (d) {
        d3.selectAll(".dbox").style("z-index", 0);
        d3.select("#dbox" + d.index).style("z-index", 1);
    })
}